Compare commits

...
Author SHA1 Message Date
linmianxunandCursor e4743541f7 完善报价单填表与币种回填,并补齐账号末次登录、角色按钮权限与陆运全线匹配。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 22:03:45 +08:00
jillion886andCursor 1a2e1d6cb2 补齐成交跟进:按线路推成交通知、收下未成交原因、协商中按工作日再问。
成交走询价消息服务;陆运/海运私聊下一句能记下原因;协商中回执写明顺延三个工作日和下次 9:00。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 16:08:48 +08:00
56 changed files with 3900 additions and 193 deletions
+3
View File
@@ -22,6 +22,9 @@ WECOM_CALLBACK_TOKEN=
WECOM_ENCODING_AES_KEY=
WECOM_CALLBACK_REPLY_TEXT=询价智能体接通成功。已收到:{content}
WECOM_OPS_AGENT_ID=1000014
# 现网「询价消息服务」= 运维应用 1000014(与 WECOM_OPERATIONS_* 同一套)。空则只成交不推。
WECOM_NOTIFY_AGENT_ID=1000014
WECOM_NOTIFY_SECRET=
# 会话存档席:后台员工管理里「询价机器人」的企微 userid,每个协同群必拉。可逗号分隔多个。
WECOM_ARCHIVE_SEAT_USER_IDS=
# 微盛会话存档(Worker 按存档席拉群消息)。游标只写 REDIS_KEY_PREFIX,禁止推现网官方 seq。
@@ -37,7 +37,7 @@ class QuoteTemplateParseBody(BaseModel):
yellow_count: int = Field(default=0, alias="yellowCount")
truncated: bool = False
request_id: str = Field(default="", alias="requestId")
# 未换文件重新解析:true + existingMapping,回写时旧字段优先补缺
# 未换文件重新解析:true + existingMapping,回写时智能合并(对齐保留、对不上改坐标)
merge_existing: bool = Field(default=False, alias="mergeExisting")
existing_mapping: Optional[dict[str, Any]] = Field(default=None, alias="existingMapping")
+21 -2
View File
@@ -43,6 +43,15 @@ class ChannelRuntime:
secret=settings.wecom_secret,
agent_id=agent_id,
)
notify_id = int(getattr(settings, "wecom_notify_agent_id", "") or "0") or 0
notify_secret = str(getattr(settings, "wecom_notify_secret", "") or "").strip()
self._notify_client = None
if notify_id and notify_secret:
self._notify_client = WeComAppClient(
corp_id=settings.wecom_corp_id,
secret=notify_secret,
agent_id=notify_id,
)
@property
def store(self):
@@ -105,10 +114,20 @@ class ChannelRuntime:
logger.warning("outbox 过期丢弃 id=%s", item.id)
self._store.complete_outbound(item.id)
continue
result = self._client.send_outbound(
payload = getattr(item, "payload", None) or {}
client = self._client
if payload.get("wecom_app") == "notify":
if self._notify_client is None:
logger.error("询价消息服务未配置,成交通知丢弃 id=%s", item.id)
self._store.complete_outbound(
item.id, error="notify_app_missing", permanent=True
)
continue
client = self._notify_client
result = client.send_outbound(
touser=item.touser,
content=item.content,
payload=getattr(item, "payload", None) or {},
payload=payload,
)
if result.ok:
self._store.complete_outbound(item.id)
+3
View File
@@ -60,6 +60,9 @@ class Settings(BaseSettings):
wecom_corp_id: str = Field(default="", alias="WECOM_CORP_ID")
wecom_agent_id: str = Field(default="1000010", alias="WECOM_AGENT_ID")
wecom_secret: str = Field(default="", alias="WECOM_SECRET")
# 现网「询价消息服务」:只推成交通知,不走询价小助手 1000010。
wecom_notify_agent_id: str = Field(default="", alias="WECOM_NOTIFY_AGENT_ID")
wecom_notify_secret: str = Field(default="", alias="WECOM_NOTIFY_SECRET")
wecom_archive_seat_user_ids: str = Field(
default="",
alias="WECOM_ARCHIVE_SEAT_USER_IDS",
@@ -27,7 +27,12 @@ from agent.policy.air_group_ops import (
resolve_cabin_ticket,
)
from agent.policy.air_text_flow import get_air_text_flow
from agent.policy.sea_group_ops import group_client_of, send_group_text, ticket_view
from agent.policy.sea_group_ops import (
capture_group_lost_reason,
group_client_of,
send_group_text,
ticket_view,
)
from agent.routing.air_group_intent import (
INTENT_ACTIVATE,
INTENT_CONFIRM_TMS,
@@ -233,6 +238,14 @@ def _handle_bound(
injected_quote: Optional[dict[str, Any]],
) -> str:
chat_id = message.chat_id
lost = capture_group_lost_reason(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
text=message.content or "",
)
if lost:
return lost
facts = dict(ticket_view(ticket).get("collab_facts") or {})
if intent == INTENT_PICK_OPTION or (
intent == INTENT_OTHER and str(facts.get("__pending_air_lock") or "") == "1"
@@ -17,6 +17,7 @@ from agent.policy.sea_group_ops import (
apply_group_deal,
apply_product_quote,
apply_tms_quote_choice,
capture_group_lost_reason,
dissolve_group,
group_client_of,
handle_group_file,
@@ -138,6 +139,16 @@ def handle_group_inbound(
classify_group_text,
)
lost = capture_group_lost_reason(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
text=message.content or "",
sess=sess,
)
if lost:
return lost
intent = classify_group_text(message.content or "", injected=injected_intent)
logger.info(
"group_inbound 文字 chat=%s sender=%s intent=%s chars=%s",
@@ -198,6 +198,8 @@ def handle_text_other(message: InboundMessage, *, store=None) -> str:
sess = engine.session_for_action(
message.sender_id, message.raw, text=message.content or ""
)
if sess and sess.phase == "wait_lost_reason":
return handle_text_inquiry(message, store=msg_store)
btn = engine.match_button(message.content or "", sess)
if btn:
return handle_text_inquiry(message, store=msg_store)
+17
View File
@@ -111,6 +111,7 @@ class WorkerRuntime:
threading.Thread(target=self._recognition_loop, name="recog-loop", daemon=True),
threading.Thread(target=self._archive_loop, name="archive-loop", daemon=True),
threading.Thread(target=self._wshoto_poll_loop, name="wshoto-poll", daemon=True),
threading.Thread(target=self._deal_followup_loop, name="deal-followup", daemon=True),
threading.Thread(target=self._pdf_loop, name="pdf-loop", daemon=True),
]
for t in self._threads:
@@ -273,6 +274,22 @@ class WorkerRuntime:
wait = 1.0 if has_hot_room() else interval
wait_wshoto_poll(wait)
def _deal_followup_loop(self) -> None:
"""
HTTP 槽轮询协商再问。独立线程,不占回调、不与 soffice 串队。
"""
from agent.jobs.deal_followup_poll import poll_due_negotiate
while not self._stop.is_set():
try:
self.http_jobs.run(
lambda: poll_due_negotiate(settings=self.settings),
label="deal-followup",
)
except Exception:
logger.exception("协商再问轮询失败")
self._stop.wait(60.0)
def _pdf_loop(self) -> None:
"""仅 LibreOffice 单槽消费 PDF Stream。"""
if not self.redis:
@@ -0,0 +1,120 @@
"""
协商中到期再问:Worker HTTP 槽轮询,回到当初那个会话。
本文件职责:认领主账到期单、发成交跟进。不在回调线程跑。
禁止:群解散后改发私聊;占 LibreOffice 槽;把六态写进 Redis。
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from agent.config import Settings, get_settings
from agent.policy import inquiry_copy as copy
logger = logging.getLogger(__name__)
def poll_due_negotiate(*, ledger: Any = None, settings: Optional[Settings] = None) -> int:
"""
认领到期协商中工单并再问一次。
返回发出条数。主账已清空 next_followup_at,本轮失败也不再自动补发。
"""
cfg = settings or get_settings()
book = ledger
if book is None:
if str(getattr(cfg, "ledger_backend", "http") or "http").lower() == "memory":
return 0
from agent.ledger.http_ledger import HttpLedger
book = HttpLedger()
claimer = getattr(book, "claim_due_negotiate", None)
if not callable(claimer):
return 0
rows = claimer() or []
sent = 0
for row in rows:
if not isinstance(row, dict):
continue
try:
if _reask_one(row, settings=cfg):
sent += 1
except Exception:
logger.exception("协商再问失败 wo=%s", row.get("work_order_no") or row.get("workOrderNo"))
return sent
def _reask_one(row: dict[str, Any], *, settings: Settings) -> bool:
no = str(row.get("work_order_no") or row.get("workOrderNo") or "").strip()
if not no:
return False
chat_id = str(row.get("collab_chat_id") or row.get("collabChatId") or "").strip()
sales = str(row.get("sales_wecom_id") or row.get("salesWecomId") or "").strip()
name = str(row.get("sales_name") or row.get("salesName") or "").strip() or "销售"
facts = dict(row.get("facts") or {})
quote = dict(row.get("quote") or {})
line = str(row.get("business_line") or row.get("businessLine") or "").strip().upper()
if chat_id:
ok = _send_group_reask(
chat_id=chat_id,
work_order_no=no,
sales_name=name,
facts=facts,
quote=quote,
business_line=line,
settings=settings,
)
if not ok:
logger.warning("协商再问原群失败且不补发私聊 wo=%s chat=%s", no, chat_id)
return ok
if not sales:
logger.warning("协商再问无销售 userid wo=%s", no)
return False
from agent.channel.queue import get_message_store
text = copy.deal_card(work_order_no=no, quote=quote, facts=facts)
payload = copy.deal_wecom_payload(work_order_no=no, quote=quote, facts=facts)
ok, info = get_message_store().enqueue_outbound(
touser=sales,
content=text,
dedupe_key=f"deal-reask:{no}",
payload=payload,
)
if not ok:
logger.warning("协商再问私聊入队失败 wo=%s info=%s", no, info)
return bool(ok)
def _send_group_reask(
*,
chat_id: str,
work_order_no: str,
sales_name: str,
facts: dict[str, str],
quote: dict[str, Any],
business_line: str,
settings: Settings,
) -> bool:
text = copy.group_deal_followup(sales_name=sales_name, work_order_no=work_order_no)
if business_line == "AIR":
from agent.channel.aibot.reply import AibotReplyClient
out = AibotReplyClient().send_group(chat_id=chat_id, content=text)
return bool(out.get("ok"))
from agent.channel.outbox.sender import WeComAppClient
client = WeComAppClient(
corp_id=settings.wecom_corp_id,
secret=settings.wecom_secret,
agent_id=int(settings.wecom_agent_id or "0") or 0,
)
out = client.send_group(chat_id=chat_id, content=text)
if not out.get("ok"):
return False
carder = getattr(client, "send_group_card", None)
if callable(carder):
payload = copy.deal_wecom_payload(work_order_no=work_order_no, quote=quote, facts=facts)
carder(chat_id=chat_id, template_card=payload.get("template_card") or {})
return True
+14 -4
View File
@@ -114,8 +114,8 @@ def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: boo
"""
跑 sheet_map 并回写主账;失败也必须回写 failed+原因。
mergeExisting=true(未换文件重新解析)时:与 existingMapping 补缺合并后再回写,
旧字段优先保留;换文件/首传不传该标记,整份覆盖。
mergeExisting=true(未换文件重新解析)时:与 existingMapping 智能合并后再回写,
仍对齐标签的旧坐标保留,对不上的改用新坐标;换文件/首传不传该标记,整份覆盖。
"""
from agent.config import get_settings
from agent.llm.mode_sheet_map import (
@@ -130,11 +130,14 @@ def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: boo
result = invoke_sheet_map(payload, allow_network=net, timeout_seconds=240.0, max_rounds=3)
merge_existing = bool(payload.get("mergeExisting") or payload.get("merge_existing"))
existing = payload.get("existingMapping") or payload.get("existing_mapping")
inventory = _inventory_lines_from_payload(payload)
body: dict[str, Any]
if result.get("ok") and isinstance(result.get("mapping"), dict):
mapping = dict(result["mapping"])
if merge_existing and isinstance(existing, dict):
mapping = merge_mapping_prefer_existing(existing, mapping)
mapping = merge_mapping_prefer_existing(
existing, mapping, cell_inventory=inventory
)
# 合并后再对照清单复评缺口,避免角标沿用合并前数字
remaining = assess_mapping_gaps(payload, mapping)
mapping["mappingGaps"] = [str(g) for g in remaining if g]
@@ -153,7 +156,7 @@ def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: boo
points = n_fields + n_fee + n_route
parse_message = f"已解析 {points} 个映射点"
if merge_existing:
parse_message += "(保留原映射并补缺)"
parse_message += "(对齐的保留,对不上的改用新坐标)"
if mapping.get("mappingGaps"):
preview = ";".join(mapping["mappingGaps"][:8])
more = len(mapping["mappingGaps"]) - 8
@@ -188,6 +191,13 @@ def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: boo
return {"ok": bool(body.get("ok")), "posted": posted, "kind": "quote_template_parse"}
def _inventory_lines_from_payload(payload: dict[str, Any]) -> list[str]:
"""从解析入队载荷取出单元格清单行。"""
raw = payload.get("cellInventory") or payload.get("cell_inventory") or []
if isinstance(raw, list):
return [str(x) for x in raw]
return []
def _post_parse_result(body: dict[str, Any]) -> dict[str, Any]:
"""PUT /inquiry/agent/quoteTemplate/parseResult。"""
try:
+36 -5
View File
@@ -193,15 +193,46 @@ class HttpLedger:
out.setdefault("needsPdf", str(format).lower() == "pdf")
return out
def transition(self, *, work_order_no: str, to_status: str) -> dict[str, Any]:
out = self._post(
"/inquiry/agent/ticket/transition",
{"workOrderNo": work_order_no, "toStatus": to_status},
)
def transition(
self,
*,
work_order_no: str,
to_status: str,
remark: str = "",
lost_reason: str = "",
next_followup_at: str = "",
deal_notify_summary: str = "",
) -> dict[str, Any]:
body: dict[str, Any] = {"workOrderNo": work_order_no, "toStatus": to_status}
if remark:
body["remark"] = remark
if lost_reason:
body["lostReason"] = lost_reason
if next_followup_at:
body["nextFollowupAt"] = next_followup_at
if deal_notify_summary:
body["dealNotifySummary"] = deal_notify_summary
out = self._post("/inquiry/agent/ticket/transition", body)
if not out.get("ok") and out.get("status"):
out["ok"] = True
return out if out.get("ok") else {"ok": False, "error": out.get("error") or "transition_failed"}
def patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]:
"""未成交后记下原因。不改六态。"""
out = self._post(
"/inquiry/agent/ticket/patchLostReason",
{"workOrderNo": work_order_no, "lostReason": lost_reason},
)
return out if out.get("ok") else {"ok": False, "error": out.get("error") or "patch_lost_reason_failed"}
def claim_due_negotiate(self) -> list[dict[str, Any]]:
"""认领到期协商再问,主账会清空再问时刻防重发。"""
out = self._post("/inquiry/agent/ticket/claimDueNegotiate", {})
rows = out.get("tickets") or out.get("result") or []
if isinstance(rows, list):
return [dict(x) for x in rows if isinstance(x, dict)]
return []
def match_sea_staff(self, *, origin: str, destination: str) -> list[dict[str, Any]]:
"""主账按线路返回启用的海运产品岗。"""
out = self._post(
+69 -2
View File
@@ -41,6 +41,9 @@ class MemoryTicket:
collab_ended: bool = False
attachments: list[dict[str, Any]] = field(default_factory=list)
quote_file_ready: bool = False
lost_reason: str = ""
next_followup_at: str = ""
deal_notify_summary: str = ""
class MemoryLedger:
@@ -207,14 +210,70 @@ class MemoryLedger:
"status": ticket.status,
}
def transition(self, *, work_order_no: str, to_status: str) -> dict[str, Any]:
def transition(
self,
*,
work_order_no: str,
to_status: str,
remark: str = "",
lost_reason: str = "",
next_followup_at: str = "",
deal_notify_summary: str = "",
) -> dict[str, Any]:
_ = remark
with self._lock:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
ticket.status = to_status
if to_status == "未成交":
ticket.next_followup_at = ""
if lost_reason:
ticket.lost_reason = lost_reason
elif to_status == "已成交":
ticket.next_followup_at = ""
if deal_notify_summary:
ticket.deal_notify_summary = deal_notify_summary
elif to_status == "协商中":
ticket.next_followup_at = next_followup_at
return {"ok": True, "status": ticket.status}
def patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]:
reason = (lost_reason or "").strip()
with self._lock:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
if ticket.status != "未成交":
return {"ok": False, "error": "not_lost", "status": ticket.status}
if not reason:
return {"ok": False, "error": "reason_blank"}
ticket.lost_reason = reason
return {"ok": True, "work_order_no": work_order_no, "lost_reason": reason}
def claim_due_negotiate(self) -> list[dict[str, Any]]:
now = datetime.now(_SHANGHAI).strftime("%Y-%m-%d %H:%M:%S")
hits: list[dict[str, Any]] = []
with self._lock:
for ticket in self._tickets.values():
due = (ticket.next_followup_at or "").strip()
if ticket.status != "协商中" or not due or due > now:
continue
ticket.next_followup_at = ""
hits.append(
{
"work_order_no": ticket.work_order_no,
"sales_wecom_id": ticket.sales_wecom_id,
"sales_name": "",
"business_line": ticket.business_line,
"collab_chat_id": ticket.collab_chat_id,
"status": ticket.status,
"facts": dict(ticket.facts),
"quote": dict(ticket.quote),
}
)
return hits
def get_ticket(self, *, work_order_no: str) -> MemoryTicket | None:
with self._lock:
return self._tickets.get(work_order_no)
@@ -282,8 +341,12 @@ class MemoryLedger:
status = ticket.status
quote = dict(ticket.quote or {})
wait_phase = "done"
if status in {"已成交", "未成交", "协商中", "已关闭"}:
if status in {"已成交", "已关闭"}:
wait_phase = "done"
elif status == "未成交":
wait_phase = "wait_lost_reason" if not (ticket.lost_reason or "").strip() else "done"
elif status == "协商中":
wait_phase = "wait_deal"
elif status == "已报价" and quote:
# 文件已发给销售 → 成交跟进;否则海运协同卡、空运方案卡
if ticket.quote_file_ready or quote.get("quoteFileIssued"):
@@ -306,6 +369,10 @@ class MemoryLedger:
"wait_phase": wait_phase,
"collab_chat_id": ticket.collab_chat_id,
"collab_facts": dict(ticket.collab_facts),
"lost_reason": ticket.lost_reason,
"lostReason": ticket.lost_reason,
"deal_notify_summary": ticket.deal_notify_summary,
"next_followup_at": ticket.next_followup_at,
}
def upsert_staff(self, row: dict[str, Any]) -> None:
+197 -6
View File
@@ -288,11 +288,14 @@ def assess_mapping_gaps(payload: dict[str, Any], mapping: dict[str, Any] | None)
def merge_mapping_prefer_existing(
old_mapping: dict[str, Any] | None,
new_mapping: dict[str, Any] | None,
*,
cell_inventory: list[str] | None = None,
) -> dict[str, Any]:
"""
补缺合并:旧 fields / 区域优先,新结果只补充旧里没有的 code/id。
重新解析合并:费用/线路区按 id 旧优先;fields 对照单元格清单智能取舍。
用于「未换文件重新解析」:避免已对上的字段被新一轮 AI 冲掉。
规则:旧无→用新;新无→留旧;相同→留旧;旧坐标仍贴标签→留旧;
旧坐标对不上(清单里能看到该标签)→改用新坐标。无清单时保守旧优先。
"""
new_mapping = dict(new_mapping or {})
old_mapping = old_mapping if isinstance(old_mapping, dict) else {}
@@ -300,11 +303,41 @@ def merge_mapping_prefer_existing(
new_fields = new_mapping.get("fields") if isinstance(new_mapping.get("fields"), dict) else {}
old_fields = old_mapping.get("fields") if isinstance(old_mapping.get("fields"), dict) else {}
fields: dict[str, Any] = dict(new_fields)
for code, cell in old_fields.items():
if cell is not None and str(cell).strip():
fields[str(code)] = cell
new_meta = new_mapping.get("fieldMeta") if isinstance(new_mapping.get("fieldMeta"), dict) else {}
old_meta = old_mapping.get("fieldMeta") if isinstance(old_mapping.get("fieldMeta"), dict) else {}
meta_out: dict[str, Any] = dict(new_meta)
codes = set(str(k) for k in old_fields.keys()) | set(str(k) for k in new_fields.keys())
fields: dict[str, Any] = {}
inv = list(cell_inventory or [])
for code in codes:
old_cell = str(old_fields.get(code) or "").strip()
new_cell = str(new_fields.get(code) or "").strip()
kept_old = False
if not old_cell:
chosen = new_cell
elif not new_cell:
chosen = old_cell
kept_old = True
elif old_cell.lower() == new_cell.lower():
chosen = old_cell
kept_old = True
elif inv and _field_mapping_still_aligned(code, old_cell, inv):
chosen = old_cell
kept_old = True
elif inv:
chosen = new_cell
logger.info("映射坐标纠偏 code=%s old=%s -> new=%s", code, old_cell, new_cell)
else:
chosen = old_cell
kept_old = True
if chosen:
fields[code] = chosen
if kept_old and isinstance(old_meta.get(code), dict):
meta_out[code] = dict(old_meta[code])
out["fields"] = fields
if meta_out:
out["fieldMeta"] = meta_out
out["feeRegions"] = _merge_regions_by_id(
old_mapping.get("feeRegions"), new_mapping.get("feeRegions")
@@ -315,6 +348,164 @@ def merge_mapping_prefer_existing(
return out
# 与主账 FILL_BY_LABEL 对齐的常用码→标签(智能合并校验用)
_FIELD_LABELS: dict[str, tuple[str, ...]] = {
"quote_no": ("RFQ No", "报价单编号", "报价编号"),
"quote_date": ("询价日期", "报价日期", "Inquiry Date"),
"pol": ("起运港", "POL"),
"pod": ("目的港", "POD"),
"commodity": ("品名", "货物品名", "Commodity"),
"quantity": ("货物数量", "货量"),
"fcl_or_lcl": ("整柜或拼柜",),
"container_type_qty": ("箱型箱量",),
"valid_until": ("报价有效期", "有效期至"),
"total_amount": ("费用总计", "报价合计"),
"origin": ("始发地", "始发站", "起运地"),
"destination": ("目的地",),
}
def _parse_inventory_line(line: str) -> tuple[str, str, str] | None:
"""地址 | 原文 | 合并 | 色 → (full_addr, text, merge)。"""
parts = [p.strip() for p in str(line or "").split("|")]
if len(parts) < 2:
return None
return parts[0], parts[1], parts[2] if len(parts) > 2 else "-"
def _col_index(letter: str) -> int:
n = 0
for ch in (letter or "").upper():
if "A" <= ch <= "Z":
n = n * 26 + (ord(ch) - ord("A") + 1)
return max(n - 1, 0)
def _split_addr(full: str) -> tuple[str, int, int] | None:
"""Sheet!B3 → (sheet, row0, col0)。"""
text = (full or "").strip()
if "!" not in text:
return None
sheet, addr = text.split("!", 1)
m = re.match(r"^\$?([A-Za-z]+)\$?(\d+)$", addr.strip())
if not m:
return None
return sheet, int(m.group(2)) - 1, _col_index(m.group(1))
def _merge_end_col(merge: str, fallback_col: int) -> int:
"""A3:C3 → 末列 index;无合并返回 fallback。"""
m = (merge or "").strip()
if not m or m == "-":
return fallback_col
# 可能是 A3:C3 或 Sheet!A3:C3
if "!" in m:
m = m.split("!", 1)[1]
if ":" not in m:
return fallback_col
end = m.split(":", 1)[1].strip()
mm = re.match(r"^\$?([A-Za-z]+)\$?(\d+)$", end)
if not mm:
return fallback_col
return _col_index(mm.group(1))
def _text_looks_like_label(text: str, labels: tuple[str, ...]) -> bool:
norm = (text or "").replace(":", ":").strip()
if not norm:
return False
primary = norm.split("/")[0].strip()
upper = norm.upper()
for lb in labels:
if not lb:
continue
if norm.lower().startswith(lb.lower()) or primary.lower().startswith(lb.lower()):
return True
if len(lb) >= 3 and lb.upper() in upper:
return True
return False
def _field_mapping_still_aligned(code: str, cell_ref: str, inventory: list[str]) -> bool:
"""
旧坐标是否仍可贴标签。
quote_no:贴着 RFQ 的 B3 常是 Port to Port / 工作天单选,不算对齐。
"""
labels = _FIELD_LABELS.get(str(code), ())
if not labels:
return True
parsed = _split_addr(cell_ref)
if not parsed:
return False
sheet, row, col = parsed
rows: list[tuple[str, int, int, str, str]] = []
mapped_text = ""
for line in inventory:
one = _parse_inventory_line(line)
if not one:
continue
addr, text, merge = one
sp = _split_addr(addr)
if not sp or sp[0] != sheet:
continue
rows.append((addr, sp[1], sp[2], text, merge))
if sp[1] == row and sp[2] == col:
mapped_text = text
if _text_looks_like_label(text, labels):
return True
if _is_unsuitable_value_cell(mapped_text):
return False
if str(code) == "quote_no" and mapped_text.strip() and not _looks_like_quote_no(mapped_text):
return False
saw = False
for _addr, r, c, text, merge in rows:
if not _text_looks_like_label(text, labels):
continue
saw = True
if r == row and c == col:
return True
end_col = _merge_end_col(merge, c)
if r == row:
gap = col - end_col
if 1 <= gap <= 3 and (not mapped_text.strip() or _looks_like_quote_no(mapped_text)):
return True
if str(code) == "quote_no" and gap > 3 and (
not mapped_text.strip() or _looks_like_quote_no(mapped_text)
):
return True
return not saw
def _is_unsuitable_value_cell(text: str) -> bool:
t = (text or "").replace(":", ":").strip()
if not t:
return False
lower = t.lower()
if any(x in t for x in ("○", "●", "⊙", "□", "☑")):
return True
if "port to port" in lower or "door to door" in lower:
return True
if "工作天" in t or "工作日" in t or "回复" in t:
return True
if "其它" in t and ("天" in t or "回复" in t or "Port" in t):
return True
return False
def _looks_like_quote_no(text: str) -> bool:
t = (text or "").strip()
if not t or len(t) > 40:
return False
import re as _re
if _re.match(r"(?i)WO\d{6,}", t) or _re.match(r"(?i)W0\d{6,}", t):
return True
if _re.match(r"(?i)RFQ[-_]?[A-Z0-9]+", t):
return True
return bool(_re.match(r"[A-Z]{1,5}[-_]?\d{8,}", t))
def _merge_regions_by_id(old_arr: Any, new_arr: Any) -> list[dict[str, Any]]:
"""区域按 id 合并:旧整段保留,新 id 追加。"""
out: list[dict[str, Any]] = []
+19 -3
View File
@@ -539,12 +539,28 @@ def apply_air_deal(
copy.ticket_locked(work_order_no=ticket_no(ticket), status=ticket_status(ticket)),
)
return "ticket_locked"
result = flow.ledger.transition(work_order_no=ticket_no(ticket), to_status=outcome) or {}
from agent.policy.deal_outcome import finish_deal_outcome
from agent.policy.sea_group_ops import facts_of_group
view = ticket_view(ticket)
result = finish_deal_outcome(
ledger=flow.ledger,
work_order_no=ticket_no(ticket),
outcome=outcome,
facts=facts_of_group(view),
quote=dict(view.get("quote") or {}),
business_line="AIR",
sales_name=str(view.get("sales_name") or ""),
)
if not result.get("ok"):
send_group_text(group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。")
return "ledger_fail"
send_group_text(group_client_of(flow), chat_id, copy.deal_ack(outcome, ticket_no(ticket)))
return "done"
send_group_text(
group_client_of(flow),
chat_id,
copy.deal_ack(outcome, ticket_no(ticket), str(result.get("next_followup_at") or "")),
)
return str(result.get("phase") or "done")
def apply_air_lock(
+54 -3
View File
@@ -322,6 +322,8 @@ class AirTextInquiryFlow:
allowed = (copy.BTN_ADOPT_EXCEL, copy.BTN_ADOPT_PDF, copy.BTN_REJECT)
elif phase == "wait_deal":
allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
elif phase == "wait_lost_reason":
allowed = ()
return FlowSession(
sender_id=sender_id,
thread_id=sender_id,
@@ -333,11 +335,17 @@ class AirTextInquiryFlow:
status=status,
allowed=allowed,
wait_version=1,
invited_sales_name=str(view.get("sales_name") or view.get("salesName") or ""),
collab_chat_id=str(view.get("collab_chat_id") or ""),
)
def _infer_wait_phase(self, *, status: str, quote: dict[str, Any]) -> str:
if status in {"已成交", "未成交", "协商中", "已关闭"}:
if status in {"已成交", "已关闭"}:
return "done"
if status == "未成交":
return "wait_lost_reason"
if status == "协商中":
return "wait_deal"
if status == "已报价" and quote:
return "wait_adopt"
return "tms_miss"
@@ -413,6 +421,9 @@ class AirTextInquiryFlow:
# 成交卡已经绑在报价文件那条出站上。这里再发一张独立卡,
# 会在文件还在上传时被另一条出站线程抢走,销售又先看到成交跟进。
return "wait_deal"
if sess.phase == "wait_lost_reason":
reply(copy.deal_ack("未成交", sess.work_order_no))
return "wait_lost_reason"
if sess.phase == "done":
reply(copy.deal_ack(sess.status or "已关闭", sess.work_order_no))
return "done"
@@ -462,6 +473,13 @@ class AirTextInquiryFlow:
reply=reply,
card_meta=inbound_raw,
)
# 未成交后下一句任意文字即原因;完整新询价(注入字段)则空着原因开新单。
if (
sess
and sess.phase == "wait_lost_reason"
and not (injected_facts and injected_mode)
):
return self._capture_lost_reason(sess, text, reply)
snap = extract_inquiry_snapshot(
text,
@@ -1056,15 +1074,48 @@ class AirTextInquiryFlow:
) -> str:
"""点成交/未成交/协商中:先整卡置灰,再入主账。"""
self._grey_clicked_card(reply, card_meta, replace_name=status)
result = self._ledger.transition(work_order_no=sess.work_order_no, to_status=status)
from agent.policy.deal_outcome import finish_deal_outcome
result = finish_deal_outcome(
ledger=self._ledger,
work_order_no=sess.work_order_no,
outcome=status,
facts=sess.facts,
quote=sess.quote,
business_line=sess.business_line,
sales_name=sess.invited_sales_name,
)
if not result.get("ok"):
reply("主账状态更新失败,成交结果未入账。")
return "ledger_fail"
phase = str(result.get("phase") or "done")
sess.status = status
sess.phase = phase
if phase == "wait_deal":
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
else:
sess.allowed = ()
sess.wait_version += 1
self._save(sess)
reply(copy.deal_ack(status, sess.work_order_no, str(result.get("next_followup_at") or "")))
return phase
def _capture_lost_reason(self, sess: FlowSession, text: str, reply: ReplyFn) -> str:
"""私聊未成交后收下原因,不再追问。"""
from agent.policy.deal_outcome import capture_lost_reason
result = capture_lost_reason(
ledger=self._ledger,
work_order_no=sess.work_order_no,
reason=text,
)
if not result.get("ok"):
reply("未成交原因没有记下,请再发一句。")
return "wait_lost_reason"
sess.phase = "done"
sess.allowed = ()
self._save(sess)
reply(copy.deal_ack(status, sess.work_order_no))
reply(copy.lost_reason_ack(text))
return "done"
def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None:
+210
View File
@@ -0,0 +1,210 @@
"""
成交跟进纯规则:工作日顺延、线路对负责人。
本文件职责:给私聊/群聊成交共用,不发企微、不写主账。
禁止:在回调线程算完还同步推负责人;把六态写进本文件。
"""
from __future__ import annotations
from datetime import date, datetime, timedelta, time
from typing import Any, Iterable
from zoneinfo import ZoneInfo
from agent.schema.tms_air_query import resolve_airport_code
SHANGHAI = ZoneInfo("Asia/Shanghai")
# 法定休息日(不含调休上班)。调休日放进 MAKEUP_WORKDAYS,周末也算工作日。
_HOLIDAYS = frozenset(
{
# 2026
date(2026, 1, 1),
date(2026, 1, 2),
date(2026, 1, 3),
date(2026, 2, 15),
date(2026, 2, 16),
date(2026, 2, 17),
date(2026, 2, 18),
date(2026, 2, 19),
date(2026, 2, 20),
date(2026, 2, 21),
date(2026, 2, 22),
date(2026, 2, 23),
date(2026, 4, 4),
date(2026, 4, 5),
date(2026, 4, 6),
date(2026, 5, 1),
date(2026, 5, 2),
date(2026, 5, 3),
date(2026, 5, 4),
date(2026, 5, 5),
date(2026, 6, 19),
date(2026, 6, 20),
date(2026, 6, 21),
date(2026, 9, 25),
date(2026, 9, 26),
date(2026, 9, 27),
date(2026, 10, 1),
date(2026, 10, 2),
date(2026, 10, 3),
date(2026, 10, 4),
date(2026, 10, 5),
date(2026, 10, 6),
date(2026, 10, 7),
date(2026, 10, 8),
# 2027 元旦预留
date(2027, 1, 1),
date(2027, 1, 2),
date(2027, 1, 3),
}
)
# 周末调休上班:算工作日。
_MAKEUP_WORKDAYS = frozenset(
{
date(2026, 1, 4),
date(2026, 2, 14),
date(2026, 2, 28),
date(2026, 5, 9),
date(2026, 10, 10),
}
)
def is_workday(day: date) -> bool:
"""周六日默认休息;法定假休息;调休上班算工作日。"""
if day in _HOLIDAYS:
return False
if day in _MAKEUP_WORKDAYS:
return True
return day.weekday() < 5
def shift_workdays(start: date, count: int) -> date:
"""
从 start 当天之后数 count 个工作日。
成交选协商中:顺延 3 个工作日后,第四个工作日再问,故 count=4。
"""
if count < 1:
return start
cur = start
seen = 0
while seen < count:
cur = cur + timedelta(days=1)
if is_workday(cur):
seen += 1
return cur
def next_followup_at(now: datetime | None = None) -> datetime:
"""第四个工作日 Asia/Shanghai 09:00。"""
base = now.astimezone(SHANGHAI) if now else datetime.now(SHANGHAI)
day = shift_workdays(base.date(), 4)
return datetime.combine(day, time(9, 0), tzinfo=SHANGHAI)
def _split_routes(raw: str) -> list[str]:
out: list[str] = []
for part in (raw or "").replace(",", ",").replace("、", ",").split(","):
t = part.strip()
if t:
out.append(t)
return out
def air_route_keys(facts: dict[str, str] | None) -> set[str]:
"""本单可对上的空运线路名:三字码对 + 目的港。"""
src = dict(facts or {})
origin = resolve_airport_code(str(src.get("起运港") or src.get("originCode") or ""))
dest = resolve_airport_code(str(src.get("目的港") or src.get("destinationCode") or ""))
keys: set[str] = set()
if dest:
keys.add(dest)
if origin and dest:
keys.add(f"{origin}-{dest}")
return keys
def _is_waicai(facts: dict[str, str] | None) -> bool:
src = dict(facts or {})
for key in ("外采", "是否外采", "采购方式"):
val = str(src.get(key) or "").strip().lower()
if val in {"1", "是", "true", "yes", "外采"}:
return True
blob = "".join(str(v) for v in src.values())
return "外采" in blob
def leader_matches(
*,
role_code: str,
routes: str,
is_leader: bool,
business_line: str,
facts: dict[str, str] | None,
) -> bool:
"""
员工管理「是负责人 + 负责线路对得上」。
海运:海运全线。陆运:线路类别整词。空运:三字码对 / 目的港 / 全线;外采须工单标明。
"""
if not is_leader:
return False
line = (business_line or "").strip().upper()
code = (role_code or "").strip().lower()
tokens = _split_routes(routes)
if line == "SEA":
return code == "sea" and "海运全线" in tokens
if line == "LAND":
want = str((facts or {}).get("线路类别") or "").strip()
return code == "land" and bool(want) and want in tokens
if line == "AIR":
if code != "air":
return False
if "全线" in tokens:
return True
if "外采-空运" in tokens and _is_waicai(facts):
return True
keys = air_route_keys(facts)
return any(t in keys for t in tokens)
return False
def pick_leaders(
staff: Iterable[dict[str, Any]],
*,
business_line: str,
facts: dict[str, str] | None,
) -> list[dict[str, str]]:
"""从员工行筛出该推的负责人(启用且有企微 id)。"""
out: list[dict[str, str]] = []
seen: set[str] = set()
for row in staff:
if str(row.get("status") or "active").lower() != "active":
continue
wecom = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
if not wecom or wecom in seen:
continue
leader_flag = row.get("isLeader")
if leader_flag is None:
leader_flag = row.get("is_leader")
is_leader = leader_flag in {True, 1, "1", "true", "True"}
if not leader_matches(
role_code=str(row.get("roleCode") or row.get("role_code") or ""),
routes=str(row.get("routes") or ""),
is_leader=bool(is_leader),
business_line=business_line,
facts=facts,
):
continue
seen.add(wecom)
out.append(
{
"name": str(row.get("name") or "").strip(),
"wecomId": wecom,
"roleCode": str(row.get("roleCode") or row.get("role_code") or ""),
}
)
return out
+160
View File
@@ -0,0 +1,160 @@
"""
成交三态落地:改主账六态、推负责人、收下未成交原因、排协商再问。
本文件职责:私聊/群聊共用。不在回调线程等 LLM。推送只入 outbox。
禁止:把图成功当主账成功;成交失败还去推负责人;未成交推负责人。
"""
from __future__ import annotations
import logging
from typing import Any, Callable, Optional
from agent.config import get_settings
from agent.policy import inquiry_copy as copy
from agent.policy.deal_followup import next_followup_at, pick_leaders
logger = logging.getLogger(__name__)
EnqueueFn = Callable[..., tuple[bool, str]]
def notify_app_ready() -> bool:
"""询价消息服务已配 AgentId+Secret 才推。空则只成交不推。"""
settings = get_settings()
return bool(
str(getattr(settings, "wecom_notify_agent_id", "") or "").strip()
and str(getattr(settings, "wecom_notify_secret", "") or "").strip()
)
def _role_of(business_line: str) -> str:
line = (business_line or "").strip().upper()
if line == "SEA":
return "sea"
if line == "LAND":
return "land"
return "air"
def _enqueue_default(
*,
touser: str,
content: str,
dedupe_key: str,
payload: dict[str, Any],
) -> tuple[bool, str]:
from agent.channel.queue import get_message_store
return get_message_store().enqueue_outbound(
touser=touser,
content=content,
dedupe_key=dedupe_key,
payload=payload,
)
def finish_deal_outcome(
*,
ledger: Any,
work_order_no: str,
outcome: str,
facts: dict[str, str] | None,
quote: dict[str, Any] | None,
business_line: str,
sales_name: str = "",
enqueue: Optional[EnqueueFn] = None,
notify_ready: Optional[bool] = None,
) -> dict[str, Any]:
"""
先入主账,再按结果推人或排再问。
返回 ok / phase / notify_summary。phase:
已成交 done;未成交 wait_lost_reason;协商中 wait_deal。
"""
no = (work_order_no or "").strip()
status = (outcome or "").strip()
extra: dict[str, Any] = {}
summary = ""
if status == "协商中":
extra["next_followup_at"] = next_followup_at().strftime("%Y-%m-%d %H:%M:%S")
if status == "已成交":
staff: list[dict[str, Any]] = []
lister = getattr(ledger, "list_staff_by_role", None)
if callable(lister):
try:
staff = list(lister(role_code=_role_of(business_line)) or [])
except Exception:
logger.exception("成交匹配负责人失败 wo=%s", no)
leaders = pick_leaders(staff, business_line=business_line, facts=facts)
ready = notify_app_ready() if notify_ready is None else bool(notify_ready)
if not leaders:
summary = "无匹配负责人"
elif not ready:
summary = "通知未发出"
else:
names = [str(x.get("name") or x.get("wecomId") or "").strip() for x in leaders]
summary = "已推:" + "、".join(n for n in names if n)
sender = enqueue or _enqueue_default
payload = copy.deal_notify_wecom_payload(
work_order_no=no,
sales_name=sales_name,
facts=facts or {},
quote=quote or {},
business_line=business_line,
)
text = copy.deal_notify_card(
work_order_no=no,
sales_name=sales_name,
facts=facts or {},
quote=quote or {},
business_line=business_line,
)
for row in leaders:
uid = str(row.get("wecomId") or "").strip()
if not uid:
continue
try:
ok, info = sender(
touser=uid,
content=text,
dedupe_key=f"deal-notify:{no}:{uid}",
payload=dict(payload),
)
if not ok:
logger.warning("成交通知入队失败 wo=%s uid=%s info=%s", no, uid, info)
except Exception:
logger.exception("成交通知入队异常 wo=%s uid=%s", no, uid)
extra["deal_notify_summary"] = summary
extra["remark"] = summary
writer = getattr(ledger, "transition", None)
if not callable(writer):
return {"ok": False, "error": "no_transition", "phase": "ledger_fail"}
result = writer(work_order_no=no, to_status=status, **extra) or {}
if not result.get("ok"):
return {"ok": False, "error": result.get("error") or "transition_failed", "phase": "ledger_fail"}
if status == "未成交":
phase = "wait_lost_reason"
elif status == "协商中":
phase = "wait_deal"
else:
phase = "done"
return {
"ok": True,
"phase": phase,
"notify_summary": summary,
"status": status,
"next_followup_at": str(extra.get("next_followup_at") or ""),
}
def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict[str, Any]:
"""把销售下一句写进未成交原因。"""
text = (reason or "").strip()
no = (work_order_no or "").strip()
if not no or not text:
return {"ok": False, "error": "blank"}
patcher = getattr(ledger, "patch_lost_reason", None)
if not callable(patcher):
return {"ok": False, "error": "no_patch"}
return patcher(work_order_no=no, lost_reason=text) or {"ok": False}
+94 -2
View File
@@ -1732,12 +1732,104 @@ def group_deal_mention(*, sales_name: str, work_order_no: str) -> str:
return group_deal_followup(sales_name=sales_name, work_order_no=work_order_no)
def deal_ack(outcome: str, work_order_no: str) -> str:
def deal_ack(outcome: str, work_order_no: str, next_followup_at: str = "") -> str:
"""成交三态回执。协商中写明顺延三个工作日和下次再问的月日 9:00。"""
if outcome == "已成交":
return f"已记录为「已成交」。工单 {work_order_no} 已关闭智能体跟进。"
if outcome == "未成交":
return "已记录为「未成交」。请补充未成交原因。"
return f"已记录为「协商中」。工单 {work_order_no} 跟进周期已顺延。"
when = _followup_month_day(next_followup_at)
if when:
return f"已记录为「协商中」。已顺延三个工作日,{when}的9:00再次发起成交状态询问。"
return "已记录为「协商中」。已顺延三个工作日,第四个工作日早上9:00再次发起成交状态询问。"
def _followup_month_day(raw: str) -> str:
"""把主账再问时刻收成「9月24日」。"""
from datetime import datetime
text = (raw or "").strip()
if not text:
return ""
for fmt, n in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d", 10)):
try:
dt = datetime.strptime(text[:n], fmt)
return f"{dt.month}月{dt.day}日"
except ValueError:
continue
return ""
def lost_reason_ack(reason: str = "") -> str:
"""收下未成交原因后回显原句,不再追问。"""
text = (reason or "").strip()
if text.endswith(("。", ".", "!", "!", "?", "?")):
return f"已记下未成交原因:{text}"
return f"已记下未成交原因:{text}。" if text else "已记下未成交原因。"
def deal_notify_card(
*,
work_order_no: str,
sales_name: str,
facts: dict[str, str] | None,
quote: dict[str, Any] | None,
business_line: str,
) -> str:
"""成交通知文字兜底:方案卡压缩。不写销售,避免标题出现「销售 销售」。"""
no = (work_order_no or "").strip()
_ = sales_name
mode = scheme_transport_mode(business_line)
rows = scheme_horizontal_rows(
work_order_no=no,
facts=dict(facts or {}),
quote=dict(quote or {}),
transport_mode=mode,
)
lines = [f"成交通知 工单 {no}"]
lines.extend(f"{row['keyname']}:{row['value']}" for row in rows)
return "\n".join(lines)
def deal_notify_wecom_payload(
*,
work_order_no: str,
sales_name: str,
facts: dict[str, str] | None,
quote: dict[str, Any] | None,
business_line: str,
) -> dict[str, Any]:
"""
成交通知卡:走询价消息服务,不走询价小助手。
字段按报价方案卡压缩,避免超过 6 格。标题不写销售。
"""
no = (work_order_no or "").strip()
_ = sales_name
mode = scheme_transport_mode(business_line)
rows = scheme_horizontal_rows(
work_order_no=no,
facts=dict(facts or {}),
quote=dict(quote or {}),
transport_mode=mode,
)
from agent.config import get_settings
base = str(getattr(get_settings(), "public_base_url", "") or "").strip().rstrip("/")
action_url = base or "https://work.weixin.qq.com"
return {
"msgtype": "template_card",
"wecom_app": "notify",
"template_card": {
"card_type": "text_notice",
"source": {"desc": "成交通知", "desc_color": 0},
"main_title": {
"title": f"成交通知 工单 {no}",
},
"horizontal_content_list": rows,
"card_action": {"type": 1, "url": action_url},
},
}
def air_need_work_order() -> str:
@@ -91,6 +91,13 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
reply=reply,
sess=sess,
)
# 未成交后下一句任意文字即原因。不能走下面的 new_over_old,否则会当新询价丢掉。
if (
sess
and sess.phase == "wait_lost_reason"
and not (injected_facts and injected_mode)
):
return self._capture_lost_reason(sess, text, reply)
if sess and phase not in _LAND_FRONT:
# 查价后半段书签还在时,销售又发新陆运:只走陆运前半段。
# 禁止再 super 去海运 on_text,海运会把 LAND 转回来,递归到死、一句都不回。
+66 -7
View File
@@ -64,6 +64,7 @@ def ticket_view(ticket: object) -> dict[str, Any]:
view.get("productNames") or view.get("product_names") or [],
)
view.setdefault("sales_name", view.get("salesName") or view.get("sales_name") or "")
view.setdefault("lost_reason", view.get("lostReason") or view.get("lost_reason") or "")
facts = dict(view.get("collab_facts") or view.get("collabFacts") or {})
view.setdefault("collab_facts", facts)
flag = str(facts.get("__ended") or view.get("collab_ended") or view.get("collabEnded") or "").strip().lower()
@@ -89,6 +90,7 @@ def ticket_view(ticket: object) -> dict[str, Any]:
"product_ids": list(getattr(ticket, "product_ids", []) or []),
"product_names": list(getattr(ticket, "product_names", []) or []),
"sales_name": str(getattr(ticket, "sales_name", "") or ""),
"lost_reason": str(getattr(ticket, "lost_reason", "") or ""),
}
@@ -987,18 +989,75 @@ def apply_group_deal(
if not (quote.get("total") or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
send_group_text(client, chat_id, copy.group_deal_need_quote())
return "deal_need_quote"
writer = getattr(flow.ledger, "transition", None)
if callable(writer):
result = writer(work_order_no=no, to_status=outcome) or {}
if not result.get("ok"):
send_group_text(client, chat_id, "主账状态更新失败,成交结果未入账。")
return "ledger_fail"
from agent.policy.deal_outcome import finish_deal_outcome
result = finish_deal_outcome(
ledger=flow.ledger,
work_order_no=no,
outcome=outcome,
facts=facts_of_group(view, sess),
quote=quote,
business_line=str(view.get("business_line") or (sess.business_line if sess else "SEA") or "SEA"),
sales_name=str(view.get("sales_name") or (getattr(sess, "invited_sales_name", "") if sess else "") or ""),
)
if not result.get("ok"):
send_group_text(client, chat_id, "主账状态更新失败,成交结果未入账。")
return "ledger_fail"
phase = str(result.get("phase") or "done")
if sess is not None:
sess.status = outcome
sess.phase = phase
if phase == "wait_deal":
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
else:
sess.allowed = ()
flow._save(sess)
send_group_text(client, chat_id, copy.deal_ack(outcome, no, str(result.get("next_followup_at") or "")))
return phase
def facts_of_group(view: dict[str, Any], sess: Any = None) -> dict[str, str]:
"""主账字段打底,书签覆盖;给负责人匹配和成交通知卡用。"""
facts = dict(view.get("facts") or {})
if sess is not None and getattr(sess, "facts", None):
facts.update(dict(sess.facts or {}))
return facts
def capture_group_lost_reason(
*,
flow: Any,
ticket: object,
sender_id: str,
text: str,
sess: Any = None,
) -> str:
"""
群里未成交后,本单销售下一句任意文字即原因。
空运群是否要 @ 由入站通道先拦,这里不再判。
"""
ticket = hydrate_ticket(flow, ticket)
view = ticket_view(ticket)
no = ticket_no(ticket)
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
client = group_client_of(flow)
if ticket_status(ticket) != "未成交":
return ""
if str(view.get("lost_reason") or "").strip():
return ""
if not is_sales(ticket, sender_id):
return ""
from agent.policy.deal_outcome import capture_lost_reason
result = capture_lost_reason(ledger=flow.ledger, work_order_no=no, reason=text)
if not result.get("ok"):
send_group_text(client, chat_id, "未成交原因没有记下,请再发一句。")
return "wait_lost_reason"
if sess is not None:
sess.phase = "done"
sess.allowed = ()
flow._save(sess)
send_group_text(client, chat_id, copy.deal_ack(outcome, no))
send_group_text(client, chat_id, copy.lost_reason_ack(text))
return "done"
@@ -85,6 +85,13 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
reply=reply,
card_meta=inbound_raw,
)
# 未成交后下一句任意文字即原因,不能先抽字段当成新询价。
if (
sess
and sess.phase == "wait_lost_reason"
and not (injected_facts and injected_mode)
):
return self._capture_lost_reason(sess, text, reply)
snap = extract_inquiry_snapshot(
text,
+144
View File
@@ -0,0 +1,144 @@
"""
成交跟进:工作日顺延、线路对负责人、未成交收下原因(不打外网)。
"""
from __future__ import annotations
import unittest
from datetime import date, datetime
from zoneinfo import ZoneInfo
from agent.policy.deal_followup import (
SHANGHAI,
air_route_keys,
leader_matches,
next_followup_at,
shift_workdays,
)
class TestWorkdays(unittest.TestCase):
def test_skip_weekend(self) -> None:
# 2026-09-18 周五,顺延 3 个工作日 → 21、22、23,第四个工作日 24
self.assertEqual(shift_workdays(date(2026, 9, 18), 3), date(2026, 9, 23))
self.assertEqual(shift_workdays(date(2026, 9, 18), 4), date(2026, 9, 24))
def test_skip_national_day(self) -> None:
# 2026-09-30 周三,国庆休 → 第四个工作日落到节后
fourth = shift_workdays(date(2026, 9, 30), 4)
self.assertGreaterEqual(fourth, date(2026, 10, 9))
self.assertNotEqual(fourth.weekday(), 5)
self.assertNotEqual(fourth.weekday(), 6)
def test_next_followup_is_9am(self) -> None:
when = next_followup_at(datetime(2026, 9, 18, 15, 30, tzinfo=SHANGHAI))
self.assertEqual(when.hour, 9)
self.assertEqual(when.minute, 0)
self.assertEqual(when.tzinfo, SHANGHAI)
self.assertEqual(when.date(), date(2026, 9, 24))
class TestAirRouteKeys(unittest.TestCase):
def test_pair_and_dest(self) -> None:
keys = air_route_keys({"起运港": "SZX", "目的港": "KUL"})
self.assertIn("SZX-KUL", keys)
self.assertIn("KUL", keys)
def test_chinese_city_projects_iata(self) -> None:
keys = air_route_keys({"起运港": "深圳", "目的港": "吉隆坡"})
self.assertIn("SZX-KUL", keys)
self.assertIn("KUL", keys)
class TestLeaderMatch(unittest.TestCase):
def test_sea_all_line(self) -> None:
self.assertTrue(
leader_matches(
role_code="sea",
routes="海运全线",
is_leader=True,
business_line="SEA",
facts={},
)
)
self.assertFalse(
leader_matches(
role_code="sea",
routes="海运全线",
is_leader=False,
business_line="SEA",
facts={},
)
)
def test_land_route_category(self) -> None:
self.assertTrue(
leader_matches(
role_code="land",
routes="国内长途/零担,东南亚",
is_leader=True,
business_line="LAND",
facts={"线路类别": "国内长途/零担"},
)
)
self.assertFalse(
leader_matches(
role_code="land",
routes="东南亚",
is_leader=True,
business_line="LAND",
facts={"线路类别": "国内长途/零担"},
)
)
def test_air_pair_dest_and_all(self) -> None:
facts = {"起运港": "SZX", "目的港": "KUL"}
self.assertTrue(
leader_matches(
role_code="air",
routes="SZX-KUL",
is_leader=True,
business_line="AIR",
facts=facts,
)
)
self.assertTrue(
leader_matches(
role_code="air",
routes="KUL",
is_leader=True,
business_line="AIR",
facts=facts,
)
)
self.assertTrue(
leader_matches(
role_code="air",
routes="全线",
is_leader=True,
business_line="AIR",
facts=facts,
)
)
self.assertFalse(
leader_matches(
role_code="air",
routes="外采-空运",
is_leader=True,
business_line="AIR",
facts=facts,
)
)
self.assertTrue(
leader_matches(
role_code="air",
routes="外采-空运",
is_leader=True,
business_line="AIR",
facts={**facts, "外采": "1"},
)
)
if __name__ == "__main__":
unittest.main()
+247
View File
@@ -0,0 +1,247 @@
"""
成交三态:入账、推负责人、收下未成交原因、协商中不停会话。
"""
from __future__ import annotations
import unittest
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import AirTextInquiryFlow, FlowSession
from agent.policy.deal_outcome import capture_lost_reason, finish_deal_outcome
from agent.policy.sea_text_flow import SeaTextInquiryFlow
class TestDealOutcome(unittest.TestCase):
def setUp(self) -> None:
self.ledger = MemoryLedger()
created = self.ledger.create_ticket(
sender_id="sales1",
business_line="SEA",
facts={"起运港": "上海", "目的港": "洛杉矶", "品名": "衣服"},
)
self.wo = str(created.get("work_order_no") or "")
self.ledger.transition(work_order_no=self.wo, to_status="已报价")
self.ledger.upsert_staff(
{
"name": "海运负责人",
"wecomId": "lead-sea",
"roleCode": "sea",
"routes": "海运全线",
"isLeader": 1,
"status": "active",
}
)
def test_deal_notifies_leaders(self) -> None:
queued: list[dict] = []
def enqueue(*, touser, content, dedupe_key, payload):
queued.append(
{
"touser": touser,
"content": content,
"dedupe_key": dedupe_key,
"payload": payload,
}
)
return True, "ok"
out = finish_deal_outcome(
ledger=self.ledger,
work_order_no=self.wo,
outcome="已成交",
facts={"起运港": "上海", "目的港": "洛杉矶"},
quote={"total": "1000", "currency": "USD"},
business_line="SEA",
sales_name="吴吉林",
enqueue=enqueue,
notify_ready=True,
)
self.assertTrue(out.get("ok"))
self.assertEqual(out.get("phase"), "done")
self.assertIn("海运负责人", str(out.get("notify_summary") or ""))
self.assertEqual(queued[0]["touser"], "lead-sea")
self.assertEqual(queued[0]["payload"].get("wecom_app"), "notify")
ticket = self.ledger.get_ticket(work_order_no=self.wo)
self.assertEqual(ticket.status, "已成交")
self.assertEqual(ticket.next_followup_at, "")
def test_deal_notify_card_omits_sales_label(self) -> None:
"""成交通知不要「销售」二字:标题副标和正文都不写。"""
text = copy.deal_notify_card(
work_order_no="WO202609180008",
sales_name="吴吉林",
facts={"起运地": "泉州", "目的地": "南京", "线路类别": "国内城配/拖车/打包"},
quote={},
business_line="LAND",
)
self.assertNotIn("销售", text)
payload = copy.deal_notify_wecom_payload(
work_order_no="WO202609180008",
sales_name="吴吉林",
facts={"起运地": "泉州", "目的地": "南京", "线路类别": "国内城配/拖车/打包"},
quote={},
business_line="LAND",
)
title = (payload.get("template_card") or {}).get("main_title") or {}
blob = str(title.get("title") or "") + str(title.get("desc") or "")
self.assertNotIn("销售", blob)
self.assertNotIn("desc", title)
def test_deal_without_leader_still_closes(self) -> None:
self.ledger.upsert_staff(
{
"name": "不是负责人",
"wecomId": "lead-sea",
"roleCode": "sea",
"routes": "海运全线",
"isLeader": 0,
"status": "active",
}
)
out = finish_deal_outcome(
ledger=self.ledger,
work_order_no=self.wo,
outcome="已成交",
facts={},
quote={},
business_line="SEA",
notify_ready=True,
)
self.assertEqual(out.get("notify_summary"), "无匹配负责人")
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).status, "已成交")
def test_lost_waits_for_reason(self) -> None:
out = finish_deal_outcome(
ledger=self.ledger,
work_order_no=self.wo,
outcome="未成交",
facts={},
quote={},
business_line="SEA",
)
self.assertEqual(out.get("phase"), "wait_lost_reason")
ticket = self.ledger.get_ticket(work_order_no=self.wo)
self.assertEqual(ticket.status, "未成交")
self.assertEqual(ticket.lost_reason, "")
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
self.assertEqual(view.get("wait_phase"), "wait_lost_reason")
captured = capture_lost_reason(
ledger=self.ledger, work_order_no=self.wo, reason="客人比价了"
)
self.assertTrue(captured.get("ok"))
ticket = self.ledger.get_ticket(work_order_no=self.wo)
self.assertEqual(ticket.lost_reason, "客人比价了")
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
self.assertEqual(view.get("wait_phase"), "done")
def test_lost_reason_ack_repeats_user_text(self) -> None:
self.assertEqual(copy.lost_reason_ack("客户不要了"), "已记下未成交原因:客户不要了。")
self.assertEqual(copy.lost_reason_ack("价格高。"), "已记下未成交原因:价格高。")
def test_negotiate_ack_names_followup_day(self) -> None:
text = copy.deal_ack("协商中", "WO202609190007", "2026-09-24 09:00:00")
self.assertEqual(
text,
"已记录为「协商中」。已顺延三个工作日,9月24日的9:00再次发起成交状态询问。",
)
self.assertNotIn("跟进周期已顺延", text)
def test_negotiate_keeps_wait_deal(self) -> None:
out = finish_deal_outcome(
ledger=self.ledger,
work_order_no=self.wo,
outcome="协商中",
facts={},
quote={},
business_line="SEA",
)
self.assertEqual(out.get("phase"), "wait_deal")
ticket = self.ledger.get_ticket(work_order_no=self.wo)
self.assertEqual(ticket.status, "协商中")
self.assertTrue(ticket.next_followup_at)
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
self.assertEqual(view.get("wait_phase"), "wait_deal")
again = finish_deal_outcome(
ledger=self.ledger,
work_order_no=self.wo,
outcome="协商中",
facts={},
quote={},
business_line="SEA",
)
self.assertEqual(again.get("phase"), "wait_deal")
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).status, "协商中")
self.assertTrue(again.get("next_followup_at"))
def test_private_next_text_is_lost_reason(self) -> None:
flow = AirTextInquiryFlow(self.ledger)
sess = FlowSession(
sender_id="sales1",
thread_id="sales1",
phase="wait_deal",
business_line="SEA",
work_order_no=self.wo,
allowed=(copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE),
)
flow._save(sess)
replies: list[str] = []
phase = flow.on_button(
sender_id="sales1",
action=copy.BTN_LOST,
reply=lambda t, extra=None: replies.append(t),
)
self.assertEqual(phase, "wait_lost_reason")
phase = flow.on_text(
sender_id="sales1",
text="价格高",
reply=lambda t, extra=None: replies.append(t),
)
self.assertEqual(phase, "done")
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).lost_reason, "价格高")
self.assertTrue(any(t == "已记下未成交原因:价格高。" for t in replies))
def test_sea_private_next_text_is_lost_reason(self) -> None:
flow = SeaTextInquiryFlow(self.ledger)
flow._save(
FlowSession(
sender_id="sales1",
thread_id="sales1",
phase="wait_deal",
business_line="SEA",
work_order_no=self.wo,
allowed=(copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE),
)
)
replies: list[str] = []
phase = flow.on_button(
sender_id="sales1",
action=copy.BTN_LOST,
reply=lambda t, extra=None: replies.append(t),
)
self.assertEqual(phase, "wait_lost_reason")
phase = flow.on_text(
sender_id="sales1",
text="客人比价了",
reply=lambda t, extra=None: replies.append(t),
)
self.assertEqual(phase, "done")
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).lost_reason, "客人比价了")
def test_claim_due_negotiate_clears_schedule(self) -> None:
finish_deal_outcome(
ledger=self.ledger,
work_order_no=self.wo,
outcome="协商中",
facts={},
quote={},
business_line="SEA",
)
ticket = self.ledger.get_ticket(work_order_no=self.wo)
ticket.next_followup_at = "2020-01-01 09:00:00"
rows = self.ledger.claim_due_negotiate()
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["work_order_no"], self.wo)
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).next_followup_at, "")
@@ -26,6 +26,7 @@ os.environ.setdefault("LEDGER_BACKEND", "memory")
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import FlowSession
from agent.policy.land_text_flow import LandTextInquiryFlow
@@ -623,5 +624,46 @@ class LandTextPullTests(unittest.TestCase):
self.assertTrue(is_product(ticket, "u1", flow=self.flow))
class LandLostReasonTests(unittest.TestCase):
"""陆运私聊未成交后,下一句任意文字要记下原因,不能当新询价丢掉。"""
def test_land_private_next_text_is_lost_reason(self) -> None:
ledger = MemoryLedger()
created = ledger.create_ticket(
sender_id="WuJiLin",
business_line="LAND",
facts={"起运地": "泉州", "目的地": "南京", "线路类别": "国内城配/拖车/打包"},
)
wo = str(created.get("work_order_no") or "")
ledger.transition(work_order_no=wo, to_status="已报价")
flow = LandTextInquiryFlow(ledger)
flow._save(
FlowSession(
sender_id="WuJiLin",
thread_id="WuJiLin",
phase="wait_deal",
business_line="LAND",
work_order_no=wo,
allowed=(copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE),
)
)
replies: list[str] = []
phase = flow.on_button(
sender_id="WuJiLin",
action=copy.BTN_LOST,
reply=lambda t, extra=None: replies.append(t),
)
self.assertEqual(phase, "wait_lost_reason")
self.assertEqual(ledger.get_ticket(work_order_no=wo).status, "未成交")
phase = flow.on_text(
sender_id="WuJiLin",
text="价格高",
reply=lambda t, extra=None: replies.append(t),
)
self.assertEqual(phase, "done")
self.assertEqual(ledger.get_ticket(work_order_no=wo).lost_reason, "价格高")
self.assertTrue(any(t == "已记下未成交原因:价格高。" for t in replies))
if __name__ == "__main__":
unittest.main()
+28 -1
View File
@@ -1522,9 +1522,36 @@ class SeaGroupOpsTests(unittest.TestCase):
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "done")
self.assertEqual(phase, "wait_lost_reason")
self.assertEqual(self.ledger.get_ticket(work_order_no=wo).status, "未成交")
def test_sales_next_text_is_lost_reason(self) -> None:
wo, chat = self._open_group()
handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="lost-1",
content="未成交",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
)
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="lost-2",
content="客人比价了",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "done")
self.assertEqual(self.ledger.get_ticket(work_order_no=wo).lost_reason, "客人比价了")
def test_sales_can_close_deal_by_text(self) -> None:
"""群里销售说成交,要入账并回话,不能装没听见。相近说法也认。"""
wo, chat = self._open_group()
+42 -3
View File
@@ -179,15 +179,54 @@ class TestSheetMapParse(unittest.TestCase):
"routeRegions": [],
"mappingGaps": [],
}
# 无清单:保守旧优先
merged = merge_mapping_prefer_existing(old, new)
self.assertEqual(merged["fields"]["quote_no"], "S!B3") # 旧优先
self.assertEqual(merged["fields"]["pol"], "S!B5") # 旧保留
self.assertEqual(merged["fields"]["pod"], "S!B6") # 新补缺
self.assertEqual(merged["fields"]["quote_no"], "S!B3")
self.assertEqual(merged["fields"]["pol"], "S!B5")
self.assertEqual(merged["fields"]["pod"], "S!B6")
ids = [r["id"] for r in merged["feeRegions"]]
self.assertEqual(ids[0], "origin_charges")
self.assertEqual(merged["feeRegions"][0]["title"], "旧始发港")
self.assertIn("ocean_freight", ids)
def test_merge_replaces_misaligned_quote_no(self) -> None:
"""旧 quote_no 落在单选 B3 → 改用新坐标 F3。"""
from agent.llm.mode_sheet_map import merge_mapping_prefer_existing
inventory = [
"S!A3 | RFQ No: | A3:A3 | -",
"S!B3 | ○ Port to Port / 其它,3工作天回复 | B3:E3 | -",
"S!F3 | | F3:G3 | -",
]
old = {"fields": {"quote_no": "S!B3"}, "feeRegions": [], "routeRegions": []}
new = {"fields": {"quote_no": "S!F3"}, "feeRegions": [], "routeRegions": []}
merged = merge_mapping_prefer_existing(old, new, cell_inventory=inventory)
self.assertEqual(merged["fields"]["quote_no"], "S!F3")
def test_merge_keeps_aligned_quote_no_when_ai_drifts(self) -> None:
"""旧已在 RFQ 旁空格、新一轮漂到远处 → 仍保留旧坐标。"""
from agent.llm.mode_sheet_map import merge_mapping_prefer_existing
inventory = [
"S!A3 | RFQ No: | - | -",
"S!B3 | | - | -",
]
old = {"fields": {"quote_no": "S!B3"}, "feeRegions": [], "routeRegions": []}
new = {"fields": {"quote_no": "S!Z99"}, "feeRegions": [], "routeRegions": []}
merged = merge_mapping_prefer_existing(old, new, cell_inventory=inventory)
self.assertEqual(merged["fields"]["quote_no"], "S!B3")
def test_b3_radio_not_aligned(self) -> None:
from agent.llm.mode_sheet_map import _field_mapping_still_aligned
inventory = [
"S!A3 | RFQ No: | - | -",
"S!B3 | ○ Port to Port | - | -",
"S!F3 | WO202609190002 | - | -",
]
self.assertFalse(_field_mapping_still_aligned("quote_no", "S!B3", inventory))
self.assertTrue(_field_mapping_still_aligned("quote_no", "S!F3", inventory))
def test_invoke_retries_when_gaps(self) -> None:
calls: list[str] = []
@@ -138,4 +138,11 @@ public class LoginUser {
*/
private Boolean dashboardSelfOnly;
/**
* 最后登录时间(账号列表展示;登录成功写入)。
*/
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date lastLoginTime;
}
@@ -122,6 +122,10 @@ public class InquiryAgentController {
Object to = body == null ? null : body.get("toStatus");
req.setWorkOrderNo(no == null ? "" : String.valueOf(no));
req.setToStatus(to == null ? "" : String.valueOf(to));
req.setRemark(firstStr(body, "remark"));
req.setLostReason(firstStr(body, "lostReason", "lost_reason"));
req.setNextFollowupAt(firstStr(body, "nextFollowupAt", "next_followup_at"));
req.setDealNotifySummary(firstStr(body, "dealNotifySummary", "deal_notify_summary"));
try {
org.jeecg.modules.inquiry.entity.InquiryTicket ticket =
workOrderService.transition(req, "agent");
@@ -135,6 +139,31 @@ public class InquiryAgentController {
}
}
@ApiOperation("智能体补未成交原因(不改六态)")
@PostMapping("/ticket/patchLostReason")
public Result<Map<String, Object>> patchLostReason(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
return Result.OK(workOrderService.patchLostReason(
firstStr(body, "workOrderNo", "work_order_no"),
firstStr(body, "lostReason", "lost_reason"),
"agent"));
}
@ApiOperation("认领到期协商再问(Worker HTTP 槽)")
@PostMapping("/ticket/claimDueNegotiate")
public Result<Map<String, Object>> claimDueNegotiate(HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
Map<String, Object> out = new java.util.LinkedHashMap<>();
out.put("ok", true);
out.put("tickets", workOrderService.claimDueNegotiate());
return Result.OK(out);
}
@ApiOperation("智能体续办读单(只读,校验发起人)")
@PostMapping("/ticket/get")
public Result<Map<String, Object>> get(@RequestBody Map<String, Object> body,
@@ -30,6 +30,7 @@ public final class TicketStatuses {
/**
* from -> 允许的 to 集合。
* 终态(已成交/未成交/已关闭)默认不可再流转,除非显式「关闭」从非终态进入已关闭。
* 协商中允许再进协商中:只顺延再问,不重开询价。
*/
private static final Map<String, Set<String>> ALLOWED;
@@ -37,7 +38,8 @@ public final class TicketStatuses {
Map<String, Set<String>> m = new HashMap<>();
m.put(INQUIRING, setOf(QUOTED, NEGOTIATING, CLOSED));
m.put(QUOTED, setOf(NEGOTIATING, DEAL, LOST, CLOSED));
m.put(NEGOTIATING, setOf(QUOTED, DEAL, LOST, CLOSED));
// 协商中可再选协商中:顺延下一轮再问,次数不限。
m.put(NEGOTIATING, setOf(QUOTED, DEAL, LOST, CLOSED, NEGOTIATING));
m.put(DEAL, Collections.emptySet());
m.put(LOST, Collections.emptySet());
m.put(CLOSED, Collections.emptySet());
@@ -20,4 +20,13 @@ public class TicketTransitionRequest {
@ApiModelProperty("备注/关单原因")
private String remark;
@ApiModelProperty("未成交原因(仅未成交)")
private String lostReason;
@ApiModelProperty("协商中下次再问时间")
private String nextFollowupAt;
@ApiModelProperty("成交通知结果摘要")
private String dealNotifySummary;
}
@@ -115,6 +115,28 @@ public class InquiryTicket implements Serializable {
@ApiModelProperty("关单原因")
private String closeReason;
/**
* 未成交原因:销售下一句任意文字;没回则空着。
* 不是 closeReason,后台要单独展示。
*/
@ApiModelProperty("未成交原因")
private String lostReason;
/**
* 协商中下次再问时刻(第四个工作日 09:00)。
* 中途改成交/未成交或再问发出后清空。
*/
@ApiModelProperty("协商中下次再问时间")
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date nextFollowupAt;
/**
* 成交通知结果:已推哪些负责人 / 无匹配负责人 / 通知未发出。
*/
@ApiModelProperty("成交通知结果")
private String dealNotifySummary;
@ApiModelProperty("业务版本")
private Integer version;
@@ -109,4 +109,15 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
* 空运群激活/切换当前工单。一单一群,未结案才能绑。不改六态。
*/
Map<String, Object> activateAirGroup(Map<String, Object> body);
/**
* 未成交后补原因。已成交/其它六态拒绝。不改六态。
*/
Map<String, Object> patchLostReason(String workOrderNo, String lostReason, String operator);
/**
* 认领已到期的协商中再问:清空 nextFollowupAt,避免 Worker 重复发。
* 只改到期行,不加全局锁。
*/
java.util.List<java.util.Map<String, Object>> claimDueNegotiate();
}
@@ -846,6 +846,13 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
String eta = first(q, "eta", "transit_days", "transitDays");
if (StringUtils.isNotBlank(eta) && !"-".equals(eta)) {
out.put("transit_days", eta);
out.put("transit_time", eta);
}
// 空运计费重:TMS chargeableWeightKg → 模板 chargeable_weight_kg
String chargeable = first(q, "chargeableWeightKg", "chargeable_weight_kg", "chargeable_weight");
if (StringUtils.isNotBlank(chargeable) && !"-".equals(chargeable)) {
out.put("chargeable_weight_kg", chargeable);
out.put("chargeableWeightKg", chargeable);
}
for (Map.Entry<String, String> e : QuoteTemplateExcelSupport.LAND_TMS_KEY_TO_CODE.entrySet()) {
String val = first(q, e.getKey(), e.getValue());
@@ -892,9 +899,102 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
if (!rows.isEmpty()) {
out.put("fee_rows", rows);
}
// 海运分区:透传 pol/ocean/pod 桶,供 feeRegions 按段回填
for (String bucket : new String[]{"polCostItems", "oceanCostItems", "podCostItems"}) {
JSONArray part = QuoteTemplateExcelSupport.parseFeeRows(q.get("fee_rows_" + bucket));
if (part.isEmpty()) {
part = QuoteTemplateExcelSupport.parseFeeRows(q.get(bucket));
}
if (!part.isEmpty()) {
out.put("fee_rows_" + bucket, part);
out.put(bucket, part);
}
}
// 陆运线路:透传 landSegments / route_rows,供 routeRegions 填项目一
JSONArray landSegs = QuoteTemplateExcelSupport.parseFeeRows(q.get("route_rows"));
if (landSegs.isEmpty()) {
landSegs = QuoteTemplateExcelSupport.parseFeeRows(q.get("landSegments"));
}
if (!landSegs.isEmpty()) {
out.put("landSegments", landSegs);
out.put("route_rows", landSegs);
// 首段时效若 facts 尚无,用腿上 transit
if (StringUtils.isBlank(out.getString("transit_time")) || "-".equals(out.getString("transit_time"))) {
JSONObject firstSeg = landSegs.getJSONObject(0);
if (firstSeg != null) {
String t = firstNonBlankLocal(firstSeg.getString("transit_time"), firstSeg.getString("transitDays"));
if (StringUtils.isNotBlank(t)) {
out.put("transit_time", t);
out.put("transit_days", t);
}
}
}
} else if (looksLandInquiry(src)) {
// 无 landSegments 时:用询价 OD + fee_lines 分项拼一行,尽量填「提货费/运输费/送货费」列
JSONObject one = new JSONObject(true);
one.put("origin", out.getString("origin"));
one.put("destination", out.getString("destination"));
one.put("transit_time", out.getString("transit_time"));
one.put("transitDays", out.getString("transit_days"));
one.put("currency", out.getString("currency"));
one.put("pickup_fee", out.getString("pickup_fee"));
one.put("delivery_fee", out.getString("delivery_fee"));
one.put("price_per_cbm", out.getString("price_per_cbm"));
one.put("price_per_kg", out.getString("price_per_kg"));
String landAmt = firstNonBlankLocal(out.getString("land_freight"), out.getString("price_per_cbm"));
for (int i = 0; i < rows.size(); i++) {
JSONObject fee = rows.getJSONObject(i);
if (fee == null) {
continue;
}
String fname = StringUtils.trimToEmpty(fee.getString("name"));
String famt = StringUtils.trimToEmpty(fee.getString("amount"));
if (StringUtils.isBlank(famt) || "-".equals(famt)) {
continue;
}
if (fname.contains("提货") && StringUtils.isBlank(one.getString("pickup_fee"))) {
one.put("pickup_fee", famt);
} else if ((fname.contains("送货") || fname.contains("派送")) && StringUtils.isBlank(one.getString("delivery_fee"))) {
one.put("delivery_fee", famt);
} else if (fname.contains("CBM") && StringUtils.isBlank(one.getString("price_per_cbm"))) {
one.put("price_per_cbm", famt);
} else if ((fname.contains("KG") || fname.contains("kg") || fname.contains("公斤"))
&& StringUtils.isBlank(one.getString("price_per_kg"))) {
one.put("price_per_kg", famt);
} else if (fname.contains("运输") || fname.contains("陆运") || fname.contains("运费")) {
landAmt = famt;
one.put("salePrice", famt);
one.put("amount", famt);
if (StringUtils.isNotBlank(fee.getString("remark"))) {
one.put("remark", fee.getString("remark"));
}
}
}
if (StringUtils.isNotBlank(landAmt) && StringUtils.isBlank(one.getString("amount"))) {
one.put("amount", landAmt);
one.put("salePrice", landAmt);
}
if (StringUtils.isNotBlank(one.getString("origin")) || StringUtils.isNotBlank(one.getString("destination"))) {
JSONArray routeRows = new JSONArray();
routeRows.add(one);
out.put("route_rows", routeRows);
}
}
return out;
}
private static String firstNonBlankLocal(String... vals) {
if (vals == null) {
return "";
}
for (String v : vals) {
if (StringUtils.isNotBlank(v) && !"-".equals(v.trim())) {
return v.trim();
}
}
return "";
}
/**
* 询价事实里已有陆运选项/始发站,运费兜底名就不能再写成空运费。
*/
@@ -1013,7 +1113,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
/**
* 智能体回写 AI 映射。校验白名单与 feeRegion 增行配置;未映射必填 → mapping_incomplete。
* <p>
* mergeExisting=true 时:与库中旧 mapping 补缺合并(旧字段/旧区域优先,新结果只补没有的)。
* mergeExisting=true 时:与库中旧 mapping 智能合并(对齐标签的旧坐标保留,对不上的改用新坐标)。
*/
@Override
@Transactional(rollbackFor = Exception.class)
@@ -1054,13 +1154,34 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
versionMapper.updateById(ver);
return;
}
// 未换文件重新解析:旧映射优先,避免已对上的字段被新一轮 AI 冲掉
// 未换文件重新解析:仍对齐的旧坐标保留;对不上标签的改用新坐标
if (mergeExisting && StringUtils.isNotBlank(ver.getMappingJson())) {
try {
JSONObject old = JSON.parseObject(ver.getMappingJson());
if (old != null) {
mapping = mergeMappingPreferExisting(old, mapping);
log.info("模板映射补缺合并 versionId={} keepFields={}",
org.apache.poi.ss.usermodel.Workbook wb = null;
try {
if (StringUtils.isNotBlank(ver.getObjectKey())) {
Path local = resolveObjectPath(ver.getObjectKey());
try (InputStream in = Files.newInputStream(local)) {
wb = org.apache.poi.ss.usermodel.WorkbookFactory.create(in);
}
}
} catch (Exception ex) {
log.warn("合并校验打开模板失败 versionId={} err={}", versionId, ex.getMessage());
}
try {
mapping = mergeMappingPreferExisting(old, mapping, wb);
} finally {
if (wb != null) {
try {
wb.close();
} catch (Exception ignored) {
// ignore
}
}
}
log.info("模板映射智能合并 versionId={} oldFields={}",
versionId,
old.getJSONObject("fields") == null ? 0 : old.getJSONObject("fields").size());
}
@@ -1088,7 +1209,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
} else if (mergeExisting && !"failed".equals(ver.getParseStatus())) {
// 合并后重算摘要点数,避免沿用 AI 单轮字数
int pts = ver.getMappingPointCount() == null ? 0 : ver.getMappingPointCount();
String base = "已解析 " + pts + " 个映射点(保留原映射并补缺)";
String base = "已解析 " + pts + " 个映射点(对齐的保留,对不上的改用新坐标)";
if (StringUtils.isNotBlank(parseMessage) && parseMessage.contains("未解析到")) {
int idx = parseMessage.indexOf(";未解析到");
if (idx > 0) {
@@ -1103,10 +1224,23 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
/**
* 补缺合并:旧 fields / feeRegions / routeRegions 优先;新结果只补充旧里没有的 code/id。
* 不删旧项;mappingGaps 以新结果为准(智能体已按合并后复评时更准,此处兜底保留新 gaps)。
* 重新解析合并(无 Workbook 时退化为旧坐标优先,兼容单测)。
*/
static JSONObject mergeMappingPreferExisting(JSONObject oldMapping, JSONObject newMapping) {
public static JSONObject mergeMappingPreferExisting(JSONObject oldMapping, JSONObject newMapping) {
return mergeMappingPreferExisting(oldMapping, newMapping, null);
}
/**
* 重新解析合并:费用/线路区仍按 id 旧优先;fields 在对照模板后智能取舍。
* <p>
* 规则:旧无→用新;新无→留旧;两者相同→留旧;旧坐标仍贴标签→留旧;
* 旧坐标对不上标签(有 Workbook 可校验)→改用新坐标。
* mappingGaps 以新结果为准。
*
* @param workbook 可空;有则校验旧坐标是否仍对齐标签
*/
public static JSONObject mergeMappingPreferExisting(JSONObject oldMapping, JSONObject newMapping,
org.apache.poi.ss.usermodel.Workbook workbook) {
JSONObject out = new JSONObject(true);
if (newMapping != null) {
out.putAll(newMapping);
@@ -1114,29 +1248,99 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
if (oldMapping == null) {
return out;
}
// fields:先放新的,再用旧的覆盖同名 → 旧优先
JSONObject fields = new JSONObject(true);
JSONObject newFields = newMapping == null ? null : newMapping.getJSONObject("fields");
JSONObject oldFields = oldMapping.getJSONObject("fields");
JSONObject fields = new JSONObject(true);
if (newFields != null) {
fields.putAll(newFields);
}
JSONObject newMeta = newMapping == null ? null : newMapping.getJSONObject("fieldMeta");
JSONObject oldMeta = oldMapping.getJSONObject("fieldMeta");
JSONObject metaOut = new JSONObject(true);
if (newMeta != null) {
metaOut.putAll(newMeta);
}
Set<String> codes = new LinkedHashSet<>();
if (oldFields != null) {
for (String code : oldFields.keySet()) {
Object cell = oldFields.get(code);
if (cell != null && StringUtils.isNotBlank(String.valueOf(cell))) {
fields.put(code, cell);
codes.addAll(oldFields.keySet());
}
if (newFields != null) {
codes.addAll(newFields.keySet());
}
for (String code : codes) {
String oldCell = oldFields == null ? null : StringUtils.trimToNull(oldFields.getString(code));
String newCell = newFields == null ? null : StringUtils.trimToNull(newFields.getString(code));
String chosen;
boolean keptOld;
if (StringUtils.isBlank(oldCell)) {
chosen = newCell;
keptOld = false;
} else if (StringUtils.isBlank(newCell)) {
chosen = oldCell;
keptOld = true;
} else if (oldCell.equalsIgnoreCase(newCell)) {
chosen = oldCell;
keptOld = true;
// 新旧都是 B3 单选时仍要纠到 RFQ 标签格
if ("quote_no".equals(code) && workbook != null
&& !QuoteTemplateExcelSupport.fieldMappingStillAligned(workbook, code, oldCell)) {
String better = QuoteTemplateExcelSupport.relocateQuoteNoCell(workbook, oldCell);
if (StringUtils.isNotBlank(better) && !better.equalsIgnoreCase(oldCell)) {
chosen = better;
keptOld = false;
JSONObject qm = metaOut.getJSONObject("quote_no");
if (qm == null) {
qm = new JSONObject(true);
}
qm.put("sameCellAfterColon", true);
metaOut.put("quote_no", qm);
log.info("映射坐标纠偏 code={} old={} -> new={}", code, oldCell, chosen);
}
}
} else if (workbook != null
&& QuoteTemplateExcelSupport.fieldMappingStillAligned(workbook, code, oldCell)) {
chosen = oldCell;
keptOld = true;
} else if (workbook != null) {
// 旧坐标对不上标签 → 用新;quote_no 若仍落在单选格则再纠到标签格冒号后
chosen = newCell;
keptOld = false;
if ("quote_no".equals(code)) {
String better = QuoteTemplateExcelSupport.relocateQuoteNoCell(workbook, newCell);
if (StringUtils.isNotBlank(better)) {
chosen = better;
}
// 纠到标签格后,出单必须同格冒号后写
JSONObject qm = metaOut.getJSONObject("quote_no");
if (qm == null) {
qm = new JSONObject(true);
}
qm.put("sameCellAfterColon", true);
metaOut.put("quote_no", qm);
}
log.info("映射坐标纠偏 code={} old={} -> new={}", code, oldCell, chosen);
} else {
// 无表可校验时保守保留旧坐标
chosen = oldCell;
keptOld = true;
}
if (StringUtils.isNotBlank(chosen)) {
fields.put(code, chosen);
}
if (keptOld && oldMeta != null && oldMeta.getJSONObject(code) != null) {
metaOut.put(code, oldMeta.getJSONObject(code));
}
}
out.put("fields", fields);
if (!metaOut.isEmpty()) {
out.put("fieldMeta", metaOut);
}
out.put("feeRegions", mergeRegionsById(
oldMapping.getJSONArray("feeRegions"),
newMapping == null ? null : newMapping.getJSONArray("feeRegions")));
out.put("routeRegions", mergeRegionsById(
oldMapping.getJSONArray("routeRegions"),
newMapping == null ? null : newMapping.getJSONArray("routeRegions")));
// 说明性标签:齐全时清空;否则保留新的
if (out.getJSONArray("mappingGaps") != null && out.getJSONArray("mappingGaps").isEmpty()) {
out.put("unmappedRequired", new JSONArray());
out.put("unmappedLabels", new JSONArray());
@@ -174,6 +174,9 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl<InquiryStaffRoleMap
one.put("wecomId", row.getWecomId());
one.put("roleCode", row.getRoleCode());
one.put("status", row.getStatus());
// 成交跟进按「是负责人 + 负责线路」匹配,缺这两项智能体对不上人。
one.put("isLeader", row.getIsLeader());
one.put("routes", StringUtils.defaultString(row.getRoutes()));
out.add(one);
}
return out;
@@ -209,11 +209,27 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
if (TicketStatuses.CLOSED.equals(to) && StringUtils.isNotBlank(req.getRemark())) {
ticket.setCloseReason(req.getRemark());
}
if (TicketStatuses.LOST.equals(to)) {
ticket.setNextFollowupAt(null);
if (StringUtils.isNotBlank(req.getLostReason())) {
ticket.setLostReason(StringUtils.left(req.getLostReason().trim(), 500));
}
} else if (TicketStatuses.DEAL.equals(to)) {
ticket.setNextFollowupAt(null);
if (StringUtils.isNotBlank(req.getDealNotifySummary())) {
ticket.setDealNotifySummary(StringUtils.left(req.getDealNotifySummary().trim(), 500));
}
} else if (TicketStatuses.NEGOTIATING.equals(to)) {
Date follow = parseDateTime(req.getNextFollowupAt());
ticket.setNextFollowupAt(follow);
}
ticket.setVersion(ticket.getVersion() == null ? 2 : ticket.getVersion() + 1);
ticket.setUpdateBy(operator);
ticket.setUpdateTime(new Date());
updateById(ticket);
appendLog(ticket, from, to, "transition", operator, req.getRemark());
String remark = StringUtils.defaultIfBlank(req.getRemark(),
StringUtils.defaultIfBlank(req.getDealNotifySummary(), req.getLostReason()));
appendLog(ticket, from, to, "transition", operator, remark);
return ticket;
}
@@ -569,9 +585,13 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
}
String status = StringUtils.defaultString(ticket.getStatus());
String waitPhase;
if (TicketStatuses.DEAL.equals(status) || TicketStatuses.LOST.equals(status)
|| TicketStatuses.NEGOTIATING.equals(status) || TicketStatuses.CLOSED.equals(status)) {
if (TicketStatuses.DEAL.equals(status) || TicketStatuses.CLOSED.equals(status)) {
waitPhase = "done";
} else if (TicketStatuses.LOST.equals(status)) {
waitPhase = StringUtils.isBlank(ticket.getLostReason()) ? "wait_lost_reason" : "done";
} else if (TicketStatuses.NEGOTIATING.equals(status)) {
// 协商中会话不能结束:还要再问成交,也可中途改成交/未成交。
waitPhase = "wait_deal";
} else if (TicketStatuses.QUOTED.equals(status) && !quote.isEmpty()) {
// 文件已发出:成交跟进。六态仍是已报价,不能退回协同/出方案卡
if (quoteFileAlreadyIssued(no, quote)) {
@@ -592,6 +612,12 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
out.put("facts", facts);
out.put("quote", quote);
out.put("wait_phase", waitPhase);
out.put("lost_reason", StringUtils.defaultString(ticket.getLostReason()));
out.put("lostReason", StringUtils.defaultString(ticket.getLostReason()));
out.put("deal_notify_summary", StringUtils.defaultString(ticket.getDealNotifySummary()));
if (ticket.getNextFollowupAt() != null) {
out.put("next_followup_at", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(ticket.getNextFollowupAt()));
}
out.put("collab_chat_id", StringUtils.defaultString(ticket.getCollabChatId()));
Map<String, String> collabFacts = new LinkedHashMap<>();
if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) {
@@ -773,6 +799,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
out.put("business_line", StringUtils.defaultString(ticket.getBusinessLine()));
out.put("facts", parseFactsJson(ticket.getFactsJson()));
out.put("quote", latestQuotePayload(ticket.getWorkOrderNo()));
out.put("lost_reason", StringUtils.defaultString(ticket.getLostReason()));
out.put("lostReason", StringUtils.defaultString(ticket.getLostReason()));
return out;
}
@@ -1027,6 +1055,130 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
.last("LIMIT 1"), false);
}
/**
* 未成交后补原因。不改六态;已有原因可覆盖。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> patchLostReason(String workOrderNo, String lostReason, String operator) {
Map<String, Object> out = new LinkedHashMap<>();
String no = StringUtils.trimToEmpty(workOrderNo);
String reason = StringUtils.left(StringUtils.trimToEmpty(lostReason), 500);
out.put("ok", false);
if (StringUtils.isBlank(no) || StringUtils.isBlank(reason)) {
out.put("error", "workOrderNo_or_reason_blank");
return out;
}
InquiryTicket ticket = findByWorkOrderNo(no);
if (ticket == null) {
out.put("error", "ticket_not_found");
return out;
}
if (!TicketStatuses.LOST.equals(ticket.getStatus())) {
out.put("error", "not_lost");
out.put("status", ticket.getStatus());
return out;
}
ticket.setLostReason(reason);
ticket.setUpdateBy(StringUtils.defaultIfBlank(operator, "agent"));
ticket.setUpdateTime(new Date());
updateById(ticket);
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "lost_reason", operator, reason);
out.put("ok", true);
out.put("work_order_no", no);
out.put("lost_reason", reason);
return out;
}
/**
* 认领到期协商再问:状态仍是协商中且 next_followup_at 已到。
* 清空再问时刻,Worker 只发这一轮;销售再选协商中才会排下一轮。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public List<Map<String, Object>> claimDueNegotiate() {
Date now = new Date();
List<InquiryTicket> due = list(new LambdaQueryWrapper<InquiryTicket>()
.eq(InquiryTicket::getStatus, TicketStatuses.NEGOTIATING)
.isNotNull(InquiryTicket::getNextFollowupAt)
.le(InquiryTicket::getNextFollowupAt, now)
.last("LIMIT 50"));
List<Map<String, Object>> out = new ArrayList<>();
if (due == null || due.isEmpty()) {
return out;
}
for (InquiryTicket ticket : due) {
ticket.setNextFollowupAt(null);
ticket.setUpdateBy("agent");
ticket.setUpdateTime(now);
updateById(ticket);
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "negotiate_followup", "agent", "协商再问已发出");
out.add(toDueNegotiateRow(ticket));
}
return out;
}
/**
* Worker 再问需要的最小快照:会话通道、字段、报价。
*/
private Map<String, Object> toDueNegotiateRow(InquiryTicket ticket) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("work_order_no", ticket.getWorkOrderNo());
row.put("workOrderNo", ticket.getWorkOrderNo());
row.put("sales_wecom_id", StringUtils.defaultString(ticket.getSalesWecomId()));
row.put("salesWecomId", StringUtils.defaultString(ticket.getSalesWecomId()));
row.put("sales_name", StringUtils.defaultString(ticket.getSalesName()));
row.put("business_line", StringUtils.defaultString(ticket.getBusinessLine()));
row.put("collab_chat_id", StringUtils.defaultString(ticket.getCollabChatId()));
row.put("status", ticket.getStatus());
Map<String, String> facts = new LinkedHashMap<>();
if (StringUtils.isNotBlank(ticket.getFactsJson())) {
JSONObject stored = JSON.parseObject(ticket.getFactsJson());
if (stored != null) {
for (String key : stored.keySet()) {
if (stored.get(key) != null) {
facts.put(key, String.valueOf(stored.get(key)));
}
}
}
}
if (StringUtils.isNotBlank(ticket.getOrigin())) {
facts.putIfAbsent("起运港", ticket.getOrigin());
}
if (StringUtils.isNotBlank(ticket.getDestination())) {
facts.putIfAbsent("目的港", ticket.getDestination());
}
row.put("facts", facts);
Map<String, Object> quote = new LinkedHashMap<>();
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
.eq(InquiryQuote::getWorkOrderNo, ticket.getWorkOrderNo())
.orderByDesc(InquiryQuote::getCreateTime)
.last("LIMIT 1"));
if (quotes != null && !quotes.isEmpty() && StringUtils.isNotBlank(quotes.get(0).getPayloadJson())) {
JSONObject parsed = JSON.parseObject(quotes.get(0).getPayloadJson());
if (parsed != null) {
quote.putAll(parsed);
}
}
row.put("quote", quote);
return row;
}
private static Date parseDateTime(String raw) {
if (StringUtils.isBlank(raw)) {
return null;
}
String text = raw.trim().replace("T", " ");
if (text.length() >= 19) {
text = text.substring(0, 19);
}
try {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(text);
} catch (Exception e) {
return parseDate(raw);
}
}
private void appendLog(InquiryTicket ticket, String from, String to, String action, String operator, String remark) {
InquiryTicketStatusLog log = new InquiryTicketStatusLog();
log.setTicketId(ticket.getId());
@@ -12,7 +12,7 @@ import java.util.Map;
* 陆运拉群人员匹配:只看员工管理里岗位=产品·陆运且启用的人。
* <p>
* 用本单线路类别对负责线路:按逗号/顿号拆整词,禁止把「中港/中亚/中欧」按斜杠切开。
* 没有「陆运全线」快捷项;四条都要跟就须后台四条都勾。
* 负责线路含「陆运全线」时视为命中全部线路类别(与海运「海运全线」对称)。
* 禁止:按销售 userid 伪造名单;不查企微通讯录。
*/
public final class LandStaffMatcher {
@@ -47,7 +47,9 @@ public final class LandStaffMatcher {
if (StringUtils.isBlank(row.getWecomId())) {
continue;
}
if (!routeTokens(row.getRoutes()).contains(want)) {
List<String> tokens = routeTokens(row.getRoutes());
// 陆运全线:跟海运全线一样,一条勾选覆盖全部线路类别
if (!tokens.contains("陆运全线") && !tokens.contains(want)) {
continue;
}
Map<String, Object> item = new LinkedHashMap<>();
@@ -300,6 +300,8 @@ public class InquiryTmsQueryService {
quote.put("source", "TMS");
quote.put("source_label", "TMS 标准报价");
quote.put("fee_lines", buildFeeLinesJson(data, freightMoney.amount, otherMoney.amount, headerCurrency));
// 海运 feeItems 是分段桶:polCostItems / oceanCostItems / podCostItems,单独透出给模板分区回填
putSeaFeeBuckets(quote, data);
// 锁舱必须带着 TMS 报价编号和舱位选项,不能只落金额。
// 真实 TMS 的 quoteId 形如 AIR-数字-数字,optionNo 是 AIR-OPT01(中间没有第二根横杠)。
String quoteId = firstText(data.get("quoteId"), data.get("quote_id"),
@@ -318,6 +320,12 @@ public class InquiryTmsQueryService {
? String.valueOf(airOptions)
: JSON.toJSONString(airOptions));
}
// 陆运线路段:与空运 airOptions 同级透出,供模板 routeRegions 填项目一
putLandSegments(quote, data, tms);
String isMulti = firstText(data.get("isMulti"), data.get("is_multi"));
if (StringUtils.isNotBlank(isMulti)) {
quote.put("isMulti", isMulti);
}
String chargeable = firstText(data.get("chargeableWeightKg"), data.get("chargeable_weight_kg"));
if (StringUtils.isNotBlank(chargeable)) {
quote.put("chargeableWeightKg", chargeable);
@@ -325,6 +333,127 @@ public class InquiryTmsQueryService {
return quote;
}
/**
* 陆运 landSegments 原样+规范化写入 quote。
* <p>
* 为何:模板 routeRegions 的 tmsSource=landSegments;若不落库/不入 quote,
* 出单只能写出始发目的时效,车型价/分腿价全空。
*/
@SuppressWarnings("unchecked")
private static void putLandSegments(Map<String, String> quote, Map<String, Object> data, Map<String, Object> tms) {
if (quote == null) {
return;
}
Object raw = first(
data == null ? null : data.get("landSegments"),
data == null ? null : data.get("land_segments"),
tms == null ? null : tms.get("landSegments"),
tms == null ? null : tms.get("land_segments"));
if (raw == null) {
return;
}
String json = raw instanceof String ? String.valueOf(raw) : JSON.toJSONString(raw);
if (StringUtils.isBlank(json) || "null".equalsIgnoreCase(json) || "[]".equals(json.trim())) {
return;
}
quote.put("landSegments", json);
// 同步一份 route_rows,字段名对齐填表(transit_time / salePrice 等)
List<Map<String, String>> rows = normalizeLandSegmentRows(raw);
if (!rows.isEmpty()) {
quote.put("route_rows", JSON.toJSONString(rows));
}
// feeItems 为空时,用各腿售价补一条运输费,避免报价明细全空
String feeLines = quote.get("fee_lines");
if ((StringUtils.isBlank(feeLines) || "[]".equals(feeLines.trim())) && !rows.isEmpty()) {
List<Map<String, String>> lines = new ArrayList<>();
for (Map<String, String> seg : rows) {
String amount = firstText(seg.get("salePrice"), seg.get("costPrice"), seg.get("amount"));
if (StringUtils.isBlank(amount) || "-".equals(amount)) {
continue;
}
Map<String, String> line = new LinkedHashMap<>();
line.put("name", "运输费");
line.put("amount", amount);
if (StringUtils.isNotBlank(seg.get("currency"))) {
line.put("currency", seg.get("currency"));
}
if (StringUtils.isNotBlank(seg.get("remark"))) {
line.put("remark", seg.get("remark"));
}
lines.add(line);
}
if (!lines.isEmpty()) {
quote.put("fee_lines", JSON.toJSONString(lines));
}
}
}
/**
* landSegments[] → 填表行:统一 origin/destination/salePrice/transit_time/vehicleType。
*/
@SuppressWarnings("unchecked")
static List<Map<String, String>> normalizeLandSegmentRows(Object raw) {
List<Map<String, String>> out = new ArrayList<>();
List<?> list;
if (raw instanceof List) {
list = (List<?>) raw;
} else if (raw instanceof String) {
try {
Object parsed = JSON.parse(String.valueOf(raw));
if (!(parsed instanceof List)) {
return out;
}
list = (List<?>) parsed;
} catch (Exception e) {
return out;
}
} else {
return out;
}
for (Object item : list) {
if (!(item instanceof Map)) {
continue;
}
Map<?, ?> seg = (Map<?, ?>) item;
Map<String, String> row = new LinkedHashMap<>();
putExtra(row, "segmentSequence", firstText(seg.get("segmentSequence"), seg.get("sequence")));
putExtra(row, "routeLegSequence", firstText(seg.get("routeLegSequence"), seg.get("legSequence")));
putExtra(row, "origin", firstText(seg.get("origin"), seg.get("originName"), seg.get("originCode")));
putExtra(row, "originCode", firstText(seg.get("originCode")));
putExtra(row, "destination", firstText(seg.get("destination"), seg.get("destinationName"), seg.get("destinationCode")));
putExtra(row, "destinationCode", firstText(seg.get("destinationCode")));
putExtra(row, "vehicleType", firstText(seg.get("vehicleType"), seg.get("vehicle_type"), seg.get("truckType")));
putExtra(row, "landTransportType", firstText(seg.get("landTransportType")));
putExtra(row, "routeCategory", firstText(seg.get("routeCategory")));
putExtra(row, "currency", firstText(seg.get("currency"), seg.get("settlementCurrency")));
String sale = firstText(seg.get("salePrice"), seg.get("sale_price"));
String cost = firstText(seg.get("costPrice"), seg.get("cost_price"));
putExtra(row, "salePrice", sale);
putExtra(row, "costPrice", cost);
putExtra(row, "amount", firstText(sale, cost));
String transit = firstText(seg.get("transitDays"), seg.get("transit_time"), seg.get("transitTime"),
seg.get("aging"));
putExtra(row, "transitDays", transit);
putExtra(row, "transit_time", transit);
putExtra(row, "remark", firstText(seg.get("remark"), seg.get("note"), seg.get("description")));
// 腿上若带分项费率扩展字段,原样带上供「提货费/元/CBM」列匹配
String[][] rateKeys = {
{"pickupFee", "pickup_fee"}, {"deliveryFee", "delivery_fee"},
{"pricePerCbm", "price_per_cbm"}, {"pricePerKg", "price_per_kg"},
{"minCharge", "min_charge"}, {"landFreight", "land_freight"}
};
for (String[] pair : rateKeys) {
putExtra(row, pair[1], firstText(seg.get(pair[0]), seg.get(pair[1])));
putExtra(row, pair[0], firstText(seg.get(pair[0])));
}
if (StringUtils.isNotBlank(row.get("origin")) || StringUtils.isNotBlank(row.get("destination"))
|| StringUtils.isNotBlank(row.get("amount"))) {
out.add(row);
}
}
return out;
}
/**
* 把本次 TMS 报价写回工单主表,供后台列表/详情展示。
* 没有的成本/毛利率不编;售价用销售合计。
@@ -351,6 +480,8 @@ public class InquiryTmsQueryService {
/**
* 抽出销售侧价格明细行。优先 TMS 费用清单,没有再用常见费用字段兜底。
* 每行带自己的币种;没有就继承表头,仍然不编造。
* 海运 feeItems 常是 [{polCostItems:[...]},{oceanCostItems:[...]},{podCostItems:[...]}],
* 需展开各桶再扁平,否则顶层无 feeName 会抽成空列表。
*/
@SuppressWarnings("unchecked")
private static String buildFeeLinesJson(Map<String, Object> data, String freight, String other, String headerCurrency) {
@@ -362,14 +493,22 @@ public class InquiryTmsQueryService {
continue;
}
Map<?, ?> row = (Map<?, ?>) item;
String name = firstText(row.get("feeName"), row.get("name"), row.get("chargeName"),
row.get("itemName"), row.get("feeType"));
String amount = firstText(row.get("salePrice"), row.get("amount"), row.get("price"),
row.get("feeAmount"), row.get("totalPrice"));
String lineCurrency = TmsQuoteCurrency.fromFeeRow(row);
addFeeLine(lines, name, amount,
StringUtils.isNotBlank(lineCurrency) ? lineCurrency : headerCurrency,
extrasFromFee(row));
// 海运分段桶:展开子列表
boolean nested = false;
for (String bucket : new String[]{"polCostItems", "oceanCostItems", "podCostItems"}) {
Object sub = row.get(bucket);
if (sub instanceof List) {
nested = true;
for (Object one : (List<?>) sub) {
if (one instanceof Map) {
appendFeeLineFromRow(lines, (Map<?, ?>) one, headerCurrency);
}
}
}
}
if (!nested) {
appendFeeLineFromRow(lines, row, headerCurrency);
}
}
}
if (lines.isEmpty()) {
@@ -384,6 +523,60 @@ public class InquiryTmsQueryService {
return JSON.toJSONString(lines);
}
/**
* 海运三段费用桶单独落 quote,供模板 feeRegions 按 tmsSource 分区写,禁止三段共用扁平 fee_lines。
*/
@SuppressWarnings("unchecked")
private static void putSeaFeeBuckets(Map<String, String> quote, Map<String, Object> data) {
if (quote == null || data == null) {
return;
}
Object raw = first(data.get("feeItems"), data.get("feeList"));
if (!(raw instanceof List)) {
return;
}
Map<String, List<Map<String, String>>> buckets = new LinkedHashMap<>();
buckets.put("polCostItems", new ArrayList<>());
buckets.put("oceanCostItems", new ArrayList<>());
buckets.put("podCostItems", new ArrayList<>());
String headerCurrency = TmsQuoteCurrency.fromHeader(data);
for (Object item : (List<?>) raw) {
if (!(item instanceof Map)) {
continue;
}
Map<?, ?> row = (Map<?, ?>) item;
for (String bucket : buckets.keySet()) {
Object sub = row.get(bucket);
if (!(sub instanceof List)) {
continue;
}
for (Object one : (List<?>) sub) {
if (one instanceof Map) {
appendFeeLineFromRow(buckets.get(bucket), (Map<?, ?>) one, headerCurrency);
}
}
}
}
for (Map.Entry<String, List<Map<String, String>>> e : buckets.entrySet()) {
if (!e.getValue().isEmpty()) {
String json = JSON.toJSONString(e.getValue());
quote.put(e.getKey(), json);
quote.put("fee_rows_" + e.getKey(), json);
}
}
}
private static void appendFeeLineFromRow(List<Map<String, String>> lines, Map<?, ?> row, String headerCurrency) {
String name = firstText(row.get("feeName"), row.get("name"), row.get("chargeName"),
row.get("itemName"), row.get("feeType"));
String amount = firstText(row.get("salePrice"), row.get("amount"), row.get("price"),
row.get("feeAmount"), row.get("totalPrice"));
String lineCurrency = TmsQuoteCurrency.fromFeeRow(row);
addFeeLine(lines, name, amount,
StringUtils.isNotBlank(lineCurrency) ? lineCurrency : headerCurrency,
extrasFromFee(row));
}
/**
* 费用行上的单位 / 数量 / 单价 / 备注只取 TMS 原文,没有就空着。
*/
@@ -0,0 +1,14 @@
-- 成交跟进:未成交原因 / 协商再问时刻 / 成交通知摘要。
-- 仅改 inquiry_robot;幂等需手工确认列是否已存在。
-- 库:inquiry_robot
SET NAMES utf8mb4;
ALTER TABLE `inq_ticket`
ADD COLUMN `lost_reason` varchar(500) NULL COMMENT '未成交原因,不是关单原因' AFTER `close_reason`;
ALTER TABLE `inq_ticket`
ADD COLUMN `next_followup_at` datetime NULL COMMENT '协商中下次再问(工作日 09:00)' AFTER `lost_reason`;
ALTER TABLE `inq_ticket`
ADD COLUMN `deal_notify_summary` varchar(500) NULL COMMENT '成交通知:已推负责人/无匹配/未发出' AFTER `next_followup_at`;
@@ -0,0 +1,76 @@
-- 询价后台菜单按钮:让角色树展开「查看/导出」等明细(对齐旧运营后台)
-- 库:inquiry_robot;幂等。
-- 根因:子菜单 is_leaf=1 时 Jeecg queryTreeList 不递归 menu_type=2 按钮。
-- 1) 有子节点的菜单必须非叶子
UPDATE sys_permission
SET is_leaf = 0
WHERE id IN (
'b1000000000000000000000000000001',
'b1000000000000000000000000000002',
'b1000000000000000000000000000003',
'b1000000000000000000000000000004',
'b1000000000000000000000000000005',
'b1000000000000000000000000000006',
'b1000000000000000000000000000010',
'b1000000000000000000000000000011',
'b1000000000000000000000000000012',
'b1000000000000000000000000000013'
);
-- 2) 页面菜单不要挂 perms(权限码只在按钮上)
UPDATE sys_permission
SET perms = NULL
WHERE id IN (
'b1000000000000000000000000000002',
'b1000000000000000000000000000003',
'b1000000000000000000000000000004',
'b1000000000000000000000000000005',
'b1000000000000000000000000000006'
);
-- 3) 已有按钮改名为旧后台口径
UPDATE sys_permission SET name='查看' WHERE id='b1000000000000000000000000000021';
UPDATE sys_permission SET name='查看' WHERE id='b1000000000000000000000000000031';
UPDATE sys_permission SET name='关闭工单' WHERE id='b1000000000000000000000000000032';
UPDATE sys_permission SET name='查看' WHERE id='b1000000000000000000000000000041';
UPDATE sys_permission SET name='新增' WHERE id='b1000000000000000000000000000042';
UPDATE sys_permission SET name='编辑' WHERE id='b1000000000000000000000000000043';
UPDATE sys_permission SET name='停用' WHERE id='b1000000000000000000000000000044';
UPDATE sys_permission SET name='查看' WHERE id='b1000000000000000000000000000051';
UPDATE sys_permission SET name='新增' WHERE id='b1000000000000000000000000000052';
UPDATE sys_permission SET name='编辑' WHERE id='b1000000000000000000000000000053';
UPDATE sys_permission SET name='停用' WHERE id='b1000000000000000000000000000054';
UPDATE sys_permission SET name='查看' WHERE id='b1000000000000000000000000000061';
UPDATE sys_permission SET name='上传' WHERE id='b1000000000000000000000000000062';
UPDATE sys_permission SET name='编辑' WHERE id='b1000000000000000000000000000063';
UPDATE sys_permission SET name='启停' WHERE id='b1000000000000000000000000000064';
UPDATE sys_permission SET name='查看' WHERE id='b1000000000000000000000000000071';
UPDATE sys_permission SET name='编辑' WHERE id='b1000000000000000000000000000072';
-- 4) 补旧后台有、当前缺的按钮
INSERT IGNORE INTO sys_permission
(id,parent_id,name,is_route,menu_type,perms,perms_type,sort_no,always_show,is_leaf,keep_alive,hidden,hide_tab,create_by,create_time,del_flag,rule_flag,status,internal_or_external)
VALUES
-- 分析看板:导出 / 更新数据
('b1000000000000000000000000000022','b1000000000000000000000000000002','导出',0,2,'inquiry:dashboard:export','1',2,0,1,0,0,0,'admin',NOW(),0,0,'1',0),
('b1000000000000000000000000000023','b1000000000000000000000000000002','更新数据',0,2,'inquiry:dashboard:refresh','1',3,0,1,0,0,0,'admin',NOW(),0,0,'1',0),
-- 询价工单:导出 / 查看详情
('b1000000000000000000000000000033','b1000000000000000000000000000003','导出',0,2,'inquiry:workorder:export','1',2,0,1,0,0,0,'admin',NOW(),0,0,'1',0),
('b1000000000000000000000000000034','b1000000000000000000000000000003','查看详情',0,2,'inquiry:workorder:detail','1',3,0,1,0,0,0,'admin',NOW(),0,0,'1',0),
-- 账号管理
('b1000000000000000000000000000081','b1000000000000000000000000000011','查看',0,2,'inquiry:account:view','1',1,0,1,0,0,0,'admin',NOW(),0,0,'1',0),
('b1000000000000000000000000000082','b1000000000000000000000000000011','编辑',0,2,'inquiry:account:edit','1',2,0,1,0,0,0,'admin',NOW(),0,0,'1',0),
-- 角色管理
('b1000000000000000000000000000083','b1000000000000000000000000000012','查看',0,2,'inquiry:role:view','1',1,0,1,0,0,0,'admin',NOW(),0,0,'1',0),
('b1000000000000000000000000000084','b1000000000000000000000000000012','编辑',0,2,'inquiry:role:edit','1',2,0,1,0,0,0,'admin',NOW(),0,0,'1',0);
-- 关闭工单排序靠后(详情后)
UPDATE sys_permission SET sort_no = 4 WHERE id = 'b1000000000000000000000000000032';
-- 5) admin 角色补授权全部 b100 权限
INSERT IGNORE INTO sys_role_permission (id, role_id, permission_id)
SELECT MD5(CONCAT('ytd-inq-menu:', r.id, ':', p.id)), r.id, p.id
FROM sys_role r
JOIN sys_permission p ON p.id LIKE 'b100%' AND p.del_flag = 0
WHERE r.role_code = 'admin';
@@ -0,0 +1,27 @@
-- 账号「最后登录时间」:列表展示真实登录,不再误用 createTime。
-- 库:inquiry_robot;幂等。
-- 1) 列
SET @has_col := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'sys_user'
AND column_name = 'last_login_time'
);
SET @sql := IF(@has_col = 0,
'ALTER TABLE sys_user ADD COLUMN last_login_time datetime NULL DEFAULT NULL COMMENT ''最后登录时间'' AFTER update_time',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 2) 用登录日志回填(log_type=1);已有值不覆盖
UPDATE sys_user u
INNER JOIN (
SELECT COALESCE(NULLIF(userid, ''), username) AS uname, MAX(create_time) AS last_t
FROM sys_log
WHERE log_type = 1
AND (userid IS NOT NULL OR username IS NOT NULL)
GROUP BY COALESCE(NULLIF(userid, ''), username)
) x ON x.uname = u.username
SET u.last_login_time = x.last_t
WHERE u.last_login_time IS NULL
AND x.last_t IS NOT NULL;
@@ -14,6 +14,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
* 陆运拉群人员匹配:不连库。
* <p>
* 只认产品·陆运启用岗;负责线路按逗号整词对线路类别,禁止按斜杠切开「中港/中亚/中欧」。
* 「陆运全线」命中任意线路类别。
*/
public class LandStaffMatcherTest {
@@ -37,6 +38,14 @@ public class LandStaffMatcherTest {
assertEquals(0, LandStaffMatcher.match(Arrays.asList(row), "东南亚").size());
}
@Test
public void landAllRoutesHitsAnyCategory() {
InquiryStaffRole all = staff("land", "active", "z", "全线岗", "陆运全线");
assertEquals(1, LandStaffMatcher.match(Arrays.asList(all), "东南亚").size());
assertEquals(1, LandStaffMatcher.match(Arrays.asList(all), "中港/中亚/中欧").size());
assertEquals(1, LandStaffMatcher.match(Arrays.asList(all), "国内长途/零担").size());
}
@Test
public void ignoreSeaRole() {
InquiryStaffRole sea = staff("sea", "active", "s", "海", "东南亚");
@@ -26,6 +26,12 @@ public class SeaStaffMatcherTest {
assertEquals("p1", hits.get(0).get("wecomId"));
}
@Test
public void seaAllLineAliasPulls() {
InquiryStaffRole row = staff("sea", "active", "p1b", "全线产品", "海运全线");
assertEquals(1, SeaStaffMatcher.match(Arrays.asList(row), "上海", "洛杉矶").size());
}
@Test
public void specificPortPullsWhenMatch() {
InquiryStaffRole row = staff("sea", "active", "p2", "李产品", "上海,宁波");
@@ -132,7 +132,7 @@ public class QuoteTemplateFillTest {
}
@Test
public void seaFeeRemarkSplitsAndWidensColumn() throws Exception {
public void seaFeeRemarkStripsInternalMarkup() throws Exception {
Path src = Files.createTempFile("quote-remark-src", ".xlsx");
Path out = Files.createTempFile("quote-remark-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
@@ -156,13 +156,9 @@ public class QuoteTemplateFillTest {
try (Workbook wb = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = wb.getSheetAt(0);
org.apache.poi.ss.usermodel.Row row = sheet.getRow(18);
String remark = row.getCell(8).getStringCellValue();
assertTrue(remark.contains("计价单位:柜"));
assertTrue(remark.contains("加点系数:0.0000000"));
assertTrue(remark.contains("\n"));
assertTrue(row.getCell(8).getCellStyle().getWrapText());
assertTrue(sheet.getColumnWidth(8) >= 18 * 256);
assertTrue(row.getHeightInPoints() >= 30f);
String remark = row.getCell(8) == null ? "" : row.getCell(8).getStringCellValue();
assertEquals("", remark == null ? "" : remark.trim());
assertEquals(15.0, row.getCell(7).getNumericCellValue(), 0.01);
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
@@ -591,6 +587,176 @@ public class QuoteTemplateFillTest {
assertEquals(2, out.getJSONArray("fee_rows").size());
}
@Test
public void buildFillFactsProjectsLandRouteFromFeeLinesWhenNoSegments() {
Map<String, Object> facts = new HashMap<>();
facts.put("起运港", "泉州市");
facts.put("目的港", "南京市");
facts.put("运输类型", "国内运输拼车");
Map<String, Object> quote = new HashMap<>();
quote.put("total", "110.00");
quote.put("currency", "CNY");
quote.put("eta", "48天");
quote.put("fee_lines", "[{\"name\":\"运输费\",\"amount\":\"110.00\",\"currency\":\"CNY\",\"remark\":\"票结\"}]");
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
.buildFillFacts("WO202609190017", facts, quote, "LinMianXun");
assertEquals("泉州市", out.getString("origin"));
assertEquals("南京市", out.getString("destination"));
assertEquals("48天", out.getString("transit_time"));
assertTrue(out.getJSONArray("route_rows") != null && !out.getJSONArray("route_rows").isEmpty());
assertEquals("110.00", out.getJSONArray("route_rows").getJSONObject(0).getString("salePrice"));
}
/**
* 陆运拼车:H5「报价日期:」常被标进 fixedCells;同格冒号后必须仍能写出日期。
* 与有效期误绑同一格时,只写报价日期,不让 TMS valid_until 盖掉标签含义。
*/
@Test
public void quoteDateWritesAfterColonEvenWhenFixedYellow() throws Exception {
Path src = Files.createTempFile("quote-date-src", ".xlsx");
Path out = Files.createTempFile("quote-date-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("国内运输拼车报价模板");
row(sheet, 4).createCell(7).setCellValue("报价日期:");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject(true);
JSONObject fields = new JSONObject(true);
fields.put("valid_until", "国内运输拼车报价模板!H5");
fields.put("quote_date", "国内运输拼车报价模板!H5");
mapping.put("fields", fields);
JSONObject meta = new JSONObject(true);
JSONObject after = new JSONObject(true);
after.put("sameCellAfterColon", true);
meta.put("quote_date", after);
meta.put("valid_until", after);
mapping.put("fieldMeta", meta);
mapping.put("fixedCells", JSONArray.parseArray("[\"国内运输拼车报价模板!H5\"]"));
mapping.put("fixedYellowCells", JSONArray.parseArray("[\"国内运输拼车报价模板!H5\"]"));
JSONObject facts = new JSONObject(true);
facts.put("quote_date", "2026-09-19");
facts.put("valid_until", "2026-04-30");
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
assertEquals("报价日期:2026-09-19", read(filled.getSheetAt(0), 4, 7));
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* 产品口径:单元格标黄 = 固定占位,出单不赋值。
* 陆运「项目二」整段黄底时,即使 mapping 配了 feeRegions,也不得改写模板占位行。
*/
@Test
public void yellowFeeRegionRowsAreNotOverwritten() throws Exception {
Path src = Files.createTempFile("quote-yellow-src", ".xlsx");
Path out = Files.createTempFile("quote-yellow-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("国内运输拼车报价模板");
row(sheet, 11).createCell(1).setCellValue("项目二:其它费用");
org.apache.poi.ss.usermodel.CellStyle yellow = wb.createCellStyle();
yellow.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.YELLOW.getIndex());
yellow.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
org.apache.poi.ss.usermodel.Row data = row(sheet, 13);
org.apache.poi.ss.usermodel.Cell cName = data.createCell(2);
cName.setCellValue("打包费");
cName.setCellStyle(yellow);
org.apache.poi.ss.usermodel.Cell cAmt = data.createCell(7);
cAmt.setCellValue("明确收费");
cAmt.setCellStyle(yellow);
row(sheet, 18).createCell(1).setCellValue("交易条件说明");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject(true);
mapping.put("fields", new JSONObject());
JSONArray regions = new JSONArray();
JSONObject region = new JSONObject(true);
region.put("id", "other_charges");
region.put("title", "项目二:其它费用");
region.put("anchor", "国内运输拼车报价模板!B14");
region.put("stopAtText", JSONArray.parseArray("[\"交易条件说明\"]"));
region.put("grow", "insert_before_stop");
JSONObject columns = new JSONObject(true);
columns.put("name", "C");
columns.put("amount", "H");
region.put("columns", columns);
regions.add(region);
mapping.put("feeRegions", regions);
JSONObject facts = new JSONObject(true);
facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows(
"[{\"name\":\"运输费\",\"amount\":\"110.00\",\"unit\":\"票数\"}]"));
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertEquals("打包费", read(sheet, 13, 2));
assertEquals("明确收费", read(sheet, 13, 7));
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
@Test
public void seaDynamicContainerHeaderReplacedWithBoxType() throws Exception {
Path src = Files.createTempFile("quote-box-src", ".xlsx");
Path out = Files.createTempFile("quote-box-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("海运询价单");
row(sheet, 16).createCell(0).setCellValue("始发港费用/Origin Charges");
org.apache.poi.ss.usermodel.Row head = row(sheet, 17);
head.createCell(0).setCellValue("项目/Items");
head.createCell(2).setCellValue("箱型\n(动态填充箱型)");
head.createCell(7).setCellValue("金额/Ammount");
row(sheet, 18).createCell(0).setCellValue("操作费");
row(sheet, 27).createCell(0).setCellValue("始发港费用汇总/POL Local Charges");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject(true);
mapping.put("sheet", "海运询价单");
mapping.put("fields", new JSONObject());
JSONArray regions = new JSONArray();
JSONObject region = new JSONObject(true);
region.put("id", "origin_charges");
region.put("title", "始发港费用/Origin Charges");
region.put("tmsSource", "polCostItems");
region.put("headerRow", 18);
region.put("anchor", "海运询价单!A19");
region.put("dynamicContainerHeader", true);
region.put("stopAtText", JSONArray.parseArray("[\"始发港费用汇总\"]"));
JSONObject columns = new JSONObject(true);
columns.put("name", "A");
columns.put("container_rate", "C");
columns.put("amount", "H");
region.put("columns", columns);
regions.add(region);
mapping.put("feeRegions", regions);
JSONObject facts = new JSONObject(true);
facts.put("container_type_qty", "20GP*1");
facts.put("fee_rows_polCostItems", QuoteTemplateExcelSupport.parseFeeRows(
"[{\"name\":\"操作费\",\"amount\":\"680\",\"currency\":\"CNY\",\"unit_price\":\"680\"}]"));
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
String head = read(filled.getSheetAt(0), 17, 2);
assertTrue(head.contains("20GP"), "header=" + head);
assertTrue(head.contains("(") && head.contains(")"), "header missing parens: " + head);
assertTrue(!head.contains("动态填充"), "header still placeholder: " + head);
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
private static org.apache.poi.ss.usermodel.Row row(Sheet sheet, int r) {
org.apache.poi.ss.usermodel.Row exist = sheet.getRow(r);
return exist == null ? sheet.createRow(r) : exist;
@@ -0,0 +1,114 @@
package org.jeecg.modules.inquiry.template;
import com.alibaba.fastjson.JSONObject;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* RFQ 纠偏、重新解析智能合并、备注过滤。
* <p>
* 产品口径:B3 已有单选等内容时,编号写在 A3「RFQ No:」冒号后,不覆盖 B3、不跳到 F3。
*/
public class QuoteTemplateMappingMergeTest {
@Test
public void b3RadioIsNotAlignedQuoteNo() throws Exception {
try (Workbook wb = seaHeaderWorkbook()) {
assertFalse(QuoteTemplateExcelSupport.fieldMappingStillAligned(wb, "quote_no", "海运询价单!B3"));
assertTrue(QuoteTemplateExcelSupport.fieldMappingStillAligned(wb, "quote_no", "海运询价单!A3"));
}
}
@Test
public void relocateQuoteNoSkipsRadioToLabelA3() throws Exception {
try (Workbook wb = seaHeaderWorkbook()) {
String better = QuoteTemplateExcelSupport.relocateQuoteNoCell(wb, "海运询价单!B3");
assertEquals("海运询价单!A3", better);
}
}
@Test
public void mergeReplacesB3WithA3() throws Exception {
try (Workbook wb = seaHeaderWorkbook()) {
JSONObject old = fields("quote_no", "海运询价单!B3");
JSONObject neu = fields("quote_no", "海运询价单!B3");
JSONObject merged = InquiryQuoteTemplateServiceImpl.mergeMappingPreferExisting(old, neu, wb);
assertEquals("海运询价单!A3", merged.getJSONObject("fields").getString("quote_no"));
assertTrue(merged.getJSONObject("fieldMeta").getJSONObject("quote_no")
.getBooleanValue("sameCellAfterColon"));
}
}
@Test
public void fillWritesQuoteNoAfterColonOnA3() throws Exception {
Path src = Files.createTempFile("quote-rfq-src", ".xlsx");
Path out = Files.createTempFile("quote-rfq-out", ".xlsx");
try (Workbook wb = seaHeaderWorkbook()) {
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject(true);
JSONObject f = new JSONObject(true);
f.put("quote_no", "海运询价单!B3");
mapping.put("fields", f);
// A3 在固定区也应能冒号后写入
mapping.put("fixedCells", com.alibaba.fastjson.JSONArray.parseArray("[\"海运询价单!A3\"]"));
JSONObject facts = new JSONObject(true);
facts.put("quote_no", "WO202609190002");
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook wb = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = wb.getSheet("海运询价单");
assertEquals("RFQ No:WO202609190002", sheet.getRow(2).getCell(0).getStringCellValue());
// B3 单选不得被工单号覆盖
assertEquals("○ Port to Port\n○ 其它,3工作天回复",
sheet.getRow(2).getCell(1).getStringCellValue().replace("\r\n", "\n"));
// F3 不应被误写
assertTrue(sheet.getRow(2).getCell(5) == null
|| org.apache.commons.lang3.StringUtils.isBlank(sheet.getRow(2).getCell(5).getStringCellValue()));
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
@Test
public void formatFeeRemarkDropsMarkup() {
assertEquals("", QuoteTemplateExcelSupport.formatFeeRemark("计价单位:柜;加点系数:0.0000000"));
assertEquals("紧急出运", QuoteTemplateExcelSupport.formatFeeRemark("紧急出运;加点系数:0.1"));
}
private static JSONObject fields(String code, String cell) {
JSONObject mapping = new JSONObject(true);
JSONObject f = new JSONObject(true);
f.put(code, cell);
mapping.put("fields", f);
mapping.put("feeRegions", new com.alibaba.fastjson.JSONArray());
mapping.put("routeRegions", new com.alibaba.fastjson.JSONArray());
return mapping;
}
/**
* 迷你海运表头:A3=RFQ No:,B3=单选,F3=空(旧逻辑曾误写这里)。
*/
private static Workbook seaHeaderWorkbook() {
Workbook wb = new XSSFWorkbook();
Sheet sheet = wb.createSheet("海运询价单");
Row row = sheet.createRow(2);
row.createCell(0).setCellValue("RFQ No:");
row.createCell(1).setCellValue("○ Port to Port\n○ 其它,3工作天回复");
row.createCell(5).setCellValue("");
return wb;
}
}
@@ -2,11 +2,14 @@ package org.jeecg.modules.inquiry.tms;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -114,24 +117,69 @@ public class TmsQuoteCurrencyTest {
}
@Test
public void toAgentQuoteKeepsRealAirLockIds() {
Map<String, Object> option = new HashMap<>();
option.put("optionNo", "AIR-OPT01");
option.put("originAirportCode", "ZUH");
option.put("destinationAirportCode", "CRK");
public void toAgentQuoteSplitsSeaFeeBuckets() {
Map<String, Object> pol = new HashMap<>();
pol.put("feeName", "操作费");
pol.put("amount", 680);
pol.put("currency", "CNY");
Map<String, Object> ocean = new HashMap<>();
ocean.put("feeName", "海运费");
ocean.put("amount", 100);
ocean.put("currency", "USD");
Map<String, Object> pod = new HashMap<>();
pod.put("feeName", "文件费");
pod.put("amount", 450);
pod.put("currency", "CNY");
List<Map<String, Object>> feeItems = new ArrayList<>();
feeItems.add(Collections.singletonMap("polCostItems", Collections.singletonList(pol)));
feeItems.add(Collections.singletonMap("oceanCostItems", Collections.singletonList(ocean)));
feeItems.add(Collections.singletonMap("podCostItems", Collections.singletonList(pod)));
Map<String, Object> data = new HashMap<>();
data.put("quoteFound", true);
data.put("quoteId", "AIR-2094721692181016578-2094721692654972929");
data.put("totalSalePrice", "11792.00");
data.put("totalSalePrice", "1230");
data.put("currency", "CNY");
data.put("chargeableWeightKg", 1072);
data.put("airOptions", Collections.singletonList(option));
data.put("feeItems", feeItems);
Map<String, Object> tms = new HashMap<>();
tms.put("code", 1);
tms.put("tmsData", data);
Map<String, String> quote = InquiryTmsQueryService.toAgentQuote(tms);
assertEquals("AIR-2094721692181016578-2094721692654972929", quote.get("quoteId"));
assertTrue(quote.get("airOptions").contains("AIR-OPT01"));
assertEquals("1072", quote.get("chargeableWeightKg"));
assertTrue(quote.get("fee_lines").contains("操作费"));
assertTrue(quote.get("fee_lines").contains("海运费"));
assertTrue(quote.get("fee_lines").contains("文件费"));
assertTrue(quote.get("fee_rows_polCostItems").contains("操作费"));
assertFalse(quote.get("fee_rows_polCostItems").contains("海运费"));
assertTrue(quote.get("fee_rows_oceanCostItems").contains("海运费"));
assertTrue(quote.get("fee_rows_podCostItems").contains("文件费"));
}
@Test
public void toAgentQuoteProjectsLandSegments() {
Map<String, Object> seg = new HashMap<>();
seg.put("segmentSequence", 1);
seg.put("routeLegSequence", 1);
seg.put("origin", "泉州市");
seg.put("destination", "南京市");
seg.put("vehicleType", "3T");
seg.put("salePrice", "2800");
seg.put("currency", "CNY");
seg.put("transitDays", "3");
seg.put("remark", "普货");
Map<String, Object> data = new HashMap<>();
data.put("quoteFound", true);
data.put("totalSalePrice", "2800");
data.put("currency", "CNY");
data.put("feeItems", Collections.emptyList());
data.put("isMulti", 1);
data.put("landSegments", Collections.singletonList(seg));
Map<String, Object> tms = new HashMap<>();
tms.put("code", 1);
tms.put("tmsData", data);
Map<String, String> quote = InquiryTmsQueryService.toAgentQuote(tms);
assertEquals("1", quote.get("isMulti"));
assertTrue(quote.get("landSegments").contains("泉州市"));
assertTrue(quote.get("route_rows").contains("\"salePrice\":\"2800\""));
assertTrue(quote.get("route_rows").contains("\"transit_time\":\"3\""));
assertTrue(quote.get("fee_lines").contains("运输费"));
assertTrue(quote.get("fee_lines").contains("2800"));
}
}
@@ -4,6 +4,7 @@ import cn.hutool.core.util.RandomUtil;
import com.alibaba.fastjson.JSONObject;
import com.aliyuncs.exceptions.ClientException;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
@@ -465,6 +466,18 @@ public class LoginController {
// 获取用户部门信息
JSONObject obj = new JSONObject(new LinkedHashMap<>());
// 询价账号管理:写入最后登录时间,列表不再误用 createTime(种子常为 2019)
Date loginAt = new Date();
sysUser.setLastLoginTime(loginAt);
try {
sysUserService.update(
new LambdaUpdateWrapper<SysUser>()
.eq(SysUser::getId, sysUser.getId())
.set(SysUser::getLastLoginTime, loginAt));
} catch (Exception e) {
log.warn("写入最后登录时间失败 username={}", username, e);
}
//1.生成token
String token = JwtUtil.sign(username, syspassword);
// 设置token缓存有效时间
@@ -161,6 +161,16 @@ public class SysUser implements Serializable {
* 更新时间
*/
private Date updateTime;
/**
* 最后登录时间。
* 账号管理列表展示;登录成功时写入。禁止用 createTime 冒充(种子账号常为 2019)。
*/
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@TableField("last_login_time")
private Date lastLoginTime;
/**
* 同步工作流引擎1同步0不同步
*/
@@ -43,6 +43,19 @@ export function roleTagFor(index: number) {
return ROLE_TAG_CYCLE[index % ROLE_TAG_CYCLE.length];
}
/**
* 最后登录展示为 `yyyy-MM-dd HH:mm:ss`;无记录显示 —。
* 兼容接口偶发 ISO(带 T)或已格式化字符串。
*/
export function formatLastLogin(raw: unknown): string {
if (raw == null || raw === '') return '—';
const s = String(raw).trim();
if (!s) return '—';
const m = s.match(/^(\d{4}-\d{2}-\d{2})[T\s](\d{2}:\d{2}:\d{2})/);
if (m) return `${m[1]} ${m[2]}`;
return s;
}
/** username=admin 或角色码/名含超级管理员 → 内置超管 */
export function isSuperAdminUser(u: { username?: string; roleLabel?: string; roleCode?: string }) {
const name = String(u.username || '').trim().toLowerCase();
@@ -71,7 +84,8 @@ export function mapUserToAccount(
roleLabel: superAdmin ? '超级管理员' : roleLabel,
roleTag: superAdmin ? 'tag-violet' : roleTag,
phoneMasked: u.phone || '未填写',
lastLogin: u.lastLoginTime || u.createTime || '—',
// 只用真实最后登录;禁止回退 createTime(Jeecg 种子常为 2019,会误导成「最后登录」)
lastLogin: formatLastLogin(u.lastLoginTime),
status: Number(u.status) === 1 ? 'active' : 'disabled',
dashboardSelfOnly: Boolean(u.dashboardSelfOnly === true || u.dashboardSelfOnly === 1),
isSuperAdmin: superAdmin,
@@ -361,9 +361,8 @@ function removeAccount(row: AccountRow) {
>
编辑
</button>
<span v-else class="account-locked" title="超级管理员不可在后台编辑">不可编辑</span>
<!-- 超管只隐藏编辑/删除;重置密码仍可用。不展示「不可编辑」占位文案 -->
<button
v-if="!row.isSuperAdmin"
class="link-action link-muted"
type="button"
@click="resetPassword(row)"
@@ -520,11 +519,6 @@ function removeAccount(row: AccountRow) {
gap: 10px;
}
.account-locked {
color: #94a3b8;
font-size: 12px;
}
.account-self-only {
display: flex;
flex-wrap: wrap;
@@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* 角色管理:对接 Jeecg /sys/role 与菜单权限树。
* 数据权限用线路标签写入角色 description,避免伪造第二套 RBAC。
* 数据权限按 Excel 线路目录树勾选,写入角色 description 的 [dataPerms:…],不另造 RBAC。
*/
import { computed, onMounted, reactive, ref } from "vue";
import { message, Modal } from "ant-design-vue";
@@ -15,6 +15,14 @@ import {
saveRolePermission,
type RoleRow,
} from "/@/views/inquiry/role/role.api";
import {
DATA_PERM_TREE,
SYSTEM_EXCEPTION_NOTIFY_KEY,
SYSTEM_EXCEPTION_NOTIFY_TITLE,
dataPermTitle,
leafDataPermKeys,
normalizeDataPermKeys,
} from "/@/views/inquiry/shared/dataPermCatalog";
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
@@ -30,19 +38,12 @@ const form = reactive({
status: "active" as RoleRow["status"],
checkedKeys: [] as string[],
dataPerms: [] as string[],
/** 旧后台独立项:系统异常消息通知 */
systemExceptionNotify: false,
});
const dataOptions = [
"空运·全部线路",
"空运·CGO-KUL",
"空运·HKG-KUL",
"海运·全部线路",
"海运·海运全线",
"陆运·全部线路",
"陆运·中港/中亚/中欧",
"陆运·东南亚",
"系统异常·消息通知",
];
const dataPermTree = DATA_PERM_TREE;
const systemExceptionNotifyTitle = SYSTEM_EXCEPTION_NOTIFY_TITLE;
const modalTitle = computed(() => (editing.value ? "编辑角色" : "新增角色"));
const modalOkText = computed(() => "保存");
@@ -68,15 +69,29 @@ function parseDataPerms(desc: string | undefined): string[] {
if (!desc) return [];
const m = desc.match(/\[dataPerms:(.+?)\]/);
if (!m) return [];
return m[1]
.split("|")
.map((s) => s.trim())
.filter(Boolean);
return normalizeDataPermKeys(
m[1]
.split("|")
.map((s) => s.trim())
.filter(Boolean),
);
}
function buildDescription(dataPerms: string[]) {
if (!dataPerms.length) return "";
return `[dataPerms:${dataPerms.join("|")}]`;
function buildDescription(dataPerms: string[], systemExceptionNotify: boolean) {
const keys = leafDataPermKeys(dataPerms);
if (systemExceptionNotify && !keys.includes(SYSTEM_EXCEPTION_NOTIFY_KEY)) {
keys.push(SYSTEM_EXCEPTION_NOTIFY_KEY);
}
if (!keys.length) return "";
return `[dataPerms:${keys.join("|")}]`;
}
function displayDataPerms(keys: string[]): string[] {
return keys.map((k) => dataPermTitle(k));
}
function routeDataPermsOnly(keys: string[]): string[] {
return keys.filter((k) => k !== SYSTEM_EXCEPTION_NOTIFY_KEY);
}
async function loadMenuTree() {
@@ -143,18 +158,21 @@ function openCreate() {
status: "active",
checkedKeys: [],
dataPerms: [],
systemExceptionNotify: false,
});
modalOpen.value = true;
}
async function openEdit(row: RoleRow) {
editing.value = row;
const allPerms = [...row.dataPerms];
Object.assign(form, {
name: row.name,
roleCode: row.roleCode,
status: row.status,
checkedKeys: [] as string[],
dataPerms: [...row.dataPerms],
dataPerms: routeDataPermsOnly(allPerms),
systemExceptionNotify: allPerms.includes(SYSTEM_EXCEPTION_NOTIFY_KEY),
});
try {
const perms: any = await queryRolePermission({ roleId: row.id });
@@ -179,7 +197,7 @@ async function save() {
id: editing.value?.id,
roleName: form.name.trim(),
roleCode,
description: buildDescription(form.dataPerms),
description: buildDescription(form.dataPerms, form.systemExceptionNotify),
},
Boolean(editing.value),
);
@@ -214,6 +232,12 @@ function removeRole(row: RoleRow) {
},
});
}
/** a-tree 可能回传对象或数组;统一成叶子 key 列表 */
function onDataPermCheck(checked: string[] | { checked: string[] }) {
const keys = Array.isArray(checked) ? checked : checked?.checked || [];
form.dataPerms = leafDataPermKeys(keys.map(String));
}
</script>
<template>
@@ -252,7 +276,7 @@ function removeRole(row: RoleRow) {
</td>
<td>
<div class="role-perm-summary">
<span v-for="d in row.dataPerms" :key="d" class="tag">{{ d }}</span>
<span v-for="d in displayDataPerms(row.dataPerms)" :key="d" class="tag">{{ d }}</span>
<span v-if="!row.dataPerms.length" style="color: #94a3b8">—</span>
</div>
</td>
@@ -306,14 +330,20 @@ function removeRole(row: RoleRow) {
<div class="admin-modal-field">
<label class="admin-modal-label">数据权限</label>
<a-select
v-model:value="form.dataPerms"
mode="multiple"
style="width: 100%"
:options="dataOptions.map((d) => ({ value: d, label: d }))"
placeholder="选择线路 / 通知范围"
:max-tag-count="3"
/>
<div class="perm-tree-box">
<a-tree
:checkedKeys="form.dataPerms"
checkable
default-expand-all
:tree-data="dataPermTree"
:field-names="{ title: 'title', key: 'key', children: 'children' }"
@check="onDataPermCheck"
/>
</div>
<label class="notify-check">
<input v-model="form.systemExceptionNotify" type="checkbox" />
{{ systemExceptionNotifyTitle }}
</label>
</div>
<div class="admin-modal-field">
@@ -340,4 +370,14 @@ function removeRole(row: RoleRow) {
align-items: center;
gap: 10px;
}
.notify-check {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
font-size: 13px;
color: #0f172a;
cursor: pointer;
}
</style>
@@ -0,0 +1,178 @@
/**
* 运营后台数据权限 / 员工负责线路 单一目录。
*
* 来源:《询价智能体运营后台数据权限下拉选项.xlsx》(2026-09)。
* key = 写入 sys_role.description / inq_staff_role.routes 的稳定码;
* title = 界面展示文案。
*/
export type DataPermNode = {
key: string;
title: string;
children?: DataPermNode[];
};
/** 空运具体线路(不含全线) */
export const AIR_ROUTE_ITEMS: { key: string; title: string }[] = [
{ key: "CGO-KUL", title: "郑州-吉隆坡(CGO-KUL)" },
{ key: "SZX-KUL", title: "深圳-吉隆坡(SZX-KUL)" },
{ key: "HKG-KUL", title: "香港-吉隆坡(HKG-KUL)" },
{ key: "NKG-KUL", title: "南京-吉隆坡(NKG-KUL)" },
{ key: "HKG-DEL", title: "香港-新德里(HKG-DEL)" },
{ key: "CGO-DEL", title: "郑州-新德里(CGO-DEL)" },
{ key: "CGO-BLR", title: "郑州-班加罗尔(CGO-BLR)" },
{ key: "CGO-MAA", title: "郑州-金奈(CGO-MAA)" },
{ key: "CTU-MAA", title: "成都-金奈(CTU-MAA)" },
{ key: "CKG-BOM", title: "重庆-孟买(CKG-BOM)" },
{ key: "ZUH-CRK", title: "珠海-克拉克(ZUH-CRK)" },
{ key: "SZX-CRK", title: "深圳-克拉克(SZX-CRK)" },
{ key: "EHU-ORD", title: "鄂州-芝加哥(EHU-ORD)" },
{ key: "WNZ-CGK", title: "温州-雅加达(WNZ-CGK)" },
{ key: "国内-曼谷(BKK 外采)", title: "国内-曼谷(BKK 外采)" },
{ key: "国外-国外(外采-空运)", title: "国外-国外(外采-空运)" },
{ key: "其他线路和快递双清包税(外采-空运)", title: "其他线路和快递双清包税(外采-空运)" },
];
export const LAND_ROUTE_ITEMS: { key: string; title: string }[] = [
{ key: "中港/中亚/中欧", title: "中港/中亚/中欧" },
{ key: "国内长途/零担", title: "国内长途/零担" },
{ key: "国内城配/拖车/打包", title: "国内城配/拖车/打包" },
{ key: "东南亚", title: "东南亚" },
];
/** 角色弹窗数据权限树(父节点仅作分组,不写入 description;文案对齐旧后台「空运数据」) */
export const DATA_PERM_TREE: DataPermNode[] = [
{
key: "group:air",
title: "空运数据",
children: [{ key: "空运全线", title: "空运全线" }, ...AIR_ROUTE_ITEMS],
},
{
key: "group:sea",
title: "海运数据",
children: [{ key: "海运全线", title: "海运全线" }],
},
{
key: "group:land",
title: "陆运数据",
children: [{ key: "陆运全线", title: "陆运全线" }, ...LAND_ROUTE_ITEMS],
},
];
/** 旧后台独立勾选项:系统异常消息通知(企微) */
export const SYSTEM_EXCEPTION_NOTIFY_KEY = "系统异常·消息通知";
export const SYSTEM_EXCEPTION_NOTIFY_TITLE = "系统异常消息通知(企微)";
/** 员工岗位 → 可选负责线路 key(与角色数据权限同源) */
export const ROUTES_BY_ROLE: Record<"air" | "sea" | "land", string[]> = {
air: ["空运全线", ...AIR_ROUTE_ITEMS.map((x) => x.key)],
sea: ["海运全线"],
land: ["陆运全线", ...LAND_ROUTE_ITEMS.map((x) => x.key)],
};
const TITLE_BY_KEY: Record<string, string> = (() => {
const m: Record<string, string> = {
空运全线: "空运全线",
海运全线: "海运全线",
陆运全线: "陆运全线",
[SYSTEM_EXCEPTION_NOTIFY_KEY]: SYSTEM_EXCEPTION_NOTIFY_TITLE,
};
for (const x of AIR_ROUTE_ITEMS) m[x.key] = x.title;
for (const x of LAND_ROUTE_ITEMS) m[x.key] = x.title;
return m;
})();
/** 已知稳定码集合(不含分组父节点) */
export const KNOWN_DATA_PERM_KEYS = new Set(Object.keys(TITLE_BY_KEY));
/**
* 旧写法 → 新稳定码。
* 无法识别时返回 trim 后原文(可能仍展示,但不进勾选树)。
*/
const ALIAS_TO_KEY: Record<string, string> = {
空运全线: "空运全线",
空运全部线路: "空运全线",
"空运·全部线路": "空运全线",
"空运·全线": "空运全线",
全线: "空运全线",
海运全线: "海运全线",
"海运·全部线路": "海运全线",
"海运·海运全线": "海运全线",
陆运全线: "陆运全线",
"陆运·全部线路": "陆运全线",
"陆运·中港/中亚/中欧": "中港/中亚/中欧",
"陆运·国内长途/零担": "国内长途/零担",
"陆运·国内城配/拖车/打包": "国内城配/拖车/打包",
"陆运·东南亚": "东南亚",
BKK: "国内-曼谷(BKK 外采)",
"外采-空运": "国外-国外(外采-空运)",
[SYSTEM_EXCEPTION_NOTIFY_KEY]: SYSTEM_EXCEPTION_NOTIFY_KEY,
系统异常消息通知: SYSTEM_EXCEPTION_NOTIFY_KEY,
"系统异常消息通知(企微)": SYSTEM_EXCEPTION_NOTIFY_KEY,
};
for (const item of AIR_ROUTE_ITEMS) {
ALIAS_TO_KEY[item.key] = item.key;
ALIAS_TO_KEY[item.title] = item.key;
ALIAS_TO_KEY[`空运·${item.key}`] = item.key;
}
for (const item of LAND_ROUTE_ITEMS) {
ALIAS_TO_KEY[item.key] = item.key;
ALIAS_TO_KEY[`陆运·${item.key}`] = item.key;
}
export function normalizeDataPermKey(raw: string): string {
const s = String(raw || "").trim();
if (!s) return "";
if (ALIAS_TO_KEY[s]) return ALIAS_TO_KEY[s];
// 空运·XXX / 陆运·XXX 兜底剥前缀
const m = s.match(/^(?:空运|海运|陆运)[·•](.+)$/);
if (m) {
const inner = m[1].trim();
if (ALIAS_TO_KEY[inner]) return ALIAS_TO_KEY[inner];
if (inner === "全部线路" || inner === "全线") {
if (s.startsWith("空运")) return "空运全线";
if (s.startsWith("海运")) return "海运全线";
if (s.startsWith("陆运")) return "陆运全线";
}
if (KNOWN_DATA_PERM_KEYS.has(inner)) return inner;
}
return s;
}
/** 批量归一;去重保序;只保留已知码(角色勾选回显用) */
export function normalizeDataPermKeys(rawList: string[]): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const raw of rawList || []) {
const k = normalizeDataPermKey(raw);
if (!k || !KNOWN_DATA_PERM_KEYS.has(k) || seen.has(k)) continue;
seen.add(k);
out.push(k);
}
return out;
}
/** 员工 routes:归一后保留已知码;未知旧值也保留以免丢数据 */
export function normalizeStaffRouteKeys(rawList: string[]): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const raw of rawList || []) {
const k = normalizeDataPermKey(raw);
if (!k || seen.has(k)) continue;
seen.add(k);
out.push(k);
}
return out;
}
export function dataPermTitle(key: string): string {
const k = normalizeDataPermKey(key);
return TITLE_BY_KEY[k] || key;
}
/** Ant Design Tree 用:过滤掉分组父 key,只交叶子 */
export function leafDataPermKeys(checked: string[]): string[] {
return (checked || []).filter((k) => KNOWN_DATA_PERM_KEYS.has(k));
}
@@ -20,6 +20,11 @@ import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
import { joinRoutes, parseRouteList } from "/@/views/inquiry/staffRole/staffRoutes";
import {
ROUTES_BY_ROLE,
dataPermTitle,
normalizeStaffRouteKeys,
} from "/@/views/inquiry/shared/dataPermCatalog";
type StaffRole = "sales" | "air" | "sea" | "land";
@@ -52,26 +57,7 @@ const roleLabelMap: Record<StaffRole, string> = {
land: "产品·陆运",
};
/** 按岗位的负责线路选项(与原型 admin-staff.html ROUTES_BY_ROLE 一致) */
const ROUTES_BY_ROLE: Record<Exclude<StaffRole, "sales">, string[]> = {
air: [
"CGO-KUL",
"SZX-KUL",
"HKG-KUL",
"NKG-KUL",
"HKG-DEL",
"CGO-MAA",
"CGO-BLR",
"ZUH-CRK",
"WNZ-CGK",
"BKK",
"外采-空运",
"全线",
],
sea: ["海运全线"],
land: ["中港/中亚/中欧", "国内长途/零担", "国内城配/拖车/打包", "东南亚"],
};
/** 负责线路与角色数据权限同源(Excel《数据权限下拉选项》) */
const ALL_ROUTE_FILTER_OPTIONS = [
...ROUTES_BY_ROLE.air,
...ROUTES_BY_ROLE.sea,
@@ -120,8 +106,11 @@ const form = reactive({
/** 当前岗位可选线路;销售为空 */
const routeOptions = computed(() => {
if (!isProductOrAir(form.role)) return [] as string[];
return ROUTES_BY_ROLE[form.role];
if (!isProductOrAir(form.role)) return [] as { label: string; value: string }[];
return ROUTES_BY_ROLE[form.role].map((key) => ({
value: key,
label: dataPermTitle(key),
}));
});
const showLeaderAndRoutes = computed(() => isProductOrAir(form.role));
@@ -367,7 +356,7 @@ watch(
return;
}
const valid = new Set(ROUTES_BY_ROLE[role]);
form.routeList = form.routeList.filter((r) => valid.has(r));
form.routeList = normalizeStaffRouteKeys(form.routeList).filter((r) => valid.has(r));
},
);
@@ -392,7 +381,10 @@ function openCreate() {
function openEdit(row: StaffRow) {
editing.value = row;
const valid = isProductOrAir(row.role) ? new Set(ROUTES_BY_ROLE[row.role]) : null;
const routes = parseRouteList(row.routes).filter((r) => (valid ? valid.has(r) : true));
// 旧 routes(全线/BKK/外采-空运 等)归一到 Excel 码后再过滤本岗选项
const routes = normalizeStaffRouteKeys(parseRouteList(row.routes)).filter((r) =>
valid ? valid.has(r) : true,
);
Object.assign(form, {
name: row.name,
wecomId: row.wecomId,
@@ -432,7 +424,9 @@ async function saveStaff() {
departName: form.dept.trim() || undefined,
roleCode: form.role,
roleLabel: roleLabelMap[form.role],
routes: isProductOrAir(form.role) ? joinRoutes(form.routeList) : undefined,
routes: isProductOrAir(form.role)
? joinRoutes(normalizeStaffRouteKeys(form.routeList))
: undefined,
isLeader: isProductOrAir(form.role) ? (form.isLeader === "true" ? 1 : 0) : null,
notifyEventException: form.notifyEventException ? 1 : 0,
status: form.status,
@@ -480,7 +474,7 @@ async function syncWecom() {
}
function routeTags(routes: string) {
return parseRouteList(routes);
return normalizeStaffRouteKeys(parseRouteList(routes)).map((k) => dataPermTitle(k));
}
function filterRouteOption(input: string, option: any) {
@@ -535,7 +529,7 @@ function filterRouteOption(input: string, option: any) {
</select>
<select v-model="filters.route">
<option value="">负责线路</option>
<option v-for="r in ALL_ROUTE_FILTER_OPTIONS" :key="r" :value="r">{{ r }}</option>
<option v-for="r in ALL_ROUTE_FILTER_OPTIONS" :key="r" :value="r">{{ dataPermTitle(r) }}</option>
</select>
<select v-model="filters.status">
<option value="">状态</option>
@@ -713,7 +707,7 @@ function filterRouteOption(input: string, option: any) {
show-search
class="staff-route-select"
placeholder="请选择负责线路,可多选"
:options="routeOptions.map((r) => ({ label: r, value: r }))"
:options="routeOptions"
:filter-option="filterRouteOption"
/>
</div>
@@ -2,7 +2,7 @@
* 员工管理「负责线路」拆合。
*
* 只按逗号/中文逗号/顿号拆整词,禁止把「中港/中亚/中欧」「国内长途/零担」按斜杠切开。
* 陆运没有「陆运全线」快捷项。
* 选项与角色数据权限同源(含空运全线 / 海运全线 / 陆运全线)。
*/
/** 把库里的逗号串还原成多选项。空或占位「-」当没有。 */
@@ -434,7 +434,7 @@ async function toggleStatus(row: TemplateRow) {
/**
* 重新提交 AI 映射解析(已解析也可点;解析中禁用)。
* 未换文件:主账带 mergeExisting,回写时保留已有字段只补缺。
* 未换文件:对齐标签的旧坐标保留,对不上的改用新坐标;缺的字段仍补上。
* 副作用:主账入队;列表进入解析中并轮询。
*/
async function reparseRow(row: TemplateRow) {
@@ -691,7 +691,7 @@ async function downloadPreviewOrOriginal() {
:title="
row.parseStatus === '解析中'
? '解析中,请稍候'
: '重新解析:保留已有映射,只补缺;换文件请用编辑上传'
: '重新解析:对齐的保留,对不上的改坐标;换文件请用编辑上传'
"
@click.stop="reparseRow(row)"
>
@@ -48,6 +48,9 @@ type TicketRow = {
volumeCbm: string;
goodsName: string;
updateTime: string;
lostReason: string;
nextFollowupAt: string;
dealNotifySummary: string;
};
const rows = ref<TicketRow[]>([]);
@@ -265,6 +268,9 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
volumeCbm: fmtMeasure(t.volumeCbm, "CBM"),
goodsName: dash(t.goodsName),
updateTime: fmtTime(t.updateTime || t.createTime),
lostReason: dash(t.lostReason || t.lost_reason),
nextFollowupAt: fmtTime(t.nextFollowupAt || t.next_followup_at),
dealNotifySummary: dash(t.dealNotifySummary || t.deal_notify_summary),
};
}
@@ -630,6 +636,18 @@ function refresh() {
<div class="ticket-detail-item"><label>事件异常</label><span>{{ current.eventException }}</span></div>
<div class="ticket-detail-item"><label>系统异常</label><span>{{ current.systemException }}</span></div>
<div class="ticket-detail-item"><label>系统异常原因</label><span>{{ current.systemExceptionReason }}</span></div>
<div v-if="current.status === '已成交'" class="ticket-detail-item">
<label>成交时间</label><span>{{ current.updateTime }}</span>
</div>
<div v-if="current.status === '已成交'" class="ticket-detail-item">
<label>成交通知</label><span>{{ current.dealNotifySummary }}</span>
</div>
<div v-if="current.status === '未成交'" class="ticket-detail-item">
<label>未成交原因</label><span>{{ current.lostReason }}</span>
</div>
<div v-if="current.status === '协商中'" class="ticket-detail-item">
<label>下次再问</label><span>{{ current.nextFollowupAt }}</span>
</div>
</div>
</section>