diff --git a/inquiry-agent/agent/handlers/air_group_collab.py b/inquiry-agent/agent/handlers/air_group_collab.py index 77d479c..1e748f2 100644 --- a/inquiry-agent/agent/handlers/air_group_collab.py +++ b/inquiry-agent/agent/handlers/air_group_collab.py @@ -205,6 +205,46 @@ def handle_air_group( intent, wo or "-", ) + # #region agent log + try: + import json + import time + from pathlib import Path + + from agent.llm.extract_text import labeled_quote_date + + _payload = { + "sessionId": "717349", + "runId": "post-fix", + "hypothesisId": "H1", + "location": "air_group_collab.py:handle_air_group", + "message": "air group inbound classified", + "data": { + "chat_tail": chat_id[-8:], + "sender_tail": (message.sender_id or "")[-8:], + "intent": intent, + "wo": wo or "", + "text_head": (text or "")[:100], + "labeled_qd": labeled_quote_date(text), + "source": str((message.raw or {}).get("source") or ""), + }, + "timestamp": int(time.time() * 1000), + } + _line = json.dumps(_payload, ensure_ascii=False) + "\n" + for _p in ( + Path(__file__).resolve().parents[3] / "debug-717349.log", + Path(r"C:\Users\ytd_extdev_0903\incoming\debug-717349.log"), + Path(r"E:\wwwroot\agent\current\debug-717349.log"), + ): + try: + _p.parent.mkdir(parents=True, exist_ok=True) + with _p.open("a", encoding="utf-8") as _fh: + _fh.write(_line) + except Exception: + pass + except Exception: + pass + # #endregion if intent in {INTENT_LOCK, INTENT_RELEASE, INTENT_RELEASE_ASK}: return _handle_cabin( engine, @@ -548,6 +588,36 @@ def _handle_bound( keep_tms=intent == INTENT_KEEP_TMS, ) if intent == INTENT_FIELDS: + # #region agent log + try: + import json + import time + from pathlib import Path + + _p = Path(__file__).resolve().parents[3] / "debug-866775.log" + with _p.open("a", encoding="utf-8") as _fh: + _fh.write( + json.dumps( + { + "sessionId": "866775", + "hypothesisId": "C", + "location": "air_group_collab.py:_handle_bound", + "message": "INTENT_FIELDS path", + "data": { + "text_head": (message.content or "")[:80], + "has_quote_date_label": ( + "报价日期" in (message.content or "") + ), + }, + "timestamp": int(time.time() * 1000), + }, + ensure_ascii=False, + ) + + "\n" + ) + except Exception: + pass + # #endregion return apply_air_fields( flow=engine, ticket=ticket, diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py index 6529bc3..d081687 100644 --- a/inquiry-agent/agent/ledger/http_ledger.py +++ b/inquiry-agent/agent/ledger/http_ledger.py @@ -476,44 +476,6 @@ class HttpLedger: body["airline"] = airline.strip().upper() if (flight_no or "").strip(): body["flightNo"] = flight_no.strip().upper() - # #region agent log - try: - import json as _json - import time as _time - from pathlib import Path as _Path - - _line = _json.dumps( - { - "sessionId": "3ba460", - "runId": "pre-fix", - "hypothesisId": "C", - "location": "http_ledger.py:lock_cabin", - "message": "ledger_lock_called", - "data": { - "wo": work_order_no, - "optionNo": option_no, - "airline": (airline or "").strip(), - "flightNo": (flight_no or "").strip(), - "instruction_head": (instruction_text or "")[:80], - }, - "timestamp": int(_time.time() * 1000), - }, - ensure_ascii=False, - ) + "\n" - for _p in ( - _Path(r"d:\own\友通达询价智能体(新)\debug-3ba460.log"), - _Path(r"C:/Users/ytd_extdev_0903/incoming/debug-3ba460.log"), - _Path(r"E:/wwwroot/agent/current/debug-3ba460.log"), - ): - try: - _p.parent.mkdir(parents=True, exist_ok=True) - with _p.open("a", encoding="utf-8") as _fh: - _fh.write(_line) - except Exception: - pass - except Exception: - pass - # #endregion return self._post( "/inquiry/agent/tms/lockCabin", body, diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py index 9ab97b7..6ccc3f7 100644 --- a/inquiry-agent/agent/ledger/memory_ledger.py +++ b/inquiry-agent/agent/ledger/memory_ledger.py @@ -304,12 +304,18 @@ class MemoryLedger: } def update_facts(self, *, work_order_no: str, facts: dict[str, str]) -> dict[str, Any]: - """选定线路后写回起运港等询价事实。""" + """选定线路后写回起运港等询价事实。空值不删已有键,与主账合并语义一致。""" with self._lock: ticket = self._tickets.get(work_order_no) if ticket is None: return {"ok": False, "error": "ticket_not_found"} - ticket.facts = dict(facts) + merged = dict(ticket.facts or {}) + for key, raw in (facts or {}).items(): + name = str(key or "").strip() + val = str(raw or "").strip() + if name and val: + merged[name] = val + ticket.facts = merged return {"ok": True} def void_quote_files(self, *, work_order_no: str) -> dict[str, Any]: diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py index 3a1c54e..f793817 100644 --- a/inquiry-agent/agent/llm/extract_text.py +++ b/inquiry-agent/agent/llm/extract_text.py @@ -46,7 +46,8 @@ _LABEL_KEYS = ( r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|" + _QUOTE_DATE_LABEL_RE + r"|运输分类|车型/数量|车型数量|" - r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|增值服务(非必填)|增值服务|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|" + r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|增值服务(非必填)|增值服务|商品海关编码|海关编码|HS编码|HS|" + r"是否含油|是否含电|是否带电|是否含磁|" r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸" ) _LABEL_LINE = re.compile(rf"^({_LABEL_KEYS})\s*[::]\s*(.+)$") @@ -739,7 +740,11 @@ def _merge_optional_inquiry_labels(text: str, facts: dict[str, str]) -> dict[str def _harvest_labels(text: str, labels: tuple[str, ...]) -> dict[str, str]: - """按标签切原话。同一起点只留最长标签。""" + """ + 按标签切原话。同一起点只留最长标签。 + + 冒号后本行为空视为未填,不跨行吞下一栏(核对卡复制粘贴时空起运地不能变成「目的地:…」)。 + """ raw = text or "" hits: list[tuple[int, str]] = [] for label in labels: @@ -771,8 +776,15 @@ def _harvest_labels(text: str, labels: tuple[str, ...]) -> dict[str, str]: # 增值服务只认冒号后同一行。空行不能把下一行「报价日期(非必填):」收成服务。 if label.startswith("增值服务"): chunk = chunk.split("\n", 1)[0] - chunk = re.sub(r"^[\s::,,、]+", "", chunk) - chunk = re.sub(r"[\s,,、]+$", "", chunk) + same_line = chunk.split("\n", 1)[0] + same_line_body = re.sub(r"^[\s::,,、]+", "", same_line).strip() + # 冒号后本行为空 = 未填。禁止跨行把下一栏「目的地:吉隆坡」收成起运地。 + # 核对卡复制粘贴时非必填常整行只有「起运地(非必填):」,下一行才是必填目的地。 + if not same_line_body: + chunk = "" + else: + chunk = re.sub(r"^[\s::,,、]+", "", chunk) + chunk = re.sub(r"[\s,,、]+$", "", chunk) if chunk: out[label] = chunk return out @@ -834,15 +846,18 @@ def extract_air_collab_fields( injected: Optional[dict[str, str]] = None, ) -> dict[str, str]: """ - 空运群只认是否含电、是否含磁。 + 空运群协同项:是否含电、是否含磁(「是否带电」折成是否含电)。 复用海运口语收割,再滤成这两项。 """ keys = ("是否含电", "是否含磁") if injected: + from agent.schema.field_validate import normalize_facts + + norm = normalize_facts(dict(injected)) return { k: str(v).strip() - for k, v in (injected or {}).items() + for k, v in norm.items() if k in keys and str(v or "").strip() } got = extract_collab_fields(text, allow_b=allow_b) @@ -933,28 +948,38 @@ def _ascii_amount(raw: str) -> str: def _currency_of(text: str, hint: str = "") -> str: """ - 从正文/提示抽三字码。正文没写币种时只回传 hint,禁止默认编造 USD。 + 从正文抽三字码;正文没写币种时才用 hint,禁止默认编造 USD。 + 正文显式币种优先于 baseline:TMS 已是 HKD 时,产品写「1RMB/KG」必须落 CNY, + 禁止把 hint 拼进正文导致 HKD 盖掉 RMB(WO202610140040)。 + RMB 是人民币拼音缩写,统一映射 CNY。 港币必须先于「元」判断,避免「港元」被误判成 CNY。 - WO202610090107:TMS 是 HKD,产品只写「运输费调整为1100」时必须继承 HKD。 + WO202610090107:TMS 是 HKD,产品只写「运输费调整为1100」时继承 HKD。 """ - blob = f"{hint} {text}".upper() - raw = f"{hint} {text}" - if any(x in blob for x in ("HKD", "港币", "港元", "HK$")) or "港币" in raw or "港元" in raw: + text_only = (text or "").upper() + text_raw = text or "" + # 正文显式币种(不含 hint),与「仅继承 TMS」区分开。 + text_has_hkd = any(x in text_only for x in ("HKD", "港币", "港元", "HK$")) or ( + "港币" in text_raw or "港元" in text_raw + ) + text_has_cny = any(x in text_only for x in ("CNY", "RMB", "人民币")) or ( + "元" in text_raw and "港元" not in text_raw and "港币" not in text_raw + ) + # 先看正文,再退回 hint;禁止 hint+正文混检让 baseline HKD 压过正文 RMB。 + if text_has_hkd: return "HKD" - if any(x in blob for x in ("CNY", "RMB", "人民币")) or ( - "元" in raw and "港元" not in raw and "港币" not in raw - ): + if text_has_cny: return "CNY" - if any(x in blob for x in ("USD", "美金", "美元")): + if any(x in text_only for x in ("USD", "美金", "美元")): return "USD" - if any(x in blob for x in ("EUR", "欧元")): + if any(x in text_only for x in ("EUR", "欧元")): return "EUR" - if any(x in blob for x in ("JPY", "日元")): + if any(x in text_only for x in ("JPY", "日元")): return "JPY" - if any(x in blob for x in ("GBP", "英镑")): + if any(x in text_only for x in ("GBP", "英镑")): return "GBP" - return str(hint or "").strip().upper() + hint_u = str(hint or "").strip().upper() + return "CNY" if hint_u == "RMB" else hint_u def _normalize_fee_project(raw: str) -> str: @@ -983,6 +1008,94 @@ def _strip_group_bot_mentions(text: str) -> str: return cleaned.strip() +# 报价日期整段:标签+日期。费用抽取前剥掉,避免把年份 2026 当成金额。 +_RE_QUOTE_DATE_CHUNK = re.compile( + rf"(?:{_QUOTE_DATE_LABEL_RE})(?:(非必填)|\(非必填\))?\s*[::]?\s*" + r"(?:\d{4}\s*[-/.年]\s*\d{1,2}\s*[-/.月]\s*\d{1,2}\s*日?" + r"|\d{1,2}\s*[-/.月]\s*\d{1,2}\s*日?)", + re.I, +) +# 报价有效期(TMS 展示用,不是航线报价格);剥掉避免年份当金额。 +_RE_VALID_UNTIL_CHUNK = re.compile( + r"报价有效期\s*[::]?\s*" + r"(?:\d{4}\s*[-/.年]\s*\d{1,2}\s*[-/.月]\s*\d{1,2}\s*日?" + r"|\d{1,2}\s*[-/.月]\s*\d{1,2}\s*日?)", + re.I, +) +# 时效要求:空运无此字段;剥掉避免「3天」被抽成费用 3。 +_RE_TRANSIT_REQ_CHUNK = re.compile( + r"(?:时效要求|要求运输时效)\s*[::]?\s*" + r"\d+(?:\.\d+)?\s*(?:天|日|工作日|个工作日)?", + re.I, +) + + +def _strip_quote_date_chunks(text: str) -> str: + """去掉「报价日期:2026-10-15」整段,留给费用行抽取。""" + return _RE_QUOTE_DATE_CHUNK.sub(" ", text or "") + + +def _strip_non_fee_meta_chunks(text: str) -> str: + """ + 去掉不能当费用金额的元数据段:报价日期、报价有效期、时效要求。 + + 运行证据(717349):「报价有效期:2026-10-31」抽成 total=2026; + 「时效要求:3天」抽成 total=3。 + """ + out = _strip_quote_date_chunks(text or "") + out = _RE_VALID_UNTIL_CHUNK.sub(" ", out) + out = _RE_TRANSIT_REQ_CHUNK.sub(" ", out) + return out + + +def labeled_quote_date(text: str) -> str: + """ + 群里带标签的报价日期,规范成 yyyy-MM-dd;没有则空串。 + + 给空运协同意图与补字段用。不改主账。 + """ + from agent.schema.field_validate import _quote_date_labeled_value + + return str(_quote_date_labeled_value(text) or "").strip() + + +def is_quote_date_only_message(text: str) -> bool: + """ + 整句只是在补报价日期(可带 @、工单号),没有费用金额。 + + 运行证据:报价日期:2026-10-15 曾被抽成空运费 2026。 + """ + raw = (text or "").strip() + if not raw or not labeled_quote_date(raw): + return False + rest = _strip_group_bot_mentions(_strip_non_fee_meta_chunks(raw)) + rest = re.sub(r"WO\d{12}", "", rest, flags=re.I) + rest = re.sub(r"[\s::,,。、;;@#]+", "", rest) + return not rest.strip() + + +def is_non_fee_meta_only_message(text: str) -> bool: + """ + 整句只是报价日期 / 报价有效期 / 时效要求(可带 @、工单号),没有费用。 + + 这类句不能走航线报价;有效期来自 TMS,时效空运不写工单。 + """ + raw = (text or "").strip() + if not raw: + return False + has_meta = bool( + labeled_quote_date(raw) + or _RE_VALID_UNTIL_CHUNK.search(raw) + or _RE_TRANSIT_REQ_CHUNK.search(raw) + ) + if not has_meta: + return False + rest = _strip_group_bot_mentions(_strip_non_fee_meta_chunks(raw)) + rest = re.sub(r"WO\d{12}", "", rest, flags=re.I) + rest = re.sub(r"[\s::,,。、;;@#]+", "", rest) + return not rest.strip() + + def _split_fee_project_prefix(text: str) -> tuple[str, str]: """ 拆句首分段标签。例:起运港、增值费_板,单价… → (起运港, 增值费_板,单价…) @@ -1111,12 +1224,18 @@ def extract_product_quote( compact = stripped.replace(" ", "").replace(" ", "") if re.fullmatch(r"WO\d{12}", compact, re.I): return {} + # 只补报价日期/有效期/时效要求时直接空:年份、天数不能当费用金额。 + if is_quote_date_only_message(raw) or is_non_fee_meta_only_message(raw): + return {} band = _parse_weight_band_quote(stripped or raw, baseline_currency=base_ccy) if band: return band # 费率句若仍落到普通费用行,会把「燃油附加费」配上句首公斤数。先剥掉再抽。 # 必须用剥掉 @询价小助手 后的正文,否则句首「起运港、」分段标签匹配不到。 - scrubbed = _strip_rate_and_band_noise(stripped or raw) + # 顺带剥掉报价日期/有效期/时效要求,避免年份、天数再抽成金额。 + scrubbed = _strip_rate_and_band_noise( + _strip_non_fee_meta_chunks(stripped or raw) + ) rows = _parse_fee_rows(scrubbed, baseline_currency=base_ccy) if scrubbed.strip() else [] if rows: ccy = rows[0].get("currency") or _currency_of(raw, base_ccy) or base_ccy @@ -1137,7 +1256,9 @@ def extract_product_quote( } out.update(_extract_rate_fields(raw)) return out - hit = _RE_MONEY.search(_ascii_amount(scrubbed or raw)) + # 剥掉报价日期后只剩空白时,禁止回退到原文再抽金额(否则年份 2026 会当空运费)。 + money_src = scrubbed if scrubbed.strip() else "" + hit = _RE_MONEY.search(_ascii_amount(money_src)) if money_src else None if not hit: rates = _extract_rate_fields(raw) return rates if rates else {} diff --git a/inquiry-agent/agent/llm/mode_extract_fields.py b/inquiry-agent/agent/llm/mode_extract_fields.py index b57b4e3..3ea8cdb 100644 --- a/inquiry-agent/agent/llm/mode_extract_fields.py +++ b/inquiry-agent/agent/llm/mode_extract_fields.py @@ -135,8 +135,11 @@ def build_extract_messages(text: str) -> list[dict[str, str]]: "陆运「通关口岸:皇岗」「通关口岸皇岗」「过皇岗」有说就写入通关口岸,没说留空,禁止编造口岸。" "原话单独出现 DAP、DDP、CIF、CIP、FOB、EXW 时写入贸易条款;没出现留空,禁止编造。" "「从南京飞吉隆坡」起运港=南京、目的港=吉隆坡;「北京飞吉隆坡」起运港=北京。" - "「货交上海」「货交:上海」「货交到上海」是提货地址=上海,不是起运港。" - "没有另写起运地、三字码航线或「从哪飞/到」时,起运港必须空串。" + "陆运口语「广州到深圳」「广东省深圳市福田区到香港」→起运港=左侧地名、目的港=右侧地名;" + "不要把左侧写成提货地址,右侧目的地也不能丢。" + "「货交上海」「货交:上海」「货交到上海」才是提货地址=上海,不是起运港。" + "仅空运且只有「货交…」、没有另写起运地/三字码航线/「从哪飞」时,起运港必须空串;" + "陆运「A到B」必须写入起运港和目的港。" "原话是机场三字码或航线就原样记,禁止翻译成城市名:" "SZX-CGK / SZX/CGK / SZX到CGK → 起运港=SZX、目的港=CGK,不要写成深圳、雅加达。" "换城市必须覆盖旧值,不要沿用上一句的港口。" @@ -214,4 +217,35 @@ def invoke_extract_fields( parsed.get("business_line") or "-", sorted((parsed.get("facts") or {}).keys()), ) + # #region agent log + try: + import json + import time + from pathlib import Path + + _facts = dict(parsed.get("facts") or {}) + _log = { + "sessionId": "0593f5", + "runId": "post-fix", + "hypothesisId": "A", + "location": "mode_extract_fields.py:invoke_extract_fields", + "message": "DeepSeek B place fields", + "data": { + "text_head": (text or "")[:80], + "ok": parsed.get("ok"), + "business_line": parsed.get("business_line"), + "land_subtype": parsed.get("land_subtype"), + "起运港": _facts.get("起运港"), + "目的港": _facts.get("目的港"), + "提货地址": _facts.get("提货地址"), + "fact_keys": sorted(_facts.keys()), + }, + "timestamp": int(time.time() * 1000), + } + Path(__file__).resolve().parents[3].joinpath("debug-0593f5.log").open( + "a", encoding="utf-8" + ).write(json.dumps(_log, ensure_ascii=False) + "\n") + except Exception: + pass + # #endregion return parsed diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index cc788b9..05c22ec 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -320,7 +320,10 @@ def send_air_brief_and_sheet( 必须先发出工单信息,模板等摘要发出后再发,禁止后台线程抢先发文件。 填表报价必须带上 TMS 总销售价:群票上的 latest quote 常只有增点费/拆板费明细, 若直接拿去出单,预估费用会落成明细加总(如 200)而不是 salePrice(如 7918.25)。 + 调用方刚写回的询价字段(如报价日期)优先,不能被 hydrate 旧快照盖掉。 """ + incoming_view = ticket_view(ticket) if ticket is not None else {} + incoming_facts = dict(incoming_view.get("facts") or {}) ticket = hydrate_ticket(flow, ticket) view = ticket_view(ticket) no = ticket_no(ticket) @@ -328,6 +331,12 @@ 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 {}) + # 群里刚补的报价日期等:有值就盖到摘要用的 facts 上。 + for key, raw in incoming_facts.items(): + val = str(raw or "").strip() + if key and val: + facts[key] = val + view["facts"] = facts collab = _seed_air_activate_collab(flow, view) view["collab_facts"] = collab view_q = dict(view.get("quote") or {}) @@ -617,11 +626,19 @@ def resolve_cabin_ticket( def apply_air_fields(*, flow: Any, ticket: object, text: str, sender_id: str, chat_id: str) -> str: """ - 补含电/含磁。对齐海运:写入后重发群摘要 + 空运模板,不当一句「已记下」。 + 补含电/含磁,以及报价日期。 + + 含电含磁走协同字段;报价日期写入询价 facts(主账 quote_date 列)。 + 货好时间、贸易条款、客户名称不在群里补(产品口径)。 + 写入后重发群摘要 + 空运模板,不当一句「已记下」。 """ + from agent.llm.extract_text import labeled_quote_date + ticket = hydrate_ticket(flow, ticket) no = ticket_no(ticket) client = group_client_of(flow) + fields = extract_air_collab_fields(text, allow_b=True) + quote_date = labeled_quote_date(text) if not is_air_ticket(ticket): send_group_text(client, chat_id, copy.air_need_work_order()) return "need_work_order" @@ -631,11 +648,23 @@ def apply_air_fields(*, flow: Any, ticket: object, text: str, sender_id: str, ch if not can_fill_fields(flow, ticket, sender_id): send_group_text(client, chat_id, copy.air_fields_denied()) return "fields_denied" - fields = extract_air_collab_fields(text, allow_b=True) - if not fields: + if not fields and not quote_date: 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) + if fields: + flow.ledger.patch_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id) + write_ok = True + if quote_date: + # 只打补丁键:主账侧合并,避免整包 facts 误伤。 + patch = {"报价日期": quote_date} + writer = getattr(flow.ledger, "update_facts", None) + if callable(writer): + try: + out = writer(work_order_no=no, facts=patch) or {} + write_ok = bool(out.get("ok", True)) + except Exception: + write_ok = False + logger.exception("空运群写报价日期失败 wo=%s", no) # 必须回读本单。按群再查会拿到同群更早的陆运/海运旧单(测服 WO202609230007)。 getter = getattr(flow.ledger, "get_ticket", None) reloaded = getter(work_order_no=no) if callable(getter) else None @@ -644,6 +673,10 @@ def apply_air_fields(*, flow: Any, ticket: object, text: str, sender_id: str, ch collab = dict(view.get("collab_facts") or {}) collab.update(fields) view["collab_facts"] = collab + if quote_date: + facts_view = dict(view.get("facts") or {}) + facts_view["报价日期"] = quote_date + view["facts"] = facts_view view["collab_chat_id"] = chat_id send_air_brief_and_sheet(flow=flow, ticket=view, chat_id=chat_id) return "fields_saved" @@ -1012,8 +1045,9 @@ def apply_air_lock( 航线锁舱。只有工单已是「已成交」才打主账。 还没回复成交:点名销售先确认。协商中、未成交各用一句,不催销售改口。 - 舱位编号、航司、航班号没齐时只请补全,不调 TMS 锁舱,不标系统异常。 - 三项齐了(话里写的优先,报价里已有的可以补上)才打主账锁舱。 + 话里写的优先,报价里已有的舱位可以补上;补不上也要打主账—— + 主账会合并历史 TMS / 现拉运价库(产品报价无舱位时不能在智能体侧先挡住)。 + 主账回 lock_params_incomplete 再群里补问,不标系统异常。 上次锁舱失败标过暂停的,航线再锁仍要打 TMS。锁成功不再推成交跟进。 """ if not is_airline(flow, sender_id): diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index 56d6c63..bcab9c5 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -32,6 +32,20 @@ logger = logging.getLogger(__name__) ReplyFn = Callable[..., Optional[str]] +def _ledger_quote_pending_cabin(quote: dict[str, Any]) -> dict[str, Any]: + """ + 未选舱前写主账用的薄快照:保留 airOptions 供恢复,清空总价/费用明细, + 并打 pending_cabin_pick,避免后台把备选舱位当成已报价。 + """ + out = dict(quote or {}) + out["pending_cabin_pick"] = "1" + out["fee_lines"] = [] + out["total"] = "" + out.pop("selected_air_option", None) + out.pop("selectedAirOption", None) + return out + + @dataclass class FlowSession: """一单会话书签(运行账,不是六态真相)。""" @@ -334,10 +348,11 @@ class AirTextInquiryFlow: number = (sess.work_order_no or "").strip() if number and not is_virtual_no(number): return None + hold_before = str(getattr(sess, "sensitive_hold", "") or "") if sess else "" blocked, new_hold, _edits = judge( text=text, facts=facts, - hold=str(getattr(sess, "sensitive_hold", "") or "") if sess else "", + hold=hold_before, ) if sess is not None: sess.sensitive_hold = new_hold @@ -1379,19 +1394,23 @@ class AirTextInquiryFlow: options = parse_air_options(quote) # 没填报价日期:不按起运地收口,TMS 返回的舱位全部给销售点。1 条也要点。 + # 未选定前六态保持询价中,禁止把备选舱位费写进后台「已报价」。 if not str((sess.facts or {}).get("报价日期") or "").strip() and options: quote = dict(quote) quote["airOptions"] = sort_cabin_options(options) - up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价") + ledger_quote = _ledger_quote_pending_cabin(quote) + up = self._ledger.upsert_quote( + work_order_no=no, quote=ledger_quote, to_status="询价中" + ) if not up.get("ok"): - reply("主账写入报价失败,不能当作已报价。") + reply("主账写入舱位备选失败,请稍后重试。") sess.phase = "ledger_fail" self._save(sess) return "ledger_fail" sess.quote = quote copy.remember_tms_quote(sess, quote) sess.tms_quote = dict(quote) - sess.status = "已报价" + sess.status = "询价中" return self._offer_air_routes( sess=sess, work_order_no=no, @@ -1402,6 +1421,9 @@ class AirTextInquiryFlow: stated_origin = str( (sess.facts or {}).get("起运港") or (sess.facts or {}).get("起运地") or "" ).strip() + # 摘要占位「-」等于没写起运地,禁止当地名过滤舱位。 + if stated_origin in {"-", "—", "-"}: + stated_origin = "" # 写了起运地:只认这个起运地的线路,直接报价,不让销售再选。 # 没写起运地:TMS 按目的地回多条时,才进选线页。 if stated_origin: @@ -1425,6 +1447,27 @@ class AirTextInquiryFlow: ) elif options and not needs_route_pick(quote): quote = project_selected_quote(quote, options[0]) + # 仍要选线:未选定前只落备选快照,六态保持询价中。 + if not stated_origin and needs_route_pick(quote): + ledger_quote = _ledger_quote_pending_cabin(quote) + up = self._ledger.upsert_quote( + work_order_no=no, quote=ledger_quote, to_status="询价中" + ) + if not up.get("ok"): + reply("主账写入线路备选失败,请稍后重试。") + sess.phase = "ledger_fail" + self._save(sess) + return "ledger_fail" + sess.quote = quote + copy.remember_tms_quote(sess, quote) + sess.tms_quote = dict(quote) + sess.status = "询价中" + return self._offer_air_routes( + sess=sess, + work_order_no=no, + quote=quote, + reply=reply, + ) up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价") if not up.get("ok"): reply("主账写入报价失败,不能当作已报价。") @@ -1435,13 +1478,6 @@ class AirTextInquiryFlow: copy.remember_tms_quote(sess, quote) sess.tms_quote = dict(quote) sess.status = "已报价" - if not stated_origin and needs_route_pick(quote): - return self._offer_air_routes( - sess=sess, - work_order_no=no, - quote=quote, - reply=reply, - ) sess.phase = "wait_collab" sess.allowed = (copy.BTN_SKIP_COLLAB,) sess.wait_version += 1 diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 51c3261..0e61d7f 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -650,6 +650,42 @@ def ask_clarify( if key == "起运港": continue val = known(src, key) + # #region agent log + if key == "added_services": + try: + import json as _dbg_json + import time as _dbg_time + from pathlib import Path as _DbgPath + + _payload = { + "sessionId": "5ea5b3", + "runId": "post-fix", + "hypothesisId": "H3", + "location": "inquiry_copy.py:ask_clarify", + "message": "clarify display added_services", + "data": { + "raw_facts": str(src.get("added_services") or ""), + "display_val": val, + "label": label, + }, + "timestamp": int(_dbg_time.time() * 1000), + } + for _p in ( + _DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log", + _DbgPath(r"d:/own/友通达询价智能体(新)/debug-5ea5b3.log"), + _DbgPath(r"C:/Users/ytd_extdev_0903/incoming/debug-5ea5b3.log"), + ): + try: + _p.parent.mkdir(parents=True, exist_ok=True) + with _p.open("a", encoding="utf-8") as _f: + _f.write( + _dbg_json.dumps(_payload, ensure_ascii=False) + "\n" + ) + except Exception: + pass + except Exception: + pass + # #endregion if val: lines.append(f"{label}:{val}") lines.append("") @@ -818,6 +854,30 @@ def inquiry_field_lines( ] # 建单确认和空运群:有内容才多一行。没填不出现。核对卡的空行不在这里。 added = (facts.get("added_services") or facts.get("增值服务") or "").strip() + # #region agent log + try: + import json as _dbg_json + import time as _dbg_time + from pathlib import Path as _DbgPath + + _payload = { + "sessionId": "5ea5b3", + "runId": "pre-fix", + "hypothesisId": "H3", + "location": "inquiry_copy.py:air_confirm_lines", + "message": "display added_services value", + "data": { + "added": added, + "facts_keys": sorted(str(k) for k in (facts or {}).keys()), + }, + "timestamp": int(_dbg_time.time() * 1000), + } + _path = _DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log" + with _path.open("a", encoding="utf-8") as _f: + _f.write(_dbg_json.dumps(_payload, ensure_ascii=False) + "\n") + except Exception: + pass + # #endregion if added and added not in {"-", "—", "-"}: lines.insert(-1, f"增值服务:{added}") return lines @@ -3140,7 +3200,7 @@ def air_sales_only_deal() -> str: def air_fields_not_recognized() -> str: - return "听到了。空运群里可补充是否含电、是否含磁。" + return "听到了。空运群里可补充是否含电、是否含磁,或报价日期。" def air_quote_not_recognized() -> str: @@ -3148,7 +3208,7 @@ def air_quote_not_recognized() -> str: def air_fields_denied() -> str: - return "只有这张单的销售或航线可以补含电/含磁。" + return "只有这张单的销售或航线可以补含电/含磁或报价日期。" def air_no_tms_quote_to_lock(work_order_no: str) -> str: @@ -3346,9 +3406,10 @@ def air_text_quote_for_lock_example(work_order_no: str = "") -> str: 需要锁舱时,请航线用纯文字报价的示例句。 附件报价不拆金额、常锁不成;产品口径改为引导航线改发文字价。 + 示例必须带计费单位(空运常见 KG),避免航线漏写单位导致文字价无法落库。 """ no = (work_order_no or "").strip() or "工单号" - return f"{no},空运段,空运费 单价CNY99.00,数量100 ,金额CNY9900.00@询价小助手" + return f"{no},空运段,KG,空运费 单价CNY99.00,数量100 ,金额CNY9900.00@询价小助手" def air_attachment_quote_lock_tip(*, work_order_no: str) -> str: diff --git a/inquiry-agent/agent/policy/land_text_flow.py b/inquiry-agent/agent/policy/land_text_flow.py index c6b1553..11805e5 100644 --- a/inquiry-agent/agent/policy/land_text_flow.py +++ b/inquiry-agent/agent/policy/land_text_flow.py @@ -138,6 +138,39 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): sess.work_order_no or "-", ) sess = None + # #region agent log + try: + import json as _json + import time as _time + from pathlib import Path as _Path + + with _Path(r"d:\own\友通达询价智能体(新)\debug-d331ea.log").open("a", encoding="utf-8") as _dbg: + _dbg.write( + _json.dumps( + { + "sessionId": "d331ea", + "runId": "post-fix", + "hypothesisId": "D", + "location": "land_text_flow.py:on_text:sess_keep", + "message": "land sess keep-or-clear", + "data": { + "phase_in": phase, + "kept_sess": sess is not None, + "in_front": phase in _LAND_FRONT, + "sensitive_hold": ( + str(getattr(sess, "sensitive_hold", "") or "")[:120] if sess else "" + ), + "text": (text or "")[:120], + }, + "timestamp": int(_time.time() * 1000), + }, + ensure_ascii=False, + ) + + "\n" + ) + except Exception: + pass + # #endregion snap = extract_inquiry_snapshot( text, diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index 3013b1a..eb0b324 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -931,12 +931,13 @@ def apply_product_quote( sess.collab_facts = merged line = str(view.get("business_line") or view.get("businessLine") or "").strip().upper() base = _current_quote(view, sess) + baseline_ccy = _quote_currency_hint(base) quote = extract_product_quote( text, injected=injected_quote, business_line=line, transport_mode="陆运" if line == "LAND" else ("海运" if line == "SEA" else ""), - baseline_currency=_quote_currency_hint(base), + baseline_currency=baseline_ccy, ) if not quote: logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200]) @@ -1644,9 +1645,13 @@ def _merged_total( 总销售价:有 TMS 原合计就按改价差额加减,不能只加产品改的那几行。 美元行已含在 TMS 人民币合计里,不擅自换汇;只调整和合计同币种的差额。 + 产品正文已点币种(如 RMB→CNY)时,表头跟产品,禁止 TMS HKD 再盖回去(WO040)。 """ src = dict(base or {}) - header_ccy = str(src.get("currency") or incoming.get("currency") or "").strip() + # 产品显式币种优先:否则「1RMB/KG」合并后表头仍停在 TMS HKD。 + header_ccy = str(incoming.get("currency") or src.get("currency") or "").strip() + if header_ccy.upper() == "RMB": + header_ccy = "CNY" base_total = _parse_fee_amount(src.get("total")) if base_total is not None: total_ccy = (header_ccy or "CNY").upper() diff --git a/inquiry-agent/agent/policy/sensitive_cargo.py b/inquiry-agent/agent/policy/sensitive_cargo.py index ac778da..55da939 100644 --- a/inquiry-agent/agent/policy/sensitive_cargo.py +++ b/inquiry-agent/agent/policy/sensitive_cargo.py @@ -103,6 +103,41 @@ _WRITE_KEYS = { # 建单前要看的当前字段。没写进这几项、但原话里出现过的,靠 sensitive_hold 记住。 _SCAN_KEYS = ("品名", "货物品名", "起运港", "起运地", "始发站", "备注") +# 新开一票的说法。命中且本句没有违禁词时,丢掉上一句没改口的 hold。 +_FRESH_INQUIRY = re.compile( + r"(查一下|帮我查|询一下|询个价|询价|请报价|报一下价|报个价|费用)" +) +# 只补量尺、件数、包装:不算新询价,旧 hold 继续有效。 +_MEASURE_SUPPLEMENT = re.compile( + r"(?:" + r"重量|毛重|体积|件数|货量|包装(?:方式|类型)?" + r")\s*[::]?\s*\d" + r"|\d+\s*(?:kgs?|kg|公斤|千克|吨|t|cbm|方|件|箱|托)", + re.I, +) + + +def looks_like_fresh_inquiry(text: str) -> bool: + """ + 当前句是不是另开一票,而不是接着补上一票的字段。 + + 「查一下目的地吉隆坡的费用」算新开;「重量100KGS」不算。 + 用来在本句已无违禁词时清掉 sticky hold,避免正常询价被上一句炸弹拖死。 + """ + body = (text or "").strip() + if not body: + return False + if _MEASURE_SUPPLEMENT.search(body) and not _FRESH_INQUIRY.search(body): + return False + return bool(_FRESH_INQUIRY.search(body)) + + +def scrub_blocked_scan_fields(facts: dict[str, Any]) -> None: + """新开一票时,把品名/起运/备注里仍带着违禁词的旧值清掉,避免 field_hit 再拦。""" + for key in _SCAN_KEYS: + if contains_blocked(str((facts or {}).get(key) or "")): + facts[key] = "" + def contains_blocked(text: str) -> bool: """ @@ -203,6 +238,7 @@ def judge(*, text: str, facts: dict[str, Any], hold: str) -> tuple[bool, str]: 当前句已经把品名说成普通货(运送衣服、改成衣服、换成衣服):前面的违禁原话作废。 同一句里先提到旧词再改口,按改口后的品名放行。 只补重量、件数:旧原话继续有效。 + 本句无违禁词且像另开一票(查一下…费用):旧 hold 作废,并清掉字段里残留的违禁词。 品名改干净之后,起运地或备注里还留着违禁词,仍然拦截。 会就地更新 facts 里本句写明的品名、起运地、备注。 第三个返回值是本句写下的键,多段用来写回每一段,避免把别的字段盖掉。 @@ -210,14 +246,23 @@ def judge(*, text: str, facts: dict[str, Any], hold: str) -> tuple[bool, str]: edits = apply_field_edits(text, facts) replaced = bool(spoken_cargo(text)) or is_cargo_rename(text) message_hit = contains_blocked(text) + fresh = False if replaced and not contains_blocked(str(facts.get("品名") or "")): # 已经说出新的普通货品名。旧词留在这句里只是在改口,不再记住。 new_hold = "" message_hit = False elif message_hit: new_hold = (text or "").strip() or (hold or "") + elif hold and looks_like_fresh_inquiry(text): + # 本句没有违禁词,且是另开一票(查一下…费用),上一句违禁原话作废。 + # 只补重量/件数不走这里,旧 hold 继续拦。 + fresh = True + new_hold = "" + scrub_blocked_scan_fields(facts) else: new_hold = hold or "" field_text = "\n".join(str((facts or {}).get(key) or "") for key in _SCAN_KEYS) - blocked = message_hit or contains_blocked(new_hold) or contains_blocked(field_text) + hold_hit = contains_blocked(new_hold) + field_hit = contains_blocked(field_text) + blocked = message_hit or hold_hit or field_hit return blocked, new_hold, edits diff --git a/inquiry-agent/agent/routing/air_group_intent.py b/inquiry-agent/agent/routing/air_group_intent.py index f074524..67af5ee 100644 --- a/inquiry-agent/agent/routing/air_group_intent.py +++ b/inquiry-agent/agent/routing/air_group_intent.py @@ -7,7 +7,10 @@ from __future__ import annotations +import json import logging +import time +from pathlib import Path from typing import Optional from agent.policy import inquiry_copy as copy @@ -32,6 +35,7 @@ from agent.routing.group_intent import ( logger = logging.getLogger(__name__) + INTENT_ACTIVATE = "air_activate" INTENT_LOCK = "air_lock" INTENT_RELEASE = "air_release" @@ -131,18 +135,50 @@ def classify_air_group_text( if use_net and getattr(settings, "llm_data_usage_confirmed", False): got = _classify_with_model(raw, settings) if got in _VALID: - return _prefer_staff_quote(got, raw) + preferred = _prefer_staff_quote(got, raw) + from agent.llm.extract_text import ( + extract_product_quote, + is_non_fee_meta_only_message, + is_quote_date_only_message, + labeled_quote_date, + ) + + # 模型若把「报价日期/有效期/时效」判成报价:有日期/含电含磁回补字段,否则 other。 + if preferred == INTENT_QUOTE and ( + is_quote_date_only_message(raw) + or is_non_fee_meta_only_message(raw) + or (labeled_quote_date(raw) and not extract_product_quote(raw)) + ): + if labeled_quote_date(raw) or ( + "是否含电" in raw or "是否含磁" in raw or "是否带电" in raw + ): + preferred = INTENT_FIELDS + elif is_non_fee_meta_only_message(raw): + preferred = INTENT_OTHER + else: + preferred = INTENT_FIELDS + return preferred except Exception: logger.exception("空运群意图模型失败,回退规则") - from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote + from agent.llm.extract_text import ( + extract_air_collab_fields, + extract_product_quote, + is_non_fee_meta_only_message, + is_quote_date_only_message, + labeled_quote_date, + ) from agent.schema.quote_adjust import extract_adjust # 费用金额先当航线报价。「空运费改为900」不是销售调价。 + # 报价有效期/时效要求整句不是报价。 + if is_non_fee_meta_only_message(raw) and not labeled_quote_date(raw): + return INTENT_OTHER if text_is_staff_fee_quote(raw): return INTENT_QUOTE if extract_adjust(raw).is_adjust: return INTENT_ADJUST - if extract_product_quote(raw): + quote_hit = extract_product_quote(raw) + if quote_hit: return INTENT_QUOTE confirmed = _hint_confirm_tms(raw) if confirmed: @@ -153,7 +189,9 @@ def classify_air_group_text( closed = _hint_deal_outcome(raw) if closed: return closed - if extract_air_collab_fields(raw): + air_fields = extract_air_collab_fields(raw) + quote_date = labeled_quote_date(raw) + if air_fields or quote_date: return INTENT_FIELDS if _looks_like_option(raw): return INTENT_PICK_OPTION @@ -244,7 +282,8 @@ def _classify_with_model(text: str, settings) -> str: "说出释放舱位、释放舱、把舱位释放了时不要回 air_release;" "air_activate=只是指定/切换工单号;" "air_pick_option=在补充或选择舱位编号、航司、航班号,写法不限;" - "collab_fields=在补是否含电/是否含磁;" + "collab_fields=在补是否含电/是否含磁/是否带电,或补报价日期;" + "只有报价有效期或时效要求、没有费用金额时回 other,不要回 product_quote;" "quote_adjust=销售在按比例改已有费用(上调、上浮、加10%、减5%、加一成),或只改总价/总销售价;" "product_quote=航线在报某一项费用的金额。写出「空运费9900」「空运费改为900USD」是报价,不是调价;" "quote_confirm_tms=确认沿用当前TMS报价;" @@ -264,5 +303,7 @@ def _classify_with_model(text: str, settings) -> str: {"role": "user", "content": text[:500]}, ], ) - got = str((data or {}).get("content") or "").strip().split()[0] if data else "" + # chat 返回 LlmHttpResult,不是 dict。 + content = str(getattr(data, "content", "") or "").strip() + got = content.split()[0] if content else "" return got if got in _VALID else "" diff --git a/inquiry-agent/agent/schema/added_services.py b/inquiry-agent/agent/schema/added_services.py index 61f5154..f8d3e26 100644 --- a/inquiry-agent/agent/schema/added_services.py +++ b/inquiry-agent/agent/schema/added_services.py @@ -3,6 +3,7 @@ 本文件职责:把销售原话收成多项 added_services,供确认、工单和 TMS 的 addedServices 使用。 12 个服务词只要完整出现就记;名单外的必须写在「增值服务」后面才记,并保留原词。 +服务词后面紧跟的「+数字」(如 TC +1.5、托盘+1)并进同一项,写成 TC+1.5、托盘+1。 包装类型/包装方式这一栏里的「托盘」只算包装,不抄进增值服务。 「增值服务:」同一行为空时,不把下一行收进来,也不沿用上一句已经记下的服务。 不挡查价。不改六态。海运、陆运不走这里。 @@ -30,6 +31,8 @@ _CN_RE = re.compile("|".join(re.escape(word) for word in _CN_TERMS)) _MUST_GO_RE = re.compile(r"(?i)MUST\s*GO") _DG_RE = re.compile(r"(?i)(? list[str]: 补问示例行「(如:托盘、…)」整行不看,避免清单被当成已填。 「不带电」不算带电;「卡转低消」不算再加一项卡转。 + 「TC +1.5」记成 TC+1.5,数量跟服务名同一项。 副作用:无。 """ raw = _without_clarify_examples(text or "") events: list[tuple[int, str]] = [] # 包装栏里的托盘已在 _canonical_events 排除,这里只收正文里真正的服务词。 - events.extend(_canonical_events(raw)) + events.extend((start, word) for start, _end, word in _canonical_events(raw)) for match in _LABEL_RE.finditer(raw): value = (match.group(1) or "").strip() if not value or value.startswith("(如") or value.startswith("(如"): @@ -100,7 +104,52 @@ def harvest_added_services(text: str) -> list[str]: if extra: events.append((base + at + max(piece.find(extra), 0), extra)) events.sort(key=lambda item: item[0]) - return _dedupe([word for _pos, word in events]) + result = _dedupe([word for _pos, word in events]) + # #region agent log + try: + import json as _dbg_json + import time as _dbg_time + from pathlib import Path as _DbgPath + + _tails: list[dict[str, str]] = [] + for _m in list(_TC_RE.finditer(raw)) + list(_CN_RE.finditer(raw)): + if _m.group(0) not in {"TC", "tc", "托盘"} and _m.group(0).casefold() != "tc": + continue + _end = _m.end() + _tails.append( + { + "word": _m.group(0), + "after": raw[_end : _end + 12], + } + ) + _payload = { + "sessionId": "5ea5b3", + "runId": "post-fix", + "hypothesisId": "H1", + "location": "added_services.py:harvest_added_services", + "message": "harvest result vs trailing modifiers", + "data": { + "text_preview": (raw or "")[:120], + "found": result, + "match_tails": _tails, + }, + "timestamp": int(_dbg_time.time() * 1000), + } + for _path in ( + _DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log", + _DbgPath(r"C:/Users/ytd_extdev_0903/incoming/debug-5ea5b3.log"), + _DbgPath(r"d:/own/友通达询价智能体(新)/debug-5ea5b3.log"), + ): + try: + _path.parent.mkdir(parents=True, exist_ok=True) + with _path.open("a", encoding="utf-8") as _f: + _f.write(_dbg_json.dumps(_payload, ensure_ascii=False) + "\n") + except Exception: + pass + except Exception: + pass + # #endregion + return result def apply_air_value_added( @@ -115,6 +164,7 @@ def apply_air_value_added( 只应在空运调用。副作用:返回新字典,不改入参。 """ merged = dict(facts or {}) + before_added = str(merged.get("added_services") or "") found = harvest_added_services(text) labeled = _labeled_added_value(text) # 销售把「增值服务:」留空:这一栏就是空的。 @@ -122,23 +172,81 @@ def apply_air_value_added( # 本句没写这一栏、也没提到服务时,才保留已经记下的旧值。 if labeled == "": items = found + branch = "labeled_empty" elif found: items = _dedupe(split_added_services(previous) + found) + branch = "found_merge" else: items = _dedupe( split_added_services(previous) + split_added_services(str(merged.get("added_services") or "")) ) + branch = "keep_previous" if items: merged["added_services"] = "、".join(items) elif not str(merged.get("added_services") or "").strip(): merged.pop("added_services", None) _keep_stated_package(text, merged) + # #region agent log + try: + import json as _dbg_json + import time as _dbg_time + from pathlib import Path as _DbgPath + + _payload = { + "sessionId": "5ea5b3", + "runId": "post-fix", + "hypothesisId": "H2", + "location": "added_services.py:apply_air_value_added", + "message": "apply merge before/after", + "data": { + "text_preview": (text or "")[:120], + "before_added": before_added, + "found": found, + "previous": previous, + "labeled": labeled, + "branch": branch, + "after_added": str(merged.get("added_services") or ""), + "pack": str(merged.get("包装方式") or ""), + }, + "timestamp": int(_dbg_time.time() * 1000), + } + for _path in ( + _DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log", + _DbgPath(r"C:/Users/ytd_extdev_0903/incoming/debug-5ea5b3.log"), + _DbgPath(r"d:/own/友通达询价智能体(新)/debug-5ea5b3.log"), + ): + try: + _path.parent.mkdir(parents=True, exist_ok=True) + with _path.open("a", encoding="utf-8") as _f: + _f.write(_dbg_json.dumps(_payload, ensure_ascii=False) + "\n") + except Exception: + pass + except Exception: + pass + # #endregion return merged -def _canonical_events(raw: str) -> list[tuple[int, str]]: - """按原文位置列出 12 个标准词。重叠区间只留更长的那一项。""" +def _attach_qty_suffix(raw: str, start: int, end: int, word: str) -> tuple[int, int, str]: + """ + 服务词后面若有 +数字,并进同一项。 + + 例:TC +1.5 → (起点, 终点, "TC+1.5")。没有数量则原样返回。 + 为何:销售口播常写「TC +1.5、托盘 +1」,只记服务名会丢数量。 + """ + match = _QTY_SUFFIX_RE.match(raw or "", end) + if not match: + return start, end, word + return start, match.end(), f"{word}+{match.group(1)}" + + +def _canonical_events(raw: str) -> list[tuple[int, int, str]]: + """ + 按原文位置列出 12 个标准词(可带 +数量)。 + + 返回 (起点, 终点, 展示词)。重叠区间只留更长的那一项。 + """ found: list[tuple[int, int, str]] = [] package_pallets = _package_pallet_spans(raw) for match in _CN_RE.finditer(raw): @@ -147,21 +255,21 @@ def _canonical_events(raw: str) -> list[tuple[int, str]]: continue if word == "托盘" and _span_inside(match.start(), match.end(), package_pallets): continue - found.append((match.start(), match.end(), word)) + found.append(_attach_qty_suffix(raw, match.start(), match.end(), word)) for match in _MUST_GO_RE.finditer(raw): - found.append((match.start(), match.end(), "MUST GO")) + found.append(_attach_qty_suffix(raw, match.start(), match.end(), "MUST GO")) for match in _DG_RE.finditer(raw): - found.append((match.start(), match.end(), "DG")) + found.append(_attach_qty_suffix(raw, match.start(), match.end(), "DG")) for match in _TC_RE.finditer(raw): - found.append((match.start(), match.end(), "TC")) + found.append(_attach_qty_suffix(raw, match.start(), match.end(), "TC")) found.sort(key=lambda item: (item[0], -(item[1] - item[0]))) accepted: list[tuple[int, int]] = [] - events: list[tuple[int, str]] = [] + events: list[tuple[int, int, str]] = [] for start, end, word in found: if any(not (end <= left or start >= right) for left, right in accepted): continue accepted.append((start, end)) - events.append((start, word)) + events.append((start, end, word)) return events @@ -171,31 +279,19 @@ def _outside_list_text(piece: str) -> str: 「托盘」本身会被标准词扫到,这里不重复收。 「打木架和带电」只把打木架留下来,带电仍由标准词按出现位置记。 + 带 +数量 的标准词整段擦掉,避免残留「+1.5」再当成名单外服务。 """ spans = _canonical_events(piece) if not spans: return piece.strip() chars = list(piece) - for start, word in spans: - # _canonical_events 只有起点。用标准词长度擦掉;MUST GO 用原文长度。 - end = start + _span_len(piece, start, word) + for start, end, _word in spans: for index in range(start, min(end, len(chars))): chars[index] = " " rest = _FILLER.sub("", "".join(chars)) return rest.strip() -def _span_len(piece: str, start: int, word: str) -> int: - """标准词在原文里占几位。MUST GO 中间空格可有可无。""" - if word == "MUST GO": - match = _MUST_GO_RE.match(piece, start) - if match: - return match.end() - match.start() - if word in {"DG", "TC"}: - return 2 - return len(word) - - def _labeled_added_value(text: str) -> str | None: """ 原话里有没有单独的「增值服务」栏。 diff --git a/inquiry-agent/agent/schema/air_route_options.py b/inquiry-agent/agent/schema/air_route_options.py index 2838819..7b531c3 100644 --- a/inquiry-agent/agent/schema/air_route_options.py +++ b/inquiry-agent/agent/schema/air_route_options.py @@ -432,6 +432,8 @@ def project_selected_quote(quote: dict[str, Any] | None, option: dict[str, Any] base["valid_until"] = src.get("validUntil") base["selected_air_option"] = src base["route_line"] = route_line(src, {}) + # 选定后不再是待选舱快照。 + base.pop("pending_cabin_pick", None) return base diff --git a/inquiry-agent/agent/schema/field_validate.py b/inquiry-agent/agent/schema/field_validate.py index e0619f1..fb82a2d 100644 --- a/inquiry-agent/agent/schema/field_validate.py +++ b/inquiry-agent/agent/schema/field_validate.py @@ -76,7 +76,8 @@ LAND_DISPLAY = { # 协同补充字段:拉群后可静默写入,不挡首次查价。内部键仍是 HS编码。 SEA_COLLAB_FIELDS = ("货好时间", "HS编码", "是否含油", "是否含电", "是否含磁") -# 空运群只补含电/含磁,摘要不要把海运协同五项搬过去。 +# 空运群只补含电/含磁(及报价日期),摘要不要把海运协同五项搬过去。 +# 货好时间、贸易条款、客户名称不在群里补(产品口径)。 AIR_COLLAB_FIELDS = ("是否含电", "是否含磁") # 群摘要对外展示名。内部继续用 HS编码,避免改合同键。 SEA_COLLAB_DISPLAY = { @@ -222,6 +223,10 @@ _ALIASES = { "海关编码": "HS编码", "客户名称": "客户名称", "货值": "货值", + # 模板/口语常写「是否带电」,合同内部键仍是是否含电。 + "是否带电": "是否含电", + "是否含电": "是否含电", + "是否含磁": "是否含磁", "是否为危险品": "是否为危险品", "通关口岸": "通关口岸", "通关口岸(非必填)": "通关口岸", @@ -253,11 +258,15 @@ def shanghai_today() -> str: return datetime.now(_SHANGHAI).date().isoformat() +# 销售卡片/摘要占位横杠:等于没写,禁止落进 facts 再当起运地出站(运行证据 WO202610150086)。 +_PLACEHOLDER_DASHES = frozenset({"-", "—", "-"}) + + def normalize_facts(facts: dict[str, Any] | None) -> dict[str, str]: """ 把展示名/别名折成合同内部键,去掉首尾空白。 - 空值丢弃;不解释语义。 + 空值与摘要占位「-」丢弃;不解释语义。 """ out: dict[str, str] = {} for raw_key, raw_val in (facts or {}).items(): @@ -268,7 +277,7 @@ def normalize_facts(facts: dict[str, Any] | None) -> dict[str, str]: if raw_val is None: continue val = str(raw_val).strip() - if not val: + if not val or val in _PLACEHOLDER_DASHES: continue out[key] = val _fold_land_vehicle_qty(out) @@ -527,9 +536,16 @@ def harvest_oral_collab(text: str) -> dict[str, str]: 只认原话里已经说死的词(不含电、随时可提、普货),不猜贸易条款和海关编码。 「(如:随时可提)」这种补问示例行不算销售说了货好时间。 危险品:否词(不是危险品/非危/普货)先于是词,避免「不是危险品」收成是。 + 「是否带电:否」里的「带电」不能当成肯定(运行证据 session 717349)。 """ raw = _without_clarify_examples(text or "") out: dict[str, str] = {} + # 先剥掉「是否带电/含电/含磁:是|否」标签行,再口语匹配,避免标签里的「带电」误判成是。 + oral_src = re.sub( + r"是否(?:带电|含电|含磁|含油)\s*[::]\s*[是否]", + " ", + raw, + ) yes_no = ( ("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")), ("是否含油", ("不含油", "不带油", "无油"), ("含油", "带油")), @@ -541,9 +557,9 @@ 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): + if any(w in oral_src for w in no_words): out[key] = "否" - elif any(w in raw for w in yes_words): + elif any(w in oral_src for w in yes_words): out[key] = "是" ready = ("随时可提", "货好随时", "随时货好", "货已好", "已经货好") for word in ready: diff --git a/inquiry-agent/agent/schema/tms_air_query.py b/inquiry-agent/agent/schema/tms_air_query.py index 919a5b0..01f70c1 100644 --- a/inquiry-agent/agent/schema/tms_air_query.py +++ b/inquiry-agent/agent/schema/tms_air_query.py @@ -202,15 +202,27 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]: 包装只看销售是否说了,散货等原文原样保留。 """ src = dict(facts or {}) + # 摘要占位「-」当空:不能拿去 resolve 机场,否则 TMS 回 1003 起飞机场无法匹配。 + _dash = frozenset({"-", "—", "-"}) origin = str(src.get("起运港") or src.get("起运地") or "").strip() + if origin in _dash: + origin = "" dest = str(src.get("目的港") or "").strip() + if dest in _dash: + dest = "" cargo = str(src.get("品名") or "").strip() + if cargo in _dash: + cargo = "" pieces = _first_number(str(src.get("件数") or "")) weight = _first_number(str(src.get("毛重") or "")) volume = volume_cbm_number(str(src.get("体积") or "")) pack_raw = str(src.get("包装方式") or src.get("包装类型") or "").strip() + if pack_raw in _dash: + pack_raw = "" pack_code = resolve_package_type(pack_raw) quote_date = str(src.get("报价日期") or "").strip() + if quote_date in _dash: + quote_date = "" # 三字码必须按当前地名重算,禁止沿用上一句的 NKG/KUL origin_code = resolve_airport_code(origin) dest_code = resolve_airport_code(dest) @@ -240,6 +252,10 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]: ok = not missing out_facts = dict(src) + # 横杠占位从出站 facts 清掉,避免建单把「-」写入起运港列。 + for _k in ("起运港", "起运地"): + if str(out_facts.get(_k) or "").strip() in _dash: + out_facts.pop(_k, None) if origin: out_facts["起运港"] = origin if origin_code: @@ -247,6 +263,8 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]: else: out_facts.pop("originCode", None) else: + out_facts.pop("起运港", None) + out_facts.pop("起运地", None) out_facts.pop("originCode", None) if dest: out_facts["目的港"] = dest diff --git a/inquiry-agent/tests/test_air_added_services.py b/inquiry-agent/tests/test_air_added_services.py index 12d45a6..2f5df51 100644 --- a/inquiry-agent/tests/test_air_added_services.py +++ b/inquiry-agent/tests/test_air_added_services.py @@ -51,6 +51,16 @@ class AirAddedServicesTests(unittest.TestCase): self.assertEqual(comma, ["托盘", "前置仓", "带电"]) spoken = harvest_added_services("这票 must go,要dg和tc") self.assertEqual(spoken, ["MUST GO", "DG", "TC"]) + # 服务名后的 +数量要跟进同一项,不能只剩 TC、托盘。 + self.assertEqual( + harvest_added_services("查一下香港到吉隆坡书籍TC +1.5、托盘 +1的报价"), + ["TC+1.5", "托盘+1"], + ) + self.assertEqual( + harvest_added_services("增值服务:TC+1.5、托盘 +1"), + ["TC+1.5", "托盘+1"], + ) + def test_longest_match_and_negation(self) -> None: self.assertEqual(harvest_added_services("要卡转低消"), ["卡转低消"]) diff --git a/inquiry-agent/tests/test_land_group_collab.py b/inquiry-agent/tests/test_land_group_collab.py index 13b30a0..2425770 100644 --- a/inquiry-agent/tests/test_land_group_collab.py +++ b/inquiry-agent/tests/test_land_group_collab.py @@ -369,11 +369,41 @@ class LandGroupCollabTests(unittest.TestCase): self.assertTrue(rows) self.assertEqual(str(rows[0].get("name") or ""), "陆运费") self.assertEqual(str(land.get("total") or ""), "1") + self.assertEqual(str(land.get("currency") or ""), "CNY") sea = extract_product_quote("价格为1RMB/KG", business_line="SEA") self.assertEqual(str((sea.get("fee_rows") or [{}])[0].get("name") or ""), "海运费") air = extract_product_quote("价格为1RMB/KG", business_line="AIR") self.assertEqual(str((air.get("fee_rows") or [{}])[0].get("name") or ""), "空运费") + def test_land_rmb_overrides_tms_hkd_baseline(self) -> None: + """WO040:TMS 已是 HKD 时,产品写「价格为1RMB/KG」必须落 CNY,禁止 hint 盖成 HKD。""" + from agent.llm.extract_text import extract_product_quote + from agent.policy.sea_group_ops import _quote_currency_hint, merge_quote_fees + + tms = { + "currency": "HKD", + "cost": "HKD 1000", + "total": "1000", + "source": "TMS", + "fee_lines": [ + {"name": "运输费", "amount": "1000", "currency": "HKD"}, + ], + } + self.assertEqual(_quote_currency_hint(tms), "HKD") + quote = extract_product_quote( + "价格为1RMB/KG", + business_line="LAND", + baseline_currency=_quote_currency_hint(tms), + ) + self.assertEqual(str(quote.get("currency") or ""), "CNY") + self.assertEqual(str((quote.get("fee_rows") or [{}])[0].get("currency") or ""), "CNY") + merged = merge_quote_fees(tms, quote) + self.assertEqual(str(merged.get("currency") or ""), "CNY") + self.assertEqual( + str((merged.get("fee_rows") or [{}])[-1].get("currency") or ""), + "CNY", + ) + def test_land_adjust_without_currency_inherits_tms_hkd(self) -> None: """WO202610090107:TMS 港币,产品只写「运输费调整为1100」时继承 HKD,禁止默认 USD。""" from agent.llm.extract_text import extract_product_quote diff --git a/inquiry-agent/tests/test_sensitive_cargo.py b/inquiry-agent/tests/test_sensitive_cargo.py index e7379b2..9dd394f 100644 --- a/inquiry-agent/tests/test_sensitive_cargo.py +++ b/inquiry-agent/tests/test_sensitive_cargo.py @@ -68,6 +68,19 @@ class JudgeTests(unittest.TestCase): self.assertEqual(hold3, "") self.assertEqual(edits.get("品名"), "服装") + def test_fresh_inquiry_clears_hold_without_cargo_rename(self) -> None: + """问过炸弹被拦后,再查吉隆坡费用应放行;不能继续套旧 hold。""" + hold = "查一下广西到胡志明炸弹的费用" + facts = {"品名": "炸弹", "起运港": "广西"} + blocked, new_hold, _edits = judge( + text="查一下目的地吉隆坡的费用", + facts=facts, + hold=hold, + ) + self.assertFalse(blocked) + self.assertEqual(new_hold, "") + self.assertFalse(contains_blocked(str(facts.get("品名") or ""))) + def test_rename_sentence_that_still_says_the_word_stays_blocked(self) -> None: blocked, hold, _edits = judge(text="不是炸弹,品名服装", facts={"品名": "服装"}, hold="") self.assertTrue(blocked) diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryQuoteTemplateController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryQuoteTemplateController.java index 47075c7..7a3cd97 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryQuoteTemplateController.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryQuoteTemplateController.java @@ -13,6 +13,7 @@ import org.jeecg.common.system.vo.LoginUser; import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplate; import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplateVersion; import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService; +import org.jeecg.modules.inquiry.support.InquiryQuoteTemplateBizScope; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; @@ -32,7 +33,8 @@ import java.util.Map; * 报价模板管理 API。 *

* 路径 /inquiry/quoteTemplate;权限 inquiry:template:*。 - * 智能体只读列表走 /agent/listEnabled(服务账号 token)。 + * 有菜单权限只表示能进页面。列表按角色业务范围收窄:陆运中港只看陆运模板。 + * 智能体只读列表走 /agent/listEnabled(服务账号 token),不按登录人的角色过滤。 * 禁止:在本 Controller 调 LLM;LibreOffice 仅允许预览截图接口短等本机 soffice。 */ @Slf4j @@ -48,12 +50,22 @@ public class InquiryQuoteTemplateController { @ApiOperation("分页列表(含当前版本摘要)") @RequiresPermissions("inquiry:template:view") @GetMapping("/list") - public Result>> list(@RequestParam(required = false) String keyword, - @RequestParam(required = false) String bizType, - @RequestParam(required = false) String status, - @RequestParam(defaultValue = "1") Integer pageNo, - @RequestParam(defaultValue = "20") Integer pageSize) { - return Result.OK(quoteTemplateService.pageWithVersion(keyword, bizType, status, pageNo, pageSize)); + public Result> list(@RequestParam(required = false) String keyword, + @RequestParam(required = false) String bizType, + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "1") Integer pageNo, + @RequestParam(defaultValue = "20") Integer pageSize) { + IPage> page = quoteTemplateService.pageWithVersion( + keyword, bizType, status, pageNo, pageSize); + Map body = new HashMap<>(); + body.put("records", page.getRecords()); + body.put("total", page.getTotal()); + body.put("size", page.getSize()); + body.put("current", page.getCurrent()); + body.put("pages", page.getPages()); + // 前端业务下拉只渲染这些项。超管是空运、海运、陆运三项。 + body.put("allowedBizTypes", InquiryQuoteTemplateBizScope.allowedLabelsForCurrentUser()); + return Result.OK(body); } @AutoLog(value = "报价模板-上传版本") @@ -143,7 +155,11 @@ public class InquiryQuoteTemplateController { @RequiresPermissions("inquiry:template:view") @GetMapping("/queryById") public Result queryById(@RequestParam String id) { - return Result.OK(quoteTemplateService.getById(id)); + InquiryQuoteTemplate row = quoteTemplateService.getById(id); + if (row != null) { + InquiryQuoteTemplateBizScope.assertBizAllowed(row.getBizType()); + } + return Result.OK(row); } @ApiOperation("编辑逻辑头(名称/业务/关键词/模式/默认标记,不换文件)") @@ -157,10 +173,12 @@ public class InquiryQuoteTemplateController { if (db == null) { return Result.error("模板不存在"); } + InquiryQuoteTemplateBizScope.assertBizAllowed(db.getBizType()); if (StringUtils.isNotBlank(body.getName())) { db.setName(body.getName()); } if (StringUtils.isNotBlank(body.getBizType())) { + InquiryQuoteTemplateBizScope.assertBizAllowed(body.getBizType()); db.setBizType(body.getBizType()); } if (StringUtils.isNotBlank(body.getKeywordsJson())) { diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryWorkOrderController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryWorkOrderController.java index 7c3a9fe..0868418 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryWorkOrderController.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryWorkOrderController.java @@ -14,6 +14,7 @@ import org.jeecg.modules.inquiry.dto.TicketAbnormalRequest; import org.jeecg.modules.inquiry.dto.TicketTransitionRequest; import org.jeecg.modules.inquiry.entity.InquiryTicket; import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService; +import org.jeecg.modules.inquiry.support.InquiryDataScopeSupport; import org.apache.shiro.authz.annotation.Logical; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; @@ -28,6 +29,7 @@ import java.util.Map; *

* 请求线程只做 DB 事务与校验,不调用 LLM / LibreOffice / Graph。 * 权限:inquiry:workorder:list / transition 等。 + * 有菜单权限只表示能进页面。列表、导出、详情再按角色业务范围收窄。 */ @Slf4j @Api(tags = "询价-工单") @@ -42,7 +44,7 @@ public class InquiryWorkOrderController { @ApiOperation("后台列表") @RequiresPermissions("inquiry:workorder:list") @GetMapping("/view/list") - public Result> viewList( + public Result> viewList( @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo, @RequestParam(name = "pageSize", defaultValue = "20") Integer pageSize, HttpServletRequest req) { @@ -55,13 +57,21 @@ public class InquiryWorkOrderController { copyParam(req, filters, "systemException"); copyParam(req, filters, "dateFrom"); copyParam(req, filters, "dateTo"); - return Result.OK(workOrderService.pageView(new Page<>(pageNo, pageSize), filters)); + IPage page = workOrderService.pageView(new Page<>(pageNo, pageSize), filters); + Map body = new HashMap<>(); + body.put("records", page.getRecords()); + body.put("total", page.getTotal()); + body.put("size", page.getSize()); + body.put("current", page.getCurrent()); + body.put("pages", page.getPages()); + body.put("allowedBizTypes", InquiryDataScopeSupport.ticketBizOptionsForCurrentUser()); + return Result.OK(body); } @ApiOperation("兼容 list 路径") @RequiresPermissions("inquiry:workorder:list") @GetMapping("/list") - public Result> list( + public Result> list( @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo, @RequestParam(name = "pageSize", defaultValue = "20") Integer pageSize, HttpServletRequest req) { @@ -105,7 +115,11 @@ public class InquiryWorkOrderController { Map detail = workOrderService.detailView(workOrderNo); return Result.OK((InquiryTicket) detail.get("ticket")); } - return Result.OK(workOrderService.getById(id)); + InquiryTicket row = workOrderService.getById(id); + if (row != null) { + InquiryDataScopeSupport.assertTicketBizAllowed(row); + } + return Result.OK(row); } @AutoLog(value = "工单-六态流转") diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java index 7a67868..7da8911 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java @@ -90,6 +90,7 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService { .ge(InquiryTicket::getCreateTime, from) .le(InquiryTicket::getCreateTime, to); InquiryDataScopeSupport.applySelfOnly(qw); + InquiryDataScopeSupport.applyRoleBizLines(qw); List tickets = ticketMapper.selectList(qw); long total = tickets.size(); 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 fe513a9..f90085b 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 @@ -20,6 +20,7 @@ import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateMapper; import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateVersionMapper; import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper; import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper; +import org.jeecg.modules.inquiry.support.InquiryQuoteTemplateBizScope; import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport; import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService; import org.jeecg.modules.inquiry.template.QuoteTemplateAiParseClient; @@ -98,12 +99,28 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl> pageWithVersion(String keyword, String bizType, String status, int pageNo, int pageSize) { QueryWrapper qw = new QueryWrapper<>(); - if (StringUtils.isNotBlank(bizType)) { - qw.eq("biz_type", bizType); + Set allowed = InquiryQuoteTemplateBizScope.currentAllowedOrNull(); + String asked = StringUtils.trimToEmpty(bizType); + if (allowed != null) { + if (StringUtils.isNotBlank(asked) && !allowed.contains(asked)) { + qw.apply("1 = 0"); + } else if (StringUtils.isNotBlank(asked)) { + qw.eq("biz_type", asked); + } else if (allowed.isEmpty()) { + qw.apply("1 = 0"); + } else { + qw.in("biz_type", allowed); + } + } else if (StringUtils.isNotBlank(asked)) { + qw.eq("biz_type", asked); } if (StringUtils.isNotBlank(status)) { qw.eq("status", status); @@ -179,7 +196,10 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl listVersions(String templateId) { + requireTemplateBiz(templateId); return versionMapper.selectList(new QueryWrapper() .eq("template_id", templateId) .orderByDesc("version_no")); @@ -410,6 +473,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl buildSheetPreview(String versionId) throws Exception { + requireVersionBiz(versionId); InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId); if (ver == null) { throw new IllegalArgumentException("版本不存在"); @@ -625,6 +695,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl= 10 ? created.substring(0, 10) : created); + // #region agent log + try { + java.nio.file.Path dbg = java.nio.file.Paths.get( + "C:/Users/ytd_extdev_0903/incoming/debug-717349.log"); + String line = "{\"sessionId\":\"717349\",\"runId\":\"post-fix\",\"hypothesisId\":\"T1\"," + + "\"location\":\"InquiryQuoteTemplateServiceImpl.buildFillFacts\"," + + "\"message\":\"template quote_date from create\"," + + "\"data\":{\"wo\":\"" + StringUtils.defaultString(workOrderNo) + + "\",\"quote_date\":\"" + out.getString("quote_date") + + "\",\"fact_quote\":\"" + StringUtils.defaultString(first(src, "报价日期", "quote_date")) + + "\"},\"timestamp\":" + System.currentTimeMillis() + "}\n"; + java.nio.file.Files.write(dbg, line.getBytes(java.nio.charset.StandardCharsets.UTF_8), + java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND); + } catch (Exception ignore) { + // 调试埋点失败不影响出单 + } + // #endregion + } else { + out.put("quote_date", first(src, "报价日期", "quote_date", "询价日期", "走货日期", "询价时间", + "走货时间", "发货时间", "发货日期", "出货日期", "出货时间", "提货时间", "提货日期")); + } out.put("trade_terms", first(src, "贸易条款", "trade_terms")); out.put("运输分类", first(src, "运输分类")); out.put("cargo_ready_date", first(src, "货好时间", "cargo_ready_date")); @@ -1002,6 +1101,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl 0) { ver.setParseStatus("parsed"); ver.setParseMessage("已解析 " + ver.getMappingPointCount() + " 个映射点"); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java index 84cf2d3..5c0bcb4 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java @@ -148,8 +148,9 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl quotes = quoteMapper.selectList(new LambdaQueryWrapper() .eq(InquiryQuote::getWorkOrderNo, workOrderNo) @@ -210,19 +212,77 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl quoteRows = quotesByNo.get(ticket.getWorkOrderNo()); - InquiryTicketPriceSupport.apply(ticket, quoteRows); - if (quoteRows != null) { - for (InquiryQuote q : quoteRows) { + // 空运未选舱的 TMS 备选:不进后台报价列,也不参与销售价汇总。 + List displayRows = filterOutPendingAirCabinQuotes(quoteRows); + InquiryTicketPriceSupport.apply(ticket, displayRows); + if (displayRows != null) { + for (InquiryQuote q : displayRows) { if (q == null) { continue; } q.setSourceLabel(InquiryQuoteDisplaySupport.displayLabel(q.getSource(), q.getSourceLabel())); } } - ticket.setQuotes(quoteRows); + ticket.setQuotes(displayRows); } } + /** + * 列表/详情:丢掉空运「待选舱」TMS 行,避免未选舱却显示费用。 + * 判定与 InquiryTmsQueryService.isAirCabinPendingSelection 对齐。 + */ + static List filterOutPendingAirCabinQuotes(List rows) { + if (rows == null || rows.isEmpty()) { + return rows; + } + List out = new ArrayList<>(); + for (InquiryQuote q : rows) { + if (q == null) { + continue; + } + if (isPendingAirCabinQuoteRow(q)) { + continue; + } + out.add(q); + } + return out; + } + + static boolean isPendingAirCabinQuoteRow(InquiryQuote q) { + if (q == null || StringUtils.isBlank(q.getPayloadJson())) { + return false; + } + try { + JSONObject obj = JSON.parseObject(q.getPayloadJson()); + if (obj == null) { + return false; + } + if ("1".equals(StringUtils.trimToEmpty(obj.getString("pending_cabin_pick")))) { + return true; + } + if (obj.get("selected_air_option") != null || obj.get("selectedAirOption") != null) { + return false; + } + Object raw = obj.get("airOptions"); + if (raw == null) { + raw = obj.get("air_options"); + } + if (raw instanceof List) { + return !((List) raw).isEmpty(); + } + if (raw instanceof String) { + String s = ((String) raw).trim(); + if (s.startsWith("[")) { + List parsed = JSON.parseArray(s); + return parsed != null && !parsed.isEmpty(); + } + } + } catch (Exception ignored) { + return false; + } + return false; + } + /** 列表一次取出本页工单的报价记录,避免按单 N+1。 */ private Map> loadQuotesByWorkOrder(List tickets) { List nos = new ArrayList<>(); @@ -300,6 +360,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl facts = req.getFacts() == null ? new HashMap<>() : req.getFacts(); + Map facts = scrubPlaceholderFacts( + req.getFacts() == null ? new HashMap<>() : new HashMap<>(req.getFacts())); InquiryTicket ticket = new InquiryTicket(); ticket.setWorkOrderNo(nextWorkOrderNo()); ticket.setSalesWecomId(req.getSalesWecomId()); ticket.setSalesName(resolveStaffDisplayName(req.getSalesWecomId())); ticket.setBusinessLine(req.getBusinessLine().trim().toUpperCase()); ticket.setBizTypeLabel(bizLabel(ticket.getBusinessLine())); - ticket.setOrigin(facts.get("起运港")); - ticket.setDestination(facts.get("目的港")); - ticket.setGoodsName(facts.get("品名")); + // 摘要占位「-」不落库:空运不起运地应空串出站,横杠会骗 TMS 回 1003。 + ticket.setOrigin(placeOrNull(facts.get("起运港"))); + ticket.setDestination(placeOrNull(facts.get("目的港"))); + ticket.setGoodsName(placeOrNull(facts.get("品名"))); ticket.setPieces(parseInt(facts.get("件数"))); ticket.setWeightKg(parseDecimal(firstFact(facts, "重量", "毛重"))); // 体积常写尺寸 1.1*1.2*3,必须相乘;旧解析剥 * 会变成 1.11.23 直接 NumberFormat 炸建单。 @@ -548,7 +611,9 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl quote = raw instanceof Map ? (Map) raw : new LinkedHashMap(); - return tmsQueryService.replaceTmsQuote(no, quote); + // 智能体可显式传 toStatus=询价中:空运未选舱只落备选,不迁已报价。 + String toStatus = text(body, "toStatus", "to_status"); + return tmsQueryService.replaceTmsQuote(no, quote, toStatus); } private String nextWorkOrderNo() { @@ -698,13 +765,49 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl scrubPlaceholderFacts(Map facts) { + if (facts == null || facts.isEmpty()) { + return facts == null ? new HashMap<>() : facts; + } + for (String key : new String[] { + "起运港", "起运地", "目的港", "目的地", "品名", "包装方式", "包装类型", "报价日期" + }) { + String val = facts.get(key); + if (val != null && isPlaceholderDash(val)) { + facts.remove(key); + } + } + return facts; + } + private static Integer parseInt(String raw) { if (StringUtils.isBlank(raw)) { return null; @@ -755,6 +858,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl - * 口径:非超管且 {@code dashboardSelfOnly=true} → + * 只看自己:非超管且 {@code dashboardSelfOnly=true} → * {@code salesWecomId = 工号} 或 {@code createBy = 登录名}。 - * 看板与工单列表共用,避免只看板生效、列表漏看全站。 + * 业务范围:角色是陆运中港,或数据权限只有陆运线路时,看板和工单只留陆运。 + * 看板与工单列表共用,避免只收一边。没有登录上下文时不限制(智能体内部调用)。 * 线程:只读登录态,无全局锁。 */ public final class InquiryDataScopeSupport { @@ -32,13 +38,15 @@ public final class InquiryDataScopeSupport { if (login == null) { return; } - if (isAdministrator(login)) { - return; - } - if (!Boolean.TRUE.equals(login.getDashboardSelfOnly())) { - return; - } + boolean admin = isAdministrator(login); + Boolean selfOnly = login.getDashboardSelfOnly(); String username = StringUtils.trimToEmpty(login.getUsername()); + if (admin) { + return; + } + if (!Boolean.TRUE.equals(selfOnly)) { + return; + } String wecom = wecomId(login); qw.and(w -> { boolean any = false; @@ -60,6 +68,85 @@ public final class InquiryDataScopeSupport { }); } + /** + * 按角色把工单收成空运 / 海运 / 陆运。不限制时原样返回。 + * 有业务线码时只认码;业务线空着才用中文标签兜底,避免海运单被写成「陆运」后漏出来。 + */ + public static void applyRoleBizLines(LambdaQueryWrapper qw) { + if (qw == null) { + return; + } + Set allowed = InquiryQuoteTemplateBizScope.currentAllowedOrNull(); + if (allowed == null) { + return; + } + List codes = new ArrayList(); + List marks = new ArrayList(); + if (allowed.contains(InquiryRoleBizScope.LAND)) { + codes.add("LAND"); + marks.add("陆"); + } + if (allowed.contains(InquiryRoleBizScope.AIR)) { + codes.add("AIR"); + marks.add("空"); + } + if (allowed.contains(InquiryRoleBizScope.SEA)) { + codes.add("SEA"); + marks.add("海"); + } + if (codes.isEmpty()) { + qw.apply("1 = 0"); + return; + } + StringBuilder sql = new StringBuilder(); + sql.append("(UPPER(business_line) IN ("); + for (int i = 0; i < codes.size(); i++) { + if (i > 0) { + sql.append(','); + } + sql.append('\'').append(codes.get(i)).append('\''); + } + sql.append(") OR ((business_line IS NULL OR business_line = '') AND ("); + for (int i = 0; i < marks.size(); i++) { + if (i > 0) { + sql.append(" OR "); + } + sql.append("biz_type_label LIKE '%").append(marks.get(i)).append("%'"); + } + sql.append(")))"); + qw.apply(sql.toString()); + } + + /** + * 详情、流转、异常处理:列表里不该出现的工单,按编号打开也不给看。 + */ + public static void assertTicketBizAllowed(InquiryTicket ticket) { + if (ticket == null) { + return; + } + Set allowed = InquiryQuoteTemplateBizScope.currentAllowedOrNull(); + if (InquiryRoleBizScope.ticketInScope(ticket.getBusinessLine(), ticket.getBizTypeLabel(), allowed)) { + return; + } + String scope = InquiryRoleBizScope.joinLabels(allowed); + if (StringUtils.isBlank(scope)) { + scope = "已授权业务"; + } + throw new JeecgBootException("当前账号只能查看" + scope + "的询价工单和数据看板"); + } + + /** + * 工单业务下拉。不限制时含多段联运;陆运角色只有陆运。 + */ + public static List ticketBizOptionsForCurrentUser() { + Set allowed = InquiryQuoteTemplateBizScope.currentAllowedOrNull(); + List labels = new ArrayList(InquiryRoleBizScope.orderedLabels(allowed)); + if (allowed == null) { + labels.add("多段联运"); + } + return labels; + } + private static LoginUser currentLoginUser() { try { Object p = SecurityUtils.getSubject().getPrincipal(); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryQuoteTemplateBizScope.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryQuoteTemplateBizScope.java new file mode 100644 index 0000000..ff5d55b --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryQuoteTemplateBizScope.java @@ -0,0 +1,153 @@ +package org.jeecg.modules.inquiry.support; + +import org.apache.commons.lang3.StringUtils; +import org.apache.shiro.SecurityUtils; +import org.jeecg.common.exception.JeecgBootException; +import org.jeecg.common.system.vo.LoginUser; +import org.jeecg.common.util.SpringContextUtils; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * 后台报价模板的业务范围:按当前登录人的角色收成空运 / 海运 / 陆运。 + *

+ * 菜单权限 {@code inquiry:template:view} 只决定能不能进「报价模板」页。 + * 进了页面之后,陆运中港这类角色只应看到陆运模板,不能把海运、空运模板一并列出来。 + * 智能体出单走服务账号的 {@code /agent/listEnabled},不经过这里,避免按某个销售的角色把别的业务模板藏掉。 + *

+ * 线程:只读当前请求的登录态和角色表,无锁。没有登录上下文时不限制(内部任务、单测)。 + */ +public final class InquiryQuoteTemplateBizScope { + + private InquiryQuoteTemplateBizScope() { + } + + /** + * 当前登录人允许的业务线。 + * + * @return {@code null} 表示不限制(未登录、超管、角色未标明业务) + */ + public static Set currentAllowedOrNull() { + LoginUser login = currentLoginUser(); + if (login == null) { + return null; + } + String username = StringUtils.trimToEmpty(login.getUsername()); + List roles = loadRoles(username); + return InquiryRoleBizScope.resolve(isAdministrator(login, roles), roles); + } + + /** + * 给报价模板页下拉用。不限制时返回空运、海运、陆运三项。 + */ + public static List allowedLabelsForCurrentUser() { + return InquiryRoleBizScope.orderedLabels(currentAllowedOrNull()); + } + + /** + * 当前账号能否操作这条业务的模板。未登录或不限制时放行。 + */ + public static boolean isBizAllowed(String bizType) { + Set allowed = currentAllowedOrNull(); + if (allowed == null) { + return true; + } + return allowed.contains(StringUtils.trimToEmpty(bizType)); + } + + /** + * 越权时直接拒绝,避免只藏列表、预览和下载仍能按编号打开海运/空运模板。 + */ + public static void assertBizAllowed(String bizType) { + if (isBizAllowed(bizType)) { + return; + } + Set allowed = currentAllowedOrNull(); + String scope = InquiryRoleBizScope.joinLabels(allowed); + if (StringUtils.isBlank(scope)) { + scope = "已授权业务"; + } + throw new JeecgBootException("当前账号只能查看和维护" + scope + "报价模板"); + } + + private static LoginUser currentLoginUser() { + try { + Object principal = SecurityUtils.getSubject().getPrincipal(); + if (principal instanceof LoginUser) { + return (LoginUser) principal; + } + } catch (Exception ignored) { + // 无 Shiro 上下文:内部调用不按人过滤 + } + return null; + } + + /** + * 超管看全部模板。只认登录名 admin、角色码 admin、角色名「管理员 / 超级管理员」。 + * 「陆运管理员」这种带业务的名字不放行。 + */ + private static boolean isAdministrator(LoginUser login, List roles) { + if (login == null) { + return false; + } + if ("admin".equalsIgnoreCase(StringUtils.trimToEmpty(login.getUsername()))) { + return true; + } + if (roles != null) { + for (InquiryRoleBizScope.RoleSnap role : roles) { + if (role == null) { + continue; + } + String code = StringUtils.trimToEmpty(role.getRoleCode()); + String name = StringUtils.trimToEmpty(role.getRoleName()); + if ("admin".equalsIgnoreCase(code) || "管理员".equals(name) || "超级管理员".equals(name)) { + return true; + } + } + } + try { + return SecurityUtils.getSubject().hasRole("admin") + || SecurityUtils.getSubject().hasRole("管理员"); + } catch (Exception e) { + return false; + } + } + + /** + * 读账号绑定角色的名称和数据权限。失败时当没有角色,不把异常抛到列表上。 + */ + private static List loadRoles(String username) { + List roles = new ArrayList<>(); + if (StringUtils.isBlank(username)) { + return roles; + } + String sql = "SELECT r.role_name, r.role_code, r.description FROM sys_role r " + + "INNER JOIN sys_user_role ur ON ur.role_id = r.id " + + "INNER JOIN sys_user u ON u.id = ur.user_id WHERE u.username = ?"; + try { + DataSource ds = SpringContextUtils.getBean(DataSource.class); + try (Connection conn = ds.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, username); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + roles.add(new InquiryRoleBizScope.RoleSnap( + rs.getString("role_name"), + rs.getString("role_code"), + rs.getString("description"))); + } + } + } + } catch (Exception ignored) { + // 角色表暂时读不到时返回空,列表不因此 500。 + // 上层会当成「没配业务范围」,暂不收窄;下次请求再读。 + } + return roles; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryRoleBizScope.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryRoleBizScope.java new file mode 100644 index 0000000..59a4390 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryRoleBizScope.java @@ -0,0 +1,274 @@ +package org.jeecg.modules.inquiry.support; + +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 把角色名称和角色数据权限,收成报价模板能用的业务线:空运 / 海运 / 陆运。 + *

+ * 数据权限写在 {@code sys_role.description} 的 {@code [dataPerms:…]},目录与后台 + * {@code dataPermCatalog.ts} 一致。报价模板只有业务线,没有「中港 / 国内整车」这一层, + * 所以陆运线路(含中港)都归到「陆运」。 + *

+ * 纯函数,不读库、不碰登录态。超管是否放行由调用方传入。 + */ +public final class InquiryRoleBizScope { + + public static final String AIR = "空运"; + public static final String SEA = "海运"; + public static final String LAND = "陆运"; + + /** 角色描述里数据权限的包裹格式,与后台角色保存一致。 */ + private static final String PERM_OPEN = "[dataPerms:"; + + /** 陆运线路叶子(写入时不带「陆运·」前缀)。 */ + private static final Set LAND_ROUTE_KEYS = new LinkedHashSet(Arrays.asList( + "陆运全线", + "中港/中亚/中欧", + "国内长途/零担", + "国内城配/拖车/打包", + "东南亚" + )); + + private static final List BIZ_ORDER = Arrays.asList(AIR, SEA, LAND); + + /** 空运线路码,如 CGO-KUL。中港角色名不含这种码。 */ + private static final Pattern AIRPORT_PAIR = Pattern.compile(".*[A-Z]{3}-[A-Z]{3}.*"); + + private InquiryRoleBizScope() { + } + + /** + * 一个角色的名称、编码、描述。编码用于识别超管角色,描述用于读数据权限。 + */ + public static final class RoleSnap { + private final String roleName; + private final String roleCode; + private final String description; + + public RoleSnap(String roleName, String roleCode, String description) { + this.roleName = roleName; + this.roleCode = roleCode; + this.description = description; + } + + public String getRoleName() { + return roleName; + } + + public String getRoleCode() { + return roleCode; + } + + public String getDescription() { + return description; + } + } + + /** + * 当前账号能看的报价模板业务。 + *

+ * {@code null} 表示不限制:超管,或所有角色都看不出业务线(老账号不被误锁)。 + * 非空则列表、预览、改删都只能落在这些业务里。多个角色取并集。 + * 角色名已点明单一业务(如「陆运中港」)时,以角色名为准,避免功能权限开通后仍看到海陆空全部模板。 + * + * @param administrator 登录名 admin、角色码 admin、或角色名「管理员」 + * @param roles 该账号绑定的角色;可空 + */ + public static Set resolve(boolean administrator, List roles) { + if (administrator) { + return null; + } + LinkedHashSet allowed = new LinkedHashSet<>(); + boolean anyScoped = false; + if (roles != null) { + for (RoleSnap role : roles) { + if (role == null) { + continue; + } + Set fromName = bizFromRoleName(role.getRoleName()); + Set fromPerms = bizFromDataPerms(role.getDescription()); + // 角色名只指向一条业务线时,数据权限不再把别的业务线加进来。 + // 「陆运中港」即使描述里误勾了海运/空运,报价模板也只出陆运。 + if (fromName.size() == 1) { + anyScoped = true; + allowed.addAll(fromName); + continue; + } + if (!fromPerms.isEmpty()) { + anyScoped = true; + allowed.addAll(fromPerms); + continue; + } + if (!fromName.isEmpty()) { + anyScoped = true; + allowed.addAll(fromName); + } + } + } + if (!anyScoped) { + return null; + } + return allowed; + } + + /** + * 界面下拉用的稳定顺序:空运、海运、陆运。{@code allowed == null} 时三项都返回。 + */ + public static List orderedLabels(Set allowed) { + List out = new ArrayList(3); + for (String biz : BIZ_ORDER) { + if (allowed == null || allowed.contains(biz)) { + out.add(biz); + } + } + return out; + } + + /** 给销售看的业务名,顿号拼接。空集合返回空串。 */ + public static String joinLabels(Collection allowed) { + Collection source = allowed == null ? Collections.emptySet() : allowed; + return String.join("、", orderedLabels(new LinkedHashSet(source))); + } + + /** + * 从角色描述取出数据权限稳定码。没有这段标记时返回空列表。 + */ + public static List parseDataPermKeys(String description) { + List keys = new ArrayList<>(); + if (StringUtils.isBlank(description)) { + return keys; + } + int open = description.indexOf(PERM_OPEN); + if (open < 0) { + return keys; + } + int close = description.indexOf(']', open); + if (close < 0) { + return keys; + } + String body = description.substring(open + PERM_OPEN.length(), close); + for (String part : body.split("\\|")) { + String key = StringUtils.trimToEmpty(part); + if (StringUtils.isNotBlank(key)) { + keys.add(key); + } + } + return keys; + } + + /** + * 一条数据权限对应的业务线。系统异常通知、认不出的码返回 null,不扩大可见范围。 + */ + public static String bizOfDataPermKey(String raw) { + String key = StringUtils.trimToEmpty(raw); + if (key.isEmpty() || key.contains("系统异常")) { + return null; + } + if (key.contains("空运")) { + return AIR; + } + if (key.contains("海运")) { + return SEA; + } + if (key.contains("陆运") || LAND_ROUTE_KEYS.contains(key)) { + return LAND; + } + // 剥掉「空运· / 陆运·」之后再认一次叶子(旧数据可能带前缀,上面 contains 已覆盖带前缀的)。 + String leaf = key; + int dot = Math.max(key.lastIndexOf('·'), key.lastIndexOf('•')); + if (dot >= 0 && dot + 1 < key.length()) { + leaf = key.substring(dot + 1).trim(); + } + if (LAND_ROUTE_KEYS.contains(leaf)) { + return LAND; + } + if (AIRPORT_PAIR.matcher(key).matches() || key.contains("外采") || key.contains("曼谷")) { + return AIR; + } + return null; + } + + /** + * 角色名称里点名的业务。中港归陆运。名称同时含多条业务时都算上。 + */ + public static Set bizFromRoleName(String roleName) { + LinkedHashSet out = new LinkedHashSet<>(); + String name = StringUtils.trimToEmpty(roleName); + if (name.isEmpty()) { + return out; + } + if (name.contains("空运")) { + out.add(AIR); + } + if (name.contains("海运")) { + out.add(SEA); + } + if (name.contains("陆运") || name.contains("中港")) { + out.add(LAND); + } + return out; + } + + /** + * 工单业务线归到空运 / 海运 / 陆运。 + * 已写 SEA/AIR/LAND 时以它为准;空着才看中文标签。多段联运不归进任何一条。 + */ + public static String classifyTicketBiz(String businessLine, String bizTypeLabel) { + String line = StringUtils.trimToEmpty(businessLine).toUpperCase(Locale.ROOT); + if ("LAND".equals(line) || line.contains("陆")) { + return LAND; + } + if ("AIR".equals(line) || line.contains("空")) { + return AIR; + } + if ("SEA".equals(line) || line.contains("海")) { + return SEA; + } + if (StringUtils.isNotBlank(line)) { + return ""; + } + String label = StringUtils.trimToEmpty(bizTypeLabel); + if (label.contains("陆")) { + return LAND; + } + if (label.contains("空")) { + return AIR; + } + if (label.contains("海")) { + return SEA; + } + return ""; + } + + /** + * {@code allowed == null} 表示不限制。多段、认不出的工单对有范围的账号不可见。 + */ + public static boolean ticketInScope(String businessLine, String bizTypeLabel, Set allowed) { + if (allowed == null) { + return true; + } + String biz = classifyTicketBiz(businessLine, bizTypeLabel); + return StringUtils.isNotBlank(biz) && allowed.contains(biz); + } + + private static Set bizFromDataPerms(String description) { + LinkedHashSet out = new LinkedHashSet<>(); + for (String key : parseDataPermKeys(description)) { + String biz = bizOfDataPermKey(key); + if (biz != null) { + out.add(biz); + } + } + return out; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketFactsSupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketFactsSupport.java index b2ee8e1..f891a25 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketFactsSupport.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketFactsSupport.java @@ -39,19 +39,20 @@ public final class InquiryTicketFactsSupport { continue; } String val = e.getValue().trim(); - if (val.isEmpty()) { + // 摘要占位「-」等于没写,禁止盖进工单起运港。 + if (val.isEmpty() || isPlaceholderDash(val)) { continue; } merged.put(e.getKey(), val); } ticket.setFactsJson(JSON.toJSONString(merged)); - if (StringUtils.isNotBlank(merged.get("起运港"))) { + if (StringUtils.isNotBlank(merged.get("起运港")) && !isPlaceholderDash(merged.get("起运港"))) { ticket.setOrigin(merged.get("起运港")); } - if (StringUtils.isNotBlank(merged.get("目的港"))) { + if (StringUtils.isNotBlank(merged.get("目的港")) && !isPlaceholderDash(merged.get("目的港"))) { ticket.setDestination(merged.get("目的港")); } - if (StringUtils.isNotBlank(merged.get("品名"))) { + if (StringUtils.isNotBlank(merged.get("品名")) && !isPlaceholderDash(merged.get("品名"))) { ticket.setGoodsName(merged.get("品名")); } Integer pieces = parseInt(merged.get("件数")); @@ -114,6 +115,15 @@ public final class InquiryTicketFactsSupport { return null; } + /** 销售摘要占位横杠,不当真实起运/目的。 */ + private static boolean isPlaceholderDash(String raw) { + if (StringUtils.isBlank(raw)) { + return false; + } + String t = raw.trim(); + return "-".equals(t) || "—".equals(t) || "-".equals(t); + } + private static Integer parseInt(String raw) { if (StringUtils.isBlank(raw)) { return null; diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java index 8fe90bb..5bbf3d1 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java @@ -178,6 +178,11 @@ public class InquiryTmsCabinService { if (!properties.lockConfigured()) { return fail("lock_disabled", "主账未开放锁舱"); } + // 工单报价日空/脏(如事实只有「:」)时,先拦再出站。 + // 禁止用舱位起飞日顶过去后打 TMS,再回「选项不匹配」(WO202610140047)。 + if (isTicketQuoteDateMissing(ticket)) { + return fail("lock_incomplete", "报价日期为空,请补充报价日期后重新锁舱"); + } Map req = new LinkedHashMap<>(); req.put("workOrderNo", no); if (StringUtils.isNotBlank(quoteId)) { @@ -187,7 +192,7 @@ public class InquiryTmsCabinService { req.put("flightDate", firstNonBlank( text(option, "flightDate"), flightDateFromDeparture(text(option, "departureTime")), - quoteDate(ticket))); + effectiveTicketQuoteDate(ticket))); req.put("origin", firstNonBlank(text(option, "originAirportCode"), fact(ticket, "originCode"), text(option, "origin"), ticket.getOrigin(), @@ -241,18 +246,15 @@ public class InquiryTmsCabinService { // 缺必填仍出站会被 TMS 1001;在此拦住并回清原因。 String incomplete = validateManualLockOutbound(req); // 现拉仍无舱位时,改成业务补问口径(禁止堆「缺少 optionNo」技术字段名)。 + // 报价日期空 → flightDate/validUntil 落不下来:优先提示补报价日期, + // 禁止被「运价库无匹配→补舱位」话术盖掉(运行证据 WO202610140005)。 if (StringUtils.isNotBlank(incomplete)) { - String refreshClass = text(quote, "_lockRefreshClass"); - String refreshMsg = text(quote, "_lockRefreshMsg"); - boolean noMatch = "TMS_NO_QUOTE_1002".equals(refreshClass) - || (StringUtils.isNotBlank(refreshMsg) && refreshMsg.contains("无匹配")); - if (noMatch) { - incomplete = "本单是手工报价,且运价库无匹配舱位,无法自动带出航班。" - + "请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。" - + "(现拉TMS:" + firstNonBlank(refreshMsg, "标准运价库无匹配报价") + ")"; - } else if (StringUtils.isNotBlank(refreshMsg)) { - incomplete = incomplete + "(现拉TMS:" + refreshMsg + ")"; - } + incomplete = rewriteManualLockIncompleteReason( + incomplete, + ticket, + req, + text(quote, "_lockRefreshClass"), + text(quote, "_lockRefreshMsg")); } if (StringUtils.isNotBlank(incomplete)) { log.info("tms.lock incomplete wo={} optionNo={} missing={}", @@ -893,6 +895,39 @@ public class InquiryTmsCabinService { return ""; } + /** + * 手工锁舱校验失败话术改写。 + *

+ * 报价日期空导致 flightDate/validUntil 落不下来时,优先提示补报价日期; + * 否则现拉无舱位时改成「补舱位三项」业务口径。 + */ + static String rewriteManualLockIncompleteReason( + String incomplete, + InquiryTicket ticket, + Map req, + String refreshClass, + String refreshMsg) { + String why = StringUtils.trimToEmpty(incomplete); + if (why.isEmpty()) { + return ""; + } + // 列空、事实只有「:」等脏值,都算报价日缺失(WO047)。 + if (isTicketQuoteDateMissing(ticket)) { + return "报价日期为空,请补充报价日期后重新锁舱"; + } + boolean noMatch = "TMS_NO_QUOTE_1002".equals(StringUtils.trimToEmpty(refreshClass)) + || (StringUtils.isNotBlank(refreshMsg) && refreshMsg.contains("无匹配")); + if (noMatch) { + return "本单是手工报价,且运价库无匹配舱位,无法自动带出航班。" + + "请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。" + + "(现拉TMS:" + firstNonBlank(refreshMsg, "标准运价库无匹配报价") + ")"; + } + if (StringUtils.isNotBlank(refreshMsg)) { + return why + "(现拉TMS:" + refreshMsg + ")"; + } + return why; + } + /** * MANUAL 出站必填校验。缺字段直接失败,禁止 HMAC 空壳打 TMS(会 1001)。 * 返回中文原因;齐了返回空串。 @@ -1382,6 +1417,27 @@ public class InquiryTmsCabinService { return new SimpleDateFormat("yyyy-MM-dd").format(ticket.getQuoteDate()); } + /** + * 锁舱用的工单报价日:列优先,其次事实里能规范成 yyyy-MM-dd 的值。 + * 「:」、空串、乱码不算日期。 + */ + static String effectiveTicketQuoteDate(InquiryTicket ticket) { + String fromCol = quoteDate(ticket); + if (StringUtils.isNotBlank(fromCol)) { + return fromCol; + } + String fromFact = fact(ticket, "报价日期", "quoteDate", "quote_date"); + String normalized = TmsV2RequestMapper.normalizeQuoteDate(fromFact); + return normalized == null ? "" : normalized; + } + + /** + * 工单是否缺少可用报价日(列空且事实无合法日期)。 + */ + static boolean isTicketQuoteDateMissing(InquiryTicket ticket) { + return StringUtils.isBlank(effectiveTicketQuoteDate(ticket)); + } + private static String fact(InquiryTicket ticket, String... keys) { if (ticket == null || StringUtils.isBlank(ticket.getFactsJson())) { return ""; diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java index ae1b360..f610fe3 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java @@ -70,6 +70,8 @@ public class InquiryTmsQueryService { if (ticket != null) { mergeTicketFacts(facts, ticket); } + // 摘要占位「-」清成空,禁止当起运地出站(运行证据 WO202610150086 → TMS 1003 originCode)。 + scrubPlaceholderDashes(facts); String line = resolveTransportLine( body, facts, @@ -82,9 +84,9 @@ public class InquiryTmsQueryService { Map segment = new LinkedHashMap<>(); segment.put("sequence", 1); segment.put("transportMode", line); - String origin = text(facts.get("起运港")); - String destination = text(facts.get("目的港")); - segment.put("cargoName", text(facts.get("品名"))); + String origin = placeText(facts.get("起运港")); + String destination = placeText(facts.get("目的港")); + segment.put("cargoName", placeText(facts.get("品名"))); if ("SEA".equals(line)) { // 海运不拦空运件数/重量/体积/包装;港口只带名称,没有港口字典也不跳过。 segment.put("origin", origin); @@ -107,9 +109,10 @@ public class InquiryTmsQueryService { segment.put("destination", destination); segment.putAll(TmsLandSegmentMapper.build(facts)); } else { - // 空运:地名栏留空,三字码只放 originCode / destinationCode。 - segment.put("origin", ""); - segment.put("destination", ""); + // 空运:地名传销售原文(广西/克拉克),能投影再带 originCode/destinationCode。 + // 对不上三字码不拦出站、不编假码;起运地非必填可空串。 + segment.put("origin", origin); + segment.put("destination", destination); segment.put("quantity", TmsV2RequestMapper.asInteger(facts.get("件数"))); segment.put("weightKg", TmsV2RequestMapper.asPositiveDecimal(facts.get("毛重"))); segment.put("volumeCbm", TmsV2RequestMapper.asVolumeCbm(facts.get("体积"))); @@ -173,7 +176,8 @@ public class InquiryTmsQueryService { } try { Map tms = gateway.query(internal, cacheKey); - return classifyAndPersist(workOrderNo, tms); + Map classified = classifyAndPersist(workOrderNo, tms); + return classified; } catch (RuntimeException ex) { log.warn("tms.query tech wo={} err={}", workOrderNo, ex.getMessage()); return tech(ex.getMessage()); @@ -242,20 +246,33 @@ public class InquiryTmsQueryService { log.info("tms.quote skip frozen wo={} status={}", workOrderNo, ticket.getStatus()); return; } + boolean airPending = isAirCabinPendingSelection(quote); InquiryQuote row = new InquiryQuote(); row.setTicketId(ticket.getId()); row.setWorkOrderNo(workOrderNo); row.setQuoteVersion(1); applySnapshotToQuoteRow(row, quote); + if (airPending) { + // 未选舱:保留 airOptions 供恢复,清空展示金额,禁止后台当成已报价。 + sanitizePendingAirCabinRow(row, quote); + } row.setCreateTime(new Date()); quoteMapper.insert(row); List quotes = quoteMapper.selectList(new LambdaQueryWrapper() .eq(InquiryQuote::getWorkOrderNo, workOrderNo) .orderByAsc(InquiryQuote::getCreateTime)); InquiryTicketPriceSupport.apply(ticket, quotes); + if (airPending) { + ticket.setSalePrice(""); + ticket.setCostPrice(""); + ticket.setProfitRate(""); + } ticket.setTmsHit(1); workOrderService.updateById(ticket); - if (TicketStatuses.canTransit(ticket.getStatus(), TicketStatuses.QUOTED)) { + // 空运备选舱位未选:保持询价中,不迁已报价(合同:选定后才出报价)。 + boolean willTransit = !airPending + && TicketStatuses.canTransit(ticket.getStatus(), TicketStatuses.QUOTED); + if (willTransit) { TicketTransitionRequest tr = new TicketTransitionRequest(); tr.setWorkOrderNo(workOrderNo); tr.setToStatus(TicketStatuses.QUOTED); @@ -263,6 +280,87 @@ public class InquiryTmsQueryService { } } + /** + * 空运尚在选舱/选线:有 airOptions 且没有 selected_air_option。 + * 此时禁止迁已报价、禁止后台展示最终费用。 + */ + static boolean isAirCabinPendingSelection(Map quote) { + if (quote == null || quote.isEmpty()) { + return false; + } + if (hasSelectedAirOption(quote)) { + return false; + } + if ("1".equals(StringUtils.trimToEmpty(quote.get("pending_cabin_pick")))) { + return true; + } + return countAirOptions(quote) >= 1; + } + + static boolean hasSelectedAirOption(Map quote) { + if (quote == null) { + return false; + } + return StringUtils.isNotBlank(quote.get("selected_air_option")) + || StringUtils.isNotBlank(quote.get("selectedAirOption")); + } + + static int countAirOptions(Map quote) { + if (quote == null) { + return 0; + } + String airRaw = firstText(quote.get("airOptions"), quote.get("air_options")); + if (StringUtils.isBlank(airRaw)) { + return 0; + } + try { + Object parsed = airRaw.trim().startsWith("[") ? JSON.parse(airRaw) : null; + if (parsed instanceof List) { + return ((List) parsed).size(); + } + } catch (Exception ignored) { + return 0; + } + return 0; + } + + static int countFeeLines(Map quote) { + if (quote == null) { + return 0; + } + String fees = firstText(quote.get("fee_lines"), quote.get("fee_rows")); + if (StringUtils.isBlank(fees) || !fees.trim().startsWith("[")) { + return 0; + } + try { + List fl = JSON.parseArray(fees); + return fl == null ? 0 : fl.size(); + } catch (Exception ignored) { + return 0; + } + } + + /** + * 未选舱落库:payload 保留 airOptions,清空 total/fee_lines/priceText。 + */ + static void sanitizePendingAirCabinRow(InquiryQuote row, Map quote) { + if (row == null) { + return; + } + row.setPriceText(""); + Map sanitized = new LinkedHashMap<>(); + if (quote != null) { + sanitized.putAll(quote); + } + sanitized.put("pending_cabin_pick", "1"); + sanitized.put("fee_lines", "[]"); + sanitized.put("total", ""); + sanitized.remove("selected_air_option"); + sanitized.remove("selectedAirOption"); + row.setPayloadJson(JSON.toJSONString(sanitized)); + } + + /** * 把 TMS 规范化回包投影成智能体 quote。单测可直接调。 * 金额保持数字原文;币种单独落 currency / fee_lines.currency,禁止默认 CNY。 @@ -619,6 +717,14 @@ public class InquiryTmsQueryService { * 不新插 TMS 行,避免后台出现两条「TMS标准报价」。已冻结工单拒绝。 */ public Map replaceTmsQuote(String workOrderNo, Map incoming) { + return replaceTmsQuote(workOrderNo, incoming, null); + } + + /** + * 覆盖 TMS 快照。toStatus=询价中 或空运未选舱时,只落备选、不迁已报价。 + * 选定后(有 selected_air_option / 投影完成)才迁已报价。 + */ + public Map replaceTmsQuote(String workOrderNo, Map incoming, String toStatus) { Map out = new LinkedHashMap<>(); if (StringUtils.isBlank(workOrderNo) || incoming == null || incoming.isEmpty()) { out.put("ok", false); @@ -642,6 +748,8 @@ public class InquiryTmsQueryService { } Map quote = stringifyQuote(incoming); applyLandSegmentTotals(quote); + boolean airPending = isAirCabinPendingSelection(quote) + || TicketStatuses.INQUIRING.equals(StringUtils.trimToEmpty(toStatus)); InquiryQuote row = quoteMapper.selectOne(new LambdaQueryWrapper() .eq(InquiryQuote::getWorkOrderNo, workOrderNo) .eq(InquiryQuote::getSource, "TMS") @@ -656,6 +764,9 @@ public class InquiryTmsQueryService { row.setCreateTime(new Date()); } applySnapshotToQuoteRow(row, quote); + if (airPending) { + sanitizePendingAirCabinRow(row, quote); + } row.setUpdateTime(new Date()); if (insert) { quoteMapper.insert(row); @@ -666,13 +777,21 @@ public class InquiryTmsQueryService { .eq(InquiryQuote::getWorkOrderNo, workOrderNo) .orderByAsc(InquiryQuote::getCreateTime)); InquiryTicketPriceSupport.apply(ticket, quotes); + if (airPending) { + ticket.setSalePrice(""); + ticket.setCostPrice(""); + ticket.setProfitRate(""); + } ticket.setTmsHit(1); workOrderService.updateById(ticket); - if (TicketStatuses.canTransit(ticket.getStatus(), TicketStatuses.QUOTED)) { + boolean wantQuoted = !airPending + && (StringUtils.isBlank(toStatus) || TicketStatuses.QUOTED.equals(toStatus.trim())); + if (wantQuoted && TicketStatuses.canTransit(ticket.getStatus(), TicketStatuses.QUOTED)) { TicketTransitionRequest tr = new TicketTransitionRequest(); tr.setWorkOrderNo(workOrderNo); tr.setToStatus(TicketStatuses.QUOTED); workOrderService.transition(tr, "agent"); + ticket.setStatus(TicketStatuses.QUOTED); } out.put("ok", true); out.put("quote_version", row.getQuoteVersion() == null ? 1 : row.getQuoteVersion()); @@ -704,8 +823,39 @@ public class InquiryTmsQueryService { return quote; } - private static boolean blankOrDash(String raw) { - return StringUtils.isBlank(raw) || "-".equals(raw.trim()); + /** + * 空串或摘要占位横杠(- / — / -)。 + * 横杠只用于销售展示「没写」,不能当起运地/目的地原文出站。 + */ + static boolean blankOrDash(String raw) { + if (StringUtils.isBlank(raw)) { + return true; + } + String t = raw.trim(); + return "-".equals(t) || "—".equals(t) || "-".equals(t); + } + + /** + * 查价 facts 里的占位横杠清掉,避免 merge 工单列后又把「-」打给 TMS。 + */ + static void scrubPlaceholderDashes(Map facts) { + if (facts == null || facts.isEmpty()) { + return; + } + for (String key : new String[] { + "起运港", "起运地", "目的港", "目的地", "品名", "包装方式", "包装类型", "报价日期" + }) { + Object raw = facts.get(key); + if (raw != null && blankOrDash(String.valueOf(raw))) { + facts.remove(key); + } + } + } + + /** 地名/品名出站:横杠当空串。 */ + static String placeText(Object value) { + String t = text(value); + return blankOrDash(t) ? "" : t; } /** @@ -1408,7 +1558,7 @@ public class InquiryTmsQueryService { } /** - * 空运出站齐套检查。地名栏允许空串,禁止再拿 destination 拦查价。 + * 空运出站齐套检查。起运地/目的地原文可空,禁止拿地名栏拦查价。 * 报价日期非必填,空着也算出站齐套。 */ static List missingAirQuery(String workOrderNo, String quoteDate, Map segment) { @@ -1542,7 +1692,8 @@ public class InquiryTmsQueryService { } private static void putIfBlank(Map facts, String key, String val) { - if (blank(facts.get(key)) && StringUtils.isNotBlank(val)) { + // 工单列若是「-」,禁止再补进查价 facts。 + if (blank(facts.get(key)) && StringUtils.isNotBlank(val) && !blankOrDash(val)) { facts.put(key, val); } } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java index 3884fd6..550dc51 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java @@ -79,16 +79,19 @@ public final class TmsV2RequestMapper { mode = parentMode; } put(target, "transportMode", mode); + // 空运/海运/陆运:地名传销售原文;空运另带能投影出的三字码。 + // 空运起运地非必填,原文空时仍留空串给 TMS,禁止当「没值」删掉。 + Object originRaw = first(source, "origin"); + Object destRaw = first(source, "destination"); if ("AIR".equalsIgnoreCase(String.valueOf(mode == null ? "" : mode))) { - // 空运地名栏必须空串,三字码只放 originCode / destinationCode。 - target.put("origin", ""); - target.put("destination", ""); + target.put("origin", originRaw == null ? "" : originRaw); + target.put("destination", destRaw == null ? "" : destRaw); put(target, "originCode", first(source, "originCode")); put(target, "destinationCode", first(source, "destinationCode")); } else { - put(target, "origin", first(source, "origin")); + put(target, "origin", originRaw); put(target, "originCode", first(source, "originCode")); - put(target, "destination", first(source, "destination")); + put(target, "destination", destRaw); put(target, "destinationCode", first(source, "destinationCode")); } put(target, "cargoName", first(source, "cargoName", "goodsName")); @@ -330,7 +333,7 @@ public final class TmsV2RequestMapper { } private static Map compact(Map value) { - // 空运地名栏要留空串给 TMS,禁止把 origin / destination 当成「没值」删掉。 + // 空运起运地可空:空串仍留给 TMS,禁止把 origin / destination 当成「没值」删掉。 value.entrySet().removeIf(e -> { Object current = e.getValue(); if (current == null) { diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryRoleBizScopeTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryRoleBizScopeTest.java new file mode 100644 index 0000000..fb84b5a --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryRoleBizScopeTest.java @@ -0,0 +1,106 @@ +package org.jeecg.modules.inquiry.support; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 报价模板业务范围:陆运中港角色只落到「陆运」,不连库。 + */ +public class InquiryRoleBizScopeTest { + + @Test + public void landHongKongRoleNameShowsOnlyLandTemplates() { + Set allowed = InquiryRoleBizScope.resolve(false, Collections.singletonList( + new InquiryRoleBizScope.RoleSnap("陆运中港", "land_hk", ""))); + assertEquals(Collections.singleton(InquiryRoleBizScope.LAND), allowed); + assertEquals(Collections.singletonList("陆运"), InquiryRoleBizScope.orderedLabels(allowed)); + } + + @Test + public void landRouteDataPermMapsToLandEvenIfRoleNameIsGeneric() { + Set allowed = InquiryRoleBizScope.resolve(false, Collections.singletonList( + new InquiryRoleBizScope.RoleSnap("销售", "sales", + "[dataPerms:中港/中亚/中欧|东南亚]"))); + assertEquals(Collections.singleton(InquiryRoleBizScope.LAND), allowed); + } + + @Test + public void namedLandRoleIgnoresExtraSeaAndAirDataPerms() { + Set allowed = InquiryRoleBizScope.resolve(false, Collections.singletonList( + new InquiryRoleBizScope.RoleSnap("陆运中港", "land_hk", + "[dataPerms:中港/中亚/中欧|海运全线|空运全线]"))); + assertEquals(Collections.singleton(InquiryRoleBizScope.LAND), allowed); + } + + @Test + public void airAndSeaPermsStaySplitWhenRoleNameHasNoBusiness() { + Set allowed = InquiryRoleBizScope.resolve(false, Collections.singletonList( + new InquiryRoleBizScope.RoleSnap("线路专员", "route", + "[dataPerms:CGO-KUL|海运全线]"))); + assertEquals(new HashSet(Arrays.asList( + InquiryRoleBizScope.AIR, InquiryRoleBizScope.SEA)), allowed); + assertEquals(Arrays.asList("空运", "海运"), InquiryRoleBizScope.orderedLabels(allowed)); + } + + @Test + public void twoRolesUnionTheirBusinessLines() { + Set allowed = InquiryRoleBizScope.resolve(false, Arrays.asList( + new InquiryRoleBizScope.RoleSnap("陆运中港", "land_hk", ""), + new InquiryRoleBizScope.RoleSnap("空运专员", "air", "[dataPerms:空运全线]"))); + assertEquals(new HashSet(Arrays.asList( + InquiryRoleBizScope.LAND, InquiryRoleBizScope.AIR)), allowed); + } + + @Test + public void adminAndUnscopedRoleAreNotFiltered() { + assertNull(InquiryRoleBizScope.resolve(true, Collections.singletonList( + new InquiryRoleBizScope.RoleSnap("陆运中港", "land_hk", "")))); + assertNull(InquiryRoleBizScope.resolve(false, Collections.singletonList( + new InquiryRoleBizScope.RoleSnap("运营", "ops", "")))); + assertEquals(Arrays.asList("空运", "海运", "陆运"), InquiryRoleBizScope.orderedLabels(null)); + } + + @Test + public void systemNoticeAloneDoesNotOpenAllLines() { + Set allowed = InquiryRoleBizScope.resolve(false, Collections.singletonList( + new InquiryRoleBizScope.RoleSnap("陆运中港", "land_hk", + "[dataPerms:系统异常·消息通知]"))); + assertEquals(Collections.singleton(InquiryRoleBizScope.LAND), allowed); + assertNull(InquiryRoleBizScope.resolve(false, Collections.singletonList( + new InquiryRoleBizScope.RoleSnap("运维", "ops", + "[dataPerms:系统异常·消息通知]")))); + } + + @Test + public void airportAndBangkokKeysAreAir() { + assertEquals(InquiryRoleBizScope.AIR, InquiryRoleBizScope.bizOfDataPermKey("HKG-KUL")); + assertEquals(InquiryRoleBizScope.AIR, InquiryRoleBizScope.bizOfDataPermKey("国内-曼谷(BKK 外采)")); + assertEquals(InquiryRoleBizScope.SEA, InquiryRoleBizScope.bizOfDataPermKey("海运全线")); + assertEquals(InquiryRoleBizScope.LAND, InquiryRoleBizScope.bizOfDataPermKey("国内长途/零担")); + List keys = InquiryRoleBizScope.parseDataPermKeys("[dataPerms:中港/中亚/中欧|空运全线]"); + assertTrue(keys.contains("中港/中亚/中欧")); + } + + @Test + public void landRoleCannotSeeAirOrSeaTickets() { + Set land = Collections.singleton(InquiryRoleBizScope.LAND); + assertTrue(InquiryRoleBizScope.ticketInScope("LAND", "陆运", land)); + assertTrue(InquiryRoleBizScope.ticketInScope("", "陆运", land)); + assertFalse(InquiryRoleBizScope.ticketInScope("AIR", "空运", land)); + assertFalse(InquiryRoleBizScope.ticketInScope("SEA", "海运", land)); + assertFalse(InquiryRoleBizScope.ticketInScope("MULTI", "多段联运", land)); + assertTrue(InquiryRoleBizScope.ticketInScope("AIR", "空运", null)); + assertEquals(InquiryRoleBizScope.LAND, InquiryRoleBizScope.classifyTicketBiz("land", "")); + assertEquals("", InquiryRoleBizScope.classifyTicketBiz("MULTI", "陆运")); + } +} 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 7e41a68..3c21f81 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 @@ -2683,6 +2683,21 @@ public class QuoteTemplateFillTest { } } + /** + * 报价单「报价日期」格用工单创建日,不拿询价事实里的报价日期(TMS 用)。 + */ + @Test + public void buildFillFactsQuoteDatePrefersTicketCreateDate() { + Map facts = new HashMap<>(); + facts.put("报价日期", "2026-10-18"); + facts.put("ticket_create_date", "2026-10-15"); + facts.put("起运港", "广西"); + facts.put("目的港", "肇庆"); + JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl + .buildFillFacts("WO202610150031", facts, new HashMap<>(), "WuJiLin", "AIR"); + assertEquals("2026-10-15", out.getString("quote_date")); + } + /** * 业务员写中文名,不写企微账号。客户名称只留销售填的,账号不能占这一格。 */ diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java index 6502745..6d1f4af 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java @@ -350,6 +350,33 @@ public class InquiryTmsCabinServiceTest { assertEquals("", InquiryTmsCabinService.validateManualLockOutbound(ok)); } + @Test + public void rewriteIncompletePrefersEmptyQuoteDateOverNoMatchCabinTip() { + org.jeecg.modules.inquiry.entity.InquiryTicket ticket = + new org.jeecg.modules.inquiry.entity.InquiryTicket(); + ticket.setFactsJson("{}"); + Map req = new HashMap<>(); + req.put("flightDate", ""); + req.put("validUntil", ""); + String raw = "锁舱参数不完整,缺少:有效期validUntil。请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。"; + String why = InquiryTmsCabinService.rewriteManualLockIncompleteReason( + raw, ticket, req, "TMS_NO_QUOTE_1002", "标准运价库无匹配报价"); + assertEquals("报价日期为空,请补充报价日期后重新锁舱", why); + } + + @Test + public void colonOnlyFactQuoteDateCountsAsMissing() { + org.jeecg.modules.inquiry.entity.InquiryTicket ticket = + new org.jeecg.modules.inquiry.entity.InquiryTicket(); + ticket.setFactsJson("{\"报价日期\":\":\"}"); + org.junit.jupiter.api.Assertions.assertTrue(InquiryTmsCabinService.isTicketQuoteDateMissing(ticket)); + assertEquals("", InquiryTmsCabinService.effectiveTicketQuoteDate(ticket)); + + ticket.setFactsJson("{\"报价日期\":\"2026-10-14\"}"); + org.junit.jupiter.api.Assertions.assertFalse(InquiryTmsCabinService.isTicketQuoteDateMissing(ticket)); + assertEquals("2026-10-14", InquiryTmsCabinService.effectiveTicketQuoteDate(ticket)); + } + @Test public void airlineCodeOrBlankRejectsChineseName() { assertEquals("CZ", InquiryTmsCabinService.airlineCodeOrBlank("CZ")); diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryServiceTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryServiceTest.java index 3e08d8c..59436cd 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryServiceTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryServiceTest.java @@ -77,7 +77,7 @@ public class InquiryTmsQueryServiceTest { @Test public void airQueryAllowsEmptyPlaceNames() { - // 地名栏空串不能拦出站;三字码和包装原文齐了就要打 TMS。 + // 起运地可空不能拦出站;包装与货量齐了就要打 TMS。 java.util.Map segment = new java.util.HashMap(); segment.put("origin", ""); segment.put("destination", ""); @@ -93,6 +93,38 @@ public class InquiryTmsQueryServiceTest { org.junit.jupiter.api.Assertions.assertTrue(missing.isEmpty(), String.valueOf(missing)); } + @Test + public void scrubPlaceholderDashesClearsOriginDash() { + // 摘要「-」必须清掉,否则 TMS 当起飞机场去匹配会 1003。 + java.util.Map facts = new java.util.HashMap(); + facts.put("起运港", "-"); + facts.put("目的港", "KUL"); + facts.put("品名", "书籍"); + InquiryTmsQueryService.scrubPlaceholderDashes(facts); + org.junit.jupiter.api.Assertions.assertFalse(facts.containsKey("起运港")); + org.junit.jupiter.api.Assertions.assertEquals("KUL", facts.get("目的港")); + org.junit.jupiter.api.Assertions.assertEquals("", InquiryTmsQueryService.placeText("-")); + org.junit.jupiter.api.Assertions.assertEquals("上海", InquiryTmsQueryService.placeText("上海")); + org.junit.jupiter.api.Assertions.assertTrue(InquiryTmsQueryService.blankOrDash("—")); + } + + @Test + public void airQueryAllowsGuangxiOriginalWithoutOriginCode() { + // 销售写广西:原文出站,对不上三字码也不拦。 + java.util.Map segment = new java.util.HashMap(); + segment.put("origin", "广西"); + segment.put("destination", "克拉克"); + segment.put("destinationCode", "CRK"); + segment.put("cargoName", "衣服"); + segment.put("quantity", 3); + segment.put("weightKg", "1.5"); + segment.put("volumeCbm", "100"); + segment.put("packageType", "托盘"); + java.util.List missing = InquiryTmsQueryService.missingAirQuery( + "WO202610140054", null, segment); + org.junit.jupiter.api.Assertions.assertTrue(missing.isEmpty(), String.valueOf(missing)); + } + @Test public void airQueryAllowsBlankQuoteDate() { // 空运没填报价日期也算出站齐套,不能再补成当天才放行。 diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java index 3a69ff9..458c88d 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java @@ -187,7 +187,7 @@ public class TmsV2RequestMapperTest { assertEquals("东莞仓库", out.get("placeOfReceipt")); assertEquals("马尼拉", out.get("deliveryAddress")); assertEquals("", out.get("origin")); - assertEquals("", out.get("destination")); + assertEquals("克拉克", out.get("destination")); assertEquals("CRK", out.get("destinationCode")); assertFalse(out.containsKey("transportTerms")); assertFalse(out.containsKey("tradeTerms")); @@ -221,12 +221,38 @@ public class TmsV2RequestMapperTest { } @Test - public void airPlaceNamesStayEmptyCodesOnlyAndRawPackage() { - // 地名栏必须空串;三字码只放 *Code。包装原文原样,禁止编成 2。 + public void airPlaceNamesKeepOriginalAndCodesWithRawPackage() { + // 地名传销售原文;三字码另放 *Code。包装原文原样,禁止编成 2。 Map seg = new HashMap<>(); - seg.put("origin", "ZUH"); + seg.put("origin", "广西"); + seg.put("destination", "克拉克"); + seg.put("destinationCode", "CRK"); + seg.put("cargoName", "衣服"); + seg.put("quantity", "3"); + seg.put("weightKg", "1.5"); + seg.put("volumeCbm", "100"); + seg.put("packageType", "托盘"); + Map internal = new HashMap<>(); + internal.put("transportMode", "AIR"); + internal.put("quoteDate", "2026-09-25"); + internal.put("segments", Collections.singletonList(seg)); + Map body = TmsV2RequestMapper.quote(internal); + @SuppressWarnings("unchecked") + List> segs = (List>) body.get("segments"); + Map out = segs.get(0); + assertEquals("广西", out.get("origin")); + assertNull(out.get("originCode")); + assertEquals("克拉克", out.get("destination")); + assertEquals("CRK", out.get("destinationCode")); + assertEquals("托盘", out.get("packageType")); + } + + @Test + public void airCodesStillPassWithOriginalNames() { + Map seg = new HashMap<>(); + seg.put("origin", "珠海"); seg.put("originCode", "ZUH"); - seg.put("destination", "CRK"); + seg.put("destination", "克拉克"); seg.put("destinationCode", "CRK"); seg.put("cargoName", "茶叶"); seg.put("quantity", "1"); @@ -241,9 +267,9 @@ public class TmsV2RequestMapperTest { @SuppressWarnings("unchecked") List> segs = (List>) body.get("segments"); Map out = segs.get(0); - assertEquals("", out.get("origin")); + assertEquals("珠海", out.get("origin")); assertEquals("ZUH", out.get("originCode")); - assertEquals("", out.get("destination")); + assertEquals("克拉克", out.get("destination")); assertEquals("CRK", out.get("destinationCode")); assertEquals("托盘", out.get("packageType")); } diff --git a/inquiry-backend/src/views/inquiry/template/index.vue b/inquiry-backend/src/views/inquiry/template/index.vue index fa456c6..9e5e7bb 100644 --- a/inquiry-backend/src/views/inquiry/template/index.vue +++ b/inquiry-backend/src/views/inquiry/template/index.vue @@ -5,6 +5,7 @@ * - 版式由上传 Excel 决定;列表展示「映射点数」而非假占位符 * - 预览:原表截图(LibreOffice 转图) * - 解析状态列可点:弹窗看已解析 / 未解析清单 + * - 业务下拉和列表按主账返回的 allowedBizTypes 收窄:陆运角色不出现海运、空运 */ import { computed, onMounted, onUnmounted, reactive, ref } from "vue"; import { Modal, message } from "ant-design-vue"; @@ -55,6 +56,29 @@ const keyword = ref(""); const bizFilter = ref(""); const statusFilter = ref(""); +/** 报价模板只有这三条业务。主账按角色收窄后,下拉只留账号能看的。 */ +const ALL_BIZ = ["空运", "海运", "陆运"] as const; +type BizName = (typeof ALL_BIZ)[number]; +const bizOptions = ref([...ALL_BIZ]); + +function defaultBiz(): TemplateRow["bizType"] { + return bizOptions.value[0] || "海运"; +} + +/** 主账没带回范围时保持三项,避免旧接口把下拉清空。 */ +function applyAllowedBiz(raw: unknown) { + const incoming = Array.isArray(raw) ? raw.map((item) => String(item)) : []; + const next = ALL_BIZ.filter((biz) => incoming.includes(biz)); + if (!next.length) return; + bizOptions.value = [...next]; + if (bizFilter.value && !bizOptions.value.includes(bizFilter.value as BizName)) { + bizFilter.value = ""; + } + if (!bizOptions.value.includes(form.bizType as BizName)) { + form.bizType = defaultBiz(); + } +} + const formOpen = ref(false); const editing = ref(null); const previewOpen = ref(false); @@ -364,6 +388,7 @@ async function loadList() { pageSize: 200, }); const records = res?.records || res?.result?.records || []; + applyAllowedBiz(res?.allowedBizTypes ?? res?.result?.allowedBizTypes); rows.value = (records as any[]).map(mapApiRow); scheduleParsePoll(); } catch (e: any) { @@ -397,6 +422,7 @@ function scheduleParsePoll() { pageSize: 200, }); const records = res?.records || res?.result?.records || []; + applyAllowedBiz(res?.allowedBizTypes ?? res?.result?.allowedBizTypes); rows.value = (records as any[]).map(mapApiRow); } catch { /* 轮询失败不打断页面 */ @@ -420,7 +446,7 @@ onUnmounted(() => { function resetForm() { Object.assign(form, { name: "", - bizType: "海运" as TemplateRow["bizType"], + bizType: defaultBiz(), mode: "auto" as const, keywordItems: [""], isDefault: false, @@ -708,9 +734,7 @@ async function downloadPreviewOrOriginal() { - - - +

diff --git a/inquiry-backend/src/views/inquiry/workOrder/index.vue b/inquiry-backend/src/views/inquiry/workOrder/index.vue index ab9b8ea..e73c8d9 100644 --- a/inquiry-backend/src/views/inquiry/workOrder/index.vue +++ b/inquiry-backend/src/views/inquiry/workOrder/index.vue @@ -2,6 +2,7 @@ /** * 询价工单列表:对接主账 /inquiry/workOrder。 * 六态关单/重试必须调主账;禁止本地改 status 冒充成功。 + * 业务下拉按主账 allowedBizTypes 收窄:陆运角色不出现海运、空运。 * 详情/关单弹窗必须用 AdminModal:本工程 ant-design-vue 3 的 a-modal 只认 visible, * v-model:open 不会弹出(账号/角色页已统一走 AdminModal)。 */ @@ -112,6 +113,20 @@ const filters = reactive({ dateTo: "", }); +/** 工单业务下拉。主账按角色收窄后,陆运账号不再出现海运、空运、多段。 */ +const ALL_TICKET_BIZ = ["空运", "海运", "陆运", "多段联运"] as const; +const ticketBizOptions = ref([...ALL_TICKET_BIZ]); + +function applyTicketBizOptions(raw: unknown) { + const incoming = Array.isArray(raw) ? raw.map((item) => String(item)) : []; + const next = ALL_TICKET_BIZ.filter((biz) => incoming.includes(biz)); + if (!next.length) return; + ticketBizOptions.value = [...next]; + if (filters.bizType && !ticketBizOptions.value.includes(filters.bizType)) { + filters.bizType = ""; + } +} + const detailOpen = ref(false); const closeOpen = ref(false); const current = ref(null); @@ -460,6 +475,20 @@ function requirementSummaryLines(t: any): string[] { return lines; } +/** + * 空运尚未选定舱位/线路:payload 有 airOptions 且无 selected,或 pending_cabin_pick。 + * 此时后台不应展示 TMS 费用,避免「未选舱却已报价」。 + */ +function isPendingAirCabinQuote(q: any): boolean { + const payload = asObject(q?.payloadJson) || asObject(q?.payload) || {}; + if (String(payload.pending_cabin_pick || "") === "1") return true; + const selected = + asObject(payload.selected_air_option) || asObject(payload.selectedAirOption); + if (selected) return false; + const opts = asFeeArray(payload.airOptions || payload.air_options); + return opts.length > 0; +} + function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline: any[] = []): TicketRow { const attachRows: TicketAttach[] = (attachments || []) .map((a) => ({ @@ -479,7 +508,9 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline initiator, demandTitle: reqLines[0] || "—", demandDetail: reqLines.slice(1).join("\n"), - quotes: (quotes || []).map((q) => ({ + quotes: (quotes || []) + .filter((q) => !isPendingAirCabinQuote(q)) + .map((q) => ({ source: quoteSourceLabel(q), price: q.priceText || "-", time: fmtTime(q.createTime), @@ -587,6 +618,7 @@ async function loadList() { }); const page = res?.records ? res : res?.result || {}; const records = page.records || []; + applyTicketBizOptions(page.allowedBizTypes); total.value = Number(page.total || records.length || 0); rows.value = (records as any[]).map((t) => mapTicket(t, t.quotes || [])); // 删减后当前页可能越界,回退到最后一页 @@ -766,10 +798,7 @@ async function exportTickets() {