diff --git a/inquiry-agent/agent/handlers/air_group_collab.py b/inquiry-agent/agent/handlers/air_group_collab.py index d105937..d40093e 100644 --- a/inquiry-agent/agent/handlers/air_group_collab.py +++ b/inquiry-agent/agent/handlers/air_group_collab.py @@ -13,6 +13,7 @@ from typing import Any, Optional from agent.channel.wecom.models import InboundMessage from agent.policy import inquiry_copy as copy from agent.policy.air_group_ops import ( + is_air_ticket, activate_ticket, apply_air_confirm_tms, apply_air_deal, @@ -167,7 +168,7 @@ def handle_air_group( return phase else: ticket = current_ticket(engine, chat_id) - if ticket is None: + if ticket is None or not is_air_ticket(ticket): send_group_text(group_client_of(engine), chat_id, copy.air_need_work_order()) return "need_work_order" if is_handoff_ticket(ticket): @@ -210,7 +211,7 @@ def _handle_file(engine, message: InboundMessage) -> str: return phase else: ticket = current_ticket(engine, chat_id) - if ticket is None: + if ticket is None or not is_air_ticket(ticket): send_group_text(group_client_of(engine), chat_id, copy.air_need_work_order()) return "need_work_order" if is_handoff_ticket(ticket): diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py index 16d99ee..a22420f 100644 --- a/inquiry-agent/agent/ledger/memory_ledger.py +++ b/inquiry-agent/agent/ledger/memory_ledger.py @@ -665,7 +665,10 @@ class MemoryLedger: ticket = self._tickets.get(work_order_no) if ticket is None: return {"ok": False, "error": "ticket_not_found"} - ticket.collab_chat_id = (chat_id or "").strip() + room = (chat_id or "").strip() + # 一群同时只跟一张单:陆运/海运旧绑定也要清掉,否则群里补字段会写到旧单。 + self._unbind_other_collab_rooms(room=room, keep_work_order_no=work_order_no) + ticket.collab_chat_id = room ticket.collab_member_ids = [str(x) for x in member_ids] ticket.product_ids = [str(x) for x in (product_ids or [])] ticket.product_names = [str(x).strip() for x in (product_names or []) if str(x).strip()] @@ -743,6 +746,22 @@ class MemoryLedger: ticket.collab_facts.pop(name, None) return {"ok": True, "facts": dict(ticket.collab_facts)} + def _unbind_other_collab_rooms(self, *, room: str, keep_work_order_no: str) -> None: + """ + 同一群只留 keep 这一张单。调用方必须已持 self._lock。 + + 陆运/海运旧单也要摘掉。测服 WO202609230023 补协同写到了同群陆运 WO202609230007。 + """ + room_id = (room or "").strip() + keep = (keep_work_order_no or "").strip() + if not room_id: + return + for other in self._tickets.values(): + if other.work_order_no == keep: + continue + if other.collab_chat_id == room_id: + other.collab_chat_id = "" + def find_by_collab_chat(self, *, chat_id: str) -> MemoryTicket | None: """用工微群 chat_id 找回工单。""" raw = (chat_id or "").strip() @@ -772,10 +791,7 @@ class MemoryLedger: return {"ok": False, "error": "terminal", "status": ticket.status} if ticket.collab_chat_id and ticket.collab_chat_id != room: return {"ok": False, "error": "occupied", "other_chat_id": ticket.collab_chat_id} - for other in self._tickets.values(): - if other.work_order_no != no and other.collab_chat_id == room: - if (other.business_line or "").upper() == "AIR": - other.collab_chat_id = "" + self._unbind_other_collab_rooms(room=room, keep_work_order_no=no) ticket.collab_chat_id = room return { "ok": True, diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py index 3601350..b102f51 100644 --- a/inquiry-agent/agent/llm/extract_text.py +++ b/inquiry-agent/agent/llm/extract_text.py @@ -11,27 +11,43 @@ import logging import re from typing import Any, Optional +from agent.schema.field_validate import QUOTE_DATE_SYNONYMS + logger = logging.getLogger(__name__) +# 报价日期同义词。长的在前,避免短词截断。货好时间、提货地址不在这里。 +_QUOTE_DATE_LABEL_RE = "|".join( + re.escape(name) + for name in sorted(("报价日期",) + QUOTE_DATE_SYNONYMS, key=len, reverse=True) +) + _MODE_WORDS = ( ("空运", "AIR"), ("海运", "SEA"), ("陆运", "LAND"), ) +# 未定运输方式时:整句只回 1/2/3 对应陆运/海运/空运。10件、1.件数 不算。 +_MODE_INDEX = re.compile(r"^\s*([1231-3])\s*[\.、.]?\s*$") +_MODE_BY_INDEX = { + "1": "LAND", + "2": "SEA", + "3": "AIR", + "1": "LAND", + "2": "SEA", + "3": "AIR", +} -# 仅「键:值」一行一条,键必须是合同字段或展示名 -_LABEL_LINE = re.compile( - r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|" - r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|" +# 仅「键:值」一行一条,键必须是合同字段或展示名。报价日期含询价/走货/发货等同义词。 +_LABEL_KEYS = ( + r"起运地|起运港|目的地|目的港|品名|件数|毛重|体积(CBM)|体积\(CBM\)|体积|重量(KG)|重量\(KG\)|重量|" + r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|" + + _QUOTE_DATE_LABEL_RE + + r"|运输分类|车型/数量|车型数量|" r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|" - r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(.+)$" -) -_LABEL_INLINE = re.compile( - r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|" - r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|" - r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|" - r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(\S+)" + r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸" ) +_LABEL_LINE = re.compile(rf"^({_LABEL_KEYS})\s*[::]\s*(.+)$") +_LABEL_INLINE = re.compile(rf"({_LABEL_KEYS})\s*[::]\s*(\S+)") # 陆运核对卡非必填:销售可能写「通关口岸皇岗」或照抄「通关口岸(非必填):皇岗」。长词优先。 _OPTIONAL_INQUIRY_LABELS = ( "通关口岸(非必填)", @@ -52,6 +68,48 @@ _OPTIONAL_INQUIRY_LABELS = ( ) # 图上常见「4. 希望送到…」:编号项不是询价字段,值收到这里为止。 _NUMBERED_ITEM = re.compile(r"(?m)^\s*\d+[\.、.]\s+\S") +# 补问复制清单:1.件数(必填):10 ;下一行 (如:10)不算值。 +_CLARIFY_EXAMPLE_LINE = re.compile(r"^\s*[((]如\s*[::]") +_CLARIFY_INDEX = re.compile(r"^\s*\d+[\.、.]\s*") +_CLARIFY_DECOR = re.compile(r"((?:KG|CBM|kg|cbm|必填|非必填))|\((?:KG|CBM|必填|非必填)\)", re.I) +_CLARIFY_NAMES = frozenset( + { + "起运地", + "起运港", + "目的地", + "目的港", + "品名", + "货物品名", + "件数", + "毛重", + "体积(CBM)", + "体积", + "重量(KG)", + "重量", + "包装方式", + "包装类型", + "货量", + "货物数量", + "整柜或拼柜", + "箱型箱量", + "报价日期", + *QUOTE_DATE_SYNONYMS, + "运输分类", + "运输方式", + "运输类型", + "线路类别", + "车型/数量", + "车型数量", + "贸易条款", + "货源地", + "货好时间", + "提货地址", + "收货地址", + "送货地址", + "通关口岸", + "始发站", + } +) # 报价单表头和格子在同一行:只洗这些询价键,不碰提货/收货地址。 _SHEET_CLEAN_KEYS = frozenset( { @@ -62,7 +120,10 @@ _SHEET_CLEAN_KEYS = frozenset( "品名", "件数", "毛重", + "体积(CBM)", "体积", + "重量(KG)", + "重量", "货量", "货物数量", "整柜或拼柜", @@ -70,6 +131,7 @@ _SHEET_CLEAN_KEYS = frozenset( "贸易条款", "运输分类", "报价日期", + *QUOTE_DATE_SYNONYMS, "包装方式", "包装类型", } @@ -121,7 +183,9 @@ def detect_transport_mode(text: str) -> str: """ 运输方式:空运/海运/陆运,或空运口语「飞」(南京飞吉隆坡)。 + 未定方式时整句回 1/2/3 分别是陆运/海运/空运;「1.陆运」仍按文字认。 写了海运/陆运以书面为准。只说飞就是空运,补问件数时不得再问运输方式。 + 「10件」「1.件数」不是运输方式。 """ raw = text or "" found = [code for word, code in _MODE_WORDS if word in raw] @@ -129,30 +193,79 @@ def detect_transport_mode(text: str) -> str: return found[0] if len(found) > 1: return "" + hit = _MODE_INDEX.match(raw.strip()) + if hit: + return _MODE_BY_INDEX.get(hit.group(1), "") if "飞" in raw: return "AIR" return "" +def parse_clarify_paste_facts(text: str) -> dict[str, str]: + """ + 认补问复制清单:编号 + 字段名 +(单位/必填)+ 冒号后的值。 + + 「(如:10)」整行丢掉。冒号后为空也不收,避免把示例当成已填。 + 不猜港口,不改六态。 + """ + facts: dict[str, str] = {} + for line in (text or "").splitlines(): + raw = (line or "").strip() + if not raw or _CLARIFY_EXAMPLE_LINE.match(raw): + continue + raw = _CLARIFY_INDEX.sub("", raw, count=1) + if ":" not in raw and ":" not in raw: + continue + if ":" in raw: + name, val = raw.split(":", 1) + else: + name, val = raw.split(":", 1) + name = _CLARIFY_DECOR.sub("", name).strip() + val = (val or "").strip() + if not name or name not in _CLARIFY_NAMES: + continue + if not val or _CLARIFY_EXAMPLE_LINE.match(val): + continue + facts[name] = clip_inquiry_value(val) + return {key: val for key, val in facts.items() if val} + + def parse_labeled_facts(text: str) -> dict[str, str]: """ 只采集「字段名:值」,整行优先;同一句里的标签也收。不猜港口。 + 补问复制清单(1.件数(必填):10)优先。 报价单常把表头和格子写在一行(起运港/POL(必填) 纽约港), 冒号对不上时再按表头切一刀,值只留格子。 """ - facts: dict[str, str] = {} - raw = text or "" + facts: dict[str, str] = parse_clarify_paste_facts(text) + raw = "\n".join( + line + for line in (text or "").splitlines() + if not _CLARIFY_EXAMPLE_LINE.match(line.strip()) + ) for line in raw.splitlines(): - m = _LABEL_LINE.match(line.strip()) - if m: + shown = line.strip() + m = _LABEL_LINE.match(shown) + if m and not str(facts.get(m.group(1)) or "").strip(): facts[m.group(1)] = clip_sheet_field_value(m.group(2).strip()) if not facts: for m in _LABEL_INLINE.finditer(raw): facts[m.group(1)] = clip_sheet_field_value(m.group(2).strip()) + from agent.schema.field_validate import normalize_facts + + taken = set(normalize_facts(facts)) for key, val in harvest_sheet_inquiry_cells(raw).items(): - if val and not str(facts.get(key) or "").strip(): - facts[key] = val + if not val: + continue + mapped = next(iter(normalize_facts({key: val})), "") + if mapped and mapped in taken: + continue + if str(facts.get(key) or "").strip(): + continue + facts[key] = val + if mapped: + taken.add(mapped) return {key: val for key, val in facts.items() if val} @@ -287,6 +400,9 @@ def clip_inquiry_value(raw: str) -> str: text = (raw or "").strip() if not text: return "" + text = re.sub(r"[((]如\s*[::][^))]*[))]?\s*$", "", text).strip() + if _CLARIFY_EXAMPLE_LINE.match(text): + return "" hit = _NUMBERED_ITEM.search(text) if hit and hit.start() > 0: text = text[: hit.start()] @@ -384,7 +500,7 @@ def _label_is_token(raw: str, pos: int, label: str) -> bool: return False end = pos + len(label) nxt = raw[end : end + 6] - if label in {"重量", "体积"} and nxt.startswith("("): + if label in {"重量", "体积"} and (nxt.startswith("(") or nxt.startswith("(")): return False return True diff --git a/inquiry-agent/agent/llm/mode_extract_fields.py b/inquiry-agent/agent/llm/mode_extract_fields.py index aba5fa5..912893c 100644 --- a/inquiry-agent/agent/llm/mode_extract_fields.py +++ b/inquiry-agent/agent/llm/mode_extract_fields.py @@ -128,6 +128,8 @@ def build_extract_messages(text: str) -> list[dict[str, str]]: "10件/3pcs→空运件数;海运同样数字+件也要写入货量;100KGS/180kg/1.5KG/100公斤→毛重;2CBM/4.2cbm/2.5立方/2方→体积;" "托盘/散货/卡板/纸箱/木箱→包装方式(销售说什么记什么,散货也要抽)。" "9月15日/9月15号/2026-09-15→报价日期,规范成 yyyy-MM-dd,未写年用今年。" + "询价日期、走货日期、询价时间、走货时间、发货时间、发货日期、出货日期、出货时间、提货时间、提货日期一律写入报价日期。" + "货好时间、提货地址不要写成报价日期。" "陆运「通关口岸:皇岗」「通关口岸皇岗」「过皇岗」有说就写入通关口岸,没说留空,禁止编造口岸。" "「从南京飞吉隆坡」起运港=南京、目的港=吉隆坡;「北京飞吉隆坡」起运港=北京。" "原话是机场三字码或航线就原样记,禁止翻译成城市名:" diff --git a/inquiry-agent/agent/llm/mode_sheet_map.py b/inquiry-agent/agent/llm/mode_sheet_map.py index 51b650b..d21c25e 100644 --- a/inquiry-agent/agent/llm/mode_sheet_map.py +++ b/inquiry-agent/agent/llm/mode_sheet_map.py @@ -25,7 +25,24 @@ _PROMPT_CACHE: str | None = None # 清单原文片段 → 白名单 code(按运输方式过滤;关键词避免过短英文误报) _LABEL_TO_CODE_COMMON: list[tuple[tuple[str, ...], str]] = [ (("报价单编号", "询价单号", "RFQ No", "Quote No"), "quote_no"), - (("报价日期", "询价日期", "Inquiry Date", "Quote Date"), "quote_date"), + ( + ( + "报价日期", + "询价日期", + "走货日期", + "询价时间", + "走货时间", + "发货时间", + "发货日期", + "出货日期", + "出货时间", + "提货时间", + "提货日期", + "Inquiry Date", + "Quote Date", + ), + "quote_date", + ), (("有效期至", "有效期", "Validity"), "valid_until"), (("客户名称", "Customer Name", "Customer"), "customer_name"), (("联系人", "Contact Person", "Contact Name"), "contact_name"), @@ -351,7 +368,20 @@ def merge_mapping_prefer_existing( # 与主账 FILL_BY_LABEL 对齐的常用码→标签(智能合并校验用) _FIELD_LABELS: dict[str, tuple[str, ...]] = { "quote_no": ("RFQ No", "报价单编号", "报价编号"), - "quote_date": ("询价日期", "报价日期", "Inquiry Date"), + "quote_date": ( + "报价日期", + "询价日期", + "走货日期", + "询价时间", + "走货时间", + "发货时间", + "发货日期", + "出货日期", + "出货时间", + "提货时间", + "提货日期", + "Inquiry Date", + ), "pol": ("起运港", "POL"), "pod": ("目的港", "POD"), "commodity": ("品名", "货物品名", "Commodity"), diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index c205f42..b87a5a4 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -94,6 +94,13 @@ def operator_name(flow: Any, sender_id: str) -> str: return (sender_id or "").strip() or "航线" +def is_air_ticket(ticket: object) -> bool: + """按群找回的工单必须是空运。陆运旧单不能当空运当前单补含电含磁。""" + view = ticket_view(ticket) + line = str(view.get("business_line") or view.get("businessLine") or "").strip().upper() + return not line or line == "AIR" + + def can_fill_fields(flow: Any, ticket: object, sender_id: str) -> bool: return is_sales(ticket, sender_id) or is_airline(flow, sender_id) @@ -147,6 +154,8 @@ def activate_ticket( ticket = hydrate_ticket(flow, _ticket_from_activate(book, no, out)) if is_handoff_ticket(ticket): return None, "handoff_ignore" + # 先落默认协同,再决定要不要发摘要。发文件切单不说话,工单上仍要有含电/含磁。 + _seed_air_activate_collab(flow, ticket) if speak: send_air_brief_and_sheet(flow=flow, ticket=ticket, chat_id=chat_id) _watch_archive_room(chat_id, no) @@ -156,6 +165,54 @@ def activate_ticket( return ticket, "activated" +def _collab_slot_empty(val: object) -> bool: + """协同项没写,或只写了横杠。横杠是摘要占位,不能当成已经填过。""" + return str(val or "").strip() in {"", "-", "—", "-"} + + +def _seed_air_activate_collab(flow: Any, ticket: object) -> dict[str, str]: + """ + 空运群激活时预填协同,并记到主账。 + + 私聊已经写过的含电/含磁先带上。 + 货物品名正好是「普货」、该项还空着,默认否。 + 群里已经记下的值不覆盖。只补空项。 + """ + from agent.schema.field_validate import ( + AIR_COLLAB_FIELDS, + air_general_cargo_collab_defaults, + pick_collab_from_facts, + ) + + view = ticket_view(ticket) + no = ticket_no(ticket) + facts = dict(view.get("facts") or {}) + # 主账有时品名在货物名称列,事实里没带「品名」。群摘要仍能看到普货。 + if not str(facts.get("品名") or facts.get("货物品名") or "").strip(): + cargo = str(view.get("goodsName") or view.get("goods_name") or "").strip() + if cargo: + facts["品名"] = cargo + seeded = pick_collab_from_facts(facts, AIR_COLLAB_FIELDS) + for key, val in air_general_cargo_collab_defaults(facts).items(): + if _collab_slot_empty(seeded.get(key)): + seeded[key] = val + collab = dict(view.get("collab_facts") or {}) + to_write: dict[str, str] = {} + for key, val in seeded.items(): + if val and _collab_slot_empty(collab.get(key)): + collab[key] = val + to_write[key] = val + if to_write: + patcher = getattr(getattr(flow, "ledger", None), "patch_collab_facts", None) + if callable(patcher): + patcher( + work_order_no=no, + facts=to_write, + sender_id=str(view.get("sales_wecom_id") or view.get("salesWecomId") or ""), + ) + return collab + + def send_air_brief_and_sheet( *, flow: Any, @@ -177,25 +234,7 @@ 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 ""), - ) + collab = _seed_air_activate_collab(flow, view) view["collab_facts"] = collab view_q = dict(view.get("quote") or {}) override = dict(quote_override or {}) @@ -358,6 +397,9 @@ def apply_air_fields(*, flow: Any, ticket: object, text: str, sender_id: str, ch ticket = hydrate_ticket(flow, ticket) no = ticket_no(ticket) client = group_client_of(flow) + if not is_air_ticket(ticket): + send_group_text(client, chat_id, copy.air_need_work_order()) + return "need_work_order" stopped = halt_closed_group(flow=flow, ticket=ticket, chat_id=chat_id) if stopped: return stopped @@ -369,7 +411,10 @@ def apply_air_fields(*, flow: Any, ticket: object, text: str, sender_id: str, ch send_group_text(client, chat_id, copy.air_fields_not_recognized()) return "fields_empty" flow.ledger.patch_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id) - ticket2 = current_ticket(flow, chat_id) or ticket + # 必须回读本单。按群再查会拿到同群更早的陆运/海运旧单(测服 WO202609230007)。 + getter = getattr(flow.ledger, "get_ticket", None) + reloaded = getter(work_order_no=no) if callable(getter) else None + ticket2 = hydrate_ticket(flow, reloaded or ticket) view = ticket_view(ticket2) collab = dict(view.get("collab_facts") or {}) collab.update(fields) diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index 67692a3..0d9d22d 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -817,7 +817,7 @@ class AirTextInquiryFlow: chat_fn=chat_fn, inbound_raw=inbound_raw, ) - reply("请补充运输方式:空运、海运或陆运。") + reply(copy.ASK_TRANSPORT) return "need_mode" check = validate_required_fields(facts=facts, business_line="AIR") diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 6527897..263d73f 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -26,23 +26,22 @@ from agent.schema.field_validate import ( ) from agent.schema.land_options import option_prompt as _land_option_prompt from agent.schema.quote_adjust import total_price_label -from agent.schema.sea_options import sea_class_prompt # 空运补问:必填 7 项 + 非必填。报价日期默认当天,不进待补充。 _AIR_EXAMPLES = { "起运港": "南京", "目的港": "吉隆坡", "品名": "茶叶", - "件数": "10件", - "毛重": "100KGS", - "体积": "2CBM", - "包装方式": "托盘", + "件数": "10", + "毛重": "100", + "体积": "2", + "包装方式": "托盘、散货", "报价日期": "2026-09-15", "货源地": "东莞", "货好时间": "随时可提", "提货地址": "东莞仓库", "收货地址": "马尼拉", - "贸易条款": "FOB", + "贸易条款": "DDP、DAP、CIF、CIP", } _AIR_CLARIFY = tuple( (key, label, _AIR_EXAMPLES[key]) for key, label in AIR_REQUIRED_FIELDS @@ -114,7 +113,15 @@ _CONTINUE_HINT = ( ) -ASK_TRANSPORT = "请补充运输方式:海运 / 空运 / 陆运。" +ASK_TRANSPORT = ( + "请回复数字或文字,告知我询价的运输方式:\n" + "1.陆运\n" + "2.海运\n" + "3.空运" +) +# 待补充清单:请销售整段复制,只在冒号后填值;示例行不算已填。 +CLARIFY_PASTE_HINT = "请复制粘贴以下内容,通过回复数字或文字方式,告知我以下字段值:" +_PENDING_UNITS = {} HANDOFF_ALREADY_COLLAB = "该工单已拉产品进群协同。" HANDOFF_CLARIFY_REASON = "参数提取不全(连续 3 次反问仍不完整)" HANDOFF_INTENT_REASON = "已识别您希望转人工处理" @@ -179,10 +186,9 @@ ASK_LAND_SUBTYPE = _land_option_prompt(current={}) def land_sample_image_payload() -> dict[str, Any]: """ - 填写样例表图片出站。 + 填写样例表图片载荷。编号清单上线后不再主动出站,保留给旧调用。 - 走企微 image,不走文件气泡。路径随发布包,出站线程再读字节。 - 禁止在回调线程上传。 + 走企微 image,不走文件气泡。禁止在回调线程上传。 """ from agent.schema.land_options import sample_image_path @@ -195,7 +201,7 @@ def land_sample_image_payload() -> dict[str, Any]: def land_sample_image_fallback() -> str: - """图片失败时回文字样例,避免销售什么都看不到。""" + """旧图失败兜底。新补问不再发图,一般不会走到这里。""" from agent.schema.land_options import sample_fallback_text return sample_fallback_text() @@ -278,14 +284,40 @@ def _land_optional_keys(land_type: str) -> tuple[str, ...]: return () +def _pending_field_block( + index: int, + label: str, + example: str = "", + *, + optional: bool = False, + unit: str = "", +) -> str: + """ + 待补充一项:编号 + 字段名 + 必填/单位,示例另起一行。 + + 销售侧如: + 1.件数(必填): + (如:10) + 2.重量(KG)(必填): + (如:100) + 复制回去时只认冒号后面的值;下一行「(如:…)」不算已填。 + """ + mark = "(非必填)" if optional else "(必填)" + extra = f"({unit})" if (unit or "").strip() else "" + head = f"{index}.{label}{extra}{mark}:" + shown = (example or "").strip() + if shown: + return f"{head}\n(如:{shown})" + return head + + def _land_field_line(key: str, value: str, *, required: bool, example: str = "") -> str: - """销售侧一行:名称(必填/非必填):值或示例。""" + """已识别一行:名称(必填/非必填):值。待补充走 _pending_field_block。""" label = LAND_DISPLAY.get(key, key) mark = "必填" if required else "非必填" if value: return f"{label}({mark}):{value}" - if example: - return f"{label}({mark}):(如:{example})" + _ = example return f"{label}({mark}):" @@ -350,11 +382,22 @@ def ask_land_fields( if not pending: return "" lines.append("") - lines.append("待补充:") + lines.append(CLARIFY_PASTE_HINT) + seq = 0 for key in pending: + seq += 1 required = key not in optional example = _LAND_EXAMPLES.get(key, "") - lines.append(_land_field_line(key, "", required=required, example=example)) + label = LAND_DISPLAY.get(key, key) + lines.append( + _pending_field_block( + seq, + label, + example, + optional=not required, + unit=_PENDING_UNITS.get(key, ""), + ) + ) return "\n".join(lines) @@ -394,14 +437,13 @@ def _sea_known_value(facts: dict[str, str], key: str) -> str: def _air_field_line(key: str, value: str, *, required: bool, example: str = "") -> str: - """空运销售侧一行:名称(必填/非必填):值或示例。""" + """空运核对卡一行:名称(必填/非必填):值。待补充走 _pending_field_block。""" label = AIR_DISPLAY_LABEL(key) mark = "必填" if required else "非必填" text = (value or "").strip() if text: return f"{label}({mark}):{text}" - if example: - return f"{label}({mark}):(如:{example})" + _ = example return f"{label}({mark}):" @@ -440,7 +482,7 @@ def ask_clarify( transport_mode: str = "空运", ) -> str: """ - 缺项补问:先回显已识别,再列待补充并给示例。 + 缺项补问:先回显已识别,再给可复制清单;示例另起一行,如「1.件数(必填):」下一行「(如:10)」。 报价日期写进已识别(销售说了用原话,没说用当天),不出现在待补充。 空运非必填一并列出,没填进待补充,不挡查价。 @@ -470,18 +512,28 @@ def ask_clarify( if val: lines.append(f"{label}:{val}") lines.append("") - lines.append("待补充:") + lines.append(CLARIFY_PASTE_HINT) any_missing = False + seq = 0 if "运输方式" in missing_set: any_missing = True - lines.append("运输方式:(如:空运)") + seq += 1 + lines.append(_pending_field_block(seq, "运输方式", "空运")) for key, label, example in rows: if key in missing_set: any_missing = True + seq += 1 if sea and key == "运输分类": - lines.append(sea_class_prompt()) + lines.append(_pending_field_block(seq, "运输分类", "Port to Port、其他")) else: - lines.append(f"{label}:(如:{example})") + lines.append( + _pending_field_block( + seq, + label, + example, + unit=_PENDING_UNITS.get(key, ""), + ) + ) if not sea: optional_pending = [ (key, label, example) @@ -490,8 +542,9 @@ def ask_clarify( ] for key, label, example in optional_pending: any_missing = True + seq += 1 shown = "起运港" if key == "起运港" else label - lines.append(f"{shown}(非必填):(如:{example})") + lines.append(_pending_field_block(seq, shown, example, optional=True)) if not any_missing: return "" return "\n".join(lines) @@ -507,8 +560,10 @@ def ask_missing(display_names: Iterable[str]) -> str: "毛重": "毛重", "重量": "毛重", "重量(KG)": "毛重", + "重量(KG)": "毛重", "体积": "体积", "体积(CBM)": "体积", + "体积(CBM)": "体积", "包装类型": "包装方式", "包装方式": "包装方式", "货物数量": "货量", @@ -610,8 +665,8 @@ def inquiry_field_lines( f"目的地:{dest}", f"品名:{cargo}", f"件数:{_with_unit(str(facts.get('件数') or ''), '件')}", - f"重量:{_with_unit(str(facts.get('毛重') or ''), 'KGS')}", - f"体积:{_with_unit(str(facts.get('体积') or ''), 'CBM')}", + f"重量(KG):{_with_unit(str(facts.get('毛重') or ''), 'KGS')}", + f"体积(CBM):{_with_unit(str(facts.get('体积') or ''), 'CBM')}", f"包装类型:{pack}", f"报价日期:{quote_date}", ] @@ -2601,8 +2656,17 @@ def air_group_brief( lines.append("") lines.append("协同补充字段:") collab = dict(collab_facts or {}) + from agent.schema.field_validate import air_general_cargo_collab_defaults + + # 品名是普货时,摘要里不能再把没写的含电/含磁显示成横杠。 + for key, val in air_general_cargo_collab_defaults(facts).items(): + current = (collab.get(key) or "").strip() + if current in {"", "-", "—", "-"}: + collab[key] = val for key in AIR_COLLAB_FIELDS: - val = (collab.get(key) or "").strip() or "-" + val = (collab.get(key) or "").strip() + if val in {"", "-", "—", "-"}: + val = "-" lines.append(f"{key}:{val}") tms, manual, adjust = _split_brief_quotes( quote=quote, diff --git a/inquiry-agent/agent/policy/land_text_flow.py b/inquiry-agent/agent/policy/land_text_flow.py index 39ac497..69fc14c 100644 --- a/inquiry-agent/agent/policy/land_text_flow.py +++ b/inquiry-agent/agent/policy/land_text_flow.py @@ -24,7 +24,6 @@ from agent.schema.land_options import ( harvest_land_options, looks_like_confirm, option_prompt, - option_prompt_needs_sample, sample_pair_allowed, validate_land_options, ) @@ -225,14 +224,12 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): 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, diff --git a/inquiry-agent/agent/policy/multi_segments.py b/inquiry-agent/agent/policy/multi_segments.py index 3f6ae83..33300a0 100644 --- a/inquiry-agent/agent/policy/multi_segments.py +++ b/inquiry-agent/agent/policy/multi_segments.py @@ -22,7 +22,6 @@ from agent.llm.extract_text import parse_labeled_facts from agent.schema.land_options import ( harvest_land_options, option_prompt, - option_prompt_needs_sample, scrub_land_placeholder_ports, validate_land_options, ) @@ -155,7 +154,7 @@ def refresh_segments(segments: list[Segment]) -> tuple[bool, list[Segment], str] 按各段运输方式校验必填,并把默认报价日期写回。 返回:是否已齐、更新后的段、缺口文案。缺口一条消息说完,不套群摘要。 - 待补充的举例与单聊相同,例如「品名:(如:茶叶)」。 + 待补充的举例与单聊相同:字段名一行,下一行「(如:茶叶)」。 """ updated: list[Segment] = [] blocks: list[str] = [] @@ -369,7 +368,10 @@ def _segment_clarify(seg: Segment, check: dict[str, Any]) -> str: return "" missing = [str(x) for x in (check.get("missing") or []) if str(x).strip()] if check.get("need_land_subtype"): - return "待补充:\n运输类型(必填):(如:国内运输拼车)" + return ( + copy.CLARIFY_PASTE_HINT + + "\n1.运输类型(必填):\n(如:国内运输拼车)" + ) if seg.mode == "LAND": if check.get("need_land_options"): # 和单聊一样先列出线路类别、运输分类,选完再问询价字段。 diff --git a/inquiry-agent/agent/policy/multi_text_flow.py b/inquiry-agent/agent/policy/multi_text_flow.py index 787c4f5..fc9f008 100644 --- a/inquiry-agent/agent/policy/multi_text_flow.py +++ b/inquiry-agent/agent/policy/multi_text_flow.py @@ -37,7 +37,6 @@ from agent.policy.multi_segments import ( ) from agent.schema.land_options import ( looks_like_confirm, - option_prompt_needs_sample, sample_pair_allowed, ) @@ -291,17 +290,11 @@ class MultiTextInquiryFlow: def _reply_gaps(self, segments: list[Segment], gaps: str, reply: ReplyFn) -> None: """ - 补问正文。陆运还缺类型或线路时,再发一张和单聊相同的填写样例图。 + 补问正文。陆运选项走复制清单,不再另发填写样例图。 - 副作用:出站文字,可能再出一张图片。只在 Worker 里调。 + 副作用:出站文字。只在 Worker 里调。 """ reply(gaps) - for seg in segments: - if seg.mode != "LAND": - continue - if option_prompt_needs_sample(current=seg.facts): - reply(copy.land_sample_image_fallback(), copy.land_sample_image_payload()) - return def _enrich_segments( self, diff --git a/inquiry-agent/agent/quote_templates/prompts/mapping_analyzer.txt b/inquiry-agent/agent/quote_templates/prompts/mapping_analyzer.txt index bf8aec8..1411dd1 100644 --- a/inquiry-agent/agent/quote_templates/prompts/mapping_analyzer.txt +++ b/inquiry-agent/agent/quote_templates/prompts/mapping_analyzer.txt @@ -26,7 +26,7 @@ ════════════════════════════════════ (1)询价事实 facts(对话/工单,不是 TMS 价) -起运港、目的港/目的地、品名、货量、整柜或拼柜、箱型箱量、件数、重量、体积、贸易条款、货好时间、报价日期/询价日期、HS、含油电磁、客户名称、联系人、电话、提送货/派送地址、特别说明等(有则带)。 +起运港、目的港/目的地、品名、货量、整柜或拼柜、箱型箱量、件数、重量、体积、贸易条款、货好时间、报价日期/询价日期/走货日期/发货日期/出货日期/提货日期、HS、含油电磁、客户名称、联系人、电话、提送货/派送地址、特别说明等(有则带)。 (2)TMS 报价 data(经主账查询返回;出单前投影为 fillFacts / fee_rows / 分段费用) 顶层共通字段(海陆空响应里都可能出现): @@ -42,7 +42,7 @@ data.chargeableWeightKg, missingFields, remark data.isMulti(空运/陆运:1=单段或单方案,2=多段/多方案三选一等,以业务约定为准) 系统补全:quote_no←工单号;sales_name←业务员;quote_date 空则当天。 -「询价日期」与「报价日期」→ 都映射 quote_date。 +「报价日期」「询价日期」「走货日期」「询价时间」「走货时间」「发货时间」「发货日期」「出货日期」「出货时间」「提货时间」「提货日期」→ 都映射 quote_date。货好时间、提货地址不要映射成 quote_date。 ════════════════════════════════════ 【二】TMS 返回结构 → 填表投影(出单程序做;你的映射必须接得住) @@ -145,7 +145,7 @@ customer_name ← 客户名称、客户 contact_name ← 联系人、承运联系人 contact_phone ← 电话 quote_no ← 报价单编号、报价编号、RFQ No -quote_date ← 报价日期、询价日期、Inquiry Date +quote_date ← 报价日期、询价日期、走货日期、询价时间、走货时间、发货时间、发货日期、出货日期、出货时间、提货时间、提货日期、Inquiry Date valid_until ← 有效期至、报价有效期、period(← TMS validUntil) currency ← 币别、币种(← TMS currency / settlementCurrency) sales_name ← 业务员 diff --git a/inquiry-agent/agent/schema/field_validate.py b/inquiry-agent/agent/schema/field_validate.py index d412749..b62630d 100644 --- a/inquiry-agent/agent/schema/field_validate.py +++ b/inquiry-agent/agent/schema/field_validate.py @@ -24,8 +24,8 @@ AIR_REQUIRED_FIELDS = ( ("目的港", "目的地"), ("品名", "品名"), ("件数", "件数"), - ("毛重", "重量"), - ("体积", "体积"), + ("毛重", "重量(KG)"), + ("体积", "体积(CBM)"), ("包装方式", "包装类型"), ("报价日期", "报价日期"), ) @@ -65,8 +65,8 @@ LAND_DISPLAY = { "起运港": "始发站", "目的港": "目的地", "品名": "货物品名", - "毛重": "重量", - "体积": "体积", + "毛重": "重量(KG)", + "体积": "体积(CBM)", "车型/数量": "车型/数量", "通关口岸": "通关口岸", "报价日期": "报价日期", @@ -178,6 +178,20 @@ _RE_WEIGHT_WORD = re.compile(r"(?:毛重|重量)\s*[::]?\s*(\d+(?:\.\d+)?)") _RE_VOLUME_WORD = re.compile(r"体积\s*[::]?\s*(\d+(?:\.\d+)?)") # 销售原话里的包装词:当前句出现则覆盖旧值,散货也要收下 _PACK_WORDS = ("纸箱", "木箱", "托盘", "卡板", "散货") +# 销售口中的报价日期。内部键仍是报价日期。货好时间、提货地址不是这里。 +QUOTE_DATE_SYNONYMS = ( + "询价日期", + "走货日期", + "询价时间", + "走货时间", + "发货时间", + "发货日期", + "出货日期", + "出货时间", + "提货时间", + "提货日期", +) + # 报价日期:9月15日 / 2026-09-15;未写年用上海当年 _RE_DATE_YMD = re.compile( r"(20\d{2})\s*[-/.年]\s*(\d{1,2})\s*[-/.月]\s*(\d{1,2})\s*[日号]?" @@ -193,8 +207,10 @@ _ALIASES = { "始发站": "起运港", "货物品名": "品名", "重量(KG)": "毛重", + "重量(KG)": "毛重", "重量": "毛重", "体积(CBM)": "体积", + "体积(CBM)": "体积", "车型/数量": "车型/数量", "车型数量": "车型/数量", "数量": "数量", @@ -220,6 +236,8 @@ _ALIASES = { "贸易条款(非必填)": "贸易条款", "起运港(非必填)": "起运港", } +for _quote_alias in QUOTE_DATE_SYNONYMS: + _ALIASES[_quote_alias] = "报价日期" def shanghai_today() -> str: @@ -239,6 +257,7 @@ def normalize_facts(facts: dict[str, Any] | None) -> dict[str, str]: for raw_key, raw_val in (facts or {}).items(): key = str(raw_key).strip() key = re.sub(r"(必填)|(非必填)|\(必填\)|\(非必填\)", "", key).strip() + key = re.sub(r"(KG)|(CBM)|\(KG\)|\(CBM\)", "", key, flags=re.I).strip() key = _ALIASES.get(key, key) if raw_val is None: continue @@ -285,6 +304,32 @@ def _harvest_packaging(raw: str) -> str: return last +def _quote_date_labeled_value(raw: str) -> str: + """ + 原话里「报价日期」或同义词后面的值。 + + 询价日期、走货日期、发货时间、提货日期等都算报价日期。 + 能写成 yyyy-MM-dd 就规范;「明天」这类留原文。 + 没有这些标签则空串,交给句子里的裸日期。 + 同一句出现多次,用最后一次,避免前面的货好时间把日期抢走。 + """ + text = raw or "" + labels = ("报价日期",) + QUOTE_DATE_SYNONYMS + best_at = -1 + best_val = "" + for label in labels: + pattern = re.compile( + rf"(?= best_at: + best_at = hit.start() + best_val = hit.group(1).strip().strip(",,。;;、") + if not best_val: + return "" + return _harvest_quote_date(best_val) or best_val + + def _harvest_quote_date(raw: str) -> str: """当前原话里的报价日期,规范成 yyyy-MM-dd;没有则空串。""" from datetime import datetime @@ -304,15 +349,26 @@ def _harvest_quote_date(raw: str) -> str: return "" +_RE_CLARIFY_EXAMPLE = re.compile(r"^\s*[((]如\s*[::]") + + +def _without_clarify_examples(text: str) -> str: + """补问清单里的「(如:托盘、散货)」整行丢掉,避免示例被当成已填。""" + return "\n".join( + line for line in (text or "").splitlines() if not _RE_CLARIFY_EXAMPLE.match(line) + ) + + def harvest_oral_measures(text: str, facts: dict[str, str] | None = None) -> dict[str, str]: """ 从当前原话机读件数/重量/体积/包装/报价日期。 当前句写了的覆盖旧值:散货替换托盘,9月16日写入报价日期。 + 补问复制清单的示例行不参与机读。 副作用:无。不改主账。不猜港口。 """ merged = dict(facts or {}) - raw = text or "" + raw = _without_clarify_examples(text or "") hit = _RE_PIECES.search(raw) if hit: merged["件数"] = f"{hit.group(1)}{hit.group(2)}" @@ -334,9 +390,13 @@ def harvest_oral_measures(text: str, facts: dict[str, str] | None = None) -> dic pack = _harvest_packaging(raw) if pack: merged["包装方式"] = pack - quote_date = _harvest_quote_date(raw) - if quote_date: - merged["报价日期"] = quote_date + labeled_date = _quote_date_labeled_value(raw) + if labeled_date: + merged["报价日期"] = labeled_date + else: + quote_date = _harvest_quote_date(raw) + if quote_date: + merged["报价日期"] = quote_date from agent.schema.tms_air_query import harvest_named_air_route return harvest_named_air_route(raw, merged) @@ -405,6 +465,25 @@ def pick_collab_from_facts( return out +def air_general_cargo_collab_defaults(facts: dict[str, str] | None) -> dict[str, str]: + """ + 空运货物品名正好是「普货」时,含电、含磁缺省为否。 + + 只补事实里还没写的项。已经写了「是」或「否」的不在这里改。 + 品名多一个字(如普货纸箱)不算普货,不默认。 + 调用方仍须遵守:群里已记下的协同值优先,本函数只给空项。 + """ + src = normalize_facts(facts) + if (src.get("品名") or "").strip() != "普货": + return {} + out: dict[str, str] = {} + for key in AIR_COLLAB_FIELDS: + # 摘要占位「-」等于没写,不能挡住默认否。 + if (src.get(key) or "").strip() in {"", "-", "—", "-"}: + out[key] = "否" + return out + + def harvest_oral_collab(text: str) -> dict[str, str]: """ 群里口语协同:含油/含电/含磁、货好时间、是否为危险品。 diff --git a/inquiry-agent/agent/schema/land_options.py b/inquiry-agent/agent/schema/land_options.py index 1c6664a..a2664fa 100644 --- a/inquiry-agent/agent/schema/land_options.py +++ b/inquiry-agent/agent/schema/land_options.py @@ -1,7 +1,7 @@ """ 陆运选项规则:运输类型、线路类别、运输分类。 -本文件职责:从销售原话收取标准项、校验是否在名单内、生成补问清单(含填写样例表)、判断「确定查价」意图。 +本文件职责:从销售原话或复制填空清单收取标准项、校验是否在名单内、生成补问清单、判断「确定查价」意图。 三项各自必填,组合不拦截查价。拉群必须对上填写样例。运输分类句里的「中港/东南亚」不得冒充类型或线路。 禁止:改六态;发明枚举;把选项当货物字段;再要单独的中港装载方式。 线程:纯函数,无共享可变状态,可在 Worker 槽调用。 @@ -9,6 +9,7 @@ from __future__ import annotations +import re from pathlib import Path from typing import Any @@ -53,6 +54,21 @@ def scrub_land_placeholder_ports(facts: dict[str, str] | None) -> dict[str, str] out[str(key)] = val return out +# 复制清单里的线路编号:1.x=拼车 2.x=整车,编号比文字更短,必须先认。 +ROUTE_CODES = ( + ("1.1", "国内运输拼车", "国内长途/零担"), + ("1.2", "国内运输拼车", "国内城配/拖车/打包"), + ("2.1", "国内运输整车", "国内长途/零担"), + ("2.2", "国内运输整车", "国内城配/拖车/打包"), + ("3.1", "跨境集拼", "中港/中亚/中欧"), + ("3.2", "跨境集拼", "东南亚"), + ("4.1", "跨境整车", "中港/中亚/中欧"), + ("4.2", "跨境整车", "东南亚"), + ("5.1", "中港整车", "中港/中亚/中欧"), + ("6.1", "中港零担/集拼", "中港/中亚/中欧"), +) +_ROUTE_CODE_MAP = {code: (typ, route) for code, typ, route in ROUTE_CODES} + # 填写样例,同时作为拉群门禁:对不上的类型+线路可以查价,但不能拉群。 SAMPLE_PAIRS = ( ("国内运输拼车", "国内长途/零担"), @@ -173,10 +189,118 @@ def canonicalize_hk_load(raw: str) -> str: return "" +_PASTE_LABEL = re.compile( + r"(运输类型|线路类别|运输分类)(?:(必填))?[ \t]*[::][ \t]*([^\n]*)" +) +# 1.国内拼车 / 1.1国内长途 都是清单选项行,不能当销售已答。 +_MENU_LINE = re.compile(r"^\s*(?:\d+\.\d+|\d+\.)") +_STEP_LINE = re.compile(r"^\s*Step[12]\s*:", re.I) +_COPY_FENCE = re.compile(r"^\s*-{2,}请复制以下内容-{2,}\s*$") + + +def _apply_route_code(merged: dict[str, str], raw: str, *, fill_type: bool) -> bool: + """编号 1.1~6.1 收成线路;类型还空着时按编号补上。""" + token = (raw or "").strip() + if not token: + return False + code = "" + for key in sorted(_ROUTE_CODE_MAP, key=len, reverse=True): + if token == key or token.startswith(key): + code = key + break + if not code: + return False + typ, route = _ROUTE_CODE_MAP[code] + merged["线路类别"] = route + if fill_type and not str(merged.get("运输类型") or "").strip(): + merged["运输类型"] = typ + return True + + +def _decode_option_token(key: str, raw: str) -> str: + """冒号后的数字或文字收成标准项。认不到返回空串。""" + token = (raw or "").strip() + if not token: + return "" + if key == "运输类型": + if token.isdigit(): + idx = int(token) + if 1 <= idx <= len(TRANSPORT_TYPES): + return TRANSPORT_TYPES[idx - 1] + return canonicalize_transport_type(token) + if key == "线路类别": + for code, _typ, route in ROUTE_CODES: + if token == code or token.startswith(code): + return route + return canonicalize_route(token) + if key == "运输分类": + if token.isdigit(): + idx = int(token) + if 1 <= idx <= len(LAND_CLASSES): + return LAND_CLASSES[idx - 1] + return canonicalize_land_class(token) + return "" + + +def _parse_land_option_labels(text: str) -> dict[str, str]: + """复制清单里「字段(必填):值」只取冒号同行,空着的不当已填。""" + found: dict[str, str] = {} + inferred_type = "" + for match in _PASTE_LABEL.finditer(text or ""): + key = match.group(1) + token = (match.group(2) or "").strip() + if not token: + continue + if key == "线路类别": + hit = "" + for code, typ, route in ROUTE_CODES: + if token == code or token.startswith(code): + found["线路类别"] = route + inferred_type = typ + hit = code + break + if hit: + continue + decoded = _decode_option_token(key, token) + if decoded: + found[key] = decoded + if inferred_type and not found.get("运输类型"): + found["运输类型"] = inferred_type + return found + + +def _strip_land_option_prompt(text: str) -> str: + """ + 丢掉补问清单的 Step 和编号选项行,避免未填清单被收成已选。 + + 整句只有一个编号(如 1.1)要留下,那是销售在答题。 + """ + raw = (text or "").strip() + if not raw: + return "" + compact = raw.replace(" ", "") + if compact in _ROUTE_CODE_MAP or compact.isdigit(): + return raw + kept: list[str] = [] + for line in raw.splitlines(): + if _STEP_LINE.match(line): + continue + if _COPY_FENCE.match(line): + continue + if _PASTE_LABEL.match(line): + continue + if _MENU_LINE.match(line): + continue + kept.append(line) + return "\n".join(kept).strip() + + def harvest_land_options(text: str, current: dict[str, str] | None = None) -> dict[str, str]: """ 从一句原话收取陆运选项,合并到已有值上。 + 先收复制清单冒号后的数字/编号/文字,再收口语。 + 未填的补问清单本身不能当成已选。 先收运输分类并把命中句从原文挖掉,再收类型/线路,避免分类里的东南亚、中港冒充选项。 已有非空项默认保留;本句明确写出另一标准项时才覆盖。 已有值对不上标准枚举则丢掉。不改货物字段。返回新 dict,不修改 current。 @@ -187,7 +311,28 @@ def harvest_land_options(text: str, current: dict[str, str] | None = None) -> di if str(v).strip() } _keep_canonical_options(merged) - blob = text or "" + labeled = _parse_land_option_labels(text or "") + for key in ("运输类型", "线路类别", "运输分类"): + if labeled.get(key): + merged[key] = labeled[key] + + blob = _strip_land_option_prompt(text or "") + compact = blob.replace(" ", "") + if compact in _ROUTE_CODE_MAP: + _apply_route_code(merged, compact, fill_type=not bool(merged.get("运输类型"))) + merged.pop("中港装载方式", None) + return merged + if compact.isdigit() and len(compact) == 1: + idx = int(compact) + missing_type = not merged.get("运输类型") + missing_class = not merged.get("运输分类") + if missing_type and not missing_class and 1 <= idx <= len(TRANSPORT_TYPES): + merged["运输类型"] = TRANSPORT_TYPES[idx - 1] + elif missing_class and not missing_type and 1 <= idx <= len(LAND_CLASSES): + merged["运输分类"] = LAND_CLASSES[idx - 1] + merged.pop("中港装载方式", None) + return merged + found_class = canonicalize_land_class(blob) remainder = blob.replace(found_class, " ") if found_class else blob found_type = canonicalize_transport_type(remainder) @@ -259,19 +404,46 @@ def validate_land_options(opts: dict[str, str]) -> dict[str, Any]: } +_OPTION_PASTE_HEAD = ( + "Step1: 请复制以下内容,粘贴到输入框;", + "Step2:在粘贴内容的每个字段的冒号后,回复数字或文字,告诉我对应字段的值:", + "---请复制以下内容---", +) + + +def _type_prompt_lines() -> list[str]: + lines = ["运输类型(必填):"] + for i, name in enumerate(TRANSPORT_TYPES, start=1): + lines.append(f"{i}.{name}") + return lines + + +def _route_prompt_lines() -> list[str]: + lines = ["线路类别(必填):"] + for code, _typ, route in ROUTE_CODES: + lines.append(f"{code}{route}") + return lines + + def _class_prompt_lines() -> list[str]: - lines = ["运输分类(必填):可选择回复,"] + lines = ["运输分类(必填):"] for i, name in enumerate(LAND_CLASSES, start=1): lines.append(f"{i}.{name}") return lines +# 编号清单已覆盖类型+线路,补问不再发填写样例图。验收脚本认这个开关。 +SAMPLE_IMAGE_DISABLED = True + + 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 + """ + 填写样例图已停发。编号清单已经覆盖类型+线路搭配,不再另出图。 + + 保留函数名避免旧调用处报缺符号;恒为假。current 不使用。 + """ + _ = current + return False def sample_image_path() -> Path: @@ -298,10 +470,10 @@ def _sample_table() -> str: def option_prompt(*, current: dict[str, str]) -> str: """ - 按已识别项生成补问清单。 + 按已识别项生成复制填空清单。 - 缺哪项列哪项。运输类型或线路类别缺任一则提示见图,样例图另发出站。只缺分类不附图。 - 不修改 current。 + 缺哪项列哪项。销售把数字或文字填在字段冒号后面。 + 填写样例图不再另发。不修改 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 "") @@ -310,34 +482,21 @@ def option_prompt(*, current: dict[str, str]) -> str: need_type = t not in TRANSPORT_TYPES need_route = r not in ROUTES need_class = c not in LAND_CLASSES - lines: list[str] = [] - - if need_type and need_route and need_class: - lines.append("请一次选择:运输类型、线路类别、运输分类") - lines.append("") - lines.append("运输类型(必填):可选择回复," + "、".join(TRANSPORT_TYPES)) - lines.append("") - lines.append("线路类别(必填):可选择回复," + "、".join(ROUTES)) - lines.append("") - lines.extend(_class_prompt_lines()) - lines.append("") - lines.append("填写样例见图。") - return "\n".join(lines).strip() - + if not (need_type or need_route or need_class): + return "" + chunks: list[list[str]] = [] if need_type: - lines.append("运输类型(必填):可选择回复," + "、".join(TRANSPORT_TYPES)) + chunks.append(_type_prompt_lines()) if need_route: - if lines: - lines.append("") - lines.append("线路类别(必填):可选择回复," + "、".join(ROUTES)) + chunks.append(_route_prompt_lines()) 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() + chunks.append(_class_prompt_lines()) + body: list[str] = list(_OPTION_PASTE_HEAD) + for i, block in enumerate(chunks): + if i: + body.append("") + body.extend(block) + return "\n".join(body).strip() def looks_like_confirm(text: str) -> bool: diff --git a/inquiry-agent/agent/schema/tms_air_query.py b/inquiry-agent/agent/schema/tms_air_query.py index d0bb111..9771832 100644 --- a/inquiry-agent/agent/schema/tms_air_query.py +++ b/inquiry-agent/agent/schema/tms_air_query.py @@ -183,10 +183,10 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]: missing_display.append("件数") if not weight: missing.append("毛重") - missing_display.append("重量") + missing_display.append("重量(KG)") if not volume: missing.append("体积") - missing_display.append("体积") + missing_display.append("体积(CBM)") if not pack_raw: missing.append("包装方式") missing_display.append("包装类型") diff --git a/inquiry-agent/tests/test_air_field_contract.py b/inquiry-agent/tests/test_air_field_contract.py index 3df85d8..0d8d61a 100644 --- a/inquiry-agent/tests/test_air_field_contract.py +++ b/inquiry-agent/tests/test_air_field_contract.py @@ -17,7 +17,10 @@ os.environ.setdefault("YTD_ENV", "test") from agent.ledger.memory_ledger import MemoryLedger from agent.policy import inquiry_copy as copy from agent.policy.air_text_flow import AirTextInquiryFlow -from agent.schema.field_validate import validate_required_fields +from agent.schema.field_validate import ( + air_general_cargo_collab_defaults, + validate_required_fields, +) from agent.schema.tms_air_query import assemble_air_query @@ -164,6 +167,23 @@ class AirFieldContractTests(unittest.TestCase): self.assertFalse(out["ok"]) self.assertIn("目的港", out["missing"]) + def test_general_cargo_defaults_battery_and_magnet(self) -> None: + """品名正好是普货,含电含磁还空着才默认否。""" + self.assertEqual( + air_general_cargo_collab_defaults({"品名": "普货"}), + {"是否含电": "否", "是否含磁": "否"}, + ) + self.assertEqual( + air_general_cargo_collab_defaults({"货物品名": "普货"}), + {"是否含电": "否", "是否含磁": "否"}, + ) + self.assertEqual( + air_general_cargo_collab_defaults({"品名": "普货", "是否含电": "是"}), + {"是否含磁": "否"}, + ) + self.assertEqual(air_general_cargo_collab_defaults({"品名": "配件"}), {}) + self.assertEqual(air_general_cargo_collab_defaults({"品名": "普货纸箱"}), {}) + if __name__ == "__main__": unittest.main() diff --git a/inquiry-agent/tests/test_air_group_collab.py b/inquiry-agent/tests/test_air_group_collab.py index 132c7af..15cdee9 100644 --- a/inquiry-agent/tests/test_air_group_collab.py +++ b/inquiry-agent/tests/test_air_group_collab.py @@ -167,6 +167,20 @@ class AirGroupCollabTests(unittest.TestCase): self.assertIn("起运地:ZUH", text) self.assertIn("\n\n协同补充字段:\n是否含电:-", text) self.assertIn("是否含磁:-", text) + general = copy.air_group_brief( + work_order_no="WO202609230017", + facts={ + "起运地": "上海", + "目的地": "洛杉矶", + "品名": "普货", + }, + quote=None, + collab_facts={}, + has_price=False, + ) + self.assertIn("是否含电:否", general) + self.assertIn("是否含磁:否", general) + self.assertNotIn("是否含电:-", general) self.assertIn("\n\nTMS报价:\nTMS 暂无匹配报价", text) self.assertNotIn("当前工单:", text) self.assertNotIn("贸易条款", text) @@ -402,6 +416,78 @@ class AirGroupCollabTests(unittest.TestCase): self.assertIn("是否含磁:是", joined) self.assertIn("是否含电:-", joined) + def test_activate_general_cargo_defaults_battery_and_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="gc1"), + flow=self.flow, + ledger=self.ledger, + ) + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertEqual(ticket.collab_facts.get("是否含电"), "否") + self.assertEqual(ticket.collab_facts.get("是否含磁"), "否") + joined = "\n".join(self._texts()) + self.assertIn("是否含电:否", joined) + self.assertIn("是否含磁:否", joined) + fill = dict(getattr(self.ledger, "last_render", {}).get("facts") or {}) + self.assertEqual(fill.get("是否含电") or fill.get("has_battery"), "否") + self.assertEqual(fill.get("是否含磁") or fill.get("has_magnet"), "否") + + def test_activate_general_cargo_keeps_explicit_battery(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="gc2"), + flow=self.flow, + ledger=self.ledger, + ) + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertEqual(ticket.collab_facts.get("是否含电"), "是") + self.assertEqual(ticket.collab_facts.get("是否含磁"), "否") + 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( @@ -460,6 +546,57 @@ class AirGroupCollabTests(unittest.TestCase): self.assertLess(texts[-1], files[-1]) self.assertEqual(len(files), 2) + def test_collab_after_activate_does_not_use_old_land_ticket(self) -> None: + """ + 测服 WO202609230023:空运进原群激活后补含电含磁, + 不能写到同群更早的陆运单 WO202609230007,摘要也不能变成空字段。 + """ + land = self.ledger.create_ticket( + sender_id="sales1", + business_line="LAND", + facts={ + "运输类型": "国内运输整车", + "始发站": "深圳", + "目的站": "上海", + "品名": "配件", + }, + )["work_order_no"] + self.ledger.bind_collab_group( + work_order_no=land, + chat_id="wr_air_1", + member_ids=["sales1"], + ) + air = self._create_air() + handle_air_group( + self._msg(sender="sales1", text=air, chat="wr_air_1", mid="mix0"), + flow=self.flow, + ledger=self.ledger, + ) + self.assertEqual(self.ledger.find_by_collab_chat(chat_id="wr_air_1").work_order_no, air) + self.assertEqual( + str(self.ledger.get_ticket(work_order_no=land).collab_chat_id or ""), + "", + ) + self.groups.sent.clear() + phase = handle_air_group( + self._msg(sender="sales1", text="含电 不含磁", chat="wr_air_1", mid="mix1"), + flow=self.flow, + ledger=self.ledger, + injected_intent="collab_fields", + ) + self.assertEqual(phase, "fields_saved") + air_ticket = self.ledger.get_ticket(work_order_no=air) + land_ticket = self.ledger.get_ticket(work_order_no=land) + self.assertEqual(air_ticket.collab_facts.get("是否含电"), "是") + self.assertEqual(air_ticket.collab_facts.get("是否含磁"), "否") + self.assertFalse(land_ticket.collab_facts.get("是否含电")) + joined = "\n".join(self._texts()) + self.assertIn(f"工单号:{air}", joined) + self.assertNotIn(f"工单号:{land}", joined) + self.assertIn("起运地:上海", joined) + self.assertIn("重量(KG):100", joined) + self.assertNotIn("起运地:-", joined) + def test_sales_quote_rejected(self) -> None: wo = self._create_air() handle_air_group( diff --git a/inquiry-agent/tests/test_air_text_inquiry.py b/inquiry-agent/tests/test_air_text_inquiry.py index 2977ef5..8d98a21 100644 --- a/inquiry-agent/tests/test_air_text_inquiry.py +++ b/inquiry-agent/tests/test_air_text_inquiry.py @@ -96,11 +96,13 @@ class AirTextInquiryTests(unittest.TestCase): self.assertIn("目的地:克拉克", text) self.assertIn("品名:茶叶", text) self.assertIn("件数:10件", text) - self.assertIn("待补充:", text) - self.assertIn("重量:(如:100KGS)", text) - self.assertIn("体积:(如:2CBM)", text) - self.assertIn("包装类型:(如:托盘)", text) - self.assertIn("货源地(非必填)", text) + self.assertIn(copy.CLARIFY_PASTE_HINT, text) + self.assertNotIn("待补充:", text) + self.assertIn("1.重量(KG)(必填):\n(如:100)", text) + self.assertIn("2.体积(CBM)(必填):\n(如:2)", text) + self.assertIn("3.包装类型(必填):\n(如:托盘、散货)", text) + self.assertNotIn("重量:(如:", text) + self.assertIn("货源地(非必填):\n(如:东莞)", text) def test_inquiry_card_matches_sales_format(self) -> None: text = copy.inquiry_card( @@ -128,8 +130,8 @@ class AirTextInquiryTests(unittest.TestCase): "目的地:克拉克", "品名:茶叶", "件数:10件", - "重量:1", - "体积:8", + "重量(KG):1", + "体积(CBM):8", "包装类型:托盘", "报价日期:2026-09-12", "工单已创建,正在查询TMS报价。", @@ -199,7 +201,33 @@ class AirTextInquiryTests(unittest.TestCase): injected_mode="", ) self.assertEqual(phase, "need_mode") - self.assertIn("运输方式", self.replies[-1]) + self.assertIn(copy.ASK_TRANSPORT, self.replies[-1]) + self.assertIn("1.陆运", self.replies[-1]) + self.assertIn("2.海运", self.replies[-1]) + self.assertIn("3.空运", self.replies[-1]) + + def test_missing_mode_accepts_number_or_word(self) -> None: + """未定运输方式时,回 3 或「空运」都走空运。""" + self.flow.on_text( + sender_id="u_mode_num", + text="帮我看看价格", + reply=self._reply, + injected_facts={}, + injected_mode="", + ) + phase = self.flow.on_text(sender_id="u_mode_num", text="3", reply=self._reply) + self.assertNotEqual(phase, "need_mode") + self.assertEqual(self.flow.session_of("u_mode_num").business_line, "AIR") + self.flow.on_text( + sender_id="u_mode_word", + text="帮我看看价格", + reply=self._reply, + injected_facts={}, + injected_mode="", + ) + phase = self.flow.on_text(sender_id="u_mode_word", text="空运", reply=self._reply) + self.assertNotEqual(phase, "need_mode") + self.assertEqual(self.flow.session_of("u_mode_word").business_line, "AIR") def test_pack_bulk_cargo_accepted_as_user_said(self) -> None: facts = dict(COMPLETE_AIR) @@ -251,13 +279,48 @@ class AirTextInquiryTests(unittest.TestCase): self.assertIn("请确认并补充以下信息", last) self.assertIn("运输方式:空运", last) self.assertIn("起运地:上海", last) - self.assertIn("待补充:", last) + self.assertIn(copy.CLARIFY_PASTE_HINT, last) self.assertIn("目的地", last) self.assertIn("件数", last) self.assertIn("如:", last) self.assertIn("报价日期:", last) self.assertNotIn("报价日期:(如", last) + def test_clarify_paste_reply_fills_missing_required(self) -> None: + """销售复制清单只填冒号后的值,空着的包装类型继续补问。""" + phase = self.flow.on_text( + sender_id="paste1", + text="空运询价", + reply=self._reply, + injected_facts={ + "起运港": "南京", + "目的港": "吉隆坡", + "品名": "茶叶", + "报价日期": "2026-09-17", + }, + injected_mode="AIR", + ) + self.assertEqual(phase, "clarify") + phase = self.flow.on_text( + sender_id="paste1", + text=( + "1.件数(必填):10\n" + "(如:10)\n" + "2.重量(KG)(必填):10\n" + "(如:100)\n" + "3.体积(CBM)(必填):10\n" + "(如:2)\n" + "4.包装类型(必填):\n" + "(如:托盘、散货)" + ), + reply=self._reply, + ) + self.assertEqual(phase, "clarify") + last = self.replies[-1] + self.assertIn("件数:10件", last) + self.assertIn("包装类型(必填)", last) + self.assertNotIn("包装类型:托盘、散货", last) + def test_complete_air_hit_then_skip_adopt_excel_deal(self) -> None: phase = self._on_text( sender_id="u1", @@ -272,7 +335,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertIn("运输方式:空运", joined) self.assertIn("起运地:上海", joined) self.assertIn("目的地:洛杉矶", joined) - self.assertIn("重量:", joined) + self.assertIn("重量(KG):", joined) self.assertIn("正在查询TMS报价", joined) self.assertNotIn("【询价卡片】", joined) self.assertNotIn("[test]", joined) @@ -400,8 +463,8 @@ class AirTextInquiryTests(unittest.TestCase): joined = "\n".join(self.replies) self.assertIn("起运地:南京", joined) self.assertIn("目的地:吉隆坡", joined) - self.assertIn("重量:", joined) - self.assertIn("体积:", joined) + self.assertIn("重量(KG):", joined) + self.assertIn("体积(CBM):", joined) self.assertIn("包装类型:散货", joined) self.assertIn("报价日期:2026-09-16", joined) self.assertNotIn("重量(KG)", joined) @@ -985,7 +1048,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertIn("【WO202609140001报价方案】", text) self.assertIn("工单号:WO202609140001", text) self.assertIn("起运地:上海", text) - self.assertIn("体积:2.5", text) + self.assertIn("体积(CBM):2.5", text) self.assertIn("空运费:USD 1,200", text) self.assertNotIn("时效", text) self.assertIn("报价有效期:2026-09-20", text) diff --git a/inquiry-agent/tests/test_extract_oral.py b/inquiry-agent/tests/test_extract_oral.py index d4b49a8..bf1d8a4 100644 --- a/inquiry-agent/tests/test_extract_oral.py +++ b/inquiry-agent/tests/test_extract_oral.py @@ -15,7 +15,12 @@ if ROOT not in sys.path: os.environ.setdefault("LEDGER_BACKEND", "memory") -from agent.llm.extract_text import extract_inquiry_snapshot, parse_labeled_facts +from agent.llm.extract_text import ( + detect_transport_mode, + extract_inquiry_snapshot, + parse_labeled_facts, +) +from agent.schema.field_validate import normalize_facts from agent.llm.mode_extract_fields import invoke_shell, parse_extract_payload from agent.policy.air_text_flow import AirTextInquiryFlow from agent.ledger.memory_ledger import MemoryLedger @@ -260,7 +265,7 @@ class OralExtractTests(unittest.TestCase): work_order_no="WO1", facts={**facts, "起运港": "南京", "目的港": "吉隆坡", "品名": "茶叶", "件数": "10"}, ) - self.assertIn("重量:1.5KG", shown) + self.assertIn("重量(KG):1.5KG", shown) self.assertNotIn("1.5KGS", shown) def test_oral_units_fill_weight_volume_without_b(self) -> None: @@ -326,6 +331,36 @@ class OralExtractTests(unittest.TestCase): self.assertIn("目的地:洛杉矶", joined) self.assertNotIn("【询价卡片】", joined) + def test_mode_index_maps_land_sea_air(self) -> None: + self.assertEqual(detect_transport_mode("1"), "LAND") + self.assertEqual(detect_transport_mode("2"), "SEA") + self.assertEqual(detect_transport_mode("3"), "AIR") + self.assertEqual(detect_transport_mode("1.陆运"), "LAND") + self.assertEqual(detect_transport_mode("空运"), "AIR") + self.assertEqual(detect_transport_mode("10件"), "") + self.assertEqual(detect_transport_mode("1.件数(必填):10"), "") + + +class ClarifyPasteTests(unittest.TestCase): + def test_paste_numbered_required_lines_and_skip_examples(self) -> None: + """复制补问清单:只收冒号后的值,示例行和空项都不算填了。""" + text = """ +1.件数(必填):10 +(如:10) +2.重量(KG)(必填):10 +(如:100) +3.体积(CBM)(必填):10 +(如:2) +4.包装类型(必填): +(如:托盘、散货) +""" + facts = normalize_facts(parse_labeled_facts(text)) + self.assertEqual(facts.get("件数"), "10") + self.assertEqual(facts.get("毛重"), "10") + self.assertEqual(facts.get("体积"), "10") + self.assertNotIn("包装方式", facts) + self.assertNotEqual(facts.get("包装方式"), "托盘、散货") + class ImageOptionalClipTests(unittest.TestCase): def test_numbered_extra_lines_do_not_enter_fields(self) -> None: @@ -361,7 +396,7 @@ class ImageOptionalClipTests(unittest.TestCase): transport_mode="空运", ) self.assertNotIn("希望送到", card) - self.assertNotIn("4.", card) + self.assertNotIn("4. 希望送到", card) self.assertIn("收货地址", card) @@ -435,6 +470,32 @@ class SeaSheetHeaderClipTests(unittest.TestCase): self.assertNotIn("送货地址", facts.get("整柜或拼柜") or "") self.assertNotIn("抵达", facts.get("箱型箱量") or "") + def test_quote_date_synonyms_for_sea_land_air(self) -> None: + """询价/走货/发货/出货/提货的日期或时间,海运陆运空运都记成报价日期。""" + from agent.schema.field_validate import QUOTE_DATE_SYNONYMS, normalize_facts + + modes = (("海运", "SEA"), ("陆运", "LAND"), ("空运", "AIR")) + for label in QUOTE_DATE_SYNONYMS: + self.assertEqual(normalize_facts({label: "2026-09-23"})["报价日期"], "2026-09-23") + for word, mode in modes: + snap = extract_inquiry_snapshot(f"{word}\n{label}:2026-09-23", allow_b=False) + self.assertEqual(snap["business_line"], mode, label) + self.assertEqual(snap["facts"].get("报价日期"), "2026-09-23", label) + self.assertNotIn(label, snap["facts"]) + + def test_quote_date_synonym_does_not_steal_ready_or_pickup(self) -> None: + """货好时间、提货地址仍是原字段;同义词日期不被前面的日期抢走。""" + snap = extract_inquiry_snapshot( + "海运\n货好时间:9月20日\n提货地址:上海仓库\n询价日期:2026-09-15", + allow_b=False, + ) + facts = snap["facts"] + self.assertEqual(facts.get("报价日期"), "2026-09-15") + self.assertEqual(facts.get("货好时间"), "9月20日") + self.assertEqual(facts.get("提货地址"), "上海仓库") + oral = extract_inquiry_snapshot("空运 发货日期9月15日", allow_b=False) + self.assertEqual(oral["facts"].get("报价日期"), "2026-09-15") + if __name__ == "__main__": unittest.main() diff --git a/inquiry-agent/tests/test_land_copy.py b/inquiry-agent/tests/test_land_copy.py index 443b39d..1adad0c 100644 --- a/inquiry-agent/tests/test_land_copy.py +++ b/inquiry-agent/tests/test_land_copy.py @@ -121,9 +121,10 @@ class LandCopyTest(unittest.TestCase): land_subtype="跨境集拼", ) self.assertIn("始发站(必填):广州", text) - self.assertIn("待补充:", text) - self.assertIn("体积(必填)", text) - self.assertIn("通关口岸(非必填)", text) + self.assertIn(copy.CLARIFY_PASTE_HINT, text) + self.assertIn("1.体积(CBM)(必填):\n(如:100CBM)", text) + self.assertIn("2.通关口岸(非必填):\n(如:皇岗)", text) + self.assertNotIn("体积(必填):(如:", text) self.assertNotIn(copy.LAND_CONFIRM_TAIL, text) def test_ask_land_fields_domestic_has_no_customs(self): @@ -138,7 +139,7 @@ class LandCopyTest(unittest.TestCase): missing_keys=["体积"], land_subtype="国内运输拼车", ) - self.assertIn("体积(必填)", text) + self.assertIn("1.体积(CBM)(必填):\n(如:100CBM)", text) self.assertNotIn("通关口岸", text) def test_ask_land_fields_filled_customs_not_pending(self): @@ -154,7 +155,7 @@ class LandCopyTest(unittest.TestCase): land_subtype="中港整车", ) self.assertIn("通关口岸(非必填):皇岗", text) - pending = text.split("待补充:", 1)[1] + pending = text.split(copy.CLARIFY_PASTE_HINT, 1)[1] self.assertNotIn("通关口岸", pending) def test_inquiry_card_lists_land_fields_and_wo(self): @@ -205,7 +206,7 @@ class LandCopyTest(unittest.TestCase): self.assertIn("运输类型:国内运输拼车", text) self.assertIn("始发站:泉州市", text) self.assertIn("货物品名:通信机柜及线缆配件", text) - self.assertIn("体积:10CBM", text) + self.assertIn("体积(CBM):10CBM", text) self.assertNotIn("整柜或拼柜", text) self.assertNotIn("箱型箱量", text) self.assertNotIn("件数:", text) diff --git a/inquiry-agent/tests/test_land_options.py b/inquiry-agent/tests/test_land_options.py index 2ce4cb5..0b0bb67 100644 --- a/inquiry-agent/tests/test_land_options.py +++ b/inquiry-agent/tests/test_land_options.py @@ -1,5 +1,5 @@ """ -陆运选项单测:三项各自必填、不联动;中港拆进运输类型;样例表跟类型/线路缺项走。 +陆运选项单测:三项各自必填、不联动;中港拆进运输类型;补问只发复制清单,不再发填写样例图。 不连企微、主账、TMS。 """ @@ -108,48 +108,49 @@ 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.assertIn("国内长途/零担", text) - self.assertIn("1.普件(东南亚、中港常用线路/常规车型)", text) - self.assertIn("填写样例见图", text) + self.assertIn("Step1: 请复制以下内容,粘贴到输入框;", text) + self.assertIn("Step2:在粘贴内容的每个字段的冒号后,回复数字或文字,告诉我对应字段的值:", text) + self.assertIn("---请复制以下内容---\n运输类型(必填):", text) + self.assertIn("运输类型(必填):\n1.国内运输拼车", text) + self.assertIn("6.中港零担/集拼", text) + self.assertIn("线路类别(必填):\n1.1国内长途/零担", text) + self.assertIn("6.1中港/中亚/中欧", text) + self.assertIn("运输分类(必填):\n1.普件(东南亚、中港常用线路/常规车型)", text) + self.assertIn("4.急件", text) + self.assertNotIn("请一次选择:运输类型", text) + self.assertNotIn("可选择回复", text) + self.assertNotIn("填写样例见图", text) self.assertNotIn("国内运输整车 + 国内城配/拖车/打包", text) - self.assertNotIn("┌", text) - self.assertNotIn("| 国内运输整车 |", text) - self.assertNotIn("仅供参考", text) self.assertNotIn("中港装载方式", text) - self.assertNotIn("跟运输类型联动", text) 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("Step1: 请复制以下内容,粘贴到输入框;", text) + self.assertIn("---请复制以下内容---", text) self.assertNotIn("请补充陆运类型:国内运输拼车", text) def test_sample_table_when_route_missing(self): text = option_prompt(current={"运输类型": "国内运输拼车"}) - self.assertIn("线路类别(必填)", text) - self.assertIn("填写样例见图", text) - self.assertTrue(option_prompt_needs_sample(current={"运输类型": "国内运输拼车"})) + self.assertIn("Step1: 请复制以下内容,粘贴到输入框;", text) + self.assertIn("---请复制以下内容---\n线路类别(必填):", text) + self.assertIn("线路类别(必填):\n1.1国内长途/零担", text) + self.assertIn("运输分类(必填):\n1.普件", text) + self.assertFalse(option_prompt_needs_sample(current={"运输类型": "国内运输拼车"})) + self.assertFalse(option_prompt_needs_sample(current={})) self.assertNotIn("国内运输拼车 + 国内长途/零担", text) self.assertNotIn("请一次选择:运输类型", text) self.assertNotIn("运输类型(必填)", text) 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.assertIn("Step1: 请复制以下内容,粘贴到输入框;", text) + self.assertIn("---请复制以下内容---", text) + self.assertNotIn("填写样例见图", text) + self.assertNotIn("填写样例", 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( @@ -179,6 +180,30 @@ class LandOptionsTest(unittest.TestCase): self.assertNotIn("目的港", out) self.assertEqual(out.get("品名"), "衣服") + def test_paste_numbers_and_codes(self): + """复制清单后在冒号后填数字/编号,要收到标准项。""" + pasted = ( + "运输类型(必填):2\n" + "线路类别(必填):2.1\n" + "运输分类(必填):4" + ) + got = harvest_land_options(pasted) + self.assertEqual(got.get("运输类型"), "国内运输整车") + self.assertEqual(got.get("线路类别"), "国内长途/零担") + self.assertEqual(got.get("运输分类"), "急件") + + def test_route_code_infers_type_when_empty(self): + got = harvest_land_options("1.1") + self.assertEqual(got.get("运输类型"), "国内运输拼车") + self.assertEqual(got.get("线路类别"), "国内长途/零担") + + def test_unfilled_prompt_does_not_eat_menu(self): + """原样粘贴未填的清单,不能把选项名当成已选。""" + got = harvest_land_options(option_prompt(current={})) + self.assertFalse(got.get("运输类型")) + self.assertFalse(got.get("线路类别")) + self.assertFalse(got.get("运输分类")) + def test_confirm_intent(self): self.assertTrue(looks_like_confirm("确认")) 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 bedb46b..9e21973 100644 --- a/inquiry-agent/tests/test_land_text_inquiry.py +++ b/inquiry-agent/tests/test_land_text_inquiry.py @@ -73,11 +73,10 @@ class LandTextInquiryFrontTests(unittest.TestCase): self.assertIn("线路类别", text) self.assertIn("运输分类", text) self.assertIn("中港整车", text) - self.assertIn("填写样例见图", text) + self.assertIn("Step1: 请复制以下内容,粘贴到输入框;", 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 ""))) + self.assertFalse(any(x.get("msgtype") == "image" for x in self.extras)) def test_known_type_does_not_reask_type(self) -> None: phase = self.flow.on_text( @@ -90,8 +89,9 @@ 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)) + self.assertIn("Step1: 请复制以下内容,粘贴到输入框;", self._joined()) + self.assertIn("---请复制以下内容---", self._joined()) + self.assertFalse(any(x.get("msgtype") == "image" for x in self.extras)) def test_unlinked_route_still_keeps_type(self) -> None: phase = self.flow.on_text( @@ -247,7 +247,7 @@ class LandTextInquiryFrontTests(unittest.TestCase): ) self.assertEqual(phase, "clarify") self.assertNotIn(copy.LAND_CONFIRM_TAIL, self._joined()) - self.assertIn("体积(必填)", self._joined()) + self.assertIn("体积(CBM)(必填)", self._joined()) self.assertNotIn("通关口岸", self._joined()) diff --git a/inquiry-agent/tests/test_multi_segments.py b/inquiry-agent/tests/test_multi_segments.py index 29cb0f5..bff41f3 100644 --- a/inquiry-agent/tests/test_multi_segments.py +++ b/inquiry-agent/tests/test_multi_segments.py @@ -11,6 +11,7 @@ if ROOT not in sys.path: sys.path.insert(0, ROOT) from agent.ledger.memory_ledger import MemoryLedger +from agent.policy import inquiry_copy as copy from agent.policy.multi_segments import ( Segment, classify_multi_text, @@ -130,10 +131,11 @@ class SplitTests(unittest.TestCase): ready, _, text = refresh_segments(outcome.segments) self.assertFalse(ready) self.assertIn("请确认并补充以下信息:", text) - self.assertIn("待补充:", text) - self.assertIn("品名:(如:茶叶)", text) - self.assertIn("货源地(非必填):(如:东莞)", text) - self.assertIn("贸易条款(非必填):(如:FOB)", text) + self.assertIn(copy.CLARIFY_PASTE_HINT, text) + self.assertIn("品名(必填):\n(如:茶叶)", text) + self.assertIn("货源地(非必填):\n(如:东莞)", text) + self.assertIn("贸易条款(非必填):\n(如:DDP、DAP、CIF、CIP)", text) + self.assertNotIn("品名:(如:茶叶)", text) def test_port_to_port_fills_sea_class(self) -> None: """海运只缺运输分类时,回复 Port to Port 要写进该段,和单聊一样。""" @@ -226,10 +228,10 @@ class SplitTests(unittest.TestCase): ] ready, updated, text = refresh_segments(segs) self.assertFalse(ready) - self.assertIn("线路类别(必填):可选择回复", text) - self.assertIn("国内长途/零担", text) + self.assertIn("线路类别(必填):", text) + self.assertIn("1.1国内长途/零担", text) self.assertIn("国内城配/拖车/打包", text) - self.assertIn("运输分类(必填):可选择回复", text) + self.assertIn("运输分类(必填):", text) self.assertIn("1.普件", text) self.assertNotIn("始发站", text) self.assertNotIn("请回复「确定」创建工单", text) @@ -301,9 +303,9 @@ class FlowTests(unittest.TestCase): return "ok" def last_prompt(self) -> str: - """样例图兜底会后发,取带段号或可选择回复的那条补问。""" + """取带段号或复制填空清单的那条补问。""" for text in reversed(self.replies): - if "段1" in text or "可选择回复" in text or "请回复「确定」" in text or "待补充" in text: + if "段1" in text or "Step1:" in text or "请回复「确定」" in text or "待补充" in text: return text return self.replies[-1] if self.replies else "" @@ -313,8 +315,8 @@ class FlowTests(unittest.TestCase): card = self.replies[-1] self.assertIn("请确认并补充以下信息:", card) self.assertIn("段2:空运", card) - self.assertIn("待补充:", card) - self.assertIn("包装类型:(如:", card) + self.assertIn(copy.CLARIFY_PASTE_HINT, card) + self.assertIn("包装类型(必填):\n(如:", card) self.assertNotIn("TMS", card) def test_clarify_reply_gets_a_followup(self) -> None: @@ -377,7 +379,7 @@ class FlowTests(unittest.TestCase): ) card = self.last_prompt() self.assertEqual(phase, "clarify") - self.assertIn("线路类别(必填):可选择回复", card) + self.assertIn("线路类别(必填):", card) self.assertIn("纽约港", card) self.assertIn("巴生西港", card) self.assertIn("机器设备", card) @@ -393,8 +395,8 @@ class FlowTests(unittest.TestCase): phase = self.flow.on_text(sender_id="sales", text=start, reply=self.reply) self.assertEqual(phase, "clarify") first = self.last_prompt() - self.assertIn("线路类别(必填):可选择回复", first) - self.assertIn("运输分类(必填):可选择回复", first) + self.assertIn("线路类别(必填):", first) + self.assertIn("运输分类(必填):", first) self.assertNotIn("始发站", first) phase = self.flow.on_text( sender_id="sales", @@ -411,7 +413,7 @@ class FlowTests(unittest.TestCase): ) card = self.last_prompt() self.assertEqual(phase, "clarify") - self.assertIn("线路类别(必填):可选择回复", card) + self.assertIn("线路类别(必填):", card) self.assertNotIn("始发站", card) self.assertNotIn("请回复「确定」创建工单", card) @@ -466,8 +468,8 @@ class FlowTests(unittest.TestCase): phase = self.flow.on_text(sender_id="sales", text=text, reply=self.reply) self.assertEqual(phase, "clarify") card = self.last_prompt() - self.assertIn("线路类别(必填):可选择回复", card) - self.assertIn("运输分类(必填):可选择回复", card) + self.assertIn("线路类别(必填):", card) + self.assertIn("运输分类(必填):", card) self.assertNotIn("始发站", card) self.assertNotIn("请回复「确定」创建工单", card) diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py index f05aa25..25b9a08 100644 --- a/inquiry-agent/tests/test_sea_text_inquiry.py +++ b/inquiry-agent/tests/test_sea_text_inquiry.py @@ -167,7 +167,8 @@ class SeaTextInquiryTests(unittest.TestCase): self.assertIn("运输方式:海运", text) self.assertIn("起运港:上海", text) self.assertIn("货物数量:20吨", text) - self.assertIn("箱型箱量:(如", text) + self.assertIn("1.箱型箱量(必填):\n(如:1x40HQ)", text) + self.assertNotIn("箱型箱量:(如", text) self.assertNotIn("件数", text) self.assertNotIn("重量", text) self.assertNotIn("报价日期:(如", text) @@ -256,8 +257,7 @@ class SeaTextInquiryTests(unittest.TestCase): missing_keys=["运输分类"], transport_mode="海运", ) - self.assertIn("Port to Port", text) - self.assertIn("其他", text) + self.assertIn("1.运输分类(必填):\n(如:Port to Port、其他)", text) self.assertIn("贸易条款:FOB", text) def test_incomplete_without_trade_terms_does_not_create_ticket(self) -> None: 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 d41205c..948bfd6 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 @@ -814,8 +814,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl + * 同一群只留这一张单,陆运/海运/空运旧绑定一并摘掉。 */ @Override @Transactional(rollbackFor = Exception.class) @@ -1000,6 +1002,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl + * 同群若仍有多张单,取最近更新的一张,避免 LIMIT 1 无排序拿到最早的陆运旧单。 */ @Override public Map findByCollabChat(String chatId) { @@ -1075,6 +1080,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl() .eq(InquiryTicket::getCollabChatId, id) + .orderByDesc(InquiryTicket::getUpdateTime) .last("LIMIT 1"), false); if (ticket == null) { return out; @@ -1241,7 +1247,10 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl + * 陆运/海运旧单也要摘掉。测服 WO202609230023 激活后补协同, + * 按群找回仍拿到同群陆运 WO202609230007,摘要字段全空。 */ @Override @Transactional(rollbackFor = Exception.class) @@ -1260,20 +1269,10 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl others = list(new LambdaQueryWrapper() - .eq(InquiryTicket::getCollabChatId, room) - .ne(InquiryTicket::getWorkOrderNo, no)); - if (others != null) { - for (InquiryTicket other : others) { - if (!"AIR".equalsIgnoreCase(StringUtils.trimToEmpty(other.getBusinessLine()))) { - continue; - } - other.setCollabChatId(""); - other.setUpdateTime(new Date()); - updateById(other); - } - } + unbindOtherTicketsInRoom(room, no); ticket.setCollabChatId(room); + // 品名是普货:激活时就把含电、含磁写成否,群摘要不再显示横杠。 + seedAirGeneralCargoCollab(ticket); ticket.setUpdateTime(new Date()); updateById(ticket); appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "activate_air_group", "agent", room); @@ -1288,9 +1287,79 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl others = list(new LambdaQueryWrapper() + .eq(InquiryTicket::getCollabChatId, chat) + .ne(InquiryTicket::getWorkOrderNo, keep)); + if (others == null) { + return; + } + Date now = new Date(); + for (InquiryTicket other : others) { + other.setCollabChatId(""); + other.setUpdateTime(now); + updateById(other); + } + } + + /** + * 空运品名正好是「普货」时,含电、含磁还空着就写成否。 + *

+ * 已经写了「是」或「否」的不改。摘要里的横杠当成没写。 + * 只改传入工单上的协同 JSON,由调用方落库。海运、陆运不进这里。 + */ + private void seedAirGeneralCargoCollab(InquiryTicket ticket) { + if (ticket == null || !"AIR".equalsIgnoreCase(StringUtils.trimToEmpty(ticket.getBusinessLine()))) { + return; + } + String name = StringUtils.trimToEmpty(ticket.getGoodsName()); + if (!"普货".equals(name)) { + Map facts = parseFactsJson(ticket.getFactsJson()); + name = StringUtils.trimToEmpty(facts.get("品名")); + if (!"普货".equals(name)) { + name = StringUtils.trimToEmpty(facts.get("货物品名")); + } + } + if (!"普货".equals(name)) { + return; + } + JSONObject collab = new JSONObject(true); + if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) { + JSONObject parsed = JSON.parseObject(ticket.getCollabFactsJson()); + if (parsed != null) { + collab.putAll(parsed); + } + } + boolean changed = false; + for (String key : new String[]{"是否含电", "是否含磁"}) { + if (collabBlank(collab.getString(key))) { + collab.put(key, "否"); + changed = true; + } + } + if (changed) { + ticket.setCollabFactsJson(JSON.toJSONString(collab)); + } + } + + /** 协同项没写,或只写了横杠。 */ + private static boolean collabBlank(String raw) { + String val = StringUtils.trimToEmpty(raw); + return val.isEmpty() || "-".equals(val) || "—".equals(val) || "-".equals(val); + } + private static Map parseFactsJson(String raw) { Map facts = new LinkedHashMap<>(); if (StringUtils.isBlank(raw)) { diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupport.java index b90f448..6c46c29 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupport.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupport.java @@ -45,20 +45,20 @@ public final class InquiryTicketDisplaySupport { List quantities = new ArrayList<>(); if ("AIR".equals(businessLine) || containsChinese(lineLabel, "空运")) { addLabel(quantities, "件数", ticket.getPieces()); - addUnit(quantities, "毛重", ticket.getWeightKg(), "kg"); - addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM"); + addUnit(quantities, "重量(KG)", ticket.getWeightKg(), "kg"); + addUnit(quantities, "体积(CBM)", ticket.getVolumeCbm(), "CBM"); } else if ("SEA".equals(businessLine) || containsChinese(lineLabel, "海运")) { addLabel(quantities, "件数", ticket.getPieces()); - addUnit(quantities, "重量", ticket.getWeightKg(), "kg"); - addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM"); + addUnit(quantities, "重量(KG)", ticket.getWeightKg(), "kg"); + addUnit(quantities, "体积(CBM)", ticket.getVolumeCbm(), "CBM"); } else if ("LAND".equals(businessLine) || containsChinese(lineLabel, "陆运")) { - addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM"); - addUnit(quantities, "重量", ticket.getWeightKg(), "kg"); + addUnit(quantities, "体积(CBM)", ticket.getVolumeCbm(), "CBM"); + addUnit(quantities, "重量(KG)", ticket.getWeightKg(), "kg"); addLabel(quantities, "件数", ticket.getPieces()); } else { addLabel(quantities, "件数", ticket.getPieces()); - addUnit(quantities, "重量", ticket.getWeightKg(), "kg"); - addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM"); + addUnit(quantities, "重量(KG)", ticket.getWeightKg(), "kg"); + addUnit(quantities, "体积(CBM)", ticket.getVolumeCbm(), "CBM"); } addLine(lines, join(quantities, " · ")); 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 1621c7f..5735d5f 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 @@ -41,6 +41,16 @@ public final class QuoteTemplateExcelSupport { static { FILL_BY_LABEL.put("询价日期", "quote_date"); FILL_BY_LABEL.put("报价日期", "quote_date"); + // 销售口中的报价日期。货好时间、提货地址不在这里。 + FILL_BY_LABEL.put("走货日期", "quote_date"); + FILL_BY_LABEL.put("询价时间", "quote_date"); + FILL_BY_LABEL.put("走货时间", "quote_date"); + FILL_BY_LABEL.put("发货时间", "quote_date"); + FILL_BY_LABEL.put("发货日期", "quote_date"); + FILL_BY_LABEL.put("出货日期", "quote_date"); + FILL_BY_LABEL.put("出货时间", "quote_date"); + FILL_BY_LABEL.put("提货时间", "quote_date"); + FILL_BY_LABEL.put("提货日期", "quote_date"); FILL_BY_LABEL.put("品名", "commodity"); FILL_BY_LABEL.put("货物数量", "quantity"); FILL_BY_LABEL.put("整柜或拼柜", "fcl_or_lcl"); @@ -70,7 +80,9 @@ public final class QuoteTemplateExcelSupport { FILL_BY_LABEL.put("目的地", "destination"); FILL_BY_LABEL.put("件数", "pieces"); FILL_BY_LABEL.put("重量", "weight_kg"); + FILL_BY_LABEL.put("重量(KG)", "weight_kg"); FILL_BY_LABEL.put("体积", "volume_cbm"); + FILL_BY_LABEL.put("体积(CBM)", "volume_cbm"); FILL_BY_LABEL.put("始发地", "origin"); FILL_BY_LABEL.put("始发站", "origin"); FILL_BY_LABEL.put("目的地", "destination"); @@ -530,8 +542,8 @@ public final class QuoteTemplateExcelSupport { m.put("trade_terms", "贸易条款"); m.put("cargo_ready_date", "货好时间"); m.put("pieces", "件数"); - m.put("weight_kg", "重量"); - m.put("volume_cbm", "体积"); + m.put("weight_kg", "重量(KG)"); + m.put("volume_cbm", "体积(CBM)"); m.put("total_amount", "报价合计"); return m; } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/quote-template-mapping-v1.json b/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/quote-template-mapping-v1.json index 90fd301..a0a7ad3 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/quote-template-mapping-v1.json +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/quote-template-mapping-v1.json @@ -96,8 +96,8 @@ { "code": "trade_terms", "label": "贸易条款", "requiredHint": false }, { "code": "cargo_ready_date", "label": "货好时间", "requiredHint": false }, { "code": "pieces", "label": "件数", "requiredHint": false }, - { "code": "weight_kg", "label": "重量", "requiredHint": false }, - { "code": "volume_cbm", "label": "体积", "requiredHint": false }, + { "code": "weight_kg", "label": "重量(KG)", "requiredHint": false }, + { "code": "volume_cbm", "label": "体积(CBM)", "requiredHint": false }, { "code": "chargeable_weight_kg", "label": "计费重", "requiredHint": false }, { "code": "delivery_address", "label": "派送地址", "requiredHint": false }, { "code": "pickup_address", "label": "提货地址", "requiredHint": false }, @@ -131,6 +131,15 @@ "报价编号": "quote_no", "报价日期": "quote_date", "询价日期": "quote_date", + "走货日期": "quote_date", + "询价时间": "quote_date", + "走货时间": "quote_date", + "发货时间": "quote_date", + "发货日期": "quote_date", + "出货日期": "quote_date", + "出货时间": "quote_date", + "提货时间": "quote_date", + "提货日期": "quote_date", "有效期至": "valid_until", "报价有效期": "valid_until", "币别": "currency", @@ -152,8 +161,10 @@ "件数": "pieces", "件 数": "pieces", "重量": "weight_kg", + "重量(KG)": "weight_kg", "重 量": "weight_kg", "体积": "volume_cbm", + "体积(CBM)": "volume_cbm", "体 积": "volume_cbm", "费用总计": "total_amount", "报价合计": "total_amount", diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupportTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupportTest.java index 29a34d6..21cee3e 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupportTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupportTest.java @@ -30,12 +30,12 @@ public class InquiryTicketDisplaySupportTest { List lines = InquiryTicketDisplaySupport.requirementSummaryLines(ticket); assertEquals("空运 · ZUH → CRK", lines.get(0)); - assertEquals("件数 10 · 毛重 1kg · 体积 8CBM", lines.get(1)); + assertEquals("件数 10 · 重量(KG) 1kg · 体积(CBM) 8CBM", lines.get(1)); assertEquals("品名 茶叶", lines.get(2)); InquiryTicketDisplaySupport.applyRequirementSummary(ticket); assertEquals("空运 · ZUH → CRK", ticket.getDemandTitle()); - assertEquals("件数 10 · 毛重 1kg · 体积 8CBM\n品名 茶叶", ticket.getDemandDetail()); + assertEquals("件数 10 · 重量(KG) 1kg · 体积(CBM) 8CBM\n品名 茶叶", ticket.getDemandDetail()); } @Test 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 6cfc9ff..2b79add 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 @@ -112,6 +112,22 @@ public class QuoteTemplateFillTest { assertTrue(StringUtils.isBlank(out.getString("时效"))); } + @Test + public void buildFillFactsAirGeneralCargoDefaultsBatteryAndMagnet() { + Map facts = new HashMap<>(); + facts.put("品名", "普货"); + facts.put("运输方式", "空运"); + JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl + .buildFillFacts("WO202609230017", facts, new HashMap<>(), "WuJiLin", "AIR"); + assertEquals("否", out.getString("是否含电")); + assertEquals("否", out.getString("是否含磁")); + facts.put("是否含电", "是"); + JSONObject kept = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl + .buildFillFacts("WO202609230017", facts, new HashMap<>(), "WuJiLin", "AIR"); + assertEquals("是", kept.getString("是否含电")); + assertEquals("否", kept.getString("是否含磁")); + } + @Test public void writeFeeDetailColumnsOntoNamedFeeRow() throws Exception { Path src = Files.createTempFile("quote-fee-src", ".xlsx"); diff --git a/inquiry-backend/src/views/inquiry/template/previewSample.ts b/inquiry-backend/src/views/inquiry/template/previewSample.ts index d7cefa5..25aa1b9 100644 --- a/inquiry-backend/src/views/inquiry/template/previewSample.ts +++ b/inquiry-backend/src/views/inquiry/template/previewSample.ts @@ -24,8 +24,8 @@ export const FIELD_LABELS: Record = { trade_terms: "贸易条款", cargo_ready_date: "货好时间", pieces: "件数", - weight_kg: "重量", - volume_cbm: "体积", + weight_kg: "重量(KG)", + volume_cbm: "体积(CBM)", total_amount: "报价合计", chargeable_weight_kg: "计费重", delivery_address: "送货地址", diff --git a/inquiry-backend/src/views/inquiry/workOrder/index.vue b/inquiry-backend/src/views/inquiry/workOrder/index.vue index 23effa1..93be4cc 100644 --- a/inquiry-backend/src/views/inquiry/workOrder/index.vue +++ b/inquiry-backend/src/views/inquiry/workOrder/index.vue @@ -1,4 +1,4 @@ -