diff --git a/inquiry-agent/agent/assets/land_sample_pairs.png b/inquiry-agent/agent/assets/land_sample_pairs.png new file mode 100644 index 0000000..122e4ff Binary files /dev/null and b/inquiry-agent/agent/assets/land_sample_pairs.png differ diff --git a/inquiry-agent/agent/channel/outbox/sender.py b/inquiry-agent/agent/channel/outbox/sender.py index 0451ba0..612c772 100644 --- a/inquiry-agent/agent/channel/outbox/sender.py +++ b/inquiry-agent/agent/channel/outbox/sender.py @@ -67,6 +67,21 @@ class WeComAppClient: 副作用:出站 HTTP 调企微;失败返回 ok=False,由 outbox 重试。 """ extra = dict(payload or {}) + if extra.get("msgtype") == "image": + image_result = self.send_image( + touser=touser, + filename=str(extra.get("filename") or "image.png"), + filepath=str(extra.get("filepath") or ""), + file_b64=str(extra.get("file_b64") or extra.get("fileBase64") or ""), + ) + if image_result.ok: + return image_result + logger.warning( + "图片发送失败,回退文本 err=%s:%s", + image_result.errcode, + image_result.errmsg, + ) + return self.send_text(touser=touser, content=content) if extra.get("msgtype") == "file": file_result = self.send_file( touser=touser, @@ -213,7 +228,9 @@ class WeComAppClient: return WeComSendResult(ok=False, errcode=-1, errmsg=f"b64:{exc}") if not raw: return WeComSendResult(ok=False, errcode=-1, errmsg="file_empty") - media_id, up = self._upload_media(filename=filename or "quote.xlsx", content=raw) + media_id, up = self._upload_media( + filename=filename or "quote.xlsx", content=raw, media_type="file" + ) if not media_id: return up return self._post_message( @@ -226,8 +243,64 @@ class WeComAppClient: preview=f"[file]{filename}", ) - def _upload_media(self, *, filename: str, content: bytes) -> tuple[str, WeComSendResult]: - """cgi-bin/media/upload;密钥不写日志。""" + def send_image( + self, + *, + touser: str, + filename: str, + filepath: str = "", + file_b64: str = "", + ) -> WeComSendResult: + """ + 先上传 image 素材再发图片消息,聊天里直接看图。 + + 字节来自本机路径或 Base64。禁止在回调线程调用。 + """ + if not touser: + return WeComSendResult(ok=False, errcode=-1, errmsg="touser 为空") + raw = b"" + if filepath: + try: + from pathlib import Path + + raw = Path(filepath).read_bytes() + except OSError as exc: + return WeComSendResult(ok=False, errcode=-1, errmsg=f"read_file:{exc}") + elif file_b64: + import base64 + + try: + raw = base64.b64decode(file_b64) + except Exception as exc: # noqa: BLE001 + return WeComSendResult(ok=False, errcode=-1, errmsg=f"b64:{exc}") + if not raw: + return WeComSendResult(ok=False, errcode=-1, errmsg="image_empty") + name = filename or "image.png" + media_id, up = self._upload_media( + filename=name, content=raw, media_type="image" + ) + if not media_id: + return up + return self._post_message( + { + "touser": touser, + "msgtype": "image", + "agentid": self._agent_id, + "image": {"media_id": media_id}, + }, + preview=f"[image]{name}", + ) + + def _upload_media( + self, + *, + filename: str, + content: bytes, + media_type: str = "file", + ) -> tuple[str, WeComSendResult]: + """cgi-bin/media/upload;type=file 或 image。密钥不写日志。""" + kind = (media_type or "file").strip() or "file" + mime = "image/png" if kind == "image" else "application/octet-stream" if not self._corp_id or not self._secret: return "", WeComSendResult(ok=False, errcode=-1, errmsg="WECOM_CORP_ID/SECRET 未配置") try: @@ -240,8 +313,8 @@ class WeComAppClient: with httpx.Client(timeout=max(self._timeout, 30.0)) as client: resp = client.post( url, - params={"access_token": token, "type": "file"}, - files={"media": (filename, content, "application/octet-stream")}, + params={"access_token": token, "type": kind}, + files={"media": (filename, content, mime)}, ) data = resp.json() except Exception as exc: # noqa: BLE001 @@ -250,7 +323,7 @@ class WeComAppClient: errcode = int(data.get("errcode") or 0) media_id = str(data.get("media_id") or "") if errcode != 0 or not media_id: - logger.warning("企微上传文件失败 errcode=%s", errcode) + logger.warning("企微上传素材失败 type=%s errcode=%s", kind, errcode) return "", WeComSendResult( ok=False, errcode=errcode, errmsg=str(data.get("errmsg") or "upload_fail"), raw=data ) diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py index 9ee2f47..2937cf9 100644 --- a/inquiry-agent/agent/llm/extract_text.py +++ b/inquiry-agent/agent/llm/extract_text.py @@ -23,12 +23,31 @@ _MODE_WORDS = ( _LABEL_LINE = re.compile( r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|" r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型/数量|车型数量|" - r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(.+)$" + r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|" + r"客户名称|货值|是否为危险品)\s*[::]\s*(.+)$" ) _LABEL_INLINE = re.compile( r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|" r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型/数量|车型数量|" - r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(\S+)" + r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|" + r"客户名称|货值|是否为危险品)\s*[::]\s*(\S+)" +) +# 群里协同标签,冒号可省略:「客户名称张三」「货值 10万」。长词优先。 +_COLLAB_LABELS = ( + "是否为危险品", + "商品海关编码", + "车型/数量", + "包装方式", + "客户名称", + "海关编码", + "贸易条款", + "货好时间", + "是否含油", + "是否含电", + "是否含磁", + "HS编码", + "货值", + "HS", ) @@ -64,6 +83,23 @@ def parse_labeled_facts(text: str) -> dict[str, str]: return facts +def _merge_spoken_collab(text: str, facts: dict[str, str]) -> dict[str, str]: + """ + 私聊已经说到的协同项写进事实,进群才能预填。 + + 不覆盖已有询价键。不拿去补问、不挡查价。 + """ + from agent.schema.field_validate import harvest_oral_collab, normalize_facts + + merged = dict(facts or {}) + extra = dict(harvest_oral_collab(text)) + extra.update(normalize_facts(harvest_collab_labels(text))) + for key, val in extra.items(): + if val and not str(merged.get(key) or "").strip(): + merged[key] = val + return merged + + def extract_inquiry_snapshot( text: str, *, @@ -80,11 +116,12 @@ def extract_inquiry_snapshot( 标签行覆盖 B 的同名字段。B 未出现的字段保持空,不本地猜港口。 件数/毛重/体积若原话已带 TMS 单位(件/KGS/CBM),空字段可机读补上。 """ - from agent.schema.field_validate import harvest_oral_measures, normalize_facts + from agent.schema.field_validate import harvest_oral_collab, harvest_oral_measures, normalize_facts 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))) + facts = _merge_spoken_collab(text, facts) subtype = str(facts.get("运输类型") or "").strip() return { "business_line": mode, @@ -109,6 +146,7 @@ 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)) + facts = _merge_spoken_collab(text, 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 @@ -123,37 +161,90 @@ def extract_inquiry_snapshot( } +def harvest_collab_labels(text: str) -> dict[str, str]: + """ + 群里协同标签:有无冒号都收。 + + 「客户名称张三 包装方式编织袋 货值 10万」切成三项。 + 只说「纸箱」没有标签则不收。值取到下一个标签之前。 + """ + raw = text or "" + hits: list[tuple[int, str]] = [] + for label in _COLLAB_LABELS: + start = 0 + while True: + pos = raw.find(label, start) + if pos < 0: + break + hits.append((pos, label)) + start = pos + len(label) + if not hits: + return {} + hits.sort() + # 同一起点只留最长标签,避免 HS 抢 HS编码。 + kept: list[tuple[int, str]] = [] + for pos, label in hits: + if kept and kept[-1][0] == pos and len(label) <= len(kept[-1][1]): + continue + if kept and kept[-1][0] == pos: + kept[-1] = (pos, label) + continue + kept.append((pos, label)) + out: dict[str, str] = {} + for i, (pos, label) in enumerate(kept): + begin = pos + len(label) + end = kept[i + 1][0] if i + 1 < len(kept) else len(raw) + chunk = raw[begin:end] + chunk = re.sub(r"^[\s::,,、]+", "", chunk) + chunk = re.sub(r"[\s,,、]+$", "", chunk) + if chunk: + out[label] = chunk + return out + + def extract_collab_fields( text: str, *, allow_b: bool = False, injected: Optional[dict[str, str]] = None, + allowed_keys: Optional[tuple[str, ...]] = None, ) -> dict[str, str]: """ - 群里协同字段:标签「键:值」+ 口语含油/含电/含磁、货好时间。 + 群里协同字段:标签(冒号可省)+ 口语含油/含电/含磁/危险品、货好时间。 - 闲聊、报价金额不进这里。商品海关编码 / HS 折成 HS编码。 - allow_b 保留给调用方;协同六项不走询价七项的 B 合同。 + allowed_keys 传入则只返回本单该有的项。不传则海运六项 ∪ 陆运协同键,给意图分类。 + 闲聊、只说「纸箱」不进这里。商品海关编码 / HS 折成 HS编码。 """ from agent.schema.field_validate import ( - SEA_COLLAB_FIELDS, + ALL_COLLAB_KEYS, harvest_oral_collab, normalize_facts, ) del allow_b + keys = tuple(allowed_keys) if allowed_keys is not None else ALL_COLLAB_KEYS if injected: return { k: str(v).strip() for k, v in normalize_facts(dict(injected)).items() - if k in SEA_COLLAB_FIELDS and str(v or "").strip() + if k in keys and str(v or "").strip() } labeled = normalize_facts(parse_labeled_facts(text)) + labeled.update(normalize_facts(harvest_collab_labels(text))) oral = harvest_oral_collab(text) + if "是否为危险品" in oral: + if allowed_keys is not None and "是否为危险品" not in keys: + oral.pop("是否为危险品", None) + elif allowed_keys is None: + raw = text or "" + if "普货" in raw and not any( + w in raw for w in ("危险", "非危", "有危", "含危", "是否为危险品") + ): + oral.pop("是否为危险品", None) merged = dict(oral) merged.update({k: v for k, v in labeled.items() if v}) out: dict[str, str] = {} - for key in SEA_COLLAB_FIELDS: + for key in keys: val = (merged.get(key) or "").strip() if val: out[key] = val diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index 97cea1f..fb04f40 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -155,7 +155,25 @@ def send_air_brief_and_sheet( if room and not view.get("collab_chat_id"): view["collab_chat_id"] = room facts = dict(view.get("facts") or {}) + from agent.schema.field_validate import AIR_COLLAB_FIELDS, pick_collab_from_facts + + seeded = pick_collab_from_facts(facts, AIR_COLLAB_FIELDS) collab = dict(view.get("collab_facts") or {}) + if seeded: + changed = False + for k, v in seeded.items(): + if v and not str(collab.get(k) or "").strip(): + collab[k] = v + changed = True + if changed: + view["collab_facts"] = collab + patcher = getattr(getattr(flow, "ledger", None), "patch_collab_facts", None) + if callable(patcher): + patcher( + work_order_no=no, + facts=seeded, + sender_id=str(view.get("sales_wecom_id") or view.get("salesWecomId") or ""), + ) view["collab_facts"] = collab view_q = dict(view.get("quote") or {}) override = dict(quote_override or {}) diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 92fe889..233f10f 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -17,9 +17,10 @@ from agent.schema.field_validate import ( AIR_COLLAB_FIELDS, AIR_REQUIRED_FIELDS, LAND_DISPLAY, - SEA_COLLAB_DISPLAY, SEA_COLLAB_FIELDS, SEA_REQUIRED_FIELDS, + collab_display_name, + collab_fields_for_ticket, display_name, ) from agent.schema.land_options import option_prompt as _land_option_prompt @@ -101,6 +102,30 @@ _CONTINUE_HINT = ( ASK_TRANSPORT = "请补充运输方式:海运 / 空运 / 陆运。" # 旧一句只问运输类型;销售要一次看到类型、线路、分类。残留调用也走完整清单。 ASK_LAND_SUBTYPE = _land_option_prompt(current={}) + + +def land_sample_image_payload() -> dict[str, Any]: + """ + 填写样例表图片出站。 + + 走企微 image,不走文件气泡。路径随发布包,出站线程再读字节。 + 禁止在回调线程上传。 + """ + from agent.schema.land_options import sample_image_path + + path = sample_image_path() + return { + "msgtype": "image", + "filename": "填写样例.png", + "filepath": str(path), + } + + +def land_sample_image_fallback() -> str: + """图片失败时回文字样例,避免销售什么都看不到。""" + from agent.schema.land_options import sample_fallback_text + + return sample_fallback_text() FIRST_CHAT = "你好,我是询价机器人。请直接描述客户需求,例如「上海到洛杉矶空运,3 件 180kg,下周三前到」,我会解析需求并查询 TMS 标准报价。" ROUTE_MODEL_FAILED = "刚才没听清,请再说一次:是新询价,还是要接着办哪张工单?" LAND_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。" @@ -111,9 +136,8 @@ 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_FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港零担/集拼"}) +_LAND_CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港整车", "中港零担/集拼"}) _LAND_EXAMPLES = { "起运港": "广州", "目的港": "深圳", @@ -127,11 +151,8 @@ _LAND_EXAMPLES = { 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 - # 整车或还没选定装载:卡片/补问都不出现车型/数量 + _ = facts + if land_type == "中港整车": return _LAND_HK_FULL_KEYS if land_type in _LAND_FTL_TYPES: return _LAND_FTL_KEYS @@ -142,8 +163,9 @@ def land_confirm_card(facts: dict[str, str] | None) -> str: """ 确定查价前的核对卡。没有工单号。 - 行列出运输类型/线路/分类(中港再加装载)和该类型货物字段。 + 行列出运输类型/线路/分类和该类型货物字段。 国内不出现通关口岸;跨境/中港通关口岸非必填,没填留空行。 + 不再单独列出中港装载方式。 """ src = dict(facts or {}) land_type = (src.get("运输类型") or "").strip() @@ -152,8 +174,6 @@ def land_confirm_card(facts: dict[str, str] | None) -> str: 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() @@ -170,6 +190,11 @@ def land_group_no_staff() -> str: return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的陆运产品岗后再试。" +def land_group_pair_rejected() -> str: + """类型+线路不在填写样例里,禁止拉群。""" + return "拉群失败,请排查填写内容,并重新发起工单。" + + def ask_land_fields( *, facts: dict[str, str] | None = None, @@ -182,7 +207,7 @@ def ask_land_fields( 报价日期不进待补充。 """ src = dict(facts or {}) - skip = {"报价日期", "运输类型", "线路类别", "运输分类", "中港装载方式"} + 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 = ["请确认并补充以下信息:", "运输方式:陆运"] @@ -373,10 +398,6 @@ def inquiry_field_lines( 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 "-" @@ -1151,8 +1172,10 @@ def sea_group_brief( lines.append("") lines.append("协同补充字段:") collab = dict(collab_facts or {}) - for key in SEA_COLLAB_FIELDS: - label = SEA_COLLAB_DISPLAY.get(key, key) + line = "LAND" if mode == "陆运" else "SEA" + collab_keys = collab_fields_for_ticket(facts, line) or SEA_COLLAB_FIELDS + for key in collab_keys: + label = collab_display_name(key, line) val = (collab.get(key) or collab.get(label) or "").strip() or "-" lines.append(f"{label}:{val}") tms = dict(tms_quote) if tms_quote is not None else {} @@ -1298,14 +1321,10 @@ 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]) @@ -1576,7 +1595,15 @@ def group_quote_not_recognized() -> str: return "没有识别到报价,请产品再发一次价格或 Excel/PDF" -def group_fields_not_recognized() -> str: +def group_fields_not_recognized( + keys: tuple[str, ...] | None = None, + business_line: str = "", +) -> str: + """没认出协同字段。陆运列出本单可补项;海运保持原六项口。""" + if keys: + line = (business_line or "").strip().upper() or "SEA" + names = [collab_display_name(k, line) for k in keys] + return "听到了。请直接说" + "、".join(names) + "。" return "听到了。请直接说贸易条款、货好时间、商品海关编码、是否含油/含电/含磁。" diff --git a/inquiry-agent/agent/policy/land_text_flow.py b/inquiry-agent/agent/policy/land_text_flow.py index 37a3612..db93440 100644 --- a/inquiry-agent/agent/policy/land_text_flow.py +++ b/inquiry-agent/agent/policy/land_text_flow.py @@ -1,7 +1,7 @@ """ 陆运文字询价确定性流程。 -本文件职责:选项联动 → 按类型补货物 → 无工单号核对卡 →(确定后)建单查 TMS。 +本文件职责:三项选项各自必填(不联动)→ 按类型补货物 → 无工单号核对卡 →(确定后)建单查 TMS。 查价后半段复用海运拉群/报价单/成交,拉人按线路类别,禁止陆运岗全员。 禁止:字段齐了就建单;直连 TMS;把海运「出卡即查价」改掉。 调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。 @@ -23,6 +23,8 @@ from agent.schema.land_options import ( harvest_land_options, looks_like_confirm, option_prompt, + option_prompt_needs_sample, + sample_pair_allowed, validate_land_options, ) from agent.schema.tms_land_query import assemble_land_query @@ -112,7 +114,7 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): subtype = str(snap.get("land_subtype") or facts.get("运输类型") or "").strip() if subtype and not str(facts.get("运输类型") or "").strip(): facts["运输类型"] = subtype - option_keys = ("运输类型", "线路类别", "运输分类", "中港装载方式") + 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) @@ -184,6 +186,23 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): sess=sess, ) + def _reply_land_options( + self, + reply: ReplyFn, + facts: dict[str, str], + *, + message: str = "", + ) -> None: + """ + 选项补问:先发文字清单,需要样例时再发表格图。 + + 图片失败由出站回退文字样例。不在本方法上传素材。 + """ + body = (message or "").strip() or option_prompt(current=facts) + reply(body) + if option_prompt_needs_sample(current=facts): + reply(copy.land_sample_image_fallback(), copy.land_sample_image_payload()) + def _confirm_or_hold( self, *, @@ -221,7 +240,7 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): """ 销售确定之后才建陆运工单并查 TMS。 - 核对卡阶段没有工单号。运输分类、中港装载方式写入工单 facts,不进入 assemble 出站。 + 核对卡阶段没有工单号。运输分类写入工单 facts,不进入 assemble 出站。 """ import uuid @@ -363,10 +382,10 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): 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)}) + 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)}) + 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 {"运输类型", "线路类别"}: @@ -412,7 +431,11 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): 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._reply_land_options( + reply, + facts, + message=str(err.get("message") or ""), + ) self._save( FlowSession( sender_id=sender_id, @@ -427,12 +450,11 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): 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._reply_land_options(reply, facts) self._save( FlowSession( sender_id=sender_id, @@ -574,6 +596,32 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): clear_pending_land_confirm(sender_id) + def _pull_collab_group( + self, + sess: FlowSession, + reply: ReplyFn, + card_meta: Optional[dict[str, Any]] = None, + ) -> str: + """ + 点「拉产品进群协同」:先核填写样例,再按线路拉人建群。 + + 类型+线路对不上样例:不建群,告诉销售重新开单。按钮保持可点。 + 对得上则走海运同一套建群。 + """ + facts = dict(sess.facts or {}) + land_type = str(facts.get("运输类型") or "").strip() + route = str(facts.get("线路类别") or "").strip() + if not sample_pair_allowed(land_type, route): + logger.info( + "land.group.reject pair type=%s route=%s wo=%s", + land_type or "-", + route or "-", + sess.work_order_no or "-", + ) + reply(copy.land_group_pair_rejected()) + return sess.phase or "wait_collab" + return super()._pull_collab_group(sess, reply, card_meta=card_meta) + def _match_sea_staff(self, sess: FlowSession) -> list[dict[str, Any]]: """按本单线路类别整词匹配产品·陆运,禁止全员。""" matcher = getattr(self._ledger, "match_land_staff", None) diff --git a/inquiry-agent/agent/policy/quote_overlay.py b/inquiry-agent/agent/policy/quote_overlay.py index 6b0eefc..3e039b6 100644 --- a/inquiry-agent/agent/policy/quote_overlay.py +++ b/inquiry-agent/agent/policy/quote_overlay.py @@ -24,6 +24,10 @@ _COLLAB_LABEL_MARKERS: tuple[tuple[str, tuple[str, ...]], ...] = ( ("是否含油", ("是否含油", "has_oil")), ("是否含电", ("是否含电", "has_battery")), ("是否含磁", ("是否含磁", "has_magnet")), + ("客户名称", ("客户名称", "customer_name")), + ("货值", ("货值", "cargo_value")), + ("是否为危险品", ("是否为危险品", "is_dangerous")), + ("包装方式", ("包装方式", "包装类型")), ) diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index fa03384..a2d2749 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -14,7 +14,12 @@ from typing import Any, Optional from agent.llm.extract_text import extract_collab_fields, extract_product_quote from agent.policy.quote_overlay import overlay_labeled_facts from agent.policy import inquiry_copy as copy -from agent.schema.field_validate import SEA_COLLAB_DISPLAY, SEA_COLLAB_FIELDS +from agent.schema.field_validate import ( + SEA_COLLAB_DISPLAY, + SEA_COLLAB_FIELDS, + collab_display_name, + collab_fields_for_ticket, +) # 主账填表按英文码取格子;中文键必须同时带上,否则货好时间/条款写不进表。 _COLLAB_FILL_ALIASES = { @@ -24,6 +29,10 @@ _COLLAB_FILL_ALIASES = { "是否含油": ("是否含油", "has_oil"), "是否含电": ("是否含电", "has_battery"), "是否含磁": ("是否含磁", "has_magnet"), + "客户名称": ("客户名称", "customer_name"), + "货值": ("货值", "cargo_value"), + "是否为危险品": ("是否为危险品", "is_dangerous"), + "包装方式": ("包装方式", "包装类型"), } logger = logging.getLogger(__name__) @@ -91,6 +100,24 @@ def ticket_no(ticket: object) -> str: return str(ticket_view(ticket).get("work_order_no") or "").strip() +def _collab_keys_of(ticket: object, sess: Any = None) -> tuple[str, tuple[str, ...]]: + """本单业务线 + 允许写入的协同内部键。""" + view = ticket_view(ticket) + facts = dict(view.get("facts") or {}) + if sess is not None and getattr(sess, "facts", None): + facts.update(dict(sess.facts or {})) + line = str( + view.get("business_line") + or view.get("businessLine") + or (getattr(sess, "business_line", "") if sess is not None else "") + or "SEA" + ).strip().upper() or "SEA" + keys = collab_fields_for_ticket(facts, line) + if not keys and line != "LAND": + keys = SEA_COLLAB_FIELDS + return line, keys + + def is_terminal(ticket: object) -> bool: return ticket_status(ticket) in TERMINAL @@ -378,6 +405,13 @@ def send_brief_and_sheet( quote = dict(override or sess_q or view_q or tms or manual) sales_name, products, mention_ids = resolve_group_mentions(flow, ticket, sess) has_tms = bool(tms.get("total") or _quote_fee_rows(tms)) + line = str( + view.get("business_line") + or view.get("businessLine") + or (sess.business_line if sess is not None else "") + or "SEA" + ).strip().upper() + mode = "陆运" if line == "LAND" else "海运" brief = copy.sea_group_brief( work_order_no=no, facts=facts, @@ -388,9 +422,16 @@ def send_brief_and_sheet( has_price=has_tms, tms_quote=tms, product_quote=manual, + transport_mode=mode, ) send_group_text(client, chat_id, brief, mention_userids=mention_ids) - send_filled_sheet(flow=flow, ticket=ticket, sess=sess, quote_override=quote) + send_filled_sheet( + flow=flow, + ticket=ticket, + sess=sess, + quote_override=quote, + business_line=line, + ) return "brief_and_sheet" @@ -400,7 +441,7 @@ def send_filled_sheet( ticket: object, sess: Any = None, quote_override: Optional[dict[str, Any]] = None, - business_line: str = "SEA", + business_line: str = "", chat_id: str = "", ) -> dict[str, Any]: """ @@ -429,7 +470,7 @@ def send_filled_sheet( flow=flow, chat_id=room, work_order_no=no, - facts=_facts_for_fill(facts, collab), + facts=_facts_for_fill(facts, collab, business_line=line or "SEA"), quote=quote, sales_id=str( (sess.sender_id if sess else "") @@ -441,15 +482,20 @@ def send_filled_sheet( ) -def _collab_value(collab: dict[str, str], key: str) -> str: - """协同六项:内部键、对外展示名都能取到。""" +def _collab_value(collab: dict[str, str], key: str, business_line: str = "SEA") -> str: + """协同项:内部键、对外展示名都能取到。""" src = dict(collab or {}) val = str(src.get(key) or "").strip() if val: return val - label = SEA_COLLAB_DISPLAY.get(key, "") + label = collab_display_name(key, business_line) if label: val = str(src.get(label) or "").strip() + if val: + return val + sea_label = SEA_COLLAB_DISPLAY.get(key, "") + if sea_label: + val = str(src.get(sea_label) or "").strip() return val @@ -466,15 +512,22 @@ def _oil_elec_mag_text(collab: dict[str, str]) -> str: return f"油:{oil or '-'};电:{elec or '-'};磁:{mag or '-'}" -def _facts_for_fill(facts: dict[str, str], collab: dict[str, str]) -> dict[str, str]: +def _facts_for_fill( + facts: dict[str, str], + collab: dict[str, str], + business_line: str = "SEA", +) -> dict[str, str]: """ - 询价七项 + 协同六项。中文键和填表英文码一起带上。 + 询价字段 + 本单协同项。中文键和填表英文码一起带上。 主账 writeFactsByLabel 只认 cargo_ready_date / trade_terms,只传「货好时间」会空着。 + 模板没有格子的新项照样带上,overlay 找不到标签就跳过。 """ merged = dict(facts or {}) src = dict(collab or {}) - for key in SEA_COLLAB_FIELDS: - val = _collab_value(src, key) + line = (business_line or "SEA").strip().upper() or "SEA" + keys = collab_fields_for_ticket(facts, line) or SEA_COLLAB_FIELDS + for key in keys: + val = _collab_value(src, key, line) if not val: continue for alias in _COLLAB_FILL_ALIASES.get(key, (key,)): @@ -549,7 +602,7 @@ def apply_collab_update( sender_id: str, sess: Any = None, ) -> str: - """补协同六项。终态要说明原因。""" + """补本单协同项。终态要说明原因。不该有的项当没补到。""" ticket = hydrate_ticket(flow, ticket) no = ticket_no(ticket) chat_id = str((sess.collab_chat_id if sess else "") or ticket_view(ticket).get("collab_chat_id") or "") @@ -557,11 +610,21 @@ def apply_collab_update( if is_terminal(ticket): send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket))) return "ticket_locked" - fields = extract_collab_fields(text, allow_b=True) + line, keys = _collab_keys_of(ticket, sess) + fields = extract_collab_fields(text, allow_b=True, allowed_keys=keys) if not fields: - send_group_text(client, chat_id, copy.group_fields_not_recognized()) + send_group_text( + client, + chat_id, + copy.group_fields_not_recognized(keys=keys or None, business_line=line), + ) return "collab_not_recognized" - flow.apply_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id) + flow.apply_collab_facts( + work_order_no=no, + facts=fields, + sender_id=sender_id, + allowed_keys=keys, + ) if sess is not None: merged = dict(sess.collab_facts) merged.update(fields) @@ -603,9 +666,15 @@ def apply_product_quote( if not is_product(ticket, sender_id, flow=flow): send_group_text(client, chat_id, copy.group_only_product_can_quote()) return "quote_not_product" - fields = extract_collab_fields(text) + fields = extract_collab_fields(text, allowed_keys=_collab_keys_of(ticket, sess)[1]) if fields: - flow.apply_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id) + line, keys = _collab_keys_of(ticket, sess) + flow.apply_collab_facts( + work_order_no=no, + facts=fields, + sender_id=sender_id, + allowed_keys=keys, + ) if sess is not None: merged = dict(sess.collab_facts) merged.update(fields) @@ -772,7 +841,7 @@ def _persist_quote_sheet( result = renderer( work_order_no=no, business_line=line, - facts=_facts_for_fill(facts, collab), + facts=_facts_for_fill(facts, collab, business_line=line or "SEA"), quote=quote, format="xlsx", sales_wecom_id=str( diff --git a/inquiry-agent/agent/policy/sea_text_flow.py b/inquiry-agent/agent/policy/sea_text_flow.py index 15f92af..a240c71 100644 --- a/inquiry-agent/agent/policy/sea_text_flow.py +++ b/inquiry-agent/agent/policy/sea_text_flow.py @@ -514,6 +514,24 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): product_ids=product_ids, ) + from agent.schema.field_validate import collab_fields_for_ticket, pick_collab_from_facts + + line = (sess.business_line or "SEA").upper() + keys = collab_fields_for_ticket(sess.facts, line) + seeded = pick_collab_from_facts(sess.facts, keys) + if seeded: + merged = dict(sess.collab_facts or {}) + for k, v in seeded.items(): + if v and not str(merged.get(k) or "").strip(): + merged[k] = v + sess.collab_facts = merged + self.apply_collab_facts( + work_order_no=no, + facts=seeded, + sender_id=sess.sender_id, + allowed_keys=keys, + ) + brief = copy.sea_group_brief( work_order_no=no, facts=sess.facts, @@ -638,6 +656,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): work_order_no: str, facts: dict[str, str], sender_id: str, + allowed_keys: tuple[str, ...] | None = None, ) -> dict[str, Any]: """ 群里静默写入协同字段。不改六态、不回复、不出报价版本。 @@ -647,10 +666,11 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): from agent.schema.field_validate import SEA_COLLAB_FIELDS no = (work_order_no or "").strip() + keys = tuple(allowed_keys) if allowed_keys is not None else SEA_COLLAB_FIELDS cleaned = { k: str(v).strip() for k, v in (facts or {}).items() - if k in SEA_COLLAB_FIELDS and str(v or "").strip() + if k in keys and str(v or "").strip() } if not no or not cleaned: return {"ok": False, "updated": []} diff --git a/inquiry-agent/agent/schema/field_validate.py b/inquiry-agent/agent/schema/field_validate.py index 286091d..6588234 100644 --- a/inquiry-agent/agent/schema/field_validate.py +++ b/inquiry-agent/agent/schema/field_validate.py @@ -64,6 +64,90 @@ AIR_COLLAB_FIELDS = ("是否含电", "是否含磁") SEA_COLLAB_DISPLAY = { "HS编码": "商品海关编码", } +# 陆运摘要标题:海关编码,不要写成海运的商品海关编码。 +LAND_COLLAB_DISPLAY = { + "HS编码": "海关编码", +} +# 填写样例允许拉群的类型+线路 → 协同内部键(顺序与规格一致)。 +_LAND_COLLAB_BY_PAIR: dict[tuple[str, str], tuple[str, ...]] = { + ("国内运输拼车", "国内长途/零担"): ( + "客户名称", + "包装方式", + "货值", + "是否为危险品", + ), + ("国内运输拼车", "国内城配/拖车/打包"): ( + "客户名称", + "包装方式", + "货值", + "是否为危险品", + ), + ("国内运输整车", "国内长途/零担"): ( + "客户名称", + "货值", + "包装方式", + "是否为危险品", + ), + ("国内运输整车", "国内城配/拖车/打包"): ( + "客户名称", + "货值", + "包装方式", + "是否为危险品", + ), + ("跨境集拼", "中港/中亚/中欧"): ( + "客户名称", + "HS编码", + "贸易条款", + "包装方式", + "是否为危险品", + ), + ("跨境集拼", "东南亚"): ( + "客户名称", + "HS编码", + "贸易条款", + "包装方式", + "是否为危险品", + ), + ("跨境整车", "中港/中亚/中欧"): ( + "客户名称", + "HS编码", + "贸易条款", + "货值", + "包装方式", + "是否为危险品", + ), + ("跨境整车", "东南亚"): ( + "客户名称", + "HS编码", + "贸易条款", + "货值", + "包装方式", + "是否为危险品", + ), + ("中港整车", "中港/中亚/中欧"): ( + "客户名称", + "HS编码", + "车型/数量", + "贸易条款", + "货值", + "包装方式", + "是否为危险品", + ), + ("中港零担/集拼", "中港/中亚/中欧"): ( + "客户名称", + "HS编码", + "贸易条款", + "包装方式", + "是否为危险品", + ), +} +# 意图分类用:海运六项 ∪ 陆运全部协同键。 +ALL_COLLAB_KEYS = tuple( + dict.fromkeys( + list(SEA_COLLAB_FIELDS) + + ["客户名称", "包装方式", "货值", "是否为危险品", "车型/数量"] + ) +) # 口语里已经带上 TMS 文档规定的单位时,补进空字段。 # 只认「数字+单位」,不猜港口,不把长宽高换算成立方。 @@ -102,6 +186,9 @@ _ALIASES = { "HS CODE": "HS编码", "商品海关编码": "HS编码", "海关编码": "HS编码", + "客户名称": "客户名称", + "货值": "货值", + "是否为危险品": "是否为危险品", } @@ -224,11 +311,75 @@ def harvest_oral_measures(text: str, facts: dict[str, str] | None = None) -> dic return harvest_named_air_route(raw, merged) +def land_collab_fields(land_type: str, route: str) -> tuple[str, ...]: + """ + 本单陆运协同内部键,顺序与规格一致。 + + 对不上填写样例返回空元组,调用方不得按海运六项去列。 + 海关编码内部仍是 HS编码。副作用:无。 + """ + from agent.schema.land_options import canonicalize_route, canonicalize_transport_type + + t = canonicalize_transport_type(land_type) or (land_type or "").strip() + r = canonicalize_route(route) or (route or "").strip() + return _LAND_COLLAB_BY_PAIR.get((t, r), ()) + + +def collab_fields_for_ticket(facts: dict[str, str] | None, business_line: str) -> tuple[str, ...]: + """ + 按业务线取出群摘要 / 写入允许的协同键。 + + LAND 看事实里的运输类型+线路类别;SEA/AIR 用固定名单。 + """ + line = (business_line or "").strip().upper() + if line == "AIR": + return AIR_COLLAB_FIELDS + if line == "LAND": + src = dict(facts or {}) + return land_collab_fields( + str(src.get("运输类型") or ""), + str(src.get("线路类别") or ""), + ) + return SEA_COLLAB_FIELDS + + +def collab_display_name(key: str, business_line: str) -> str: + """协同项对外标题。陆运 HS编码 → 海关编码;海运 → 商品海关编码。""" + line = (business_line or "").strip().upper() + if line == "LAND": + return LAND_COLLAB_DISPLAY.get(key, key) + return SEA_COLLAB_DISPLAY.get(key, key) + + +def pick_collab_from_facts( + facts: dict[str, str] | None, + keys: tuple[str, ...], +) -> dict[str, str]: + """ + 从私聊事实里取出已有协同键,供进群预填。 + + 只取 keys 里非空值。海关编码/商品海关编码经 normalize 折成 HS编码。 + 不在 keys 的询价项(如跨境整车车型/数量)不会被取出。 + 不覆盖调用方已有协同值;本函数只返回候选。 + """ + allowed = set(keys or ()) + if not allowed: + return {} + src = normalize_facts(facts) + out: dict[str, str] = {} + for key in keys: + val = (src.get(key) or "").strip() + if val: + out[key] = val + return out + + def harvest_oral_collab(text: str) -> dict[str, str]: """ - 群里口语协同三项:含油/含电/含磁、货好时间。 + 群里口语协同:含油/含电/含磁、货好时间、是否为危险品。 - 只认原话里已经说死的词(不含电、随时可提),不猜贸易条款和海关编码。 + 只认原话里已经说死的词(不含电、随时可提、普货),不猜贸易条款和海关编码。 + 危险品:否词(不是危险品/非危/普货)先于是词,避免「不是危险品」收成是。 """ raw = text or "" out: dict[str, str] = {} @@ -236,6 +387,11 @@ def harvest_oral_collab(text: str) -> dict[str, str]: ("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")), ("是否含油", ("不含油", "不带油", "无油"), ("含油", "带油")), ("是否含磁", ("不含磁", "不带磁", "无磁"), ("含磁", "带磁")), + ( + "是否为危险品", + ("不是危险品", "非危险品", "非危", "普货"), + ("危险品", "有危", "含危"), + ), ) for key, no_words, yes_words in yes_no: if any(w in raw for w in no_words): @@ -279,7 +435,8 @@ def _pricing_required_keys( land_subtype: str, facts: dict[str, str] | None = None, ) -> list[str]: - """当前激活的首次查价内部键列表。中港再按装载方式收窄。""" + """当前激活的首次查价内部键列表。中港整车/零担是两个运输类型。""" + _ = facts line = (business_line or "").strip().upper() pricing = contract.get("pricing_required") or {} if line == "SEA": @@ -291,14 +448,6 @@ def _pricing_required_keys( 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 [] diff --git a/inquiry-agent/agent/schema/land_options.py b/inquiry-agent/agent/schema/land_options.py index f5aae51..a7ca8d5 100644 --- a/inquiry-agent/agent/schema/land_options.py +++ b/inquiry-agent/agent/schema/land_options.py @@ -1,61 +1,64 @@ """ -陆运选项规则:运输类型、线路类别、运输分类、中港装载方式。 +陆运选项规则:运输类型、线路类别、运输分类。 -本文件职责:从销售原话收取标准项、校验联动、生成补问清单、判断「确定查价」意图。 -联动错误只纠正那一项能选什么,不整单作废。 -禁止:改六态;发明枚举;把选项当货物字段。 +本文件职责:从销售原话收取标准项、校验是否在名单内、生成补问清单(含填写样例表)、判断「确定查价」意图。 +三项各自必填,组合不拦截查价。拉群必须对上填写样例。运输分类句里的「中港/东南亚」不得冒充类型或线路。 +禁止:改六态;发明枚举;把选项当货物字段;再要单独的中港装载方式。 线程:纯函数,无共享可变状态,可在 Worker 槽调用。 """ from __future__ import annotations +from pathlib import Path 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 = ( - "普件,中港、东南亚常用线路/常规车型", - "普件,中亚、中欧、东南亚涉及国内+国外段", - "普件,中亚、中欧、东南亚涉及境外段清派等特殊要求", +ROUTES = ( + "国内长途/零担", + "国内城配/拖车/打包", + "中港/中亚/中欧", + "东南亚", +) +LAND_CLASSES = ( + "普件(东南亚、中港常用线路/常规车型)", + "普件(东南亚、中亚、中欧涉及国内+国外段)", + "普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)", "急件", ) -DOMESTIC_CLASSES = ( - "普件,常用线路/常规车型", - "普件,非常用线路/临时线路/特种车型", - "急件", -) -CLASSES_BY_ROUTE = { - "中港/中亚/中欧": CROSS_CLASSES, - "东南亚": CROSS_CLASSES, - "国内长途/零担": DOMESTIC_CLASSES, - "国内城配/拖车/打包": DOMESTIC_CLASSES, -} -HK_LOADS = ("整车", "零担/集拼") -# 口语别名 → 标准运输类型。长词优先,避免「拼车」误伤「整车」。 +# 填写样例,同时作为拉群门禁:对不上的类型+线路可以查价,但不能拉群。 +SAMPLE_PAIRS = ( + ("国内运输拼车", "国内长途/零担"), + ("国内运输拼车", "国内城配/拖车/打包"), + ("国内运输整车", "国内长途/零担"), + ("国内运输整车", "国内城配/拖车/打包"), + ("跨境集拼", "中港/中亚/中欧"), + ("跨境集拼", "东南亚"), + ("跨境整车", "中港/中亚/中欧"), + ("跨境整车", "东南亚"), + ("中港整车", "中港/中亚/中欧"), + ("中港零担/集拼", "中港/中亚/中欧"), +) + +# 口语别名 → 标准运输类型。长词优先。禁止把单独「中港」收成类型。 _TYPE_ALIASES = ( ("国内运输拼车", "国内运输拼车"), ("国内运输整车", "国内运输整车"), + ("中港零担/集拼", "中港零担/集拼"), + ("中港零担", "中港零担/集拼"), + ("中港集拼", "中港零担/集拼"), + ("中港整车", "中港整车"), ("国内拼车", "国内运输拼车"), ("国内整车", "国内运输整车"), ("跨境集拼", "跨境集拼"), ("跨境整车", "跨境整车"), - ("中港", "中港"), ) _ROUTE_ALIASES = ( @@ -67,20 +70,9 @@ _ROUTE_ALIASES = ( ("东南亚", "东南亚"), ) -# 分类全称从长到短;「急件」单独出现才算急件,避免普件句被误收。 +# 分类全称从长到短;「急件」单独出现才算急件。 _CLASS_CANON = tuple( - sorted( - {*(CROSS_CLASSES + DOMESTIC_CLASSES)}, - key=len, - reverse=True, - ) -) - -_HK_LOAD_ALIASES = ( - ("零担/集拼", "零担/集拼"), - ("零担", "零担/集拼"), - ("集拼", "零担/集拼"), - ("整车", "整车"), + sorted(LAND_CLASSES, key=len, reverse=True) ) _CONFIRM_PHRASES = ("可以查了", "确定", "确认", "查价") @@ -92,6 +84,7 @@ def canonicalize_transport_type(raw: str) -> str: 把销售原话收成标准运输类型。 参数:任意一句。返回 TRANSPORT_TYPES 之一或空串。 + 只说「中港」不算选完,避免分类句里的中港把类型改掉。 副作用:无。先匹配更长别名。 """ text = (raw or "").strip() @@ -104,7 +97,7 @@ def canonicalize_transport_type(raw: str) -> str: def canonicalize_route(raw: str) -> str: - """把原话收成标准线路类别;对不上返回空串。""" + """把原话收成标准线路类别;对不上返回空串。不把单独「中港」收成中港/中亚/中欧。""" text = (raw or "").strip() if not text: return "" @@ -136,14 +129,25 @@ def canonicalize_land_class(raw: str) -> str: return "" +def sample_pair_allowed(land_type: str, route: str) -> bool: + """ + 类型+线路是否在填写样例里。 + + 查价不调用本函数;点「拉产品进群协同」时必须为真,否则不建群。 + 入参先收成标准项。副作用:无。 + """ + t = canonicalize_transport_type(land_type) or (land_type or "").strip() + r = canonicalize_route(route) or (route or "").strip() + return (t, r) in SAMPLE_PAIRS + + 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 + """ + 旧中港装载方式不再收取。 + + 保留函数名避免旧调用处报缺符号;恒返回空串。新单用运输类型「中港整车」「中港零担/集拼」。 + """ + _ = raw return "" @@ -151,9 +155,9 @@ def harvest_land_options(text: str, current: dict[str, str] | None = None) -> di """ 从一句原话收取陆运选项,合并到已有值上。 + 先收运输分类并把命中句从原文挖掉,再收类型/线路,避免分类里的东南亚、中港冒充选项。 已有非空项默认保留;本句明确写出另一标准项时才覆盖。 - 已有值对不上标准枚举则丢掉,避免模型把「陆运」写成运输类型后整张清单重问。 - 不改货物字段。返回新 dict,不修改 current。 + 已有值对不上标准枚举则丢掉。不改货物字段。返回新 dict,不修改 current。 """ merged = { k: str(v).strip() @@ -162,23 +166,22 @@ def harvest_land_options(text: str, current: dict[str, str] | None = None) -> di } _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) + remainder = blob.replace(found_class, " ") if found_class else blob + found_type = canonicalize_transport_type(remainder) + found_route = canonicalize_route(remainder) if found_type: merged["运输类型"] = found_type if found_route: merged["线路类别"] = found_route if found_class: merged["运输分类"] = found_class - if found_load: - merged["中港装载方式"] = found_load + merged.pop("中港装载方式", None) return merged def _keep_canonical_options(merged: dict[str, str]) -> None: - """只留能对上枚举的选项;模型胡写的「陆运」不能冒充运输类型。""" + """只留能对上枚举的选项;模型胡写的「陆运」不能冒充运输类型。旧中港两项作废。""" t = canonicalize_transport_type(merged.get("运输类型") or "") if t: merged["运输类型"] = t @@ -194,138 +197,124 @@ def _keep_canonical_options(merged: dict[str, str]) -> None: merged["运输分类"] = c else: merged.pop("运输分类", None) - load = canonicalize_hk_load(merged.get("中港装载方式") or "") - if load: - merged["中港装载方式"] = load - else: - merged.pop("中港装载方式", None) + merged.pop("中港装载方式", None) def validate_land_options(opts: dict[str, str]) -> dict[str, Any]: """ - 校验选项是否齐全且联动正确。 + 校验三项是否都在名单内。组合不拦截。 - 返回 ok / missing / errors。errors 每项 {field, message},只纠正错的那一项。 - 中港缺装载方式进 missing,不改货物字段。 + 返回 ok / missing / errors。对不上名单的项当缺项,不写联动纠正句。 副作用:无。 """ 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 "" + t = canonicalize_transport_type(src.get("运输类型") or "") + r = canonicalize_route(src.get("线路类别") or "") + c = canonicalize_land_class(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: + if r not in ROUTES: + missing.append("线路类别") + r = "" + if c not in LAND_CLASSES: missing.append("运输分类") + c = "" - if t == "中港": - if load not in HK_LOADS: - missing.append("中港装载方式") - ok = not missing and not errors + ok = not missing return { "ok": ok, "missing": missing, - "errors": errors, + "errors": [], "normalized": { "运输类型": t, "线路类别": r, "运输分类": c, - "中港装载方式": load if t == "中港" else "", + "中港装载方式": "", }, } +def _class_prompt_lines() -> list[str]: + lines = ["运输分类(必填):可选择回复,"] + for i, name in enumerate(LAND_CLASSES, start=1): + lines.append(f"{i}.{name}") + return lines + + +def option_prompt_needs_sample(*, current: dict[str, str]) -> bool: + """运输类型或线路类别缺任一,就要发填写样例图。只缺分类不发。""" + src = {k: str(v).strip() for k, v in dict(current or {}).items() if str(v).strip()} + t = canonicalize_transport_type(src.get("运输类型") or "") + r = canonicalize_route(src.get("线路类别") or "") + return t not in TRANSPORT_TYPES or r not in ROUTES + + +def sample_image_path() -> Path: + """填写样例表 PNG。和源码放一起,随 agent 发布包走。""" + return Path(__file__).resolve().parent.parent / "assets" / "land_sample_pairs.png" + + +def sample_fallback_text() -> str: + """ + 图片发不出去时的文字兜底。 + + 一行一种搭配,不再画框线。 + """ + lines = ["填写样例"] + for i, (typ, route) in enumerate(SAMPLE_PAIRS, start=1): + lines.append(f"{i}.{typ} + {route}") + return "\n".join(lines) + + +def _sample_table() -> str: + """兼容旧名:补问正文不再内嵌样例,只作图片失败兜底。""" + return sample_fallback_text() + + 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 "" + t = canonicalize_transport_type(src.get("运输类型") or "") + r = canonicalize_route(src.get("线路类别") or "") + c = canonicalize_land_class(src.get("运输分类") or "") + need_type = t not in TRANSPORT_TYPES + need_route = r not in ROUTES + need_class = c not in LAND_CLASSES lines: list[str] = [] - if t not in TRANSPORT_TYPES: - lines.append("请一次选择:运输类型、线路类别、运输分类。") + if need_type and need_route and need_class: + lines.append("请一次选择:运输类型、线路类别、运输分类") lines.append("") - lines.append("运输类型:") - lines.append("、".join(TRANSPORT_TYPES)) + lines.append("运输类型(必填):可选择回复," + "、".join(TRANSPORT_TYPES)) lines.append("") - lines.append("线路类别(跟运输类型联动):") - lines.append("国内运输拼车 / 国内运输整车:" + "、".join(DOMESTIC_ROUTES)) - lines.append("跨境集拼 / 跨境整车 / 中港:" + "、".join(CROSS_ROUTES)) + lines.append("线路类别(必填):可选择回复," + "、".join(ROUTES)) lines.append("") - lines.append("运输分类(跟线路类别联动):") - lines.append("中港/中亚/中欧、东南亚:" + ";".join(CROSS_CLASSES)) - lines.append("国内长途/零担、国内城配/拖车/打包:" + ";".join(DOMESTIC_CLASSES)) + lines.extend(_class_prompt_lines()) lines.append("") - lines.append("运输类型选中港时,还要选择:" + "、".join(HK_LOADS)) + lines.append("填写样例见图。") 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)) + if need_type: + lines.append("运输类型(必填):可选择回复," + "、".join(TRANSPORT_TYPES)) + if need_route: + if lines: + lines.append("") + lines.append("线路类别(必填):可选择回复," + "、".join(ROUTES)) + if need_class: + if lines: + lines.append("") + lines.extend(_class_prompt_lines()) + if need_type or need_route: + lines.append("") + lines.append("填写样例见图。") return "\n".join(lines).strip() diff --git a/inquiry-agent/agent/schema/tms_land_query.py b/inquiry-agent/agent/schema/tms_land_query.py index 2cfefec..4880785 100644 --- a/inquiry-agent/agent/schema/tms_land_query.py +++ b/inquiry-agent/agent/schema/tms_land_query.py @@ -4,8 +4,8 @@ 本文件职责:核对卡通过后、建单查价前,确认当前运输类型的货物字段能拼出查价正文。 带去:运输类型、线路类别、始发/目的/品名/报价日期,以及该类型的体积重量或车型/数量;通关口岸有值才带。 出站类型/线路必须译成 TMS 6.1.1 英文枚举,线路键名 routeCategory。工单与卡片仍用中文。 -中港整车不带车型/数量。车型/数量是一个字段,出站再拆。 -禁止:把运输分类、中港装载方式放进出站;直连 TMS;改六态。 +中港整车、中港零担/集拼出站类型都是 CHINA_HK。中港整车不带车型/数量。 +禁止:把运输分类放进出站;直连 TMS;改六态。 线程:纯函数。 """ @@ -16,20 +16,21 @@ 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({"跨境集拼", "跨境整车", "中港"}) +FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港零担/集拼"}) +HK_FTL_TYPE = "中港整车" +CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港整车", "中港零担/集拼"}) _TYPE_ENUM = { "国内运输拼车": "DOMESTIC_LTL", "国内运输整车": "DOMESTIC_FTL", "跨境集拼": "CROSS_BORDER_LTL", "跨境整车": "CROSS_BORDER_FTL", - "中港": "CHINA_HK", + "中港整车": "CHINA_HK", + "中港零担/集拼": "CHINA_HK", } _ROUTE_ENUM = { "国内长途/零担": "DOMESTIC_LONG_LTL", @@ -91,13 +92,12 @@ def assemble_land_query(facts: dict[str, Any] | None) -> dict[str, Any]: """ 把已采集陆运字段拼成 TMS 查价参数。 - 不齐则 ok=False,missing 给销售补问。齐了也不带分类/中港装载。 + 不齐则 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() @@ -106,7 +106,6 @@ def assemble_land_query(facts: dict[str, Any] | None) -> dict[str, Any]: 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: @@ -141,10 +140,9 @@ def assemble_land_query(facts: dict[str, Any] | None) -> dict[str, Any]: missing.append("毛重") else: outbound["weightKg"] = wgt - elif land_type == "中港" and hk_load != "零担/集拼": - # 中港整车(或尚未选定装载)不采集、不带出车型/数量 + elif land_type == HK_FTL_TYPE: pass - elif land_type in FTL_TYPES or land_type == "中港": + elif land_type in FTL_TYPES: combo = ( src.get("车型/数量") or raw.get("车型/数量") diff --git a/inquiry-agent/tests/test_air_group_collab.py b/inquiry-agent/tests/test_air_group_collab.py index 83a6ec7..ded89e8 100644 --- a/inquiry-agent/tests/test_air_group_collab.py +++ b/inquiry-agent/tests/test_air_group_collab.py @@ -226,6 +226,37 @@ class AirGroupCollabTests(unittest.TestCase): self.assertEqual(phase, "activated") self.assertEqual(self.ledger.find_by_collab_chat(chat_id="wr_air_1").work_order_no, wo2) + def test_activate_prefills_magnet(self) -> None: + created = self.ledger.create_ticket( + sender_id="sales1", + business_line="AIR", + facts={ + "起运地": "上海", + "目的地": "洛杉矶", + "品名": "配件", + "件数": "3", + "毛重": "100", + "体积": "2", + "包装类型": "纸箱", + "报价日期": "2026-09-16", + "是否含磁": "是", + }, + ) + wo = created["work_order_no"] + self.ledger.upsert_quote( + work_order_no=wo, + quote={"total": "USD 800", "source": "TMS", "quoteId": "QT-AIR-1"}, + to_status="已报价", + ) + handle_air_group( + self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="pre1"), + flow=self.flow, + ledger=self.ledger, + ) + joined = "\n".join(self._texts()) + self.assertIn("是否含磁:是", joined) + self.assertIn("是否含电:-", joined) + def test_second_group_occupied(self) -> None: wo = self._create_air() handle_air_group( diff --git a/inquiry-agent/tests/test_land_collab_fields.py b/inquiry-agent/tests/test_land_collab_fields.py new file mode 100644 index 0000000..e0032a5 --- /dev/null +++ b/inquiry-agent/tests/test_land_collab_fields.py @@ -0,0 +1,102 @@ +""" +陆运群协同项:六套名单、展示名、预填、口语危险品。 +不连企微 / 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.llm.extract_text import extract_collab_fields +from agent.schema.field_validate import ( + collab_display_name, + collab_fields_for_ticket, + harvest_oral_collab, + land_collab_fields, + pick_collab_from_facts, +) + + +class LandCollabFieldsTest(unittest.TestCase): + def test_domestic_ltl_keys(self): + keys = land_collab_fields("国内运输拼车", "国内长途/零担") + self.assertEqual(keys, ("客户名称", "包装方式", "货值", "是否为危险品")) + + def test_domestic_ftl_longhaul_keys(self): + keys = land_collab_fields("国内运输整车", "国内长途/零担") + self.assertEqual(keys, ("客户名称", "货值", "包装方式", "是否为危险品")) + + def test_domestic_ftl_order(self): + keys = land_collab_fields("国内运输整车", "国内城配/拖车/打包") + self.assertEqual(keys, ("客户名称", "货值", "包装方式", "是否为危险品")) + + def test_cross_ftl_no_vehicle(self): + keys = land_collab_fields("跨境整车", "东南亚") + self.assertIn("HS编码", keys) + self.assertNotIn("车型/数量", keys) + + def test_hk_ftl_has_vehicle(self): + keys = land_collab_fields("中港整车", "中港/中亚/中欧") + self.assertIn("车型/数量", keys) + self.assertEqual(keys[0], "客户名称") + + def test_unlisted_pair_empty(self): + self.assertEqual(land_collab_fields("国内运输拼车", "东南亚"), ()) + + def test_land_hs_display(self): + self.assertEqual(collab_display_name("HS编码", "LAND"), "海关编码") + self.assertEqual(collab_display_name("HS编码", "SEA"), "商品海关编码") + + def test_ticket_sea_air(self): + self.assertIn("是否含电", collab_fields_for_ticket({}, "SEA")) + self.assertEqual( + collab_fields_for_ticket({}, "AIR"), + ("是否含电", "是否含磁"), + ) + + def test_pick_skips_inquiry_vehicle(self): + got = pick_collab_from_facts( + {"包装方式": "纸箱", "车型/数量": "9米/1", "海关编码": "8479"}, + land_collab_fields("跨境整车", "东南亚"), + ) + self.assertEqual(got.get("包装方式"), "纸箱") + self.assertEqual(got.get("HS编码"), "8479") + self.assertNotIn("车型/数量", got) + + def test_dg_oral(self): + self.assertEqual(harvest_oral_collab("普货")["是否为危险品"], "否") + self.assertEqual(harvest_oral_collab("危险品")["是否为危险品"], "是") + self.assertEqual(harvest_oral_collab("不是危险品")["是否为危险品"], "否") + + def test_labeled_without_colon(self): + allowed = land_collab_fields("国内运输拼车", "国内长途/零担") + got = extract_collab_fields( + "客户名称张三 包装方式编织袋 货值 10万", + allowed_keys=allowed, + ) + self.assertEqual(got.get("客户名称"), "张三") + self.assertEqual(got.get("包装方式"), "编织袋") + self.assertEqual(got.get("货值"), "10万") + + def test_bare_pack_ignored(self): + allowed = land_collab_fields("国内运输拼车", "国内长途/零担") + self.assertEqual(extract_collab_fields("编织袋", allowed_keys=allowed), {}) + + def test_trade_terms_filtered_for_ltl(self): + allowed = land_collab_fields("国内运输拼车", "国内长途/零担") + got = extract_collab_fields("客户名称张三,贸易条款 FOB", allowed_keys=allowed) + self.assertEqual(got, {"客户名称": "张三"}) + + def test_sea_labeled_still_works(self): + got = extract_collab_fields("贸易条款:FOB") + self.assertEqual(got.get("贸易条款"), "FOB") + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-agent/tests/test_land_copy.py b/inquiry-agent/tests/test_land_copy.py index fccf9aa..ec313fd 100644 --- a/inquiry-agent/tests/test_land_copy.py +++ b/inquiry-agent/tests/test_land_copy.py @@ -53,13 +53,12 @@ class LandCopyTest(unittest.TestCase): ) self.assertIn("通关口岸(非必填):", text) - def test_hk_shows_load(self): + def test_hk_lcl_shows_vehicle_not_load_field(self): text = copy.land_confirm_card( { - "运输类型": "中港", + "运输类型": "中港零担/集拼", "线路类别": "东南亚", "运输分类": "急件", - "中港装载方式": "零担/集拼", "起运港": "广州", "目的港": "香港", "品名": "衣服", @@ -67,25 +66,26 @@ class LandCopyTest(unittest.TestCase): "报价日期": "2026-09-17", } ) - self.assertIn("零担/集拼", text) + self.assertIn("运输类型(必填):中港零担/集拼", text) self.assertIn("车型/数量(必填):9米6/1", text) - self.assertNotIn("\n数量(必填)", text) + self.assertNotIn("中港装载方式", text) + self.assertIn("通关口岸(非必填)", text) def test_hk_full_truck_hides_vehicle(self): text = copy.land_confirm_card( { - "运输类型": "中港", + "运输类型": "中港整车", "线路类别": "东南亚", "运输分类": "急件", - "中港装载方式": "整车", "起运港": "广州", "目的港": "香港", "品名": "衣服", "报价日期": "2026-09-17", } ) - self.assertIn("整车", text) + self.assertIn("运输类型(必填):中港整车", text) self.assertNotIn("车型/数量", text) + self.assertNotIn("中港装载方式", text) def test_domestic_ftl_one_field(self): text = copy.land_confirm_card( @@ -134,7 +134,7 @@ class LandCopyTest(unittest.TestCase): facts = { "运输类型": "国内运输拼车", "线路类别": "国内城配/拖车/打包", - "运输分类": "普件,常用线路/常规车型", + "运输分类": "普件(东南亚、中港常用线路/常规车型)", "起运港": "泉州市", "目的港": "南京市", "品名": "通信机柜及线缆配件", @@ -201,6 +201,66 @@ class LandCopyTest(unittest.TestCase): self.assertIn("9米6/1", rows["货物"]) self.assertNotIn("CBM", rows["货物"]) + def test_land_brief_only_ltl_collab(self): + text = copy.sea_group_brief( + work_order_no="WO1", + facts={ + "运输类型": "国内运输拼车", + "线路类别": "国内长途/零担", + "运输分类": "急件", + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "体积": "1", + "毛重": "1", + "报价日期": "2026-09-18", + }, + quote=None, + collab_facts={"包装方式": "纸箱"}, + sales_name="销", + product_names=["产"], + has_price=False, + transport_mode="陆运", + ) + self.assertIn("包装方式:纸箱", text) + self.assertIn("客户名称:-", text) + self.assertIn("货值:-", text) + self.assertIn("是否为危险品:-", text) + self.assertNotIn("贸易条款", text) + self.assertNotIn("商品海关编码", text) + self.assertNotIn("海关编码", text) + + def test_land_brief_ftl_longhaul_order(self): + text = copy.sea_group_brief( + work_order_no="WO2", + facts={ + "运输类型": "国内运输整车", + "线路类别": "国内长途/零担", + "运输分类": "急件", + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "车型/数量": "9米/1", + "报价日期": "2026-09-18", + }, + quote=None, + collab_facts={}, + sales_name="销", + product_names=["产"], + has_price=False, + transport_mode="陆运", + ) + collab = text.split("协同补充字段")[1] + self.assertLess(collab.find("客户名称"), collab.find("货值")) + self.assertLess(collab.find("货值"), collab.find("包装方式")) + self.assertNotIn("贸易条款", text) + + def test_land_unrecognized_lists_keys(self): + keys = ("客户名称", "包装方式", "货值", "是否为危险品") + text = copy.group_fields_not_recognized(keys=keys, business_line="LAND") + self.assertIn("客户名称", text) + self.assertNotIn("货好时间", text) + 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 index c94a05d..77a5c46 100644 --- a/inquiry-agent/tests/test_land_field_validate.py +++ b/inquiry-agent/tests/test_land_field_validate.py @@ -33,10 +33,9 @@ class LandFieldValidateTest(unittest.TestCase): "品名": "衣服", "体积": "10CBM", "毛重": "50kg", - "中港装载方式": "零担/集拼", }, business_line="LAND", - land_subtype="中港", + land_subtype="中港零担/集拼", ) self.assertIn("车型/数量", got["missing"]) self.assertNotIn("数量", got["missing"]) @@ -48,10 +47,9 @@ class LandFieldValidateTest(unittest.TestCase): "起运港": "广州", "目的港": "香港", "品名": "衣服", - "中港装载方式": "整车", }, business_line="LAND", - land_subtype="中港", + land_subtype="中港整车", ) self.assertTrue(got["ok"]) self.assertNotIn("车型/数量", got["missing"]) diff --git a/inquiry-agent/tests/test_land_group_collab.py b/inquiry-agent/tests/test_land_group_collab.py new file mode 100644 index 0000000..579c0e1 --- /dev/null +++ b/inquiry-agent/tests/test_land_group_collab.py @@ -0,0 +1,305 @@ +""" +陆运群内协同:摘要只列本单协同项、补字段过滤、预填。 +不连企微 / 真实 TMS / 8180。 +""" + +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.wecom.models import InboundMessage +from agent.handlers.group_collab import handle_group_collab +from agent.ledger.memory_ledger import MemoryLedger +from agent.policy import inquiry_copy as copy +from agent.policy.land_text_flow import LandTextInquiryFlow +from agent.routing.group_intent import INTENT_FIELDS, INTENT_QUOTE + + +class CountingLedger(MemoryLedger): + def __init__(self) -> None: + super().__init__() + self.last_created_no = "" + + def create_ticket(self, **kwargs): + out = super().create_ticket(**kwargs) + self.last_created_no = str(out.get("work_order_no") or "") + return out + + +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_collab", "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 LandGroupCollabTests(unittest.TestCase): + def setUp(self) -> None: + self.ledger = CountingLedger() + self.ledger.set_quote_templates( + [ + { + "templateId": "mem-default-land", + "name": "陆运默认", + "bizType": "陆运", + "keywords": [], + "isDefault": True, + } + ] + ) + self.ledger.set_land_staff( + [ + { + "roleCode": "land", + "status": "active", + "wecomId": "p_dom", + "name": "乙", + "routes": "国内长途/零担,国内城配/拖车/打包,中港/中亚/中欧,东南亚", + } + ] + ) + 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 _group_texts(self) -> str: + return "\n".join(x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text") + + def _open( + self, + *, + option_text: str, + extra_facts: dict | None = None, + cargo: dict | None = None, + ) -> tuple[str, str]: + facts = { + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + } + facts.update(cargo or {}) + facts.update(extra_facts or {}) + self.flow.on_text( + sender_id="u1", + text=option_text, + reply=self._reply, + injected_facts=facts, + injected_mode="LAND", + ) + self.flow.on_text(sender_id="u1", text="确定", reply=self._reply) + self.replies.clear() + phase = self.flow.on_button( + sender_id="u1", + action=copy.BTN_PULL_COLLAB, + reply=self._reply, + ) + self.assertEqual(phase, "collab_group") + wo = self.flow.session_of("u1").work_order_no + return wo, "wr_land_collab" + + def _msg(self, *, sender: str, text: str, mid: str) -> InboundMessage: + return InboundMessage( + sender_id=sender, + message_id=mid, + content=text, + msg_type="text", + chat_id="wr_land_collab", + chat_type="group", + ) + + def test_ltl_brief_only_four_collab_and_prefill_pack(self) -> None: + self._open( + option_text="国内运输拼车 国内长途/零担 急件 包装方式纸箱", + extra_facts={"包装方式": "纸箱"}, + ) + brief = self._group_texts() + self.assertIn("包装方式:纸箱", brief) + self.assertIn("客户名称:-", brief) + self.assertIn("货值:-", brief) + self.assertIn("是否为危险品:-", brief) + self.assertNotIn("贸易条款", brief) + self.assertNotIn("商品海关编码", brief) + self.assertNotIn("海关编码", brief) + + def test_domestic_ftl_longhaul_can_pull_and_brief_order(self) -> None: + """国内整车配长途:允许拉群;协同顺序是客户名称、货值、包装方式、是否为危险品。""" + self._open( + option_text="国内运输整车 国内长途/零担 急件", + cargo={"车型/数量": "9米/1"}, + ) + self.assertTrue(self.groups.created) + brief = self._group_texts() + collab_part = brief.split("协同补充字段")[1] + self.assertIn("客户名称:-", collab_part) + self.assertIn("货值:-", collab_part) + self.assertIn("包装方式:-", collab_part) + self.assertIn("是否为危险品:-", collab_part) + self.assertLess(collab_part.find("客户名称"), collab_part.find("货值")) + self.assertLess(collab_part.find("货值"), collab_part.find("包装方式")) + self.assertLess(collab_part.find("包装方式"), collab_part.find("是否为危险品")) + self.assertNotIn("贸易条款", brief) + self.assertNotIn("海关编码", brief) + # 车型/数量只在询价字段,不进协同。 + self.assertIn("车型/数量:9米/1", brief.split("协同补充字段")[0]) + self.assertNotIn("车型/数量", collab_part) + + def test_cross_ftl_vehicle_stays_inquiry(self) -> None: + wo, _ = self._open( + option_text="跨境整车 东南亚 急件", + cargo={ + "目的港": "河内", + "车型/数量": "9米/1", + }, + ) + brief = self._group_texts() + self.assertIn("车型/数量:9米/1", brief.split("协同补充字段")[0]) + self.assertNotIn("车型/数量", brief.split("协同补充字段")[1]) + self.groups.sent.clear() + phase = handle_group_collab( + self._msg(sender="u1", text="车型/数量 12米/1", mid="v1"), + flow=self.flow, + ledger=self.ledger, + injected_intent=INTENT_FIELDS, + ) + self.assertEqual(phase, "collab_not_recognized") + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertEqual(ticket.facts.get("车型/数量"), "9米/1") + self.assertFalse(ticket.collab_facts.get("车型/数量")) + + def test_hk_ftl_vehicle_is_collab(self) -> None: + wo, _ = self._open( + option_text="中港整车 中港/中亚/中欧 急件", + cargo={"目的港": "香港"}, + ) + brief = self._group_texts() + collab_part = brief.split("协同补充字段")[1] + self.assertIn("车型/数量:-", collab_part) + self.assertNotIn("车型/数量", brief.split("协同补充字段")[0]) + self.groups.sent.clear() + phase = handle_group_collab( + self._msg(sender="u1", text="车型/数量 9米/1", mid="hk1"), + flow=self.flow, + ledger=self.ledger, + injected_intent=INTENT_FIELDS, + ) + self.assertEqual(phase, "collab_field_saved") + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertEqual(ticket.collab_facts.get("车型/数量"), "9米/1") + self.assertFalse(ticket.facts.get("车型/数量")) + self.assertIn("车型/数量:9米/1", self._group_texts()) + + def test_ltl_rejects_trade_terms_only(self) -> None: + self._open(option_text="国内运输拼车 国内长途/零担 急件") + self.groups.sent.clear() + phase = handle_group_collab( + self._msg(sender="u1", text="贸易条款 FOB", mid="t1"), + flow=self.flow, + ledger=self.ledger, + injected_intent=INTENT_FIELDS, + ) + self.assertEqual(phase, "collab_not_recognized") + text = self._group_texts() + self.assertIn("客户名称", text) + self.assertNotIn("货好时间", text) + + def test_ltl_keeps_name_drops_trade_terms(self) -> None: + wo, _ = self._open(option_text="国内运输拼车 国内长途/零担 急件") + self.groups.sent.clear() + phase = handle_group_collab( + self._msg(sender="u1", text="客户名称张三,贸易条款 FOB", mid="t2"), + flow=self.flow, + ledger=self.ledger, + injected_intent=INTENT_FIELDS, + ) + self.assertEqual(phase, "collab_field_saved") + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertEqual(ticket.collab_facts.get("客户名称"), "张三") + self.assertFalse(ticket.collab_facts.get("贸易条款")) + self.assertIn("客户名称:张三", self._group_texts()) + + def test_dg_and_value_and_bare_pack(self) -> None: + wo, _ = self._open(option_text="国内运输拼车 国内长途/零担 急件") + handle_group_collab( + self._msg(sender="u1", text="普货", mid="dg1"), + flow=self.flow, + ledger=self.ledger, + injected_intent=INTENT_FIELDS, + ) + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertEqual(ticket.collab_facts.get("是否为危险品"), "否") + handle_group_collab( + self._msg(sender="u1", text="危险品", mid="dg2"), + flow=self.flow, + ledger=self.ledger, + injected_intent=INTENT_FIELDS, + ) + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertEqual(ticket.collab_facts.get("是否为危险品"), "是") + handle_group_collab( + self._msg(sender="u1", text="货值 10万 包装方式编织袋", mid="v1"), + flow=self.flow, + ledger=self.ledger, + injected_intent=INTENT_FIELDS, + ) + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertEqual(ticket.collab_facts.get("货值"), "10万") + self.assertEqual(ticket.collab_facts.get("包装方式"), "编织袋") + self.groups.sent.clear() + phase = handle_group_collab( + self._msg(sender="u1", text="编织袋", mid="v2"), + flow=self.flow, + ledger=self.ledger, + injected_intent=INTENT_FIELDS, + ) + self.assertEqual(phase, "collab_not_recognized") + + def test_product_can_quote_without_collab(self) -> None: + wo, _ = self._open(option_text="国内运输拼车 国内长途/零担 急件") + phase = handle_group_collab( + self._msg(sender="p_dom", text="运费 1000", mid="q1"), + flow=self.flow, + ledger=self.ledger, + injected_intent=INTENT_QUOTE, + injected_quote={"total": "1000", "source_label": "产品报价"}, + ) + self.assertIn(phase, {"product_quote_sent", "wait_tms_choice"}) + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertTrue(ticket.quote) + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-agent/tests/test_land_options.py b/inquiry-agent/tests/test_land_options.py index eb25527..0d140aa 100644 --- a/inquiry-agent/tests/test_land_options.py +++ b/inquiry-agent/tests/test_land_options.py @@ -1,5 +1,5 @@ """ -陆运选项联动单测:类型/线路/分类/中港装载,选错只纠正该项。 +陆运选项单测:三项各自必填、不联动;中港拆进运输类型;样例表跟类型/线路缺项走。 不连企微、主账、TMS。 """ @@ -18,12 +18,14 @@ from agent.schema.land_options import ( harvest_land_options, looks_like_confirm, option_prompt, + option_prompt_needs_sample, + sample_pair_allowed, validate_land_options, ) class LandOptionsTest(unittest.TestCase): - def test_domestic_ltl_rejects_sea_route(self): + def test_any_type_route_combo_ok(self): got = validate_land_options( { "运输类型": "国内运输拼车", @@ -31,28 +33,70 @@ class LandOptionsTest(unittest.TestCase): "运输分类": "急件", } ) - self.assertFalse(got["ok"]) - msg = got["errors"][0]["message"] - self.assertIn("国内长途/零担", msg) - self.assertIn("国内城配/拖车/打包", msg) + self.assertTrue(got["ok"]) + self.assertEqual(got["normalized"]["运输类型"], "国内运输拼车") + self.assertEqual(got["normalized"]["线路类别"], "东南亚") + + def test_bare_hk_does_not_fill_type_or_route(self): + cur = harvest_land_options("中港 急件") + self.assertFalse(cur.get("运输类型")) + self.assertFalse(cur.get("线路类别")) + self.assertEqual(cur.get("运输分类"), "急件") + + def test_hk_full_and_lcl_types(self): + self.assertEqual( + harvest_land_options("中港整车")["运输类型"], "中港整车" + ) + self.assertEqual( + harvest_land_options("中港零担/集拼 东南亚")["运输类型"], + "中港零担/集拼", + ) + self.assertEqual( + harvest_land_options("中港零担")["运输类型"], "中港零担/集拼" + ) + + def test_new_class_names(self): + cur = harvest_land_options( + "普件(东南亚、中港常用线路/常规车型)" + ) + self.assertEqual( + cur["运输分类"], "普件(东南亚、中港常用线路/常规车型)" + ) + self.assertFalse( + harvest_land_options("普件,常用线路/常规车型").get("运输分类") + ) + + def test_no_hk_load_field(self): + got = validate_land_options( + { + "运输类型": "中港整车", + "线路类别": "东南亚", + "运输分类": "急件", + } + ) + self.assertTrue(got["ok"]) + self.assertNotIn("中港装载方式", got["missing"]) + self.assertFalse(got["normalized"].get("中港装载方式")) def test_followup_route_class_keeps_type(self): cur = harvest_land_options( - "国内城配/拖车/打包;普件,常用线路/常规车型", + "国内城配/拖车/打包;普件(东南亚、中港常用线路/常规车型)", current={"运输类型": "国内运输拼车"}, ) self.assertEqual(cur["运输类型"], "国内运输拼车") 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) @@ -64,36 +108,66 @@ class LandOptionsTest(unittest.TestCase): 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) + self.assertIn("请一次选择:运输类型、线路类别、运输分类", text) + self.assertIn("运输类型(必填):可选择回复,", text) + self.assertIn("中港整车", text) + self.assertIn("中港零担/集拼", text) + self.assertIn("线路类别(必填):可选择回复,", text) + self.assertIn("国内长途/零担", text) + self.assertIn("1.普件(东南亚、中港常用线路/常规车型)", text) + self.assertIn("填写样例见图", text) + self.assertNotIn("国内运输整车 + 国内城配/拖车/打包", text) + self.assertNotIn("┌", text) + self.assertNotIn("| 国内运输整车 |", text) + self.assertNotIn("仅供参考", text) + self.assertNotIn("中港装载方式", text) + self.assertNotIn("跟运输类型联动", text) - def test_copy_alias_lists_all_three(self): + def test_copy_alias_lists_new_types(self): from agent.policy import inquiry_copy as copy text = copy.ASK_LAND_SUBTYPE - self.assertIn("运输类型", text) - self.assertIn("线路类别", text) - self.assertIn("运输分类", text) + self.assertIn("中港整车", text) + self.assertIn("填写样例", text) self.assertNotIn("请补充陆运类型:国内运输拼车", text) - def test_narrow_prompt_when_type_known(self): + def test_sample_table_when_route_missing(self): text = option_prompt(current={"运输类型": "国内运输拼车"}) - self.assertIn("国内长途/零担", text) - self.assertNotIn("请选择运输类型", text) + self.assertIn("线路类别(必填)", text) + self.assertIn("填写样例见图", text) + self.assertTrue(option_prompt_needs_sample(current={"运输类型": "国内运输拼车"})) + self.assertNotIn("国内运输拼车 + 国内长途/零担", text) + self.assertNotIn("请一次选择:运输类型", text) + self.assertNotIn("运输类型(必填)", text) - def test_hk_needs_load(self): - got = validate_land_options( - { - "运输类型": "中港", + def test_sample_pairs_go_to_image_not_text(self): + from agent.policy import inquiry_copy as copy + + text = option_prompt(current={}) + self.assertIn("填写样例见图", text) + self.assertNotIn("国内运输拼车 + 国内长途/零担", text) + extra = copy.land_sample_image_payload() + self.assertEqual(extra.get("msgtype"), "image") + self.assertTrue(os.path.isfile(str(extra.get("filepath") or ""))) + self.assertGreater(os.path.getsize(str(extra["filepath"])), 1000) + + def test_no_sample_when_only_class_missing(self): + text = option_prompt( + current={ + "运输类型": "国内运输拼车", "线路类别": "东南亚", - "运输分类": "急件", } ) - self.assertIn("中港装载方式", got["missing"]) + self.assertIn("运输分类(必填)", text) + self.assertNotIn("填写样例", text) + + def test_sample_pair_gate(self): + self.assertTrue(sample_pair_allowed("国内运输拼车", "国内长途/零担")) + self.assertTrue(sample_pair_allowed("国内运输整车", "国内城配/拖车/打包")) + self.assertTrue(sample_pair_allowed("国内运输整车", "国内长途/零担")) + self.assertTrue(sample_pair_allowed("中港整车", "中港/中亚/中欧")) + self.assertFalse(sample_pair_allowed("国内运输拼车", "东南亚")) + self.assertFalse(sample_pair_allowed("中港整车", "东南亚")) def test_confirm_intent(self): self.assertTrue(looks_like_confirm("确认")) diff --git a/inquiry-agent/tests/test_land_text_inquiry.py b/inquiry-agent/tests/test_land_text_inquiry.py index 0eeac60..97c7c81 100644 --- a/inquiry-agent/tests/test_land_text_inquiry.py +++ b/inquiry-agent/tests/test_land_text_inquiry.py @@ -47,9 +47,11 @@ class LandTextInquiryFrontTests(unittest.TestCase): self.ledger = CountingLedger() self.flow = LandTextInquiryFlow(self.ledger) self.replies: list[str] = [] + self.extras: list[dict] = [] def _reply(self, text: str, extra=None) -> str: self.replies.append(text) + self.extras.append(dict(extra or {})) return "" def _joined(self) -> str: @@ -69,7 +71,12 @@ class LandTextInquiryFrontTests(unittest.TestCase): self.assertIn("国内运输拼车", text) self.assertIn("线路类别", text) self.assertIn("运输分类", text) + self.assertIn("中港整车", text) + self.assertIn("填写样例见图", text) self.assertNotIn("请补充陆运类型:", text) + img = [x for x in self.extras if x.get("msgtype") == "image"] + self.assertEqual(len(img), 1) + self.assertTrue(os.path.isfile(str(img[0].get("filepath") or ""))) def test_known_type_does_not_reask_type(self) -> None: phase = self.flow.on_text( @@ -82,8 +89,10 @@ class LandTextInquiryFrontTests(unittest.TestCase): self.assertEqual(phase, "need_land_options") self.assertNotIn("请选择运输类型", self._joined()) self.assertIn("国内长途/零担", self._joined()) + self.assertIn("填写样例见图", self._joined()) + self.assertTrue(any(x.get("msgtype") == "image" for x in self.extras)) - def test_wrong_route_keeps_type(self) -> None: + def test_unlinked_route_still_keeps_type(self) -> None: phase = self.flow.on_text( sender_id="u1", text="国内运输拼车,线路东南亚", @@ -92,14 +101,16 @@ class LandTextInquiryFrontTests(unittest.TestCase): injected_mode="LAND", ) self.assertEqual(phase, "need_land_options") - self.assertIn("国内长途/零担", self._joined()) + self.assertIn("运输分类", self._joined()) + self.assertNotIn("填写样例", self._joined()) sess = self.flow.session_of("u1") self.assertEqual(sess.facts.get("运输类型"), "国内运输拼车") + self.assertEqual(sess.facts.get("线路类别"), "东南亚") def test_hk_lcl_asks_vehicle(self) -> None: phase = self.flow.on_text( sender_id="u1", - text="中港 东南亚 急件 零担", + text="中港零担/集拼 东南亚 急件", reply=self._reply, injected_facts={ "起运港": "广州", @@ -119,7 +130,7 @@ class LandTextInquiryFrontTests(unittest.TestCase): def test_hk_full_truck_skips_vehicle(self) -> None: phase = self.flow.on_text( sender_id="u1", - text="中港 东南亚 急件 整车", + text="中港整车 东南亚 急件", reply=self._reply, injected_facts={ "起运港": "广州", @@ -403,7 +414,7 @@ class LandTextConfirmTests(unittest.TestCase): self.replies.clear() phase = self.flow.on_text( sender_id="u1", - text="国内城配/拖车/打包;普件,常用线路/常规车型", + text="国内城配/拖车/打包;普件(东南亚、中港常用线路/常规车型)", reply=self._reply, injected_facts={"品名": "通信机柜及线缆配件", "运输类型": "陆运"}, injected_mode="LAND", @@ -413,7 +424,7 @@ class LandTextConfirmTests(unittest.TestCase): self.assertNotIn("请一次选择:运输类型", text) self.assertIn("国内运输拼车", text) self.assertIn("国内城配/拖车/打包", text) - self.assertIn("普件,常用线路/常规车型", text) + self.assertIn("普件(东南亚、中港常用线路/常规车型)", text) class FakeGroupClient: @@ -513,6 +524,36 @@ class LandTextPullTests(unittest.TestCase): self.assertEqual(self.groups.created, []) self.assertEqual(phase, "wait_collab") + def test_pull_rejects_unlisted_pair(self) -> None: + self.ledger.set_land_staff( + [ + {"roleCode": "land", "status": "active", "wecomId": "p_sea", "name": "甲", "routes": "东南亚"}, + ] + ) + 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() + phase = self.flow.on_button( + sender_id="u1", + action=copy.BTN_PULL_COLLAB, + reply=self._reply, + ) + self.assertEqual(self.groups.created, []) + self.assertIn("拉群失败,请排查填写内容,并重新发起工单。", "\n".join(self.replies)) + self.assertNotEqual(phase, "collab_group") + def test_land_role_is_product_in_group(self) -> None: from agent.policy.sea_group_ops import is_product diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py index 35e8253..0d790f8 100644 --- a/inquiry-agent/tests/test_sea_text_inquiry.py +++ b/inquiry-agent/tests/test_sea_text_inquiry.py @@ -452,6 +452,39 @@ class SeaSkipAndPullTests(unittest.TestCase): ) self.assertEqual(again, "card_discard") + def test_pull_prefills_spoken_no_battery(self) -> None: + self.ledger.set_sea_staff( + [ + { + "name": "张产品", + "wecomId": "prod1", + "roleCode": "sea", + "routes": "海运·全部线路", + "status": "active", + } + ] + ) + facts = dict(COMPLETE_SEA) + facts["是否含电"] = "否" + self.flow.on_text( + sender_id="s1", + text="海运不含电", + reply=self._reply, + injected_facts=facts, + injected_mode="SEA", + ) + private = "\n".join(self.replies) + self.assertNotIn("是否含电", private) + self.flow.on_button( + sender_id="s1", + action=copy.BTN_PULL_COLLAB, + reply=self._reply, + ) + group_text = "\n".join( + x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text" + ) + self.assertIn("是否含电:否", group_text) + def test_pull_sends_sheet_when_client_not_injected(self) -> None: """测服正式路径不注入 group_client,拉群仍要发出填好的模板。""" from unittest.mock import patch diff --git a/inquiry-agent/tests/test_tms_land_query.py b/inquiry-agent/tests/test_tms_land_query.py index a6f750a..4e2d75d 100644 --- a/inquiry-agent/tests/test_tms_land_query.py +++ b/inquiry-agent/tests/test_tms_land_query.py @@ -18,10 +18,9 @@ from agent.schema.tms_land_query import assemble_land_query class TmsLandQueryTest(unittest.TestCase): def test_outbound_omits_class_and_hk_load(self): facts = { - "运输类型": "中港", + "运输类型": "中港零担/集拼", "线路类别": "东南亚", "运输分类": "急件", - "中港装载方式": "零担/集拼", "起运港": "广州", "目的港": "香港", "品名": "衣服", @@ -44,9 +43,8 @@ class TmsLandQueryTest(unittest.TestCase): def test_hk_full_truck_omits_vehicle(self): facts = { - "运输类型": "中港", + "运输类型": "中港整车", "线路类别": "东南亚", - "中港装载方式": "整车", "起运港": "广州", "目的港": "香港", "品名": "衣服", 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 index 6008fca..b8997fd 100644 --- 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 @@ -19,6 +19,8 @@ public final class TmsLandEnumMapper { TYPE.put("国内运输整车", "DOMESTIC_FTL"); TYPE.put("跨境集拼", "CROSS_BORDER_LTL"); TYPE.put("跨境整车", "CROSS_BORDER_FTL"); + TYPE.put("中港整车", "CHINA_HK"); + TYPE.put("中港零担/集拼", "CHINA_HK"); TYPE.put("中港", "CHINA_HK"); TYPE.put("DOMESTIC_LTL", "DOMESTIC_LTL"); TYPE.put("DOMESTIC_FTL", "DOMESTIC_FTL"); 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 index 442babd..9f79b7c 100644 --- 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 @@ -15,9 +15,10 @@ import java.util.regex.Pattern; /** * 陆运 TMS 查价段:按运输类型拼出站字段。 *
- * 职责:把工单 facts 里的中文键写成 TMS 段;运输分类、中港装载方式只留在工单,不进出站。
- * 拼车/跨境集拼带体积重量;国内整车/跨境整车带「车型/数量」一项再拆成出站车型与台数;
- * 中港整车不带车型/数量,中港零担/集拼才带。通关口岸有值才放。
+ * 职责:把工单 facts 里的中文键写成 TMS 段;运输分类只留在工单,不进出站。
+ * 拼车/跨境集拼带体积重量;国内整车/跨境整车/中港零担带「车型/数量」一项再拆成出站车型与台数;
+ * 中港整车不带车型/数量。通关口岸有值才放。
+ * 中港整车、中港零担/集拼出站类型都是 CHINA_HK。
* 类型/线路出站必须是 TMS 6.1.1 英文枚举,线路键名 routeCategory。
* 禁止:按空运要包装件数;把分类当查价条件;直连 TMS。
*/
@@ -26,9 +27,9 @@ public final class TmsLandSegmentMapper {
/** 体积+重量那一套。 */
private static final Set