From 56ba8e93b5f51a88b9d796b946469f30bd4cbfff Mon Sep 17 00:00:00 2001 From: linmianxun <634841751@qq.com> Date: Wed, 28 Oct 2026 19:48:47 +0800 Subject: [PATCH] =?UTF-8?q?=E5=91=98=E5=B7=A5=E5=B2=97=E4=BD=8D=E5=8F=AF?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E5=90=AF=E7=94=A8=EF=BC=9B=E8=B0=83=E4=BB=B7?= =?UTF-8?q?=E8=AE=A4=E7=A9=BA=E8=BF=90=E8=88=AA=E7=BA=BF=EF=BC=9B=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=AD=98=E6=A1=A3=E8=B7=B3=E5=8F=B7=E8=A1=A5=E6=8B=89?= =?UTF-8?q?=EF=BC=9B=E9=99=86=E8=BF=90=E6=8A=A5=E4=BB=B7=E6=8C=89=E8=BD=A6?= =?UTF-8?q?=E5=9E=8B=E5=88=97=E5=9B=9E=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- inquiry-agent/agent/channel/outbox/sender.py | 1 + inquiry-agent/agent/channel/wshoto/poll.py | 88 ++ inquiry-agent/agent/handlers/card_action.py | 7 +- inquiry-agent/agent/handlers/group_collab.py | 19 + inquiry-agent/agent/handlers/text_inquiry.py | 15 + .../agent/jobs/event_exception_poll.py | 42 +- inquiry-agent/agent/llm/extract_text.py | 88 +- .../agent/llm/mode_extract_adjust.py | 5 + .../agent/llm/mode_extract_fields.py | 4 +- inquiry-agent/agent/policy/air_group_ops.py | 133 ++- inquiry-agent/agent/policy/air_quote_copy.py | 21 +- inquiry-agent/agent/policy/air_text_flow.py | 30 + inquiry-agent/agent/policy/deal_followup.py | 61 +- inquiry-agent/agent/policy/deal_outcome.py | 31 +- inquiry-agent/agent/policy/event_exception.py | 2 +- inquiry-agent/agent/policy/inquiry_copy.py | 101 ++- inquiry-agent/agent/policy/multi_group_ops.py | 19 +- .../agent/policy/quote_adjust_ops.py | 14 +- inquiry-agent/agent/policy/sea_group_ops.py | 136 +++- inquiry-agent/agent/routing/group_intent.py | 32 + inquiry-agent/agent/schema/field_validate.py | 27 +- inquiry-agent/agent/schema/quote_adjust.py | 190 ++++- inquiry-agent/agent/schema/tms_land_query.py | 17 +- inquiry-agent/tests/test_deal_followup.py | 33 + inquiry-agent/tests/test_deal_outcome.py | 47 ++ inquiry-agent/tests/test_event_exception.py | 15 +- inquiry-agent/tests/test_extract_oral.py | 51 ++ .../tests/test_land_collab_fields.py | 6 +- inquiry-agent/tests/test_land_copy.py | 13 +- .../tests/test_land_field_validate.py | 31 +- inquiry-agent/tests/test_land_text_inquiry.py | 13 +- inquiry-agent/tests/test_quote_adjust.py | 227 ++++++ inquiry-agent/tests/test_sea_text_inquiry.py | 58 ++ inquiry-agent/tests/test_wshoto_archive.py | 79 +- .../InquiryStaffRoleController.java | 13 + .../service/IInquiryStaffRoleService.java | 5 + .../impl/InquiryQuoteTemplateServiceImpl.java | 85 +- .../impl/InquiryStaffRoleServiceImpl.java | 15 + .../impl/InquiryWorkOrderServiceImpl.java | 14 + .../support/InquiryTicketDisplaySupport.java | 45 +- .../template/LandQuoteTemplateFields.java | 7 + .../template/QuoteTemplateExcelSupport.java | 760 +++++++++++++++--- .../inquiry/tms/InquiryTmsCabinService.java | 46 +- .../inquiry/tms/TmsLandEnumMapper.java | 11 +- .../inquiry/tms/TmsLandSegmentMapper.java | 41 +- .../inquiry/tms/TmsV2RequestMapper.java | 2 +- .../contracts/quote-template-mapping-v1.json | 3 + .../tms-domestic-vehicle-mapping-v1.json | 9 +- .../template/QuoteTemplateFillTest.java | 338 ++++++++ .../tms/InquiryTmsCabinServiceTest.java | 10 +- .../inquiry/tms/TmsLandEnumMapperTest.java | 6 +- .../inquiry/tms/TmsLandSegmentMapperTest.java | 56 +- .../src/router/guard/permissionGuard.ts | 51 -- .../src/store/modules/permission.ts | 98 --- .../src/views/inquiry/dashboard/index.vue | 110 +-- .../inquiry/shared/inquiryAuthDownload.ts | 23 - .../src/views/inquiry/staffRole/index.vue | 28 + .../views/inquiry/staffRole/staffRole.api.ts | 13 +- .../src/views/inquiry/template/index.vue | 23 - .../src/views/sys/exception/Exception.vue | 22 - prompt/12-测试环境部署.md | 118 ++- prompt/13-环境对照.md | 145 ++-- 62 files changed, 2960 insertions(+), 793 deletions(-) diff --git a/inquiry-agent/agent/channel/outbox/sender.py b/inquiry-agent/agent/channel/outbox/sender.py index 727c517..d791531 100644 --- a/inquiry-agent/agent/channel/outbox/sender.py +++ b/inquiry-agent/agent/channel/outbox/sender.py @@ -546,6 +546,7 @@ class WeComAppClient: return {} info = data.get("chat_info") or data.get("chatinfo") or {} return { + "name": str(info.get("name") or "").strip(), "owner": str(info.get("owner") or "").strip(), "userlist": [ str(x).strip() diff --git a/inquiry-agent/agent/channel/wshoto/poll.py b/inquiry-agent/agent/channel/wshoto/poll.py index 5c5efb5..ef11c0b 100644 --- a/inquiry-agent/agent/channel/wshoto/poll.py +++ b/inquiry-agent/agent/channel/wshoto/poll.py @@ -278,6 +278,83 @@ def _poll_by_scan_users( return merged +def _merge_records_by_seq( + *batches: Sequence[dict[str, Any]], + min_seq: int = 0, +) -> list[dict[str, Any]]: + """按 seq 去重合并多页;同 seq 后写覆盖。只保留 seq > min_seq。""" + by_seq: dict[int, dict[str, Any]] = {} + for batch in batches: + for rec in batch or (): + seq = _record_seq(rec) + if seq <= min_seq: + continue + by_seq[seq] = dict(rec) + return [by_seq[s] for s in sorted(by_seq)] + + +def _backfill_room_seq_gap( + api: WshotoOpenClient, + *, + room: str, + last_seq: int, + rows: list[dict[str, Any]], + page_size: int, + lookback: int, + clock: Callable[[], float], +) -> list[dict[str, Any]]: + """ + 增量结果跳号时,用时间窗补中间漏掉的消息。 + + 现网:游标 9892,管家增量只回撤回 9899,把「解散群」9898 永久跨过。 + 若本页最小 seq > last_seq+1,再打一枪时间窗,按 seq 合并后返回。 + 补洞失败则原样返回(调用方仍按本页 max 推进,故补洞要尽量成功)。 + """ + if last_seq <= 0 or not rows: + return rows + page_seqs = [_record_seq(r) for r in rows if _record_seq(r) > 0] + if not page_seqs: + return rows + min_page = min(page_seqs) + if min_page <= last_seq + 1: + return rows + now_i = int(clock()) + window = min(max(60, int(lookback or 0)), _NEW_ROOM_LOOKBACK_SEC) + logger.warning( + "微盛房间增量跳号 chat=%s last=%s min_page=%s,时间窗补洞 window=%ss", + room, + last_seq, + min_page, + window, + ) + try: + fill = api.list_group_messages( + userid="", + target_id=room, + last_seq=0, + page_size=page_size, + msg_time_start=now_i - window, + msg_time_end=now_i, + ) + except WshotoError as exc: + logger.warning( + "微盛房间补洞失败 chat=%s code=%s msg=%s", + room, + exc.code, + exc.msg, + ) + return rows + merged = _merge_records_by_seq(rows, fill or [], min_seq=last_seq) + if len(merged) > len(rows): + logger.info( + "微盛房间补洞成功 chat=%s added=%s total=%s", + room, + len(merged) - len(rows), + len(merged), + ) + return merged + + def _poll_by_rooms( api: WshotoOpenClient, *, @@ -341,6 +418,17 @@ def _poll_by_rooms( polled += 1 if not is_room_hot(room): cold_polled += 1 + # 增量若跳号:管家有时先吐出撤回等高 seq,游标一步跨过中间正文 + # (现网:解散群 seq=9898 被撤回 9899 推过去后永远听不见)。跳号则时间窗补洞。 + rows = _backfill_room_seq_gap( + api, + room=room, + last_seq=last, + rows=list(rows or []), + page_size=page_size, + lookback=lookback, + clock=clock, + ) max_seq = last for rec in rows: seq = _record_seq(rec) diff --git a/inquiry-agent/agent/handlers/card_action.py b/inquiry-agent/agent/handlers/card_action.py index 0b7b1e8..e8f7b8e 100644 --- a/inquiry-agent/agent/handlers/card_action.py +++ b/inquiry-agent/agent/handlers/card_action.py @@ -117,11 +117,14 @@ def handle_card_action( wo = copy.work_order_from_card_meta(payload) code = str((payload or {}).get("response_code") or extra.get("response_code") or "")[:24] msgtype = str(extra.get("msgtype") or "text") - # 文本 ack 按工单+动作去重;置灰更新仍带 code(每次 ResponseCode 只能用一次) + # 文本/方案卡按工单+动作+waitVersion 去重。 + # 调价后再点「生成」时正文前缀相同,若不带 waitVersion,第二次方案卡会被 PG 唯一键吃掉, + # 价格卡已置灰成「已提交」但销售收不到生成 Excel/PDF 的方案卡。 + wait_ver = int(getattr(sess, "wait_version", 0) or 0) if sess is not None else 0 if msgtype == "update_template_card": dedupe_key = f"card-grey:{sender_id}:{wo}:{code}:{action}" else: - dedupe_key = f"card-ack:{sender_id}:{wo}:{action}:{body[:24]}" + dedupe_key = f"card-ack:{sender_id}:{wo}:{action}:v{wait_ver}:{body[:24]}" ok, info = msg_store.enqueue_outbound( touser=sender_id, content=body, diff --git a/inquiry-agent/agent/handlers/group_collab.py b/inquiry-agent/agent/handlers/group_collab.py index fe8062f..a676324 100644 --- a/inquiry-agent/agent/handlers/group_collab.py +++ b/inquiry-agent/agent/handlers/group_collab.py @@ -19,6 +19,7 @@ from agent.policy.sea_group_ops import ( apply_tms_quote_choice, capture_group_lost_reason, dissolve_group, + dissolve_orphan_group, group_client_of, handle_group_file, hydrate_ticket, @@ -82,6 +83,24 @@ def handle_group_inbound( ticket = _find_ticket(book, chat_id) if ticket is None: logger.info("group_inbound 对不上工单 chat=%s", chat_id) + # 老群/迁库丢绑定:对不上工单时,「解散群」仍按企微当前成员清人。 + from agent.routing.group_intent import INTENT_DISSOLVE, classify_group_text + + kind_orphan = (message.msg_type or "text").lower() + if kind_orphan in {"", "text"} and ( + classify_group_text(message.content or "", injected=injected_intent) + == INTENT_DISSOLVE + ): + logger.info( + "group_inbound 无工单仍解散 chat=%s sender=%s", + chat_id, + message.sender_id, + ) + return dissolve_orphan_group( + flow=engine, + chat_id=chat_id, + sender_id=message.sender_id or "", + ) return "group_ignore" if flow is None: view0 = ticket_view(ticket) diff --git a/inquiry-agent/agent/handlers/text_inquiry.py b/inquiry-agent/agent/handlers/text_inquiry.py index a278238..b3e1498 100644 --- a/inquiry-agent/agent/handlers/text_inquiry.py +++ b/inquiry-agent/agent/handlers/text_inquiry.py @@ -156,6 +156,7 @@ def _drop_other_mode_drafts(*, owner: str, sender_id: str) -> None: def _flow_waiting_lost_reason(sender_id: str, sea, air, land): """谁在等未成交原因就交给谁。刚记下未成交的那条优先。""" + from agent.policy.deal_outcome import ticket_missing_in_ledger from agent.policy.handoff_human import is_handoff_frozen waiting = [] @@ -165,6 +166,20 @@ def _flow_waiting_lost_reason(sender_id: str, sea, air, land): continue if (sess.phase or "").strip() != "wait_lost_reason": continue + # 清数后主账无单:丢掉孤儿书签,不要继续抢走新询价。 + if ticket_missing_in_ledger( + getattr(flow, "_ledger", None), + work_order_no=str(getattr(sess, "work_order_no", "") or ""), + sender_id=sender_id, + ): + abandon = getattr(flow, "abandon_orphan_lost_wait", None) + if callable(abandon): + abandon(sess) + else: + drop = getattr(flow, "drop_current_bookmark", None) + if callable(drop): + drop(sender_id) + continue waiting.append((int(getattr(sess, "wait_version", 0) or 0), flow)) if not waiting: return None diff --git a/inquiry-agent/agent/jobs/event_exception_poll.py b/inquiry-agent/agent/jobs/event_exception_poll.py index ac13bd4..9421460 100644 --- a/inquiry-agent/agent/jobs/event_exception_poll.py +++ b/inquiry-agent/agent/jobs/event_exception_poll.py @@ -202,10 +202,12 @@ def _handle_one( watchers = [dict(x) for x in (row.get("watchers") or []) if isinstance(x, dict)] sales = _ticket_sales(row, watchers) people = pick_mentions(wait_kind=kind, sales=sales, members=watchers, business_line=line) - # 空运群往往只绑了群号,成员列表是空的,群里就找不到航线岗,话术会落成「相关人员」。 - # 只补航线超时。销售超时不能改去 @ 航线,只 @ 本单销售。 - if kind == WAIT_AIR_QUOTE and not people: - people = _airline_staff_mentions(book) + # 空运群往往只绑了群号;且航线超时必须按本单「负责线路」点名,禁止整岗广播。 + # 与激活摘要 resolve_air_mentions 同口径。销售超时不能改去 @ 航线。 + if kind == WAIT_AIR_QUOTE: + route_people = _airline_staff_mentions(book, facts=facts) + if route_people: + people = route_people names = [str(p.get("name") or "").strip() for p in people if str(p.get("name") or "").strip()] chat_id = str(row.get("collab_chat_id") or row.get("collabChatId") or "").strip() where = conversation_of(wait_kind=kind, collab_chat_id=chat_id) @@ -295,22 +297,42 @@ def _ticket_sales(row: dict[str, Any], watchers: list[dict[str, Any]]) -> dict[s return matched -def _airline_staff_mentions(book: Any) -> list[dict[str, Any]]: +def _airline_staff_mentions( + book: Any, + *, + facts: dict[str, Any] | None = None, +) -> list[dict[str, Any]]: """ - 主账按岗位列出启用的航线同事。 + 主账列出启用航线·空运,再按本单负责线路过滤。 - 只在群成员里没有航线岗时调用。主账失败或没有这个接口时返回空,话术仍用「相关人员」。 - 不写工单。可与巡检线程并发,每次只读这一单的员工名单。 + 与空运群激活摘要同口径:对上起运/目的(或空运全线);对不上则只留全线。 + 主账失败或没有接口时返回空,话术仍用「相关人员」。不写工单。 """ + from agent.policy.deal_followup import air_staff_route_matches + lister = getattr(book, "list_staff_by_role", None) if not callable(lister): return [] try: - rows = list(lister(role_code="air") or []) + rows = [dict(x) for x in (lister(role_code="air") or []) if isinstance(x, dict)] except Exception: logger.exception("事件异常列航线岗失败") return [] - return mentions_for_role(rows, role_code="air") + matched = [ + r + for r in rows + if air_staff_route_matches(routes=str(r.get("routes") or ""), facts=facts) + ] + if not matched: + matched = [ + r + for r in rows + if any( + t.strip() in {"空运全线", "全线"} + for t in str(r.get("routes") or "").replace(",", ",").replace("、", ",").split(",") + ) + ] + return mentions_for_role(matched, role_code="air") def _send_app_cards( diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py index 556e895..02b4fbb 100644 --- a/inquiry-agent/agent/llm/extract_text.py +++ b/inquiry-agent/agent/llm/extract_text.py @@ -186,6 +186,20 @@ _TIGHTEN_LABELS = tuple( ) ) _FEE_NAME = re.compile(r"^\*?[\u4e00-\u9fffA-Za-z0-9]{1,12}费$") +# 识图/标签错位:报价编号、工单号、长数字进了起运地/目的地。三字码航线不动。 +_QUOTE_LIKE_PLACE = re.compile( + r"^(?:" + r"(?:WO|VT)\d{10,}" + r"|QT[-_]?\d{6,}" + r"|\d{8,}" + r"|[A-Za-z]{1,6}\d{8,}" + r")$", + re.I, +) +_QUOTE_NO_IN_TEXT = re.compile( + r"(?:报价编号|报价单编号|RFQ\s*No\.?|Quote\s*No\.?)\s*[::]?\s*([A-Za-z0-9\-_]{6,})", + re.I, +) _SHEET_STOP = ( "报价明细", "报价条件", @@ -455,6 +469,64 @@ def _drop_fee_place_values(facts: dict[str, str]) -> dict[str, str]: return out +def looks_like_quote_id_place(raw: str | None) -> bool: + """ + 是否像报价编号/工单号,而不是起运地或目的地。 + + 识图常把「报价编号:QT…」错贴到起运地。SZX、南京、SZX-CGK 不算。 + """ + text = str(raw or "").strip() + if not text: + return False + if "报价编号" in text or "报价单编号" in text: + return True + # 去掉常见装饰后再判 + cleaned = re.sub(r"[((]\s*(?:必填|非必填)\s*[))]", "", text).strip(" \t::,,。;;") + if not cleaned: + return False + if _QUOTE_LIKE_PLACE.match(cleaned): + return True + # 纯数字超过 7 位多半是系统编号,不是港口名 + if re.fullmatch(r"\d{8,}", cleaned.replace(" ", "")): + return True + return False + + +def _quote_nos_mentioned(text: str) -> set[str]: + """原话/识图正文里出现的报价编号值。""" + return { + m.group(1).strip() + for m in _QUOTE_NO_IN_TEXT.finditer(text or "") + if (m.group(1) or "").strip() + } + + +def _drop_quote_like_place_values( + facts: dict[str, str], + *, + source_text: str = "", +) -> dict[str, str]: + """ + 起运地/目的地若是报价编号、工单号或原话「报价编号:」同一值,丢掉。 + + 副作用:无写库;只清洗本轮 facts。 + """ + out = dict(facts or {}) + mentioned = _quote_nos_mentioned(source_text) + for key in ("起运港", "起运地", "目的港", "目的地"): + val = str(out.get(key) or "").strip() + if not val: + continue + if not (looks_like_quote_id_place(val) or val in mentioned): + continue + out.pop(key, None) + if key == "起运港": + out.pop("originCode", None) + if key == "目的港": + out.pop("destinationCode", None) + return out + + def extract_inquiry_snapshot( text: str, *, @@ -483,6 +555,7 @@ def extract_inquiry_snapshot( if str(facts.get(key) or "").strip(): facts[key] = clip_inquiry_value(str(facts[key])) facts = _drop_fee_place_values(_clip_sheet_inquiry_facts(facts)) + facts = _drop_quote_like_place_values(facts, source_text=text) facts = _apply_air_added_services(text, facts, mode) subtype = str(facts.get("运输类型") or "").strip() return { @@ -539,6 +612,7 @@ def extract_inquiry_snapshot( if str(facts.get(key) or "").strip(): facts[key] = clip_inquiry_value(str(facts[key])) facts = _drop_fee_place_values(_clip_sheet_inquiry_facts(facts)) + facts = _drop_quote_like_place_values(facts, source_text=text) facts = _apply_air_added_services(text, facts, mode) land_subtype = str((b_payload or {}).get("land_subtype") or facts.get("运输类型") or "").strip() if land_subtype and not str(facts.get("运输类型") or "").strip(): @@ -1013,8 +1087,18 @@ _SKIP_FEE_NAMES = frozenset( # 航线常写「空运费 单价CNY99,数量100,金额CNY199」。单价和金额不是同一个数,必须认金额。 # WO202610130003:协同句常见「空运费, KG, 单价CNY99.00, 数量100, 金额CNY9900」——费名与单价之间夹单位; # WO202610140008:航线写「增值费_板,单价CNY10…」——下划线单位;漏认会落到金额误抽,出单默认单位「票」。 +# WO202610190081:打板费,次,单价…——「次/人」等中文单位不在旧名单里时整句匹配失败,数量落成 1、金额=单价。 # 数量/金额前也可能只有空格无逗号。漏匹配会落到 _RE_FEE_LINE,把单价 99 当成行金额、数量被补成 1。 -_FEE_UOM = r"KG|KGS|公斤|千克|TON|CBM|柜|票|板数|板|点|[A-Za-z]{1,8}" +# 业务单位表(长词/带斜杠优先,避免「次/人」被拆成「次」): +# 板数/点/人/天/货值/个/页数/柜/次/份/张/台/平方/件/计重/箱数/数量/千克/立方/卷/ +# 单数/票数/次/人/车/小时/人/小时/公里数/月/趟/周/批/项 + 英文重量体积码。 +# 末尾短中文通配:表外单位夹在费名与单价之间也不挡抽取。 +_FEE_UOM = ( + r"次/人|小时/人|板数|页数|箱数|单数|票数|公里数|货值|计重|平方|立方|千克|小时|" + r"数量|KG|KGS|公斤|TON|CBM|柜|票|板|点|次|人|天|个|份|张|台|件|卷|车|月|趟|周|批|项|" + r"托|套|袋|包|箱|" + r"[A-Za-z]{1,8}|[\u4e00-\u9fff]{1,3}" +) _RE_FEE_PRICED = re.compile( r"(?P[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)" rf"(?:(?:\s*[,,、/]\s*|\s+|_)(?P{_FEE_UOM}))?" @@ -1251,7 +1335,9 @@ def _parse_fee_rows(text: str, baseline_currency: str = "") -> list[dict[str, st row[key] = val rows.append(row) + priced_hits = 0 for hit in _RE_FEE_PRICED.finditer(raw): + priced_hits += 1 # 有「金额」用金额;只有单价时才用单价。禁止把单价误当成这一行的总金额。 amount = (hit.group("amount") or "").strip() or (hit.group("unit") or "").strip() ccy = hit.group("amt_ccy") or hit.group("unit_ccy") or "" diff --git a/inquiry-agent/agent/llm/mode_extract_adjust.py b/inquiry-agent/agent/llm/mode_extract_adjust.py index fa438df..5cee373 100644 --- a/inquiry-agent/agent/llm/mode_extract_adjust.py +++ b/inquiry-agent/agent/llm/mode_extract_adjust.py @@ -117,6 +117,11 @@ def build_adjust_messages(text: str, fee_names: list[str]) -> list[dict[str, str "只动费用合计(销售也可能说总销售价、总成本价、合计、总价、汇总价):wants_total=true,items 仍要一条(fee_name 可空),带 kind/direction/value。" "kind 只能 percent 或 target;direction 只能 up/down/set。" "value 必须来自原话数字;一成=10。禁止编造费用名和金额。" + "语义:上调/加/涨 X% → kind=percent direction=up;" + "下调/减/降 X% → kind=percent direction=down;" + "增加到/提升到/调整到/涨到/升到/增至/变为/上浮到/调到/改为 X% " + "→ kind=percent direction=set(改到原价的 X%,不是再上调 X%);" + "提升到/改为/变为 具体金额(无百分号)→ kind=target direction=set。" "不是调价(问候、新询价、谢谢)时 is_adjust=false。" f"当前可调费用名:{names}" ), diff --git a/inquiry-agent/agent/llm/mode_extract_fields.py b/inquiry-agent/agent/llm/mode_extract_fields.py index 3b8188f..62aa711 100644 --- a/inquiry-agent/agent/llm/mode_extract_fields.py +++ b/inquiry-agent/agent/llm/mode_extract_fields.py @@ -125,7 +125,8 @@ def build_extract_messages(text: str) -> list[dict[str, str]]: "你是询价字段抽取器。只根据销售原话抽取字段,调用 extract_inquiry_fields。" "未出现的字段必须空串,禁止编造港口、重量、日期。" "空运件数/毛重/体积/包装方式;海运货量与箱型箱量;陆运按二级类型。" - "陆运整车/中港零担的车型写入车型/数量:45HQ(13.5厢车)、9米6/1、3T;" + "国内整车/跨境整车的车型写入车型/数量:45HQ(13.5厢车)、9米6/1、3T;" + "中港零担/集拼不要抽车型/数量;中港整车的车型写入车型/数量;" "陆运不要把 45HQ/厢车 写成箱型箱量。" "海运时 15件/20吨/3箱 必须写入货量,不要只写到件数把货量留空。" "原话已带单位必须抽取,不要因为没写「毛重:」就留空:" @@ -144,6 +145,7 @@ def build_extract_messages(text: str) -> list[dict[str, str]]: "陆运「A到B」必须写入起运港和目的港。" "原话是机场三字码或航线就原样记,禁止翻译成城市名:" "SZX-CGK / SZX/CGK / SZX到CGK → 起运港=SZX、目的港=CGK,不要写成深圳、雅加达。" + "报价编号、报价单编号、工单号(WO/VT…)、长数字编号不是起运港或目的港,必须空串。" "换城市必须覆盖旧值,不要沿用上一句的港口。" "运输方式仅在原话能判断时填 AIR/SEA/LAND,否则 UNKNOWN。" ), diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index eabb419..f042ec3 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -13,7 +13,6 @@ import json import logging import re import time -from pathlib import Path from typing import Any, Optional from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote @@ -71,6 +70,60 @@ def _ticket_flight_date(ticket: object) -> str: ).strip() +def _quote_date_from_lock_instruction(instruction: str) -> str: + """ + 锁舱补参句里的报价日期。 + + 先认「报价日期:…」标签,再机读句末 yyyy-MM-dd(产品示例: + AIR-OPT01,南航,航班CZ327,2026-10-18)。不改主账。 + """ + from agent.llm.extract_text import labeled_quote_date + from agent.schema.field_validate import _harvest_quote_date + + labeled = labeled_quote_date(instruction or "") + if labeled: + return labeled + return _harvest_quote_date(instruction or "") + + +def _absorb_lock_quote_date( + *, + flow: Any, + ticket: object, + instruction: str, + work_order_no: str, +) -> tuple[object, str, str]: + """ + 工单报价日空、锁舱句里又写了日期时,先写入主账再锁。 + + 返回:(最新 ticket, 生效报价日, 本句抽出的日期)。 + 写失败不挡缺项提示;生效日仍可用本句日期做本地齐套判断。 + """ + from agent.schema.field_validate import is_blank_quote_date + + current = _ticket_flight_date(ticket) + said = _quote_date_from_lock_instruction(instruction) + if not said: + return ticket, current, "" + if not is_blank_quote_date(current): + return ticket, current, said + writer = getattr(flow.ledger, "update_facts", None) + write_ok = False + if callable(writer): + try: + out = writer(work_order_no=work_order_no, facts={"报价日期": said}) or {} + write_ok = bool(out.get("ok", True)) + except Exception: + logger.exception("锁舱句写报价日期失败 wo=%s", work_order_no) + write_ok = False + if write_ok: + getter = getattr(flow.ledger, "get_ticket", None) + reloaded = getter(work_order_no=work_order_no) if callable(getter) else None + ticket2 = hydrate_ticket(flow, reloaded or ticket) + return ticket2, _ticket_flight_date(ticket2) or said, said + return ticket, said, said + + def _lock_receipt_fields( ticket: object, quote: dict[str, Any], @@ -451,6 +504,20 @@ def _total_matches_fee_row_sum(quote: dict[str, Any], total_text: str) -> bool: return abs(total_num - summed) < 0.01 +def _total_is_thin_vs_tms(cur_total: str, tms_total: str) -> bool: + """ + 薄报价:明细加总明显小于 TMS 销售价(WO040:200 << 7918)才盖成销售价。 + + 航线在舱位价上再加增值费/增点费时,total=明细之和会大于 salePrice(如 4966 > 3966), + 此时必须保留航线总价,禁止再盖回 TMS。 + """ + cur = _money_plain_number(cur_total) + tms = _money_plain_number(tms_total) + if cur is None or tms is None: + return False + return cur + 0.01 < tms + + def _sheet_quote_with_tms_sale_total( quote: dict[str, Any], *, @@ -490,7 +557,10 @@ def _sheet_quote_with_tms_sale_total( out[key] = proj_val if not tms_total: return out - need_tms_total = (not cur_total) or cur_total == "-" or _total_matches_fee_row_sum(out, cur_total) + # 仅「空/薄报价加总」才盖 TMS;航线加出增值费后 total>salePrice 必须保留。 + matches_sum = bool(cur_total) and cur_total != "-" and _total_matches_fee_row_sum(out, cur_total) + thin_vs_tms = matches_sum and _total_is_thin_vs_tms(cur_total, tms_total) + need_tms_total = (not cur_total) or cur_total == "-" or thin_vs_tms if need_tms_total: out["total"] = str(projected.get("total") or tms_total).strip() if projected.get("currency") and not str(out.get("currency") or "").strip(): @@ -500,9 +570,13 @@ def _sheet_quote_with_tms_sale_total( def resolve_air_mentions(flow: Any, ticket: object) -> tuple[str, list[str], list[str]]: """ - 空运群摘要必须 @ 到本单销售和航线·空运,对上海运 @ 销售/@ 产品。 + 空运群摘要 @ 本单销售 + 负责本单线路的航线·空运(按后台 routes 过滤)。 + 对上海运 @ 销售/@ 产品;禁止再按岗位整表广播。 """ + from agent.policy.deal_followup import air_staff_route_matches + view = ticket_view(ticket) + facts = dict(view.get("facts") or {}) sales_id = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip() sales_name = str(view.get("sales_name") or view.get("salesName") or "").strip() if not sales_name or sales_name == "销售": @@ -519,7 +593,25 @@ def resolve_air_mentions(flow: Any, ticket: object) -> tuple[str, list[str], lis rows = list(lister(role_code="air") or []) except Exception: logger.exception("列航线岗失败") + matched_rows: list[dict[str, Any]] = [] for row in rows: + if not isinstance(row, dict): + continue + if air_staff_route_matches(routes=str(row.get("routes") or ""), facts=facts): + matched_rows.append(row) + # 对不上具体线路时,只保留「空运全线」,避免再 @ 全岗 + if not matched_rows: + for row in rows: + if not isinstance(row, dict): + continue + toks = [ + t.strip() + for t in str(row.get("routes") or "").replace(",", ",").replace("、", ",").split(",") + if t.strip() + ] + if any(t in {"空运全线", "全线"} for t in toks): + matched_rows.append(row) + for row in matched_rows: uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip() name = str(row.get("name") or "").strip() or _staff_display_name(flow, uid) if not uid: @@ -1062,6 +1154,13 @@ def apply_air_lock( if blocked: return blocked no = ticket_no(ticket) + # 补参句带日期时先写入,避免舱位三项齐了仍反复要报价日期。 + ticket, quote_date, _said_quote_date = _absorb_lock_quote_date( + flow=flow, + ticket=ticket, + instruction=instruction, + work_order_no=no, + ) name = operator_name(flow, sender_id) view_quote = dict(ticket_view(ticket).get("quote") or {}) air_quote = dict((view_quote.get("segment_quotes") or {}).get("AIR") or {}) @@ -1079,7 +1178,9 @@ def apply_air_lock( origin, destination, airline_label, flight_date = _lock_receipt_fields( ticket, quote, picked, air_code ) - missing = _missing_lock_labels(picked, air_code, flight) + if not quote_date: + quote_date = _ticket_flight_date(ticket) + missing = _missing_lock_labels(picked, air_code, flight, quote_date=quote_date) if missing and _quote_ready_for_lock(quote): _remember_partial_lock( flow, @@ -1812,8 +1913,20 @@ def _option_row(quote: dict[str, Any], option_no: str) -> Optional[dict[str, Any return None -def _missing_lock_labels(option_no: str, airline: str, flight_no: str) -> list[str]: - """缺的中文名,顺序固定:舱位编号、航司、航班号。""" +def _missing_lock_labels( + option_no: str, + airline: str, + flight_no: str, + *, + quote_date: str = "", +) -> list[str]: + """ + 缺的中文名,顺序固定:舱位编号、航司、航班号、报价日期。 + + 报价日期空也要进缺项,避免先只补舱位三项、再被主账单独拦一次。 + """ + from agent.schema.field_validate import is_blank_quote_date + missing: list[str] = [] if not (option_no or "").strip(): missing.append("舱位编号") @@ -1821,12 +1934,18 @@ def _missing_lock_labels(option_no: str, airline: str, flight_no: str) -> list[s missing.append("航司") if not _FLIGHT_CODE_RE.match((flight_no or "").strip().upper()): missing.append("航班号") + if is_blank_quote_date(quote_date): + missing.append("报价日期") return missing def _labels_from_incomplete_reason(reason: str) -> list[str]: """主账回了「缺少:…」时,按中文名收回缺项,丢掉英文字段名。""" - found = [label for label in ("舱位编号", "航司", "航班号") if label in (reason or "")] + found = [ + label + for label in ("舱位编号", "航司", "航班号", "报价日期") + if label in (reason or "") + ] return found or ["舱位编号", "航司", "航班号"] diff --git a/inquiry-agent/agent/policy/air_quote_copy.py b/inquiry-agent/agent/policy/air_quote_copy.py index 89aa7f1..e4f6e27 100644 --- a/inquiry-agent/agent/policy/air_quote_copy.py +++ b/inquiry-agent/agent/policy/air_quote_copy.py @@ -216,15 +216,22 @@ def _fee_rows(quote: dict[str, Any] | None, facts: dict[str, str] | None) -> lis ) src = dict(quote or {}) - option = resolve_display_air_option(src, facts) + # 销售调价后扁平 fee_lines 是真相;舱位 feeItems 常仍是 TMS 原价/被合计盖住的单价。 + source = str(src.get("source") or "").strip().upper() + label = str(src.get("source_label") or src.get("sourceLabel") or "").replace(" ", "") + prefer_flat = source == "SALES_ADJUST" or "销售调价" in label rows: list[dict[str, Any]] = [] - if option: - from agent.schema.air_route_options import option_fee_dicts + if prefer_flat: + rows = _named_rows(src.get("fee_lines") or src.get("fee_rows")) + if not rows: + option = resolve_display_air_option(src, facts) + if option: + from agent.schema.air_route_options import option_fee_dicts - # 与投影同一套:舱位缺 feeItems 时补整单顶层空运费,再拼起运/目的附加费。 - rows = option_fee_dicts(option, src) - if not rows and len(parse_air_options(src)) > 1: - return [] + # 与投影同一套:舱位缺 feeItems 时补整单顶层空运费,再拼起运/目的附加费。 + rows = option_fee_dicts(option, src) + if not rows and len(parse_air_options(src)) > 1: + return [] if not rows: rows = _named_rows(src.get("feeItems") or src.get("fee_items")) if not rows: diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index 603ea20..3e62d2c 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -2264,6 +2264,26 @@ class AirTextInquiryFlow: ) return phase + def abandon_orphan_lost_wait(self, sess: FlowSession) -> None: + """ + 主账已无此单,但仍停在「等未成交原因」:丢掉书签与进程内索引。 + + 清数后常见:Redis 还挂着 WO,下一句永远 patch 失败,新询价也被抢走。 + """ + sid = (sess.sender_id or "").strip() + no = (sess.work_order_no or "").strip() + if sid: + with self._lock: + self._by_sender.pop(sid, None) + self._delete_bookmark(sid) + if no: + with self._lock: + self._by_ticket.pop(no, None) + upper = no.upper() + for key in list(self._by_ticket.keys()): + if (key or "").upper() == upper: + self._by_ticket.pop(key, None) + def _capture_lost_reason(self, sess: FlowSession, text: str, reply: ReplyFn) -> str: """私聊未成交后收下原因,不再追问。""" from agent.policy.deal_outcome import capture_lost_reason @@ -2274,6 +2294,16 @@ class AirTextInquiryFlow: reason=text, ) if not result.get("ok"): + err = str(result.get("error") or "").strip() + # 清数后主账无单:不能再死循环「请再发一句」,否则挡死新询价。 + if err in {"ticket_not_found", "not_found"}: + no = (sess.work_order_no or "").strip() + self.abandon_orphan_lost_wait(sess) + reply( + f"工单 {no or ''} 已不在系统中(可能已清数),无需再补未成交原因。" + "请直接发起新询价。" + ) + return "orphan_cleared" reply("未成交原因没有记下,请再发一句。") return "wait_lost_reason" sess.phase = "done" diff --git a/inquiry-agent/agent/policy/deal_followup.py b/inquiry-agent/agent/policy/deal_followup.py index da633c4..db0d347 100644 --- a/inquiry-agent/agent/policy/deal_followup.py +++ b/inquiry-agent/agent/policy/deal_followup.py @@ -181,11 +181,33 @@ def _split_routes(raw: str) -> list[str]: return out +def _air_od_codes(facts: dict[str, str] | None) -> tuple[str, str]: + """从工单 facts 取空运起运/目的三字码(兼容港/地/投影字段)。""" + src = dict(facts or {}) + origin = resolve_airport_code( + str( + src.get("起运港") + or src.get("起运地") + or src.get("originCode") + or src.get("origin_code") + or "" + ) + ) + dest = resolve_airport_code( + str( + src.get("目的港") + or src.get("目的地") + or src.get("destinationCode") + or src.get("destination_code") + or "" + ) + ) + return origin, dest + + def air_route_keys(facts: dict[str, str] | None) -> set[str]: """本单可对上的空运线路名:三字码对 + 目的港。""" - src = dict(facts or {}) - origin = resolve_airport_code(str(src.get("起运港") or src.get("originCode") or "")) - dest = resolve_airport_code(str(src.get("目的港") or src.get("destinationCode") or "")) + origin, dest = _air_od_codes(facts) keys: set[str] = set() if dest: keys.add(dest) @@ -194,6 +216,32 @@ def air_route_keys(facts: dict[str, str] | None) -> set[str]: return keys +def air_staff_route_matches(*, routes: str, facts: dict[str, str] | None) -> bool: + """ + 空运群 @ 航线:按员工「负责线路」是否对上本单。 + + 与成交负责人推送同口径(全线 / 外采 / 三字码对),但不要求 isLeader。 + 仅有目的港、无起运时:后台常配 CGO-KUL,允许 token 以 -目的 结尾命中。 + """ + tokens = _split_routes(routes) + if not tokens: + return False + if _has_all_line(tokens, line="AIR"): + return True + if any("外采-空运" in t or t == "外采-空运" for t in tokens) and _is_waicai(facts): + return True + origin, dest = _air_od_codes(facts) + keys = air_route_keys(facts) + if any(t in keys for t in tokens): + return True + # 只有目的港:KUL 应对上 CGO-KUL / SZX-KUL 等配线 + if dest and not origin: + suffix = f"-{dest}" + if any(t == dest or t.endswith(suffix) for t in tokens): + return True + return False + + def _is_waicai(facts: dict[str, str] | None) -> bool: src = dict(facts or {}) for key in ("外采", "是否外采", "采购方式"): @@ -251,12 +299,7 @@ def leader_matches( if line == "AIR": if code != "air": return False - if _has_all_line(tokens, line="AIR"): - return True - if "外采-空运" in tokens and _is_waicai(facts): - return True - keys = air_route_keys(facts) - return any(t in keys for t in tokens) + return air_staff_route_matches(routes=routes, facts=facts) return False diff --git a/inquiry-agent/agent/policy/deal_outcome.py b/inquiry-agent/agent/policy/deal_outcome.py index 2df8c10..8db0193 100644 --- a/inquiry-agent/agent/policy/deal_outcome.py +++ b/inquiry-agent/agent/policy/deal_outcome.py @@ -7,8 +7,11 @@ from __future__ import annotations +import json import logging import re +import time +from pathlib import Path from typing import Any, Callable, Optional from agent.config import get_settings @@ -16,12 +19,14 @@ from agent.policy import inquiry_copy as copy from agent.policy.deal_followup import ( DEFAULT_NEGOTIATE_HOURS, TIMEOUT_CODE_DEAL_NEGOTIATE, + air_route_keys, next_followup_at, pick_leaders, ) logger = logging.getLogger(__name__) + EnqueueFn = Callable[..., tuple[bool, str]] # 成交跟进里先认未成交,避免被「成交」误伤。 @@ -204,6 +209,7 @@ def finish_deal_outcome( business_line=business_line, ) enqueued = 0 + enqueue_fails: list[str] = [] for row in leaders: uid = str(row.get("wecomId") or "").strip() if not uid: @@ -218,9 +224,12 @@ def finish_deal_outcome( if ok: enqueued += 1 else: + enqueue_fails.append(f"{uid}:{info}") logger.warning("成交通知入队失败 wo=%s uid=%s info=%s", no, uid, info) - except Exception: + except Exception as exc: # noqa: BLE001 + enqueue_fails.append(f"{uid}:exc") logger.exception("成交通知入队异常 wo=%s uid=%s", no, uid) + _ = exc except Exception: logger.exception("成交通知组装失败,仍继续入账 wo=%s", no) summary = "通知未发出" @@ -383,3 +392,23 @@ def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict clear_wait(ledger, work_order_no=no) return out + + +def ticket_missing_in_ledger(ledger: Any, *, work_order_no: str, sender_id: str = "") -> bool: + """ + 主账已无此工单(清数/删单后)。 + + 用于丢掉「等原因」孤儿书签,避免死循环「请再发一句」并挡住新询价。 + """ + no = (work_order_no or "").strip() + if not no: + return True + getter = getattr(ledger, "get_for_agent", None) + if not callable(getter): + return False + try: + view = getter(work_order_no=no, sender_id=sender_id or "") or {} + except Exception: + logger.exception("查工单是否存在失败 wo=%s", no) + return False + return not bool(view.get("found")) diff --git a/inquiry-agent/agent/policy/event_exception.py b/inquiry-agent/agent/policy/event_exception.py index e2be884..d3611eb 100644 --- a/inquiry-agent/agent/policy/event_exception.py +++ b/inquiry-agent/agent/policy/event_exception.py @@ -215,7 +215,7 @@ def mentions_for_role( 从员工行里取出启用、有企微、有姓名的指定岗位。 空运群激活只记群号,协同成员经常是空的。超时巡检在群成员里找不到航线岗时, - 用本函数点名员工管理里的航线·空运,和进群摘要 @ 的是同一批人。 + 用本函数点名员工管理里已按本单线路筛过的航线·空运(与进群摘要同口径)。 不发消息、不写主账。 """ want = (role_code or "").strip().lower() diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 953f3fd..9c33e13 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -323,8 +323,10 @@ LAND_TMS_RETRYING = "正在重新查询TMS报价。工单:{wo}" _LAND_LCL_KEYS = ("起运港", "目的港", "品名", "体积", "毛重", "报价日期") _LAND_FTL_KEYS = ("起运港", "目的港", "品名", "车型/数量", "报价日期") -_LAND_HK_FULL_KEYS = ("起运港", "目的港", "品名", "报价日期") -_LAND_FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港零担/集拼"}) +# 中港零担/集拼:核对卡/方案卡不展示车型/数量(中港整车要采车型/数量) +_LAND_HK_LCL_KEYS = ("起运港", "目的港", "品名", "报价日期") +_LAND_FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港整车"}) +_LAND_HK_NO_VEHICLE_TYPES = frozenset({"中港零担/集拼"}) _LAND_CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港整车", "中港零担/集拼"}) _LAND_EXAMPLES = { "起运港": "广州", @@ -341,10 +343,10 @@ _LAND_EXAMPLES = { def _land_cargo_keys(land_type: str, facts: dict[str, str] | None = None) -> tuple[str, ...]: - """当前运输类型要展示的货物内部键。中港整车不要车型/数量。""" + """当前运输类型要展示的货物内部键。仅中港零担/集拼不要车型/数量。""" _ = facts - if land_type == "中港整车": - return _LAND_HK_FULL_KEYS + if land_type in _LAND_HK_NO_VEHICLE_TYPES: + return _LAND_HK_LCL_KEYS if land_type in _LAND_FTL_TYPES: return _LAND_FTL_KEYS return _LAND_LCL_KEYS @@ -629,7 +631,8 @@ def ask_clarify( 空运报价日期:说了就写成「报价日期:日期」,没说写成「报价日期(非必填):」,不进待补充。 海运报价日期写进已识别(销售说了用原话,没说用当天),不出现在待补充。 - 空运非必填一并列出,没填进待补充,不挡查价。 + 空运:仅当仍有必填缺口时,才把空着的非必填一并放进待补充; + 必填已齐则返回空串,由核对卡引导回复「确定」,禁止只甩一堆非必填逼回复。 """ src = dict(facts or {}) skip = {"报价日期"} @@ -660,45 +663,59 @@ def ask_clarify( val = known(src, key) if val: lines.append(f"{label}:{val}") - lines.append("") - lines.append(CLARIFY_PASTE_HINT) - any_missing = False - seq = 0 + # 先判断有没有必填缺口。仅非必填空着时不发「请复制粘贴」,交给核对卡让销售直接回复确定。 + required_items: list[tuple[str, str, str, str]] = [] if "运输方式" in missing_set: - any_missing = True - seq += 1 - lines.append(_pending_field_block(seq, "运输方式", "空运")) - if not sea and not known(src, "起运港"): - any_missing = True - seq += 1 - lines.append(_pending_field_block(seq, "起运地", _AIR_EXAMPLES["起运港"], optional=True)) + required_items.append(("运输方式", "运输方式", "空运", "")) for key, label, example in rows: if key in missing_set: - any_missing = True - seq += 1 if sea and key == "运输分类": - lines.append(_pending_field_block(seq, "运输分类", "Port to Port、其他")) + required_items.append((key, "运输分类", "Port to Port、其他", "")) else: - lines.append( - _pending_field_block( - seq, - label, - example, - unit=_PENDING_UNITS.get(key, ""), - ) + required_items.append( + (key, label, example, _PENDING_UNITS.get(key, "")) ) - if not sea: - optional_pending = [ + optional_pending: list[tuple[str, str, str]] = [] + if not sea and required_items: + # 已有必填要补时,空运非必填一并列出;起运地排在目的地前面。 + if not known(src, "起运港"): + optional_pending.append( + ("起运港", "起运地", _AIR_EXAMPLES["起运港"]) + ) + optional_pending.extend( (key, label, example) for key, label, example in _AIR_OPTIONAL_CLARIFY if key != "起运港" and not known(src, key) - ] - for key, label, example in optional_pending: - any_missing = True - seq += 1 - lines.append(_pending_field_block(seq, label, example, optional=True)) - if not any_missing: + ) + if not required_items: return "" + lines.append("") + lines.append(CLARIFY_PASTE_HINT) + seq = 0 + origin_block = "" + other_optional_blocks: list[str] = [] + for key, label, example in optional_pending: + if key == "起运港": + seq += 1 + origin_block = _pending_field_block( + seq, label, example, optional=True + ) + # 起运地(非必填)紧挨目的地上面:有则先写起运地,再写必填缺口。 + if origin_block: + lines.append(origin_block) + for _key, label, example, unit in required_items: + seq += 1 + lines.append( + _pending_field_block(seq, label, example, unit=unit) + ) + for key, label, example in optional_pending: + if key == "起运港": + continue + seq += 1 + other_optional_blocks.append( + _pending_field_block(seq, label, example, optional=True) + ) + lines.extend(other_optional_blocks) return "\n".join(lines) @@ -3179,19 +3196,21 @@ def air_no_tms_quote_to_lock(work_order_no: str) -> str: def air_lock_need_params(missing=None) -> str: """ - 舱位编号、航司、航班号没齐时,只请航线补全。 + 舱位编号、航司、航班号(及报价日期空时)没齐时,只请航线补全。 这时还没锁舱,不能说 TMS 锁舱失败,也不能通知 IT。 - 缺哪项就点哪项;三项都缺时是产品指定的那一句。 + 缺哪项就点哪项;报价日期也空时示例末尾带日期。 """ labels = [str(x).strip() for x in (missing or []) if str(x).strip()] if not labels: labels = ["舱位编号", "航司", "航班号"] joined = "、".join(labels) - return ( - f"锁舱参数不完整,缺少:{joined}。" - "请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。" - ) + # 报价日期空:产品指定示例带日期,避免航线补完舱位三项后再被单独拦一次。 + if "报价日期" in labels: + example = "AIR-OPT01,南航,航班CZ327,2026-10-18" + else: + example = "AIR-OPT01,南航,航班CZ327" + return f"锁舱参数不完整,缺少:{joined}。请补充后再锁,例如:{example}。" def air_lock_need_quote(work_order_no: str) -> str: diff --git a/inquiry-agent/agent/policy/multi_group_ops.py b/inquiry-agent/agent/policy/multi_group_ops.py index da96a03..dc78af0 100644 --- a/inquiry-agent/agent/policy/multi_group_ops.py +++ b/inquiry-agent/agent/policy/multi_group_ops.py @@ -603,10 +603,27 @@ def _staff_rows(book: Any, seg: Segment) -> list[dict[str, Any]]: return [] return [dict(x) for x in (lister(role_code="land") or []) if isinstance(x, dict)] if seg.mode == "AIR": + from agent.policy.deal_followup import air_staff_route_matches + lister = getattr(book, "list_staff_by_role", None) if not callable(lister): return [] - return [dict(x) for x in (lister(role_code="air") or []) if isinstance(x, dict)] + rows = [dict(x) for x in (lister(role_code="air") or []) if isinstance(x, dict)] + hits = [ + r + for r in rows + if air_staff_route_matches(routes=str(r.get("routes") or ""), facts=seg.facts) + ] + if not hits: + hits = [ + r + for r in rows + if any( + t.strip() in {"空运全线", "全线"} + for t in str(r.get("routes") or "").replace(",", ",").split(",") + ) + ] + return hits except Exception: logger.exception("多段激活匹配产品失败 mode=%s", seg.mode) return [] diff --git a/inquiry-agent/agent/policy/quote_adjust_ops.py b/inquiry-agent/agent/policy/quote_adjust_ops.py index 7894329..cc11429 100644 --- a/inquiry-agent/agent/policy/quote_adjust_ops.py +++ b/inquiry-agent/agent/policy/quote_adjust_ops.py @@ -87,7 +87,10 @@ def _reply_apply_messages(result, reply) -> None: def _emit_private_card(flow: Any, sess: Any, reply) -> None: - """新价格明细卡:海运/陆运带拉群,空运只带跳过协同。""" + """新价格明细卡:海运/陆运带拉群,空运只带跳过协同。 + + 空运货物信息用工单 facts(起运地/品名等);不传会整段变成「-」。 + """ line = (sess.business_line or "").upper() no = sess.work_order_no quote = dict(sess.quote or {}) @@ -95,6 +98,7 @@ def _emit_private_card(flow: Any, sess: Any, reply) -> None: quote["business_line"] = line first = getattr(sess, "first_or_same", "first") or "first" history = getattr(sess, "history_work_order_no", "") or "" + facts = {str(k): str(v) for k, v in dict(getattr(sess, "facts", None) or {}).items()} if line in {"SEA", "LAND"} and hasattr(flow, "_emit_sea_hit"): sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB) flow._emit_sea_hit( @@ -114,6 +118,7 @@ def _emit_private_card(flow: Any, sess: Any, reply) -> None: reply, work_order_no=no, quote=quote, + facts=facts, first_or_same=first, history_work_order_no=history, status="已报价", @@ -210,6 +215,13 @@ def try_private_adjust( sess.quote = persist sess.status = "已报价" sess.phase = "wait_collab" + # 必须先写回允许点的按钮再存书签。若等 emit 后再改 allowed, + # Worker 重启后会读到空 allowed,销售点「生成报价单」会被静默丢掉。 + line = (str(getattr(sess, "business_line", "") or "")).upper() + if line in {"SEA", "LAND"}: + sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB) + else: + sess.allowed = (copy.BTN_SKIP_COLLAB,) sess.wait_version += 1 flow._save(sess) _emit_private_card(flow, sess, reply) diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index 9ab2012..bc1c82e 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -1200,11 +1200,132 @@ def handle_group_file( return "file_archived" +def _live_appchat_member_ids(client: Any, chat_id: str) -> list[str]: + """ + 读企微应用群当前成员。主账名单过期时必须靠这个清人。 + + 失败回空列表,不抛。不写主账。 + """ + raw = (chat_id or "").strip() + if not raw: + return [] + info_fn = getattr(client, "_appchat_info", None) + if not callable(info_fn): + return [] + try: + info = info_fn(chat_id=raw) or {} + except Exception: + logger.exception("读应用群成员失败 chat=%s", raw) + return [] + return [ + str(x).strip() + for x in (info.get("userlist") or []) + if str(x).strip() + ] + + +def _merge_dismiss_userids(*groups: list[str]) -> list[str]: + """合并待移出名单,去重保序。""" + out: list[str] = [] + seen: set[str] = set() + for group in groups: + for uid in group or []: + u = str(uid or "").strip() + if not u or u in seen: + continue + seen.add(u) + out.append(u) + return out + + +def dissolve_orphan_group( + *, + flow: Any, + chat_id: str, + sender_id: str = "", +) -> str: + """ + 主账对不上工单时仍解散应用群。 + + 老群、迁库丢绑定、虚拟转人工未写主账都会走到这里。 + 以企微当前成员为准移人;群名像工单号则尽量标协同结束。 + 不改六态。先回话再移人。 + """ + room = (chat_id or "").strip() + if not room: + return "group_ignore" + client = group_client_of(flow) + info_fn = getattr(client, "_appchat_info", None) + info: dict[str, Any] = {} + if callable(info_fn): + try: + info = info_fn(chat_id=room) or {} + except Exception: + logger.exception("孤儿群读成员失败 chat=%s", room) + info = {} + members = _merge_dismiss_userids( + list(info.get("userlist") or []), + [sender_id] if (sender_id or "").strip() else [], + ) + name = str(info.get("name") or "").strip() + no = "" + try: + from agent.policy import inquiry_copy as _copy + + hit = str(_copy.extract_work_order_no(name) or "").strip() + if hit: + no = hit + elif name.upper().startswith(("WO", "VT")): + no = name.strip() + except Exception: + no = name if name.upper().startswith(("WO", "VT")) else "" + display_no = no or name or "-" + send_group_text(client, room, copy.group_dissolved(work_order_no=display_no)) + dismiss = getattr(client, "dismiss_group", None) + dismiss_ok = False + if callable(dismiss) and members: + out = dismiss(chat_id=room, userids=members) or {} + dismiss_ok = bool(out.get("ok")) + if not dismiss_ok: + logger.warning( + "孤儿群移人失败 chat=%s err=%s members=%s", + room, + out.get("error"), + len(members), + ) + elif not members: + logger.warning("孤儿群无成员可移 chat=%s name=%s", room, name or "-") + try: + from agent.channel.wshoto.rooms import unwatch_collab_room + + unwatch_collab_room(room) + except Exception: + logger.exception("孤儿群取消存档监听失败 chat=%s", room) + if no: + ender = getattr(getattr(flow, "ledger", None), "end_collab_group", None) + if callable(ender): + try: + ender(work_order_no=no) + except Exception: + logger.exception("孤儿群标协同结束失败 wo=%s", no) + logger.info( + "orphan_dissolve chat=%s name=%s wo=%s members=%s dismiss_ok=%s by=%s", + room, + name or "-", + no or "-", + len(members), + dismiss_ok, + (sender_id or "")[:32], + ) + return "group_dissolved" + + def dissolve_group(*, flow: Any, ticket: object, sess: Any = None) -> str: """ 解散应用群,这单协同结束,不改六态。 先发「群已解散」再移人:移完人销售可能已不在群,收不到确认。 + 移人名单 = 主账/书签成员 ∪ 企微当前成员,避免名单过期清不干净。 """ view = ticket_view(ticket) no = ticket_no(ticket) @@ -1213,11 +1334,22 @@ def dissolve_group(*, flow: Any, ticket: object, sess: Any = None) -> str: members = list(view.get("collab_member_ids") or view.get("memberIds") or []) if sess is not None: members = list(sess.invited_userids or members) + live = _live_appchat_member_ids(client, chat_id) + members = _merge_dismiss_userids(members, live) # 先回话再移人,避免销售被移出后看不到确认。 send_group_text(client, chat_id, copy.group_dissolved(work_order_no=no)) dismiss = getattr(client, "dismiss_group", None) - if callable(dismiss) and chat_id: - dismiss(chat_id=chat_id, userids=members) + if callable(dismiss) and chat_id and members: + out = dismiss(chat_id=chat_id, userids=members) or {} + if not out.get("ok"): + logger.warning( + "解散群移人失败 chat=%s wo=%s err=%s", + chat_id, + no, + out.get("error"), + ) + elif chat_id and not members: + logger.warning("解散群无成员可移 chat=%s wo=%s", chat_id, no) if chat_id: try: from agent.channel.wshoto.rooms import unwatch_collab_room diff --git a/inquiry-agent/agent/routing/group_intent.py b/inquiry-agent/agent/routing/group_intent.py index 1add7d8..cb18f3c 100644 --- a/inquiry-agent/agent/routing/group_intent.py +++ b/inquiry-agent/agent/routing/group_intent.py @@ -96,6 +96,18 @@ _CONFIRM_CHOICE = { "沿用tms", "沿用tms报价", } +# 解散相近说法。禁止只认整句「解散群」,模型失败时也要能清群。 +_DISSOLVE_HINTS = ( + "解散群", + "解散这个群", + "解散该群", + "把群解散", + "这个群解散", + "群解散吧", + "解散吧", + "清群", + "散群", +) _CONFIRM_HINTS = ( "确认报价", "确认该报价", @@ -195,6 +207,10 @@ def classify_group_text( exact = _exact_tms_choice(raw) if exact: return exact + # 先认解散口令:避免模型关/失败时「解散群」掉进 other 静默无反应。 + hinted_dissolve = _hint_dissolve(raw) + if hinted_dissolve: + return hinted_dissolve try: from agent.config import get_settings @@ -303,6 +319,22 @@ def _hint_deal_outcome(text: str) -> str: return "" +def _hint_dissolve(text: str) -> str: + """ + 要把协同群解散。口令不写死整句「解散群」。 + + 含金额的报价句不走这里(上层已先抽价)。整句很短且像清群时认。 + """ + blob = _norm_intent_text(text) + if not blob: + return "" + if any(x in blob for x in _DISSOLVE_HINTS): + return INTENT_DISSOLVE + if "解散" in blob and "群" in blob: + return INTENT_DISSOLVE + return "" + + def _classify_with_model(text: str, settings) -> str: """DeepSeek 只回一个意图词。失败空串。""" from agent.llm.http_client import LlmHttpClient diff --git a/inquiry-agent/agent/schema/field_validate.py b/inquiry-agent/agent/schema/field_validate.py index 45b0439..d3ff97f 100644 --- a/inquiry-agent/agent/schema/field_validate.py +++ b/inquiry-agent/agent/schema/field_validate.py @@ -146,7 +146,6 @@ _LAND_COLLAB_BY_PAIR: dict[tuple[str, str], tuple[str, ...]] = { ("中港整车", "中港/中亚/中欧"): ( "客户名称", "HS编码", - "车型/数量", "贸易条款", "货值", "包装方式", @@ -769,21 +768,29 @@ def _pricing_required_keys( land_subtype: str, facts: dict[str, str] | None = None, ) -> list[str]: - """当前激活的首次查价内部键列表。中港整车/零担是两个运输类型。""" + """ + 当前激活的首次查价内部键列表。中港整车/零担是两个运输类型。 + + 合同里偶有「车型数量」等别名键,这里折成 normalize_facts 后的内部键, + 避免销售已填「车型/数量」仍被判缺。 + """ _ = facts line = (business_line or "").strip().upper() pricing = contract.get("pricing_required") or {} + raw: list[str] = [] if line == "SEA": - return list((pricing.get("SEA") or {}).get("always") or []) - if line == "AIR": - return list((pricing.get("AIR") or {}).get("always") or []) - if line == "LAND": + raw = list((pricing.get("SEA") or {}).get("always") or []) + elif line == "AIR": + raw = list((pricing.get("AIR") or {}).get("always") or []) + # 空运选填键不得进首次查价必填:合同与 AIR_OPTIONAL_FIELDS 对齐,避免再逼销售补起运地。 + optional_air = {key for key, _label in AIR_OPTIONAL_FIELDS} | {"报价日期"} + raw = [k for k in raw if k not in optional_air] + elif line == "LAND": land = pricing.get("LAND") or {} block = land.get(land_subtype) or {} - if not block: - return [] - return list(block.get("always") or []) - return [] + if block: + raw = list(block.get("always") or []) + return [_ALIASES.get(k, k) for k in raw] _PLACE_KEYS = ("起运港", "目的港") diff --git a/inquiry-agent/agent/schema/quote_adjust.py b/inquiry-agent/agent/schema/quote_adjust.py index 074fa8b..e6cd8d1 100644 --- a/inquiry-agent/agent/schema/quote_adjust.py +++ b/inquiry-agent/agent/schema/quote_adjust.py @@ -23,9 +23,15 @@ _PERCENT_UP = re.compile( r"(上调|上涨|上升|上浮|提高|提升|加|涨|升)\s*(\d+(?:\.\d+)?)\s*%" ) _PERCENT_DOWN = re.compile(r"(下调|下降|降低|减|降)\s*(\d+(?:\.\d+)?)\s*%") -_TARGET = re.compile( - r"(提升到|减少到|上调到|下调到|加到|减到|调到|改为|改成)\s*(\d+(?:\.\d+)?)" +# 「增加到/调整到/涨到 110%」= 改到原价的 110%,不是上调 110%。必须先于绝对金额与相对上调。 +_TO_VERBS = ( + "增加到|提高到|提升到|减少到|上调到|下调到|调整到|上浮到|上涨到|上升到|" + "涨到|升到|加到|减到|调到|增至|变为|变成|改为|改成|调整为|调整成" ) +_PERCENT_TO = re.compile(rf"({_TO_VERBS})\s*(\d+(?:\.\d+)?)\s*%") +# (?!\d) 防止「增加到110%」回退成「加到11」;(?!\s*%) 把带百分号的留给 _PERCENT_TO。 +_TARGET = re.compile(rf"({_TO_VERBS})\s*(\d+(?:\.\d+)?)(?!\d)(?!\s*%)") +_DAO_PERCENT = re.compile(rf"(?:{_TO_VERBS})\s*(\d+(?:\.\d+)?)\s*%") _SPLIT = re.compile(r"[,,;;。!!??\n]|和(?=\S)") _FEE_TOKEN = re.compile(r"([\u4e00-\u9fffA-Za-z0-9]{1,16}费)") _TOTAL_WORDS = ("费用合计", "合计", "总价", "汇总价", "总销售价", "总成本价") @@ -432,9 +438,74 @@ def row_currency(row: dict[str, Any] | None, fallback: str = "") -> str: return prefix or (fallback or "").strip().upper().replace("RMB", "CNY") +def _parse_qty(raw: Any) -> Optional[float]: + """数量/计费重转浮点;读不了返回 None。""" + if raw in (None, ""): + return None + try: + return float(str(raw).replace(",", "").strip()) + except ValueError: + return None + + +def _row_or_quote_qty(row: dict[str, Any], quote: dict[str, Any] | None = None) -> Optional[float]: + """ + 费用行数量;行上没有时用工单/舱位计费重兜底。 + + 空运调价常只有 unitPrice+amount,quantity 在 chargeableWeightKg。 + """ + qty = _parse_qty(row.get("quantity")) + if qty is not None: + return qty + qty = _parse_qty(row.get("chargeQuantity")) + if qty is not None: + return qty + src = dict(quote or {}) + for key in ("chargeableWeightKg", "chargeable_weight_kg", "chargeableWeight"): + qty = _parse_qty(src.get(key)) + if qty is not None: + return qty + opt = src.get("selected_air_option") or src.get("selectedAirOption") + if isinstance(opt, str) and opt.strip(): + try: + opt = json.loads(opt) + except json.JSONDecodeError: + opt = None + if isinstance(opt, dict): + for key in ("chargeableWeightKg", "chargeable_weight_kg", "chargeableWeight"): + qty = _parse_qty(opt.get(key)) + if qty is not None: + return qty + return None + + def _apply_amount(row: dict[str, Any], amount: str) -> None: + """ + 写入行金额;有数量时单价=金额÷数量,禁止把行合计原样塞进单价。 + + 空运 KG:调价后金额是单价×计费重,展示读 unit_price;无数量(按票等)才单价=金额。 + """ row["amount"] = amount - row["unit_price"] = amount + qty_raw = row.get("quantity") + if qty_raw in (None, ""): + qty_raw = row.get("chargeQuantity") + qty_num: Optional[float] = None + if qty_raw not in (None, ""): + try: + qty_num = float(str(qty_raw).replace(",", "").strip()) + except ValueError: + qty_num = None + amt_num, _ = parse_money(amount) + if qty_num is not None and abs(qty_num) > 1e-9 and amt_num is not None: + # 有计费数量:单价从行金额反推,避免 3385.8 被当成「每KG」 + unit_plain = format_money(amt_num / qty_num, sample=str(row.get("unit_price") or row.get("unitPrice") or "")) + unit_plain = _plain_amount(unit_plain) + row["unit_price"] = unit_plain + row["unitPrice"] = unit_plain + else: + row["unit_price"] = amount + if row.get("unitPrice") not in (None, ""): + row["unitPrice"] = amount for key in ("salePrice", "price", "value"): if row.get(key) not in (None, ""): row[key] = amount @@ -518,7 +589,12 @@ def text_has_total_only_adjust(text: str) -> bool: 「总销售价改为180」仍是销售调价。点了「空运费」「报关费」则不是。不打模型。 """ raw = text or "" - if not (_TARGET.search(raw) or _PERCENT_UP.search(raw) or _PERCENT_DOWN.search(raw)): + if not ( + _TARGET.search(raw) + or _PERCENT_TO.search(raw) + or _PERCENT_UP.search(raw) + or _PERCENT_DOWN.search(raw) + ): return False if not any(word in raw for word in _TOTAL_WORDS) and "报价" not in raw: return False @@ -546,27 +622,30 @@ def extract_adjust( 本地回退只给无网/模型失败用,不是正式识别。 allow_model=False 时禁止打模型。 """ + raw = (text or "").strip() if injected is not None: if isinstance(injected, AdjustExtract): - return injected + return _coerce_dao_percent(raw, injected) items = [] - for raw in injected.get("items") or []: - if not isinstance(raw, dict): + for row in injected.get("items") or []: + if not isinstance(row, dict): continue items.append( AdjustItem( - fee_name=str(raw.get("fee_name") or "").strip(), - kind=str(raw.get("kind") or "percent"), - direction=str(raw.get("direction") or "up"), - value=float(raw.get("value") or 0), + fee_name=str(row.get("fee_name") or "").strip(), + kind=str(row.get("kind") or "percent"), + direction=str(row.get("direction") or "up"), + value=float(row.get("value") or 0), ) ) - return AdjustExtract( - is_adjust=bool(injected.get("is_adjust")), - items=items, - wants_total=bool(injected.get("wants_total")), + return _coerce_dao_percent( + raw, + AdjustExtract( + is_adjust=bool(injected.get("is_adjust")), + items=items, + wants_total=bool(injected.get("wants_total")), + ), ) - raw = (text or "").strip() if not raw: return AdjustExtract(is_adjust=False) names = [str(x).strip() for x in (fee_names or []) if str(x).strip()] @@ -617,11 +696,16 @@ def extract_adjust( items=merged, wants_total=bool(modeled.wants_total), ) - return modeled + return _coerce_dao_percent(raw, modeled) except Exception: logger.exception("调价模型抽取失败,回退本地") total_hit = any(w in raw for w in _TOTAL_WORDS) - has_op = bool(_PERCENT_UP.search(raw) or _PERCENT_DOWN.search(raw) or _TARGET.search(raw)) + has_op = bool( + _PERCENT_UP.search(raw) + or _PERCENT_DOWN.search(raw) + or _PERCENT_TO.search(raw) + or _TARGET.search(raw) + ) fee_in_text = [n for n in names_sorted if n and n in raw] if total_hit and has_op and not fee_in_text: op = _item_from_chunk(raw, "") @@ -634,7 +718,9 @@ def extract_adjust( item = _item_from_chunk(raw, "") if item: items.append(item) - return AdjustExtract(is_adjust=bool(items or has_op), items=items) + return _coerce_dao_percent( + raw, AdjustExtract(is_adjust=bool(items or has_op), items=items) + ) def _local_fee_items(raw: str, names_sorted: list[str]) -> list[AdjustItem]: @@ -664,10 +750,40 @@ def _local_fee_items(raw: str, names_sorted: list[str]) -> list[AdjustItem]: return items +def _coerce_dao_percent(text: str, extract: AdjustExtract) -> AdjustExtract: + """ + 「增加到110%」纠正为 percent/set。 + + 模型常抽成 percent/up;本地旧正则会抽成绝对金额 target。两种都不能当上调。 + """ + hit = _DAO_PERCENT.search(text or "") + if not hit or not extract.items: + return extract + val = float(hit.group(1)) + fixed: list[AdjustItem] = [] + changed = False + for item in extract.items: + same_num = abs(float(item.value) - val) < 1e-9 + if item.kind == "percent" and item.direction in {"up", "down"} and same_num: + fixed.append(AdjustItem(item.fee_name, "percent", "set", val)) + changed = True + elif item.kind == "target" and item.direction == "set" and same_num: + fixed.append(AdjustItem(item.fee_name, "percent", "set", val)) + changed = True + else: + fixed.append(item) + if not changed: + return extract + return AdjustExtract(is_adjust=extract.is_adjust, items=fixed, wants_total=extract.wants_total) + + def _item_from_chunk(chunk: str, fee_name: str) -> Optional[AdjustItem]: + to_pct = _PERCENT_TO.search(chunk) up = _PERCENT_UP.search(chunk) down = _PERCENT_DOWN.search(chunk) target = _TARGET.search(chunk) + if to_pct: + return AdjustItem(fee_name=fee_name, kind="percent", direction="set", value=float(to_pct.group(2))) if target: return AdjustItem(fee_name=fee_name, kind="target", direction="set", value=float(target.group(2))) if up: @@ -1167,6 +1283,10 @@ def _apply_total_only( if item.kind == "target": new_amt = float(item.value) how = f"改到 {item.value:g}" + elif item.kind == "percent" and item.direction == "set": + # 「费用合计增加到110%」= 改到原合计的 110%,不是上调 110% + new_amt = old_total * (float(item.value) / 100.0) + how = f"调到 {item.value:g}%" elif item.direction == "down": new_amt = old_total * (1 - float(item.value) / 100.0) how = f"下调 {item.value:g}%" @@ -1311,15 +1431,45 @@ def apply_adjust( if old_amt is None: continue line_ccy = row_currency(row, header) or prefix - if item.kind == "target": + old_up, _ = parse_money(row.get("unit_price") or row.get("unitPrice")) + qty_num = _row_or_quote_qty(row, src) + # 有单价且有计费重:比例调价按「单价」缩放,再算行金额=单价×数量。 + # 否则会把行合计×110% 后整笔写进单价(15→42633.53)。 + scale_by_unit = ( + item.kind == "percent" + and old_up is not None + and qty_num is not None + and abs(qty_num) > 1e-9 + and abs(old_up - old_amt) > 1e-6 + ) + if scale_by_unit: + if item.direction == "set": + new_up = old_up * (float(item.value) / 100.0) + how = f"调到 {item.value:g}%" + elif item.direction == "down": + new_up = old_up * (1 - float(item.value) / 100.0) + how = f"下调 {item.value:g}%" + else: + new_up = old_up * (1 + float(item.value) / 100.0) + how = f"上调 {item.value:g}%" + new_amt = new_up * qty_num + if row.get("quantity") in (None, ""): + row["quantity"] = f"{qty_num:.10g}" + elif item.kind == "target": new_amt = float(item.value) how = f"改到 {item.value:g}" + elif item.kind == "percent" and item.direction == "set": + # 「运输费增加到110%」= 原价 × 110%,不是上调 110%(×210%) + new_amt = old_amt * (float(item.value) / 100.0) + how = f"调到 {item.value:g}%" elif item.direction == "down": new_amt = old_amt * (1 - float(item.value) / 100.0) how = f"下调 {item.value:g}%" else: new_amt = old_amt * (1 + float(item.value) / 100.0) how = f"上调 {item.value:g}%" + if qty_num is not None and row.get("quantity") in (None, ""): + row["quantity"] = f"{qty_num:.10g}" _apply_amount(row, format_money(new_amt, sample=str(row.get("amount") or ""), currency=line_ccy or prefix)) rows[idx] = row if not any_applied: diff --git a/inquiry-agent/agent/schema/tms_land_query.py b/inquiry-agent/agent/schema/tms_land_query.py index c283fc7..1c57ff9 100644 --- a/inquiry-agent/agent/schema/tms_land_query.py +++ b/inquiry-agent/agent/schema/tms_land_query.py @@ -4,7 +4,7 @@ 本文件职责:核对卡通过后、建单查价前,确认当前运输类型的货物字段能拼出查价正文。 带去:运输类型、线路类别、始发/目的/品名/报价日期,以及该类型的体积重量或车型/数量;通关口岸有值才带。 出站类型/线路译成 TMS 用的值,线路键名 routeCategory。工单与卡片仍用销售中文。 -中港整车、中港零担/集拼出站类型都只写「中港」。中港整车不带车型/数量。 +中港整车、中港零担/集拼出站类型按细类原文传。中港整车带车型/数量;仅中港零担/集拼不带。 禁止:把运输分类放进出站;直连 TMS;改六态。 线程:纯函数。 """ @@ -20,8 +20,10 @@ from agent.schema.field_validate import apply_quote_date_default, normalize_fact logger = logging.getLogger(__name__) LCL_TYPES = frozenset({"国内运输拼车", "跨境集拼"}) -FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港零担/集拼"}) -HK_FTL_TYPE = "中港整车" +FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港整车"}) +# 仅中港零担/集拼:询价不采车型/数量,出站也不带。 +HK_NO_VEHICLE_TYPES = frozenset({"中港零担/集拼"}) +HK_FTL_TYPE = "中港整车" # 兼容旧调用名;现与国内/跨境整车同属 FTL_TYPES CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港整车", "中港零担/集拼"}) _TYPE_ENUM = { @@ -29,8 +31,8 @@ _TYPE_ENUM = { "国内运输整车": "DOMESTIC_FTL", "跨境集拼": "CROSS_BORDER_LTL", "跨境整车": "CROSS_BORDER_FTL", - "中港整车": "中港", - "中港零担/集拼": "中港", + "中港整车": "中港整车", + "中港零担/集拼": "中港零担/集拼", } _ROUTE_ENUM = { "国内长途/零担": "DOMESTIC_LONG_LTL", @@ -45,7 +47,7 @@ _TAIL_QTY = re.compile(r"^(.*?)[\s,,]+(\d+)\s*(台|辆)?$") def tms_land_type_code(raw: str) -> str: - """销售中文运输类型 → 查价 landTransportType。中港两类都是「中港」。对不上空串。""" + """销售中文运输类型 → 查价 landTransportType。中港两类按细类原文。对不上空串。""" text = (raw or "").strip() if text in _TYPE_ENUM: return _TYPE_ENUM[text] @@ -140,7 +142,8 @@ def assemble_land_query(facts: dict[str, Any] | None) -> dict[str, Any]: missing.append("毛重") else: outbound["weightKg"] = wgt - elif land_type == HK_FTL_TYPE: + elif land_type in HK_NO_VEHICLE_TYPES: + # 中港零担/集拼:不要车型/数量,也不改要体积毛重 pass elif land_type in FTL_TYPES: combo = ( diff --git a/inquiry-agent/tests/test_deal_followup.py b/inquiry-agent/tests/test_deal_followup.py index 5c79149..e6cce41 100644 --- a/inquiry-agent/tests/test_deal_followup.py +++ b/inquiry-agent/tests/test_deal_followup.py @@ -12,6 +12,7 @@ from agent.policy.deal_followup import ( SHANGHAI, add_workday_hours, air_route_keys, + air_staff_route_matches, is_workday, leader_matches, next_followup_at, @@ -193,5 +194,37 @@ class TestLeaderMatch(unittest.TestCase): ) +class TestAirStaffRouteMatches(unittest.TestCase): + """群 @ 航线:按负责线路过滤,不要求 isLeader。""" + + def test_ehu_ord_pair_and_all_line(self) -> None: + facts = {"起运港": "EHU", "目的港": "ORD"} + self.assertTrue(air_staff_route_matches(routes="EHU-ORD", facts=facts)) + self.assertTrue(air_staff_route_matches(routes="空运全线", facts=facts)) + self.assertFalse(air_staff_route_matches(routes="CGO-KUL,SZX-KUL", facts=facts)) + self.assertFalse(air_staff_route_matches(routes="HKG-KUL,NKG-KUL", facts=facts)) + + def test_dest_only_matches_pair_suffix(self) -> None: + facts = {"目的港": "KUL"} + self.assertTrue(air_staff_route_matches(routes="CGO-KUL,SZX-KUL", facts=facts)) + self.assertTrue(air_staff_route_matches(routes="HKG-KUL,NKG-KUL", facts=facts)) + self.assertFalse(air_staff_route_matches(routes="EHU-ORD", facts=facts)) + self.assertFalse(air_staff_route_matches(routes="CGO-BLR,CGO-MAA", facts=facts)) + + def test_non_leader_still_matches(self) -> None: + # 群 @ 与成交推负责人不同:非负责人只要线路对也要 @ + facts = {"起运港": "EHU", "目的港": "ORD"} + self.assertTrue(air_staff_route_matches(routes="EHU-ORD", facts=facts)) + self.assertFalse( + leader_matches( + role_code="air", + routes="EHU-ORD", + is_leader=False, + business_line="AIR", + facts=facts, + ) + ) + + if __name__ == "__main__": unittest.main() diff --git a/inquiry-agent/tests/test_deal_outcome.py b/inquiry-agent/tests/test_deal_outcome.py index 28e89db..8bcea21 100644 --- a/inquiry-agent/tests/test_deal_outcome.py +++ b/inquiry-agent/tests/test_deal_outcome.py @@ -364,6 +364,53 @@ class TestDealOutcome(unittest.TestCase): self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).lost_reason, "价格高") self.assertTrue(any(t == "已记下未成交原因:价格高。" for t in replies)) + def test_orphan_lost_wait_clears_when_ticket_missing(self) -> None: + """清数后主账无单:等原因书签必须丢掉,不能死循环「请再发一句」。""" + from agent.handlers.text_inquiry import _flow_waiting_lost_reason + from agent.policy.air_text_flow import AirTextInquiryFlow + from agent.policy.land_text_flow import LandTextInquiryFlow + + book = MemoryLedger() + air = AirTextInquiryFlow(book) + sea = SeaTextInquiryFlow(book) + land = LandTextInquiryFlow(book) + ghost = "WO202610090041" + land._save( + FlowSession( + sender_id="YangHuiMing", + thread_id="YangHuiMing", + phase="wait_lost_reason", + business_line="LAND", + work_order_no=ghost, + status="未成交", + ) + ) + self.assertIsNotNone(land.session_of("YangHuiMing")) + # 路由前应 scrub 孤儿,不再交给陆运等原因 + self.assertIsNone(_flow_waiting_lost_reason("YangHuiMing", sea, air, land)) + self.assertIsNone(land.session_of("YangHuiMing")) + + # 若仍走到 capture:也应清书签并提示可新询价 + land._save( + FlowSession( + sender_id="YangHuiMing", + thread_id="YangHuiMing", + phase="wait_lost_reason", + business_line="LAND", + work_order_no=ghost, + status="未成交", + ) + ) + replies: list[str] = [] + phase = land._capture_lost_reason( + land.session_of("YangHuiMing"), + "客户取消订单", + lambda t, extra=None: replies.append(t), + ) + self.assertEqual(phase, "orphan_cleared") + self.assertTrue(any("已不在系统中" in t for t in replies)) + self.assertIsNone(land.session_of("YangHuiMing")) + def test_sea_private_next_text_is_lost_reason(self) -> None: flow = SeaTextInquiryFlow(self.ledger) flow._save( diff --git a/inquiry-agent/tests/test_event_exception.py b/inquiry-agent/tests/test_event_exception.py index b1e29b5..f2a1c29 100644 --- a/inquiry-agent/tests/test_event_exception.py +++ b/inquiry-agent/tests/test_event_exception.py @@ -300,10 +300,22 @@ class TestPoll(unittest.TestCase): "name": "吴季林", "roleCode": "air", "status": "active", + "routes": "空运全线", + }, + { + "wecomId": "air2", + "name": "KUL专员", + "roleCode": "air", + "status": "active", + "routes": "CGO-KUL,SZX-KUL", }, ] ) - created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={"起运港": "南京"}) + created = book.create_ticket( + sender_id="sales1", + business_line="AIR", + facts={"起运港": "EHU", "目的港": "ORD"}, + ) wo = created["work_order_no"] now = datetime(2026, 10, 9, 18, 0, 0, tzinfo=SHANGHAI) book.start_event_wait(work_order_no=wo, wait_kind="air_quote") @@ -331,6 +343,7 @@ class TestPoll(unittest.TestCase): f"航线响应超时\n关联询价工单 {wo}。@吴季林已超过 0.25 工作小时 无响应,事件异常已记录工单,请及时处理。", ) self.assertNotIn("相关人员", groups[0]["content"]) + self.assertNotIn("KUL专员", groups[0]["content"]) def test_sales_timeout_mentions_sales_not_airline(self) -> None: """销售超时只 @ 本单销售。私聊也带 @,不点航线,不写相关人员。""" diff --git a/inquiry-agent/tests/test_extract_oral.py b/inquiry-agent/tests/test_extract_oral.py index a6b2a34..0d78382 100644 --- a/inquiry-agent/tests/test_extract_oral.py +++ b/inquiry-agent/tests/test_extract_oral.py @@ -721,5 +721,56 @@ class AirQuoteSheetInquiryOnlyTests(unittest.TestCase): self.assertNotIn("友通达", blob) +class QuoteIdNotOriginTests(unittest.TestCase): + """识图把报价编号错贴到起运地时必须丢掉,不能当港口查价。""" + + def test_quote_no_labeled_as_origin_dropped(self) -> None: + text = ( + "报价编号:QT202610220088\n" + "起运地:QT202610220088\n" + "目的地:吉隆坡\n" + "品名:茶叶\n" + "件数:21件\n" + "毛重:11KG\n" + "体积:1.1*2.1*2.2\n" + "包装类型:托盘\n" + "运输方式:空运" + ) + facts = extract_inquiry_snapshot(text, allow_b=False)["facts"] + self.assertNotIn("起运港", facts) + self.assertEqual(facts.get("目的港"), "吉隆坡") + + def test_work_order_as_origin_dropped(self) -> None: + text = ( + "起运地:WO202610220001\n" + "目的地:吉隆坡\n" + "品名:茶叶\n" + "件数:10件\n" + "毛重:11KG\n" + "体积:1CBM\n" + "包装方式:托盘\n" + "空运" + ) + facts = extract_inquiry_snapshot(text, allow_b=False)["facts"] + self.assertNotIn("起运港", facts) + self.assertEqual(facts.get("目的港"), "吉隆坡") + + def test_real_city_origin_kept(self) -> None: + text = ( + "报价编号:QT202610220088\n" + "起运地:南京\n" + "目的地:吉隆坡\n" + "品名:茶叶\n" + "件数:10件\n" + "毛重:11KG\n" + "体积:1CBM\n" + "包装方式:托盘\n" + "空运" + ) + facts = extract_inquiry_snapshot(text, allow_b=False)["facts"] + self.assertEqual(facts.get("起运港"), "南京") + self.assertEqual(facts.get("目的港"), "吉隆坡") + + if __name__ == "__main__": unittest.main() diff --git a/inquiry-agent/tests/test_land_collab_fields.py b/inquiry-agent/tests/test_land_collab_fields.py index e0032a5..4eacff9 100644 --- a/inquiry-agent/tests/test_land_collab_fields.py +++ b/inquiry-agent/tests/test_land_collab_fields.py @@ -41,10 +41,12 @@ class LandCollabFieldsTest(unittest.TestCase): self.assertIn("HS编码", keys) self.assertNotIn("车型/数量", keys) - def test_hk_ftl_has_vehicle(self): + def test_hk_ftl_vehicle_is_inquiry_not_collab(self): + """中港整车车型/数量已改到询价必填,协同不再要。""" keys = land_collab_fields("中港整车", "中港/中亚/中欧") - self.assertIn("车型/数量", keys) + self.assertNotIn("车型/数量", keys) self.assertEqual(keys[0], "客户名称") + self.assertIn("HS编码", keys) def test_unlisted_pair_empty(self): self.assertEqual(land_collab_fields("国内运输拼车", "东南亚"), ()) diff --git a/inquiry-agent/tests/test_land_copy.py b/inquiry-agent/tests/test_land_copy.py index b37a546..fef9abf 100644 --- a/inquiry-agent/tests/test_land_copy.py +++ b/inquiry-agent/tests/test_land_copy.py @@ -53,11 +53,11 @@ class LandCopyTest(unittest.TestCase): ) self.assertIn("通关口岸(非必填):", text) - def test_hk_lcl_shows_vehicle_not_load_field(self): + def test_hk_lcl_hides_vehicle_not_load_field(self): text = copy.land_confirm_card( { "运输类型": "中港零担/集拼", - "线路类别": "东南亚", + "线路类别": "中港/中亚/中欧", "运输分类": "急件", "起运港": "广州", "目的港": "香港", @@ -67,24 +67,25 @@ class LandCopyTest(unittest.TestCase): } ) self.assertIn("运输类型(必填):中港零担/集拼", text) - self.assertIn("车型/数量(必填):9米6/1", text) + self.assertNotIn("车型/数量", text) self.assertNotIn("中港装载方式", text) self.assertIn("通关口岸(非必填)", text) - def test_hk_full_truck_hides_vehicle(self): + def test_hk_full_truck_shows_vehicle(self): text = copy.land_confirm_card( { "运输类型": "中港整车", - "线路类别": "东南亚", + "线路类别": "中港/中亚/中欧", "运输分类": "急件", "起运港": "广州", "目的港": "香港", "品名": "衣服", + "车型/数量": "9米6/1", "报价日期": "2026-09-17", } ) self.assertIn("运输类型(必填):中港整车", text) - self.assertNotIn("车型/数量", text) + self.assertIn("车型/数量(必填):9米6/1", text) self.assertNotIn("中港装载方式", text) def test_domestic_ftl_one_field(self): diff --git a/inquiry-agent/tests/test_land_field_validate.py b/inquiry-agent/tests/test_land_field_validate.py index d6b11e3..0abf449 100644 --- a/inquiry-agent/tests/test_land_field_validate.py +++ b/inquiry-agent/tests/test_land_field_validate.py @@ -31,24 +31,25 @@ class LandFieldValidateTest(unittest.TestCase): self.assertFalse(got["ok"]) self.assertIn("体积", got["missing"]) - def test_hk_lcl_still_needs_vehicle(self): + def test_hk_lcl_skips_vehicle(self): + """中港零担/集拼:询价不要车型/数量。""" got = validate_required_fields( facts={ "起运港": "广州", "目的港": "香港", "品名": "衣服", - "体积": "10CBM", - "毛重": "50kg", }, business_line="LAND", land_subtype="中港零担/集拼", ) - self.assertIn("车型/数量", got["missing"]) - self.assertNotIn("数量", got["missing"]) - self.assertNotIn("车型", got["missing"]) + self.assertTrue(got["ok"]) + self.assertNotIn("车型/数量", got["missing"]) + self.assertNotIn("体积", got["missing"]) + self.assertNotIn("毛重", got["missing"]) - def test_hk_full_truck_skips_vehicle(self): - got = validate_required_fields( + def test_hk_full_truck_requires_vehicle(self): + """中港整车:询价必填车型/数量。""" + missing = validate_required_fields( facts={ "起运港": "广州", "目的港": "香港", @@ -57,8 +58,20 @@ class LandFieldValidateTest(unittest.TestCase): business_line="LAND", land_subtype="中港整车", ) + self.assertFalse(missing["ok"]) + self.assertIn("车型/数量", missing["missing"]) + got = validate_required_fields( + facts={ + "起运港": "广州", + "目的港": "香港", + "品名": "衣服", + "车型/数量": "9米6/1", + }, + business_line="LAND", + land_subtype="中港整车", + ) self.assertTrue(got["ok"]) - self.assertNotIn("车型/数量", got["missing"]) + self.assertEqual(got["facts"]["车型/数量"], "9米6/1") def test_domestic_ftl_one_field(self): got = validate_required_fields( diff --git a/inquiry-agent/tests/test_land_text_inquiry.py b/inquiry-agent/tests/test_land_text_inquiry.py index 4158173..f268d35 100644 --- a/inquiry-agent/tests/test_land_text_inquiry.py +++ b/inquiry-agent/tests/test_land_text_inquiry.py @@ -112,25 +112,22 @@ class LandTextInquiryFrontTests(unittest.TestCase): self.assertEqual(sess.facts.get("运输类型"), "国内运输拼车") self.assertEqual(sess.facts.get("线路类别"), "东南亚") - def test_hk_lcl_asks_vehicle(self) -> None: + def test_hk_lcl_skips_vehicle(self) -> None: + """中港零担/集拼:不补问车型/数量,齐字段后直接出核对卡。""" phase = self.flow.on_text( sender_id="u1", - text="中港零担/集拼 东南亚 急件", + text="中港零担/集拼 中港/中亚/中欧 急件", reply=self._reply, injected_facts={ "起运港": "广州", "目的港": "香港", "品名": "衣服", - "体积": "10CBM", - "毛重": "50kg", }, injected_mode="LAND", ) - self.assertEqual(phase, "clarify") - self.assertIn("车型/数量(必填)", self._joined()) + self.assertEqual(phase, "wait_confirm") + self.assertNotIn("车型/数量", self._joined()) self.assertIn("通关口岸(非必填)", self._joined()) - self.assertNotIn("\n数量:", self._joined()) - self.assertNotIn(copy.LAND_CONFIRM_TAIL, self._joined()) self.assertEqual(self.ledger.create_calls, 0) def test_hk_full_truck_skips_vehicle(self) -> None: diff --git a/inquiry-agent/tests/test_quote_adjust.py b/inquiry-agent/tests/test_quote_adjust.py index 18e53e3..2398c12 100644 --- a/inquiry-agent/tests/test_quote_adjust.py +++ b/inquiry-agent/tests/test_quote_adjust.py @@ -22,6 +22,7 @@ from agent.schema.quote_adjust import ( apply_adjust, effective_quote, extract_adjust, + parse_money, private_session_can_adjust, total_price_label, ) @@ -48,6 +49,151 @@ class QuoteAdjustTests(unittest.TestCase): self.assertEqual(str(got.quote["total"]), "1300.00") self.assertFalse(got.currency_hint) + def test_increase_to_percent_is_set_not_up(self) -> None: + """「运输费用增加到110%」= 原价×1.1,不是上调 110%(×2.1)。""" + src = _quote(("运输费", "CNY 7500.00", "CNY"), total="CNY 7500.00") + ext = extract_adjust( + "运输费用增加到110%", + fee_names=["运输费"], + allow_model=False, + ) + self.assertTrue(ext.is_adjust) + self.assertEqual(len(ext.items), 1) + self.assertEqual(ext.items[0].kind, "percent") + self.assertEqual(ext.items[0].direction, "set") + self.assertEqual(ext.items[0].value, 110.0) + got = apply_adjust(src, ext, business_line="LAND", tms_cost="7500") + self.assertTrue(got.ok) + self.assertEqual(got.quote["fee_rows"][0]["amount"], "CNY 8250.00") + self.assertEqual(str(got.quote["total"]), "CNY 8250.00") + self.assertIn("调到 110%", got.applied[0]["how"]) + + def test_air_unit_price_percent_keeps_per_kg_not_line_total(self) -> None: + """空运「单价增加到110%」:单价 19→20.9,行金额=20.9×162;禁止把合计写进单价。""" + src = { + "currency": "CNY", + "total": "3438", + "fee_rows": [ + { + "name": "空运费", + "amount": "3078", + "currency": "CNY", + "unit": "KG", + "unit_price": "19", + "quantity": "162", + }, + { + "name": "人工费", + "amount": "200", + "currency": "CNY", + "unit": "人", + "unit_price": "200", + "quantity": "1", + }, + { + "name": "打板费", + "amount": "160", + "currency": "CNY", + "unit": "个", + "unit_price": "160", + "quantity": "1", + }, + ], + } + ext = extract_adjust( + "空运费单价增加到110%/KG", + fee_names=["空运费", "人工费", "打板费"], + allow_model=False, + ) + got = apply_adjust(src, ext, business_line="AIR") + self.assertTrue(got.ok) + air = next(r for r in got.quote["fee_rows"] if r["name"] == "空运费") + self.assertEqual(air["amount"], "3385.80") + self.assertEqual(str(air.get("unit_price") or ""), "20.90") + self.assertNotEqual(str(air.get("unit_price") or ""), "3385.80") + + def test_air_unit_percent_uses_chargeable_weight_when_qty_missing(self) -> None: + """费用行无 quantity 时,用工单计费重:单价15→16.5,禁止把行合计写进单价。""" + src = { + "currency": "CNY", + "total": "38757.75", + "chargeableWeightKg": "2583.85", + "fee_rows": [ + { + "name": "空运费", + "amount": "38757.75", + "currency": "CNY", + "unit": "KG", + "unit_price": "15", + "unitPrice": "15", + } + ], + "selected_air_option": { + "chargeableWeightKg": 2583.85, + "airlineCode": "TH", + "feeItems": [ + { + "chargeName": "空运费", + "unitPrice": 15, + "amount": 38757.75, + "unit": "KG", + "currency": "CNY", + } + ], + }, + } + ext = extract_adjust( + "空运费单价增加到110%/KG", + fee_names=["空运费"], + allow_model=False, + ) + got = apply_adjust(src, ext, business_line="AIR") + self.assertTrue(got.ok) + air = next(r for r in got.quote["fee_rows"] if r["name"] == "空运费") + self.assertEqual(str(air.get("unit_price") or ""), "16.50") + self.assertNotEqual(str(air.get("unit_price") or ""), "42633.53") + amt, _ = parse_money(air.get("amount")) + self.assertAlmostEqual(float(amt or 0), 42633.525, places=2) + + def test_model_up_coerced_when_text_says_to_percent(self) -> None: + """模型误抽成 up 时,按原话「增加到X%」纠正为 set。""" + src = _quote(("运输费", "7500", "CNY"), total="7500") + injected = { + "is_adjust": True, + "wants_total": False, + "items": [{"fee_name": "运输费", "kind": "percent", "direction": "up", "value": 110}], + } + ext = extract_adjust( + "运输费用增加到110%", + fee_names=["运输费"], + injected=injected, + allow_model=False, + ) + self.assertEqual(ext.items[0].direction, "set") + got = apply_adjust(src, ext, business_line="LAND") + self.assertEqual(got.quote["fee_rows"][0]["amount"], "8250.00") + + def test_to_percent_phrase_variants(self) -> None: + """调整到/涨到/升到/增至/变为/上浮到 X% 一律按改到原价比例。""" + src = _quote(("运输费", "CNY 7500.00", "CNY"), total="CNY 7500.00") + phrases = ( + "运输费调整到110%", + "运输费涨到110%", + "运输费升到110%", + "运输费增至110%", + "运输费变为110%", + "运输费上浮到110%", + ) + for text in phrases: + with self.subTest(text=text): + ext = extract_adjust(text, fee_names=["运输费"], allow_model=False) + self.assertTrue(ext.is_adjust, text) + self.assertEqual(ext.items[0].kind, "percent", text) + self.assertEqual(ext.items[0].direction, "set", text) + got = apply_adjust(src, ext, business_line="LAND") + self.assertTrue(got.ok, text) + self.assertEqual(got.quote["fee_rows"][0]["amount"], "CNY 8250.00", text) + def test_percent_down_and_target_in_one_sentence(self) -> None: src = _quote(("提货费", "1000", "CNY"), ("送货费", "200", "CNY"), total="1200") ext = extract_adjust("提货费减 5%,送货费提升到 180 元", fee_names=["提货费", "送货费"]) @@ -573,6 +719,87 @@ class QuoteAdjustTests(unittest.TestCase): self.assertTrue(copy.quote_is_tms(sess.tms_quote)) self.assertFalse(copy.quote_is_tms(sess.quote)) + def test_private_air_adjust_emits_facts_so_cargo_not_blank(self) -> None: + """空运私聊调价出卡必须带 sess.facts,否则起运地/品名会整段变「-」。""" + from agent.policy.quote_adjust_ops import try_private_adjust + + class _Sess: + status = "已报价" + phase = "wait_collab" + work_order_no = "WO202610280072" + business_line = "AIR" + collab_chat_id = "" + wait_version = 1 + first_or_same = "first" + history_work_order_no = "" + facts = { + "起运地": "南京", + "目的地": "吉隆坡", + "品名": "普货", + "件数": "15", + "毛重": "2403", + "体积": "15.5", + "包装类型": "托盘", + } + quote = { + "currency": "CNY", + "total": "38757.75", + "chargeableWeightKg": "2583.85", + "fee_rows": [ + { + "name": "空运费", + "amount": "38757.75", + "currency": "CNY", + "unit": "KG", + "unit_price": "15", + } + ], + } + tms_quote = {} + + class _Flow: + def __init__(self) -> None: + self.emitted = None + + class _Ledger: + def upsert_product_quote(self, **kwargs): + return {"ok": True} + + self.ledger = _Ledger() + + def _save(self, sess) -> None: + return None + + def _emit_air_hit(self, reply, **kwargs) -> None: + self.emitted = kwargs + + sess = _Sess() + flow = _Flow() + out = try_private_adjust( + flow, + sess, + "空运费单价增加到110%/KG", + lambda *_a, **_k: None, + injected_adjust={ + "is_adjust": True, + "items": [ + { + "fee_name": "空运费", + "kind": "percent", + "direction": "set", + "value": 110, + } + ], + }, + ) + self.assertEqual(out, "wait_collab") + self.assertIsNotNone(flow.emitted) + facts = dict(flow.emitted.get("facts") or {}) + self.assertEqual(facts.get("起运地"), "南京") + self.assertEqual(facts.get("品名"), "普货") + air = next(r for r in sess.quote["fee_rows"] if r["name"] == "空运费") + self.assertEqual(str(air.get("unit_price") or ""), "16.50") + def test_group_brief_keeps_tms_when_current_is_adjust(self) -> None: """拉群摘要:TMS 段必须是原价,调后价另写销售调价,禁止替换。""" tms = _quote(("运输费", "110.00", "CNY"), total="110.00") diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py index 7b4cc1c..68877f2 100644 --- a/inquiry-agent/tests/test_sea_text_inquiry.py +++ b/inquiry-agent/tests/test_sea_text_inquiry.py @@ -1427,6 +1427,64 @@ class SeaGroupCollabFieldTests(unittest.TestCase): self.assertEqual(str(rows["增值费"].get("project") or ""), "起运港") self.assertEqual(str(rows["增值费"].get("amount") or ""), "1000") + def test_air_origin_fee_unit_ci_keeps_qty_and_amount(self) -> None: + """WO081:打板费单位「次」必须认出数量100/金额1000,不能落成数量1、金额=单价。""" + from agent.llm.extract_text import extract_product_quote + + quote = extract_product_quote( + "@智价通AIQ 起运港,打板费,次,单价CNY10,数量100,金额CNY1000", + business_line="AIR", + ) + rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])} + self.assertIn("打板费", rows) + self.assertEqual(str(rows["打板费"].get("unit") or ""), "次") + self.assertEqual(str(rows["打板费"].get("unit_price") or ""), "10") + self.assertEqual(str(rows["打板费"].get("quantity") or ""), "100") + self.assertEqual(str(rows["打板费"].get("amount") or ""), "1000") + self.assertEqual(str(rows["打板费"].get("project") or ""), "起运港") + + def test_air_origin_fee_unit_ren_keeps_qty_and_amount(self) -> None: + """单位「人」同样要能抽出数量与金额。""" + from agent.llm.extract_text import extract_product_quote + + quote = extract_product_quote( + "起运港,人工费,人,单价CNY50,数量2,金额CNY100", + business_line="AIR", + ) + rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])} + self.assertIn("人工费", rows) + self.assertEqual(str(rows["人工费"].get("unit") or ""), "人") + self.assertEqual(str(rows["人工费"].get("quantity") or ""), "2") + self.assertEqual(str(rows["人工费"].get("amount") or ""), "100") + + def test_air_origin_fee_unit_slash_ci_ren(self) -> None: + """单位表「次/人」整段保留,不能拆成只认「次」。""" + from agent.llm.extract_text import extract_product_quote + + quote = extract_product_quote( + "起运港,操作费,次/人,单价CNY20,数量3,金额CNY60", + business_line="AIR", + ) + rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])} + self.assertIn("操作费", rows) + self.assertEqual(str(rows["操作费"].get("unit") or ""), "次/人") + self.assertEqual(str(rows["操作费"].get("quantity") or ""), "3") + self.assertEqual(str(rows["操作费"].get("amount") or ""), "60") + + def test_air_origin_fee_unit_hour_per_person(self) -> None: + """单位表「小时/人」整段保留。""" + from agent.llm.extract_text import extract_product_quote + + quote = extract_product_quote( + "起运港,仓储费,小时/人,单价CNY30,数量4,金额CNY120", + business_line="AIR", + ) + rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])} + self.assertIn("仓储费", rows) + self.assertEqual(str(rows["仓储费"].get("unit") or ""), "小时/人") + self.assertEqual(str(rows["仓储费"].get("quantity") or ""), "4") + self.assertEqual(str(rows["仓储费"].get("amount") or ""), "120") + def test_fee_line_text_is_a_product_quote(self) -> None: """产品按行改价:码头操作费100CNY / 报关费改为300CNY,必须抽出费用名,不能只当海运费合计。""" from agent.llm.extract_text import extract_product_quote diff --git a/inquiry-agent/tests/test_wshoto_archive.py b/inquiry-agent/tests/test_wshoto_archive.py index b583fff..4d3152f 100644 --- a/inquiry-agent/tests/test_wshoto_archive.py +++ b/inquiry-agent/tests/test_wshoto_archive.py @@ -226,7 +226,8 @@ class WshotoPollTests(unittest.TestCase): self.assertTrue(out["ok"]) call = api.calls[0] self.assertEqual(call.get("msg_time_end"), 1_800_000_000) - self.assertEqual(call.get("msg_time_start"), 1_800_000_000 - 180) + # 新群回看上限见 poll._NEW_ROOM_LOOKBACK_SEC(3600),须盖过存档延迟。 + self.assertEqual(call.get("msg_time_start"), 1_800_000_000 - 3600) def test_poll_enqueues_member_skips_seat_and_advances_seq(self) -> None: store = MemoryMessageStore() @@ -317,6 +318,82 @@ class WshotoPollTests(unittest.TestCase): self.assertEqual(b, 0) self.assertEqual(skipped, 1) + def test_room_seq_gap_backfill_keeps_dissolve(self) -> None: + """ + 现网事故:增量只先到「撤回」高 seq,游标一步跳到 9899, + 中间的「解散群」再也拉不回来。跳号时必须时间窗补洞再推进游标。 + """ + store = MemoryMessageStore() + redis = _redis() + room = "wr_gap_dissolve" + watch_collab_room(room, redis=redis) + save_last_seq(f"room:{room}", 9892, redis=redis) + + all_rows = [ + { + "msgid": "m9897", + "seq": 9897, + "from": "ShiChangChang", + "room_id": room, + "msgtype": "text", + "content": "随便说一句", + "msgtime": 1_792_546_051_000, + }, + { + "msgid": "m9898", + "seq": 9898, + "from": "ShiChangChang", + "room_id": room, + "msgtype": "text", + "content": "解散群", + "msgtime": 1_792_546_055_000, + }, + { + "msgid": "m9899", + "seq": 9899, + "from": "ShiChangChang", + "room_id": room, + "msgtype": "revoke", + "content": "[撤回消息]", + "msgtime": 1_792_546_059_000, + }, + ] + + class _GapApi: + def __init__(self) -> None: + self.calls: list[dict] = [] + + def list_group_messages(self, **kwargs): + self.calls.append(dict(kwargs)) + last = int(kwargs.get("last_seq") or 0) + time_start = int(kwargs.get("msg_time_start") or 0) + # 模拟管家增量晚到:先只吐撤回;时间窗才能看到中间「解散群」。 + if time_start > 0: + return [r for r in all_rows if int(r["seq"]) > last] + if last > 0: + return [r for r in all_rows if int(r["seq"]) == 9899 and int(r["seq"]) > last] + return list(all_rows) + + cfg = _Cfg() + cfg.wshoto_room_gap_ms = 0 + out = poll_once( + client=_GapApi(), + settings=cfg, + redis=redis, + store=store, + now=lambda: 1_792_546_100, + ) + self.assertTrue(out["ok"]) + self.assertGreaterEqual(out["enqueued"], 1) + self.assertEqual(load_last_seq(f"room:{room}", redis=redis), 9899) + claimed = [] + while True: + item = store.claim_inbound() + if item is None: + break + claimed.append(item.message.content) + self.assertIn("解散群", claimed) + def test_watched_rooms_preferred_without_seat_userid(self) -> None: store = MemoryMessageStore() redis = _redis() diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryStaffRoleController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryStaffRoleController.java index b66a574..bb78bb3 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryStaffRoleController.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryStaffRoleController.java @@ -145,6 +145,19 @@ public class InquiryStaffRoleController extends JeecgController enable(@RequestParam String id) { + staffRoleService.enable(id); + return Result.OK("已启用"); + } + @ApiOperation("按 id 查询") @RequiresPermissions("inquiry:staff:list") @GetMapping("/queryById") diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java index 2c56be1..765aaab 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java @@ -21,6 +21,11 @@ public interface IInquiryStaffRoleService extends IService { */ void disable(String id); + /** + * 重新启用员工(status=active)。 + */ + void enable(String id); + /** * 从企微通讯录全量 upsert:按 wecom_id 更新姓名/手机/部门,缺失则新建(默认销售岗)。 *

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 9ba5264..6c1c1d7 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 @@ -32,6 +32,7 @@ import org.jeecg.modules.inquiry.template.QuoteSheetFileName; import org.jeecg.modules.inquiry.template.QuoteTemplateMatcher; import org.jeecg.modules.inquiry.template.QuoteTemplateObjectStore; import org.jeecg.modules.inquiry.template.QuoteTemplateSnapshotRenderer; +import org.jeecg.modules.inquiry.tms.TmsLandSegmentMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -42,6 +43,7 @@ import org.springframework.web.multipart.MultipartFile; import java.io.ByteArrayInputStream; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -1172,9 +1174,9 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl + * 为何:TMS 无匹配、产品只报「陆运费」时 buildFillFacts 会拼 OD+金额回退行, + * 若不带车型,整车矩阵只能写「金额合计」、10T 等车型列会空(WO202610210047)。 + * 副作用:只改传入的 JSONArray 行对象;不写库。线程:出单请求线程。 + */ + private static void enrichLandRouteVehicleFromInquiry(JSONArray landSegs, JSONObject out, + Map src) { + if (landSegs == null || landSegs.isEmpty()) { + return; + } + String combo = firstNonBlankLocal( + out == null ? null : out.getString("vehicle_qty"), + first(src, "车型/数量"), + first(src, "车型数量"), + first(src, "车型"), + out == null ? null : out.getString("vehicleType"), + out == null ? null : out.getString("vehicle_type")); + if (StringUtils.isBlank(combo)) { + return; + } + String[] parts = TmsLandSegmentMapper.splitVehicleQty(combo); + String vehicle = parts == null || parts.length < 1 ? "" : StringUtils.trimToEmpty(parts[0]); + String qty = parts == null || parts.length < 2 ? "1" : StringUtils.trimToEmpty(parts[1]); + if (StringUtils.isBlank(vehicle)) { + return; + } + int patched = 0; + for (int i = 0; i < landSegs.size(); i++) { + JSONObject seg = landSegs.getJSONObject(i); + if (seg == null) { + continue; + } + String existing = firstNonBlankLocal(seg.getString("vehicleType"), seg.getString("vehicle_type")); + if (StringUtils.isNotBlank(existing)) { + continue; + } + seg.put("vehicleType", vehicle); + seg.put("vehicle_type", vehicle); + if (seg.get("quantity") == null && StringUtils.isNotBlank(qty)) { + seg.put("quantity", qty); + } + patched++; + } + } + /** * 空运选定线路 / 首条 airOptions 的 salePrice。 * 会话 quote 有时只投影了线路对象,顶层 total 空,出单预估费用必须能落到这个数。 @@ -1569,6 +1629,19 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl quote = new LinkedHashMap<>(); Map tmsQuote = new LinkedHashMap<>(); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupport.java index e46dc6d..5a185c0 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupport.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupport.java @@ -1,9 +1,16 @@ package org.jeecg.modules.inquiry.support; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; import org.apache.commons.lang3.StringUtils; import org.jeecg.modules.inquiry.entity.InquiryTicket; import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -54,19 +61,19 @@ public final class InquiryTicketDisplaySupport { if ("AIR".equals(businessLine) || containsChinese(lineLabel, "空运")) { addLabel(quantities, "件数", ticket.getPieces()); addUnit(quantities, "重量(KG)", ticket.getWeightKg(), "kg"); - addUnit(quantities, "体积(CBM)", ticket.getVolumeCbm(), "CBM"); + addVolumeDisplay(quantities, ticket); } else if ("SEA".equals(businessLine) || containsChinese(lineLabel, "海运")) { addLabel(quantities, "件数", ticket.getPieces()); addUnit(quantities, "重量(KG)", ticket.getWeightKg(), "kg"); - addUnit(quantities, "体积(CBM)", ticket.getVolumeCbm(), "CBM"); + addVolumeDisplay(quantities, ticket); } else if ("LAND".equals(businessLine) || containsChinese(lineLabel, "陆运")) { - addUnit(quantities, "体积(CBM)", ticket.getVolumeCbm(), "CBM"); + addVolumeDisplay(quantities, ticket); addUnit(quantities, "重量(KG)", ticket.getWeightKg(), "kg"); addLabel(quantities, "件数", ticket.getPieces()); } else { addLabel(quantities, "件数", ticket.getPieces()); addUnit(quantities, "重量(KG)", ticket.getWeightKg(), "kg"); - addUnit(quantities, "体积(CBM)", ticket.getVolumeCbm(), "CBM"); + addVolumeDisplay(quantities, ticket); } addLine(lines, join(quantities, " · ")); @@ -191,6 +198,36 @@ public final class InquiryTicketDisplaySupport { return String.valueOf(value).trim(); } + /** + * 后台摘要体积:优先 factsJson 销售原文(尺寸式),没有再退回相乘后的 CBM 列。 + * 原文不加「CBM」后缀,避免 1.1*2.1*2.2CBM。 + */ + private static void addVolumeDisplay(List quantities, InquiryTicket ticket) { + String raw = volumeFromFactsJson(ticket); + if (StringUtils.isNotBlank(raw)) { + addLabel(quantities, "体积(CBM)", raw); + return; + } + addUnit(quantities, "体积(CBM)", ticket.getVolumeCbm(), "CBM"); + } + + /** 从 factsJson 取体积原文;坏 JSON 或没有则空。 */ + private static String volumeFromFactsJson(InquiryTicket ticket) { + if (ticket == null || StringUtils.isBlank(ticket.getFactsJson())) { + return ""; + } + try { + JSONObject o = JSON.parseObject(ticket.getFactsJson()); + if (o == null || o.get("体积") == null) { + return ""; + } + return StringUtils.trimToEmpty(String.valueOf(o.get("体积"))); + } catch (Exception ignore) { + return ""; + } + } + + private static String join(List values, String sep) { if (values == null || values.isEmpty()) { return null; diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/LandQuoteTemplateFields.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/LandQuoteTemplateFields.java index 6b20d2b..364b0f9 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/LandQuoteTemplateFields.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/LandQuoteTemplateFields.java @@ -51,6 +51,13 @@ public final class LandQuoteTemplateFields { add("vehicle_10t_fee", "9.4*2.4*2.55M/10T费用", "vehicle10TFee"); add("vehicle_40hq_fee", "11.8*2.32*2.7M/40'HQ费用", "vehicle40HQFee"); add("vehicle_45hq_fee", "12.8*2.32*2.7M/45'HQ费用", "vehicle45HQFee"); + // 跨境整车矩阵短表头:询价写 40HQ,旧模板曾写 40'HQ,两种都认 + LABEL_TO_CODE.put("40'HQ", "vehicle_40hq_fee"); + LABEL_TO_CODE.put("40HQ", "vehicle_40hq_fee"); + LABEL_TO_CODE.put("45'HQ", "vehicle_45hq_fee"); + LABEL_TO_CODE.put("45HQ", "vehicle_45hq_fee"); + LABEL_TO_CODE.put("11.8*2.32*2.7M/40HQ费用", "vehicle_40hq_fee"); + LABEL_TO_CODE.put("12.8*2.32*2.7M/45HQ费用", "vehicle_45hq_fee"); add("vehicle_48rh_fee", "14.6*2.28*2.5M/48RH冷柜费用", "vehicle48RHFee"); add("vehicle_53r_fee", "16.3*2.7*2.8M/53尺费用", "vehicle53RFee"); add("vehicle_1375_fee", "13.5*3M/13.75平板费用", "vehicle13.75TFee"); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java index a12c818..a80df14 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java @@ -6,6 +6,7 @@ import com.alibaba.fastjson.JSONObject; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.apache.poi.ss.usermodel.*; +import org.apache.poi.ss.util.CellRangeAddress; import org.apache.poi.ss.util.CellReference; import org.apache.poi.xssf.usermodel.XSSFCell; import org.apache.poi.xssf.usermodel.XSSFColor; @@ -18,6 +19,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; import java.util.*; /** @@ -30,6 +32,7 @@ import java.util.*; @Slf4j public final class QuoteTemplateExcelSupport { + private static final Set YELLOW_RGB = new HashSet<>(Arrays.asList( "FFFFFF00", "FFFF00", "FFFFEB9C", "FFFFC000", "FFFFFF99", "FFFFCC00", "FFFFD966", "FFFFF2CC", "FFFFE699" @@ -88,6 +91,10 @@ public final class QuoteTemplateExcelSupport { FILL_BY_LABEL.put("重量(KG)", "weight_kg"); FILL_BY_LABEL.put("体积", "volume_cbm"); FILL_BY_LABEL.put("体积(CBM)", "volume_cbm"); + // 空运报价单货物信息:计费重必须进词典,否则标签回填找不到码,且 isLabelLike 会漏认短标签 + FILL_BY_LABEL.put("计费重", "chargeable_weight_kg"); + FILL_BY_LABEL.put("计费重量", "chargeable_weight_kg"); + FILL_BY_LABEL.put("Chargeable Weight", "chargeable_weight_kg"); FILL_BY_LABEL.put("始发地", "origin"); FILL_BY_LABEL.put("始发站", "origin"); FILL_BY_LABEL.put("起始地", "origin"); @@ -158,7 +165,14 @@ public final class QuoteTemplateExcelSupport { FILL_BY_LABEL.put("7.5*2.4*2.5M/8T费用", "vehicle_8t_fee"); FILL_BY_LABEL.put("9.4*2.4*2.55M/10T费用", "vehicle_10t_fee"); FILL_BY_LABEL.put("11.8*2.32*2.7M/40'HQ费用", "vehicle_40hq_fee"); + FILL_BY_LABEL.put("11.8*2.32*2.7M/40HQ费用", "vehicle_40hq_fee"); FILL_BY_LABEL.put("12.8*2.32*2.7M/45'HQ费用", "vehicle_45hq_fee"); + FILL_BY_LABEL.put("12.8*2.32*2.7M/45HQ费用", "vehicle_45hq_fee"); + // 跨境整车矩阵短表头(国内/境外车型列) + FILL_BY_LABEL.put("40'HQ", "vehicle_40hq_fee"); + FILL_BY_LABEL.put("40HQ", "vehicle_40hq_fee"); + FILL_BY_LABEL.put("45'HQ", "vehicle_45hq_fee"); + FILL_BY_LABEL.put("45HQ", "vehicle_45hq_fee"); FILL_BY_LABEL.put("14.6*2.28*2.5M/48RH冷柜费用", "vehicle_48rh_fee"); FILL_BY_LABEL.put("16.3*2.7*2.8M/53尺费用", "vehicle_53r_fee"); FILL_BY_LABEL.put("13.5*3M/13.75平板费用", "vehicle_1375_fee"); @@ -218,6 +232,12 @@ public final class QuoteTemplateExcelSupport { */ private static volatile Map TMS_DOMESTIC_ALIAS_TO_TEMPLATE; + /** + * 国内整车表头 ↔ 中港表头对等(双向)。例:12.5米/40尺 ↔ 12米/40尺。 + * 与 alias 同合同加载;失败则空表。 + */ + private static volatile Map DOMESTIC_HK_HEADER_PEER; + /** * 加载 contracts 合同(优先 classpath,其次仓库相对路径由调用方传入)。 */ @@ -598,6 +618,7 @@ public final class QuoteTemplateExcelSupport { m.put("pieces", "件数"); m.put("weight_kg", "重量(KG)"); m.put("volume_cbm", "体积(CBM)"); + m.put("chargeable_weight_kg", "计费重"); m.put("total_amount", "报价合计"); return m; } @@ -2295,13 +2316,6 @@ public final class QuoteTemplateExcelSupport { if (styleCopy <= 0) { styleCopy = 1; } - ensureRowsBeforeStop(sheet, startRow, rows.size(), stopRow, styleCopy); - // 增行后 stop 下移,再取一次,保证写入不越过汇总/下段标题 - stopRow = findStopRow(sheet, region.getJSONArray("stopAtText"), startRow); - // 海运:表头「箱型(动态填充箱型)」换成真实箱型(如 20GP) - if (Boolean.TRUE.equals(region.getBoolean("dynamicContainerHeader"))) { - applyDynamicContainerHeader(sheet, region, columns, facts, yellow); - } // 空运模板有「空运段/起运港/目的地/其它」骨架时:费用落到对应段,禁止顺填盖掉空运段首行 // 海运三段禁止走空运项目骨架(否则费目名列被写成「起运港」) int projectColIdx = seaFeeBucket || StringUtils.isBlank(columns.getString("project")) @@ -2316,6 +2330,18 @@ public final class QuoteTemplateExcelSupport { } boolean placeByProject = projectColIdx >= 0 && hasAirProjectSkeleton(sheet, startRow, stopRow, projectColIdx); + // 按段落位:先在「起运港」等段末扩槽,再全局增行;禁止只在「其他」后扩导致段外落位 + if (placeByProject) { + ensureAirProjectBandsCapacity(sheet, startRow, stopRow, projectColIdx, rows, styleCopy); + stopRow = findStopRow(sheet, region.getJSONArray("stopAtText"), startRow); + } + ensureRowsBeforeStop(sheet, startRow, rows.size(), stopRow, styleCopy); + // 增行后 stop 下移,再取一次,保证写入不越过汇总/下段标题 + stopRow = findStopRow(sheet, region.getJSONArray("stopAtText"), startRow); + // 海运:表头「箱型(动态填充箱型)」换成真实箱型(如 20GP) + if (Boolean.TRUE.equals(region.getBoolean("dynamicContainerHeader"))) { + applyDynamicContainerHeader(sheet, region, columns, facts, yellow); + } List writeRows = placeByProject ? assignAirFeeWriteRows(sheet, startRow, stopRow, projectColIdx, rows) : null; @@ -2435,26 +2461,12 @@ public final class QuoteTemplateExcelSupport { } Map> bands = buildAirProjectBands(sheet, startRow, stopRow, projectCol); Map cursors = new HashMap<>(); - List overflow = new ArrayList<>(); - for (int r = startRow; r < stopRow && r <= sheet.getLastRowNum(); r++) { - String label = normalizeAirProjectLabel(projectLabelAt(sheet, r, projectCol)); - if (StringUtils.isBlank(label)) { - overflow.add(r); - } - } - int overflowCursor = 0; + // 空白行只允许落在「本段末~下一段骨架前」,禁止用「其他」之后的全局空白当起运港溢出槽 + Map> bandOverflow = buildAirBandLocalOverflow(sheet, startRow, stopRow, projectCol, bands); + Map overflowCursors = new HashMap<>(); for (int i = 0; i < rows.size(); i++) { JSONObject rowObj = rows.getJSONObject(i); - String projectVal = normalizeAirProjectLabel( - firstNonBlank(rowObj == null ? null : rowObj.getString("project"), - rowObj == null ? null : rowObj.getString("segment"))); - if (StringUtils.isBlank(projectVal)) { - projectVal = rowObj != null && isAirWeightFeeName(rowObj.getString("name")) - ? "空运段" : "起运港"; - } else if ("空运段".equals(projectVal) && rowObj != null - && !isAirWeightFeeName(rowObj.getString("name"))) { - projectVal = "起运港"; - } + String projectVal = resolveAirFeeProjectLabel(rowObj); List band = bands.get(projectVal); Integer rowIdx = null; if (band != null && !band.isEmpty()) { @@ -2464,11 +2476,17 @@ public final class QuoteTemplateExcelSupport { cursors.put(projectVal, cur + 1); } } - // 本段槽满或无骨架:用空白行;再没有则顺延 startRow+i(保底) - while (rowIdx == null && overflowCursor < overflow.size()) { - int cand = overflow.get(overflowCursor++); - if (!isRowClaimedByOtherBand(bands, projectVal, cand)) { - rowIdx = cand; + // 本段槽满:只用本段末与下一段之间的空白,不用表尾空白 + List localOverflow = bandOverflow.get(projectVal); + if (rowIdx == null && localOverflow != null) { + int oc = overflowCursors.getOrDefault(projectVal, 0); + while (oc < localOverflow.size()) { + int cand = localOverflow.get(oc++); + overflowCursors.put(projectVal, oc); + if (!isRowClaimedByOtherBand(bands, projectVal, cand)) { + rowIdx = cand; + break; + } } } if (rowIdx == null) { @@ -2479,6 +2497,191 @@ public final class QuoteTemplateExcelSupport { return out; } + /** + * 空运费用行的项目分段:与 assign / 写入侧同一套兜底规则。 + */ + static String resolveAirFeeProjectLabel(JSONObject rowObj) { + String projectVal = normalizeAirProjectLabel( + firstNonBlank(rowObj == null ? null : rowObj.getString("project"), + rowObj == null ? null : rowObj.getString("segment"))); + if (StringUtils.isBlank(projectVal)) { + return rowObj != null && isAirWeightFeeName(rowObj.getString("name")) + ? "空运段" : "起运港"; + } + if ("空运段".equals(projectVal) && rowObj != null + && !isAirWeightFeeName(rowObj.getString("name"))) { + return "起运港"; + } + return projectVal; + } + + /** + * 各分段「本段末行之后、下一段骨架之前」的空白行,供本段微溢出;不含更后段空白。 + */ + static Map> buildAirBandLocalOverflow(Sheet sheet, int startRow, int stopRow, + int projectCol, Map> bands) { + Map> out = new LinkedHashMap<>(); + if (sheet == null || bands == null || bands.isEmpty()) { + return out; + } + List>> ordered = new ArrayList<>(); + for (Map.Entry> e : bands.entrySet()) { + if (e.getValue() != null && !e.getValue().isEmpty()) { + ordered.add(e); + } + } + ordered.sort(Comparator.comparingInt(e -> e.getValue().get(0))); + for (int i = 0; i < ordered.size(); i++) { + String key = ordered.get(i).getKey(); + List band = ordered.get(i).getValue(); + int after = band.get(band.size() - 1) + 1; + int before = (i + 1 < ordered.size()) + ? ordered.get(i + 1).getValue().get(0) + : Math.min(stopRow, sheet.getLastRowNum() + 1); + List blanks = new ArrayList<>(); + for (int r = after; r < before; r++) { + String label = normalizeAirProjectLabel(projectLabelAt(sheet, r, projectCol)); + if (StringUtils.isBlank(label)) { + blanks.add(r); + } + } + out.put(key, blanks); + } + return out; + } + + /** + * 空运按段落位:某段费用多于模板预留行时,在该段末(下一段骨架前)插入并扩项目列合并。 + * 为何:全局 ensureRowsBeforeStop 只在「预估费用」前增行,会把起运港溢出挤到「其他」之后。 + * 副作用:改 Sheet 行与合并区。线程:调用方。 + * + * @return 实际插入行数 + */ + static int ensureAirProjectBandsCapacity(Sheet sheet, int startRow, int stopRow, + int projectCol, JSONArray rows, int styleCopyRows) { + if (sheet == null || rows == null || rows.isEmpty() || projectCol < 0) { + return 0; + } + Map need = new LinkedHashMap<>(); + for (int i = 0; i < rows.size(); i++) { + String projectVal = resolveAirFeeProjectLabel(rows.getJSONObject(i)); + need.merge(projectVal, 1, Integer::sum); + } + // 自下而上扩段,避免先扩上段后下段 insertAt 失效 + List expandOrder = Arrays.asList("其它", "目的地", "起运港", "空运段"); + int totalInserted = 0; + int liveStop = stopRow; + for (String key : expandOrder) { + int want = need.getOrDefault(key, 0); + if (want <= 0) { + continue; + } + Map> bands = buildAirProjectBands(sheet, startRow, liveStop, projectCol); + List band = bands.get(key); + int have = band == null ? 0 : band.size(); + if (want <= have) { + continue; + } + int insertCount = want - have; + if (band == null || band.isEmpty()) { + continue; + } + int bandFirst = band.get(0); + int insertAt = band.get(band.size() - 1) + 1; + int styleFrom = band.get(band.size() - 1); + // 项目列沿用模板原字(「其他」不要被归一写成「其它」) + String labelToWrite = StringUtils.trimToEmpty(projectLabelAt(sheet, bandFirst, projectCol)); + if (StringUtils.isBlank(labelToWrite)) { + labelToWrite = key; + } + insertRowsAt(sheet, insertAt, insertCount, styleFrom, styleCopyRows); + for (int i = 0; i < insertCount; i++) { + forceWriteProjectSkeleton(sheet, insertAt + i, projectCol, labelToWrite); + } + extendVerticalProjectMerge(sheet, projectCol, bandFirst, insertAt + insertCount - 1); + totalInserted += insertCount; + liveStop += insertCount; + } + return totalInserted; + } + + /** + * 在指定行插入若干空行并复制样式(不清项目列以外的业务值,由后续写入覆盖)。 + */ + private static void insertRowsAt(Sheet sheet, int insertAt, int insertCount, int styleFromRow, int styleCopyRows) { + if (sheet == null || insertCount <= 0 || insertAt < 0) { + return; + } + int last = Math.max(sheet.getLastRowNum(), insertAt); + sheet.shiftRows(insertAt, last, insertCount, true, false); + int copyRows = Math.max(1, styleCopyRows); + for (int i = 0; i < insertCount; i++) { + int target = insertAt + i; + Row src = sheet.getRow(styleFromRow + (i % copyRows)); + if (src == null) { + src = sheet.getRow(styleFromRow); + } + Row dst = sheet.getRow(target); + if (dst == null) { + dst = sheet.createRow(target); + } + if (src != null) { + dst.setHeight(src.getHeight()); + short lastCell = src.getLastCellNum(); + for (int c = 0; c < lastCell; c++) { + Cell sc = src.getCell(c); + if (sc == null) { + continue; + } + Cell dc = dst.getCell(c); + if (dc == null) { + dc = dst.createCell(c); + } + if (sc.getCellStyle() != null) { + dc.setCellStyle(sc.getCellStyle()); + } + dc.setCellValue(""); + } + } + } + } + + /** + * 把项目列纵向合并扩到 newLastRow(含)。无合并则新建单列合并。 + */ + static void extendVerticalProjectMerge(Sheet sheet, int projectCol, int firstRow, int newLastRow) { + if (sheet == null || projectCol < 0 || firstRow < 0 || newLastRow < firstRow) { + return; + } + int mergeIdx = -1; + CellRangeAddress found = null; + for (int i = 0; i < sheet.getNumMergedRegions(); i++) { + CellRangeAddress range = sheet.getMergedRegion(i); + if (range.isInRange(firstRow, projectCol) + && range.getFirstColumn() == projectCol + && range.getLastColumn() == projectCol) { + mergeIdx = i; + found = range; + break; + } + } + if (found != null) { + if (found.getLastRow() >= newLastRow && found.getFirstRow() <= firstRow) { + return; + } + int fr = Math.min(found.getFirstRow(), firstRow); + int lr = Math.max(found.getLastRow(), newLastRow); + sheet.removeMergedRegion(mergeIdx); + if (lr > fr) { + sheet.addMergedRegion(new CellRangeAddress(fr, lr, projectCol, projectCol)); + } + return; + } + if (newLastRow > firstRow) { + sheet.addMergedRegion(new CellRangeAddress(firstRow, newLastRow, projectCol, projectCol)); + } + } + /** 扫描项目列,得到各分段可用行(含合并区内每一物理行)。 */ static Map> buildAirProjectBands(Sheet sheet, int startRow, int stopRow, int projectCol) { Map> bands = new LinkedHashMap<>(); @@ -4027,33 +4230,121 @@ public final class QuoteTemplateExcelSupport { /** * 车型列是否对得上当前段表头。 *

- * 1) 字面/安全子串;2) TMS 国内别名表(6.8厢车→6.8/5T,合同 tms-domestic-vehicle-mapping-v1); - * 3) 跨境整车国内↔境外吨位换算(3T↔3.5T)。禁止未收录别名瞎猜。 + * 1) 字面/安全子串;2) TMS 国内别名表(6.8厢车→6.8/5T); + * 3) 国内表头↔中港表头对等(12.5米/40尺↔12米/40尺)及吨位抽出(6.8/5T→5T,供跨境吨位列); + * 4) 跨境整车国内↔境外换算(3T 族↔3.5T)。禁止未收录别名瞎猜。 */ static boolean vehicleTypeMatchesSection(String vehicleType, String headerKey, String sectionKind) { boolean direct = vehicleTypeEquals(vehicleType, headerKey); String kind = StringUtils.trimToEmpty(sectionKind); - if (direct) { - return true; - } - // TMS 国内别名 → 报价单表头(国内整车 sectionKind 常为空,必须在跨境换算之前做) String tmsMapped = mapTmsDomesticAliasToTemplate(vehicleType); - if (StringUtils.isNotBlank(tmsMapped) && vehicleTypeEquals(tmsMapped, headerKey)) { - return true; - } - if ("overseas".equals(kind)) { + boolean tmsHit = StringUtils.isNotBlank(tmsMapped) && vehicleTypeEquals(tmsMapped, headerKey); + boolean familyHit = mappedVehicleFamilyMatchesHeader(vehicleType, tmsMapped, headerKey); + boolean result; + String via; + if (direct) { + result = true; + via = "direct"; + } else if (tmsHit) { + result = true; + via = "tms"; + } else if (familyHit) { + result = true; + via = "peer_or_tonnage"; + } else if ("overseas".equals(kind)) { String mapped = mapDomesticVehicleToOverseas(vehicleType); - boolean overseasHit = StringUtils.isNotBlank(mapped) && vehicleTypeEquals(mapped, headerKey); - return overseasHit; - } - if ("domestic".equals(kind)) { + if (StringUtils.isBlank(mapped)) { + mapped = mapDomesticVehicleToOverseas(firstNonBlank(extractVehicleTonnage(tmsMapped), + extractVehicleTonnage(vehicleType), tmsMapped)); + } + result = StringUtils.isNotBlank(mapped) && vehicleTypeEquals(mapped, headerKey); + via = result ? "overseas" : "miss"; + } else if ("domestic".equals(kind)) { String mapped = mapOverseasVehicleToDomestic(vehicleType); - boolean domesticHit = StringUtils.isNotBlank(mapped) && vehicleTypeEquals(mapped, headerKey); - return domesticHit; + result = StringUtils.isNotBlank(mapped) && (vehicleTypeEquals(mapped, headerKey) + || mappedVehicleFamilyMatchesHeader(mapped, mapTmsDomesticAliasToTemplate(mapped), headerKey)); + via = result ? "domestic" : "miss"; + } else { + result = false; + via = "miss"; + } + return result; + } + + /** + * 国内映射结果能否对上当前表头:中港对等表头,或抽出吨位对跨境裸吨位列。 + *

+ * 例:40HQ→12.5米/40尺→对等 12米/40尺;6.8厢车→6.8/5T→吨位 5T / 对等 5米/5T。 + */ + private static boolean mappedVehicleFamilyMatchesHeader(String vehicleType, String tmsMapped, String headerKey) { + if (StringUtils.isBlank(headerKey)) { + return false; + } + for (String cand : new String[]{tmsMapped, vehicleType}) { + if (StringUtils.isBlank(cand)) { + continue; + } + String peer = hkPeerHeaderOf(cand); + if (StringUtils.isNotBlank(peer) && vehicleTypeEquals(peer, headerKey)) { + return true; + } + // 表头是国内、候选已是中港时也能互认 + String headerPeer = hkPeerHeaderOf(headerKey); + if (StringUtils.isNotBlank(headerPeer) && vehicleTypeEquals(cand, headerPeer)) { + return true; + } + String ton = extractVehicleTonnage(cand); + if (StringUtils.isNotBlank(ton) && vehicleTypeEquals(ton, headerKey)) { + return true; + } } return false; } + /** + * 国内整车表头 ↔ 中港表头对等(双向查)。未收录返回空。 + */ + static String hkPeerHeaderOf(String header) { + if (StringUtils.isBlank(header)) { + return ""; + } + Map peers = domesticHkHeaderPeers(); + String raw = header.trim(); + String hit = peers.get(raw); + if (StringUtils.isNotBlank(hit)) { + return hit; + } + String compact = raw.replace(" ", ""); + hit = peers.get(compact); + if (StringUtils.isNotBlank(hit)) { + return hit; + } + String norm = normalizeVehicleToken(compact); + for (Map.Entry e : peers.entrySet()) { + if (norm.equals(normalizeVehicleToken(e.getKey()))) { + return e.getValue(); + } + } + return ""; + } + + /** + * 从「6.8/5T」「4.2米/3T」「5T」抽出吨位标记;柜型/尺型无吨位则空。 + */ + static String extractVehicleTonnage(String token) { + if (StringUtils.isBlank(token)) { + return ""; + } + String n = normalizeVehicleToken(token); + java.util.regex.Matcher m = java.util.regex.Pattern + .compile("(?:/|^)(\\d+(?:\\.\\d+)?)T$") + .matcher(n); + if (m.find()) { + return m.group(1) + "T"; + } + return ""; + } + /** * TMS/询价车型文案 → 国内整车报价单表头。 *

@@ -4086,28 +4377,43 @@ public final class QuoteTemplateExcelSupport { } private static Map tmsDomesticAliasToTemplate() { - Map local = TMS_DOMESTIC_ALIAS_TO_TEMPLATE; - if (local != null) { - return local; + ensureTmsVehicleMappingLoaded(); + return TMS_DOMESTIC_ALIAS_TO_TEMPLATE; + } + + private static Map domesticHkHeaderPeers() { + ensureTmsVehicleMappingLoaded(); + return DOMESTIC_HK_HEADER_PEER; + } + + /** + * 懒加载别名表与中港对等表;同锁一次读合同。 + */ + private static void ensureTmsVehicleMappingLoaded() { + if (TMS_DOMESTIC_ALIAS_TO_TEMPLATE != null && DOMESTIC_HK_HEADER_PEER != null) { + return; } synchronized (QuoteTemplateExcelSupport.class) { - if (TMS_DOMESTIC_ALIAS_TO_TEMPLATE == null) { - TMS_DOMESTIC_ALIAS_TO_TEMPLATE = loadTmsDomesticVehicleMapping(); + if (TMS_DOMESTIC_ALIAS_TO_TEMPLATE == null || DOMESTIC_HK_HEADER_PEER == null) { + loadTmsDomesticVehicleMapping(); } - return TMS_DOMESTIC_ALIAS_TO_TEMPLATE; } } /** - * 读 classpath 合同;失败返回空 Map(出单不炸,只是别名对不上)。 + * 读 classpath 合同;失败给空 Map(出单不炸,只是别名对不上)。 + * 副作用:写入 {@link #TMS_DOMESTIC_ALIAS_TO_TEMPLATE} 与 {@link #DOMESTIC_HK_HEADER_PEER}。 */ - private static Map loadTmsDomesticVehicleMapping() { - Map out = new LinkedHashMap<>(); + private static void loadTmsDomesticVehicleMapping() { + Map aliasOut = new LinkedHashMap<>(); + Map peerOut = new LinkedHashMap<>(); try { ClassPathResource res = new ClassPathResource("contracts/tms-domestic-vehicle-mapping-v1.json"); if (!res.exists()) { log.warn("缺少 contracts/tms-domestic-vehicle-mapping-v1.json,TMS 车型别名无法映射到报价单表头"); - return out; + TMS_DOMESTIC_ALIAS_TO_TEMPLATE = aliasOut; + DOMESTIC_HK_HEADER_PEER = peerOut; + return; } try (InputStream in = res.getInputStream()) { byte[] buf = new byte[4096]; @@ -4122,22 +4428,36 @@ public final class QuoteTemplateExcelSupport { for (String key : alias.keySet()) { String val = alias.getString(key); if (StringUtils.isNotBlank(key) && StringUtils.isNotBlank(val)) { - out.put(key.trim(), val.trim()); + aliasOut.put(key.trim(), val.trim()); } } } + JSONObject peers = root == null ? null : root.getJSONObject("domesticHeaderToHkHeader"); + if (peers != null) { + for (String key : peers.keySet()) { + String val = peers.getString(key); + if (StringUtils.isBlank(key) || StringUtils.isBlank(val)) { + continue; + } + String d = key.trim(); + String h = val.trim(); + peerOut.put(d, h); + peerOut.put(h, d); + } + } } - log.info("已加载 TMS 国内车型映射 {} 条", out.size()); + log.info("已加载 TMS 国内车型映射 {} 条、中港对等 {} 条", aliasOut.size(), peerOut.size() / 2); } catch (Exception e) { log.warn("加载 TMS 国内车型映射失败: {}", e.getMessage()); } - return out; + TMS_DOMESTIC_ALIAS_TO_TEMPLATE = aliasOut; + DOMESTIC_HK_HEADER_PEER = peerOut; } /** * 国内整车标注 → 跨境国外段表头。WO202610080006:3T 对 3.5T。 - * 未收录的返回空,禁止瞎猜。 + * 含别名(4.2厢车→4.2米/3T)与吨位抽出。未收录返回空,禁止瞎猜。 */ static String mapDomesticVehicleToOverseas(String vehicleType) { String a = normalizeVehicleToken(vehicleType); @@ -4147,6 +4467,17 @@ public final class QuoteTemplateExcelSupport { if ("3T".equals(a) || "3吨".equals(a)) { return "3.5T"; } + String ton = extractVehicleTonnage(vehicleType); + if ("3T".equals(ton)) { + return "3.5T"; + } + // 已是国内表头或 TMS 别名时:落到 4.2米/3T 族再换境外 3.5T + String tms = mapTmsDomesticAliasToTemplate(vehicleType); + if (StringUtils.isNotBlank(tms)) { + if (vehicleTypeEquals(tms, "4.2米/3T") || "3T".equals(extractVehicleTonnage(tms))) { + return "3.5T"; + } + } return ""; } @@ -4168,8 +4499,8 @@ public final class QuoteTemplateExcelSupport { if (StringUtils.isBlank(vehicleType)) { return ""; } - return vehicleType.replace(" ", "").toUpperCase(Locale.ROOT) - .replace("T", "T"); + // 与 vehicleTypeEquals 同一套去撇号,避免 40'HQ 别名表查不到。 + return normalizeVehicleCompareToken(vehicleType); } /** @@ -4444,20 +4775,40 @@ public final class QuoteTemplateExcelSupport { if (StringUtils.isBlank(actual) || StringUtils.isBlank(headerKey)) { return false; } - String a = actual.replace(" ", "").toUpperCase(Locale.ROOT).replace("T", "T"); - String b = headerKey.replace(" ", "").toUpperCase(Locale.ROOT).replace("T", "T"); + // 询价常写 40HQ,旧模板表头写 40'HQ:先去掉撇号再比,否则车型列空、金额只进含税合计。 + String a = normalizeVehicleCompareToken(actual); + String b = normalizeVehicleCompareToken(headerKey); if (a.equals(b) || a.contains(b) || b.contains(a)) { return vehicleTypeContainsSafe(a, b); } return false; } + /** + * 车型比对用归一:去空格、全角 T、英尺撇号(' ′ ’)。 + * 为何:跨境整车模板历史表头是 40'HQ,询价字段是 40HQ,不去撇号永远对不上。 + */ + private static String normalizeVehicleCompareToken(String raw) { + if (StringUtils.isBlank(raw)) { + return ""; + } + return raw.replace(" ", "").toUpperCase(Locale.ROOT) + .replace("T", "T") + .replace("'", "") + .replace("′", "") + .replace("’", ""); + } + /** * 子串命中后再做一次防误伤:裸吨位不要写进「6.8/5T」这种小数米长列。 */ private static boolean vehicleTypeContainsSafe(String actualNorm, String headerNorm) { // 裸「5T」「3T」「12T」等 if (actualNorm.matches("^\\d+(\\.\\d+)?T$")) { + // 表头也是裸吨位:必须全等,禁止 5T 靠 endsWith 误命中 3.5T + if (headerNorm.matches("^\\d+(\\.\\d+)?T$")) { + return actualNorm.equals(headerNorm); + } java.util.regex.Matcher hm = java.util.regex.Pattern .compile("^(\\d+(?:\\.\\d+)?)(米)?/") .matcher(headerNorm); @@ -4469,9 +4820,8 @@ public final class QuoteTemplateExcelSupport { return actualNorm.contains(meter); } } - // 经典「4.2米/3T」「5米/5T」:吨位段对上即可 - return headerNorm.contains("/" + actualNorm) || headerNorm.endsWith(actualNorm) - || headerNorm.equals(actualNorm); + // 经典「4.2米/3T」「5米/5T」:斜杠后吨位段对上即可(禁止无斜杠的 endsWith) + return headerNorm.contains("/" + actualNorm) || headerNorm.equals(actualNorm); } return true; } @@ -4800,6 +5150,9 @@ public final class QuoteTemplateExcelSupport { || looksLikeCurrencyCode(value) || (headerPeek != null && (headerPeek.contains("燃油") || headerPeek.contains("币种") || headerPeek.contains("起始") || headerPeek.contains("始发") || headerPeek.contains("目的"))); + boolean track10t = StringUtils.containsIgnoreCase(headerPeek, "10T") + || StringUtils.containsIgnoreCase(value, "10T") + || (colLetter != null && "H".equalsIgnoreCase(colLetter.trim())); if (yellow != null && yellow.contains(ref)) { return; } @@ -5835,7 +6188,13 @@ public final class QuoteTemplateExcelSupport { return false; } - /** 单个车型尺寸格:长*宽*高M 或 长x宽x高M。 */ + /** + * 单个陆运车型尺寸格:长*宽*高M(必须带 M/米)。 + *

+ * 空运体积常写「1.1*2.1*2.2」无单位后缀。若这里把 M 做成可选, + * writeCell / shiftLandRouteWriteDown 会把体积格当成车型副表头往下挪, + * 第二次 writeFactsByLabel 时尺寸式盖住计费重、计费重再被挤进派送地址(WO202610200007)。 + */ static boolean looksLikeVehicleDimensionText(String text) { if (StringUtils.isBlank(text)) { return false; @@ -5854,7 +6213,9 @@ public final class QuoteTemplateExcelSupport { if (stars < 2) { return false; } - return t.matches("^[0-9.]+[*X×][0-9.]+[*X×][0-9.]+M?$"); + // 必须带 M/米:4.1*2.1*2.3M 才是车型尺寸;1.1*2.1*2.2 是空运体积原文。 + return t.matches("^[0-9.]+[*X×][0-9.]+[*X×][0-9.]+M$") + || t.matches("^[0-9.]+[*X×][0-9.]+[*X×][0-9.]+米$"); } /** 「项目一:零担报价 / 整车报价」「项目二:其它费用」。 */ @@ -6031,10 +6392,14 @@ public final class QuoteTemplateExcelSupport { * TMS 分桶:{@code feeItems}=空运段,{@code departureCharges}=起运港, * {@code destinationCharges}=目的地;禁止再按费目名把起运「运输费」误归空运段。 * 缺空运段运费时用舱位价注入「空运费」。 + *

+ * 无选定舱位时仍取列表首条(兼容旧单);有 selected 时必须跟选定方案, + * 禁止误用列表第一条单价(WO202610270003:选定 TH739=15,列表首条 TH641=14.5)。 */ public static JSONArray parseAirDepartureChargeRows(Object airOptions, Object airOptionsAlt, String chargeableWeightKg, String headerCurrency) { - return parseAirDepartureChargeRows(airOptions, airOptionsAlt, null, chargeableWeightKg, headerCurrency); + return parseAirDepartureChargeRows(airOptions, airOptionsAlt, null, null, + chargeableWeightKg, headerCurrency); } /** @@ -6043,9 +6408,22 @@ public final class QuoteTemplateExcelSupport { public static JSONArray parseAirDepartureChargeRows(Object airOptions, Object airOptionsAlt, Object quoteFeeItems, String chargeableWeightKg, String headerCurrency) { + return parseAirDepartureChargeRows(airOptions, airOptionsAlt, quoteFeeItems, null, + chargeableWeightKg, headerCurrency); + } + + /** + * @param quoteFeeItems 报价顶层 feeItems;可空 + * @param selectedAirOption 用户已选舱位(selected_air_option);可空。有则只出该方案费用 + */ + public static JSONArray parseAirDepartureChargeRows(Object airOptions, Object airOptionsAlt, + Object quoteFeeItems, Object selectedAirOption, + String chargeableWeightKg, String headerCurrency) { JSONArray out = new JSONArray(); Object raw = airOptions != null ? airOptions : airOptionsAlt; JSONArray opts = parseFeeRows(raw); + // 有选定舱位:只解析这一条,避免多航班列表把首条 14.5 写进报价单 + opts = preferSelectedAirOption(opts, selectedAirOption); if (opts.isEmpty()) { return out; } @@ -6055,11 +6433,11 @@ public final class QuoteTemplateExcelSupport { if (opt == null) { continue; } - // 舱位缺 feeItems 时补整单顶层,保证空运段有桶可归 + // 舱位缺 feeItems 时补整单顶层;多航班时只留本舱 airOptionNo 对应行 if (quoteFeeItems != null && parseFeeRows(opt.get("feeItems")).isEmpty() && parseFeeRows(opt.get("fee_items")).isEmpty()) { - opt.put("feeItems", quoteFeeItems); + opt.put("feeItems", filterFeeItemsForAirOption(quoteFeeItems, opt)); } // 按 TMS 桶合并,并带上 project(空运段/起运港/目的地/其它) JSONArray charges = mergeAirOptionChargeArrays(opt); @@ -6123,6 +6501,173 @@ public final class QuoteTemplateExcelSupport { return out; } + /** + * 出单舱位列表:有 selected_air_option 时只保留选定那条(列表里能对上则用列表对象,否则用选定本身)。 + * 无选定则原样返回,调用方仍按列表顺序取第一条。 + * 副作用:无(返回新数组,不改入参)。线程:调用方。 + */ + static JSONArray preferSelectedAirOption(JSONArray airOptions, Object selectedAirOption) { + JSONObject selected = coerceJsonObject(selectedAirOption); + if (selected == null || selected.isEmpty()) { + return airOptions == null ? new JSONArray() : airOptions; + } + JSONArray opts = airOptions == null ? new JSONArray() : airOptions; + JSONObject matched = findMatchingAirOption(opts, selected); + JSONArray only = new JSONArray(); + if (matched != null) { + // 列表对象可能带 departureCharges;选定对象补缺字段(航班号/有效期等) + JSONObject merged = JSON.parseObject(JSON.toJSONString(matched)); + for (String key : selected.keySet()) { + Object sv = selected.get(key); + if (sv == null) { + continue; + } + Object mv = merged.get(key); + if (mv == null || (mv instanceof String && StringUtils.isBlank((String) mv)) + || (mv instanceof JSONArray && ((JSONArray) mv).isEmpty()) + || (mv instanceof List && ((List) mv).isEmpty())) { + merged.put(key, sv); + } + } + only.add(merged); + } else { + only.add(selected); + } + return only; + } + + /** + * 在 airOptions 里找与选定舱位同一条:优先 optionNo,再航班号+起运机场,再航班号。 + */ + static JSONObject findMatchingAirOption(JSONArray airOptions, JSONObject selected) { + if (airOptions == null || airOptions.isEmpty() || selected == null) { + return null; + } + String optNo = firstNonBlank(selected.getString("optionNo"), selected.getString("option_no"), + selected.getString("airOptionNo")); + String flight = firstNonBlank(selected.getString("flightNo"), selected.getString("flight_no")); + String origin = firstNonBlank(selected.getString("originAirportCode"), + selected.getString("origin"), selected.getString("originAirport")); + for (int i = 0; i < airOptions.size(); i++) { + JSONObject opt = airOptions.getJSONObject(i); + if (opt == null) { + continue; + } + String no = firstNonBlank(opt.getString("optionNo"), opt.getString("option_no"), + opt.getString("airOptionNo")); + if (StringUtils.isNotBlank(optNo) && optNo.equalsIgnoreCase(no)) { + return opt; + } + } + if (StringUtils.isNotBlank(flight)) { + for (int i = 0; i < airOptions.size(); i++) { + JSONObject opt = airOptions.getJSONObject(i); + if (opt == null) { + continue; + } + String fn = firstNonBlank(opt.getString("flightNo"), opt.getString("flight_no")); + if (!flight.equalsIgnoreCase(fn)) { + continue; + } + if (StringUtils.isBlank(origin)) { + return opt; + } + String oo = firstNonBlank(opt.getString("originAirportCode"), + opt.getString("origin"), opt.getString("originAirport")); + if (origin.equalsIgnoreCase(oo)) { + return opt; + } + } + } + return null; + } + + /** + * 顶层 feeItems 常挂多条航班的空运费;补进舱位时只留本舱 airOptionNo / 航班号对应行。 + * 全部没有舱位标记时原样返回(单航班旧数据)。 + * 副作用:无。线程:调用方。 + */ + static Object filterFeeItemsForAirOption(Object quoteFeeItems, JSONObject opt) { + JSONArray all = parseFeeRows(quoteFeeItems); + if (all.isEmpty() || opt == null) { + return quoteFeeItems; + } + String optNo = firstNonBlank(opt.getString("optionNo"), opt.getString("option_no"), + opt.getString("airOptionNo")); + String flight = firstNonBlank(opt.getString("flightNo"), opt.getString("flight_no")); + boolean anyTagged = false; + for (int i = 0; i < all.size(); i++) { + JSONObject fee = all.getJSONObject(i); + if (fee == null) { + continue; + } + String tag = firstNonBlank(fee.getString("airOptionNo"), fee.getString("optionNo"), + fee.getString("option_no"), fee.getString("flightNo"), fee.getString("flight_no")); + if (StringUtils.isNotBlank(tag)) { + anyTagged = true; + break; + } + } + if (!anyTagged) { + return quoteFeeItems; + } + JSONArray matched = new JSONArray(); + for (int i = 0; i < all.size(); i++) { + JSONObject fee = all.getJSONObject(i); + if (fee == null) { + continue; + } + String feeOpt = firstNonBlank(fee.getString("airOptionNo"), fee.getString("optionNo"), + fee.getString("option_no")); + String feeFlight = firstNonBlank(fee.getString("flightNo"), fee.getString("flight_no")); + boolean ok = false; + if (StringUtils.isNotBlank(optNo) && optNo.equalsIgnoreCase(feeOpt)) { + ok = true; + } else if (StringUtils.isBlank(feeOpt) && StringUtils.isNotBlank(flight) + && flight.equalsIgnoreCase(feeFlight)) { + ok = true; + } + if (ok) { + matched.add(fee); + } + } + // 对不上标记时不要退回整表(会再次落到首条错误单价);交给 injectAirFreightLineIfMissing 用 salePrice + return matched; + } + + /** + * 把 Map / JSON 字符串收成 JSONObject;解不开返回 null。 + */ + static JSONObject coerceJsonObject(Object raw) { + if (raw == null) { + return null; + } + if (raw instanceof JSONObject) { + return (JSONObject) raw; + } + if (raw instanceof Map) { + @SuppressWarnings("unchecked") + Map map = (Map) raw; + return new JSONObject(map); + } + if (raw instanceof String) { + String text = ((String) raw).trim(); + if (text.isEmpty() || !text.startsWith("{")) { + return null; + } + try { + return JSON.parseObject(text); + } catch (Exception e) { + return null; + } + } + try { + return JSON.parseObject(JSON.toJSONString(raw)); + } catch (Exception e) { + return null; + } + } + /** * 按 TMS 桶合并舱位费用,并标注 project / _airBucket。 * 顺序:feeItems(空运段)→ departureCharges(起运港)→ destinationCharges(目的地)→ 其它。 @@ -6297,9 +6842,14 @@ public final class QuoteTemplateExcelSupport { if (StringUtils.isBlank(name)) { continue; } + // 与智能体 _FEE_UOM 对齐(长词/斜杠优先):业务单位表 + 常见英文码 java.util.regex.Pattern p = java.util.regex.Pattern.compile( java.util.regex.Pattern.quote(name) - + "(?:[_\\s,,、/]+)(板数|板|票|KG|KGS|公斤|柜|点)", + + "(?:[_\\s,,、/]+)(" + + "次/人|小时/人|板数|页数|箱数|单数|票数|公里数|货值|计重|平方|立方|千克|小时|" + + "数量|KG|KGS|公斤|柜|票|板|点|次|人|天|个|份|张|台|件|卷|车|月|趟|周|批|项|" + + "托|套|袋|包|箱" + + ")", java.util.regex.Pattern.CASE_INSENSITIVE); java.util.regex.Matcher m = p.matcher(rawText); if (m.find()) { @@ -7099,27 +7649,46 @@ public final class QuoteTemplateExcelSupport { if (isLandQuoteFactCode(matched)) { return matched; } - // TMS 别名(6.8厢车)先对到表头(6.8/5T),再取填表码 + // TMS 别名(6.8厢车)先对到表头(6.8/5T),再取填表码;中港对等头一并试(12.5→12米) String templateHeader = mapTmsDomesticAliasToTemplate(name); if (StringUtils.isNotBlank(templateHeader)) { - if (FILL_BY_LABEL.containsKey(templateHeader)) { - String code = FILL_BY_LABEL.get(templateHeader); - if (isLandQuoteFactCode(code)) { - return code; - } + String code = landFactCodeForTemplateHeader(templateHeader); + if (isLandQuoteFactCode(code)) { + return code; } - String withFee = templateHeader.endsWith("费用") ? templateHeader : templateHeader + "费用"; - if (FILL_BY_LABEL.containsKey(withFee)) { - String code = FILL_BY_LABEL.get(withFee); - if (isLandQuoteFactCode(code)) { - return code; - } + String peer = hkPeerHeaderOf(templateHeader); + code = landFactCodeForTemplateHeader(peer); + if (isLandQuoteFactCode(code)) { + return code; } - String viaLand = LandQuoteTemplateFields.codeForFeeName(templateHeader); - if (isLandQuoteFactCode(viaLand)) { - return viaLand; + } + return ""; + } + + /** + * 报价单车型表头 → 陆运填表码(含「费用」后缀与 LandQuoteTemplateFields 兜底)。 + */ + private static String landFactCodeForTemplateHeader(String templateHeader) { + if (StringUtils.isBlank(templateHeader)) { + return ""; + } + if (FILL_BY_LABEL.containsKey(templateHeader)) { + String code = FILL_BY_LABEL.get(templateHeader); + if (isLandQuoteFactCode(code)) { + return code; } } + String withFee = templateHeader.endsWith("费用") ? templateHeader : templateHeader + "费用"; + if (FILL_BY_LABEL.containsKey(withFee)) { + String code = FILL_BY_LABEL.get(withFee); + if (isLandQuoteFactCode(code)) { + return code; + } + } + String viaLand = LandQuoteTemplateFields.codeForFeeName(templateHeader); + if (isLandQuoteFactCode(viaLand)) { + return viaLand; + } return ""; } @@ -7823,7 +8392,8 @@ public final class QuoteTemplateExcelSupport { wideEmpty = ref; } } - return wideEmpty != null ? wideEmpty : firstEmpty; + String chosen = wideEmpty != null ? wideEmpty : firstEmpty; + return chosen; } /** @@ -8391,9 +8961,19 @@ public final class QuoteTemplateExcelSupport { /** * 询价单左侧/右侧说明文字。写进去会把「起运港/POL…」盖成港口名,销售看起来像空表。 + *

+ * 空运货物信息短标签(体积/计费重/件数/重量/品名)只有 2~3 字:必须先按 FILL_BY_LABEL 认标签。 + * 旧逻辑 length<4 直接否,会导致「体积」右侧的「计费重」被当成值格,尺寸式写进计费重(debug-2672ec)。 */ static boolean isLabelLike(String text) { - if (StringUtils.isBlank(text) || text.length() < 4) { + if (StringUtils.isBlank(text)) { + return false; + } + String compact = text.replace(":", "").replace(":", "").replaceAll("\\s+", "").trim(); + if (FILL_BY_LABEL.containsKey(compact) || FILL_BY_LABEL.containsKey(text.trim())) { + return true; + } + if (compact.length() < 4) { return false; } if (isFeeTableHeader(text) || isContainerTypeHeader(text)) { 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 5bbf3d1..099b5e1 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,11 +178,8 @@ public class InquiryTmsCabinService { if (!properties.lockConfigured()) { return fail("lock_disabled", "主账未开放锁舱"); } - // 工单报价日空/脏(如事实只有「:」)时,先拦再出站。 - // 禁止用舱位起飞日顶过去后打 TMS,再回「选项不匹配」(WO202610140047)。 - if (isTicketQuoteDateMissing(ticket)) { - return fail("lock_incomplete", "报价日期为空,请补充报价日期后重新锁舱"); - } + // 报价日空/脏:并入下方 missingLockParamReason(可与舱位三项同句补问), + // 禁止用舱位起飞日顶过去后打 TMS(WO202610140047)。 Map req = new LinkedHashMap<>(); req.put("workOrderNo", no); if (StringUtils.isNotBlank(quoteId)) { @@ -301,10 +298,14 @@ public class InquiryTmsCabinService { } } String incomplete = missingLockParamReason( - text(req, "optionNo"), text(req, "airline"), text(req, "flightNo")); + text(req, "optionNo"), + text(req, "airline"), + text(req, "flightNo"), + isTicketQuoteDateMissing(ticket)); if (StringUtils.isNotBlank(incomplete)) { - log.info("tms.lock skip incomplete wo={} optionNo={} airline={} flightNo={}", - no, text(req, "optionNo"), text(req, "airline"), text(req, "flightNo")); + log.info("tms.lock skip incomplete wo={} optionNo={} airline={} flightNo={} quoteDateMissing={}", + no, text(req, "optionNo"), text(req, "airline"), text(req, "flightNo"), + isTicketQuoteDateMissing(ticket)); return fail("lock_params_incomplete", incomplete); } return invokeLock(ticket, req, quoteId, resolvedOption); @@ -912,8 +913,13 @@ public class InquiryTmsCabinService { return ""; } // 列空、事实只有「:」等脏值,都算报价日缺失(WO047)。 + // 与舱位缺项同一句,避免航线先补三项再被单独拦报价日。 if (isTicketQuoteDateMissing(ticket)) { - return "报价日期为空,请补充报价日期后重新锁舱"; + return missingLockParamReason( + text(req, "optionNo"), + text(req, "airline"), + text(req, "flightNo"), + true); } boolean noMatch = "TMS_NO_QUOTE_1002".equals(StringUtils.trimToEmpty(refreshClass)) || (StringUtils.isNotBlank(refreshMsg) && refreshMsg.contains("无匹配")); @@ -1302,10 +1308,20 @@ public class InquiryTmsCabinService { } /** - * 锁舱三项没齐时的群回复。不叫锁舱失败,不提 TMS 查价结果。 - * 三项都缺时是:锁舱参数不完整,缺少:舱位编号、航司、航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。 + * 锁舱参数没齐时的群回复。不叫锁舱失败,不提 TMS 查价结果。 + *

+ * 舱位三项都缺且报价日有值:缺少:舱位编号、航司、航班号。…例如:AIR-OPT01,南航,航班CZ327。 + * 报价日也空:缺项末尾加「报价日期」,示例末尾加「,2026-10-18」。 */ static String missingLockParamReason(String optionNo, String airline, String flightNo) { + return missingLockParamReason(optionNo, airline, flightNo, false); + } + + /** + * @param quoteDateMissing 工单报价日空/脏时为 true,并入缺项列表 + */ + static String missingLockParamReason( + String optionNo, String airline, String flightNo, boolean quoteDateMissing) { java.util.List missing = new ArrayList<>(); if (StringUtils.isBlank(optionNo)) { missing.add("舱位编号"); @@ -1316,11 +1332,17 @@ public class InquiryTmsCabinService { if (StringUtils.isBlank(flightNo)) { missing.add("航班号"); } + if (quoteDateMissing) { + missing.add("报价日期"); + } if (missing.isEmpty()) { return ""; } + String example = quoteDateMissing + ? "AIR-OPT01,南航,航班CZ327,2026-10-18" + : "AIR-OPT01,南航,航班CZ327"; return "锁舱参数不完整,缺少:" + String.join("、", missing) - + "。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。"; + + "。请补充后再锁,例如:" + example + "。"; } /** diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapper.java index 69d6296..3d37102 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapper.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapper.java @@ -4,7 +4,7 @@ package org.jeecg.modules.inquiry.tms; * 陆运运输类型 / 线路类别出站:产品要求原文直传,不再翻成 TMS 英文枚举。 *

* 职责:统一 trim;空串表示未填。工单/卡片/拉人本来就是中文,出站与之一致。 - * 中港整车、中港零担/集拼查价只传「中港」,工单仍留细类。 + * 中港整车、中港零担/集拼也按细类原文传 landTransportType,不再收成「中港」。 * 禁止:再把「跨境整车」写成 CROSS_BORDER_FTL;把运输分类、中港装载方式塞进出站。 * 历史英文码若仍传入,原样放过(兼容旧快照),不做双向翻译。 */ @@ -15,15 +15,10 @@ public final class TmsLandEnumMapper { /** * @param raw 销售中文(或历史英文码) - * @return 中港两类收成「中港」;其余 trim 后原文;空则 "" + * @return trim 后原文(含中港整车 / 中港零担/集拼);空则 "" */ public static String transportType(String raw) { - String text = trimOrEmpty(raw); - if ("中港整车".equals(text) || "中港零担/集拼".equals(text)) { - // zhonggang: 两类细类查价都只传「中港」 - return "中港"; - } - return text; + return trimOrEmpty(raw); } /** diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapper.java index ccc7d81..38cf7d3 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapper.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapper.java @@ -16,10 +16,10 @@ import java.util.regex.Pattern; * 陆运 TMS 查价段:按运输类型拼出站字段。 *

* 职责:把工单 facts 里的中文键写成 TMS 段;运输分类只留在工单,不进出站。 - * 拼车/跨境集拼带体积重量;国内整车/跨境整车/中港零担带「车型/数量」一项再拆成出站车型与台数; - * 中港整车不带车型/数量。通关口岸有值才放。 - * 中港整车、中港零担/集拼:出站 landTransportType 只写「中港」,工单事实仍留细类。 - * 类型/线路键名仍是 landTransportType / routeCategory。除中港两类外,值用中文原文。 + * 拼车/跨境集拼带体积重量;国内整车/跨境整车/中港整车带「车型/数量」一项再拆成出站车型与台数; + * 仅中港零担/集拼不带车型/数量。通关口岸有值才放。 + * 中港整车、中港零担/集拼:出站 landTransportType 按细类原文(不再收成「中港」)。 + * 类型/线路键名仍是 landTransportType / routeCategory;值用中文原文。 * 禁止:按空运要包装件数;把分类当查价条件;直连 TMS;再把中文翻成 CROSS_BORDER_*。 */ public final class TmsLandSegmentMapper { @@ -27,9 +27,12 @@ public final class TmsLandSegmentMapper { /** 体积+重量那一套。 */ private static final Set LCL = new HashSet( Arrays.asList("国内运输拼车", "跨境集拼")); - /** 车型/数量那一套。中港零担走这里;中港整车另判。 */ + /** 车型/数量那一套:国内整车、跨境整车、中港整车。 */ private static final Set FTL = new HashSet( - Arrays.asList("国内运输整车", "跨境整车", "中港零担/集拼")); + Arrays.asList("国内运输整车", "跨境整车", "中港整车")); + /** 仅中港零担/集拼:询价不采车型/数量,出站也不带。 */ + private static final Set HK_NO_VEHICLE = new HashSet( + Arrays.asList("中港零担/集拼")); private static final Pattern VEHICLE_SPLIT = Pattern.compile("\\s*[/xX×**]\\s*"); private static final Pattern TAIL_QTY = Pattern.compile("^(.*?)[\\s,,]+(\\d+)\\s*(台|辆)?$"); @@ -54,8 +57,8 @@ public final class TmsLandSegmentMapper { if (LCL.contains(type)) { put(seg, "volumeCbm", TmsV2RequestMapper.asVolumeCbm(source.get("体积"))); put(seg, "weightKg", TmsV2RequestMapper.asPositiveDecimal(source.get("毛重"))); - } else if ("中港整车".equals(type)) { - // 中港整车:询价不要车型/数量,出站也不带 + } else if (HK_NO_VEHICLE.contains(type)) { + // 中港零担/集拼:询价不要车型/数量,出站也不带 } else if (FTL.contains(type)) { putVehicleQty(source, seg); } @@ -68,7 +71,7 @@ public final class TmsLandSegmentMapper { /** * 陆运出站拦网:工单号、报价日期、起运目的品名、类型、线路,再加当前类型货物。 - * 不要包装、不要运输分类。中港整车不要车型/数量。 + * 不要包装、不要运输分类。仅中港零担/集拼不要车型/数量。 * * @param facts 用于读运输类型;禁止把分类写进 segment */ @@ -98,13 +101,13 @@ public final class TmsLandSegmentMapper { || LCL.contains(typeOnSeg) || "DOMESTIC_LTL".equals(typeOnSeg) || "CROSS_BORDER_LTL".equals(typeOnSeg); - boolean hkFull = "中港整车".equals(cnType) || "中港整车".equals(typeOnSeg); + // 中港细类出站已原文直传;仍同时认 facts 与段上的类型,兼容旧「中港」快照。 + boolean hkNoVehicle = HK_NO_VEHICLE.contains(cnType) + || HK_NO_VEHICLE.contains(typeOnSeg); boolean ftl = FTL.contains(cnType) || FTL.contains(typeOnSeg) || "DOMESTIC_FTL".equals(typeOnSeg) - || "CROSS_BORDER_FTL".equals(typeOnSeg) - || "CHINA_HK".equals(typeOnSeg) - || "中港零担/集拼".equals(cnType); + || "CROSS_BORDER_FTL".equals(typeOnSeg); if (lcl) { if (seg.get("volumeCbm") == null) { missing.add("volumeCbm"); @@ -112,8 +115,8 @@ public final class TmsLandSegmentMapper { if (seg.get("weightKg") == null) { missing.add("weightKg"); } - } else if (hkFull) { - // 中港整车不拦车型/数量 + } else if (hkNoVehicle) { + // 中港零担/集拼:不拦车型/数量 } else if (ftl) { if (blank(seg.get("vehicleType"))) { missing.add("vehicleType"); @@ -126,7 +129,7 @@ public final class TmsLandSegmentMapper { } /** - * 兼容旧调用:没有 facts 时按段上的类型拦;中港整车必须走四参才能放过车型。 + * 兼容旧调用:没有 facts 时按段上的类型拦;中港零担须走四参才能放过车型。 */ public static List missingQuery(String workOrderNo, String quoteDate, Map segment) { @@ -163,9 +166,11 @@ public final class TmsLandSegmentMapper { } /** - * 例:9米6/1、9米6*2、9米6 1台。拆不出台数时默认 1。 + * 例:9米6/1、9米6*2、9米6 1台、10T车/1。拆不出台数时默认 1。 + *

+ * 出单回填无 TMS 段时,也要按同一规则从询价「车型/数量」拆出 vehicleType。 */ - static String[] splitVehicleQty(String raw) { + public static String[] splitVehicleQty(String raw) { String text = text(raw); if (text.isEmpty()) { return new String[] {"", "1"}; 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 550dc51..6e232a5 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 @@ -160,7 +160,7 @@ public final class TmsV2RequestMapper { } /** - * 陆运出站:类型+线路用销售中文;中港整车、中港零担/集拼收成「中港」。线路字段名是 routeCategory。 + * 陆运出站:类型+线路用销售中文(含中港整车 / 中港零担/集拼细类)。线路字段名是 routeCategory。 * 分类和中港装载即使误放在 source 也不抄走。 */ private static void applyLandFields(Map source, Map target) { diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/quote-template-mapping-v1.json b/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/quote-template-mapping-v1.json index a0a7ad3..d9c08b0 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/quote-template-mapping-v1.json +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/quote-template-mapping-v1.json @@ -166,6 +166,9 @@ "体积": "volume_cbm", "体积(CBM)": "volume_cbm", "体 积": "volume_cbm", + "计费重": "chargeable_weight_kg", + "计费重量": "chargeable_weight_kg", + "Chargeable Weight": "chargeable_weight_kg", "费用总计": "total_amount", "报价合计": "total_amount", "RFQ No": "quote_no", diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/tms-domestic-vehicle-mapping-v1.json b/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/tms-domestic-vehicle-mapping-v1.json index 001da7c..0cee9d1 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/tms-domestic-vehicle-mapping-v1.json +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/tms-domestic-vehicle-mapping-v1.json @@ -1,6 +1,6 @@ { "schemaVersion": "tms-domestic-vehicle-mapping-v1", - "description": "TMS车型/别名 → 国内整车报价单表头车型;来源 tms国内车型映射表 -ai.xlsx", + "description": "TMS车型/别名 → 国内整车报价单表头车型;来源 tms国内车型映射表 -ai.xlsx。domesticHeaderToHkHeader 为国内表头与中港表头对等(填表时双向)。", "templateToDomesticAliases": { "4.2米/3T": [ "3T危险品", @@ -99,6 +99,13 @@ "3.5T" ] }, + "domesticHeaderToHkHeader": { + "6.8/5T": "5米/5T", + "6.8米/5T": "5米/5T", + "7.6米/8T": "6.5米/8T", + "9.6米/10T": "7.8米/10T", + "12.5米/40尺": "12米/40尺" + }, "domesticAliasToTemplate": { "3T危险品": "4.2米/3T", "4.2厢车": "4.2米/3T", 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 edccc8b..66fd224 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 @@ -17,6 +17,7 @@ import org.junit.jupiter.api.Test; import java.nio.file.Files; import java.nio.file.Path; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import org.apache.commons.lang3.StringUtils; @@ -25,6 +26,7 @@ import org.apache.poi.ss.usermodel.CellType; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -119,6 +121,97 @@ public class QuoteTemplateFillTest { assertEquals("CNY", ((JSONArray) fee).getJSONObject(0).getString("currency")); } + /** + * 调试 2672ec / 25b094:体积=1.1*2.1*2.2 时,填表事实里计费重不能原样等于体积。 + * 同时验证标签回填是否把体积写进「计费重」右侧格。 + * WO007:竖排「体积 / 计费重 / 派送地址」+ 映射写计费重 + 两次 writeFactsByLabel, + * 不得把尺寸式盖进计费重、再把 847 挤进派送地址。 + */ + @Test + public void airDimensionVolumeMustNotCopyIntoChargeableWeight() throws Exception { + Map facts = new HashMap<>(); + facts.put("运输方式", "空运"); + facts.put("品名", "茶叶"); + facts.put("件数", "21件"); + facts.put("毛重", "11 KG"); + facts.put("体积", "1.1*2.1*2.2"); + Map quote = new HashMap<>(); + quote.put("currency", "CNY"); + quote.put("total", "100"); + quote.put("chargeableWeightKg", "847.1694"); + JSONObject fill = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl + .buildFillFacts("WO202610180046", facts, quote, "WuJiLin", "AIR"); + assertEquals("1.1*2.1*2.2", fill.getString("volume_cbm")); + assertEquals("847.1694", fill.getString("chargeable_weight_kg")); + assertFalse(QuoteTemplateExcelSupport.looksLikeVehicleDimensionText("1.1*2.1*2.2"), + "空运体积尺寸式不得被当成陆运车型尺寸"); + assertTrue(QuoteTemplateExcelSupport.looksLikeVehicleDimensionText("4.1*2.1*2.3M"), + "陆运车型尺寸必须仍识别"); + + // 空运常见:标签横排「体积 | 计费重」,中间没有独立空值格。 + Path dir = Files.createTempDirectory("air-cw-vol"); + Path src = dir.resolve("tpl.xlsx"); + Path outXlsx = dir.resolve("out.xlsx"); + try (XSSFWorkbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("报价单"); + Row labels = sheet.createRow(5); + labels.createCell(0).setCellValue("体积"); + labels.createCell(1).setCellValue("计费重"); + Row values = sheet.createRow(6); + values.createCell(0).setCellValue(""); + values.createCell(1).setCellValue(""); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject mapping = new JSONObject(true); + mapping.put("fields", new JSONObject(true)); + QuoteTemplateExcelSupport.fillWorkbook(src, outXlsx, mapping, fill); + try (Workbook got = org.apache.poi.ss.usermodel.WorkbookFactory.create(Files.newInputStream(outXlsx))) { + Sheet sheet = got.getSheetAt(0); + DataFormatter fmt = new DataFormatter(); + String cwLabelOrVal = fmt.formatCellValue(sheet.getRow(5).getCell(1)); + assertFalse("1.1*2.1*2.2".equals(cwLabelOrVal), + "体积标签回填不得把尺寸式写入计费重格,实际=" + cwLabelOrVal); + } + + // 竖排货物信息(空运正式模板 Sheet2):体积 / 计费重 / 派送地址 + Path src2 = dir.resolve("tpl-vertical.xlsx"); + Path out2 = dir.resolve("out-vertical.xlsx"); + try (XSSFWorkbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("Sheet2"); + Row r11 = sheet.createRow(10); + r11.createCell(0).setCellValue("体 积:"); + r11.createCell(1).setCellValue(""); + r11.createCell(2).setCellValue("CBM"); + Row r12 = sheet.createRow(11); + r12.createCell(0).setCellValue(" 计 费 重:"); + r12.createCell(1).setCellValue(""); + r12.createCell(2).setCellValue("KG"); + Row r13 = sheet.createRow(12); + r13.createCell(0).setCellValue(" 派送地址:"); + r13.createCell(1).setCellValue(""); + try (java.io.OutputStream os = Files.newOutputStream(src2)) { + wb.write(os); + } + } + JSONObject mapping2 = new JSONObject(true); + JSONObject fields2 = new JSONObject(true); + fields2.put("chargeable_weight_kg", "Sheet2!B12"); + mapping2.put("fields", fields2); + QuoteTemplateExcelSupport.fillWorkbook(src2, out2, mapping2, fill); + try (Workbook got = org.apache.poi.ss.usermodel.WorkbookFactory.create(Files.newInputStream(out2))) { + Sheet sheet = got.getSheet("Sheet2"); + DataFormatter fmt = new DataFormatter(); + assertEquals("1.1*2.1*2.2", fmt.formatCellValue(sheet.getRow(10).getCell(1))); + assertEquals("847.1694", fmt.formatCellValue(sheet.getRow(11).getCell(1))); + assertFalse("847.1694".equals(fmt.formatCellValue(sheet.getRow(12).getCell(1))), + "计费重不得被挤进派送地址"); + assertFalse("1.1*2.1*2.2".equals(fmt.formatCellValue(sheet.getRow(11).getCell(1))), + "计费重格不得被体积尺寸式覆盖"); + } + } + /** * 空运没有时效:即便 TMS 回了 eta,出单也不填 transit_* / 时效。 * 报价有效期仍要留下。 @@ -887,6 +980,52 @@ public class QuoteTemplateFillTest { assertEquals("7918.25", out.getString("total_amount")); } + /** + * WO036:航线在舱位价上再加增点费,total=明细之和(4966.40)大于 salePrice(3966.40)。 + * 预估费用必须保留航线总价,禁止再盖回 TMS。 + */ + @Test + public void buildFillFactsAirKeepsAirlineTotalWhenAboveSalePrice() { + Map facts = new HashMap<>(); + facts.put("运输方式", "空运"); + facts.put("品名", "普货"); + Map option = new HashMap<>(); + option.put("optionNo", "AIR-OPT01"); + option.put("salePrice", 3966.40); + option.put("currency", "CNY"); + Map quote = new HashMap<>(); + quote.put("total", "4966.40"); + quote.put("currency", "CNY"); + quote.put("airOptions", java.util.Collections.singletonList(option)); + quote.put("selected_air_option", option); + quote.put("fee_lines", java.util.Arrays.asList( + new HashMap() {{ + put("name", "空运费"); + put("amount", "3667.40"); + put("currency", "CNY"); + }}, + new HashMap() {{ + put("name", "人工费"); + put("amount", "100"); + put("currency", "CNY"); + }}, + new HashMap() {{ + put("name", "打板费"); + put("amount", "199"); + put("currency", "CNY"); + }}, + new HashMap() {{ + put("name", "增点费"); + put("amount", "1000"); + put("currency", "CNY"); + }} + )); + quote.put("chargeableWeightKg", "333.4"); + JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl + .buildFillFacts("WO202610180036", facts, quote, "WuJiLin", "AIR"); + assertEquals("4966.40", out.getString("total_amount")); + } + @Test public void feeQuantityDefaultsToOneWhenBlank() { assertEquals("1", QuoteTemplateExcelSupport.feeQuantityOrDefault(null, "提送货费")); @@ -1008,6 +1147,70 @@ public class QuoteTemplateFillTest { || "16670.0".equals(rows.getJSONObject(1).getString("quantity"))); } + /** + * WO202610270003:TMS 多航班时顶层 feeItems 含 14.5/15/15; + * 出单必须跟 selected_air_option(TH739 / AIR-OPT03 / 15),禁止写列表首条 14.5。 + */ + @Test + public void parseAirChargesPrefersSelectedOptionUnitPrice() { + String airOptions = "[" + + "{\"optionNo\":\"AIR-OPT01\",\"flightNo\":\"TH641\",\"originAirportCode\":\"HKG\"," + + "\"salePrice\":37465.83,\"currency\":\"CNY\",\"chargeableWeightKg\":2583.85," + + "\"departureCharges\":[]}," + + "{\"optionNo\":\"AIR-OPT02\",\"flightNo\":\"TH6187\",\"originAirportCode\":\"SZX\"," + + "\"salePrice\":38757.75,\"currency\":\"CNY\",\"chargeableWeightKg\":2583.85," + + "\"departureCharges\":[]}," + + "{\"optionNo\":\"AIR-OPT03\",\"flightNo\":\"TH739\",\"originAirportCode\":\"NKG\"," + + "\"salePrice\":38757.75,\"currency\":\"CNY\",\"chargeableWeightKg\":2583.85," + + "\"departureCharges\":[]}" + + "]"; + String topFees = "[" + + "{\"feeCode\":\"AIR_FREIGHT\",\"feeName\":\"空运费\",\"airOptionNo\":\"AIR-OPT01\"," + + "\"unitPrice\":14.5,\"quantity\":2583.85,\"amount\":37465.83,\"unit\":\"KG\",\"currency\":\"CNY\"}," + + "{\"feeCode\":\"AIR_FREIGHT\",\"feeName\":\"空运费\",\"airOptionNo\":\"AIR-OPT02\"," + + "\"unitPrice\":15.0,\"quantity\":2583.85,\"amount\":38757.75,\"unit\":\"KG\",\"currency\":\"CNY\"}," + + "{\"feeCode\":\"AIR_FREIGHT\",\"feeName\":\"空运费\",\"airOptionNo\":\"AIR-OPT03\"," + + "\"unitPrice\":15.0,\"quantity\":2583.85,\"amount\":38757.75,\"unit\":\"KG\",\"currency\":\"CNY\"}" + + "]"; + String selected = "{\"optionNo\":\"AIR-OPT03\",\"flightNo\":\"TH739\",\"originAirportCode\":\"NKG\"," + + "\"salePrice\":38757.75,\"currency\":\"CNY\",\"chargeableWeightKg\":2583.85," + + "\"departureCharges\":[]}"; + JSONArray rows = QuoteTemplateExcelSupport.parseAirDepartureChargeRows( + airOptions, null, topFees, selected, "2583.85", "CNY"); + assertFalse(rows.isEmpty()); + assertEquals("空运费", rows.getJSONObject(0).getString("name")); + assertEquals("空运段", rows.getJSONObject(0).getString("project")); + String unitPrice = rows.getJSONObject(0).getString("unit_price"); + assertTrue(unitPrice.startsWith("15"), "选定舱位单价应为 15,实际=" + unitPrice); + String amount = rows.getJSONObject(0).getString("amount"); + assertTrue(amount.startsWith("38757.75"), "选定舱位金额应为 38757.75,实际=" + amount); + + // buildFillFacts 同样带上 selected,报价单事实不得落到 14.5 + Map facts = new LinkedHashMap<>(); + facts.put("起运港", "南京"); + facts.put("目的港", "吉隆坡"); + facts.put("计费重", "2583.85"); + Map quote = new LinkedHashMap<>(); + quote.put("source", "TMS"); + quote.put("total", "38757.75"); + quote.put("currency", "CNY"); + quote.put("chargeableWeightKg", "2583.8500"); + quote.put("airOptions", JSON.parseArray(airOptions)); + quote.put("feeItems", JSON.parseArray(topFees)); + quote.put("selected_air_option", JSON.parseObject(selected)); + quote.put("fee_lines", JSON.parseArray( + "[{\"name\":\"空运费\",\"amount\":\"CNY 38757.75\",\"currency\":\"CNY\"," + + "\"unit\":\"KG\",\"unit_price\":15.0,\"quantity\":2583.85}]")); + JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl + .buildFillFacts("WO202610270003", facts, quote, "Tester", "AIR"); + JSONArray feeRows = out.getJSONArray("fee_rows"); + assertNotNull(feeRows); + assertFalse(feeRows.isEmpty()); + String fillPrice = feeRows.getJSONObject(0).getString("unit_price"); + assertTrue(fillPrice != null && fillPrice.startsWith("15"), + "出单事实单价应为 15,实际=" + fillPrice); + } + /** * 人工只报起运港费:必须落到模板「起运港」段,禁止占掉「空运段」首行并把骨架改成起运港。 */ @@ -3208,6 +3411,27 @@ public class QuoteTemplateFillTest { } } + /** + * 中港:TMS 别名经国内映射后再对等中港表头;跨境:吨位列与 3T 族境外 3.5T;禁止 5T 误命中 3.5T。 + */ + @Test + public void hkAndCrossBorderVehicleMappingPeers() { + assertEquals("12米/40尺", QuoteTemplateExcelSupport.hkPeerHeaderOf("12.5米/40尺")); + assertEquals("12.5米/40尺", QuoteTemplateExcelSupport.hkPeerHeaderOf("12米/40尺")); + assertEquals("5T", QuoteTemplateExcelSupport.extractVehicleTonnage("6.8/5T")); + assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("40HQ", "12米/40尺", "")); + assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("6.8厢车", "5米/5T", "")); + assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("9.6厢车", "7.8米/10T", "")); + assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("6.8厢车", "5T", "domestic")); + assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("4.2厢车", "3.5T", "overseas")); + assertEquals("3.5T", QuoteTemplateExcelSupport.mapDomesticVehicleToOverseas("4.2厢车")); + assertFalse(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("5T", "3.5T", "overseas")); + assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("5T", "5T", "overseas")); + // 国内整车原路径不回归 + assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("40HQ", "12.5米/40尺", "")); + assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("6.8厢车", "6.8/5T", "")); + } + /** * 国内整车:车型格「4.2米/3T」是浅蓝表头,出单后不能变成业务员那种深蓝。 */ @@ -3343,4 +3567,118 @@ public class QuoteTemplateFillTest { assertEquals("8%", QuoteTemplateExcelSupport.formatLandPercentDisplay("8.0")); assertEquals("含税", QuoteTemplateExcelSupport.formatLandTaxIncludedDisplay("1")); } + + /** + * 空运起运港费用多于模板预留行时:必须在「起运港」段内(「其他」之前)增行, + * 禁止把多余费目挤到「其他」之后。 + */ + @Test + public void airManyOriginFeesGrowInsideOriginBandBeforeOther() throws Exception { + Path src = Files.createTempFile("quote-air-grow-src", ".xlsx"); + Path out = Files.createTempFile("quote-air-grow-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("Sheet2"); + org.apache.poi.ss.usermodel.Row head = sheet.createRow(15); + head.createCell(0).setCellValue("项目"); + head.createCell(1).setCellValue("费用名称"); + head.createCell(2).setCellValue("单位"); + head.createCell(4).setCellValue("单价"); + head.createCell(5).setCellValue("数量"); + head.createCell(6).setCellValue("金额"); + // 空运段 1 行 + org.apache.poi.ss.usermodel.Row rAir = sheet.createRow(16); + rAir.createCell(0).setCellValue("空运段"); + rAir.createCell(1).setCellValue("样例空运费"); + // 起运港仅预留 2 行(合并),远少于 5 条起运港费 + org.apache.poi.ss.usermodel.Row rPol1 = sheet.createRow(17); + rPol1.createCell(0).setCellValue("起运港"); + rPol1.createCell(1).setCellValue("样例1"); + org.apache.poi.ss.usermodel.Row rPol2 = sheet.createRow(18); + rPol2.createCell(0).setCellValue("起运港"); + rPol2.createCell(1).setCellValue("样例2"); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(17, 18, 0, 0)); + org.apache.poi.ss.usermodel.Row rOther = sheet.createRow(19); + rOther.createCell(0).setCellValue("其他"); + org.apache.poi.ss.usermodel.Row total = sheet.createRow(22); + total.createCell(0).setCellValue("预估费用"); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject mapping = new JSONObject(); + mapping.put("fields", new JSONObject()); + JSONObject region = new JSONObject(true); + region.put("id", "air_fee_detail"); + region.put("anchor", "Sheet2!A17"); + region.put("stopAtText", java.util.Collections.singletonList("预估费用")); + region.put("grow", "insert_before_stop"); + JSONObject cols = new JSONObject(true); + cols.put("project", "A"); + cols.put("name", "B"); + cols.put("unit", "C"); + cols.put("unit_price", "E"); + cols.put("quantity", "F"); + cols.put("amount", "G"); + region.put("columns", cols); + mapping.put("feeRegions", new JSONArray(java.util.Collections.singletonList(region))); + + JSONObject facts = new JSONObject(true); + facts.put("total_amount", "5000"); + facts.put("currency", "CNY"); + JSONArray feeRows = new JSONArray(); + JSONObject air = new JSONObject(true); + air.put("project", "空运段"); + air.put("name", "空运费"); + air.put("unit", "KG"); + air.put("unit_price", "10"); + air.put("quantity", "100"); + air.put("amount", "1000"); + feeRows.add(air); + String[] polNames = {"人工费", "打板费", "增点费", "电放费", "文件费", "报关费", "单证费"}; + for (String n : polNames) { + JSONObject one = new JSONObject(true); + one.put("project", "起运港"); + one.put("name", n); + one.put("unit", "次"); + one.put("unit_price", "10"); + one.put("quantity", "100"); + one.put("amount", "1000"); + feeRows.add(one); + } + facts.put("fee_rows", feeRows); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook got = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = got.getSheet("Sheet2"); + DataFormatter fmt = new DataFormatter(); + int otherRow = -1; + int lastPolFeeRow = -1; + java.util.List polFees = new java.util.ArrayList<>(); + for (int r = 16; r <= sheet.getLastRowNum(); r++) { + org.apache.poi.ss.usermodel.Row row = sheet.getRow(r); + if (row == null) { + continue; + } + String proj = fmt.formatCellValue(row.getCell(0)); + String name = fmt.formatCellValue(row.getCell(1)); + if ("其他".equals(proj.trim()) || "其它".equals(proj.trim())) { + otherRow = r; + } + if (java.util.Arrays.asList(polNames).contains(name)) { + polFees.add(name); + lastPolFeeRow = r; + // 合并格读原点:物理行可能空白,用骨架工具读 + String band = QuoteTemplateExcelSupport.normalizeAirProjectLabel( + QuoteTemplateExcelSupport.projectLabelAt(sheet, r, 0)); + assertEquals("起运港", band, "fee " + name + " must stay in 起运港 band at row " + r); + } + } + assertEquals(7, polFees.size(), "all 7 origin fees written"); + assertTrue(otherRow > 0, "其他 row present"); + assertTrue(lastPolFeeRow < otherRow, + "last origin fee row " + lastPolFeeRow + " must be before 其他 row " + otherRow); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } } 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 6d1f4af..0d00c1e 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 @@ -248,6 +248,12 @@ public class InquiryTmsCabinServiceTest { "锁舱参数不完整,缺少:航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。", InquiryTmsCabinService.missingLockParamReason("AIR-OPT01", "CZ", "")); assertEquals("", InquiryTmsCabinService.missingLockParamReason("AIR-OPT01", "CZ", "CZ327")); + assertEquals( + "锁舱参数不完整,缺少:舱位编号、航司、航班号、报价日期。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327,2026-10-18。", + InquiryTmsCabinService.missingLockParamReason("", "", "", true)); + assertEquals( + "锁舱参数不完整,缺少:报价日期。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327,2026-10-18。", + InquiryTmsCabinService.missingLockParamReason("AIR-OPT01", "CZ", "CZ327", true)); } @Test @@ -361,7 +367,9 @@ public class InquiryTmsCabinServiceTest { String raw = "锁舱参数不完整,缺少:有效期validUntil。请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。"; String why = InquiryTmsCabinService.rewriteManualLockIncompleteReason( raw, ticket, req, "TMS_NO_QUOTE_1002", "标准运价库无匹配报价"); - assertEquals("报价日期为空,请补充报价日期后重新锁舱", why); + assertEquals( + "锁舱参数不完整,缺少:舱位编号、航司、航班号、报价日期。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327,2026-10-18。", + why); } @Test diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapperTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapperTest.java index a1bc73b..463b3b6 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapperTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapperTest.java @@ -28,9 +28,9 @@ public class TmsLandEnumMapperTest { } @Test - public void hkTypesCollapseToZhonggang() { - assertEquals("中港", TmsLandEnumMapper.transportType("中港整车")); - assertEquals("中港", TmsLandEnumMapper.transportType("中港零担/集拼")); + public void hkTypesPassThroughFineClass() { + assertEquals("中港整车", TmsLandEnumMapper.transportType("中港整车")); + assertEquals("中港零担/集拼", TmsLandEnumMapper.transportType("中港零担/集拼")); assertEquals("中港集拼", TmsLandEnumMapper.transportType("中港集拼")); } diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapperTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapperTest.java index eac1d48..5f2c46f 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapperTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapperTest.java @@ -37,6 +37,42 @@ public class TmsLandSegmentMapperTest { @Test public void landFtlUsesVehicleNotVolume() { + Map facts = new HashMap(); + facts.put("运输类型", "国内运输整车"); + facts.put("线路类别", "国内长途/零担"); + facts.put("起运港", "广州"); + facts.put("目的港", "深圳"); + facts.put("品名", "衣服"); + facts.put("车型/数量", "9米6/1"); + facts.put("体积", "99"); + Map seg = TmsLandSegmentMapper.build(facts); + assertEquals("国内运输整车", seg.get("landTransportType")); + assertEquals("9米6", seg.get("vehicleType")); + assertNull(seg.get("volumeCbm")); + assertFalse(seg.containsKey("hkLoadType")); + assertEquals(Integer.valueOf(1), seg.get("quantity")); + } + + @Test + public void hkFullTruckRequiresVehicle() { + Map facts = new HashMap(); + facts.put("运输类型", "中港整车"); + facts.put("线路类别", "中港/中亚/中欧"); + facts.put("起运港", "广州"); + facts.put("目的港", "香港"); + facts.put("品名", "衣服"); + facts.put("车型/数量", "9米6/1"); + Map seg = TmsLandSegmentMapper.build(facts); + assertEquals("中港整车", seg.get("landTransportType")); + assertEquals("9米6", seg.get("vehicleType")); + assertEquals(Integer.valueOf(1), seg.get("quantity")); + java.util.List missing = TmsLandSegmentMapper.missingQuery( + "WO202609170005", "2026-09-17", seg, facts); + assertEquals(java.util.Collections.emptyList(), missing); + } + + @Test + public void hkLclOmitsVehicle() { Map facts = new HashMap(); facts.put("运输类型", "中港零担/集拼"); facts.put("线路类别", "中港/中亚/中欧"); @@ -46,25 +82,11 @@ public class TmsLandSegmentMapperTest { facts.put("车型/数量", "9米6/1"); facts.put("体积", "99"); Map seg = TmsLandSegmentMapper.build(facts); - assertEquals("中港", seg.get("landTransportType")); - assertEquals("9米6", seg.get("vehicleType")); - assertNull(seg.get("volumeCbm")); - assertFalse(seg.containsKey("hkLoadType")); - assertEquals(Integer.valueOf(1), seg.get("quantity")); - } - - @Test - public void hkFullTruckOmitsVehicle() { - Map facts = new HashMap(); - facts.put("运输类型", "中港整车"); - facts.put("线路类别", "中港/中亚/中欧"); - facts.put("起运港", "广州"); - facts.put("目的港", "香港"); - facts.put("品名", "衣服"); - Map seg = TmsLandSegmentMapper.build(facts); - assertEquals("中港", seg.get("landTransportType")); + assertEquals("中港零担/集拼", seg.get("landTransportType")); assertNull(seg.get("vehicleType")); assertNull(seg.get("quantity")); + assertNull(seg.get("volumeCbm")); + assertFalse(seg.containsKey("hkLoadType")); java.util.List missing = TmsLandSegmentMapper.missingQuery( "WO202609170005", "2026-09-17", seg, facts); assertEquals(java.util.Collections.emptyList(), missing); diff --git a/inquiry-backend/src/router/guard/permissionGuard.ts b/inquiry-backend/src/router/guard/permissionGuard.ts index be0ae58..ca54bab 100644 --- a/inquiry-backend/src/router/guard/permissionGuard.ts +++ b/inquiry-backend/src/router/guard/permissionGuard.ts @@ -129,31 +129,6 @@ export function createPermissionGuard(router: Router) { } if (permissionStore.getIsDynamicAddedRoute) { - // #region agent log - fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'a0b47d' }, - body: JSON.stringify({ - sessionId: 'a0b47d', - runId: 'pre-fix', - hypothesisId: 'H4-H5', - location: 'permissionGuard.ts:alreadyAdded', - message: '动态路由已添加,直接放行', - data: { - toPath: to.path, - toName: String(to.name || ''), - matched: (to.matched || []).map((m) => ({ path: m.path, name: String(m.name || '') })), - hasDashRoute: router.getRoutes().some((r) => String(r.path).includes('inquiry/dashboard') || String(r.name) === 'inquiry-dashboard'), - routeNamesSample: router - .getRoutes() - .map((r) => String(r.name || '')) - .filter((n) => n.includes('inquiry')) - .slice(0, 20), - }, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion next(); return; } @@ -168,32 +143,6 @@ export function createPermissionGuard(router: Router) { permissionStore.setDynamicAddedRoute(true); - // #region agent log - fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'a0b47d' }, - body: JSON.stringify({ - sessionId: 'a0b47d', - runId: 'pre-fix', - hypothesisId: 'H3-H5', - location: 'permissionGuard.ts:afterAddRoute', - message: '刚动态加路由后的匹配情况', - data: { - toPath: to.path, - toName: String(to.name || ''), - toIs404: to.name === PAGE_NOT_FOUND_NAME, - hasDashRoute: router.getRoutes().some((r) => String(r.path).includes('inquiry/dashboard') || String(r.name) === 'inquiry-dashboard'), - inquiryRoutes: router - .getRoutes() - .filter((r) => String(r.path).includes('inquiry') || String(r.name || '').includes('inquiry')) - .map((r) => ({ path: r.path, name: String(r.name || '') })) - .slice(0, 30), - }, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion - if (to.name === PAGE_NOT_FOUND_NAME) { // 动态添加路由后,此处应当重定向到fullPath,否则会加载404页面内容 next({ path: to.fullPath, replace: true, query: to.query }); diff --git a/inquiry-backend/src/store/modules/permission.ts b/inquiry-backend/src/store/modules/permission.ts index 26a8d40..a9bbd58 100644 --- a/inquiry-backend/src/store/modules/permission.ts +++ b/inquiry-backend/src/store/modules/permission.ts @@ -461,79 +461,8 @@ export const usePermissionStore = defineStore({ } // 只纠正/补挂「已有菜单或已有查看权限」的手写页,禁止灌入未授权路由 const permCodes = this.getPermCodeList || []; - // #region agent log - const _dbgCollectPaths = (list: AppRouteRecordRaw[] = [], out: string[] = []): string[] => { - for (const item of list || []) { - out.push(String(item.path || '')); - if (item.children?.length) _dbgCollectPaths(item.children as AppRouteRecordRaw[], out); - } - return out; - }; - const _dbgPathsBefore = _dbgCollectPaths(routeList || []); - const _dbgHasDashPerm = (permCodes as any[]).map(String).includes('inquiry:dashboard:view'); - fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'a0b47d' }, - body: JSON.stringify({ - sessionId: 'a0b47d', - runId: 'pre-fix', - hypothesisId: 'H1-H2', - location: 'permission.ts:buildRoutesAction:beforeEnsure', - message: '菜单拉取后、ensure/prune 前', - data: { - menuCount: (routeList || []).length, - pathCount: _dbgPathsBefore.length, - hasDashboardPath: _dbgPathsBefore.some((p) => p.includes('inquiry/dashboard')), - hasDashPerm: _dbgHasDashPerm, - permCount: (permCodes as any[]).length, - samplePerms: (permCodes as any[]).map(String).filter((c) => c.includes('inquiry')).slice(0, 20), - samplePaths: _dbgPathsBefore.filter((p) => p.includes('inquiry')).slice(0, 20), - }, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion routeList = ensureInquiryHandwrittenRoutes(routeList || [], permCodes); - // #region agent log - const _dbgAfterEnsure = _dbgCollectPaths(routeList || []); - fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'a0b47d' }, - body: JSON.stringify({ - sessionId: 'a0b47d', - runId: 'pre-fix', - hypothesisId: 'H2', - location: 'permission.ts:buildRoutesAction:afterEnsure', - message: 'ensureInquiryHandwrittenRoutes 后', - data: { - hasDashboardPath: _dbgAfterEnsure.some((p) => p.includes('inquiry/dashboard')), - inquiryPaths: _dbgAfterEnsure.filter((p) => p.includes('inquiry')).slice(0, 30), - }, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion routeList = pruneInquiryLeavesWithoutViewPerm(routeList, permCodes); - // #region agent log - const _dbgAfterPrune = _dbgCollectPaths(routeList || []); - fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'a0b47d' }, - body: JSON.stringify({ - sessionId: 'a0b47d', - runId: 'pre-fix', - hypothesisId: 'H1', - location: 'permission.ts:buildRoutesAction:afterPrune', - message: 'pruneInquiryLeavesWithoutViewPerm 后', - data: { - hasDashboardPath: _dbgAfterPrune.some((p) => p.includes('inquiry/dashboard')), - inquiryPaths: _dbgAfterPrune.filter((p) => p.includes('inquiry')).slice(0, 30), - prunedAway: _dbgAfterEnsure.includes('/inquiry/dashboard') && !_dbgAfterPrune.some((p) => p.includes('inquiry/dashboard')), - }, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion // 补挂后再标一次(ensure 新建的父级也要带 Vue3 标记) routeList = normalizeVue3MenuMarkers(routeList); // 组件地址前加斜杠处理 author: lsq date:2021-09-08 @@ -550,33 +479,6 @@ export const usePermissionStore = defineStore({ routeList = routeList.filter(routeRemoveIgnoreFilter); routeList = flatMultiLevelRoutes(routeList); - // #region agent log - const _dbgFinal = _dbgCollectPaths(routeList || []); - const _dbgTopComp = (routeList || []).slice(0, 5).map((r) => ({ - path: r.path, - name: String(r.name || ''), - compType: typeof r.component, - childN: r.children?.length || 0, - childPaths: (r.children || []).slice(0, 8).map((c) => c.path), - })); - fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'a0b47d' }, - body: JSON.stringify({ - sessionId: 'a0b47d', - runId: 'pre-fix', - hypothesisId: 'H3', - location: 'permission.ts:buildRoutesAction:afterFlat', - message: 'transform+flat 后最终路由树', - data: { - hasDashboardPath: _dbgFinal.some((p) => p.includes('inquiry/dashboard')), - inquiryPaths: _dbgFinal.filter((p) => p.includes('inquiry')).slice(0, 40), - top: _dbgTopComp, - }, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion routes = [PAGE_NOT_FOUND_ROUTE, ...routeList]; break; } diff --git a/inquiry-backend/src/views/inquiry/dashboard/index.vue b/inquiry-backend/src/views/inquiry/dashboard/index.vue index 60ca866..a358655 100644 --- a/inquiry-backend/src/views/inquiry/dashboard/index.vue +++ b/inquiry-backend/src/views/inquiry/dashboard/index.vue @@ -3,7 +3,7 @@ * 分析看板:对接主账 /inquiry/dashboard/metrics。 * 指标 / 询价趋势 / 人员排名 / 成本趋势均来自主账聚合;禁止用 Mock 假线冒充生产。 */ -import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue"; +import { computed, onMounted, onUnmounted, ref } from "vue"; import { message } from "ant-design-vue"; import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue"; import { getDashboardMetrics } from "/@/views/inquiry/dashboard/dashboard.api"; @@ -100,28 +100,6 @@ async function loadMetrics() { const res: any = await getDashboardMetrics(req); const data = res?.result || res || {}; todayMetrics.value = (data.metrics || []) as MetricCard[]; - // #region agent log - fetch("http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1", { - method: "POST", - headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "7b2ee5" }, - body: JSON.stringify({ - sessionId: "7b2ee5", - runId: "post-fix", - hypothesisId: "THOUSAND_SEP", - location: "dashboard/index.vue:loadMetrics", - message: "指标千分位展示抽样", - data: { - samples: todayMetrics.value.slice(0, 9).map((m) => ({ - key: m.key, - raw: m.value, - display: formatMetricDisplay(m), - isTime: !!m.isTime, - })), - }, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion inquiryTrend.value = ((data.trend || []) as any[]).map((p) => ({ date: String(p.date || ""), value: Number(p.value || 0), @@ -383,92 +361,6 @@ function onDocClick(e: MouseEvent) { if (!t.closest(".ref-route-filter")) routeFilterOpen.value = false; } -/** - * 调试:打开线路筛选时采集弹层/chips/祖先裁切与可滚尺寸。 - */ -function probeRouteFilterMenu(runId: string) { - // #region agent log - const menu = document.querySelector(".ref-route-filter__menu") as HTMLElement | null; - const chips = document.querySelector(".ref-route-filter__chips") as HTMLElement | null; - const filter = document.querySelector(".ref-route-filter") as HTMLElement | null; - if (!menu || !chips) { - fetch("http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1", { - method: "POST", - headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "7b2ee5" }, - body: JSON.stringify({ - sessionId: "7b2ee5", - runId, - hypothesisId: "H2", - location: "dashboard/index.vue:probeRouteFilterMenu", - message: "弹层或 chips 未找到", - data: { hasMenu: !!menu, hasChips: !!chips }, - timestamp: Date.now(), - }), - }).catch(() => {}); - return; - } - const menuCs = getComputedStyle(menu); - const chipsCs = getComputedStyle(chips); - const menuRect = menu.getBoundingClientRect(); - const chipsRect = chips.getBoundingClientRect(); - const clipAncestors: Array<{ tag: string; cls: string; overflow: string; overflowY: string; rectBottom: number }> = []; - let node: HTMLElement | null = filter; - while (node && node !== document.body) { - const cs = getComputedStyle(node); - if (cs.overflow !== "visible" || cs.overflowY !== "visible" || cs.overflowX !== "visible") { - clipAncestors.push({ - tag: node.tagName.toLowerCase(), - cls: (node.className || "").toString().slice(0, 80), - overflow: cs.overflow, - overflowY: cs.overflowY, - rectBottom: Math.round(node.getBoundingClientRect().bottom), - }); - } - node = node.parentElement; - } - const spaceBelow = Math.round(window.innerHeight - menuRect.top); - fetch("http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1", { - method: "POST", - headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "7b2ee5" }, - body: JSON.stringify({ - sessionId: "7b2ee5", - runId, - hypothesisId: "H1-H5", - location: "dashboard/index.vue:probeRouteFilterMenu", - message: "线路筛选弹层尺寸与溢出", - data: { - routeCount: costRoutes.value.length, - menu: { - h: Math.round(menuRect.height), - bottom: Math.round(menuRect.bottom), - overflow: menuCs.overflow, - overflowY: menuCs.overflowY, - maxHeight: menuCs.maxHeight, - }, - chips: { - h: Math.round(chipsRect.height), - clientH: chips.clientHeight, - scrollH: chips.scrollHeight, - canScroll: chips.scrollHeight > chips.clientHeight + 1, - overflow: chipsCs.overflow, - overflowY: chipsCs.overflowY, - maxHeight: chipsCs.maxHeight, - }, - viewport: { innerH: window.innerHeight, spaceBelow, menuClippedByViewport: menuRect.bottom > window.innerHeight + 1 }, - clipAncestors, - }, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion -} - -watch(routeFilterOpen, async (open) => { - if (!open) return; - await nextTick(); - requestAnimationFrame(() => probeRouteFilterMenu("post-fix")); -}); - const inquiryWrap = ref(null); const costWrap = ref(null); diff --git a/inquiry-backend/src/views/inquiry/shared/inquiryAuthDownload.ts b/inquiry-backend/src/views/inquiry/shared/inquiryAuthDownload.ts index a43b3af..7e7b034 100644 --- a/inquiry-backend/src/views/inquiry/shared/inquiryAuthDownload.ts +++ b/inquiry-backend/src/views/inquiry/shared/inquiryAuthDownload.ts @@ -119,29 +119,6 @@ export async function downloadAuthGet( const cd = res.headers.get('content-disposition') || ''; const name = parseDownloadFilename(cd, fallbackName); - // #region agent log - fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '489366' }, - body: JSON.stringify({ - sessionId: '489366', - runId: 'post-fix', - hypothesisId: 'FN1', - location: 'inquiryAuthDownload.ts:downloadAuthGet', - message: '导出文件名解析', - data: { - path, - cd, - fallbackName, - resolvedName: name, - hasStar: /filename\*=/i.test(cd), - hasCjk: /[\u4e00-\u9fff]/.test(name), - }, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion - const href = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = href; diff --git a/inquiry-backend/src/views/inquiry/staffRole/index.vue b/inquiry-backend/src/views/inquiry/staffRole/index.vue index e57223b..db1bafe 100644 --- a/inquiry-backend/src/views/inquiry/staffRole/index.vue +++ b/inquiry-backend/src/views/inquiry/staffRole/index.vue @@ -9,6 +9,7 @@ import { computed, onMounted, reactive, ref, watch } from "vue"; import { message, Modal } from "ant-design-vue"; import { disableStaff, + enableStaff, getStaffCounts, getStaffDepartments, getStaffList, @@ -465,6 +466,25 @@ function removeStaff(row: StaffRow) { }); } +/** + * 列表「启用」:停用行恢复 active,权限与停用相同。 + */ +function activateStaff(row: StaffRow) { + Modal.confirm({ + title: `启用员工 ${row.name}?`, + content: "确认后状态设为启用,可重新参与询价匹配。", + async onOk() { + try { + await enableStaff(row.id); + message.success("已启用"); + await loadList(); + } catch (e: any) { + message.error(e?.message || "启用失败"); + } + }, + }); +} + async function syncWecom() { if (syncing.value) return; syncing.value = true; @@ -615,6 +635,14 @@ function filterRouteOption(input: string, option: any) { > 停用 + diff --git a/inquiry-backend/src/views/inquiry/staffRole/staffRole.api.ts b/inquiry-backend/src/views/inquiry/staffRole/staffRole.api.ts index d010af8..c946c06 100644 --- a/inquiry-backend/src/views/inquiry/staffRole/staffRole.api.ts +++ b/inquiry-backend/src/views/inquiry/staffRole/staffRole.api.ts @@ -11,6 +11,7 @@ enum Api { add = '/inquiry/staffRole/add', edit = '/inquiry/staffRole/edit', disable = '/inquiry/staffRole/disable', + enable = '/inquiry/staffRole/enable', queryById = '/inquiry/staffRole/queryById', syncWecom = '/inquiry/staffRole/syncWecom', /** 旧项目同名接口:部门短名全量 */ @@ -22,7 +23,17 @@ export const getStaffList = (params) => defHttp.get({ url: Api.list, params }); export const getStaffCounts = () => defHttp.get({ url: Api.counts }); export const saveStaff = (data, isUpdate: boolean) => isUpdate ? defHttp.put({ url: Api.edit, data }) : defHttp.post({ url: Api.add, data }); -export const disableStaff = (id: string) => defHttp.post({ url: Api.disable, params: { id } }); +/** + * 停用员工。主账 @RequestParam id。 + * 非 GET 时 axios 会把 params 挪进 JSON 体,必须 joinParamsToUrl 拼到查询串。 + */ +export const disableStaff = (id: string) => + defHttp.post({ url: Api.disable, params: { id } }, { joinParamsToUrl: true }); +/** + * 重新启用员工。与停用同权限、同参数拼法。 + */ +export const enableStaff = (id: string) => + defHttp.post({ url: Api.enable, params: { id } }, { joinParamsToUrl: true }); export const getStaffById = (id: string) => defHttp.get({ url: Api.queryById, params: { id } }); /** 主账拉企微通讯录 upsert;返回 { fetched, created, updated, skipped, message } */ export const syncWecomStaff = () => defHttp.post({ url: Api.syncWecom }); diff --git a/inquiry-backend/src/views/inquiry/template/index.vue b/inquiry-backend/src/views/inquiry/template/index.vue index 103ec0d..fb93464 100644 --- a/inquiry-backend/src/views/inquiry/template/index.vue +++ b/inquiry-backend/src/views/inquiry/template/index.vue @@ -203,29 +203,6 @@ async function openPreview(row: TemplateRow) { previewState.isImage = contentType.includes("image/") || blob.type.includes("image/"); previewState.objectUrl = URL.createObjectURL(blob); previewState.loading = false; - // #region agent log - const payload = { - sessionId: "b4fa63", - runId: "post-fix", - hypothesisId: "H10", - location: "index.vue:openPreview", - message: "snapshot preview loaded", - data: { - versionId: row.versionId, - name: row.name, - isImage: previewState.isImage, - blobSize: blob.size, - contentType, - mappedCount: previewState.mappedCount, - }, - timestamp: Date.now(), - }; - fetch("http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1", { - method: "POST", - headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "b4fa63" }, - body: JSON.stringify(payload), - }).catch(() => {}); - // #endregion } catch (e: any) { previewState.loading = false; previewState.error = e?.message || "原表截图尚未就绪,请稍后重试或下载原模板"; diff --git a/inquiry-backend/src/views/sys/exception/Exception.vue b/inquiry-backend/src/views/sys/exception/Exception.vue index 023bc27..c3db956 100644 --- a/inquiry-backend/src/views/sys/exception/Exception.vue +++ b/inquiry-backend/src/views/sys/exception/Exception.vue @@ -48,28 +48,6 @@ const statusMapRef = ref(new Map()); const { query } = useRoute(); - const route = useRoute(); - // #region agent log - fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'a0b47d' }, - body: JSON.stringify({ - sessionId: 'a0b47d', - runId: 'pre-fix', - hypothesisId: 'H5', - location: 'Exception.vue:setup', - message: '进入 ErrorPage(前端路由未匹配)', - data: { - fullPath: route.fullPath, - path: route.path, - name: String(route.name || ''), - statusProp: props.status, - queryStatus: query?.status, - }, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion const go = useGo(); const redo = useRedo(); const { t } = useI18n(); diff --git a/prompt/12-测试环境部署.md b/prompt/12-测试环境部署.md index 273c221..674a732 100644 --- a/prompt/12-测试环境部署.md +++ b/prompt/12-测试环境部署.md @@ -1,93 +1,85 @@ -# 提示词:测试环境部署(同机并存) +# 提示词:测试环境部署(独立测服机) -你只部署 **测试环境**。现网继续跑。不要按现网累计发布器去切 `C:\yutongda`。现网密钥对照 `03`,进程形态见 `01`/`11`,环境对照见 `13`。 +你只部署 **测试环境**。正服继续跑。不要按现网累计发布器去切 `C:\yutongda` / `E:\wwwroot`。现网密钥对照 `03`,进程形态见 `01`/`11`,环境对照见 `13`。运维细则见 `deploy/TEST-ENV.md`。 -机器与现网同一台:公网 `129.211.181.80`,主机名 `10_206_0_15`,SSH `ytd_extdev_0903`,指纹见 `03`。私钥优先 `~/.ssh/id_ed25519_ytd`。出口白名单不变。不得输出私钥和 Secret。 - -## 现网禁止触碰 - -- 目录:`C:\yutongda`、`releases\`、`ops\active-release*.json` -- 端口:旧 8080/8809/8810/8811/8813 已停。新主账占用 **8080**,不要再起回 8180。企微回调 URL 不要改。 -- 站点:`https://ai.ytd-scm.com` 及 `/wecom/callback`、`/inquiry-form`(Nginx 反代目标切流前不准改) -- 库:`jeecg-boot`、`ytd_runtime`;Redis `ytd:prod:node01-07`;桶 `ytd-inquiry` -- 企微询价应用 `1000010` 的后台设置(不要重置 Secret、不要改回调 URL) -- 脚本:禁止对现网执行 `Deploy-YtdCumulativeRelease.ps1` -- 禁止改 `C:\yutongda\secure\`、现网 `.env`、现网 `ai.conf` 已有 location - -## 测试环境落点 +## 测服机器(产品决策:域名即开关) | 项 | 值 | |---|---| -| 运行根 | `E:\wwwroot`(`admin\current` / `agent\current`) | +| 公网 | `183.11.229.69` | +| 主机名 | `PC-20260706MVVZ` | +| SSH | `Administrator`,私钥 `~/.ssh/id_ed25519` | +| 运行根 | **`C:\inquiry-robot`** | +| 域名 | **`ai-test.ytd-scm.com`**(HTTPS;证书用 win-acme / Let’s Encrypt) | +| 开关 | 打开 `ai.ytd-scm.com`=正服;打开 `ai-test.ytd-scm.com`=测服。不做后台页面切库 | + +正服 `129.211.181.80` / `E:\wwwroot` / `ai.ytd-scm.com`:**禁止**在测服发版流程里改 Nginx、`.env`、企微回调。 + +## 正服禁止触碰 + +- 目录:`C:\yutongda`、`E:\wwwroot`、`releases\`、`ops\active-release*.json` +- 站点:`https://ai.ytd-scm.com` 及 `/wecom/callback`、`/inquiry-form` +- 库:正服 `inquiry_robot` / 旧 `jeecg-boot`;Redis 正服前缀;桶正服 +- 企微询价应用 `1000010` 后台设置(不要重置 Secret、**不要改回调 URL**) +- 脚本:禁止对正服执行累计切流发布器 + +## 测服落点 + +| 项 | 值 | +|---|---| +| 运行根 | `C:\inquiry-robot`(`admin\current` / `agent\current` / `inquiry-new`) | | 主账 | **8080** | -| 智能体 HTTP | **8910**(H5 + 企微回调 + 主账唤醒,一个进程) | -| AIBOT | **8913** 仅 127.0.0.1 | +| 智能体 HTTP | **8910**(H5 + 回调入口 + 主账唤醒,一个进程) | +| AIBOT | **8913** 仅 127.0.0.1;默认 **关** | | Worker | 无对外端口;LibreOffice 1 槽,HTTP 类 2~3 槽 | -| MySQL | 库/用户 `inquiry_robot`(数据机 `10.206.0.14:3306`,口令新开) | -| PostgreSQL | 库/用户 `inquiry_robot_runtime` | -| Redis | db1,前缀 `inquiry_robot:` | -| MinIO | 桶 `inquiry_robot`(实例仍 9000) | +| MySQL | 本机 `inquiry_robot` | +| PostgreSQL | 本机 `inquiry_robot_runtime` | +| Redis | 本机 db1,前缀 `inquiry_robot:` | +| MinIO | 本机桶 `inquiry-robot` | +| Nginx | `C:\nginx-1.18.0\myconf\ai-test.conf`;证书目录 `cert\` | -不要开 8809/8811/8812。不要 Redis 全局单例锁。Nginx 80/443、SSH 22、3306/5432/6379、9000 不要关。 +不要开 8809/8811/8812。不要 Redis 全局单例锁。 -`MYSQL_*` / `PG_*` / `REDIS_URL`(**db1**)/ `REDIS_KEY_PREFIX=inquiry_robot:` / `OBJECT_STORAGE_BUCKET=inquiry_robot` 完整对照见 `13`。口令新开,勿把现网 `MYSQL_PASSWORD`/`PG_PASSWORD` 写进新系统 `.env`。 +## 域名与企微 -## 域名与企微(与正式相同) - -测试 **不另开域名、不另申请证书、不新建企微应用、不新建智能机器人**。 - -从 `03` 原样抄入测试 `.env`:`WECOM_AGENT_ID=1000010`、Secret、Callback Token、EncodingAESKey、`WECOM_CALLBACK_URL=https://ai.ytd-scm.com/wecom/callback`、`INQUIRY_FORM_PUBLIC_BASE_URL=https://ai.ytd-scm.com/inquiry-form`、AIBOT BotId/Secret、运维 `1000014`。可信域名已是 `ai.ytd-scm.com`,不用再配。 - -**并存期:** 不要改 `ai.conf`,公网回调和 H5 仍进现网。测试用本机 `8910` 联调。`WECOM_AIBOT_ENABLED` 默认关,避免和现网 8813 抢长连接。要用企微真收消息验收新系统 = 切流(改 Nginx 反代 + 停现网 AIBOT 再起 8913),现网会暂停收询价。 +- 测服公网 URL 用 `https://ai-test.ytd-scm.com/...`(`.env` 自洽)。 +- **企微后台回调仍保持** `https://ai.ytd-scm.com/wecom/callback`(本阶段不改;真回调进测服须另批)。 +- 同一企微应用 `1000010` / 运维 `1000014`;`WECOM_AIBOT_ENABLED=false`,避免与正服抢长连接。 +- 前端构建:`VITE_GLOB_API_URL=/jeecgboot`,`VITE_GLOB_DOMAIN_URL=https://ai-test.ytd-scm.com`。 ```text YTD_ENV=test -WECOM_CALLBACK_URL=https://ai.ytd-scm.com/wecom/callback -INQUIRY_FORM_PUBLIC_BASE_URL=https://ai.ytd-scm.com/inquiry-form +YTD_DEPLOY_ROOT=C:\inquiry-robot +PUBLIC_BASE_URL=https://ai-test.ytd-scm.com +WECOM_CALLBACK_URL=https://ai-test.ytd-scm.com/wecom/callback +INQUIRY_FORM_PUBLIC_BASE_URL=https://ai-test.ytd-scm.com/inquiry-form BIZ_API_BASE_URL=http://127.0.0.1:8080/jeecgboot AGENT_CALLBACK_BASE_URL=http://127.0.0.1:8910 WECOM_AIBOT_BRIDGE_URL=http://127.0.0.1:8913 WECOM_AIBOT_INGRESS_URL=http://127.0.0.1:8910/internal/aibot/inbound -# 空运群发文件后马上回话:先停现网 8813,再 WECOM_AIBOT_ENABLED=true,并填 BOT_ID/SECRET。该群须先 @ 过小助手。 +WECOM_AIBOT_ENABLED=false ``` -前端 `VITE_GLOB_API_URL=/jeecgboot`,`VITE_GLOB_DOMAIN_URL=https://ai.ytd-scm.com`。不要加 `ai-test.conf`。 +## 证书 + +- 测服本机申请 Let’s Encrypt(win-acme)。**必须**在云安全组放行公网 80/443。 +- 未放行前可用自签;外网可临时 `https://ai-test.ytd-scm.com:8081`(浏览器不信任属预期)。 ## TMS -现网 BaseURL + Client 三件套(`03`)。QUERY / 详情 / 字典照旧。空运群锁舱/释放由新主账出站打真实 TMS。智能体只打新主账 8080,不直连 TMS。 +现网 BaseURL + Client 三件套(`03`)。QUERY / 详情 / 字典可用。测服默认关真实 LOCK/RELEASE。智能体只打本机主账 8080。 -## 库表(测试库可直接改) +## 库表 -开发需要加表、加字段、改字段时:**直接对 `inquiry_robot` / `inquiry_robot_runtime` 做版本化迁移**,不必先等 Task 批准。 - -仍须批准的:改现网库、改现网 Secret、对非测试人员发企微。测服 LOCK/RELEASE 已纳入空运群定稿,但必须现网锁舱已停。 +开发加表加字段:对测服 `inquiry_robot` / `inquiry_robot_runtime` 做版本化迁移。禁止写正服库。 ## 发布原则 -1. 新版本写入 `E:\wwwroot\` 的 `agent\releases` / `admin\releases`,再切 `current` 联接;禁止覆盖正在跑的 current 目标目录内容(先发 release 再切联接)。 -2. 用测试自己的指针;失败回指上一测试版本。 -3. 先停 **测试** 侧进程再起新的;禁止停现网服务。 -4. 禁止 scp 几个文件当正式发布。 -5. 构建不含 `.env`、密钥、日志、用户附件、dump。 -6. 健康检查 200 ≠ 业务验收。 - -## 部署前必须同时成立 - -- 新主账在 **8080**,智能体在 8910,AIBOT 在 8913 -- 不要把主账起回 8180 -- 未改 `C:\yutongda\ops\` 活动指针 - -误停现网端口或改了现网指针:立即停手,先恢复现网,再报告。 - -## 未经 Task 批准不得 - -- 现网与测服同时 LOCK/RELEASE(测服锁舱须现网已停;QUERY 按任务批准) -- 给测试可见范围以外的人或生产客户群发企微 -- 写现网库 -- 改现网 Secret、防火墙、`1000010` 回调 -- Git 强推 +1. 新版本写入 `C:\inquiry-robot\` 的 `agent\releases` / `admin\releases`,再切 `current` junction。 +2. 先停测服计划任务再起;禁止停正服服务。 +3. 构建不含 `.env`、密钥、日志、用户附件、dump。 +4. 健康检查 200 ≠ 业务验收。 ## 汇报 -Task、分支 commit、测试目录与端口、是否误碰 `C:\yutongda`(必须为否)、现网端口仍监听、TMS/企微次数、回滚点、尚未完成的企微 fresh 验收。 +测服目录与端口、是否误碰正服(必须为否)、证书状态(LE / 自签 / 待放行)、尚未完成的企微 fresh 验收。 diff --git a/prompt/13-环境对照.md b/prompt/13-环境对照.md index e230ef8..e151a57 100644 --- a/prompt/13-环境对照.md +++ b/prompt/13-环境对照.md @@ -1,123 +1,80 @@ -# 现网 vs 新系统 inquiry_robot(切换只改配置,不改代码) +# 正服 vs 测服 inquiry_robot(切换只改配置,不改代码) 同一套制品。用 `YTD_ENV=prod` 或 `YTD_ENV=test` 区分阶段(变量名仍叫 YTD_*,与 `03` 对齐)。 -**域名与企微与现网相同**(`ai.ytd-scm.com`、应用 `1000010`)。 +**产品决策(域名即开关)**:`ai.ytd-scm.com`=正服;`ai-test.ytd-scm.com`=测服。不做后台页面切库。 **新系统资源名一律 `inquiry_robot*`**,禁止再用 `ytd_test` / `ytd:test:` / `ytd-test`。 -禁止在代码里写死域名/库名。 - -切流目标(方案 B):`E:\wwwroot` 取代 `C:\yutongda`。清单见 `deploy/CUTOVER-TO-PROD.md`。 - -对照表是切换清单。现网发布仍走甲方原流程与 `C:\yutongda` 指针;新系统只写 `E:\wwwroot`。 +禁止在代码里写死域名/库名。运维见 `deploy/TEST-ENV.md`、`deploy/CUTOVER-TO-PROD.md`。 ## 对照 -| 项 | 现网(仍在跑) | 新系统 `inquiry_robot` | +| 项 | 正服 | 测服(独立机) | |---|---|---| -| `YTD_ENV` | `prod`(现网进程) | 切流前可用 `test`;切流日改 `prod` | -| 公网域名 | `ai.ytd-scm.com` | **同一域名** | -| DNS / 证书 / Nginx | 已有 `ai.conf` | **不要另开域名/证书**;反代按切流改 | -| 站点 / H5 / 企微回调 | 同左 | 配置字符串相同;企微回调 URL **不用改** | -| 后台 API 前缀 | `/jeecgboot` → 旧 Jeecg 8080 | `/jeecgboot` → **新主账 8080**(旧进程已停) | -| 运行根 | `C:\yutongda` | **`E:\wwwroot`**(`admin\current` / `agent\current`) | -| 主账端口 | 8080(旧 Jeecg,已停) | **8080**(新 Java 主账) | -| 智能体 HTTP | 8810(另 8809/8811) | **8910**(合并) | -| AIBOT | 8813 | **8913**(切流前勿双连) | -| MySQL | `jeecg-boot` / `ytd_jeecg` | **`inquiry_robot` / `inquiry_robot`** | -| PostgreSQL | `ytd_runtime` | **`inquiry_robot_runtime`** | -| Redis | db0 `ytd:prod:node01-07` | db1 **`inquiry_robot:`** | -| MinIO 桶 | `ytd-inquiry` | **`inquiry-robot`**(S3 桶名不能用下划线) | -| 询价企微应用 | `1000010` | **同一套** | -| 运维企微 | `1000014` | **同一套**;切流前告警可加 `[test]` | -| AIBOT Bot | 现网 Bot | **同一套**;切流先停 8813 再开 8913 | -| TMS | `03` 现网 Client | 同一套;切流前默认关 lock/release | -| 发布脚本 | 现网累计发布器 | 只写 `E:\wwwroot` | -| 告警前缀 | 无 | 切流前 `[test]`;切流后去掉 | +| `YTD_ENV` | `prod` | `test` | +| 公网 IP | `129.211.181.80` | `183.11.229.69` | +| 公网域名 | `ai.ytd-scm.com` | **`ai-test.ytd-scm.com`** | +| DNS / 证书 / Nginx | `ai.conf` + 正服证书 | **`ai-test.conf` + 测服证书(win-acme)** | +| 站点 / H5 | 正服域名 | 测服域名 | +| 企微回调 URL(企微后台) | `https://ai.ytd-scm.com/wecom/callback` | **暂不改后台**;测服 `.env` 可自洽写 ai-test | +| 后台 API 前缀 | `/jeecgboot` → 主账 8080 | 同左(本机 8080) | +| 运行根 | **`E:\wwwroot`** | **`C:\inquiry-robot`** | +| 主账端口 | 8080 | 8080 | +| 智能体 HTTP | 8910 | 8910 | +| AIBOT | 8913(正服可启) | 8913;**默认关** | +| MySQL | 正服 `inquiry_robot` | **本机** `inquiry_robot` | +| PostgreSQL | 正服 `inquiry_robot_runtime` | **本机** `inquiry_robot_runtime` | +| Redis | 正服 db1 `inquiry_robot:` | **本机** db1 `inquiry_robot:` | +| MinIO 桶 | `inquiry-robot` | **本机** `inquiry-robot` | +| 询价企微应用 | `1000010` | 同一套密钥,不抢长连接 | +| 运维企微 | `1000014` | 同一套;告警加 `[test]` | +| TMS | `03` | 同一套;默认关 lock/release | +| 发布 | 只写 `E:\wwwroot` | 只写 `C:\inquiry-robot` | +| 告警前缀 | 无 | `[test]` | -数据机 IP、3306/5432/6379、MinIO `127.0.0.1:9000` **两边相同**。只换库名、用户、Redis db/前缀、桶。 +**切换环境 = 换域名 / 换对应机器上的 `.env` 取值,不改代码。** +测服与正服库主机不同(测服本机),禁止测服进程连正服库。 ## 数据库与存储(同一套变量名) -代码、YAML 只读变量名,**禁止**写死库名。现网明文见 `03`;新系统口令新开,写入 `E:\wwwroot\.env`。 +代码、YAML 只读变量名,**禁止**写死库名。正服明文见 `03`;测服口令写在测服本机 `C:\inquiry-robot\.env`(勿进 Git)。 -| 变量 | 现网 | 新系统 | +| 变量 | 正服 | 测服 | |---|---|---| -| `MYSQL_HOST` | `10.206.0.14` | 同左 | -| `MYSQL_PORT` | `3306` | 同左 | -| `MYSQL_DB` | `jeecg-boot` | **`inquiry_robot`** | -| `MYSQL_USER` | `ytd_jeecg` | **`inquiry_robot`** | -| `MYSQL_PASSWORD` | 见 `03` | **新开,勿抄现网口令** | -| `YTD_MYSQL_URL` / JDBC | `.../jeecg-boot?...` | `.../inquiry_robot?...` | -| `PG_HOST` | `10.206.0.14` | 同左 | -| `PG_PORT` | `5432` | 同左 | -| `PG_DATABASE` | `ytd_runtime` | **`inquiry_robot_runtime`** | -| `PG_USER` | `ytd_runtime` | **`inquiry_robot_runtime`** | -| `PG_PASSWORD` | 见 `03` | **新开** | -| `PG_SSLMODE` | `prefer` | `prefer` | -| `CHECKPOINT_BACKEND` | `postgres` | `postgres` | -| `YTD_REDIS_HOST` | `10.206.0.14` | 同左 | -| `YTD_REDIS_PORT` | `6379` | 同左 | -| `YTD_REDIS_USERNAME` | `ytd_app` | 可同账号或新账号 | -| `REDIS_URL` | `...6379/0` | **`...6379/1`** | -| `REDIS_KEY_PREFIX` | `ytd:prod:node01-07` | **`inquiry_robot:`** | -| `OBJECT_STORAGE_BUCKET` | `ytd-inquiry` | **`inquiry-robot`** | +| `MYSQL_HOST` | 正服数据机 | **`127.0.0.1`** | +| `MYSQL_PORT` | `3306` | `3306` | +| `MYSQL_DB` / `MYSQL_USER` | `inquiry_robot` | `inquiry_robot` | +| `PG_HOST` | 正服数据机 | **`127.0.0.1`** | +| `PG_DATABASE` / `PG_USER` | `inquiry_robot_runtime` | 同左 | +| `YTD_REDIS_HOST` | 正服数据机 | **`127.0.0.1`** | +| `REDIS_URL` | `.../1` | **本机 `.../1`** | +| `REDIS_KEY_PREFIX` | `inquiry_robot:` | `inquiry_robot:` | +| `OBJECT_STORAGE_ENDPOINT_URL` | 正服 MinIO | **`http://127.0.0.1:9000`** | +| `OBJECT_STORAGE_BUCKET` | `inquiry-robot` | `inquiry-robot` | -新系统 `.env` 片段(口令留空): +测服 `.env` 片段(口令留空): ```text YTD_ENV=test -YTD_DEPLOY_ROOT=E:\wwwroot +YTD_DEPLOY_ROOT=C:\inquiry-robot +PUBLIC_BASE_URL=https://ai-test.ytd-scm.com +WECOM_CALLBACK_URL=https://ai-test.ytd-scm.com/wecom/callback +INQUIRY_FORM_PUBLIC_BASE_URL=https://ai-test.ytd-scm.com/inquiry-form +BIZ_API_BASE_URL=http://127.0.0.1:8080/jeecgboot +MYSQL_HOST=127.0.0.1 MYSQL_DB=inquiry_robot -MYSQL_USER=inquiry_robot -YTD_MYSQL_URL=jdbc:mysql://10.206.0.14:3306/inquiry_robot?characterEncoding=UTF-8&useUnicode=true&useSSL=true&requireSSL=true&verifyServerCertificate=false&tinyInt1isBit=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai +PG_HOST=127.0.0.1 PG_DATABASE=inquiry_robot_runtime -PG_USER=inquiry_robot_runtime -REDIS_URL=redis://ytd_app:<密码>@10.206.0.14:6379/1 +REDIS_URL=redis://:<密码>@127.0.0.1:6379/1 REDIS_KEY_PREFIX=inquiry_robot: OBJECT_STORAGE_BUCKET=inquiry-robot -``` - -**切换环境 = 换 `.env` 取值,不改代码、不在迁移里写死库名。** - -切流前新进程不得连 `jeecg-boot` / `ytd_runtime` / Redis `/0` 且前缀 `ytd:prod:` / 桶 `ytd-inquiry`。 - -## 代码约定 - -所有环境差异只进 `.env` / 前端构建参数 / Nginx,例如: - -```text -YTD_ENV=test -PUBLIC_BASE_URL=https://ai.ytd-scm.com -WECOM_CALLBACK_URL=https://ai.ytd-scm.com/wecom/callback -INQUIRY_FORM_PUBLIC_BASE_URL=https://ai.ytd-scm.com/inquiry-form -BIZ_API_BASE_URL=http://127.0.0.1:8080 -AGENT_CALLBACK_BASE_URL=http://127.0.0.1:8910 -WECOM_AIBOT_BRIDGE_URL=http://127.0.0.1:8913 -WECOM_AIBOT_INGRESS_URL=http://127.0.0.1:8910/internal/aibot/inbound WECOM_AIBOT_ENABLED=false -WECOM_AIBOT_BOT_ID= -WECOM_AIBOT_SECRET= -WECOM_AIBOT_WS_URL=wss://openws.work.weixin.qq.com -REDIS_KEY_PREFIX=inquiry_robot: -PG_DATABASE=inquiry_robot_runtime -MYSQL_DB=inquiry_robot -OBJECT_STORAGE_BUCKET=inquiry_robot ``` -两套 `.env` 分开放:现网仍在 `C:\yutongda\...`,新系统在 `E:\wwwroot\.env`。不要互相覆盖。 - -## 以后切到正式(新系统接管) - -见 `deploy/CUTOVER-TO-PROD.md`。顺序摘要: - -1. 新进程在隔离库 `inquiry_robot*` 验收通过。 -2. `.env`:`YTD_ENV=prod`;库继续 `inquiry_robot*`(或甲方最终名)。 -3. Nginx 反代切到新主账/智能体;企微回调 URL 不用改。 -4. 停现网 8813,再起 8913。 -5. 去掉告警 `[test]`。 +**切换环境 = 换域名 / 换对应机器 `.env`,不改代码。** ## 禁止 -- 新进程连 `jeecg-boot` / `ytd:prod:` / 桶 `ytd-inquiry` +- 测服连正服库 / 正服 Redis / 正服桶 - 新系统再使用 `ytd_test` / `ytd:test:` / `ytd-test` 命名 -- 并存期与现网 **同时** 连接同一 AIBOT 长连接 +- 与正服 **同时** 连接同一 AIBOT 长连接 - 一份 `.env` 混用两套库 +- 测服发版改正服 Nginx / 企微回调 URL