diff --git a/inquiry-agent/agent/handlers/card_action.py b/inquiry-agent/agent/handlers/card_action.py index 293a145..987b564 100644 --- a/inquiry-agent/agent/handlers/card_action.py +++ b/inquiry-agent/agent/handlers/card_action.py @@ -13,17 +13,22 @@ from typing import Any from agent.channel.queue import get_message_store from agent.policy import inquiry_copy as copy from agent.policy.air_text_flow import get_air_text_flow +from agent.policy.land_text_flow import get_land_text_flow from agent.policy.sea_text_flow import get_sea_text_flow logger = logging.getLogger(__name__) def _pick_card_flow(sender_id: str, payload: dict | None): - """点卡:海运书签能找回工单就走海运,否则空运。""" + """点卡:海运/陆运书签能找回工单就走对应流程,否则空运。""" sea = get_sea_text_flow() sess = sea.session_for_action(sender_id, payload) if sess and (sess.business_line or "").upper() == "SEA": return sea + land = get_land_text_flow() + land_sess = land.session_for_action(sender_id, payload) + if land_sess and (land_sess.business_line or "").upper() == "LAND": + return land return get_air_text_flow() diff --git a/inquiry-agent/agent/handlers/continue_thread.py b/inquiry-agent/agent/handlers/continue_thread.py index 46122f8..6da8423 100644 --- a/inquiry-agent/agent/handlers/continue_thread.py +++ b/inquiry-agent/agent/handlers/continue_thread.py @@ -14,6 +14,7 @@ from agent.channel.queue import get_message_store from agent.channel.wecom.models import InboundMessage from agent.policy import inquiry_copy as copy from agent.policy.air_text_flow import get_air_text_flow +from agent.policy.land_text_flow import get_land_text_flow from agent.policy.sea_text_flow import get_sea_text_flow logger = logging.getLogger(__name__) @@ -34,6 +35,10 @@ def _pick_continue_flow(*, sender_id: str, work_order_no: str, flow=None): hit = sea.session_by_ticket(sender_id, no) if no else None if hit and (hit.business_line or "").upper() == "SEA": return sea + land = get_land_text_flow() + land_hit = land.session_by_ticket(sender_id, no) if no else None + if land_hit and (land_hit.business_line or "").upper() == "LAND": + return land getter = getattr(sea.ledger, "get_for_agent", None) if callable(getter) and no: try: @@ -41,8 +46,11 @@ def _pick_continue_flow(*, sender_id: str, work_order_no: str, flow=None): except Exception: logger.warning("continue_thread 读主账业务线失败 wo=%s", no) view = {} - if str(view.get("business_line") or "").upper() == "SEA": + line = str(view.get("business_line") or "").upper() + if line == "SEA": return sea + if line == "LAND": + return land return get_air_text_flow() diff --git a/inquiry-agent/agent/handlers/group_collab.py b/inquiry-agent/agent/handlers/group_collab.py index 516ffc2..c223be9 100644 --- a/inquiry-agent/agent/handlers/group_collab.py +++ b/inquiry-agent/agent/handlers/group_collab.py @@ -81,6 +81,18 @@ def handle_group_inbound( if ticket is None: logger.info("group_inbound 对不上工单 chat=%s", chat_id) return "group_ignore" + if flow is None: + view0 = ticket_view(ticket) + line = str( + view0.get("business_line") + or view0.get("businessLine") + or getattr(ticket, "business_line", "") + or "" + ).upper() + if line == "LAND": + from agent.policy.land_text_flow import get_land_text_flow + + engine = get_land_text_flow() ticket = hydrate_ticket(engine, ticket) if not _sender_allowed(ticket, message.sender_id): logger.info("group_inbound 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id) diff --git a/inquiry-agent/agent/handlers/text_inquiry.py b/inquiry-agent/agent/handlers/text_inquiry.py index 3d3f249..3ded7ff 100644 --- a/inquiry-agent/agent/handlers/text_inquiry.py +++ b/inquiry-agent/agent/handlers/text_inquiry.py @@ -15,6 +15,7 @@ from agent.channel.wecom.models import InboundMessage from agent.config import get_settings from agent.llm.extract_text import detect_transport_mode from agent.policy.air_text_flow import get_air_text_flow +from agent.policy.land_text_flow import get_land_text_flow from agent.policy.sea_text_flow import get_sea_text_flow logger = logging.getLogger(__name__) @@ -22,20 +23,90 @@ logger = logging.getLogger(__name__) def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = ""): """ - 按当前书签或本句运输方式选空运/海运流程。 + 按当前书签或本句运输方式选空运/海运/陆运流程。 - 已有海运书签优先;新句写了海运走海运;其余仍走空运(含补问运输方式)。 + 陆运核对卡上的确定优先于旧海运书签,避免「确定」被海运/空运吃掉。 """ + from agent.schema.land_options import looks_like_confirm + + land = get_land_text_flow() + if looks_like_confirm(text): + restored = land.restore_confirm_session(sender_id) + if restored: + return land + retry = land.restore_tms_retry_session(sender_id) + if retry: + return land sea = get_sea_text_flow() sea_sess = sea.session_of(sender_id) if sea_sess and (sea_sess.business_line or "").upper() == "SEA": return sea + land_sess = land.session_of(sender_id) + if land_sess and (land_sess.business_line or "").upper() == "LAND": + return land mode = (injected_mode or detect_transport_mode(text) or "").upper() if mode == "SEA": return sea + if mode == "LAND": + return land return get_air_text_flow() +def is_pending_land_confirm(message: InboundMessage) -> bool: + """ + 陆运核对卡已出、本句是确定查价。 + + 测服实测「确定」两字会被 DeepSeek A 判成闲聊,必须在分发层拦住, + 不能回首次问候、不能跳过建单。 + """ + if not message.has_sender(): + return False + from agent.schema.land_options import looks_like_confirm + + if not looks_like_confirm(message.content or ""): + return False + sess = get_land_text_flow().restore_confirm_session(message.sender_id) + ok = bool( + sess + and (sess.business_line or "").upper() == "LAND" + and sess.phase == "wait_confirm" + ) + if not ok: + logger.info( + "land.confirm miss sender=%s phase=%s line=%s", + message.sender_id, + getattr(sess, "phase", "-"), + getattr(sess, "business_line", "-"), + ) + return ok + + +def is_pending_land_tms_retry(message: InboundMessage) -> bool: + """ + 陆运工单已建、上次查价失败,本句是确定重查。 + + 不能回首次问候、不能再开一张新工单。 + """ + if not message.has_sender(): + return False + from agent.schema.land_options import looks_like_confirm + + if not looks_like_confirm(message.content or ""): + return False + sess = get_land_text_flow().restore_tms_retry_session(message.sender_id) + return bool( + sess + and (sess.business_line or "").upper() == "LAND" + and sess.phase == "tms_tech" + and (sess.work_order_no or "").strip() + ) + + +def is_land_confirm_action(message: InboundMessage) -> bool: + """核对卡确定建单,或查价失败后确定重查。""" + return is_pending_land_confirm(message) or is_pending_land_tms_retry(message) + + def handle_text_inquiry( message: InboundMessage, *, @@ -110,6 +181,20 @@ def handle_text_other(message: InboundMessage, *, store=None) -> str: btn = engine.match_button(message.content or "", sess) if btn: return handle_text_inquiry(message, store=msg_store) + if is_land_confirm_action(message): + logger.info("text_other 陆运确定/重查,改走询价流程 sender=%s", message.sender_id) + return handle_text_inquiry(message, store=msg_store) + + from agent.schema.land_options import looks_like_confirm + + if looks_like_confirm(message.content or ""): + body = copy.LAND_CONFIRM_EXPIRED + msg_store.enqueue_outbound( + touser=message.sender_id, + content=body, + dedupe_key=f"land-confirm-expired:{message.message_id}", + ) + return "land_confirm_expired" body = copy.FIRST_CHAT msg_store.enqueue_outbound( diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py index 48c00d5..fc19513 100644 --- a/inquiry-agent/agent/ledger/http_ledger.py +++ b/inquiry-agent/agent/ledger/http_ledger.py @@ -213,6 +213,17 @@ class HttpLedger: return [dict(x) for x in rows if isinstance(x, dict)] return [] + def match_land_staff(self, *, route_category: str) -> list[dict[str, Any]]: + """主账按线路类别返回启用的陆运产品岗。""" + out = self._post( + "/inquiry/agent/staff/matchLand", + {"routeCategory": route_category, "线路类别": route_category}, + ) + rows = out.get("staff") or out.get("result") or [] + if isinstance(rows, list): + return [dict(x) for x in rows if isinstance(x, dict)] + return [] + def get_staff_by_wecom_id(self, *, wecom_id: str) -> dict[str, Any]: out = self._post("/inquiry/agent/staff/byWecom", {"wecomId": wecom_id}) if out.get("name") or out.get("wecomId"): diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py index cc105ef..2c3f3b3 100644 --- a/inquiry-agent/agent/ledger/memory_ledger.py +++ b/inquiry-agent/agent/ledger/memory_ledger.py @@ -66,6 +66,7 @@ class MemoryLedger: ] self.last_render: dict[str, Any] = {} self._sea_staff: list[dict[str, Any]] = [] + self._land_staff: list[dict[str, Any]] = [] self._staff_by_wecom: dict[str, dict[str, Any]] = {} self._staff_by_name: dict[str, dict[str, Any]] = {} @@ -355,6 +356,42 @@ class MemoryLedger: hits.append(dict(row)) return hits + def set_land_staff(self, rows: list[dict[str, Any]]) -> None: + """单测注入陆运产品岗。""" + with self._lock: + self._land_staff = [dict(x) for x in rows] + for row in self._land_staff: + uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip() + name = str(row.get("name") or "").strip() + if uid: + self._staff_by_wecom[uid] = dict(row) + if name: + self._staff_by_name[name] = dict(row) + + def match_land_staff(self, *, route_category: str) -> list[dict[str, Any]]: + """ + 岗位=产品·陆运、启用:负责线路整词含本单线路类别才拉。 + 不按斜杠切开「中港/中亚/中欧」。 + """ + want = (route_category or "").strip() + hits: list[dict[str, Any]] = [] + if not want: + return hits + with self._lock: + for row in self._land_staff: + if str(row.get("roleCode") or row.get("role_code") or "") != "land": + continue + if str(row.get("status") or "active") != "active": + continue + tokens = [ + p.strip() + for p in str(row.get("routes") or "").replace(",", ",").replace("、", ",").split(",") + if p.strip() + ] + if want in tokens: + hits.append(dict(row)) + return hits + def get_staff_by_wecom_id(self, *, wecom_id: str) -> dict[str, Any]: """按企微 userid 取员工展示名。""" with self._lock: diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py index 8caeaa6..9ee2f47 100644 --- a/inquiry-agent/agent/llm/extract_text.py +++ b/inquiry-agent/agent/llm/extract_text.py @@ -22,12 +22,12 @@ _MODE_WORDS = ( # 仅「键:值」一行一条,键必须是合同字段或展示名 _LABEL_LINE = re.compile( r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|" - r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型数量|" + r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型/数量|车型数量|" r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(.+)$" ) _LABEL_INLINE = re.compile( r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|" - r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型数量|" + r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型/数量|车型数量|" r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(\S+)" ) @@ -84,10 +84,12 @@ def extract_inquiry_snapshot( if injected_facts is not None: mode = (injected_mode or detect_transport_mode(text) or "").upper() + facts = harvest_oral_measures(text, normalize_facts(dict(injected_facts))) + subtype = str(facts.get("运输类型") or "").strip() return { "business_line": mode, - "land_subtype": "", - "facts": harvest_oral_measures(text, normalize_facts(dict(injected_facts))), + "land_subtype": subtype, + "facts": facts, "source": "injected", } mode = detect_transport_mode(text) @@ -107,12 +109,15 @@ def extract_inquiry_snapshot( mode = mode or str(b_payload.get("business_line") or "") source = "deepseek_b" facts = harvest_oral_measures(text, normalize_facts(facts)) + land_subtype = str((b_payload or {}).get("land_subtype") or facts.get("运输类型") or "").strip() + if land_subtype and not str(facts.get("运输类型") or "").strip(): + facts["运输类型"] = land_subtype if not mode and facts.get("起运港") and facts.get("目的港") and "飞" in (text or ""): mode = "AIR" logger.info("extract.snapshot mode=%s keys=%s src=%s", mode or "-", sorted(facts), source) return { "business_line": mode, - "land_subtype": (b_payload or {}).get("land_subtype") or "", + "land_subtype": land_subtype, "facts": facts, "source": source, } diff --git a/inquiry-agent/agent/llm/mode_extract_fields.py b/inquiry-agent/agent/llm/mode_extract_fields.py index 47c988f..93466c0 100644 --- a/inquiry-agent/agent/llm/mode_extract_fields.py +++ b/inquiry-agent/agent/llm/mode_extract_fields.py @@ -31,6 +31,7 @@ _FIELD_KEYS = ( "报价日期", "整柜或拼柜", "箱型箱量", + "车型/数量", "车型数量", ) diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index cfa8b28..c756162 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -57,7 +57,7 @@ class FlowSession: class AirTextInquiryFlow: """ - 空运文字询价 Owner。海运走 SeaTextInquiryFlow,不要再往本文件堆海运。陆运仍补问二级。 + 空运文字询价 Owner。海运走 SeaTextInquiryFlow,陆运走 LandTextInquiryFlow。 同一 sender 新开询价会覆盖「当前书签」(旧单留在主账)。 价格卡按工单号另存一份当时会话:点哪张卡就生成哪张卡当时的报价单。 @@ -411,8 +411,19 @@ class AirTextInquiryFlow: if mode != "AIR": if mode == "LAND": - reply(copy.ASK_LAND_SUBTYPE) - return "need_land_subtype" + from agent.policy.land_text_flow import get_land_text_flow + + return get_land_text_flow().on_text( + sender_id=sender_id, + text=text, + reply=reply, + injected_facts=facts, + injected_mode=mode, + b_result=b_result, + allow_b=allow_b, + chat_fn=chat_fn, + inbound_raw=inbound_raw, + ) if mode == "SEA": # 海运交给海运 Owner,空运不再回「本轮先开通空运」 from agent.policy.sea_text_flow import get_sea_text_flow diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index a63604c..92fe889 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -16,10 +16,13 @@ from typing import Any, Iterable from agent.schema.field_validate import ( AIR_COLLAB_FIELDS, AIR_REQUIRED_FIELDS, + LAND_DISPLAY, SEA_COLLAB_DISPLAY, SEA_COLLAB_FIELDS, SEA_REQUIRED_FIELDS, + display_name, ) +from agent.schema.land_options import option_prompt as _land_option_prompt # 空运补问:与合同 8 项一致。报价日期默认当天,不进待补充。 _AIR_EXAMPLES = { @@ -96,9 +99,111 @@ _CONTINUE_HINT = ( ASK_TRANSPORT = "请补充运输方式:海运 / 空运 / 陆运。" -ASK_LAND_SUBTYPE = "请补充陆运类型:国内运输拼车 / 国内运输整车 / 跨境集拼 / 跨境整车 / 中港。" +# 旧一句只问运输类型;销售要一次看到类型、线路、分类。残留调用也走完整清单。 +ASK_LAND_SUBTYPE = _land_option_prompt(current={}) FIRST_CHAT = "你好,我是询价机器人。请直接描述客户需求,例如「上海到洛杉矶空运,3 件 180kg,下周三前到」,我会解析需求并查询 TMS 标准报价。" ROUTE_MODEL_FAILED = "刚才没听清,请再说一次:是新询价,还是要接着办哪张工单?" +LAND_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。" +LAND_CONFIRM_EXPIRED = "刚才的陆运核对已过期,请把陆运需求再发一次,出核对卡后再回复确定。" +LAND_TMS_RETRY = "工单 {wo} 上次查价没成功。回复「确定」按原需求再查一次,不用重新填。" +LAND_TMS_RETRYING = "正在重新查询TMS报价。工单:{wo}" + +_LAND_LCL_KEYS = ("起运港", "目的港", "品名", "体积", "毛重", "报价日期") +_LAND_FTL_KEYS = ("起运港", "目的港", "品名", "车型/数量", "报价日期") +_LAND_HK_FULL_KEYS = ("起运港", "目的港", "品名", "报价日期") +_LAND_LCL_TYPES = frozenset({"国内运输拼车", "跨境集拼"}) +_LAND_FTL_TYPES = frozenset({"国内运输整车", "跨境整车"}) +_LAND_CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港"}) +_LAND_EXAMPLES = { + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + "车型/数量": "9米6/1", + "通关口岸": "皇岗", +} + + +def _land_cargo_keys(land_type: str, facts: dict[str, str] | None = None) -> tuple[str, ...]: + """当前运输类型要展示的货物内部键。中港整车不要车型/数量。""" + if land_type == "中港": + load = str((facts or {}).get("中港装载方式") or "").strip() + if load == "零担/集拼": + return _LAND_FTL_KEYS + # 整车或还没选定装载:卡片/补问都不出现车型/数量 + return _LAND_HK_FULL_KEYS + if land_type in _LAND_FTL_TYPES: + return _LAND_FTL_KEYS + return _LAND_LCL_KEYS + + +def land_confirm_card(facts: dict[str, str] | None) -> str: + """ + 确定查价前的核对卡。没有工单号。 + + 行列出运输类型/线路/分类(中港再加装载)和该类型货物字段。 + 国内不出现通关口岸;跨境/中港通关口岸非必填,没填留空行。 + """ + src = dict(facts or {}) + land_type = (src.get("运输类型") or "").strip() + lines = [ + f"运输类型(必填):{land_type}", + f"线路类别(必填):{(src.get('线路类别') or '').strip()}", + f"运输分类(必填):{(src.get('运输分类') or '').strip()}", + ] + if land_type == "中港": + lines.append(f"中港装载方式(必填):{(src.get('中港装载方式') or '').strip()}") + for key in _land_cargo_keys(land_type, src): + label = LAND_DISPLAY.get(key, key) + val = (src.get(key) or "").strip() + lines.append(f"{label}(必填):{val}") + if land_type in _LAND_CUSTOMS_TYPES: + lines.append(f"通关口岸(非必填):{(src.get('通关口岸') or '').strip()}") + lines.append("") + lines.append(LAND_CONFIRM_TAIL) + return "\n".join(lines) + + +def land_group_no_staff() -> str: + """没有对上线路的陆运产品,不建空群。""" + return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的陆运产品岗后再试。" + + +def ask_land_fields( + *, + facts: dict[str, str] | None = None, + missing_keys: Iterable[str] = (), + land_subtype: str = "", +) -> str: + """ + 陆运货物缺项补问。不出核对卡、不出现确定句。 + + 报价日期不进待补充。 + """ + src = dict(facts or {}) + skip = {"报价日期", "运输类型", "线路类别", "运输分类", "中港装载方式"} + missing = [k for k in missing_keys if k and k not in skip] + land_type = (land_subtype or src.get("运输类型") or "").strip() + lines = ["请确认并补充以下信息:", "运输方式:陆运"] + if land_type: + lines.append(f"运输类型:{land_type}") + for key in _land_cargo_keys(land_type, src): + val = (src.get(key) or "").strip() + if val: + lines.append(f"{LAND_DISPLAY.get(key, key)}:{val}") + lines.append("") + lines.append("待补充:") + if not missing: + return "" + for key in missing: + label = display_name(key, "LAND") + example = _LAND_EXAMPLES.get(key, "") + if example: + lines.append(f"{label}:(如:{example})") + else: + lines.append(f"{label}:") + return "\n".join(lines) def _air_known_value(facts: dict[str, str], key: str) -> str: @@ -259,6 +364,26 @@ def inquiry_field_lines( f"箱型箱量:{box}", f"报价日期:{quote_date}", ] + if (transport_mode or "").strip() == "陆运": + land_type = (facts.get("运输类型") or "").strip() + lines = [ + f"工单号:{work_order_no}", + f"运输方式:{transport_mode}", + f"运输类型:{land_type or '-'}", + f"线路类别:{(facts.get('线路类别') or '-').strip() or '-'}", + f"运输分类:{(facts.get('运输分类') or '-').strip() or '-'}", + ] + if land_type == "中港": + lines.append( + f"中港装载方式:{(facts.get('中港装载方式') or '-').strip() or '-'}" + ) + for key in _land_cargo_keys(land_type, facts): + label = LAND_DISPLAY.get(key, key) + val = (facts.get(key) or "-").strip() or "-" + lines.append(f"{label}:{val}") + if land_type in _LAND_CUSTOMS_TYPES: + lines.append(f"通关口岸:{(facts.get('通关口岸') or '').strip()}") + return lines return [ f"工单号:{work_order_no}", f"运输方式:{transport_mode}", @@ -1006,19 +1131,22 @@ def sea_group_brief( has_price: bool, tms_quote: dict[str, Any] | None = None, product_quote: dict[str, Any] | None = None, + transport_mode: str = "海运", ) -> str: """ 群里自动摘要:询价 + 协同 + TMS 报价 + 人工报价。 TMS 无价只写「TMS 暂无匹配报价」,产品报的价单独放人工报价,不能混进 TMS。 + 陆运传入 transport_mode=陆运,始发站/货物字段跟核对卡同一套。 """ no = (work_order_no or "").strip() sales = (sales_name or "").strip() or "销售" products = [n.strip() for n in (product_names or []) if (n or "").strip()] at_bits = [f"@{sales}"] + [f"@{n}" for n in products] + mode = (transport_mode or "海运").strip() or "海运" lines = [" ".join(at_bits), f"工单号:{no}", "询价字段:"] lines.extend( - inquiry_field_lines(work_order_no=no, facts=facts, transport_mode="海运")[1:] + inquiry_field_lines(work_order_no=no, facts=facts, transport_mode=mode)[1:] ) lines.append("") lines.append("协同补充字段:") @@ -1123,8 +1251,9 @@ def scheme_horizontal_rows( """ 卡片字段:一行一项,每格独立字数,不会像副标题整段截成 8CB…。 - 企微 horizontal 最多 6 条。运输方式写进航线行,避免丢字段。 + 企微 horizontal 最多 6 条。运输方式写进行程行,避免丢字段。 海运用货物数量/整柜或拼柜/箱型箱量,禁止套空运件数毛重体积包装。 + 陆运用运输类型/线路类别/体积重量或车型数量,禁止套海运箱型或空运件数包装。 不用 sub_title 堆字段(它会排到列表前面,且超长必截断)。 """ src = facts or {} @@ -1145,6 +1274,15 @@ def scheme_horizontal_rows( {"keyname": "时效", "value": _clip(tms_eta_display(q), 26)}, {"keyname": "报价有效期", "value": _clip(tms_valid_until_display(q), 26)}, ] + if mode == "陆运": + return [ + {"keyname": "工单号", "value": _clip(work_order_no, 26)}, + {"keyname": "线路", "value": _clip(f"陆运 {origin}→{dest}", 26)}, + {"keyname": "类型", "value": _clip(_land_scheme_type_line(src), 26)}, + {"keyname": "货物", "value": _clip(_land_scheme_goods_line(src), 26)}, + {"keyname": "时效", "value": _clip(tms_eta_display(q), 26)}, + {"keyname": "报价有效期", "value": _clip(tms_valid_until_display(q), 26)}, + ] pieces = _with_unit(str(src.get("件数") or ""), "件") weight = _with_unit(str(src.get("毛重") or ""), "KGS") volume = _with_unit(str(src.get("体积") or ""), "CBM") @@ -1159,6 +1297,39 @@ def scheme_horizontal_rows( ] +def _land_scheme_type_line(facts: dict[str, str]) -> str: + """方案卡「类型」格:运输类型、中港装载、线路类别、运输分类。""" + src = facts or {} + land_type = (src.get("运输类型") or "-").strip() or "-" + bits = [land_type] + if land_type == "中港": + load = (src.get("中港装载方式") or "").strip() + if load: + bits.append(load) + route = (src.get("线路类别") or "-").strip() or "-" + klass = (src.get("运输分类") or "-").strip() or "-" + bits.extend([route, klass]) + return ";".join(bits) + + +def _land_scheme_goods_line(facts: dict[str, str]) -> str: + """方案卡「货物」格:品名 + 当前类型的体积重量或车型/数量。""" + src = facts or {} + land_type = (src.get("运输类型") or "").strip() + bits: list[str] = [(src.get("品名") or "-").strip() or "-"] + for key in _land_cargo_keys(land_type, src): + if key in {"起运港", "目的港", "品名", "报价日期"}: + continue + val = (src.get(key) or "").strip() + if val: + bits.append(val) + if land_type in _LAND_CUSTOMS_TYPES: + customs = (src.get("通关口岸") or "").strip() + if customs: + bits.append(customs) + return ";".join(bits) + + def scheme_wecom_payload( *, work_order_no: str, @@ -1171,7 +1342,7 @@ def scheme_wecom_payload( 一张报价方案卡:标题带工单号、引用条、字段、三个短按钮。 按钮文案必须短:生成Excel / 生成PDF / 不采用。长文案横排会被裁。 - 海运/空运字段分开,禁止海运卡再出件数毛重体积。 + 海运/空运/陆运字段分开,禁止陆运卡再出箱型或空运件数包装。 """ _ = status src = dict(quote or {}) diff --git a/inquiry-agent/agent/policy/land_text_flow.py b/inquiry-agent/agent/policy/land_text_flow.py new file mode 100644 index 0000000..37a3612 --- /dev/null +++ b/inquiry-agent/agent/policy/land_text_flow.py @@ -0,0 +1,617 @@ +""" +陆运文字询价确定性流程。 + +本文件职责:选项联动 → 按类型补货物 → 无工单号核对卡 →(确定后)建单查 TMS。 +查价后半段复用海运拉群/报价单/成交,拉人按线路类别,禁止陆运岗全员。 +禁止:字段齐了就建单;直连 TMS;把海运「出卡即查价」改掉。 +调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。 +""" + +from __future__ import annotations + +import logging +import threading +from typing import Any, Optional + +from agent.ledger.memory_ledger import MemoryLedger +from agent.llm.extract_text import extract_inquiry_snapshot +from agent.policy import inquiry_copy as copy +from agent.policy.air_text_flow import FlowSession, ReplyFn +from agent.policy.sea_text_flow import SeaTextInquiryFlow +from agent.schema.field_validate import harvest_oral_measures, validate_required_fields +from agent.schema.land_options import ( + harvest_land_options, + looks_like_confirm, + option_prompt, + validate_land_options, +) +from agent.schema.tms_land_query import assemble_land_query + +logger = logging.getLogger(__name__) + +_LAND_FRONT = frozenset({"need_mode", "need_land_options", "clarify", "wait_confirm", "idle", ""}) + + +class LandTextInquiryFlow(SeaTextInquiryFlow): + """ + 陆运私聊 Owner。 + + 前半段与海运不同:核对通过前不建单。后半段套海运建群,匹配改走线路类别。 + """ + + def on_text( + self, + *, + sender_id: str, + text: str, + reply: ReplyFn, + injected_facts: Optional[dict[str, Any]] = None, + injected_mode: str = "", + b_result: Optional[dict[str, Any]] = None, + allow_b: bool = False, + chat_fn: Optional[Any] = None, + inbound_raw: Optional[dict[str, Any]] = None, + ) -> str: + """处理一句陆运私聊。返回阶段名。无 sender 拒绝。""" + if not (sender_id or "").strip(): + return "reject_no_sender" + sess = self.session_for_action(sender_id, inbound_raw, text=text) + if (not sess or sess.phase != "wait_confirm") and looks_like_confirm(text): + restored = self.restore_confirm_session(sender_id) + if restored: + sess = restored + else: + retry = self.restore_tms_retry_session(sender_id) + if retry: + sess = retry + phase = (sess.phase if sess else "") or "" + btn = self.match_button(text, sess) + if btn: + return self.on_button( + sender_id=sender_id, + action=btn, + reply=reply, + card_meta=inbound_raw, + ) + if sess and sess.phase == "tms_tech" and (sess.work_order_no or "").strip(): + if looks_like_confirm(text): + logger.info("land.tms_retry wo=%s sender=%s", sess.work_order_no, sender_id) + return self._quote_existing_land(sess, reply) + if sess and sess.phase == "wait_confirm" and looks_like_confirm(text): + # 确定查价不再抽字段,避免「确定」被模型改成空运/空 facts。 + logger.info("land.confirm sender=%s", sender_id) + return self._confirm_or_hold( + sender_id=sender_id, + facts=dict(sess.facts), + text=text, + reply=reply, + sess=sess, + ) + if sess and phase not in _LAND_FRONT: + # 查价后半段书签还在时,销售又发新陆运:只走陆运前半段。 + # 禁止再 super 去海运 on_text,海运会把 LAND 转回来,递归到死、一句都不回。 + logger.info( + "land.new_over_old phase=%s wo=%s", + phase, + sess.work_order_no or "-", + ) + sess = None + + snap = extract_inquiry_snapshot( + text, + injected_facts=injected_facts, + injected_mode=injected_mode, + b_result=b_result, + allow_b=allow_b, + chat_fn=chat_fn, + ) + mode = (snap.get("business_line") or "").upper() + snap_facts = dict(snap.get("facts") or {}) + facts = dict(snap_facts) + # DeepSeek B 把陆运类型放在 land_subtype,不在货物 facts 里。 + subtype = str(snap.get("land_subtype") or facts.get("运输类型") or "").strip() + if subtype and not str(facts.get("运输类型") or "").strip(): + facts["运输类型"] = subtype + option_keys = ("运输类型", "线路类别", "运输分类", "中港装载方式") + base = dict(sess.facts) if sess else {} + cargo = {k: v for k, v in facts.items() if k not in option_keys} + merged = dict(base) + merged.update(cargo) + facts = harvest_oral_measures(text, merged) + # 选项只从原话+能对上枚举的模型值收;禁止模型「陆运」盖掉已选的国内运输拼车。 + facts = harvest_land_options(text, facts) + b_opt = " ".join( + str(x).strip() + for x in ( + snap.get("land_subtype"), + *(snap_facts.get(k) for k in option_keys), + ) + if str(x or "").strip() + ) + if b_opt: + facts = harvest_land_options(b_opt, facts) + + if not mode: + mode = "LAND" if (sess and (sess.business_line or "").upper() == "LAND") else "" + if not mode: + reply(copy.ASK_TRANSPORT) + self._save( + FlowSession( + sender_id=sender_id, + thread_id=sender_id, + phase="need_mode", + business_line="", + facts=facts, + immutable_text=sess.immutable_text if sess and sess.immutable_text else text, + ) + ) + return "need_mode" + + if mode != "LAND": + if mode == "SEA": + from agent.policy.sea_text_flow import get_sea_text_flow + + return get_sea_text_flow().on_text( + sender_id=sender_id, + text=text, + reply=reply, + injected_facts=facts, + injected_mode=mode, + b_result=b_result, + allow_b=allow_b, + chat_fn=chat_fn, + inbound_raw=inbound_raw, + ) + from agent.policy.air_text_flow import get_air_text_flow + + return get_air_text_flow().on_text( + sender_id=sender_id, + text=text, + reply=reply, + injected_facts=facts, + injected_mode=mode, + b_result=b_result, + allow_b=allow_b, + chat_fn=chat_fn, + inbound_raw=inbound_raw, + ) + + return self._advance_front( + sender_id=sender_id, + facts=facts, + text=text, + reply=reply, + sess=sess, + ) + + def _confirm_or_hold( + self, + *, + sender_id: str, + facts: dict[str, str], + text: str, + reply: ReplyFn, + sess: FlowSession, + ) -> str: + """确定意图:字段仍齐则建单查价。""" + check = self._land_ready(facts) + if not check["ready"]: + return self._advance_front( + sender_id=sender_id, + facts=facts, + text=text, + reply=reply, + sess=sess, + ) + return self._create_and_quote_land( + sender_id=sender_id, + facts=check["facts"], + text=sess.immutable_text or text, + reply=reply, + ) + + def _create_and_quote_land( + self, + *, + sender_id: str, + facts: dict[str, str], + text: str, + reply: ReplyFn, + ) -> str: + """ + 销售确定之后才建陆运工单并查 TMS。 + + 核对卡阶段没有工单号。运输分类、中港装载方式写入工单 facts,不进入 assemble 出站。 + """ + import uuid + + tms_ready = assemble_land_query(facts) + if not tms_ready.get("ok"): + return self._advance_front( + sender_id=sender_id, + facts=facts, + text=text, + reply=reply, + sess=self.session_of(sender_id), + ) + self._save( + FlowSession( + sender_id=sender_id, + thread_id=sender_id, + phase="quoting", + business_line="LAND", + facts=facts, + immutable_text=text, + ) + ) + created = self._ledger.create_ticket( + sender_id=sender_id, + business_line="LAND", + facts=facts, + idempotency_key=f"create:{sender_id}:{uuid.uuid4().hex}", + ) + no = str(created.get("work_order_no") or "").strip() + logger.info("land.create wo=%s sender=%s", no or "-", sender_id) + if not no: + reply("建单失败,请稍后把刚才那条再发一次。陆运字段已经记下。") + return "ledger_fail" + self._clear_parked_confirm(sender_id) + first, history_wo = self._resolve_similar( + created, facts=facts, current_no=no, business_line="LAND" + ) + # 与海运一样先出询价确认(工单号+字段),再查价;禁止只回一句「工单已创建」。 + out_id = reply( + copy.inquiry_card( + work_order_no=no, + facts=facts, + first_or_same=first, + transport_mode="陆运", + ) + ) or "" + self._wait_inquiry_sent(str(out_id), work_order_no=no) + + tms = self._ledger.query_tms(work_order_no=no, facts=facts, business_line="LAND") + sess = FlowSession( + sender_id=sender_id, + thread_id=sender_id, + business_line="LAND", + facts=facts, + work_order_no=no, + first_or_same=first, + history_work_order_no=history_wo, + immutable_text=text, + wait_version=1, + ) + return self._apply_land_tms( + sess=sess, + tms=tms, + reply=reply, + retrying=False, + ) + + def _quote_existing_land(self, sess: FlowSession, reply: ReplyFn) -> str: + """工单已在:确定后只重查 TMS,禁止再开一单。""" + no = (sess.work_order_no or "").strip() + facts = dict(sess.facts or {}) + if not no or not facts: + reply(copy.LAND_CONFIRM_EXPIRED) + return "land_confirm_expired" + reply(copy.LAND_TMS_RETRYING.format(wo=no)) + tms = self._ledger.query_tms(work_order_no=no, facts=facts, business_line="LAND") + return self._apply_land_tms(sess=sess, tms=tms, reply=reply, retrying=True) + + def _apply_land_tms( + self, + *, + sess: FlowSession, + tms: dict[str, Any], + reply: ReplyFn, + retrying: bool, + ) -> str: + """查价回包落到有价/无价/技术失败。失败时把工单号记进待重查。""" + _ = retrying + no = (sess.work_order_no or "").strip() + if not tms.get("ok"): + reply("TMS 查询失败,请稍后重试。工单已创建:" + no) + sess.phase = "tms_tech" + self._save(sess) + self._park_tms_retry(sess) + return "tms_tech" + self._clear_parked_confirm(sess.sender_id) + if not tms.get("has_price"): + self._emit_tms_miss(reply, no) + sess.phase = "tms_miss" + sess.status = "询价中" + sess.allowed = (copy.BTN_PULL_COLLAB,) + self._save(sess) + return "tms_miss" + + quote = tms["quote"] + up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价") + if not up.get("ok"): + reply("主账写入报价失败,不能当作已报价。") + sess.phase = "ledger_fail" + self._save(sess) + return "ledger_fail" + sess.quote = quote + sess.status = "已报价" + sess.phase = "wait_collab" + sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB) + sess.wait_version += 1 + self._save(sess) + self._emit_sea_hit( + reply, + work_order_no=no, + quote=quote, + first_or_same=sess.first_or_same, + history_work_order_no=sess.history_work_order_no, + status="已报价", + ) + return "wait_collab" + + def _land_ready(self, facts: dict[str, str]) -> dict[str, Any]: + """选项+货物是否可出核对卡。""" + opt = validate_land_options(facts) + merged = dict(facts) + merged.update(opt.get("normalized") or {}) + if opt.get("errors"): + return {"ready": False, "opt": opt, "facts": merged, "stage": "options_error"} + if not opt.get("ok"): + return {"ready": False, "opt": opt, "facts": merged, "stage": "options_missing"} + land_type = (merged.get("运输类型") or "").strip() + check = validate_required_fields( + facts=merged, business_line="LAND", land_subtype=land_type + ) + cargo = dict(check["facts"]) + cargo.update({k: merged[k] for k in ("运输类型", "线路类别", "运输分类", "中港装载方式") if merged.get(k)}) + tms = assemble_land_query(cargo) + cargo = dict(tms.get("facts") or cargo) + cargo.update({k: merged[k] for k in ("运输类型", "线路类别", "运输分类", "中港装载方式") if merged.get(k)}) + missing = list(check.get("missing") or []) + for key in tms.get("missing") or []: + if key not in missing and key not in {"运输类型", "线路类别"}: + missing.append(key) + if not check["ok"] or not tms.get("ok"): + return { + "ready": False, + "opt": opt, + "facts": cargo, + "stage": "clarify", + "missing": missing, + "land_type": land_type, + } + return {"ready": True, "facts": cargo, "opt": opt, "land_type": land_type} + + def _advance_front( + self, + *, + sender_id: str, + facts: dict[str, str], + text: str, + reply: ReplyFn, + sess: Optional[FlowSession], + ) -> str: + """选项与货物往前推,出补问或核对卡。不建单。""" + state = self._land_ready(facts) + facts = dict(state["facts"]) + immutable = sess.immutable_text if sess and sess.immutable_text else text + if state.get("ready"): + reply(copy.land_confirm_card(facts)) + self._save( + FlowSession( + sender_id=sender_id, + thread_id=sender_id, + phase="wait_confirm", + business_line="LAND", + facts=facts, + immutable_text=immutable, + wait_version=(sess.wait_version + 1) if sess else 1, + ) + ) + self._park_confirm(sender_id, facts=facts, immutable_text=immutable) + return "wait_confirm" + if state.get("stage") == "options_error": + err = (state["opt"].get("errors") or [{}])[0] + reply(str(err.get("message") or option_prompt(current=facts))) + self._save( + FlowSession( + sender_id=sender_id, + thread_id=sender_id, + phase="need_land_options", + business_line="LAND", + facts=facts, + immutable_text=immutable, + wait_version=(sess.wait_version + 1) if sess else 1, + ) + ) + self._clear_parked_confirm(sender_id) + return "need_land_options" + if state["stage"] == "options_missing": + body = option_prompt(current=facts) + logger.info( + "land.options_missing missing=%s", + (state.get("opt") or {}).get("missing") or [], + ) + reply(body) + self._save( + FlowSession( + sender_id=sender_id, + thread_id=sender_id, + phase="need_land_options", + business_line="LAND", + facts=facts, + immutable_text=immutable, + wait_version=(sess.wait_version + 1) if sess else 1, + ) + ) + self._clear_parked_confirm(sender_id) + return "need_land_options" + reply( + copy.ask_land_fields( + facts=facts, + missing_keys=state.get("missing") or [], + land_subtype=state.get("land_type") or "", + ) + ) + self._save( + FlowSession( + sender_id=sender_id, + thread_id=sender_id, + phase="clarify", + business_line="LAND", + facts=facts, + immutable_text=immutable, + wait_version=(sess.wait_version + 1) if sess else 1, + ) + ) + self._clear_parked_confirm(sender_id) + return "clarify" + + def restore_confirm_session(self, sender_id: str) -> Optional[FlowSession]: + """ + 核对卡书签:先看进程内,没有再从短协调找回。 + + 双进程抢 inbox 或 HTTP 刚重启时,销售手里的核对卡还在,内存可能是空的。 + """ + hit = self.session_of(sender_id) + if ( + hit + and (hit.business_line or "").upper() == "LAND" + and hit.phase == "wait_confirm" + ): + return hit + from agent.redis_coord.pending_land_confirm import load_pending_land_confirm + + parked = load_pending_land_confirm(sender_id) + if str(parked.get("phase") or "wait_confirm") == "tms_tech": + return None + facts_in = parked.get("facts") if isinstance(parked.get("facts"), dict) else {} + facts = {str(k): str(v).strip() for k, v in dict(facts_in).items() if str(v).strip()} + if not facts: + return None + sess = FlowSession( + sender_id=sender_id, + thread_id=sender_id, + phase="wait_confirm", + business_line="LAND", + facts=facts, + immutable_text=str(parked.get("immutable_text") or ""), + wait_version=int(parked.get("wait_version") or 1), + ) + self._save(sess) + logger.info("land.confirm restored sender=%s", sender_id) + return sess + + def restore_tms_retry_session(self, sender_id: str) -> Optional[FlowSession]: + """ + 查价失败后等确定重查。进程内存没有时从短协调找回工单号+字段。 + """ + hit = self.session_of(sender_id) + if ( + hit + and (hit.business_line or "").upper() == "LAND" + and hit.phase == "tms_tech" + and (hit.work_order_no or "").strip() + ): + return hit + from agent.redis_coord.pending_land_confirm import load_pending_land_confirm + + parked = load_pending_land_confirm(sender_id) + if str(parked.get("phase") or "") != "tms_tech": + return None + no = str(parked.get("work_order_no") or "").strip() + facts_in = parked.get("facts") if isinstance(parked.get("facts"), dict) else {} + facts = {str(k): str(v).strip() for k, v in dict(facts_in).items() if str(v).strip()} + if not no or not facts: + return None + sess = FlowSession( + sender_id=sender_id, + thread_id=sender_id, + phase="tms_tech", + business_line="LAND", + facts=facts, + work_order_no=no, + immutable_text=str(parked.get("immutable_text") or ""), + wait_version=int(parked.get("wait_version") or 1), + ) + self._save(sess) + logger.info("land.tms_retry restored sender=%s wo=%s", sender_id, no) + return sess + + def _park_tms_retry(self, sess: FlowSession) -> None: + """查价失败:记下工单号,下一句确定只重查不建单。""" + from agent.redis_coord.pending_land_confirm import save_pending_land_confirm + + save_pending_land_confirm( + sess.sender_id, + facts=dict(sess.facts or {}), + immutable_text=sess.immutable_text or "", + wait_version=int(sess.wait_version or 1), + phase="tms_tech", + work_order_no=sess.work_order_no or "", + ) + + def _park_confirm( + self, + sender_id: str, + *, + facts: dict[str, str], + immutable_text: str, + ) -> None: + """出核对卡时记下字段。失败只打日志,不挡出卡。""" + from agent.redis_coord.pending_land_confirm import save_pending_land_confirm + + save_pending_land_confirm( + sender_id, + facts=facts, + immutable_text=immutable_text, + phase="wait_confirm", + ) + + def _clear_parked_confirm(self, sender_id: str) -> None: + """离开核对卡或已建单:清掉待确定,避免旧卡「确定」再开一单。""" + from agent.redis_coord.pending_land_confirm import clear_pending_land_confirm + + clear_pending_land_confirm(sender_id) + + def _match_sea_staff(self, sess: FlowSession) -> list[dict[str, Any]]: + """按本单线路类别整词匹配产品·陆运,禁止全员。""" + matcher = getattr(self._ledger, "match_land_staff", None) + if not callable(matcher): + return [] + route = str((sess.facts or {}).get("线路类别") or "").strip() + try: + rows = matcher(route_category=route) or [] + except Exception: + logger.exception("匹配陆运产品人员失败 wo=%s route=%s", sess.work_order_no, route) + return [] + return [dict(x) for x in rows if isinstance(x, dict)] + + def _group_no_staff_copy(self) -> str: + """本线路没有陆运产品岗,不建空群。""" + return copy.land_group_no_staff() + + def _group_brief_mode(self, sess: FlowSession) -> str: + _ = sess + return "陆运" + + +_FLOW: Optional[LandTextInquiryFlow] = None +_FLOW_GUARD = threading.Lock() + + +def get_land_text_flow() -> LandTextInquiryFlow: + """进程内一份陆运流程。""" + global _FLOW + with _FLOW_GUARD: + if _FLOW is None: + _FLOW = LandTextInquiryFlow() + return _FLOW + + +def reset_land_text_flow_for_test() -> LandTextInquiryFlow: + """单测重置全局陆运实例。""" + global _FLOW + with _FLOW_GUARD: + _FLOW = LandTextInquiryFlow(MemoryLedger()) + return _FLOW diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index 2986f96..fa03384 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -104,9 +104,10 @@ def is_sales(ticket: object, sender_id: str) -> bool: def is_product(ticket: object, sender_id: str, flow: Any = None) -> bool: """ - 产品岗:工单产品名单,或后台员工管理挂了产品·海运。 + 产品岗:工单产品名单,或后台员工管理挂了对应业务线产品岗。 同一个人可以既是拉群销售又是产品。先当销售挡掉的话,自己报价群里会没反应。 + 海运认 role_code=sea,陆运认 land。 """ uid = (sender_id or "").strip() if not uid: @@ -115,7 +116,7 @@ def is_product(ticket: object, sender_id: str, flow: Any = None) -> bool: products = {str(x) for x in (view.get("product_ids") or view.get("productIds") or [])} if uid in products: return True - if _staff_is_sea_product(flow, uid): + if _staff_is_line_product(flow, uid, ticket): return True if is_sales(ticket, uid): return False @@ -125,8 +126,8 @@ def is_product(ticket: object, sender_id: str, flow: Any = None) -> bool: return uid in members -def _staff_is_sea_product(flow: Any, wecom_id: str) -> bool: - """后台员工管理:role_code=sea 且能查到,才算产品岗。""" +def _staff_is_line_product(flow: Any, wecom_id: str, ticket: object = None) -> bool: + """后台员工管理:本单 SEA 认 sea,LAND 认 land。""" book = getattr(flow, "ledger", None) if flow is not None else None finder = getattr(book, "get_staff_by_wecom_id", None) if book is not None else None if not callable(finder): @@ -137,7 +138,15 @@ def _staff_is_sea_product(flow: Any, wecom_id: str) -> bool: logger.exception("查产品岗失败 wecom=%s", wecom_id) return False role = str(row.get("roleCode") or row.get("role_code") or "").strip().lower() - return role == "sea" + view = ticket_view(ticket) + line = str(view.get("business_line") or view.get("businessLine") or "").strip().upper() + want = "land" if line == "LAND" else "sea" + return role == want + + +def _staff_is_sea_product(flow: Any, wecom_id: str) -> bool: + """兼容旧调用:无工单时按海运岗认。""" + return _staff_is_line_product(flow, wecom_id, None) def filename_is_excel_pdf(name: str) -> bool: diff --git a/inquiry-agent/agent/policy/sea_text_flow.py b/inquiry-agent/agent/policy/sea_text_flow.py index 0a665c6..15f92af 100644 --- a/inquiry-agent/agent/policy/sea_text_flow.py +++ b/inquiry-agent/agent/policy/sea_text_flow.py @@ -121,8 +121,19 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): if mode != "SEA": if mode == "LAND": - reply(copy.ASK_LAND_SUBTYPE) - return "need_land_subtype" + from agent.policy.land_text_flow import get_land_text_flow + + return get_land_text_flow().on_text( + sender_id=sender_id, + text=text, + reply=reply, + injected_facts=facts, + injected_mode=mode, + b_result=b_result, + allow_b=allow_b, + chat_fn=chat_fn, + inbound_raw=inbound_raw, + ) # 空运交给空运 Owner,避免海运流程半做空运 from agent.policy.air_text_flow import get_air_text_flow @@ -272,20 +283,22 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): *, facts: dict[str, str], current_no: str, + business_line: str = "SEA", ) -> tuple[str, str]: history = str(created.get("similar_work_order_no") or "").strip() first = str(created.get("first_or_same") or "first") if first == "same" and history and history != current_no: return "same", history finder = getattr(self._ledger, "find_similar_open", None) + line = (business_line or "SEA").strip() or "SEA" if not callable(finder): return "first", "" try: similar = finder( - facts=facts, business_line="SEA", exclude_work_order_no=current_no + facts=facts, business_line=line, exclude_work_order_no=current_no ) or {} except TypeError: - similar = finder(facts=facts, business_line="SEA") or {} + similar = finder(facts=facts, business_line=line) or {} except Exception: logger.warning("find_similar 失败,按首次询价展示 current=%s", current_no) return "first", "" @@ -308,9 +321,9 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): def _session_from_ledger(self, *, sender_id: str, view: dict[str, Any]) -> FlowSession: sess = super()._session_from_ledger(sender_id=sender_id, view=view) - if sess.business_line == "SEA" and sess.phase == "wait_collab": + if sess.business_line in {"SEA", "LAND"} and sess.phase == "wait_collab": sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB) - if sess.business_line == "SEA" and sess.phase == "tms_miss": + if sess.business_line in {"SEA", "LAND"} and sess.phase == "tms_miss": sess.allowed = (copy.BTN_PULL_COLLAB,) sess.collab_chat_id = str(view.get("collab_chat_id") or "") sess.collab_facts = dict(view.get("collab_facts") or {}) @@ -452,7 +465,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): staff = self._match_sea_staff(sess) if not staff: - reply(copy.sea_group_no_staff()) + reply(self._group_no_staff_copy()) return sess.phase or "wait_collab" userids = [sess.sender_id] @@ -469,7 +482,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): product_ids.append(uid) product_names.append(name) if not product_ids: - reply(copy.sea_group_no_staff()) + reply(self._group_no_staff_copy()) return sess.phase or "wait_collab" # 销售 + 产品之后,必须再拉会话存档账号;没有也不挡建群。 @@ -509,12 +522,18 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): sales_name=sales_name, product_names=product_names, has_price=bool(sess.quote and sess.status == "已报价"), + transport_mode=self._group_brief_mode(sess), ) client.send_group(chat_id=chat_id, content=brief) sess.collab_chat_id = chat_id from agent.policy.sea_group_ops import send_filled_sheet - send_filled_sheet(flow=self, ticket=None, sess=sess) + send_filled_sheet( + flow=self, + ticket=None, + sess=sess, + business_line=(sess.business_line or "SEA"), + ) sess.invited_userids = tuple(userids) sess.invited_sales_name = sales_name sess.invited_product_names = tuple(product_names) @@ -547,6 +566,15 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): return [] return [dict(x) for x in rows if isinstance(x, dict)] + def _group_no_staff_copy(self) -> str: + """没人可拉时的私聊。海运用海运岗话术;陆运覆盖成陆运岗。""" + return copy.sea_group_no_staff() + + def _group_brief_mode(self, sess: FlowSession) -> str: + """群摘要运输方式展示。""" + _ = sess + return "海运" + def _archive_seat_userids(self) -> list[str]: """ 会话存档账号 userid 列表。 diff --git a/inquiry-agent/agent/quote_templates/match.py b/inquiry-agent/agent/quote_templates/match.py index 2b6932b..8588d4f 100644 --- a/inquiry-agent/agent/quote_templates/match.py +++ b/inquiry-agent/agent/quote_templates/match.py @@ -132,8 +132,8 @@ def pick_quote_template( quote: Optional[dict[str, Any]] = None, ) -> Optional[TemplatePick]: """ - 同业务线:先关键词命中,再默认模板。 - + 同业务线:先按关键词打分(运输类型上的词优先),再退回默认模板。 + 「陆运/国内」不当关键词,避免中港模板抢走拼车单。 参数 templates:主账 listEnabled 行或 TemplateEntry。 返回 None 时销售侧用 FAIL_MESSAGE。 """ @@ -141,7 +141,9 @@ def pick_quote_template( haystack = build_inquiry_haystack( business_line=business_line, facts=facts, quote=quote ).lower() + type_val = str((facts or {}).get("运输类型") or "").strip().lower() keyword_hit: Optional[dict[str, Any]] = None + best: tuple[int, int] = (-1, -1) default_hit: Optional[dict[str, Any]] = None for item in templates or []: row = _row_of(item) @@ -150,11 +152,12 @@ def pick_quote_template( continue if default_hit is None and _is_default(row.get("isDefault")): default_hit = row - if keyword_hit is None: - for kw in _as_keywords(row.get("keywords")): - if kw.lower() in haystack: - keyword_hit = row - break + score = _keyword_score(row.get("keywords"), haystack, type_val) + if score == (0, 0): + continue + if score > best: + best = score + keyword_hit = row hit = keyword_hit or default_hit if hit is None: return None @@ -165,3 +168,32 @@ def pick_quote_template( match_type=match_type, raw=hit, ) + + +_GENERIC_KEYWORDS = frozenset( + {"海运", "空运", "陆运", "sea", "air", "land", "国内"} +) + + +def _keyword_score(raw: Any, haystack: str, type_val: str) -> tuple[int, int]: + """ + 运输类型上的命中优先,其次全文词长。 + 「陆运/国内」跳过,避免中港、国内整车抢走拼车单。 + """ + type_hits = 0 + length = 0 + any_hit = False + for kw in _as_keywords(raw): + k = kw.lower() + if k in _GENERIC_KEYWORDS: + continue + if type_val and k in type_val: + type_hits += 1 + length += len(k) + any_hit = True + elif k in haystack: + length += len(k) + any_hit = True + if not any_hit: + return (0, 0) + return (type_hits, length) diff --git a/inquiry-agent/agent/redis_coord/keys.py b/inquiry-agent/agent/redis_coord/keys.py index 5d395eb..8845e64 100644 --- a/inquiry-agent/agent/redis_coord/keys.py +++ b/inquiry-agent/agent/redis_coord/keys.py @@ -55,6 +55,8 @@ WSHOTO_ROOM_TICKET = "wshoto:room_ticket" QUOTE_FILE_READY = "quote_file_ready" # 产品刚报价、等选「在TMS上改 / 不用TMS」:短协调,不当六态 PENDING_PRODUCT_QUOTE = "pending_product_quote" +# 陆运核对卡已出、等确定查价:短协调,不当六态、不当工单号 +PENDING_LAND_CONFIRM = "pending_land_confirm" # 空运群同一句:BOT 与存档补听去重,不当六态 AIR_INBOUND_MSG = "air_inbound_msg" AIR_INBOUND_TEXT = "air_inbound_text" diff --git a/inquiry-agent/agent/redis_coord/pending_land_confirm.py b/inquiry-agent/agent/redis_coord/pending_land_confirm.py new file mode 100644 index 0000000..aa56b69 --- /dev/null +++ b/inquiry-agent/agent/redis_coord/pending_land_confirm.py @@ -0,0 +1,89 @@ +""" +陆运核对卡待确定:等销售回复确定后再建单。 + +短协调,不当六态、不当 checkpoint。进程重启或双进程抢 inbox 后仍能读到刚才那份字段。 +禁止:把运输分类当已建单;写入现网 Redis 前缀。 +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from agent.redis_coord.keys import PENDING_LAND_CONFIRM +from agent.redis_coord.runtime import get_redis_runtime + +logger = logging.getLogger(__name__) + +_TTL_SEC = 24 * 3600 + + +def save_pending_land_confirm( + sender_id: str, + *, + facts: dict[str, str], + immutable_text: str = "", + wait_version: int = 1, + phase: str = "wait_confirm", + work_order_no: str = "", +) -> None: + """出核对卡或查价失败时记下字段,等确定建单/重查。""" + sid = (sender_id or "").strip() + if not sid or not isinstance(facts, dict) or not facts: + return + payload = { + "facts": {str(k): str(v) for k, v in facts.items() if str(v).strip()}, + "immutable_text": (immutable_text or "").strip(), + "wait_version": int(wait_version or 1), + "business_line": "LAND", + "phase": (phase or "wait_confirm").strip() or "wait_confirm", + "work_order_no": (work_order_no or "").strip(), + } + try: + client = get_redis_runtime().client + client.raw.set( + client.key(PENDING_LAND_CONFIRM, sid), + json.dumps(payload, ensure_ascii=False), + ex=_TTL_SEC, + ) + logger.info( + "land.pending saved sender=%s phase=%s wo=%s keys=%s", + sid, + payload["phase"], + payload["work_order_no"] or "-", + sorted(payload["facts"]), + ) + except Exception: + logger.exception("写陆运待确定失败 sender=%s", sid) + + +def load_pending_land_confirm(sender_id: str) -> dict[str, Any]: + """取出待确定陆运字段。没有或过期返回空。""" + sid = (sender_id or "").strip() + if not sid: + return {} + try: + client = get_redis_runtime().client + raw = client.raw.get(client.key(PENDING_LAND_CONFIRM, sid)) + if not raw: + return {} + if isinstance(raw, bytes): + raw = raw.decode("utf-8") + data = json.loads(raw) + return dict(data) if isinstance(data, dict) else {} + except Exception: + logger.exception("读陆运待确定失败 sender=%s", sid) + return {} + + +def clear_pending_land_confirm(sender_id: str) -> None: + """建单且查价成功后清掉。查价失败会改记 tms_tech,不要在失败路径调用。""" + sid = (sender_id or "").strip() + if not sid: + return + try: + client = get_redis_runtime().client + client.raw.delete(client.key(PENDING_LAND_CONFIRM, sid)) + except Exception: + logger.exception("清陆运待确定失败 sender=%s", sid) diff --git a/inquiry-agent/agent/routing/__init__.py b/inquiry-agent/agent/routing/__init__.py index e0d8302..c7b2a7f 100644 --- a/inquiry-agent/agent/routing/__init__.py +++ b/inquiry-agent/agent/routing/__init__.py @@ -77,6 +77,21 @@ class IntentRouter: kind = (event or "").strip().lower() if kind in {"template_card_event", "click", "templatecardevent"}: return RouteDecision(intent="card_action", confidence=1.0) + from agent.handlers.text_inquiry import is_land_confirm_action + from agent.channel.wecom.models import InboundMessage + from agent.schema.land_options import looks_like_confirm + + if looks_like_confirm(text or ""): + if is_land_confirm_action( + InboundMessage( + sender_id=sender_id, + content=text or "", + message_id="route-land-confirm", + ) + ): + logger.info("route 陆运核对卡确定:跳过闲聊模型 sender=%s", sender_id) + return RouteDecision(intent="ordinary_text_inquiry", confidence=1.0) + logger.info("route 确定类短句但无陆运核对卡,仍走模型 sender=%s", sender_id) return self.route(text=text, sender_id=sender_id) def enqueue_ordered_actions( diff --git a/inquiry-agent/agent/routing/deepseek_a.py b/inquiry-agent/agent/routing/deepseek_a.py index 40f5179..1317134 100644 --- a/inquiry-agent/agent/routing/deepseek_a.py +++ b/inquiry-agent/agent/routing/deepseek_a.py @@ -95,8 +95,9 @@ _SYSTEM = ( "那个单还没完、只贴一张已有工单号要你处理那张单,都算续办。" "工单号形态是 WO 加 12 位数字,尽量填进 target.work_order_no;抽不出则填空字符串。" "【新询价】intent=ordinary_text_inquiry,action_type=ORDINARY_TEXT_INQUIRY。" - "原话在描述新的运输需求(港口/机场、货物、件数重量体积、空运海运等)," + "原话在描述新的运输需求(港口/机场、货物、件数重量体积、空运海运陆运等)," "即使偶尔拿旧单号当参考,也走新询价,work_order_no 留空。" + "陆运核对卡之后回复确定、确认、查价、可以查了,仍是新询价,禁止标闲聊。" "【闲聊】intent=ordinary_text_other,action_type=ORDINARY_TEXT_OTHER。" "问候、谢谢、无关闲聊。点卡片按钮字(生成Excel/生成PDF/不采用等)也标闲聊。" "禁止因为销售没说指定口令就把续办判成新询价。" diff --git a/inquiry-agent/agent/routing/dispatch.py b/inquiry-agent/agent/routing/dispatch.py index e293e5f..561ab9f 100644 --- a/inquiry-agent/agent/routing/dispatch.py +++ b/inquiry-agent/agent/routing/dispatch.py @@ -14,7 +14,7 @@ from agent.channel.wecom.models import InboundMessage from agent.handlers import abnormal, card_action, close_deal, continue_thread, new_inquiry from agent.handlers.echo_text import handle_echo_text from agent.handlers.group_collab import handle_group_collab -from agent.handlers.text_inquiry import handle_text_inquiry, handle_text_other +from agent.handlers.text_inquiry import handle_text_inquiry, handle_text_other, is_land_confirm_action from agent.routing.decision import RouteDecision logger = logging.getLogger(__name__) @@ -210,12 +210,21 @@ def dispatch_inbound(message: InboundMessage, decision: RouteDecision) -> str: return "unknown_intent" logger.info( - "dispatch intent=%s sender=%s msg=%s wo=%s", + "dispatch intent=%s sender=%s msg=%s wo=%s land_confirm=%s", decision.intent, message.sender_id, message.message_id, decision.thread_id or "-", + is_land_confirm_action(message) if message.has_sender() else False, ) + if decision.intent in { + "ordinary_text_other", + "route_model_failed", + "route_parse_failed", + } and is_land_confirm_action(message): + logger.info("dispatch 陆运核对卡确定:纠正闲聊路由 sender=%s", message.sender_id) + handle_text_inquiry(message) + return "ordinary_text_inquiry" if decision.intent == "continue_thread": continue_thread.handle_continue_thread(message, thread_id=decision.thread_id) return decision.intent diff --git a/inquiry-agent/agent/schema/field_validate.py b/inquiry-agent/agent/schema/field_validate.py index 8b50f84..286091d 100644 --- a/inquiry-agent/agent/schema/field_validate.py +++ b/inquiry-agent/agent/schema/field_validate.py @@ -44,6 +44,18 @@ SEA_REQUIRED_FIELDS = ( ) SEA_DISPLAY = {key: label for key, label in SEA_REQUIRED_FIELDS} +# 陆运销售侧名称:卡片写始发站/货物品名;内部仍用合同键起运港/品名。 +LAND_DISPLAY = { + "起运港": "始发站", + "目的港": "目的地", + "品名": "货物品名", + "毛重": "重量", + "体积": "体积", + "车型/数量": "车型/数量", + "通关口岸": "通关口岸", + "报价日期": "报价日期", +} + # 协同补充字段:拉群后可静默写入,不挡首次查价。内部键仍是 HS编码。 SEA_COLLAB_FIELDS = ("贸易条款", "货好时间", "HS编码", "是否含油", "是否含电", "是否含磁") # 空运群只补含电/含磁,摘要不要把海运六项搬过去。 @@ -83,7 +95,9 @@ _ALIASES = { "重量(KG)": "毛重", "重量": "毛重", "体积(CBM)": "体积", - "车型/数量": "车型数量", + "车型/数量": "车型/数量", + "车型数量": "车型/数量", + "数量": "数量", "HS": "HS编码", "HS CODE": "HS编码", "商品海关编码": "HS编码", @@ -114,9 +128,33 @@ def normalize_facts(facts: dict[str, Any] | None) -> dict[str, str]: if not val: continue out[key] = val + _fold_land_vehicle_qty(out) return out +def _fold_land_vehicle_qty(merged: dict[str, str]) -> None: + """ + 把拆开的车型、数量收成一个「车型/数量」。 + + 销售侧只认这一项必填;模型若仍拆成两项,这里合并,禁止再分别补问。 + 不改主账。有「车型/数量」则保留原文,缺的再用车型、数量拼上。 + """ + combo = (merged.get("车型/数量") or "").strip() + vehicle = (merged.get("车型") or "").strip() + qty = (merged.get("数量") or "").strip() + if not combo: + if vehicle and qty: + combo = f"{vehicle}/{qty}" + elif vehicle: + combo = vehicle + elif qty: + combo = qty + elif qty and qty not in combo: + combo = f"{combo}/{qty}" + if combo: + merged["车型/数量"] = combo + + def _harvest_packaging(raw: str) -> str: """当前原话里最后出现的包装词;没有则空串。""" last = "" @@ -235,8 +273,13 @@ def apply_quote_date_default(facts: dict[str, str]) -> dict[str, str]: return merged -def _pricing_required_keys(contract: dict[str, Any], business_line: str, land_subtype: str) -> list[str]: - """当前激活的首次查价内部键列表。""" +def _pricing_required_keys( + contract: dict[str, Any], + business_line: str, + land_subtype: str, + facts: dict[str, str] | None = None, +) -> list[str]: + """当前激活的首次查价内部键列表。中港再按装载方式收窄。""" line = (business_line or "").strip().upper() pricing = contract.get("pricing_required") or {} if line == "SEA": @@ -248,6 +291,14 @@ def _pricing_required_keys(contract: dict[str, Any], business_line: str, land_su block = land.get(land_subtype) or {} if not block: return [] + if land_subtype == "中港": + from agent.schema.land_options import canonicalize_hk_load + + load = canonicalize_hk_load(str((facts or {}).get("中港装载方式") or "")) + by_load = block.get("by_hk_load") or {} + picked = by_load.get(load) if load else None + if isinstance(picked, list): + return list(picked) return list(block.get("always") or []) return [] @@ -259,6 +310,8 @@ def display_name(internal_key: str, business_line: str = "AIR") -> str: return AIR_DISPLAY.get(internal_key, internal_key) if line == "SEA": return SEA_DISPLAY.get(internal_key, internal_key) + if line == "LAND": + return LAND_DISPLAY.get(internal_key, internal_key) return internal_key @@ -292,6 +345,10 @@ def validate_required_fields( "errors": [], "business_line": line, } + if line == "LAND": + land_subtype = (land_subtype or "").strip() or str( + normalized.get("运输类型") or "" + ).strip() if line == "LAND" and not (land_subtype or "").strip(): return { "ok": False, @@ -305,7 +362,7 @@ def validate_required_fields( "business_line": line, } - required = _pricing_required_keys(contract, line, land_subtype) + required = _pricing_required_keys(contract, line, land_subtype, normalized) missing = [k for k in required if k != "报价日期" and not normalized.get(k)] missing_display = [display_name(k, line) for k in missing] ok = len(missing) == 0 diff --git a/inquiry-agent/agent/schema/land_options.py b/inquiry-agent/agent/schema/land_options.py new file mode 100644 index 0000000..f5aae51 --- /dev/null +++ b/inquiry-agent/agent/schema/land_options.py @@ -0,0 +1,344 @@ +""" +陆运选项规则:运输类型、线路类别、运输分类、中港装载方式。 + +本文件职责:从销售原话收取标准项、校验联动、生成补问清单、判断「确定查价」意图。 +联动错误只纠正那一项能选什么,不整单作废。 +禁止:改六态;发明枚举;把选项当货物字段。 +线程:纯函数,无共享可变状态,可在 Worker 槽调用。 +""" + +from __future__ import annotations + +from typing import Any + +# 与后台员工管理陆运「负责线路」同一套字,禁止改字。 +TRANSPORT_TYPES = ( + "国内运输拼车", + "国内运输整车", + "跨境集拼", + "跨境整车", + "中港", +) +DOMESTIC_ROUTES = ("国内长途/零担", "国内城配/拖车/打包") +CROSS_ROUTES = ("中港/中亚/中欧", "东南亚") +ROUTES_BY_TYPE = { + "国内运输拼车": DOMESTIC_ROUTES, + "国内运输整车": DOMESTIC_ROUTES, + "跨境集拼": CROSS_ROUTES, + "跨境整车": CROSS_ROUTES, + "中港": CROSS_ROUTES, +} +CROSS_CLASSES = ( + "普件,中港、东南亚常用线路/常规车型", + "普件,中亚、中欧、东南亚涉及国内+国外段", + "普件,中亚、中欧、东南亚涉及境外段清派等特殊要求", + "急件", +) +DOMESTIC_CLASSES = ( + "普件,常用线路/常规车型", + "普件,非常用线路/临时线路/特种车型", + "急件", +) +CLASSES_BY_ROUTE = { + "中港/中亚/中欧": CROSS_CLASSES, + "东南亚": CROSS_CLASSES, + "国内长途/零担": DOMESTIC_CLASSES, + "国内城配/拖车/打包": DOMESTIC_CLASSES, +} +HK_LOADS = ("整车", "零担/集拼") + +# 口语别名 → 标准运输类型。长词优先,避免「拼车」误伤「整车」。 +_TYPE_ALIASES = ( + ("国内运输拼车", "国内运输拼车"), + ("国内运输整车", "国内运输整车"), + ("国内拼车", "国内运输拼车"), + ("国内整车", "国内运输整车"), + ("跨境集拼", "跨境集拼"), + ("跨境整车", "跨境整车"), + ("中港", "中港"), +) + +_ROUTE_ALIASES = ( + ("国内城配/拖车/打包", "国内城配/拖车/打包"), + ("国内长途/零担", "国内长途/零担"), + ("中港/中亚/中欧", "中港/中亚/中欧"), + ("国内城配", "国内城配/拖车/打包"), + ("国内长途", "国内长途/零担"), + ("东南亚", "东南亚"), +) + +# 分类全称从长到短;「急件」单独出现才算急件,避免普件句被误收。 +_CLASS_CANON = tuple( + sorted( + {*(CROSS_CLASSES + DOMESTIC_CLASSES)}, + key=len, + reverse=True, + ) +) + +_HK_LOAD_ALIASES = ( + ("零担/集拼", "零担/集拼"), + ("零担", "零担/集拼"), + ("集拼", "零担/集拼"), + ("整车", "整车"), +) + +_CONFIRM_PHRASES = ("可以查了", "确定", "确认", "查价") +_CONFIRM_EXCLUDE = ("始发", "目的", "体积", "车型", "改成", "重量") + + +def canonicalize_transport_type(raw: str) -> str: + """ + 把销售原话收成标准运输类型。 + + 参数:任意一句。返回 TRANSPORT_TYPES 之一或空串。 + 副作用:无。先匹配更长别名。 + """ + text = (raw or "").strip() + if not text: + return "" + for needle, canon in _TYPE_ALIASES: + if needle in text: + return canon + return "" + + +def canonicalize_route(raw: str) -> str: + """把原话收成标准线路类别;对不上返回空串。""" + text = (raw or "").strip() + if not text: + return "" + for needle, canon in _ROUTE_ALIASES: + if needle in text: + return canon + return "" + + +def canonicalize_land_class(raw: str) -> str: + """ + 把原话收成标准运输分类。 + + 先匹配完整分类句;整句只有「急件」才收急件,避免普件描述被截成急件。 + """ + text = (raw or "").strip() + if not text: + return "" + for canon in _CLASS_CANON: + if canon == "急件": + continue + if canon in text: + return canon + stripped = text.replace("运输分类", "").replace(":", "").replace(":", "").strip() + if stripped == "急件" or text.strip() == "急件": + return "急件" + if "急件" in text and "普件" not in text: + return "急件" + return "" + + +def canonicalize_hk_load(raw: str) -> str: + """中港装载方式:整车或零担/集拼。""" + text = (raw or "").strip() + if not text: + return "" + for needle, canon in _HK_LOAD_ALIASES: + if needle in text: + return canon + return "" + + +def harvest_land_options(text: str, current: dict[str, str] | None = None) -> dict[str, str]: + """ + 从一句原话收取陆运选项,合并到已有值上。 + + 已有非空项默认保留;本句明确写出另一标准项时才覆盖。 + 已有值对不上标准枚举则丢掉,避免模型把「陆运」写成运输类型后整张清单重问。 + 不改货物字段。返回新 dict,不修改 current。 + """ + merged = { + k: str(v).strip() + for k, v in dict(current or {}).items() + if str(v).strip() + } + _keep_canonical_options(merged) + blob = text or "" + found_type = canonicalize_transport_type(blob) + found_route = canonicalize_route(blob) + found_class = canonicalize_land_class(blob) + found_load = canonicalize_hk_load(blob) + if found_type: + merged["运输类型"] = found_type + if found_route: + merged["线路类别"] = found_route + if found_class: + merged["运输分类"] = found_class + if found_load: + merged["中港装载方式"] = found_load + return merged + + +def _keep_canonical_options(merged: dict[str, str]) -> None: + """只留能对上枚举的选项;模型胡写的「陆运」不能冒充运输类型。""" + t = canonicalize_transport_type(merged.get("运输类型") or "") + if t: + merged["运输类型"] = t + else: + merged.pop("运输类型", None) + r = canonicalize_route(merged.get("线路类别") or "") + if r: + merged["线路类别"] = r + else: + merged.pop("线路类别", None) + c = canonicalize_land_class(merged.get("运输分类") or "") + if c: + merged["运输分类"] = c + else: + merged.pop("运输分类", None) + load = canonicalize_hk_load(merged.get("中港装载方式") or "") + if load: + merged["中港装载方式"] = load + else: + merged.pop("中港装载方式", None) + + +def validate_land_options(opts: dict[str, str]) -> dict[str, Any]: + """ + 校验选项是否齐全且联动正确。 + + 返回 ok / missing / errors。errors 每项 {field, message},只纠正错的那一项。 + 中港缺装载方式进 missing,不改货物字段。 + 副作用:无。 + """ + src = {k: str(v).strip() for k, v in dict(opts or {}).items() if str(v).strip()} + t = canonicalize_transport_type(src.get("运输类型") or "") or src.get("运输类型") or "" + r = canonicalize_route(src.get("线路类别") or "") or src.get("线路类别") or "" + c = canonicalize_land_class(src.get("运输分类") or "") or src.get("运输分类") or "" + load = canonicalize_hk_load(src.get("中港装载方式") or "") or src.get("中港装载方式") or "" + + missing: list[str] = [] + errors: list[dict[str, str]] = [] + + if t not in TRANSPORT_TYPES: + missing.append("运输类型") + t = "" + + if t: + allowed_routes = ROUTES_BY_TYPE.get(t) or () + if not r: + missing.append("线路类别") + elif r not in allowed_routes: + errors.append( + { + "field": "线路类别", + "message": "线路类别只能选:" + "、".join(f"「{x}」" for x in allowed_routes), + } + ) + r = "" + elif r: + # 类型还没有,线路先记下但不判联动 + pass + + if r: + allowed_cls = CLASSES_BY_ROUTE.get(r) or () + if not c: + missing.append("运输分类") + elif c not in allowed_cls: + errors.append( + { + "field": "运输分类", + "message": "运输分类只能选:" + "、".join(f"「{x}」" for x in allowed_cls), + } + ) + c = "" + elif "运输分类" not in missing and not c: + missing.append("运输分类") + + if t == "中港": + if load not in HK_LOADS: + missing.append("中港装载方式") + ok = not missing and not errors + return { + "ok": ok, + "missing": missing, + "errors": errors, + "normalized": { + "运输类型": t, + "线路类别": r, + "运输分类": c, + "中港装载方式": load if t == "中港" else "", + }, + } + + +def option_prompt(*, current: dict[str, str]) -> str: + """ + 按已识别项生成补问清单。 + + 全缺:一次列出类型、线路、分类、中港装载。 + 类型已知:不再问类型,只列合法线路/分类。 + 不修改 current。 + """ + src = {k: str(v).strip() for k, v in dict(current or {}).items() if str(v).strip()} + t = canonicalize_transport_type(src.get("运输类型") or "") or src.get("运输类型") or "" + r = canonicalize_route(src.get("线路类别") or "") or src.get("线路类别") or "" + lines: list[str] = [] + + if t not in TRANSPORT_TYPES: + lines.append("请一次选择:运输类型、线路类别、运输分类。") + lines.append("") + lines.append("运输类型:") + lines.append("、".join(TRANSPORT_TYPES)) + lines.append("") + lines.append("线路类别(跟运输类型联动):") + lines.append("国内运输拼车 / 国内运输整车:" + "、".join(DOMESTIC_ROUTES)) + lines.append("跨境集拼 / 跨境整车 / 中港:" + "、".join(CROSS_ROUTES)) + lines.append("") + lines.append("运输分类(跟线路类别联动):") + lines.append("中港/中亚/中欧、东南亚:" + ";".join(CROSS_CLASSES)) + lines.append("国内长途/零担、国内城配/拖车/打包:" + ";".join(DOMESTIC_CLASSES)) + lines.append("") + lines.append("运输类型选中港时,还要选择:" + "、".join(HK_LOADS)) + return "\n".join(lines).strip() + + # 类型已认出来:不再问运输类型 + allowed_routes = ROUTES_BY_TYPE.get(t) or () + if r not in allowed_routes: + lines.append(f"运输类型已选「{t}」。请选择线路类别:") + lines.append("、".join(allowed_routes)) + route_for_class = "" + else: + route_for_class = r + + if route_for_class: + allowed_cls = CLASSES_BY_ROUTE.get(route_for_class) or () + if src.get("运输分类") not in allowed_cls: + lines.append(f"线路类别已选「{route_for_class}」。请选择运输分类:") + lines.append(";".join(allowed_cls)) + elif t in TRANSPORT_TYPES: + # 线路未定,把该类型下各线路的分类一并列出,方便一次粘贴 + seen: set[str] = set() + lines.append("运输分类(跟线路类别联动):") + for route in allowed_routes: + if route in seen: + continue + seen.add(route) + lines.append(f"{route}:" + ";".join(CLASSES_BY_ROUTE.get(route) or ())) + + if t == "中港" and (src.get("中港装载方式") not in HK_LOADS): + lines.append("中港请再选择装载方式:" + "、".join(HK_LOADS)) + return "\n".join(lines).strip() + + +def looks_like_confirm(text: str) -> bool: + """ + 判断是否在表示确定查价。 + + 「确定」「确认」「查价」「可以查了」及相近说法都算。 + 同一句像在改始发/目的/体积/车型/「改成」则不当确定,避免改内容被当成查价。 + """ + raw = (text or "").strip() + if not raw: + return False + if any(x in raw for x in _CONFIRM_EXCLUDE): + return False + return any(p in raw for p in _CONFIRM_PHRASES) diff --git a/inquiry-agent/agent/schema/tms_land_query.py b/inquiry-agent/agent/schema/tms_land_query.py new file mode 100644 index 0000000..2cfefec --- /dev/null +++ b/inquiry-agent/agent/schema/tms_land_query.py @@ -0,0 +1,177 @@ +""" +陆运查价参数:对照 TMS 出站是否带全。 + +本文件职责:核对卡通过后、建单查价前,确认当前运输类型的货物字段能拼出查价正文。 +带去:运输类型、线路类别、始发/目的/品名/报价日期,以及该类型的体积重量或车型/数量;通关口岸有值才带。 +出站类型/线路必须译成 TMS 6.1.1 英文枚举,线路键名 routeCategory。工单与卡片仍用中文。 +中港整车不带车型/数量。车型/数量是一个字段,出站再拆。 +禁止:把运输分类、中港装载方式放进出站;直连 TMS;改六态。 +线程:纯函数。 +""" + +from __future__ import annotations + +import logging +import re +from typing import Any + +from agent.schema.field_validate import apply_quote_date_default, normalize_facts +from agent.schema.land_options import canonicalize_hk_load + +logger = logging.getLogger(__name__) + +LCL_TYPES = frozenset({"国内运输拼车", "跨境集拼"}) +FTL_TYPES = frozenset({"国内运输整车", "跨境整车"}) +CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港"}) + +_TYPE_ENUM = { + "国内运输拼车": "DOMESTIC_LTL", + "国内运输整车": "DOMESTIC_FTL", + "跨境集拼": "CROSS_BORDER_LTL", + "跨境整车": "CROSS_BORDER_FTL", + "中港": "CHINA_HK", +} +_ROUTE_ENUM = { + "国内长途/零担": "DOMESTIC_LONG_LTL", + "国内城配/拖车/打包": "DOMESTIC_CITY_DIST", + "中港/中亚/中欧": "CN_HK_CA_EU", + "东南亚": "SOUTHEAST_ASIA", +} + +_NUMBER = re.compile(r"(\d+(?:\.\d+)?)") +_VEHICLE_SPLIT = re.compile(r"\s*[/xX×**]\s*") +_TAIL_QTY = re.compile(r"^(.*?)[\s,,]+(\d+)\s*(台|辆)?$") + + +def tms_land_type_code(raw: str) -> str: + """销售中文运输类型 → TMS 6.1.1 landTransportType。对不上空串。""" + text = (raw or "").strip() + if text in _TYPE_ENUM: + return _TYPE_ENUM[text] + if text in set(_TYPE_ENUM.values()): + return text + return "" + + +def tms_land_route_code(raw: str) -> str: + """销售中文线路类别 → TMS 6.1.1 routeCategory。对不上空串。""" + text = (raw or "").strip() + if text in _ROUTE_ENUM: + return _ROUTE_ENUM[text] + if text in set(_ROUTE_ENUM.values()): + return text + return "" + + +def _first_number(raw: str) -> str: + hit = _NUMBER.search((raw or "").strip()) + return hit.group(1) if hit else "" + + +def split_vehicle_qty(raw: str) -> tuple[str, str]: + """ + 把「车型/数量」拆成出站车型与台数。 + + 例:9米6/1、9米6*2、9米6 1台。拆不出台数时台数默认 1,不回头补问。 + 不修改入参。 + """ + text = (raw or "").strip() + if not text: + return "", "" + parts = _VEHICLE_SPLIT.split(text, maxsplit=1) + if len(parts) == 2 and parts[0].strip() and parts[1].strip(): + return parts[0].strip(), _first_number(parts[1]) or parts[1].strip() + hit = _TAIL_QTY.match(text) + if hit and hit.group(1).strip(): + return hit.group(1).strip(), hit.group(2) + return text, "1" + + +def assemble_land_query(facts: dict[str, Any] | None) -> dict[str, Any]: + """ + 把已采集陆运字段拼成 TMS 查价参数。 + + 不齐则 ok=False,missing 给销售补问。齐了也不带分类/中港装载。 + 改了运输类型后,outbound 只带当前类型需要的键。 + """ + src = apply_quote_date_default( + normalize_facts({str(k): v for k, v in dict(facts or {}).items() if v is not None}) + ) + # 选项键不走港口别名折叠,再从原文补回 + raw = {str(k): str(v).strip() for k, v in dict(facts or {}).items() if v is not None} + land_type = (raw.get("运输类型") or src.get("运输类型") or "").strip() + route = (raw.get("线路类别") or src.get("线路类别") or "").strip() + origin = (src.get("起运港") or "").strip() + dest = (src.get("目的港") or "").strip() + cargo = (src.get("品名") or "").strip() + quote_date = (src.get("报价日期") or "").strip() + customs = (raw.get("通关口岸") or src.get("通关口岸") or "").strip() + hk_load = canonicalize_hk_load(raw.get("中港装载方式") or src.get("中港装载方式") or "") + + missing: list[str] = [] + if not land_type: + missing.append("运输类型") + if not route: + missing.append("线路类别") + if not origin: + missing.append("起运港") + if not dest: + missing.append("目的港") + if not cargo: + missing.append("品名") + + outbound: dict[str, Any] = { + "transportMode": "LAND", + "landTransportType": tms_land_type_code(land_type), + "routeCategory": tms_land_route_code(route), + "origin": origin, + "destination": dest, + "cargoName": cargo, + "quoteDate": quote_date, + } + + if land_type in LCL_TYPES: + vol = _first_number(src.get("体积") or "") + wgt = _first_number(src.get("毛重") or "") + if not vol: + missing.append("体积") + else: + outbound["volumeCbm"] = vol + if not wgt: + missing.append("毛重") + else: + outbound["weightKg"] = wgt + elif land_type == "中港" and hk_load != "零担/集拼": + # 中港整车(或尚未选定装载)不采集、不带出车型/数量 + pass + elif land_type in FTL_TYPES or land_type == "中港": + combo = ( + src.get("车型/数量") + or raw.get("车型/数量") + or src.get("车型") + or raw.get("车型") + or "" + ).strip() + if not combo: + missing.append("车型/数量") + else: + vehicle, qty = split_vehicle_qty(combo) + leftover_qty = (src.get("数量") or raw.get("数量") or "").strip() + if leftover_qty and leftover_qty not in combo: + qty = leftover_qty + outbound["vehicleType"] = vehicle + outbound["quantity"] = qty or "1" + elif land_type: + missing.append("运输类型") + + if land_type in CUSTOMS_TYPES and customs: + outbound["customsPort"] = customs + + ok = len(missing) == 0 + logger.info("tms.land.assemble ok=%s missing=%s type=%s", ok, missing, land_type or "-") + return { + "ok": ok, + "missing": missing, + "facts": src, + "outbound": outbound, + } diff --git a/inquiry-agent/tests/test_land_copy.py b/inquiry-agent/tests/test_land_copy.py new file mode 100644 index 0000000..fccf9aa --- /dev/null +++ b/inquiry-agent/tests/test_land_copy.py @@ -0,0 +1,206 @@ +""" +陆运核对卡与无产品话术。 +""" + +from __future__ import annotations + +import os +import sys +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +from agent.policy import inquiry_copy as copy + + +class LandCopyTest(unittest.TestCase): + def test_domestic_card_no_customs_no_wo(self): + text = copy.land_confirm_card( + { + "运输类型": "国内运输拼车", + "线路类别": "国内长途/零担", + "运输分类": "急件", + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + "报价日期": "2026-09-17", + } + ) + self.assertIn("始发站(必填):广州", text) + self.assertNotIn("通关口岸", text) + self.assertNotIn("WO", text) + self.assertIn('请回复“确定”进行TMS查价,内容有误时,请重新发内容。', text) + self.assertIn("运输类型", text) + self.assertIn("国内运输拼车", text) + + def test_cross_empty_customs(self): + text = copy.land_confirm_card( + { + "运输类型": "跨境集拼", + "线路类别": "东南亚", + "运输分类": "急件", + "起运港": "广州", + "目的港": "河内", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + "报价日期": "2026-09-17", + } + ) + self.assertIn("通关口岸(非必填):", text) + + def test_hk_shows_load(self): + text = copy.land_confirm_card( + { + "运输类型": "中港", + "线路类别": "东南亚", + "运输分类": "急件", + "中港装载方式": "零担/集拼", + "起运港": "广州", + "目的港": "香港", + "品名": "衣服", + "车型/数量": "9米6/1", + "报价日期": "2026-09-17", + } + ) + self.assertIn("零担/集拼", text) + self.assertIn("车型/数量(必填):9米6/1", text) + self.assertNotIn("\n数量(必填)", text) + + def test_hk_full_truck_hides_vehicle(self): + text = copy.land_confirm_card( + { + "运输类型": "中港", + "线路类别": "东南亚", + "运输分类": "急件", + "中港装载方式": "整车", + "起运港": "广州", + "目的港": "香港", + "品名": "衣服", + "报价日期": "2026-09-17", + } + ) + self.assertIn("整车", text) + self.assertNotIn("车型/数量", text) + + def test_domestic_ftl_one_field(self): + text = copy.land_confirm_card( + { + "运输类型": "国内运输整车", + "线路类别": "国内长途/零担", + "运输分类": "急件", + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "车型/数量": "9米6/1", + "报价日期": "2026-09-17", + } + ) + self.assertIn("车型/数量(必填):9米6/1", text) + self.assertNotIn("\n数量(必填)", text) + + def test_no_staff_copy(self): + self.assertIn("陆运产品岗", copy.land_group_no_staff()) + + def test_inquiry_card_lists_land_fields_and_wo(self): + text = copy.inquiry_card( + work_order_no="WO202609170006", + facts={ + "运输类型": "国内运输拼车", + "线路类别": "国内长途/零担", + "运输分类": "急件", + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + "报价日期": "2026-09-17", + }, + transport_mode="陆运", + ) + self.assertIn("工单号:WO202609170006", text) + self.assertIn("运输方式:陆运", text) + self.assertIn("运输类型:国内运输拼车", text) + self.assertIn("始发站:广州", text) + self.assertIn("货物品名:衣服", text) + self.assertIn("工单已创建,正在查询TMS报价。", text) + self.assertNotIn("件数", text) + + def test_scheme_card_uses_land_fields_not_sea(self): + facts = { + "运输类型": "国内运输拼车", + "线路类别": "国内城配/拖车/打包", + "运输分类": "普件,常用线路/常规车型", + "起运港": "泉州市", + "目的港": "南京市", + "品名": "通信机柜及线缆配件", + "体积": "10CBM", + "毛重": "10KG", + "报价日期": "2026-09-17", + } + quote = {"total": "CNY 1200", "eta": "3天", "valid_until": "2026-09-20"} + text = copy.scheme_card( + work_order_no="WO202609170011", + status="已报价", + facts=facts, + quote=quote, + transport_mode="陆运", + ) + self.assertIn("运输方式:陆运", text) + self.assertIn("运输类型:国内运输拼车", text) + self.assertIn("始发站:泉州市", text) + self.assertIn("货物品名:通信机柜及线缆配件", text) + self.assertIn("体积:10CBM", text) + self.assertNotIn("整柜或拼柜", text) + self.assertNotIn("箱型箱量", text) + self.assertNotIn("件数:", text) + self.assertNotIn("包装类型:", text) + rows = { + x["keyname"]: x["value"] + for x in copy.scheme_horizontal_rows( + work_order_no="WO202609170011", + facts=facts, + quote=quote, + transport_mode="陆运", + ) + } + self.assertIn("陆运 泉州市→南京市", rows["线路"]) + self.assertIn("国内运输拼车", rows["类型"]) + self.assertIn("国内城配/拖车/打包", rows["类型"]) + self.assertIn("通信机柜及线缆配件", rows["货物"]) + self.assertIn("10CBM", rows["货物"]) + self.assertNotIn("航线", rows) + self.assertNotIn("箱型", rows) + self.assertNotIn("包装", rows) + self.assertNotIn("空运", "".join(rows.values())) + self.assertNotIn("海运", "".join(rows.values())) + + def test_scheme_card_ftl_shows_vehicle_combo(self): + facts = { + "运输类型": "国内运输整车", + "线路类别": "国内长途/零担", + "运输分类": "急件", + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "车型/数量": "9米6/1", + } + rows = { + x["keyname"]: x["value"] + for x in copy.scheme_horizontal_rows( + work_order_no="WO202609170012", + facts=facts, + quote={}, + transport_mode="陆运", + ) + } + self.assertIn("9米6/1", rows["货物"]) + self.assertNotIn("CBM", rows["货物"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-agent/tests/test_land_dispatch.py b/inquiry-agent/tests/test_land_dispatch.py new file mode 100644 index 0000000..dbb0445 --- /dev/null +++ b/inquiry-agent/tests/test_land_dispatch.py @@ -0,0 +1,259 @@ +""" +入站选路:陆运书签/本句陆运交给 LandTextInquiryFlow;海运立即建单不变。 +""" + +from __future__ import annotations + +import os +import sys +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +os.environ.setdefault("YTD_ENV", "test") +os.environ.setdefault("REDIS_BACKEND", "memory") +os.environ.setdefault("CHECKPOINT_BACKEND", "memory") +os.environ.setdefault("MESSAGE_STORE_BACKEND", "memory") +os.environ.setdefault("LLM_DATA_USAGE_CONFIRMED", "false") +os.environ.setdefault("LLM_ALLOW_NETWORK", "false") +os.environ.setdefault("LEDGER_BACKEND", "memory") + +from agent.channel.queue import MemoryMessageStore, get_message_store +from agent.channel.wecom.models import InboundMessage +from agent.handlers.text_inquiry import handle_text_inquiry +from agent.policy.air_text_flow import reset_air_text_flow_for_test +from agent.policy.land_text_flow import get_land_text_flow, reset_land_text_flow_for_test +from agent.policy.sea_text_flow import reset_sea_text_flow_for_test +from agent.routing.decision import RouteDecision +from agent.routing.dispatch import dispatch_inbound +from tests.test_sea_text_inquiry import COMPLETE_SEA + + +class LandDispatchTests(unittest.TestCase): + def setUp(self) -> None: + reset_sea_text_flow_for_test() + reset_land_text_flow_for_test() + reset_air_text_flow_for_test() + self.store = MemoryMessageStore() + + def _outbox_text(self) -> str: + return "\n".join(i.content for i in self.store._outbox.values()) + + def test_handler_routes_land_to_option_sheet(self) -> None: + msg = InboundMessage(sender_id="ld1", message_id="m-land-1", content="陆运询价") + phase = handle_text_inquiry( + msg, + injected_facts={"起运港": "广州", "目的港": "深圳", "品名": "衣服"}, + injected_mode="LAND", + store=self.store, + ) + self.assertEqual(phase, "need_land_options") + body = self._outbox_text() + self.assertIn("运输类型", body) + self.assertIn("线路类别", body) + self.assertIn("运输分类", body) + self.assertNotIn("请补充陆运类型:", body) + + def test_air_owner_forwards_land_to_option_sheet(self) -> None: + from agent.policy.air_text_flow import get_air_text_flow + + replies: list[str] = [] + phase = get_air_text_flow().on_text( + sender_id="ld-air", + text="陆运询价", + reply=lambda t, extra=None: replies.append(t) or "", + injected_facts={"起运港": "广州", "目的港": "深圳", "品名": "衣服"}, + injected_mode="LAND", + ) + self.assertEqual(phase, "need_land_options") + text = "\n".join(replies) + self.assertIn("线路类别", text) + self.assertIn("运输分类", text) + self.assertNotIn("请补充陆运类型:", text) + + def test_land_session_survives_confirm(self) -> None: + msg1 = InboundMessage(sender_id="ld2", message_id="m-l2a", content="陆运") + facts = { + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + } + phase = handle_text_inquiry( + msg1, + injected_facts=facts, + injected_mode="LAND", + store=self.store, + ) + # 补选项走同一 sender + msg2 = InboundMessage( + sender_id="ld2", + message_id="m-l2b", + content="国内运输拼车 国内长途/零担 急件", + ) + phase = handle_text_inquiry( + msg2, + injected_facts=facts, + injected_mode="LAND", + store=self.store, + ) + self.assertEqual(phase, "wait_confirm") + msg3 = InboundMessage(sender_id="ld2", message_id="m-l2c", content="确定") + phase = handle_text_inquiry(msg3, store=self.store) + self.assertEqual(phase, "wait_collab") + + def test_confirm_misrouted_as_other_still_creates(self) -> None: + store = get_message_store() + facts = { + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + } + handle_text_inquiry( + InboundMessage(sender_id="ld3", message_id="m-l3a", content="陆运"), + injected_facts=facts, + injected_mode="LAND", + store=store, + ) + handle_text_inquiry( + InboundMessage( + sender_id="ld3", + message_id="m-l3b", + content="国内运输拼车 国内长途/零担 急件", + ), + injected_facts=facts, + injected_mode="LAND", + store=store, + ) + self.assertEqual(get_land_text_flow().session_of("ld3").phase, "wait_confirm") + name = dispatch_inbound( + InboundMessage(sender_id="ld3", message_id="m-l3c", content="确定"), + RouteDecision(intent="ordinary_text_other"), + ) + self.assertEqual(name, "ordinary_text_inquiry") + sess = get_land_text_flow().session_of("ld3") + self.assertEqual(sess.phase, "wait_collab") + self.assertTrue((sess.work_order_no or "").startswith("WO")) + bodies = "\n".join(i.content for i in store._outbox.values()) + self.assertIn(sess.work_order_no, bodies) + last = list(store._outbox.values())[-1].content + self.assertNotIn("你好,我是询价机器人", last) + + def test_confirm_after_memory_lost_still_creates(self) -> None: + store = get_message_store() + facts = { + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + } + handle_text_inquiry( + InboundMessage(sender_id="ld4", message_id="m-l4a", content="陆运"), + injected_facts=facts, + injected_mode="LAND", + store=store, + ) + handle_text_inquiry( + InboundMessage( + sender_id="ld4", + message_id="m-l4b", + content="国内运输拼车 国内长途/零担 急件", + ), + injected_facts=facts, + injected_mode="LAND", + store=store, + ) + self.assertEqual(get_land_text_flow().session_of("ld4").phase, "wait_confirm") + reset_land_text_flow_for_test() + self.assertIsNone(get_land_text_flow().session_of("ld4")) + name = dispatch_inbound( + InboundMessage(sender_id="ld4", message_id="m-l4c", content="确定"), + RouteDecision(intent="ordinary_text_other"), + ) + self.assertEqual(name, "ordinary_text_inquiry") + sess = get_land_text_flow().session_of("ld4") + self.assertEqual(sess.phase, "wait_collab") + self.assertTrue((sess.work_order_no or "").startswith("WO")) + last = list(store._outbox.values())[-1].content + self.assertNotIn("你好,我是询价机器人", last) + + def test_tms_retry_misrouted_as_other(self) -> None: + from tests.test_land_text_inquiry import CountingLedger + + class Flaky(CountingLedger): + def __init__(self) -> None: + super().__init__() + self.query_calls = 0 + + def query_tms(self, **kwargs): + self.query_calls += 1 + if self.query_calls == 1: + return { + "ok": False, + "classification": "TMS_TECH_FAILURE", + "has_price": False, + "quote": {}, + } + return super().query_tms(**kwargs) + + land = reset_land_text_flow_for_test() + land.ledger = Flaky() + facts = { + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + } + handle_text_inquiry( + InboundMessage(sender_id="ld5", message_id="m-l5a", content="陆运"), + injected_facts=facts, + injected_mode="LAND", + store=self.store, + ) + handle_text_inquiry( + InboundMessage( + sender_id="ld5", + message_id="m-l5b", + content="国内运输拼车 国内长途/零担 急件", + ), + injected_facts=facts, + injected_mode="LAND", + store=self.store, + ) + handle_text_inquiry( + InboundMessage(sender_id="ld5", message_id="m-l5c", content="确定"), + store=self.store, + ) + self.assertEqual(get_land_text_flow().session_of("ld5").phase, "tms_tech") + wo = get_land_text_flow().session_of("ld5").work_order_no + name = dispatch_inbound( + InboundMessage(sender_id="ld5", message_id="m-l5d", content="确定"), + RouteDecision(intent="ordinary_text_other"), + ) + self.assertEqual(name, "ordinary_text_inquiry") + sess = get_land_text_flow().session_of("ld5") + self.assertEqual(sess.phase, "wait_collab") + self.assertEqual(sess.work_order_no, wo) + last = list(self.store._outbox.values())[-1].content + self.assertNotIn("你好,我是询价机器人", last) + + def test_sea_still_creates_immediately(self) -> None: + msg = InboundMessage(sender_id="s-sea", message_id="m-sea-keep", content="海运询价") + phase = handle_text_inquiry( + msg, + injected_facts=COMPLETE_SEA, + injected_mode="SEA", + store=self.store, + ) + self.assertEqual(phase, "wait_collab") + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-agent/tests/test_land_field_validate.py b/inquiry-agent/tests/test_land_field_validate.py new file mode 100644 index 0000000..c94a05d --- /dev/null +++ b/inquiry-agent/tests/test_land_field_validate.py @@ -0,0 +1,109 @@ +""" +陆运五套询价必填校验。不连 TMS。 +""" + +from __future__ import annotations + +import os +import sys +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +from agent.schema.field_validate import display_name, validate_required_fields + + +class LandFieldValidateTest(unittest.TestCase): + def test_ltl_missing_volume(self): + got = validate_required_fields( + facts={"起运港": "广州", "目的港": "深圳", "品名": "衣服", "毛重": "50kg"}, + business_line="LAND", + land_subtype="国内运输拼车", + ) + self.assertFalse(got["ok"]) + self.assertIn("体积", got["missing"]) + + def test_hk_lcl_still_needs_vehicle(self): + got = validate_required_fields( + facts={ + "起运港": "广州", + "目的港": "香港", + "品名": "衣服", + "体积": "10CBM", + "毛重": "50kg", + "中港装载方式": "零担/集拼", + }, + business_line="LAND", + land_subtype="中港", + ) + self.assertIn("车型/数量", got["missing"]) + self.assertNotIn("数量", got["missing"]) + self.assertNotIn("车型", got["missing"]) + + def test_hk_full_truck_skips_vehicle(self): + got = validate_required_fields( + facts={ + "起运港": "广州", + "目的港": "香港", + "品名": "衣服", + "中港装载方式": "整车", + }, + business_line="LAND", + land_subtype="中港", + ) + self.assertTrue(got["ok"]) + self.assertNotIn("车型/数量", got["missing"]) + + def test_domestic_ftl_one_field(self): + got = validate_required_fields( + facts={ + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "车型/数量": "9米6/1", + }, + business_line="LAND", + land_subtype="国内运输整车", + ) + self.assertTrue(got["ok"]) + self.assertEqual(got["facts"]["车型/数量"], "9米6/1") + + def test_cross_ftl_folds_split_keys(self): + got = validate_required_fields( + facts={ + "起运港": "广州", + "目的港": "河内", + "品名": "衣服", + "车型": "9米6", + "数量": "1", + }, + business_line="LAND", + land_subtype="跨境整车", + ) + self.assertTrue(got["ok"]) + self.assertEqual(got["facts"]["车型/数量"], "9米6/1") + + def test_quote_date_defaults(self): + got = validate_required_fields( + facts={ + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + }, + business_line="LAND", + land_subtype="国内运输拼车", + ) + self.assertTrue(got["ok"]) + self.assertRegex(got["facts"]["报价日期"], r"^\d{4}-\d{2}-\d{2}$") + + def test_land_display_names(self): + self.assertEqual(display_name("起运港", "LAND"), "始发站") + self.assertEqual(display_name("品名", "LAND"), "货物品名") + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-agent/tests/test_land_options.py b/inquiry-agent/tests/test_land_options.py new file mode 100644 index 0000000..eb25527 --- /dev/null +++ b/inquiry-agent/tests/test_land_options.py @@ -0,0 +1,106 @@ +""" +陆运选项联动单测:类型/线路/分类/中港装载,选错只纠正该项。 + +不连企微、主账、TMS。 +""" + +from __future__ import annotations + +import os +import sys +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +from agent.schema.land_options import ( + harvest_land_options, + looks_like_confirm, + option_prompt, + validate_land_options, +) + + +class LandOptionsTest(unittest.TestCase): + def test_domestic_ltl_rejects_sea_route(self): + got = validate_land_options( + { + "运输类型": "国内运输拼车", + "线路类别": "东南亚", + "运输分类": "急件", + } + ) + self.assertFalse(got["ok"]) + msg = got["errors"][0]["message"] + self.assertIn("国内长途/零担", msg) + self.assertIn("国内城配/拖车/打包", msg) + + def test_followup_route_class_keeps_type(self): + cur = harvest_land_options( + "国内城配/拖车/打包;普件,常用线路/常规车型", + current={"运输类型": "国内运输拼车"}, + ) + self.assertEqual(cur["运输类型"], "国内运输拼车") + self.assertEqual(cur["线路类别"], "国内城配/拖车/打包") + self.assertEqual(cur["运输分类"], "普件,常用线路/常规车型") + + def test_model_land_does_not_wipe_type(self): + dropped = harvest_land_options( + "国内城配/拖车/打包;普件,常用线路/常规车型", + current={"运输类型": "陆运"}, + ) + self.assertFalse(dropped.get("运输类型")) + cur = harvest_land_options( + "国内城配/拖车/打包;普件,常用线路/常规车型", + current={"运输类型": "国内运输拼车"}, + ) + junk = harvest_land_options("陆运", current=cur) + self.assertEqual(junk["运输类型"], "国内运输拼车") + self.assertEqual(junk["线路类别"], "国内城配/拖车/打包") + cur = harvest_land_options("广州到深圳国内拼车衣服100方") + self.assertEqual(cur["运输类型"], "国内运输拼车") + self.assertFalse(cur.get("线路类别")) + + def test_full_prompt_when_empty(self): + text = option_prompt(current={}) + self.assertIn("运输类型", text) + self.assertIn("线路类别", text) + self.assertIn("运输分类", text) + self.assertIn("国内运输拼车", text) + self.assertIn("整车", text) + self.assertNotIn("请补充陆运类型:", text) + + def test_copy_alias_lists_all_three(self): + from agent.policy import inquiry_copy as copy + + text = copy.ASK_LAND_SUBTYPE + self.assertIn("运输类型", text) + self.assertIn("线路类别", text) + self.assertIn("运输分类", text) + self.assertNotIn("请补充陆运类型:国内运输拼车", text) + + def test_narrow_prompt_when_type_known(self): + text = option_prompt(current={"运输类型": "国内运输拼车"}) + self.assertIn("国内长途/零担", text) + self.assertNotIn("请选择运输类型", text) + + def test_hk_needs_load(self): + got = validate_land_options( + { + "运输类型": "中港", + "线路类别": "东南亚", + "运输分类": "急件", + } + ) + self.assertIn("中港装载方式", got["missing"]) + + def test_confirm_intent(self): + self.assertTrue(looks_like_confirm("确认")) + self.assertTrue(looks_like_confirm("可以查了")) + self.assertTrue(looks_like_confirm("查价")) + self.assertFalse(looks_like_confirm("广州到深圳衣服")) + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-agent/tests/test_land_text_inquiry.py b/inquiry-agent/tests/test_land_text_inquiry.py new file mode 100644 index 0000000..0eeac60 --- /dev/null +++ b/inquiry-agent/tests/test_land_text_inquiry.py @@ -0,0 +1,536 @@ +""" +陆运私聊:选项补齐 → 货物补问 → 无工单号核对卡。确定建单见后续用例。 + +不连企微 / 真实 TMS / 8180。 +""" + +from __future__ import annotations + +import os +import sys +import unittest +from datetime import datetime +from zoneinfo import ZoneInfo + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +os.environ.setdefault("YTD_ENV", "test") +os.environ.setdefault("REDIS_BACKEND", "memory") +os.environ.setdefault("CHECKPOINT_BACKEND", "memory") +os.environ.setdefault("MESSAGE_STORE_BACKEND", "memory") +os.environ.setdefault("LLM_DATA_USAGE_CONFIRMED", "false") +os.environ.setdefault("LLM_ALLOW_NETWORK", "false") +os.environ.setdefault("LEDGER_BACKEND", "memory") + +from agent.ledger.memory_ledger import MemoryLedger +from agent.policy import inquiry_copy as copy +from agent.policy.land_text_flow import LandTextInquiryFlow + + +class CountingLedger(MemoryLedger): + def __init__(self) -> None: + super().__init__() + self.create_calls = 0 + self.last_created_no = "" + + def create_ticket(self, **kwargs): + self.create_calls += 1 + out = super().create_ticket(**kwargs) + self.last_created_no = str(out.get("work_order_no") or "") + return out + + +class LandTextInquiryFrontTests(unittest.TestCase): + def setUp(self) -> None: + self.ledger = CountingLedger() + self.flow = LandTextInquiryFlow(self.ledger) + self.replies: list[str] = [] + + def _reply(self, text: str, extra=None) -> str: + self.replies.append(text) + return "" + + def _joined(self) -> str: + return "\n".join(self.replies) + + def test_all_options_missing_lists_sheet(self) -> None: + phase = self.flow.on_text( + sender_id="u1", + text="陆运广州到深圳衣服", + reply=self._reply, + injected_facts={"起运港": "广州", "目的港": "深圳", "品名": "衣服"}, + injected_mode="LAND", + ) + self.assertEqual(phase, "need_land_options") + self.assertEqual(self.ledger.create_calls, 0) + text = self._joined() + self.assertIn("国内运输拼车", text) + self.assertIn("线路类别", text) + self.assertIn("运输分类", text) + self.assertNotIn("请补充陆运类型:", text) + + def test_known_type_does_not_reask_type(self) -> None: + phase = self.flow.on_text( + sender_id="u1", + text="广州到深圳国内拼车衣服100方", + reply=self._reply, + injected_facts={"起运港": "广州", "目的港": "深圳", "品名": "衣服"}, + injected_mode="LAND", + ) + self.assertEqual(phase, "need_land_options") + self.assertNotIn("请选择运输类型", self._joined()) + self.assertIn("国内长途/零担", self._joined()) + + def test_wrong_route_keeps_type(self) -> None: + phase = self.flow.on_text( + sender_id="u1", + text="国内运输拼车,线路东南亚", + reply=self._reply, + injected_facts={"起运港": "广州", "目的港": "深圳", "品名": "衣服"}, + injected_mode="LAND", + ) + self.assertEqual(phase, "need_land_options") + self.assertIn("国内长途/零担", self._joined()) + sess = self.flow.session_of("u1") + self.assertEqual(sess.facts.get("运输类型"), "国内运输拼车") + + def test_hk_lcl_asks_vehicle(self) -> None: + phase = self.flow.on_text( + sender_id="u1", + text="中港 东南亚 急件 零担", + reply=self._reply, + injected_facts={ + "起运港": "广州", + "目的港": "香港", + "品名": "衣服", + "体积": "10CBM", + "毛重": "50kg", + }, + injected_mode="LAND", + ) + self.assertEqual(phase, "clarify") + self.assertIn("车型/数量", self._joined()) + self.assertNotIn("\n数量:", self._joined()) + self.assertNotIn(copy.LAND_CONFIRM_TAIL, self._joined()) + self.assertEqual(self.ledger.create_calls, 0) + + def test_hk_full_truck_skips_vehicle(self) -> None: + phase = self.flow.on_text( + sender_id="u1", + text="中港 东南亚 急件 整车", + reply=self._reply, + injected_facts={ + "起运港": "广州", + "目的港": "香港", + "品名": "衣服", + }, + injected_mode="LAND", + ) + self.assertEqual(phase, "wait_confirm") + self.assertNotIn("车型/数量", self._joined()) + self.assertEqual(self.ledger.create_calls, 0) + + def test_domestic_complete_card_no_ticket(self) -> None: + phase = self.flow.on_text( + sender_id="u1", + text="国内运输拼车 国内长途/零担 急件", + reply=self._reply, + injected_facts={ + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + }, + injected_mode="LAND", + ) + self.assertEqual(phase, "wait_confirm") + self.assertEqual(self.ledger.create_calls, 0) + today = datetime.now(ZoneInfo("Asia/Shanghai")).date().isoformat() + joined = self._joined() + self.assertIn(today, joined) + self.assertNotIn("通关口岸", joined) + self.assertNotIn("WO", joined) + self.assertIn(copy.LAND_CONFIRM_TAIL, joined) + + def test_cross_complete_shows_empty_customs(self) -> None: + phase = self.flow.on_text( + sender_id="u1", + text="跨境集拼 东南亚 急件", + reply=self._reply, + injected_facts={ + "起运港": "广州", + "目的港": "河内", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + }, + injected_mode="LAND", + ) + self.assertEqual(phase, "wait_confirm") + self.assertIn("通关口岸(非必填):", self._joined()) + + def test_missing_volume_no_confirm(self) -> None: + phase = self.flow.on_text( + sender_id="u1", + text="国内运输拼车 国内长途/零担 急件", + reply=self._reply, + injected_facts={ + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "毛重": "50kg", + }, + injected_mode="LAND", + ) + self.assertEqual(phase, "clarify") + self.assertNotIn(copy.LAND_CONFIRM_TAIL, self._joined()) + + +class LandTextConfirmTests(unittest.TestCase): + def setUp(self) -> None: + self.ledger = CountingLedger() + self.flow = LandTextInquiryFlow(self.ledger) + self.replies: list[str] = [] + + def _reply(self, text: str, extra=None) -> str: + self.replies.append(text) + return "" + + def _to_confirm(self) -> str: + return self.flow.on_text( + sender_id="u1", + text="国内运输拼车 国内长途/零担 急件", + reply=self._reply, + injected_facts={ + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + }, + injected_mode="LAND", + ) + + def test_confirm_creates_ticket_and_queries(self) -> None: + self.assertEqual(self._to_confirm(), "wait_confirm") + self.replies.clear() + phase = self.flow.on_text(sender_id="u1", text="确定", reply=self._reply) + self.assertEqual(phase, "wait_collab") + self.assertTrue(self.ledger.last_created_no.startswith("WO")) + self.assertIn(self.ledger.last_created_no, "\n".join(self.replies)) + ticket = self.ledger.get_ticket(work_order_no=self.ledger.last_created_no) + self.assertEqual(ticket.facts.get("运输类型"), "国内运输拼车") + self.assertEqual(ticket.facts.get("线路类别"), "国内长途/零担") + joined = "\n".join(self.replies) + self.assertIn("工单号:" + self.ledger.last_created_no, joined) + self.assertIn("运输方式:陆运", joined) + self.assertIn("运输类型:国内运输拼车", joined) + self.assertIn("始发站:广州", joined) + self.assertIn("工单已创建,正在查询TMS报价。", joined) + self.assertNotIn("工单已创建:" + self.ledger.last_created_no, joined) + + def test_tms_fail_then_confirm_retries_same_ticket(self) -> None: + class Flaky(CountingLedger): + def __init__(self) -> None: + super().__init__() + self.query_calls = 0 + + def query_tms(self, **kwargs): + self.query_calls += 1 + if self.query_calls == 1: + return { + "ok": False, + "classification": "TMS_TECH_FAILURE", + "has_price": False, + "quote": {}, + } + return super().query_tms(**kwargs) + + ledger = Flaky() + flow = LandTextInquiryFlow(ledger) + replies: list[str] = [] + + def reply(text: str, extra=None) -> str: + replies.append(text) + return "" + + flow.on_text( + sender_id="u1", + text="国内运输拼车 国内长途/零担 急件", + reply=reply, + injected_facts={ + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + }, + injected_mode="LAND", + ) + phase = flow.on_text(sender_id="u1", text="确定", reply=reply) + self.assertEqual(phase, "tms_tech") + self.assertEqual(ledger.create_calls, 1) + wo = ledger.last_created_no + replies.clear() + flow._by_sender.clear() + flow._by_ticket.clear() + phase = flow.on_text(sender_id="u1", text="确定", reply=reply) + self.assertEqual(phase, "wait_collab") + self.assertEqual(ledger.create_calls, 1) + self.assertEqual(ledger.query_calls, 2) + self.assertEqual(flow.session_of("u1").work_order_no, wo) + self.assertIn(wo, "\n".join(replies)) + self.assertNotIn("你好,我是询价机器人", "\n".join(replies)) + + def test_confirm_synonyms(self) -> None: + for word in ("确认", "查价", "可以查了"): + ledger = CountingLedger() + flow = LandTextInquiryFlow(ledger) + replies: list[str] = [] + + def reply(text: str, extra=None) -> str: + replies.append(text) + return "" + + flow.on_text( + sender_id="u1", + text="国内运输拼车 国内长途/零担 急件", + reply=reply, + injected_facts={ + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + }, + injected_mode="LAND", + ) + phase = flow.on_text(sender_id="u1", text=word, reply=reply) + self.assertEqual(phase, "wait_collab", msg=word) + + def test_resend_changes_type_requeries_fields(self) -> None: + self.assertEqual(self._to_confirm(), "wait_confirm") + self.replies.clear() + phase = self.flow.on_text( + sender_id="u1", + text="改成国内整车", + reply=self._reply, + ) + self.assertEqual(phase, "clarify") + self.assertTrue(any("车型" in x for x in self.replies)) + self.assertEqual(self.ledger.create_calls, 0) + + def test_new_land_after_quoted_does_not_recurse(self) -> None: + self.assertEqual(self._to_confirm(), "wait_confirm") + phase = self.flow.on_text(sender_id="u1", text="确定", reply=self._reply) + self.assertEqual(phase, "wait_collab") + self.replies.clear() + phase = self.flow.on_text( + sender_id="u1", + text="陆运,国内运输拼车,泉州市到南京市,通信机柜及线缆配件,10CBM,10KG", + reply=self._reply, + injected_facts={ + "起运港": "泉州", + "目的港": "南京", + "品名": "通信机柜及线缆配件", + "体积": "10CBM", + "毛重": "10KG", + "运输类型": "国内运输拼车", + }, + injected_mode="LAND", + ) + self.assertEqual(phase, "need_land_options") + self.assertTrue(self.replies) + self.assertIn("线路类别", "\n".join(self.replies)) + + def test_new_land_after_tms_miss_does_not_recurse(self) -> None: + self.flow.on_text( + sender_id="u1", + text="国内运输拼车 国内长途/零担 急件", + reply=self._reply, + injected_facts={ + "起运港": "广州", + "目的港": "无价", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + }, + injected_mode="LAND", + ) + phase = self.flow.on_text(sender_id="u1", text="确定", reply=self._reply) + self.assertEqual(phase, "tms_miss") + self.replies.clear() + phase = self.flow.on_text( + sender_id="u1", + text="陆运,国内运输拼车,泉州市到南京市,通信机柜及线缆配件,10CBM,10KG", + reply=self._reply, + injected_facts={ + "起运港": "泉州", + "目的港": "南京", + "品名": "通信机柜及线缆配件", + "体积": "10CBM", + "毛重": "10KG", + "运输类型": "国内运输拼车", + }, + injected_mode="LAND", + ) + self.assertEqual(phase, "need_land_options") + self.assertIn("线路类别", "\n".join(self.replies)) + + def test_followup_city_route_does_not_reask_type(self) -> None: + """ + 已选国内运输拼车后,用户只补线路+分类。 + 模型若把运输类型写成「陆运」,不得整张清单重问。 + """ + self.flow.on_text( + sender_id="u1", + text="陆运,国内运输拼车,泉州市到南京市,通信机柜及线缆配件,10CBM,10KG", + reply=self._reply, + injected_facts={ + "起运港": "泉州", + "目的港": "南京", + "品名": "通信机柜及线缆配件", + "体积": "10CBM", + "毛重": "10KG", + "运输类型": "国内运输拼车", + }, + injected_mode="LAND", + ) + self.assertEqual(self.flow.session_of("u1").phase, "need_land_options") + self.replies.clear() + phase = self.flow.on_text( + sender_id="u1", + text="国内城配/拖车/打包;普件,常用线路/常规车型", + reply=self._reply, + injected_facts={"品名": "通信机柜及线缆配件", "运输类型": "陆运"}, + injected_mode="LAND", + ) + self.assertEqual(phase, "wait_confirm") + text = "\n".join(self.replies) + self.assertNotIn("请一次选择:运输类型", text) + self.assertIn("国内运输拼车", text) + self.assertIn("国内城配/拖车/打包", text) + self.assertIn("普件,常用线路/常规车型", text) + + +class FakeGroupClient: + """单测假企微建群:不打外网。""" + + def __init__(self) -> None: + self.created: list[dict] = [] + self.sent: list[dict] = [] + + def create_group(self, *, name: str, userids: list[str]) -> dict: + self.created.append({"name": name, "userids": list(userids)}) + return {"ok": True, "chat_id": "wr_land_1", "error": ""} + + def add_group_members(self, *, chat_id: str, userids: list[str]) -> dict: + return {"ok": True} + + def send_group(self, *, chat_id: str, content: str, mention_userids: list | None = None) -> dict: + self.sent.append({"chat_id": chat_id, "content": content, "kind": "text"}) + return {"ok": True} + + def send_group_file(self, *, chat_id: str, filename: str, filepath: str = "", file_b64: str = "") -> dict: + self.sent.append({"chat_id": chat_id, "filename": filename, "kind": "file"}) + return {"ok": True} + + +class LandTextPullTests(unittest.TestCase): + def setUp(self) -> None: + self.ledger = CountingLedger() + self.ledger.set_quote_templates( + [ + { + "templateId": "mem-default-land", + "name": "陆运默认", + "bizType": "陆运", + "keywords": [], + "isDefault": True, + } + ] + ) + self.groups = FakeGroupClient() + self.flow = LandTextInquiryFlow(self.ledger, group_client=self.groups) + self.replies: list[str] = [] + + def _reply(self, text: str, extra=None) -> str: + self.replies.append(text) + return "" + + def _to_quoted(self) -> None: + self.flow.on_text( + sender_id="u1", + text="国内运输拼车 国内长途/零担 急件", + reply=self._reply, + injected_facts={ + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + }, + injected_mode="LAND", + ) + self.flow.on_text(sender_id="u1", text="确定", reply=self._reply) + self.replies.clear() + + def test_pull_only_matching_route(self) -> None: + self.ledger.set_land_staff( + [ + {"roleCode": "land", "status": "active", "wecomId": "p_sea", "name": "甲", "routes": "东南亚"}, + {"roleCode": "land", "status": "active", "wecomId": "p_dom", "name": "乙", "routes": "国内长途/零担"}, + ] + ) + self._to_quoted() + phase = self.flow.on_button( + sender_id="u1", + action=copy.BTN_PULL_COLLAB, + reply=self._reply, + ) + self.assertEqual(phase, "collab_group") + userids = self.groups.created[0]["userids"] + self.assertIn("p_dom", userids) + self.assertNotIn("p_sea", userids) + brief = "\n".join(x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text") + self.assertIn("运输类型", brief) + self.assertIn("国内运输拼车", brief) + self.assertIn("国内长途/零担", brief) + self.assertIn("急件", brief) + + def test_pull_no_staff(self) -> None: + self.ledger.set_land_staff([]) + self._to_quoted() + phase = self.flow.on_button( + sender_id="u1", + action=copy.BTN_PULL_COLLAB, + reply=self._reply, + ) + self.assertIn("陆运产品岗", "\n".join(self.replies)) + self.assertEqual(self.groups.created, []) + self.assertEqual(phase, "wait_collab") + + def test_land_role_is_product_in_group(self) -> None: + from agent.policy.sea_group_ops import is_product + + self.ledger.set_land_staff( + [ + { + "roleCode": "land", + "status": "active", + "wecomId": "u1", + "name": "销产", + "routes": "国内长途/零担", + } + ] + ) + self._to_quoted() + ticket = self.ledger.get_ticket(work_order_no=self.ledger.last_created_no) + self.assertTrue(is_product(ticket, "u1", flow=self.flow)) + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-agent/tests/test_memory_land_staff.py b/inquiry-agent/tests/test_memory_land_staff.py new file mode 100644 index 0000000..0599507 --- /dev/null +++ b/inquiry-agent/tests/test_memory_land_staff.py @@ -0,0 +1,44 @@ +""" +内存账本:陆运按线路类别整词匹配产品岗。 +""" + +from __future__ import annotations + +import os +import sys +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +os.environ.setdefault("YTD_ENV", "test") +os.environ.setdefault("LEDGER_BACKEND", "memory") + +from agent.ledger.memory_ledger import MemoryLedger + + +class MemoryLandStaffTest(unittest.TestCase): + def test_match_route_token(self): + book = MemoryLedger() + book.set_land_staff( + [ + {"roleCode": "land", "status": "active", "wecomId": "a", "name": "甲", "routes": "东南亚"}, + {"roleCode": "land", "status": "active", "wecomId": "b", "name": "乙", "routes": "国内长途/零担"}, + {"roleCode": "land", "status": "active", "wecomId": "c", "name": "丙", "routes": "中港/中亚/中欧,东南亚"}, + ] + ) + hits = book.match_land_staff(route_category="东南亚") + names = [x.get("name") for x in hits] + self.assertEqual(names, ["甲", "丙"]) + + def test_sea_staff_not_pulled(self): + book = MemoryLedger() + book.set_land_staff( + [{"roleCode": "sea", "status": "active", "wecomId": "s", "name": "海", "routes": "东南亚"}] + ) + self.assertEqual(book.match_land_staff(route_category="东南亚"), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-agent/tests/test_quote_template_match.py b/inquiry-agent/tests/test_quote_template_match.py index 359bcd8..76164c8 100644 --- a/inquiry-agent/tests/test_quote_template_match.py +++ b/inquiry-agent/tests/test_quote_template_match.py @@ -81,6 +81,109 @@ class QuoteTemplateMatchTests(unittest.TestCase): ) self.assertIsNone(hit) + def test_land_pinche_not_stolen_by_generic_land_keyword(self) -> None: + hit = pick_quote_template( + templates=[ + { + "templateId": "hk", + "name": "中港", + "bizType": "陆运", + "keywords": ["中港", "陆运"], + "isDefault": False, + }, + { + "templateId": "ltl", + "name": "国内拼车", + "bizType": "陆运", + "keywords": ["拼车", "零担"], + "isDefault": False, + }, + { + "templateId": "d1", + "name": "陆运默认", + "bizType": "陆运", + "keywords": ["普件"], + "isDefault": True, + }, + ], + business_line="LAND", + facts={ + "运输类型": "国内运输拼车", + "线路类别": "国内城配/拖车/打包", + "运输分类": "普件,常用线路/常规车型", + "起运港": "泉州市", + "目的港": "南京市", + }, + ) + self.assertIsNotNone(hit) + self.assertEqual(hit.match_type, "keyword") + self.assertEqual(hit.name, "国内拼车") + + def test_land_cross_ftl_beats_domestic_ftl(self) -> None: + hit = pick_quote_template( + templates=[ + { + "templateId": "dom", + "name": "国内整车", + "bizType": "陆运", + "keywords": ["整车", "国内"], + "isDefault": False, + }, + { + "templateId": "xb", + "name": "跨境整车", + "bizType": "陆运", + "keywords": ["跨境", "整车"], + "isDefault": False, + }, + ], + business_line="LAND", + facts={"运输类型": "跨境整车", "线路类别": "东南亚"}, + ) + self.assertIsNotNone(hit) + self.assertEqual(hit.name, "跨境整车") + + def test_land_falls_back_to_default(self) -> None: + hit = pick_quote_template( + templates=[ + { + "templateId": "hk", + "name": "中港", + "bizType": "陆运", + "keywords": ["中港"], + "isDefault": False, + }, + { + "templateId": "d1", + "name": "陆运默认", + "bizType": "陆运", + "keywords": ["急件"], + "isDefault": True, + }, + ], + business_line="LAND", + facts={"运输类型": "国内运输拼车", "运输分类": "普件,常用线路/常规车型"}, + ) + self.assertIsNotNone(hit) + self.assertEqual(hit.match_type, "default") + self.assertEqual(hit.name, "陆运默认") + + def test_land_none_without_default(self) -> None: + hit = pick_quote_template( + templates=[ + { + "templateId": "hk", + "name": "中港", + "bizType": "陆运", + "keywords": ["中港"], + "isDefault": False, + }, + ], + business_line="LAND", + facts={"运输类型": "国内运输拼车"}, + ) + self.assertIsNone(hit) + if __name__ == "__main__": unittest.main() diff --git a/inquiry-agent/tests/test_tms_land_query.py b/inquiry-agent/tests/test_tms_land_query.py new file mode 100644 index 0000000..a6f750a --- /dev/null +++ b/inquiry-agent/tests/test_tms_land_query.py @@ -0,0 +1,108 @@ +""" +陆运 TMS 出站拼参:分类和中港装载方式不得带出。 +""" + +from __future__ import annotations + +import os +import sys +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +from agent.schema.tms_land_query import assemble_land_query + + +class TmsLandQueryTest(unittest.TestCase): + def test_outbound_omits_class_and_hk_load(self): + facts = { + "运输类型": "中港", + "线路类别": "东南亚", + "运输分类": "急件", + "中港装载方式": "零担/集拼", + "起运港": "广州", + "目的港": "香港", + "品名": "衣服", + "车型/数量": "9米6/1", + "通关口岸": "皇岗", + } + got = assemble_land_query(facts) + self.assertTrue(got["ok"]) + out = got["outbound"] + self.assertEqual(out["landTransportType"], "CHINA_HK") + self.assertEqual(out["routeCategory"], "SOUTHEAST_ASIA") + self.assertNotIn("landRouteCategory", out) + self.assertEqual(out["vehicleType"], "9米6") + self.assertEqual(str(out["quantity"]), "1") + self.assertEqual(out["customsPort"], "皇岗") + self.assertNotIn("运输分类", out) + self.assertNotIn("landClass", out) + self.assertNotIn("hkLoadType", out) + self.assertNotIn("中港装载方式", out) + + def test_hk_full_truck_omits_vehicle(self): + facts = { + "运输类型": "中港", + "线路类别": "东南亚", + "中港装载方式": "整车", + "起运港": "广州", + "目的港": "香港", + "品名": "衣服", + } + got = assemble_land_query(facts) + self.assertTrue(got["ok"]) + out = got["outbound"] + self.assertNotIn("vehicleType", out) + self.assertNotIn("quantity", out) + + def test_domestic_ftl_splits_combo(self): + facts = { + "运输类型": "国内运输整车", + "线路类别": "国内长途/零担", + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "车型/数量": "9米6*2", + } + out = assemble_land_query(facts)["outbound"] + self.assertEqual(out["vehicleType"], "9米6") + self.assertEqual(out["quantity"], "2") + + def test_domestic_has_no_customs(self): + facts = { + "运输类型": "国内运输拼车", + "线路类别": "国内长途/零担", + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + } + out = assemble_land_query(facts)["outbound"] + self.assertNotIn("customsPort", out) + self.assertIn("volumeCbm", out) + self.assertEqual(out["landTransportType"], "DOMESTIC_LTL") + self.assertEqual(out["routeCategory"], "DOMESTIC_LONG_LTL") + + def test_wo011_pinche_city_dist_uses_tms_enums(self): + facts = { + "运输类型": "国内运输拼车", + "线路类别": "国内城配/拖车/打包", + "起运港": "泉州市", + "目的港": "南京市", + "品名": "通信机柜及线缆配件", + "体积": "10CBM", + "毛重": "10KG", + } + got = assemble_land_query(facts) + self.assertTrue(got["ok"]) + out = got["outbound"] + self.assertEqual(out["landTransportType"], "DOMESTIC_LTL") + self.assertEqual(out["routeCategory"], "DOMESTIC_CITY_DIST") + self.assertNotIn("landRouteCategory", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java index 393bca5..b38b8e0 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java @@ -160,6 +160,19 @@ public class InquiryAgentController { return Result.OK(out); } + @ApiOperation("陆运拉群:按线路类别匹配启用的产品·陆运") + @PostMapping("/staff/matchLand") + public Result> matchLand(@RequestBody Map body, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + String route = firstStr(body, "routeCategory", "线路类别"); + Map out = new java.util.LinkedHashMap<>(); + out.put("staff", staffRoleService.matchLandStaff(route)); + return Result.OK(out); + } + @ApiOperation("按岗位列出启用员工(空运群 @ 航线·空运)") @PostMapping("/staff/listByRole") public Result> listStaffByRole(@RequestBody Map body, diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java index bd6af91..2c56be1 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java @@ -36,6 +36,11 @@ public interface IInquiryStaffRoleService extends IService { */ java.util.List> matchSeaStaff(String origin, String destination); + /** + * 陆运拉群:启用的产品·陆运,按本单线路类别整词匹配负责线路。 + */ + java.util.List> matchLandStaff(String routeCategory); + /** * 列出该岗位启用员工。空运群 @ 航线用 roleCode=air。 */ diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java index 81ae4c4..2dfaffe 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java @@ -534,10 +534,11 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl e : QuoteTemplateExcelSupport.LAND_TMS_KEY_TO_CODE.entrySet()) { + String val = first(q, e.getKey(), e.getValue()); + if (StringUtils.isNotBlank(val) && !"-".equals(val) && StringUtils.isBlank(out.getString(e.getValue()))) { + out.put(e.getValue(), val); + } + } JSONArray rows = QuoteTemplateExcelSupport.parseFeeRows(q.get("fee_lines")); if (rows.isEmpty()) { rows = QuoteTemplateExcelSupport.parseFeeRows(q.get("fee_rows")); @@ -592,7 +603,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl src) { + if (src == null || src.isEmpty()) { + return false; + } + String line = first(src, "业务线", "business_line", "businessLine"); + if ("LAND".equalsIgnoreCase(line) || (line != null && line.contains("陆"))) { + return true; + } + return StringUtils.isNotBlank(first(src, "始发站", "运输类型", "线路类别")); + } + private static String first(Map src, String... keys) { if (src == null) { return ""; diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryStaffRoleServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryStaffRoleServiceImpl.java index fb80722..e88e78c 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryStaffRoleServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryStaffRoleServiceImpl.java @@ -144,6 +144,17 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl> matchLandStaff(String routeCategory) { + List rows = list(new QueryWrapper() + .eq("role_code", "land") + .eq("status", "active")); + return org.jeecg.modules.inquiry.staff.LandStaffMatcher.match(rows, routeCategory); + } + @Override public List> listActiveByRole(String roleCode) { String code = StringUtils.trimToEmpty(roleCode).toLowerCase(); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/staff/LandStaffMatcher.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/staff/LandStaffMatcher.java new file mode 100644 index 0000000..8f830a5 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/staff/LandStaffMatcher.java @@ -0,0 +1,84 @@ +package org.jeecg.modules.inquiry.staff; + +import org.apache.commons.lang3.StringUtils; +import org.jeecg.modules.inquiry.entity.InquiryStaffRole; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 陆运拉群人员匹配:只看员工管理里岗位=产品·陆运且启用的人。 + *

+ * 用本单线路类别对负责线路:按逗号/顿号拆整词,禁止把「中港/中亚/中欧」按斜杠切开。 + * 没有「陆运全线」快捷项;四条都要跟就须后台四条都勾。 + * 禁止:按销售 userid 伪造名单;不查企微通讯录。 + */ +public final class LandStaffMatcher { + + private LandStaffMatcher() { + } + + /** + * @param rows 员工岗位行 + * @param routeCategory 本单线路类别(与后台四项同字) + * @return 给智能体的 {name, wecomId, routes} + */ + public static List> match(List rows, String routeCategory) { + List> hits = new ArrayList<>(); + if (rows == null) { + return hits; + } + String want = StringUtils.trimToEmpty(routeCategory); + if (want.isEmpty()) { + return hits; + } + for (InquiryStaffRole row : rows) { + if (row == null) { + continue; + } + if (!"land".equalsIgnoreCase(StringUtils.trimToEmpty(row.getRoleCode()))) { + continue; + } + if (!"active".equalsIgnoreCase(StringUtils.defaultIfBlank(row.getStatus(), "active"))) { + continue; + } + if (StringUtils.isBlank(row.getWecomId())) { + continue; + } + if (!routeTokens(row.getRoutes()).contains(want)) { + continue; + } + Map item = new LinkedHashMap<>(); + item.put("name", StringUtils.defaultString(row.getName())); + item.put("wecomId", row.getWecomId().trim()); + item.put("roleCode", "land"); + item.put("routes", StringUtils.defaultString(row.getRoutes())); + item.put("status", "active"); + hits.add(item); + } + return hits; + } + + /** + * 只按中英文逗号、顿号拆。斜杠是线路名称的一部分。 + */ + static List splitRoutes(String raw) { + List out = new ArrayList<>(); + if (raw == null || raw.trim().isEmpty()) { + return out; + } + for (String part : raw.split("[,,、]+")) { + String t = part.trim(); + if (!t.isEmpty()) { + out.add(t); + } + } + return out; + } + + private static List routeTokens(String raw) { + return splitRoutes(raw); + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/LandQuoteTemplateFields.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/LandQuoteTemplateFields.java new file mode 100644 index 0000000..00be2f4 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/LandQuoteTemplateFields.java @@ -0,0 +1,127 @@ +package org.jeecg.modules.inquiry.template; + +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * 陆运报价单字段词典(TMS 对接文档 9.5)。 + *

+ * 陆运 Excel 是「标签 + 右侧金额」,不是海运那种「费用名称 / 金额」表。 + * 本类只做名称对照,不发明价格、不打 TMS。 + */ +public final class LandQuoteTemplateFields { + + private static final Map LABEL_TO_CODE = new LinkedHashMap<>(); + private static final Map KEY_TO_CODE = new LinkedHashMap<>(); + private static final Map> CODE_TO_LABELS = new LinkedHashMap<>(); + + static { + add("price_per_cbm", "元/CBM", "pricePerCbm"); + add("price_per_kg", "元/kg", "pricePerKg"); + add("delivery_fee", "送货费", "deliveryFee"); + add("pickup_fee", "提货费", "pickupFee"); + add("min_charge", "最低收费", "minCharge"); + add("transit_days", "时效(天)", "transitDays"); + add("effective_time", "生效时间", "effectiveTime"); + add("land_freight", "陆运费", "landFreight"); + add("vehicle_42_fee", "4.2米/3T费用", "vehicle42Fee"); + add("vehicle_50_fee", "5米/5T费用", "vehicle50Fee"); + add("vehicle_76_fee", "7.6米/8T费用", "vehicle76Fee"); + add("vehicle_82_fee", "8.2米/10T费用", "vehicle82Fee"); + add("vehicle_96_fee", "9.6米/10T费用", "vehicle96Fee"); + add("vehicle_12_fee", "12米/40尺费用", "vehicle12Fee"); + add("vehicle_125_fee", "12.5米/40尺费用", "vehicle125Fee"); + add("vehicle_165_fee", "16.5米/53尺费用", "vehicle165Fee"); + add("vehicle_3t_fee", "4.1*2.1*2.3M/3T费用", "vehicle3TFee"); + add("vehicle_5t_fee", "6.5*2.4*2.5M/5T费用", "vehicle5TFee"); + add("vehicle_8t_fee", "7.5*2.4*2.5M/8T费用", "vehicle8TFee"); + add("vehicle_10t_fee", "9.4*2.4*2.55M/10T费用", "vehicle10TFee"); + add("vehicle_40hq_fee", "11.8*2.32*2.7M/40'HQ费用", "vehicle40HQFee"); + add("vehicle_45hq_fee", "12.8*2.32*2.7M/45'HQ费用", "vehicle45HQFee"); + add("vehicle_48rh_fee", "14.6*2.28*2.5M/48RH冷柜费用", "vehicle48RHFee"); + add("vehicle_53r_fee", "16.3*2.7*2.8M/53尺费用", "vehicle53RFee"); + add("vehicle_1375_fee", "13.5*3M/13.75平板费用", "vehicle13.75TFee"); + add("vehicle_175_fee", "17.5*3M/17.5平板费用", "vehicle17.5TFee"); + LABEL_TO_CODE.put("时效", "transit_days"); + LABEL_TO_CODE.put("干线费", "land_freight"); + LABEL_TO_CODE.put("运费", "land_freight"); + LABEL_TO_CODE.put("4.2米/3T", "vehicle_42_fee"); + LABEL_TO_CODE.put("7.6米/8T", "vehicle_76_fee"); + LABEL_TO_CODE.put("9.6米/10T", "vehicle_96_fee"); + LABEL_TO_CODE.put("元/KG", "price_per_kg"); + } + + private LandQuoteTemplateFields() { + } + + /** + * Excel 标签 → 填表码,供按标签写右侧格子。 + */ + public static Map labelToCode() { + return Collections.unmodifiableMap(LABEL_TO_CODE); + } + + /** + * TMS 扁平字段 → 填表码。 + */ + public static Map tmsKeyToCode() { + return Collections.unmodifiableMap(KEY_TO_CODE); + } + + /** + * 费用名 / TMS key / 标签任一能对上则回填表码,对不上给空。 + */ + public static String codeForFeeName(String raw) { + if (StringUtils.isBlank(raw)) { + return ""; + } + String name = raw.trim(); + if (LABEL_TO_CODE.containsKey(name)) { + return LABEL_TO_CODE.get(name); + } + if (KEY_TO_CODE.containsKey(name)) { + return KEY_TO_CODE.get(name); + } + String lower = name.toLowerCase(Locale.ROOT); + for (Map.Entry e : LABEL_TO_CODE.entrySet()) { + String label = e.getKey(); + if (label.length() >= 4 && (name.contains(label) || label.contains(name))) { + return e.getValue(); + } + if ("元/CBM".equals(label) && (name.contains("CBM") || name.contains("cbm"))) { + return e.getValue(); + } + } + for (Map.Entry e : KEY_TO_CODE.entrySet()) { + if (e.getKey().equalsIgnoreCase(lower)) { + return e.getValue(); + } + } + return ""; + } + + /** + * 一个填表码对应的 Excel 标签,找格子时按这个顺序试。 + */ + public static List labelsFor(String code) { + if (StringUtils.isBlank(code)) { + return Collections.emptyList(); + } + List labels = CODE_TO_LABELS.get(code); + return labels == null ? Collections.emptyList() : Collections.unmodifiableList(labels); + } + + private static void add(String code, String label, String tmsKey) { + LABEL_TO_CODE.put(label, code); + if (StringUtils.isNotBlank(tmsKey)) { + KEY_TO_CODE.put(tmsKey, code); + } + CODE_TO_LABELS.computeIfAbsent(code, k -> new ArrayList()).add(label); + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java index da39c81..f49e436 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java @@ -69,11 +69,82 @@ public final class QuoteTemplateExcelSupport { FILL_BY_LABEL.put("重量", "weight_kg"); FILL_BY_LABEL.put("体积", "volume_cbm"); FILL_BY_LABEL.put("始发地", "origin"); + FILL_BY_LABEL.put("始发站", "origin"); FILL_BY_LABEL.put("目的地", "destination"); + FILL_BY_LABEL.put("货物品名", "commodity"); + FILL_BY_LABEL.put("车型/数量", "vehicle_qty"); + FILL_BY_LABEL.put("运输类型", "运输类型"); + FILL_BY_LABEL.put("线路类别", "线路类别"); + FILL_BY_LABEL.put("运输分类", "运输分类"); + FILL_BY_LABEL.put("中港装载方式", "中港装载方式"); FILL_BY_LABEL.put("币别", "currency"); FILL_BY_LABEL.put("业务员", "sales_name"); FILL_BY_LABEL.put("客户名称", "customer_name"); FILL_BY_LABEL.put("联系人", "contact_name"); + // 陆运报价单是标签栏(TMS 9.5),金额写在标签右侧 + FILL_BY_LABEL.put("元/CBM", "price_per_cbm"); + FILL_BY_LABEL.put("元/kg", "price_per_kg"); + FILL_BY_LABEL.put("元/KG", "price_per_kg"); + FILL_BY_LABEL.put("送货费", "delivery_fee"); + FILL_BY_LABEL.put("提货费", "pickup_fee"); + FILL_BY_LABEL.put("最低收费", "min_charge"); + FILL_BY_LABEL.put("时效(天)", "transit_days"); + FILL_BY_LABEL.put("时效", "transit_days"); + FILL_BY_LABEL.put("生效时间", "effective_time"); + FILL_BY_LABEL.put("陆运费", "land_freight"); + FILL_BY_LABEL.put("干线费", "land_freight"); + FILL_BY_LABEL.put("4.2米/3T费用", "vehicle_42_fee"); + FILL_BY_LABEL.put("4.2米/3T", "vehicle_42_fee"); + FILL_BY_LABEL.put("5米/5T费用", "vehicle_50_fee"); + FILL_BY_LABEL.put("7.6米/8T费用", "vehicle_76_fee"); + FILL_BY_LABEL.put("7.6米/8T", "vehicle_76_fee"); + FILL_BY_LABEL.put("8.2米/10T费用", "vehicle_82_fee"); + FILL_BY_LABEL.put("9.6米/10T费用", "vehicle_96_fee"); + FILL_BY_LABEL.put("12米/40尺费用", "vehicle_12_fee"); + FILL_BY_LABEL.put("12.5米/40尺费用", "vehicle_125_fee"); + FILL_BY_LABEL.put("16.5米/53尺费用", "vehicle_165_fee"); + FILL_BY_LABEL.put("4.1*2.1*2.3M/3T费用", "vehicle_3t_fee"); + FILL_BY_LABEL.put("6.5*2.4*2.5M/5T费用", "vehicle_5t_fee"); + FILL_BY_LABEL.put("7.5*2.4*2.5M/8T费用", "vehicle_8t_fee"); + FILL_BY_LABEL.put("9.4*2.4*2.55M/10T费用", "vehicle_10t_fee"); + FILL_BY_LABEL.put("11.8*2.32*2.7M/40'HQ费用", "vehicle_40hq_fee"); + FILL_BY_LABEL.put("12.8*2.32*2.7M/45'HQ费用", "vehicle_45hq_fee"); + FILL_BY_LABEL.put("14.6*2.28*2.5M/48RH冷柜费用", "vehicle_48rh_fee"); + FILL_BY_LABEL.put("16.3*2.7*2.8M/53尺费用", "vehicle_53r_fee"); + FILL_BY_LABEL.put("13.5*3M/13.75平板费用", "vehicle_1375_fee"); + FILL_BY_LABEL.put("17.5*3M/17.5平板费用", "vehicle_175_fee"); + } + + /** + * 陆运 TMS 扁平字段 → 填表码。费用名对不上时再按这个取。 + */ + public static final Map LAND_TMS_KEY_TO_CODE = new LinkedHashMap<>(); + + static { + LAND_TMS_KEY_TO_CODE.put("pricePerCbm", "price_per_cbm"); + LAND_TMS_KEY_TO_CODE.put("pricePerKg", "price_per_kg"); + LAND_TMS_KEY_TO_CODE.put("deliveryFee", "delivery_fee"); + LAND_TMS_KEY_TO_CODE.put("pickupFee", "pickup_fee"); + LAND_TMS_KEY_TO_CODE.put("minCharge", "min_charge"); + LAND_TMS_KEY_TO_CODE.put("transitDays", "transit_days"); + LAND_TMS_KEY_TO_CODE.put("effectiveTime", "effective_time"); + LAND_TMS_KEY_TO_CODE.put("landFreight", "land_freight"); + LAND_TMS_KEY_TO_CODE.put("vehicle42Fee", "vehicle_42_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle50Fee", "vehicle_50_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle76Fee", "vehicle_76_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle82Fee", "vehicle_82_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle96Fee", "vehicle_96_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle12Fee", "vehicle_12_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle125Fee", "vehicle_125_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle165Fee", "vehicle_165_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle3TFee", "vehicle_3t_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle5TFee", "vehicle_5t_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle8TFee", "vehicle_8t_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle10TFee", "vehicle_10t_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle40HQFee", "vehicle_40hq_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle45HQFee", "vehicle_45hq_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle48RHFee", "vehicle_48rh_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle53RFee", "vehicle_53r_fee"); } private QuoteTemplateExcelSupport() { @@ -409,16 +480,35 @@ public final class QuoteTemplateExcelSupport { continue; } Cell nameCell = findCellByFeeName(sheet, name, usedRows); + if (nameCell == null) { + nameCell = findCellByLandFeeAlias(sheet, name, usedRows); + } if (nameCell == null) { unmatched.add(rowObj); continue; } int r = nameCell.getRowIndex(); usedRows.add(r); + if (shouldWriteLandFeeBesideLabel(sheet, name, nameCell)) { + // 陆运模板没有费用表头:金额写到标签右侧,禁止落到默认 G 列 + writeAmountBesideLabel(sheet, nameCell, amount, yellow); + continue; + } Map cols = findFeeTableColumnsAbove(sheet, r); writeFeeDetailRow(sheet, r, cols, rowObj, headerCurrency, boxType, yellow, false); } for (JSONObject rowObj : unmatched) { + if (shouldWriteLandFeeBesideLabel(sheet, rowObj.getString("name"), null) + && findFeeTableColumns(sheet).isEmpty()) { + Cell alias = findCellByLandFeeAlias(sheet, rowObj.getString("name"), usedRows); + if (alias != null) { + usedRows.add(alias.getRowIndex()); + writeAmountBesideLabel(sheet, alias, rowObj.getString("amount"), yellow); + continue; + } + log.warn("报价单没有空行可追加费用 {}", rowObj.getString("name")); + continue; + } int empty = findEmptyFeeDataRow(sheet, usedRows); if (empty < 0) { log.warn("报价单没有空行可追加费用 {}", rowObj.getString("name")); @@ -431,6 +521,96 @@ public final class QuoteTemplateExcelSupport { } } + /** + * 陆运标签栏且本表没有「费用名称+金额」表头时,金额写右侧。 + * 空运「空运费」行即使没有完整表头,仍走原来的金额列兜底。 + */ + private static boolean shouldWriteLandFeeBesideLabel(Sheet sheet, String feeName, Cell nameCell) { + if (!findFeeTableColumns(sheet).isEmpty()) { + return false; + } + String code = landFactCodeForFeeName(feeName); + if (!isLandQuoteFactCode(code) && nameCell != null) { + code = landFactCodeForFeeName(readCellText(nameCell)); + } + return isLandQuoteFactCode(code); + } + /** + * 陆运标签栏:金额写到标签右侧空格,和询价字段同一套定位。 + */ + private static void writeAmountBesideLabel(Sheet sheet, Cell labelCell, String amount, Set yellow) { + if (sheet == null || labelCell == null || StringUtils.isBlank(amount) || "-".equals(amount.trim())) { + return; + } + String fillRef = resolveFillTarget(sheet, labelCell, sheet.getSheetName(), + readCellText(labelCell), new DataFormatter()); + int rowIdx; + int colIdx; + if (StringUtils.isNotBlank(fillRef)) { + ParsedRef ref = ParsedRef.parse(fillRef); + rowIdx = ref.row; + colIdx = ref.col; + } else { + rowIdx = labelCell.getRowIndex(); + colIdx = labelCell.getColumnIndex() + 1; + } + writeAmountCell(sheet, rowIdx, colIdx, amount, yellow); + } + + /** + * TMS 费用名是 pricePerCbm / 4.2米/3T 时,按陆运标签再找一格。 + */ + private static Cell findCellByLandFeeAlias(Sheet sheet, String name, Set usedRows) { + String code = landFactCodeForFeeName(name); + if (StringUtils.isBlank(code)) { + return null; + } + for (Map.Entry e : FILL_BY_LABEL.entrySet()) { + if (!code.equals(e.getValue())) { + continue; + } + Cell hit = findCellByFeeName(sheet, e.getKey(), usedRows); + if (hit != null) { + return hit; + } + } + return null; + } + + /** + * 陆运费用名 / TMS key 对到填表码。对不上给空,不发明价格。 + */ + public static String landFactCodeForFeeName(String raw) { + if (StringUtils.isBlank(raw)) { + return ""; + } + String name = raw.trim(); + if (FILL_BY_LABEL.containsKey(name)) { + String code = FILL_BY_LABEL.get(name); + if (isLandQuoteFactCode(code)) { + return code; + } + } + if (LAND_TMS_KEY_TO_CODE.containsKey(name)) { + return LAND_TMS_KEY_TO_CODE.get(name); + } + String matched = matchLabel(name, FILL_BY_LABEL); + if (isLandQuoteFactCode(matched)) { + return matched; + } + return ""; + } + + private static boolean isLandQuoteFactCode(String code) { + if (StringUtils.isBlank(code)) { + return false; + } + return code.startsWith("price_per_") || code.startsWith("vehicle_") + || "delivery_fee".equals(code) || "pickup_fee".equals(code) + || "min_charge".equals(code) || "transit_days".equals(code) + || "effective_time".equals(code) || "land_freight".equals(code); + } + /** * 写一行费用。writeName=false 时不碰已有费目名,避免把「文件费/Doc.」盖成数量 1。 */ @@ -1088,7 +1268,7 @@ public final class QuoteTemplateExcelSupport { String noColon = primary.replace(":", "").trim(); for (Map.Entry e : dict.entrySet()) { String dk = e.getKey().replace(":", ":").replace(":", "").trim(); - if (dk.length() < 2 || noColon.length() < 2) { + if (dk.length() < 3 || noColon.length() < 3) { continue; } if (dk.equalsIgnoreCase(noColon) || noColon.startsWith(dk) || dk.startsWith(noColon)) { @@ -1257,8 +1437,26 @@ public final class QuoteTemplateExcelSupport { if (primary.equals(want)) { return true; } - return primary.equals(want + "用") || primary.equals(want + "费用") - || want.equals(primary + "用") || want.equals(primary + "费用"); + if (primary.equals(want + "用") || primary.equals(want + "费用") + || want.equals(primary + "用") || want.equals(primary + "费用")) { + return true; + } + // 陆运车型标签「4.2米/3T」与「4.2米/3T费用」视为同一格;「元」太短不能这样对 + String wantPrimary = feePrimaryName(want); + if (StringUtils.isNotBlank(wantPrimary) && primary.equals(wantPrimary) + && distinctiveLandFeeToken(primary)) { + return true; + } + return false; + } + + private static boolean distinctiveLandFeeToken(String text) { + if (StringUtils.isBlank(text) || text.length() < 3) { + return false; + } + return text.contains("米") || text.contains("CBM") || text.contains("cbm") + || text.toUpperCase(Locale.ROOT).contains("KG") || text.contains("HQ") + || text.contains("T费用") || text.contains("平板"); } static Integer pickContainerRateColumn(Map cols, String boxType) { diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcher.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcher.java index f025fd6..586f0c7 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcher.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcher.java @@ -78,7 +78,10 @@ public final class QuoteTemplateMatcher { } /** - * 先扫同业务线关键词(列表已按默认优先排序),再退回该业务默认模板。 + * 同业务线:先按关键词打分(运输类型上的词优先),再退回该业务默认模板。 + *

+ * 「陆运/海运/空运/国内」不当关键词:业务线已过滤,haystack 里必有「陆运」, + * 否则中港模板会抢走国内拼车单。多模板同时命中时,类型命中更多者胜。 * * @return 命中的模板 Map,并写入 matchType=keyword|default;都没有则 null */ @@ -90,7 +93,13 @@ public final class QuoteTemplateMatcher { ) { String want = normalizeBiz(businessLine); String haystack = buildHaystack(businessLine, facts, quote).toLowerCase(Locale.ROOT); + String typeVal = ""; + if (facts != null && facts.get("运输类型") != null) { + typeVal = String.valueOf(facts.get("运输类型")).trim().toLowerCase(Locale.ROOT); + } Map keywordHit = null; + int bestTypeHits = -1; + int bestLength = -1; Map defaultHit = null; if (templates == null) { return null; @@ -106,7 +115,13 @@ public final class QuoteTemplateMatcher { if (defaultHit == null && isDefault(t.get("isDefault"))) { defaultHit = t; } - if (keywordHit == null && keywordsHit(t.get("keywords"), haystack)) { + int[] score = keywordScore(t.get("keywords"), haystack, typeVal); + if (score[0] == 0 && score[1] == 0) { + continue; + } + if (score[0] > bestTypeHits || (score[0] == bestTypeHits && score[1] > bestLength)) { + bestTypeHits = score[0]; + bestLength = score[1]; keywordHit = t; } } @@ -119,13 +134,46 @@ public final class QuoteTemplateMatcher { return null; } - private static boolean keywordsHit(Object raw, String haystack) { + /** + * 业务线名、过宽的「国内」不当关键词。 + */ + static boolean skipKeyword(String kw) { + if (kw == null || kw.isEmpty()) { + return true; + } + String k = kw.toLowerCase(Locale.ROOT); + return "海运".equals(k) || "空运".equals(k) || "陆运".equals(k) + || "sea".equals(k) || "air".equals(k) || "land".equals(k) + || "国内".equals(k); + } + + /** + * @return [运输类型命中数, 命中词总长];都是 0 表示未命中 + */ + static int[] keywordScore(Object raw, String haystack, String typeVal) { + int typeHits = 0; + int length = 0; + boolean any = false; + String type = typeVal == null ? "" : typeVal; + String hay = haystack == null ? "" : haystack; for (String kw : asKeywords(raw)) { - if (haystack.contains(kw.toLowerCase(Locale.ROOT))) { - return true; + if (skipKeyword(kw)) { + continue; + } + String k = kw.toLowerCase(Locale.ROOT); + if (!type.isEmpty() && type.contains(k)) { + typeHits++; + length += k.length(); + any = true; + } else if (hay.contains(k)) { + length += k.length(); + any = true; } } - return false; + if (!any) { + return new int[] {0, 0}; + } + return new int[] {typeHits, length}; } /** diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java index cd79ae6..940a59c 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java @@ -27,7 +27,8 @@ import java.util.TimeZone; /** * 智能体查价门面:询价必填齐了就必须打真 TMS,再按 TMS 回包分类。 *

- * 职责:询价必填齐了就出站。空运看件数/重量/体积/包装;海运看货量/整柜或拼柜/箱型箱量,不拦空运量尺。 + * 职责:询价必填齐了就出站。空运看件数/重量/体积/包装;海运看货量/整柜或拼柜/箱型箱量; + * 陆运看类型+线路+体积重量或车型数量,不拦包装,不把运输分类/中港装载送 TMS。 * 机场三字码能投影就带上;对不上也必须出站,禁止本地判「暂无报价」。 * 业务线以工单为准,禁止海运请求被默认成 AIR。 * 分类:1 且有价 → 报价;TMS 已回 1002 / 无 quoteFound / 1003 → 暂无报价; @@ -91,6 +92,9 @@ public class InquiryTmsQueryService { if (items != null) { segment.put("containerItems", items); } + } else if ("LAND".equals(line)) { + // 陆运不套空运包装/件数;分类和中港装载只在工单,mapper 不会放进出站。 + segment.putAll(TmsLandSegmentMapper.build(facts)); } else { segment.put("quantity", TmsV2RequestMapper.asInteger(facts.get("件数"))); segment.put("weightKg", TmsV2RequestMapper.asPositiveDecimal(facts.get("毛重"))); @@ -107,9 +111,14 @@ public class InquiryTmsQueryService { quoteDate = fmt.format(new Date()); } - List missing = "SEA".equals(line) - ? missingSeaQuery(workOrderNo, quoteDate, segment, facts) - : missingAirQuery(workOrderNo, quoteDate, segment); + List missing; + if ("SEA".equals(line)) { + missing = missingSeaQuery(workOrderNo, quoteDate, segment, facts); + } else if ("LAND".equals(line)) { + missing = TmsLandSegmentMapper.missingQuery(workOrderNo, quoteDate, segment, facts); + } else { + missing = missingAirQuery(workOrderNo, quoteDate, segment); + } if (!missing.isEmpty()) { log.warn("tms.query skip incomplete wo={} missing={}", workOrderNo, missing); return incomplete(missing); @@ -134,6 +143,10 @@ public class InquiryTmsQueryService { String cacheKey = line + ":" + quoteDate + ":" + origin + ":" + destination + ":" + segment.get("weightKg") + ":" + segment.get("volumeCbm"); + if ("LAND".equals(line)) { + cacheKey += ":" + segment.get("landTransportType") + ":" + segment.get("routeCategory") + + ":" + segment.get("vehicleType") + ":" + segment.get("quantity"); + } try { Map tms = gateway.query(internal, cacheKey); return classifyAndPersist(workOrderNo, tms); @@ -554,7 +567,7 @@ public class InquiryTmsQueryService { } /** - * 工单已落库的海运事实补进查价 facts,避免智能体漏传货量/箱型。 + * 工单已落库的事实补进查价 facts。海运补港口货量箱型;陆运 factsJson 里的类型/线路/分类一并带回。 */ static void mergeTicketFacts(Map facts, InquiryTicket ticket) { if (facts == null || ticket == null) { diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapper.java new file mode 100644 index 0000000..6008fca --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapper.java @@ -0,0 +1,66 @@ +package org.jeecg.modules.inquiry.tms; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 陆运 TMS 6.1.1 枚举:销售中文 → 出站码。 + *

+ * 职责:只在打 TMS 前翻译运输类型、线路类别。工单/卡片/拉人仍用中文。 + * 禁止:把运输分类、中港装载方式译进出站;发明表外码。 + */ +public final class TmsLandEnumMapper { + + private static final Map TYPE = new LinkedHashMap(); + private static final Map ROUTE = new LinkedHashMap(); + + static { + TYPE.put("国内运输拼车", "DOMESTIC_LTL"); + TYPE.put("国内运输整车", "DOMESTIC_FTL"); + TYPE.put("跨境集拼", "CROSS_BORDER_LTL"); + TYPE.put("跨境整车", "CROSS_BORDER_FTL"); + TYPE.put("中港", "CHINA_HK"); + TYPE.put("DOMESTIC_LTL", "DOMESTIC_LTL"); + TYPE.put("DOMESTIC_FTL", "DOMESTIC_FTL"); + TYPE.put("CROSS_BORDER_LTL", "CROSS_BORDER_LTL"); + TYPE.put("CROSS_BORDER_FTL", "CROSS_BORDER_FTL"); + TYPE.put("CHINA_HK", "CHINA_HK"); + ROUTE.put("国内长途/零担", "DOMESTIC_LONG_LTL"); + ROUTE.put("国内城配/拖车/打包", "DOMESTIC_CITY_DIST"); + ROUTE.put("中港/中亚/中欧", "CN_HK_CA_EU"); + ROUTE.put("东南亚", "SOUTHEAST_ASIA"); + ROUTE.put("DOMESTIC_LONG_LTL", "DOMESTIC_LONG_LTL"); + ROUTE.put("DOMESTIC_CITY_DIST", "DOMESTIC_CITY_DIST"); + ROUTE.put("CN_HK_CA_EU", "CN_HK_CA_EU"); + ROUTE.put("SOUTHEAST_ASIA", "SOUTHEAST_ASIA"); + } + + private TmsLandEnumMapper() { + } + + /** + * @param raw 销售中文或已是 TMS 码 + * @return 6.1.1 landTransportType;对不上返回空串,禁止用中文顶上 + */ + public static String transportType(String raw) { + if (raw == null) { + return ""; + } + String key = raw.trim(); + String code = TYPE.get(key); + return code == null ? "" : code; + } + + /** + * @param raw 销售中文或已是 TMS 码 + * @return 6.1.1 routeCategory;对不上返回空串 + */ + public static String routeCategory(String raw) { + if (raw == null) { + return ""; + } + String key = raw.trim(); + String code = ROUTE.get(key); + return code == null ? "" : code; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapper.java new file mode 100644 index 0000000..442babd --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapper.java @@ -0,0 +1,206 @@ +package org.jeecg.modules.inquiry.tms; + +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 陆运 TMS 查价段:按运输类型拼出站字段。 + *

+ * 职责:把工单 facts 里的中文键写成 TMS 段;运输分类、中港装载方式只留在工单,不进出站。 + * 拼车/跨境集拼带体积重量;国内整车/跨境整车带「车型/数量」一项再拆成出站车型与台数; + * 中港整车不带车型/数量,中港零担/集拼才带。通关口岸有值才放。 + * 类型/线路出站必须是 TMS 6.1.1 英文枚举,线路键名 routeCategory。 + * 禁止:按空运要包装件数;把分类当查价条件;直连 TMS。 + */ +public final class TmsLandSegmentMapper { + + /** 体积+重量那一套。 */ + private static final Set LCL = new HashSet( + Arrays.asList("国内运输拼车", "跨境集拼")); + /** 车型/数量那一套。中港不在此列,按装载方式另判。 */ + private static final Set FTL = new HashSet( + Arrays.asList("国内运输整车", "跨境整车")); + + private static final Pattern VEHICLE_SPLIT = Pattern.compile("\\s*[/xX×**]\\s*"); + private static final Pattern TAIL_QTY = Pattern.compile("^(.*?)[\\s,,]+(\\d+)\\s*(台|辆)?$"); + + private TmsLandSegmentMapper() { + } + + /** + * @param facts 工单/智能体中文 facts(含运输类型、线路类别、货物、中港装载方式) + * @return 可并入查价 segment 的键;不含 landClass / hkLoadType + */ + public static Map build(Map facts) { + Map source = facts == null ? new LinkedHashMap() : facts; + Map seg = new LinkedHashMap(); + String type = text(source.get("运输类型")); + String route = text(source.get("线路类别")); + String hkLoad = text(source.get("中港装载方式")); + put(seg, "landTransportType", TmsLandEnumMapper.transportType(type)); + put(seg, "routeCategory", TmsLandEnumMapper.routeCategory(route)); + put(seg, "origin", text(source.get("起运港"))); + put(seg, "destination", text(source.get("目的港"))); + put(seg, "cargoName", text(source.get("品名"))); + if (LCL.contains(type)) { + put(seg, "volumeCbm", TmsV2RequestMapper.asPositiveDecimal(source.get("体积"))); + put(seg, "weightKg", TmsV2RequestMapper.asPositiveDecimal(source.get("毛重"))); + } else if ("中港".equals(type) && !"零担/集拼".equals(hkLoad)) { + // 中港整车:询价不要车型/数量,出站也不带 + } else if (FTL.contains(type) || "中港".equals(type)) { + putVehicleQty(source, seg); + } + String customs = text(source.get("通关口岸")); + if (StringUtils.isNotBlank(customs)) { + seg.put("customsPort", customs); + } + return seg; + } + + /** + * 陆运出站拦网:工单号、报价日期、起运目的品名、类型、线路,再加当前类型货物。 + * 不要包装、不要运输分类、不要中港装载。中港整车不要车型/数量。 + * + * @param facts 用于读中港装载方式;禁止把装载方式写进 segment + */ + public static List missingQuery(String workOrderNo, String quoteDate, + Map segment, + Map facts) { + List missing = new ArrayList(); + if (workOrderNo == null || !workOrderNo.matches("WO\\d{12}")) { + missing.add("workOrderNo"); + } + if (quoteDate == null || quoteDate.isEmpty()) { + missing.add("quoteDate"); + } + Map seg = segment == null ? new LinkedHashMap() : segment; + String[] required = { + "origin", "destination", "cargoName", "landTransportType", "routeCategory" + }; + for (String key : required) { + if (blank(seg.get(key))) { + missing.add(key); + } + } + String typeEnum = text(seg.get("landTransportType")); + String cnType = text(facts == null ? null : facts.get("运输类型")); + String hkLoad = text(facts == null ? null : facts.get("中港装载方式")); + boolean lcl = LCL.contains(cnType) + || "DOMESTIC_LTL".equals(typeEnum) + || "CROSS_BORDER_LTL".equals(typeEnum); + boolean hk = "中港".equals(cnType) || "CHINA_HK".equals(typeEnum); + boolean ftl = FTL.contains(cnType) + || "DOMESTIC_FTL".equals(typeEnum) + || "CROSS_BORDER_FTL".equals(typeEnum); + if (lcl) { + if (seg.get("volumeCbm") == null) { + missing.add("volumeCbm"); + } + if (seg.get("weightKg") == null) { + missing.add("weightKg"); + } + } else if (hk && !"零担/集拼".equals(hkLoad)) { + // 中港整车不拦车型/数量 + } else if (ftl || hk) { + if (blank(seg.get("vehicleType"))) { + missing.add("vehicleType"); + } + if (seg.get("quantity") == null) { + missing.add("quantity"); + } + } + return missing; + } + + /** + * 兼容旧调用:没有 facts 时按段上的类型拦;中港整车必须走四参才能放过车型。 + */ + public static List missingQuery(String workOrderNo, String quoteDate, + Map segment) { + return missingQuery(workOrderNo, quoteDate, segment, null); + } + + /** + * 销售侧「车型/数量」是一个字段,出站再拆成 vehicleType + quantity。 + */ + private static void putVehicleQty(Map source, Map seg) { + String combo = firstNonBlank( + text(source.get("车型/数量")), + text(source.get("车型数量"))); + String vehicle = text(source.get("车型")); + String qtyRaw = text(source.get("数量")); + if (combo.isEmpty()) { + if (!vehicle.isEmpty() && !qtyRaw.isEmpty()) { + combo = vehicle + "/" + qtyRaw; + } else if (!vehicle.isEmpty()) { + combo = vehicle; + } + } + if (combo.isEmpty()) { + return; + } + String[] parts = splitVehicleQty(combo); + String qty = parts[1]; + if (StringUtils.isNotBlank(qtyRaw) && !combo.contains(qtyRaw)) { + qty = qtyRaw; + } + put(seg, "vehicleType", parts[0]); + Integer number = TmsV2RequestMapper.asInteger(qty); + put(seg, "quantity", number == null ? Integer.valueOf(1) : number); + } + + /** + * 例:9米6/1、9米6*2、9米6 1台。拆不出台数时默认 1。 + */ + static String[] splitVehicleQty(String raw) { + String text = text(raw); + if (text.isEmpty()) { + return new String[] {"", "1"}; + } + String[] parts = VEHICLE_SPLIT.split(text, 2); + if (parts.length == 2 && StringUtils.isNotBlank(parts[0]) && StringUtils.isNotBlank(parts[1])) { + Integer n = TmsV2RequestMapper.asInteger(parts[1]); + return new String[] {parts[0].trim(), n == null ? parts[1].trim() : String.valueOf(n)}; + } + Matcher tail = TAIL_QTY.matcher(text); + if (tail.matches() && StringUtils.isNotBlank(tail.group(1))) { + return new String[] {tail.group(1).trim(), tail.group(2)}; + } + return new String[] {text, "1"}; + } + + private static String firstNonBlank(String... values) { + if (values == null) { + return ""; + } + for (String value : values) { + if (StringUtils.isNotBlank(value)) { + return value.trim(); + } + } + return ""; + } + + private static void put(Map target, String key, Object value) { + if (value != null && !"".equals(value)) { + target.put(key, value); + } + } + + private static boolean blank(Object raw) { + return raw == null || String.valueOf(raw).trim().isEmpty(); + } + + private static String text(Object raw) { + return raw == null ? "" : String.valueOf(raw).trim(); + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java index f0a725f..33260a3 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java @@ -80,6 +80,7 @@ public final class TmsV2RequestMapper { // 海运出站:口语必须先整理。FCL 要 containerType+containerQty,loadType 只能 FCL/LCL, // cargoQty 只能是数字。销售侧「整柜 / 1x40HQ / 15件」原样留在工单,不在这里改。 applySeaFields(source, target); + applyLandFields(source, target); return compact(target); } @@ -114,6 +115,23 @@ public final class TmsV2RequestMapper { put(target, "containerQty", asInteger(qty)); } + /** + * 陆运出站:类型+线路用 6.1.1 枚举;线路字段名是 routeCategory。 + * 分类和中港装载即使误放在 source 也不抄走。中文不得原样出站。 + */ + private static void applyLandFields(Map source, Map target) { + String type = textOf(first(source, "landTransportType")); + String route = textOf(first(source, "routeCategory", "landRouteCategory")); + put(target, "landTransportType", TmsLandEnumMapper.transportType(type)); + put(target, "routeCategory", TmsLandEnumMapper.routeCategory(route)); + put(target, "vehicleType", first(source, "vehicleType")); + put(target, "customsPort", first(source, "customsPort")); + } + + private static String textOf(Object raw) { + return raw == null ? "" : String.valueOf(raw).trim(); + } + /** * 报价日期规范成 yyyy-MM-dd。解析不了则不传,避免 1001。 */ diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/staff/LandStaffMatcherTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/staff/LandStaffMatcherTest.java new file mode 100644 index 0000000..3a80235 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/staff/LandStaffMatcherTest.java @@ -0,0 +1,56 @@ +package org.jeecg.modules.inquiry.staff; + +import org.jeecg.modules.inquiry.entity.InquiryStaffRole; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 陆运拉群人员匹配:不连库。 + *

+ * 只认产品·陆运启用岗;负责线路按逗号整词对线路类别,禁止按斜杠切开「中港/中亚/中欧」。 + */ +public class LandStaffMatcherTest { + + @Test + public void southeastOnly() { + InquiryStaffRole a = staff("land", "active", "a", "甲", "东南亚"); + InquiryStaffRole b = staff("land", "active", "b", "乙", "国内长途/零担"); + InquiryStaffRole c = staff("land", "active", "c", "丙", "中港/中亚/中欧,东南亚"); + InquiryStaffRole d = staff("land", "active", "d", "丁", "中港/中亚/中欧"); + List> hits = LandStaffMatcher.match( + Arrays.asList(a, b, c, d), "东南亚"); + assertEquals(2, hits.size()); + assertEquals("甲", hits.get(0).get("name")); + assertEquals("丙", hits.get(1).get("name")); + } + + @Test + public void slashNotSplit() { + InquiryStaffRole row = staff("land", "active", "x", "张", "中港/中亚/中欧"); + assertEquals(1, LandStaffMatcher.match(Arrays.asList(row), "中港/中亚/中欧").size()); + assertEquals(0, LandStaffMatcher.match(Arrays.asList(row), "东南亚").size()); + } + + @Test + public void ignoreSeaRole() { + InquiryStaffRole sea = staff("sea", "active", "s", "海", "东南亚"); + assertTrue(LandStaffMatcher.match(Arrays.asList(sea), "东南亚").isEmpty()); + } + + private static InquiryStaffRole staff(String role, String status, String wecomId, + String name, String routes) { + InquiryStaffRole row = new InquiryStaffRole(); + row.setRoleCode(role); + row.setStatus(status); + row.setWecomId(wecomId); + row.setName(name); + row.setRoutes(routes); + return row; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java index d1af42a..f4256c0 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java @@ -14,6 +14,7 @@ import java.nio.file.Path; import java.util.HashMap; import java.util.Map; +import org.apache.commons.lang3.StringUtils; import org.apache.poi.ss.usermodel.CellType; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -487,6 +488,109 @@ public class QuoteTemplateFillTest { } } + /** + * 陆运国内拼车模板是标签栏,不是「费用名称+金额」表。 + * TMS 的 元/CBM、送货费、时效必须写到标签右侧,不能落到 G 列或整张空着。 + */ + @Test + public void landLtlTemplateWritesTmsRatesBesideLabels() throws Exception { + Path src = Files.createTempFile("quote-land-ltl-src", ".xlsx"); + Path out = Files.createTempFile("quote-land-ltl-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("国内拼车"); + row(sheet, 1).createCell(0).setCellValue("始发站"); + row(sheet, 2).createCell(0).setCellValue("目的地"); + row(sheet, 3).createCell(0).setCellValue("元/CBM"); + row(sheet, 4).createCell(0).setCellValue("元/kg"); + row(sheet, 5).createCell(0).setCellValue("送货费"); + row(sheet, 6).createCell(0).setCellValue("时效(天)"); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject facts = new JSONObject(true); + facts.put("origin", "泉州"); + facts.put("destination", "南京"); + facts.put("price_per_cbm", "280"); + facts.put("price_per_kg", "1.2"); + facts.put("delivery_fee", "50"); + facts.put("transit_days", "3"); + facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows( + "[{\"name\":\"元/CBM\",\"amount\":\"280\"}," + + "{\"name\":\"元/kg\",\"amount\":\"1.2\"}," + + "{\"name\":\"送货费\",\"amount\":\"50\"}]")); + QuoteTemplateExcelSupport.fillWorkbook(src, out, new JSONObject(), facts); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = filled.getSheetAt(0); + assertEquals("泉州", read(sheet, 1, 1)); + assertEquals("南京", read(sheet, 2, 1)); + assertEquals(280.0, sheet.getRow(3).getCell(1).getNumericCellValue(), 0.01); + assertEquals(1.2, sheet.getRow(4).getCell(1).getNumericCellValue(), 0.01); + assertEquals(50.0, sheet.getRow(5).getCell(1).getNumericCellValue(), 0.01); + assertEquals("3", read(sheet, 6, 1).replace(".0", "")); + assertTrue(sheet.getRow(3).getCell(6) == null + || StringUtils.isBlank(read(sheet, 3, 6))); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + + /** + * 国内整车:4.2米/3T 这类车型费用也是标签右侧,不是海运费目表。 + */ + @Test + public void landFtlTemplateWritesVehicleFeeBesideLabel() throws Exception { + Path src = Files.createTempFile("quote-land-ftl-src", ".xlsx"); + Path out = Files.createTempFile("quote-land-ftl-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("国内整车"); + row(sheet, 2).createCell(0).setCellValue("4.2米/3T费用"); + row(sheet, 3).createCell(0).setCellValue("7.6米/8T费用"); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject facts = new JSONObject(true); + facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows( + "[{\"name\":\"4.2米/3T费用\",\"amount\":\"1800\"}," + + "{\"name\":\"7.6米/8T费用\",\"amount\":\"2600\"}]")); + QuoteTemplateExcelSupport.fillWorkbook(src, out, new JSONObject(), facts); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = filled.getSheetAt(0); + assertEquals(1800.0, sheet.getRow(2).getCell(1).getNumericCellValue(), 0.01); + assertEquals(2600.0, sheet.getRow(3).getCell(1).getNumericCellValue(), 0.01); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + + /** + * TMS 陆运 fee_lines 要投影成模板标签码,写出单时才能对上 元/CBM。 + */ + @Test + public void buildFillFactsProjectsLandQuoteLabels() { + Map facts = new HashMap<>(); + facts.put("始发站", "泉州"); + facts.put("运输类型", "国内运输拼车"); + Map quote = new HashMap<>(); + quote.put("total", "1280"); + quote.put("currency", "CNY"); + quote.put("eta", "3"); + quote.put("fee_lines", + "[{\"name\":\"元/CBM\",\"amount\":\"280\"}," + + "{\"name\":\"送货费\",\"amount\":\"50\"}]"); + JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl + .buildFillFacts("WO202609170012", facts, quote, "WuJiLin"); + assertEquals("泉州", out.getString("origin")); + assertEquals("3", out.getString("transit_days")); + assertEquals("280", out.getString("price_per_cbm")); + assertEquals("50", out.getString("delivery_fee")); + assertEquals("1280", out.getString("total_amount")); + assertEquals(2, out.getJSONArray("fee_rows").size()); + } + 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; diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcherTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcherTest.java index 042c426..52f6f3c 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcherTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcherTest.java @@ -82,6 +82,19 @@ public class QuoteTemplateMatcherTest { assertEquals("海运默认", hit.get("name")); } + @Test + public void landClassKeywordHits() { + List> list = new ArrayList<>(); + list.add(tpl("陆运急件", "陆运", false, "急件")); + list.add(tpl("陆运默认", "陆运", true, "普件")); + Map facts = new HashMap<>(); + facts.put("运输分类", "急件"); + Map hit = QuoteTemplateMatcher.pick(list, "LAND", facts, null); + assertNotNull(hit); + assertEquals("keyword", hit.get("matchType")); + assertEquals("陆运急件", hit.get("name")); + } + @Test public void seaTemplateIgnoredForAir() { List> list = new ArrayList<>(); @@ -91,13 +104,60 @@ public class QuoteTemplateMatcherTest { assertNull(QuoteTemplateMatcher.pick(list, "AIR", facts, null)); } - private static Map tpl(String name, String biz, boolean isDefault, String keyword) { + @Test + public void landPincheNotStolenByGenericLandKeyword() { + List> list = new ArrayList<>(); + list.add(tpl("中港", "陆运", false, "中港", "陆运")); + list.add(tpl("国内拼车", "陆运", false, "拼车", "零担")); + list.add(tpl("陆运默认", "陆运", true, "普件")); + Map facts = new HashMap<>(); + facts.put("运输类型", "国内运输拼车"); + facts.put("线路类别", "国内城配/拖车/打包"); + facts.put("运输分类", "普件,常用线路/常规车型"); + Map hit = QuoteTemplateMatcher.pick(list, "LAND", facts, null); + assertNotNull(hit); + assertEquals("keyword", hit.get("matchType")); + assertEquals("国内拼车", hit.get("name")); + } + + @Test + public void landCrossFtlBeatsDomesticFtl() { + List> list = new ArrayList<>(); + list.add(tpl("国内整车", "陆运", false, "整车", "国内")); + list.add(tpl("跨境整车", "陆运", false, "跨境", "整车")); + Map facts = new HashMap<>(); + facts.put("运输类型", "跨境整车"); + facts.put("线路类别", "东南亚"); + Map hit = QuoteTemplateMatcher.pick(list, "LAND", facts, null); + assertNotNull(hit); + assertEquals("跨境整车", hit.get("name")); + } + + @Test + public void landFallsBackToDefault() { + List> list = new ArrayList<>(); + list.add(tpl("中港", "陆运", false, "中港")); + list.add(tpl("陆运默认", "陆运", true, "急件")); + Map facts = new HashMap<>(); + facts.put("运输类型", "国内运输拼车"); + facts.put("运输分类", "普件,常用线路/常规车型"); + Map hit = QuoteTemplateMatcher.pick(list, "LAND", facts, null); + assertNotNull(hit); + assertEquals("default", hit.get("matchType")); + assertEquals("陆运默认", hit.get("name")); + } + + private static Map tpl(String name, String biz, boolean isDefault, String... keywords) { Map m = new HashMap<>(); m.put("name", name); m.put("bizType", biz); m.put("isDefault", isDefault); List kws = new ArrayList<>(); - kws.add(keyword); + if (keywords != null) { + for (String keyword : keywords) { + kws.add(keyword); + } + } m.put("keywords", kws); return m; } diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryServiceTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryServiceTest.java index 8859090..f6ae696 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryServiceTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryServiceTest.java @@ -47,4 +47,23 @@ public class InquiryTmsQueryServiceTest { assertEquals("整柜", facts.get("整柜或拼柜")); assertEquals("1x40HQ", facts.get("箱型箱量")); } + + @Test + public void mergeTicketFactsJsonKeepsLandOptions() { + InquiryTicket ticket = new InquiryTicket(); + ticket.setFactsJson("{\"运输类型\":\"跨境集拼\",\"线路类别\":\"东南亚\",\"运输分类\":\"急件\"}"); + java.util.Map facts = new java.util.HashMap(); + InquiryTmsQueryService.mergeTicketFacts(facts, ticket); + assertEquals("跨境集拼", facts.get("运输类型")); + assertEquals("东南亚", facts.get("线路类别")); + assertEquals("急件", facts.get("运输分类")); + } + + @Test + public void ticketLandWinsWhenBodyOmitsLine() { + java.util.Map body = new java.util.HashMap(); + body.put("work_order_no", "WO202609170001"); + String line = InquiryTmsQueryService.resolveTransportLine(body, new java.util.HashMap(), "LAND"); + assertEquals("LAND", line); + } } diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapperTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapperTest.java new file mode 100644 index 0000000..7e6cd3b --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapperTest.java @@ -0,0 +1,23 @@ +package org.jeecg.modules.inquiry.tms; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * 陆运 TMS 枚举:销售中文不得原样出站。不打真实 TMS。 + */ +public class TmsLandEnumMapperTest { + + @Test + public void pincheCityDistMatchesTms61() { + assertEquals("DOMESTIC_LTL", TmsLandEnumMapper.transportType("国内运输拼车")); + assertEquals("DOMESTIC_CITY_DIST", TmsLandEnumMapper.routeCategory("国内城配/拖车/打包")); + } + + @Test + public void alreadyEnumStays() { + assertEquals("DOMESTIC_LTL", TmsLandEnumMapper.transportType("DOMESTIC_LTL")); + assertEquals("SOUTHEAST_ASIA", TmsLandEnumMapper.routeCategory("SOUTHEAST_ASIA")); + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapperTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapperTest.java new file mode 100644 index 0000000..16a7b16 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapperTest.java @@ -0,0 +1,124 @@ +package org.jeecg.modules.inquiry.tms; + +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +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.assertNull; + +/** + * 陆运段拼参:不打真实 TMS。分类和中港装载不得出现在出站 map。 + */ +public class TmsLandSegmentMapperTest { + + @Test + public void landSegmentOmitsClass() { + Map facts = new HashMap(); + facts.put("运输类型", "跨境集拼"); + facts.put("线路类别", "东南亚"); + facts.put("运输分类", "急件"); + facts.put("起运港", "广州"); + facts.put("目的港", "河内"); + facts.put("品名", "衣服"); + facts.put("体积", "100"); + facts.put("毛重", "50"); + facts.put("报价日期", "2026-09-17"); + Map seg = TmsLandSegmentMapper.build(facts); + assertEquals("CROSS_BORDER_LTL", seg.get("landTransportType")); + assertEquals("SOUTHEAST_ASIA", seg.get("routeCategory")); + assertFalse(seg.containsKey("landRouteCategory")); + assertNull(seg.get("landClass")); + assertFalse(seg.containsKey("hkLoadType")); + assertFalse(seg.containsKey("运输分类")); + } + + @Test + public void landFtlUsesVehicleNotVolume() { + Map facts = new HashMap(); + facts.put("运输类型", "中港"); + facts.put("线路类别", "中港/中亚/中欧"); + facts.put("中港装载方式", "零担/集拼"); + facts.put("起运港", "广州"); + facts.put("目的港", "香港"); + facts.put("品名", "衣服"); + facts.put("车型/数量", "9米6/1"); + facts.put("体积", "99"); + Map seg = TmsLandSegmentMapper.build(facts); + assertEquals("9米6", seg.get("vehicleType")); + assertNull(seg.get("volumeCbm")); + assertFalse(seg.containsKey("hkLoadType")); + assertEquals(Integer.valueOf(1), seg.get("quantity")); + } + + @Test + public void hkFullTruckOmitsVehicle() { + Map facts = new HashMap(); + facts.put("运输类型", "中港"); + facts.put("线路类别", "中港/中亚/中欧"); + facts.put("中港装载方式", "整车"); + facts.put("起运港", "广州"); + facts.put("目的港", "香港"); + facts.put("品名", "衣服"); + Map seg = TmsLandSegmentMapper.build(facts); + assertNull(seg.get("vehicleType")); + assertNull(seg.get("quantity")); + java.util.List missing = TmsLandSegmentMapper.missingQuery( + "WO202609170005", "2026-09-17", seg, facts); + assertEquals(java.util.Collections.emptyList(), missing); + } + + @Test + public void domesticFtlSplitsCombinedField() { + Map facts = new HashMap(); + facts.put("运输类型", "国内运输整车"); + facts.put("线路类别", "国内长途/零担"); + facts.put("起运港", "广州"); + facts.put("目的港", "深圳"); + facts.put("品名", "衣服"); + facts.put("车型/数量", "9米6*2"); + Map seg = TmsLandSegmentMapper.build(facts); + assertEquals("9米6", seg.get("vehicleType")); + assertEquals(Integer.valueOf(2), seg.get("quantity")); + } + + @Test + public void wo011PincheCityDistUsesTmsEnums() { + Map facts = new HashMap(); + facts.put("运输类型", "国内运输拼车"); + facts.put("线路类别", "国内城配/拖车/打包"); + facts.put("起运港", "泉州市"); + facts.put("目的港", "南京市"); + facts.put("品名", "通信机柜及线缆配件"); + facts.put("体积", "10CBM"); + facts.put("毛重", "10KG"); + Map seg = TmsLandSegmentMapper.build(facts); + assertEquals("DOMESTIC_LTL", seg.get("landTransportType")); + assertEquals("DOMESTIC_CITY_DIST", seg.get("routeCategory")); + assertFalse(seg.containsKey("landRouteCategory")); + java.util.List missing = TmsLandSegmentMapper.missingQuery( + "WO202609170011", "2026-09-17", seg, facts); + assertEquals(java.util.Collections.emptyList(), missing); + } + + @Test + public void lclPincheDoesNotRequireQuantity() { + Map facts = new HashMap(); + facts.put("运输类型", "国内运输拼车"); + facts.put("线路类别", "国内长途/零担"); + facts.put("起运港", "广州"); + facts.put("目的港", "深圳"); + facts.put("品名", "衣服"); + facts.put("体积", "100CBM"); + facts.put("毛重", "50kg"); + Map seg = TmsLandSegmentMapper.build(facts); + java.util.List missing = TmsLandSegmentMapper.missingQuery( + "WO202609170005", "2026-09-17", seg); + assertEquals("DOMESTIC_LTL", seg.get("landTransportType")); + assertEquals("DOMESTIC_LONG_LTL", seg.get("routeCategory")); + assertNull(seg.get("quantity")); + assertEquals(java.util.Collections.emptyList(), missing); + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java index 2cf2536..5eedda1 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java @@ -105,4 +105,29 @@ public class TmsV2RequestMapperTest { assertEquals("40HQ", segs.get(0).get("containerType")); assertEquals(Integer.valueOf(1), segs.get(0).get("containerQty")); } + + @Test + public void landFieldsPassThroughWithoutClass() { + Map seg = new HashMap<>(); + seg.put("origin", "广州"); + seg.put("destination", "河内"); + seg.put("cargoName", "衣服"); + seg.put("landTransportType", "跨境集拼"); + seg.put("landRouteCategory", "东南亚"); + seg.put("volumeCbm", "100"); + seg.put("weightKg", "50"); + seg.put("landClass", "急件"); + Map internal = new HashMap<>(); + internal.put("transportMode", "LAND"); + internal.put("quoteDate", "2026-09-17"); + internal.put("workOrderNo", "WO202609170001"); + internal.put("segments", Collections.singletonList(seg)); + Map body = TmsV2RequestMapper.quote(internal); + @SuppressWarnings("unchecked") + List> segs = (List>) body.get("segments"); + assertEquals("CROSS_BORDER_LTL", segs.get(0).get("landTransportType")); + assertEquals("SOUTHEAST_ASIA", segs.get(0).get("routeCategory")); + assertFalse(segs.get(0).containsKey("landRouteCategory")); + assertFalse(segs.get(0).containsKey("landClass")); + } } diff --git a/inquiry-backend/src/views/inquiry/staffRole/index.vue b/inquiry-backend/src/views/inquiry/staffRole/index.vue index 0473ec5..afe2a83 100644 --- a/inquiry-backend/src/views/inquiry/staffRole/index.vue +++ b/inquiry-backend/src/views/inquiry/staffRole/index.vue @@ -1,4 +1,4 @@ -