diff --git a/inquiry-agent/agent/handlers/air_group_collab.py b/inquiry-agent/agent/handlers/air_group_collab.py index dcb0e86..77d479c 100644 --- a/inquiry-agent/agent/handlers/air_group_collab.py +++ b/inquiry-agent/agent/handlers/air_group_collab.py @@ -447,6 +447,7 @@ def _handle_cabin( chat_id=chat_id, work_order_no=work_order_no, allow_terminal=intent == INTENT_RELEASE, + for_lock=intent == INTENT_LOCK, ) if ticket is None: return phase diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py index 6519327..6529bc3 100644 --- a/inquiry-agent/agent/ledger/http_ledger.py +++ b/inquiry-agent/agent/ledger/http_ledger.py @@ -440,12 +440,17 @@ class HttpLedger: idem=f"collab-facts:{work_order_no}:{sorted(facts)}", ) - def activate_air_group(self, *, chat_id: str, work_order_no: str) -> dict[str, Any]: - """空运群激活/切换当前工单。一单一群。""" + def activate_air_group( + self, *, chat_id: str, work_order_no: str, allow_deal: bool = False + ) -> dict[str, Any]: + """空运群激活/切换当前工单。一单一群。allow_deal 只用于已成交后锁舱补参数。""" + body: dict[str, Any] = {"chatId": chat_id, "workOrderNo": work_order_no} + if allow_deal: + body["allowDeal"] = True return self._post( "/inquiry/agent/ticket/activateAirGroup", - {"chatId": chat_id, "workOrderNo": work_order_no}, - idem=f"air-activate:{chat_id}:{work_order_no}", + body, + idem=f"air-activate:{chat_id}:{work_order_no}:{int(bool(allow_deal))}", ) def lock_cabin( @@ -479,9 +484,9 @@ class HttpLedger: _line = _json.dumps( { - "sessionId": "e38e4c", + "sessionId": "3ba460", "runId": "pre-fix", - "hypothesisId": "H-E", + "hypothesisId": "C", "location": "http_ledger.py:lock_cabin", "message": "ledger_lock_called", "data": { @@ -496,8 +501,9 @@ class HttpLedger: ensure_ascii=False, ) + "\n" for _p in ( - _Path(r"d:\own\友通达询价智能体(新)\debug-e38e4c.log"), - _Path(r"C:/Users/ytd_extdev_0903/incoming/debug-e38e4c.log"), + _Path(r"d:\own\友通达询价智能体(新)\debug-3ba460.log"), + _Path(r"C:/Users/ytd_extdev_0903/incoming/debug-3ba460.log"), + _Path(r"E:/wwwroot/agent/current/debug-3ba460.log"), ): try: _p.parent.mkdir(parents=True, exist_ok=True) diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py index c1411b9..9ab97b7 100644 --- a/inquiry-agent/agent/ledger/memory_ledger.py +++ b/inquiry-agent/agent/ledger/memory_ledger.py @@ -805,11 +805,13 @@ class MemoryLedger: return ticket return None - def activate_air_group(self, *, chat_id: str, work_order_no: str) -> dict[str, Any]: + def activate_air_group( + self, *, chat_id: str, work_order_no: str, allow_deal: bool = False + ) -> dict[str, Any]: """ 空运群当前工单:一单一群,未结案才能激活。 - 不改六态。 + allow_deal 只给已成交后的锁舱补参数:把本群记到这张单上,不改六态。 """ no = (work_order_no or "").strip() room = (chat_id or "").strip() @@ -819,7 +821,9 @@ class MemoryLedger: return {"ok": False, "error": "ticket_not_found"} if (ticket.business_line or "").upper() != "AIR": return {"ok": False, "error": "not_air", "status": ticket.status} - if ticket.status in {"已成交", "未成交", "已关闭"}: + if ticket.status in {"已成交", "未成交", "已关闭"} and not ( + allow_deal and ticket.status == "已成交" + ): return {"ok": False, "error": "terminal", "status": ticket.status} if ticket.collab_chat_id and ticket.collab_chat_id != room: return {"ok": False, "error": "occupied", "other_chat_id": ticket.collab_chat_id} @@ -844,17 +848,43 @@ class MemoryLedger: airline: str = "", flight_no: str = "", ) -> dict[str, Any]: - """单测可注入 _cabin_lock_result;默认成功。 + """单测可注入 _cabin_lock_result;默认成功。只有已成交才锁。 纯人工且视图无 TMS 舱位时,缺 option/航司/航班 → lock_incomplete, 对齐主账「现拉仍缺才回」的口径(内存账不会真查 TMS)。 """ _ = sender_id, operator_name, instruction_text, airline, flight_no + no = (work_order_no or "").strip() + ticket = self.get_ticket(work_order_no=no) + # 与主账一致:只有已成交才锁。单测注入的失败结果也不能绕过这道门。 + if ticket is not None and (ticket.status or "") != "已成交": + st = ticket.status or "" + if st == "协商中": + return { + "ok": False, + "error": "lock_negotiating", + "failReason": "工单还在协商中,成交后才能锁舱", + } + if st == "未成交": + return { + "ok": False, + "error": "lock_lost", + "failReason": "工单已是「未成交」,不能锁舱", + } + if st in {"已关闭", "转人工"}: + return { + "ok": False, + "error": "ticket_closed", + "failReason": f"工单已是「{st}」,不能锁舱", + } + return { + "ok": False, + "error": "lock_need_deal", + "failReason": "请销售先确认工单「已成交」", + } forced = getattr(self, "_cabin_lock_result", None) if isinstance(forced, dict): return dict(forced) - no = (work_order_no or "").strip() - ticket = self.get_ticket(work_order_no=no) quote = dict(ticket.quote or {}) if ticket else {} if not _memory_quote_can_lock(quote): return { diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py index 39fbbc5..3a1c54e 100644 --- a/inquiry-agent/agent/llm/extract_text.py +++ b/inquiry-agent/agent/llm/extract_text.py @@ -903,10 +903,12 @@ _SKIP_FEE_NAMES = frozenset( ) # 航线常写「空运费 单价CNY99,数量100,金额CNY199」。单价和金额不是同一个数,必须认金额。 # WO202610130003:协同句常见「空运费, KG, 单价CNY99.00, 数量100, 金额CNY9900」——费名与单价之间夹单位; +# WO202610140008:航线写「增值费_板,单价CNY10…」——下划线单位;漏认会落到金额误抽,出单默认单位「票」。 # 数量/金额前也可能只有空格无逗号。漏匹配会落到 _RE_FEE_LINE,把单价 99 当成行金额、数量被补成 1。 +_FEE_UOM = r"KG|KGS|公斤|千克|TON|CBM|柜|票|板数|板|点|[A-Za-z]{1,8}" _RE_FEE_PRICED = re.compile( r"(?P[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)" - r"(?:\s*[,,、]\s*(?:KG|KGS|公斤|千克|TON|CBM|柜|票|板|点|[A-Za-z]{1,8}))?" + rf"(?:(?:\s*[,,、/]\s*|\s+|_)(?P{_FEE_UOM}))?" r"\s*[,,、]?\s*单价\s*" rf"(?:(?P{_CCY_TOKEN})\s*)?" r"(?P\d+(?:\.\d+)?)" @@ -916,6 +918,11 @@ _RE_FEE_PRICED = re.compile( r"(?P\d+(?:\.\d+)?))?", re.I, ) +# 句首分段:起运港、增值费_板… → project=起运港(出单落到对应项目区) +_RE_FEE_PROJECT_PREFIX = re.compile( + r"^\s*(?P空运段|空运|起运港|起运地|目的地|目的港|其它|其他)\s*[,,、::]\s*", + re.I, +) _FULLWIDTH_NUM = str.maketrans("0123456789.", "0123456789.") @@ -950,14 +957,56 @@ def _currency_of(text: str, hint: str = "") -> str: return str(hint or "").strip().upper() +def _normalize_fee_project(raw: str) -> str: + """空运报价单项目列:空运段/起运港/目的地/其它。""" + t = (raw or "").strip() + if t in {"空运", "空运段"}: + return "空运段" + if t in {"起运港", "起运地"}: + return "起运港" + if t in {"目的地", "目的港"}: + return "目的地" + if t in {"其它", "其他"}: + return "其它" + return "" + + +def _strip_group_bot_mentions(text: str) -> str: + """ + 去掉群里 @询价小助手 前缀,避免挡住句首「起运港、」分段标签。 + + 企微协同句常以 @ 开头;不剥的话 project 抽不到,出单只能靠附加费默认「起运港」, + 且二次盖价时更容易丢掉分段。 + 副作用:无。 + """ + cleaned = (text or "").replace("@询价小助手", "").replace("询价小助手", "") + return cleaned.strip() + + +def _split_fee_project_prefix(text: str) -> tuple[str, str]: + """ + 拆句首分段标签。例:起运港、增值费_板,单价… → (起运港, 增值费_板,单价…) + 副作用:无。 + """ + raw = _strip_group_bot_mentions(text or "") + hit = _RE_FEE_PROJECT_PREFIX.match(raw) + if not hit: + return "", raw + project = _normalize_fee_project(hit.group("project") or "") + rest = raw[hit.end() :] + return project, rest + + def _parse_fee_rows(text: str, baseline_currency: str = "") -> list[dict[str, str]]: """ 按行抽费用:码头操作费100CNY、报关费改为300CNY。 只认带「费」的中文名或短英文费用码,避免把工单号当金额。 句里无币种时继承 baseline_currency(通常是当前 TMS/主账币种),禁止默认 USD。 + 认「费名, KG, 单价…」与「费名_板,单价…」里的计量单位,写入 unit,禁止出单再默认「票」。 """ - raw = _ascii_amount(text or "") + project, body = _split_fee_project_prefix(text or "") + raw = _ascii_amount(body) base_ccy = str(baseline_currency or "").strip().upper() rows: list[dict[str, str]] = [] seen: set[str] = set() @@ -977,6 +1026,8 @@ def _parse_fee_rows(text: str, baseline_currency: str = "") -> list[dict[str, st "amount": amount, "currency": line_ccy, } + if project: + row["project"] = project for key, val in extra.items(): if val: row[key] = val @@ -986,10 +1037,12 @@ def _parse_fee_rows(text: str, baseline_currency: str = "") -> list[dict[str, st # 有「金额」用金额;只有单价时才用单价。禁止把单价误当成这一行的总金额。 amount = (hit.group("amount") or "").strip() or (hit.group("unit") or "").strip() ccy = hit.group("amt_ccy") or hit.group("unit_ccy") or "" + uom = (hit.groupdict().get("uom") or "").strip() _add( (hit.group("name") or "").strip(), _ascii_amount(amount), ccy, + unit=uom, unit_price=_ascii_amount(hit.group("unit") or ""), quantity=_ascii_amount(hit.group("qty") or ""), ) @@ -1054,15 +1107,16 @@ def extract_product_quote( raw = (text or "").strip() if not raw: return {} - stripped = raw.replace("@询价小助手", "").replace("询价小助手", "") + stripped = _strip_group_bot_mentions(raw) compact = stripped.replace(" ", "").replace(" ", "") if re.fullmatch(r"WO\d{12}", compact, re.I): return {} - band = _parse_weight_band_quote(raw, baseline_currency=base_ccy) + band = _parse_weight_band_quote(stripped or raw, baseline_currency=base_ccy) if band: return band # 费率句若仍落到普通费用行,会把「燃油附加费」配上句首公斤数。先剥掉再抽。 - scrubbed = _strip_rate_and_band_noise(raw) + # 必须用剥掉 @询价小助手 后的正文,否则句首「起运港、」分段标签匹配不到。 + scrubbed = _strip_rate_and_band_noise(stripped or raw) rows = _parse_fee_rows(scrubbed, baseline_currency=base_ccy) if scrubbed.strip() else [] if rows: ccy = rows[0].get("currency") or _currency_of(raw, base_ccy) or base_ccy diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index 422378c..7d6256b 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -1,5 +1,5 @@ """ -空运协同群业务:激活当前工单、补含电含磁、航线报价/附件、锁舱/释放、成交跟进。 +空运协同群业务:激活当前工单、补含电含磁、航线报价/附件、成交后再锁舱、成交后仍可取消舱位。 本文件职责:群里副作用(主账 + BOT 回话)。海运应用群仍走 sea_group_ops。 禁止:自动建群/拉人/解散;拿当前工单去猜锁舱工单号。 @@ -39,6 +39,7 @@ from agent.policy.sea_group_ops import ( logger = logging.getLogger(__name__) + TERMINAL = {"已成交", "未成交", "已关闭", "转人工"} OPEN_ACTIVATE = {"询价中", "已报价", "协商中"} AIRLINE_ROLE = "air" @@ -535,12 +536,13 @@ def resolve_cabin_ticket( chat_id: str, work_order_no: str, allow_terminal: bool, + for_lock: bool = False, ) -> tuple[Optional[object], str]: """ 锁舱/释放按句中工单号取单,不猜当前单。 未结案:激活并切到本群。已在别的群:occupied,不锁不切。 - 已成交/未成交:不能再激活,但释放仍要读到这张单(成交拦住、未成交放舱)。 + 已成交可以锁、也可以取消已锁舱位,不再激活。未成交不能锁,但已有锁仍可取消。 """ book = flow.ledger no = (work_order_no or "").strip() @@ -561,13 +563,18 @@ def resolve_cabin_ticket( send_group_text(client, chat_id, copy.air_ticket_occupied(no)) return None, "occupied" if is_terminal(ticket): + st = ticket_status(ticket) + # 锁舱只在已成交之后办。未成交要能读到单,才能回「不能锁」,而不是「不能再激活」。 + if for_lock and st in {"已成交", "未成交"}: + # 已成交不再走普通激活,但要把本群记下,下一句补舱位不用再带工单号。 + if st == "已成交": + _bind_deal_cabin_room(flow, chat_id=chat_id, work_order_no=no) + return ticket, "deal_loaded" if st == "已成交" else "lost_loaded" if allow_terminal: return ticket, "terminal_loaded" - if ticket_status(ticket) == "转人工": + if st == "转人工": return None, "handoff_ignore" - send_group_text( - client, chat_id, copy.air_ticket_closed(no, ticket_status(ticket)) - ) + send_group_text(client, chat_id, copy.air_ticket_closed(no, st)) return None, "ticket_closed" return activate_ticket(flow=flow, chat_id=chat_id, work_order_no=no, speak=False) @@ -869,9 +876,91 @@ def apply_air_deal( followup_hours=result.get("negotiate_hours"), ), ) + # 共用回执仍是「已记录为已成交」。催锁只加在空运群,海运陆运不走这里。 + if outcome == "已成交" and phase == "done": + prompt_airline_to_lock(flow=flow, ticket=ticket, chat_id=chat_id, work_order_no=no) return phase +def _bind_deal_cabin_room(flow: Any, *, chat_id: str, work_order_no: str) -> None: + """已成交锁舱时把工单留在本群。普通激活仍拒绝已成交。失败只记日志,不挡这一句锁舱。""" + book = getattr(flow, "ledger", None) + activator = getattr(book, "activate_air_group", None) if book is not None else None + if not callable(activator): + return + try: + activator(chat_id=chat_id, work_order_no=work_order_no, allow_deal=True) + except TypeError: + logger.warning("主账激活不接受已成交绑群 wo=%s", work_order_no) + except Exception: + logger.exception("已成交锁舱绑群失败 wo=%s", work_order_no) + + +def prompt_airline_to_lock( + *, + flow: Any, + ticket: object, + chat_id: str, + work_order_no: str = "", +) -> None: + """ + 空运单记成已成交后,在协同群点名航线锁舱。 + + 只 @ 岗位为航线·空运的人,不改成交回执。没有群、或多段/海运/陆运,不发。 + 群消息在调用方线程发出(群回调或私聊成交后的同一轮)。 + """ + view = ticket_view(ticket) + no = (work_order_no or ticket_no(ticket)).strip() + room = (chat_id or str(view.get("collab_chat_id") or view.get("collabChatId") or "")).strip() + line = str(view.get("business_line") or view.get("businessLine") or "AIR").strip().upper() + if not no or not room or line != "AIR": + return + _sales_name, airline_names, mention_ids = resolve_air_mentions(flow, ticket) + sales_id = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip() + air_ids = [uid for uid in mention_ids if uid and uid != sales_id] + send_group_text( + group_client_of(flow), + room, + copy.air_lock_ask_airline(work_order_no=no, airline_names=airline_names), + mention_userids=air_ids, + ) + + +def _block_lock_until_deal(flow: Any, ticket: object, chat_id: str) -> str: + """ + 未成交的空运单不许锁。已成交返回空串,调用方继续锁。 + + 询价中/已报价:点名本单销售。协商中、未成交、已关闭各说各的。 + 转人工沿用静默。不打 TMS。 + """ + st = ticket_status(ticket) + if st == "已成交": + return "" + no = ticket_no(ticket) + client = group_client_of(flow) + if st == "未成交": + send_group_text(client, chat_id, copy.air_lock_blocked_lost(no)) + return "lock_lost" + if st == "协商中": + send_group_text(client, chat_id, copy.air_lock_blocked_negotiating(no)) + return "lock_negotiating" + if st == "转人工": + return "handoff_ignore" + if st == "已关闭": + send_group_text(client, chat_id, copy.air_ticket_closed(no, st)) + return "ticket_closed" + sales_name, _airline_names, _mention_ids = resolve_air_mentions(flow, ticket) + view = ticket_view(ticket) + sales_id = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip() + send_group_text( + client, + chat_id, + copy.air_lock_need_deal(sales_name=sales_name), + mention_userids=[sales_id] if sales_id else None, + ) + return "lock_need_deal" + + def apply_air_lock( *, flow: Any, @@ -884,15 +973,19 @@ def apply_air_lock( flight_no: str = "", ) -> str: """ - 航线锁舱。 + 航线锁舱。只有工单已是「已成交」才打主账。 + 还没回复成交:点名销售先确认。协商中、未成交各用一句,不催销售改口。 舱位编号、航司、航班号没齐时只请补全,不调 TMS 锁舱,不标系统异常。 三项齐了(话里写的优先,报价里已有的可以补上)才打主账锁舱。 - 上次锁舱失败标过暂停的,航线再锁仍要打 TMS。 + 上次锁舱失败标过暂停的,航线再锁仍要打 TMS。锁成功不再推成交跟进。 """ if not is_airline(flow, sender_id): send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin()) return "not_airline" + blocked = _block_lock_until_deal(flow, ticket, chat_id) + if blocked: + return blocked no = ticket_no(ticket) name = operator_name(flow, sender_id) view_quote = dict(ticket_view(ticket).get("quote") or {}) @@ -950,12 +1043,15 @@ def apply_air_lock( is_ok=lambda x: bool((x or {}).get("ok")), ) if not out.get("ok"): - if str(out.get("error") or "") == "no_quote": + err_code = str(out.get("error") or "") + if err_code == "no_quote": send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no)) return "no_quote" + # 主账再拦一遍成交状态:只回业务句,不当 TMS 故障。 + if err_code in {"lock_need_deal", "lock_negotiating", "lock_lost", "ticket_closed"}: + return _block_lock_until_deal(flow, ticket, chat_id) or err_code # 主账在出站前发现三项不齐:只请补,不当 TMS 故障,不通知 IT。 reason_text = str(out.get("failReason") or "") - err_code = str(out.get("error") or "") if err_code == "lock_params_incomplete" or "锁舱参数不完整" in reason_text: labels = missing or _labels_from_incomplete_reason(reason_text) _remember_partial_lock( @@ -1065,9 +1161,6 @@ def apply_air_lock( if ticket_is_paused(ticket): clear_system_exception(ledger=flow.ledger, work_order_no=no) - view = ticket_view(ticket) - view["collab_chat_id"] = chat_id - emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {})) return "lock_ok" def _release_should_push_deal(ticket: object) -> bool: @@ -1091,11 +1184,8 @@ def apply_air_release( if not is_airline(flow, sender_id): send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin()) return "not_airline" - # 释放同理:暂停单也允许再打 TMS,回话用释放失败原因而不是接口异常兜底。 + # 释放同理:暂停单、已成交单也允许再打 TMS。还没锁过的仍拒绝。 no = ticket_no(ticket) - if ticket_status(ticket) == "已成交": - send_group_text(group_client_of(flow), chat_id, copy.air_release_deal_blocked(no)) - return "release_deal_blocked" facts = dict(ticket_view(ticket).get("collab_facts") or {}) lock_id = str(facts.get(_LOCK_ID) or "").strip() name = operator_name(flow, sender_id) diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index e2cd19e..9a75deb 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -2053,6 +2053,16 @@ class AirTextInquiryFlow: followup_hours=result.get("negotiate_hours"), ) ) + # 私聊点已成交:催锁发到空运协同群。海运陆运、还没建群的不发。 + if status == "已成交" and (sess.business_line or "").strip().upper() == "AIR": + from agent.policy.air_group_ops import prompt_airline_to_lock + + prompt_airline_to_lock( + flow=self, + ticket=sess, + chat_id=sess.collab_chat_id, + work_order_no=sess.work_order_no, + ) return phase def _capture_lost_reason(self, sess: FlowSession, text: str, reply: ReplyFn) -> str: diff --git a/inquiry-agent/agent/policy/deal_outcome.py b/inquiry-agent/agent/policy/deal_outcome.py index c18ddfa..8f07901 100644 --- a/inquiry-agent/agent/policy/deal_outcome.py +++ b/inquiry-agent/agent/policy/deal_outcome.py @@ -177,45 +177,50 @@ def finish_deal_outcome( staff = list(lister(role_code=_role_of(business_line)) or []) except Exception: logger.exception("成交匹配负责人失败 wo=%s", no) - leaders = pick_leaders(staff, business_line=business_line, facts=facts) - ready = notify_app_ready() if notify_ready is None else bool(notify_ready) - if not leaders: - summary = "无匹配负责人" - elif not ready: + # 通知卡拼字段失败不能挡住六态「已成交」和群回执(WO001:selected_air_option 非字典)。 + try: + leaders = pick_leaders(staff, business_line=business_line, facts=facts) + ready = notify_app_ready() if notify_ready is None else bool(notify_ready) + if not leaders: + summary = "无匹配负责人" + elif not ready: + summary = "通知未发出" + else: + names = [str(x.get("name") or x.get("wecomId") or "").strip() for x in leaders] + summary = "已推:" + "、".join(n for n in names if n) + sender = enqueue or _enqueue_default + payload = copy.deal_notify_wecom_payload( + work_order_no=no, + sales_name=sales_name, + facts=facts or {}, + quote=quote or {}, + business_line=business_line, + ) + text = copy.deal_notify_card( + work_order_no=no, + sales_name=sales_name, + facts=facts or {}, + quote=quote or {}, + business_line=business_line, + ) + for row in leaders: + uid = str(row.get("wecomId") or "").strip() + if not uid: + continue + try: + ok, info = sender( + touser=uid, + content=text, + dedupe_key=f"deal-notify:{no}:{uid}", + payload=dict(payload), + ) + if not ok: + logger.warning("成交通知入队失败 wo=%s uid=%s info=%s", no, uid, info) + except Exception: + logger.exception("成交通知入队异常 wo=%s uid=%s", no, uid) + except Exception: + logger.exception("成交通知组装失败,仍继续入账 wo=%s", no) summary = "通知未发出" - else: - names = [str(x.get("name") or x.get("wecomId") or "").strip() for x in leaders] - summary = "已推:" + "、".join(n for n in names if n) - sender = enqueue or _enqueue_default - payload = copy.deal_notify_wecom_payload( - work_order_no=no, - sales_name=sales_name, - facts=facts or {}, - quote=quote or {}, - business_line=business_line, - ) - text = copy.deal_notify_card( - work_order_no=no, - sales_name=sales_name, - facts=facts or {}, - quote=quote or {}, - business_line=business_line, - ) - for row in leaders: - uid = str(row.get("wecomId") or "").strip() - if not uid: - continue - try: - ok, info = sender( - touser=uid, - content=text, - dedupe_key=f"deal-notify:{no}:{uid}", - payload=dict(payload), - ) - if not ok: - logger.warning("成交通知入队失败 wo=%s uid=%s info=%s", no, uid, info) - except Exception: - logger.exception("成交通知入队异常 wo=%s uid=%s", no, uid) extra["deal_notify_summary"] = summary extra["remark"] = summary writer = getattr(ledger, "transition", None) diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 0fa819a..b63520e 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -3420,8 +3420,63 @@ def air_release_fail(*, work_order_no: str, operator: str, reason: str) -> str: ) +def air_lock_ask_airline(*, work_order_no: str, airline_names: list[str] | None = None) -> str: + """ + 已成交后点名航线去锁舱。只在空运群另发,不改共用的「已记录为已成交」。 + + 后台 inquiring.air_lock_ask_airline。姓名来自岗位「航线·空运」。 + """ + no = (work_order_no or "").strip() + names = [str(n or "").strip() for n in (airline_names or []) if str(n or "").strip()] + at = " ".join(f"@{n}" for n in names) if names else "@航线人员" + return _cabin_copy( + "inquiring.air_lock_ask_airline", + f"{no}请锁舱{at}", + ticket_no=no, + airline_names=at, + ) + + +def air_lock_need_deal(*, sales_name: str) -> str: + """ + 销售还没回复成交、未成交或协商中,航线就要锁。点名本单销售。 + + 后台 inquiring.air_lock_need_deal。 + """ + name = (sales_name or "").strip() or "销售" + return _cabin_copy( + "inquiring.air_lock_need_deal", + f"请销售@{name}先确认工单「已成交」", + sales_name=name, + ) + + +def air_lock_blocked_negotiating(work_order_no: str) -> str: + """协商中已经回过,不能再当成没回复。后台 inquiring.air_lock_negotiating。""" + no = (work_order_no or "").strip() + return _cabin_copy( + "inquiring.air_lock_negotiating", + f"工单{no}还在协商中,成交后才能锁舱。", + ticket_no=no, + ) + + +def air_lock_blocked_lost(work_order_no: str) -> str: + """未成交不能再请销售改口确认已成交。后台 inquiring.air_lock_lost。""" + no = (work_order_no or "").strip() + return _cabin_copy( + "inquiring.air_lock_lost", + f"工单{no}已是「未成交」,不能锁舱。", + ticket_no=no, + ) + + def air_release_deal_blocked(work_order_no: str) -> str: - """已成交不能释放舱位。后台 inquiring.air_release_deal_blocked。""" + """ + 旧句:已成交不能取消舱位。 + + 现口径是成交并锁舱之后仍可取消,群里不再发这句。后台编码保留,避免旧配置对不上。 + """ no = (work_order_no or "").strip() return _cabin_copy( "inquiring.air_release_deal_blocked", diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index 3b27bbe..3013b1a 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -1753,6 +1753,12 @@ def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None _apply_fee_amount(old, amount) if item.get("currency"): old["currency"] = item.get("currency") + # 盖价时一并写入单位/数量/分段:否则旧行默认「票」会盖住新抽到的「板」、 + # project=起运港 也会丢,出单又落到错段或错单位(WO008/WO009)。 + for key in ("unit", "unit_price", "unitPrice", "quantity", "project", "segment"): + val = item.get(key) + if val not in (None, ""): + old[key] = val rows[idx] = old out = dict(base or {}) for key, val in incoming.items(): diff --git a/inquiry-agent/agent/policy/system_exception.py b/inquiry-agent/agent/policy/system_exception.py index f899aa7..8e5fc61 100644 --- a/inquiry-agent/agent/policy/system_exception.py +++ b/inquiry-agent/agent/policy/system_exception.py @@ -913,6 +913,11 @@ def _resume_cabin_step( service = "空运锁舱" reason = "空运锁舱接口调用失败" if not (out or {}).get("ok"): + err = str((out or {}).get("error") or "") + # 还没成交、协商中、未成交:业务拒绝,不标 TMS 异常、不通知 IT。 + if err in {"lock_need_deal", "lock_negotiating", "lock_lost", "ticket_closed"}: + reply(str((out or {}).get("failReason") or "还不能锁舱")) + return {"ok": False, "error": err} why = copy.air_cabin_fail_reason( tms_reason=str((out or {}).get("failReason") or (out or {}).get("error") or ""), flight_date=str(extra.get("flight_date") or ""), diff --git a/inquiry-agent/agent/schema/air_route_options.py b/inquiry-agent/agent/schema/air_route_options.py index 94a3466..3640309 100644 --- a/inquiry-agent/agent/schema/air_route_options.py +++ b/inquiry-agent/agent/schema/air_route_options.py @@ -144,11 +144,17 @@ def route_codes( """ 价格明细/方案卡用的航线三字码:有中转写成 ZUH-CAN-CRK。 对不上就不写假中转。 + + selected_air_option 可能是字典、JSON 串,或只写了舱位编号; + 不能直接 dict(字符串),否则成交卡拼航线会整单炸掉(WO001)。 """ - src = dict(option or {}) + src = _parse_option_dict(option) if not src: - q = dict(quote or {}) - src = dict(q.get("selected_air_option") or {}) + q = dict(quote or {}) if isinstance(quote, dict) else {} + # 先认 camelCase,和主账快照字段对齐。 + src = _parse_option_dict( + q.get("selected_air_option") or q.get("selectedAirOption") + ) if not src: opts = parse_air_options(q) src = dict(opts[0]) if opts else {} diff --git a/inquiry-agent/agent/schema/quote_adjust.py b/inquiry-agent/agent/schema/quote_adjust.py index e6fdd52..074fa8b 100644 --- a/inquiry-agent/agent/schema/quote_adjust.py +++ b/inquiry-agent/agent/schema/quote_adjust.py @@ -461,6 +461,11 @@ def effective_quote(tms: dict[str, Any] | None, overlay: dict[str, Any] | None) _apply_amount(old, str(item.get("amount") or "")) if item.get("currency"): old["currency"] = item.get("currency") + # 与 merge_quote_fees 一致:盖价保留新行的单位/数量/分段,禁止旧默认「票」残留。 + for key in ("unit", "unit_price", "unitPrice", "quantity", "project", "segment"): + val = item.get(key) + if val not in (None, ""): + old[key] = val rows[idx] = old out = dict(base) out["fee_rows"] = rows @@ -763,6 +768,52 @@ def _match_fee_index(name: str, fees: list[dict[str, Any]], used: set[int]) -> i ) +def _fee_air_project(fee: dict[str, Any]) -> str: + """ + 空运费用落到哪一段:有 project 跟 project;否则空运费进空运段,其余进起运港。 + + 与主账 mergeAirBucketRowsWithFlatManualFees 兜底一致,避免增值费进不了起运港。 + """ + project = str(fee.get("project") or fee.get("segment") or "").strip() + if project in {"空运段", "空运"}: + return "空运段" + if project in {"起运港", "起运地"}: + return "起运港" + if project in {"目的地", "目的港"}: + return "目的地" + if project in {"其它", "其他"}: + return "其它" + name = _fee_title(fee) + if "空运" in name and "增值" not in name and "增点" not in name: + return "空运段" + return "起运港" + + +def _fee_as_air_charge(fee: dict[str, Any], *, quote: dict[str, Any]) -> dict[str, Any]: + """扁平 fee_lines 转舱位 departureCharges / feeItems 会读的结构。""" + title = _fee_title(fee) + plain = _plain_amount(str(fee.get("amount") or "")) + unit_price = _plain_amount( + str(fee.get("unit_price") or fee.get("unitPrice") or fee.get("amount") or "") + ) + out: dict[str, Any] = { + "chargeName": title, + "feeName": title, + "name": title, + "amount": plain or unit_price, + "unitPrice": unit_price or plain, + "currency": fee.get("currency") or quote.get("currency") or "", + "project": _fee_air_project(fee), + } + unit = str(fee.get("unit") or fee.get("unitName") or "").strip() + if unit: + out["unit"] = unit + qty = str(fee.get("quantity") or fee.get("chargeQuantity") or "").strip() + if qty: + out["quantity"] = qty + return out + + def _stamp_fee_items(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None: raw, was_str = _json_struct(quote.get("feeItems")) items = [dict(x) for x in raw if isinstance(x, dict)] if isinstance(raw, list) else [] @@ -775,8 +826,20 @@ def _stamp_fee_items(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None: "currency": fee.get("currency") or quote.get("currency") or "", } for fee in fees - if _fee_title(fee) + if _fee_title(fee) and _fee_air_project(fee) == "空运段" ] + if not items: + # 无空运段费目时仍按原逻辑铺全量,避免只有起运附加费时整表空掉 + items = [ + { + "name": _fee_title(fee), + "feeName": _fee_title(fee), + "amount": _plain_amount(str(fee.get("amount") or "")), + "currency": fee.get("currency") or quote.get("currency") or "", + } + for fee in fees + if _fee_title(fee) + ] else: used: set[int] = set() for item in items: @@ -789,6 +852,22 @@ def _stamp_fee_items(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None: item["name"] = _fee_title(fees[idx]) if not item.get("feeName"): item["feeName"] = _fee_title(fees[idx]) + # 调价新增的空运段费目也要进 feeItems,否则模板只读舱位桶时看不到 + for i, fee in enumerate(fees): + if i in used or _fee_air_project(fee) != "空运段": + continue + title = _fee_title(fee) + if not title: + continue + items.append( + { + "name": title, + "feeName": title, + "amount": _plain_amount(str(fee.get("amount") or "")), + "currency": fee.get("currency") or quote.get("currency") or "", + } + ) + used.add(i) if items: _put_struct(quote, "feeItems", items, was_str) @@ -870,6 +949,67 @@ def _stamp_buckets(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None: _store_bucket(quote, key, items) +def _stamp_air_bucket_list( + opt: dict[str, Any], + key_primary: str, + key_alt: str, + fees: list[dict[str, Any]], + used: set[int], + *, + projects: set[str], + quote: dict[str, Any], +) -> None: + """ + 更新舱位某一费用桶,并把 fee_lines 里多出来的同段费用追加进去。 + + 为何:调价/产品报价常只改扁平明细;模板读 departureCharges,不追加就会缺「增值费」行。 + """ + charge_key = key_primary if key_primary in opt or key_alt not in opt else key_alt + if charge_key not in opt and key_primary not in opt and key_alt not in opt: + # 舱位从未带过该桶:只要有待追加费用就新建,否则不动 + if not any(i not in used and _fee_air_project(fees[i]) in projects for i in range(len(fees))): + return + charge_key = key_primary + charges, charges_str = [], False + else: + charges, charges_str = _json_struct(opt.get(charge_key)) + if not isinstance(charges, list): + charges = [] + copied = [dict(x) if isinstance(x, dict) else x for x in charges] + for charge in copied: + if not isinstance(charge, dict): + continue + idx = _match_fee_index(_fee_title(charge), fees, used) + if idx < 0: + continue + used.add(idx) + plain = _plain_amount(str(fees[idx].get("amount") or "")) + _write_price(charge, plain) + # 单价/数量若调价行有值也盖上,模板按单价×数量重算时才对 + fee = fees[idx] + up = _plain_amount(str(fee.get("unit_price") or fee.get("unitPrice") or "")) + if up: + charge["unitPrice"] = up + charge["unit_price"] = up + qty = str(fee.get("quantity") or "").strip() + if qty: + charge["quantity"] = qty + unit = str(fee.get("unit") or "").strip() + if unit: + charge["unit"] = unit + for i, fee in enumerate(fees): + if i in used: + continue + if _fee_air_project(fee) not in projects: + continue + title = _fee_title(fee) + if not title: + continue + copied.append(_fee_as_air_charge(fee, quote=quote)) + used.add(i) + opt[charge_key] = json.dumps(copied, ensure_ascii=False) if charges_str else copied + + def _stamp_air_copies(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None: """空运报价单读舱位 departureCharges 和 salePrice,不读改过的 fee_lines。""" total_plain = _plain_amount(str(quote.get("total") or "")) @@ -882,23 +1022,25 @@ def _stamp_air_copies(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None for opt in options: if not isinstance(opt, dict): continue - charge_key = "departureCharges" if "departureCharges" in opt or "departure_charges" not in opt else "departure_charges" - if charge_key not in opt and "departureCharges" not in opt and "departure_charges" not in opt: - charge_key = "" - if charge_key: - charges, charges_str = _json_struct(opt.get(charge_key)) - if isinstance(charges, list) and charges: - used: set[int] = set() - copied = [dict(x) if isinstance(x, dict) else x for x in charges] - for charge in copied: - if not isinstance(charge, dict): - continue - idx = _match_fee_index(_fee_title(charge), fees, used) - if idx < 0: - continue - used.add(idx) - _write_price(charge, _plain_amount(str(fees[idx].get("amount") or ""))) - opt[charge_key] = json.dumps(copied, ensure_ascii=False) if charges_str else copied + used: set[int] = set() + _stamp_air_bucket_list( + opt, + "departureCharges", + "departure_charges", + fees, + used, + projects={"起运港", "其它"}, + quote=quote, + ) + _stamp_air_bucket_list( + opt, + "destinationCharges", + "destination_charges", + fees, + used, + projects={"目的地"}, + quote=quote, + ) if total_plain: if opt.get("salePrice") not in (None, ""): opt["salePrice"] = total_plain diff --git a/inquiry-agent/tests/test_air_group_collab.py b/inquiry-agent/tests/test_air_group_collab.py index c64fea3..fa39acf 100644 --- a/inquiry-agent/tests/test_air_group_collab.py +++ b/inquiry-agent/tests/test_air_group_collab.py @@ -1055,6 +1055,8 @@ class AirGroupCollabTests(unittest.TestCase): ) self.assertEqual(phase, "done") self.assertEqual(self.ledger.get_ticket(work_order_no=wo).status, "已成交") + self.assertTrue(any(f"{wo}请锁舱@HangXian" in t for t in self._texts())) + self.assertTrue(any("已记录为「已成交」" in t for t in self._texts())) def test_lock_requires_work_order(self) -> None: phase = handle_air_group( @@ -1074,8 +1076,9 @@ class AirGroupCollabTests(unittest.TestCase): ledger=self.ledger, injected_intent="air_lock", ) - self.assertEqual(phase, "no_quote") - self.assertTrue(any("还没有报价,不能锁舱" in t for t in self._texts())) + self.assertEqual(phase, "lock_need_deal") + self.assertTrue(any("请销售@WuJiLin先确认工单「已成交」" in t for t in self._texts())) + self.assertFalse(any("还没有报价,不能锁舱" in t for t in self._texts())) self.assertFalse(any("AIR-OPT-01" in t for t in self._texts())) self.assertFalse(any("执行成功" in t for t in self._texts())) @@ -1087,7 +1090,7 @@ class AirGroupCollabTests(unittest.TestCase): "source": "manual", "source_label": "产品报价", }, - status="已报价", + status="已成交", ) phase = handle_air_group( self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_manual", mid="lm1"), @@ -1136,7 +1139,7 @@ class AirGroupCollabTests(unittest.TestCase): }, ], }, - status="已报价", + status="已成交", ) calls: list[dict] = [] orig = self.ledger.lock_cabin @@ -1167,7 +1170,8 @@ class AirGroupCollabTests(unittest.TestCase): "source": "TMS", "source_label": "TMS 标准报价", "quoteId": "QT-AIR-23", - } + }, + status="已成交", ) calls: list[dict] = [] orig = self.ledger.lock_cabin @@ -1217,7 +1221,8 @@ class AirGroupCollabTests(unittest.TestCase): "source": "TMS", "source_label": "TMS 标准报价", "quoteId": "QT-AIR-24", - } + }, + status="已成交", ) calls: list[dict] = [] orig = self.ledger.lock_cabin @@ -1257,7 +1262,8 @@ class AirGroupCollabTests(unittest.TestCase): "total": "CNY 1800", "source": "manual", "source_label": "产品报价", - } + }, + status="已成交", ) calls: list[dict] = [] orig = self.ledger.lock_cabin @@ -1285,7 +1291,8 @@ class AirGroupCollabTests(unittest.TestCase): self.assertEqual(calls[0].get("flight_no"), "MU5123") self.assertFalse(any("锁舱参数不完整" in t for t in self._texts())) - def test_lock_success_and_release_after_deal_blocked(self) -> None: + def test_deal_then_lock_then_release(self) -> None: + """成交前不能锁。成交后点名航线,锁成功可取消,不再问一次是否成交。""" wo = self._create_air() phase = handle_air_group( self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="l2"), @@ -1293,36 +1300,64 @@ class AirGroupCollabTests(unittest.TestCase): ledger=self.ledger, injected_intent="air_lock", ) - self.assertEqual(phase, "lock_ok") - self.assertTrue(any("执行成功" in t for t in self._texts())) - handle_air_group( + self.assertEqual(phase, "lock_need_deal") + self.assertTrue(any("请销售@WuJiLin先确认工单「已成交」" in t for t in self._texts())) + phase = handle_air_group( self._msg(sender="sales1", text="成交", chat="wr_air_1", mid="l3"), flow=self.flow, ledger=self.ledger, injected_intent="deal_won", ) + self.assertEqual(phase, "done") + self.assertTrue(any(f"{wo}请锁舱@HangXian" in t for t in self._texts())) + self.groups.sent.clear() phase = handle_air_group( - self._msg(sender="air1", text=f"{wo}释放舱位", chat="wr_air_1", mid="l4"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_release", - ) - self.assertEqual(phase, "release_deal_blocked") - self.assertTrue(any("已成交" in t for t in self._texts())) - - def test_lost_can_release(self) -> None: - wo = self._create_air() - handle_air_group( - self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="r0"), + self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="l4"), flow=self.flow, ledger=self.ledger, injected_intent="air_lock", ) - handle_air_group( - self._msg(sender="sales1", text="未成交", chat="wr_air_1", mid="r1"), + self.assertEqual(phase, "lock_ok") + self.assertTrue(any("执行成功" in t for t in self._texts())) + self.assertFalse(any("请确认成交状态" in t for t in self._texts())) + phase = handle_air_group( + self._msg(sender="air1", text=f"{wo}取消舱位", chat="wr_air_1", mid="l5"), flow=self.flow, ledger=self.ledger, - injected_intent="deal_lost", + ) + self.assertEqual(phase, "release_ok") + self.assertTrue(any("取消舱位成功" in t for t in self._texts())) + + def test_negotiating_and_lost_cannot_lock(self) -> None: + wo_talk = self._create_air(status="协商中") + phase = handle_air_group( + self._msg(sender="air1", text=f"{wo_talk}锁舱", chat="wr_air_talk", mid="ng1"), + flow=self.flow, + ledger=self.ledger, + injected_intent="air_lock", + ) + self.assertEqual(phase, "lock_negotiating") + self.assertTrue(any("还在协商中,成交后才能锁舱" in t for t in self._texts())) + self.assertFalse(any("先确认工单「已成交」" in t for t in self._texts())) + wo_lost = self._create_air(status="未成交") + self.groups.sent.clear() + phase = handle_air_group( + self._msg(sender="air1", text=f"{wo_lost}锁舱", chat="wr_air_lost", mid="ls1"), + flow=self.flow, + ledger=self.ledger, + injected_intent="air_lock", + ) + self.assertEqual(phase, "lock_lost") + self.assertTrue(any("已是「未成交」,不能锁舱" in t for t in self._texts())) + self.assertFalse(any("先确认工单「已成交」" in t for t in self._texts())) + + def test_lost_can_release(self) -> None: + """未成交不能新锁,但改规则前已经锁上的仍可取消。""" + wo = self._create_air(status="未成交") + self.ledger.patch_collab_facts( + work_order_no=wo, + facts={"__air_lock_id": "CA-OLD-LOCK"}, + sender_id="air1", ) before = len(self._texts()) phase = handle_air_group( @@ -1338,7 +1373,7 @@ class AirGroupCollabTests(unittest.TestCase): def test_release_wording_asks_before_cancel(self) -> None: """说出释放舱位只提问。销售回「是」不算,航线确认后才取消。""" - wo = self._create_air() + wo = self._create_air(status="已成交") handle_air_group( self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_ask", mid="ask0"), flow=self.flow, @@ -1370,7 +1405,7 @@ class AirGroupCollabTests(unittest.TestCase): self.assertIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts())) def test_release_wording_can_be_declined(self) -> None: - wo = self._create_air() + wo = self._create_air(status="已成交") handle_air_group( self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_ask_no", mid="no0"), flow=self.flow, @@ -1393,7 +1428,7 @@ class AirGroupCollabTests(unittest.TestCase): self.assertNotIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts())) def test_cancel_cabin_words_release_immediately(self) -> None: - wo = self._create_air() + wo = self._create_air(status="已成交") handle_air_group( self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_now", mid="now0"), flow=self.flow, @@ -1430,7 +1465,7 @@ class AirGroupCollabTests(unittest.TestCase): "status": "active", } ) - wo = self._create_air() + wo = self._create_air(status="已成交") phase = handle_air_group( self._msg(sender="sales1", text=f"{wo}锁舱", chat="wr_air_1", mid="s2"), flow=self.flow, @@ -1439,7 +1474,8 @@ class AirGroupCollabTests(unittest.TestCase): ) self.assertEqual(phase, "lock_ok") - def test_multi_option_asks(self) -> None: + def test_multi_tms_option_locks_first_until_named(self) -> None: + """TMS 有多条舱位且没点名时,直接锁第一条,不再追问三项。""" wo = self._create_air( quote={ "total": "USD 800", @@ -1449,7 +1485,8 @@ class AirGroupCollabTests(unittest.TestCase): {"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "flightNo": "CZ327"}, {"optionNo": "AIR-OPT-02", "airlineCode": "MU", "flightNo": "MU5123"}, ], - } + }, + status="已成交", ) phase = handle_air_group( self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="m1"), @@ -1457,16 +1494,9 @@ class AirGroupCollabTests(unittest.TestCase): ledger=self.ledger, injected_intent="air_lock", ) - self.assertEqual(phase, "wait_lock_params") - phase = handle_air_group( - self._msg(sender="air1", text="AIR-OPT-02", chat="wr_air_1", mid="m2"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_pick_option", - ) self.assertEqual(phase, "lock_ok") ticket = self.ledger.get_ticket(work_order_no=wo) - self.assertEqual(ticket.collab_facts.get("__air_lock_option"), "AIR-OPT-02") + self.assertEqual(ticket.collab_facts.get("__air_lock_option"), "AIR-OPT-01") def _file(self, *, sender: str, filename: str, chat: str, mid: str, text: str = "") -> InboundMessage: return InboundMessage( @@ -1787,7 +1817,7 @@ class AirGroupCollabTests(unittest.TestCase): def test_lock_fail_tells_operator_and_tms_reason(self) -> None: """锁舱失败群里要回操作人和 TMS 原因,不能被系统异常兜底盖掉。""" - wo = self._create_air() + wo = self._create_air(status="已成交") self.ledger._cabin_lock_result = { "ok": False, "error": "lock_fail", @@ -1810,25 +1840,20 @@ class AirGroupCollabTests(unittest.TestCase): self.assertNotIn("与工单不匹配", joined) def test_lock_fail_does_not_block_airline_quote(self) -> None: - """锁舱失败后,航线再报手工价必须收下,不能回 TMS 接口异常。""" + """成交前锁舱不会打 TMS,也不标系统异常,航线仍可继续报价。""" wo = self._create_air() handle_air_group( self._msg(sender="air1", text=wo, chat="wr_air_lock_quote", mid="lq0"), flow=self.flow, ledger=self.ledger, ) - self.ledger._cabin_lock_result = { - "ok": False, - "error": "lock_fail", - "failReason": "该航班日期不存在可锁定的配载记录", - } phase = handle_air_group( self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_quote", mid="lq1"), flow=self.flow, ledger=self.ledger, injected_intent="air_lock", ) - self.assertEqual(phase, "system_exception") + self.assertEqual(phase, "lock_need_deal") self.groups.sent.clear() phase = handle_air_group( self._msg(sender="air1", text="空运费改为900USD", chat="wr_air_lock_quote", mid="lq2"), @@ -1843,7 +1868,7 @@ class AirGroupCollabTests(unittest.TestCase): def test_lock_without_quote_still_shows_tms_reason(self) -> None: """TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。""" - wo = self._create_air() + wo = self._create_air(status="已成交") self.ledger._cabin_lock_result = { "ok": False, "error": "lock_fail", @@ -1863,7 +1888,7 @@ class AirGroupCollabTests(unittest.TestCase): def test_paused_lock_retries_and_shows_tms_reason(self) -> None: """上次锁舱失败标了系统异常后,再锁仍要打 TMS,不能只回兜底。""" - wo = self._create_air() + wo = self._create_air(status="已成交") self.ledger.mark_system_exception( work_order_no=wo, system_exception="TMS", @@ -1937,7 +1962,7 @@ class AirGroupCollabTests(unittest.TestCase): def test_release_fail_tells_operator_and_tms_reason(self) -> None: """释放失败同理:操作人、原因、已通知IT,不要系统异常兜底。""" - wo = self._create_air() + wo = self._create_air(status="已成交") handle_air_group( self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_rl_fail", mid="rf0"), flow=self.flow, diff --git a/inquiry-agent/tests/test_air_route_options.py b/inquiry-agent/tests/test_air_route_options.py index bdb529d..1c8952e 100644 --- a/inquiry-agent/tests/test_air_route_options.py +++ b/inquiry-agent/tests/test_air_route_options.py @@ -65,6 +65,32 @@ class AirRouteOptionsTests(unittest.TestCase): self.assertTrue(needs_route_pick(quote)) self.assertEqual(len(parse_air_options(quote)), 2) + def test_route_codes_tolerates_string_selected_option(self) -> None: + """主账若把 selected_air_option 存成 JSON 串或编号串,不能再 dict(字符串) 炸掉成交卡。""" + quote_json = { + "selected_air_option": ( + '{"originAirportCode":"NKG","destinationAirportCode":"KUL"}' + ), + } + self.assertEqual( + route_codes(None, {"起运港": "南京", "目的港": "吉隆坡"}, quote_json), + "NKG-KUL", + ) + quote_code = { + "selected_air_option": "AIR-OPT01", + "airOptions": [ + { + "optionNo": "AIR-OPT01", + "originAirportCode": "SZX", + "destinationAirportCode": "CGK", + } + ], + } + self.assertEqual( + route_codes(None, {"起运港": "深圳", "目的港": "雅加达"}, quote_code), + "SZX-CGK", + ) + def test_select_writes_origin(self) -> None: option = {"originAirportCode": "SZX", "destinationAirportCode": "KUL"} facts = apply_selected_option({"目的港": "吉隆坡"}, option) diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py index 99bbd7b..db7d560 100644 --- a/inquiry-agent/tests/test_sea_text_inquiry.py +++ b/inquiry-agent/tests/test_sea_text_inquiry.py @@ -1392,8 +1392,41 @@ class SeaGroupCollabFieldTests(unittest.TestCase): self.assertEqual(str(rows["空运费"].get("amount") or ""), "9900.00") self.assertEqual(str(rows["空运费"].get("unit_price") or ""), "99.00") self.assertEqual(str(rows["空运费"].get("quantity") or ""), "100") + self.assertEqual(str(rows["空运费"].get("unit") or "").upper(), "KG") + self.assertEqual(str(rows["空运费"].get("project") or ""), "空运段") self.assertEqual(str(quote.get("total") or ""), "9900.0") + def test_air_origin_fee_underscore_unit_board(self) -> None: + """WO008:起运港、增值费_板,单价… — 单位是板,不能落成票;项目是起运港。""" + from agent.llm.extract_text import extract_product_quote + + quote = extract_product_quote( + "起运港、增值费_板,单价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 ""), "起运港") + self.assertEqual(str(quote.get("total") or ""), "1000.0") + + def test_air_origin_fee_mention_prefix_keeps_project_and_unit(self) -> None: + """WO009:句首 @询价小助手 不能挡住起运港分段;逗号单位「板」不能丢。""" + from agent.llm.extract_text import extract_product_quote + + quote = extract_product_quote( + "@询价小助手 起运港、增值费,板,单价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("project") or ""), "起运港") + self.assertEqual(str(rows["增值费"].get("amount") or ""), "1000") + 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_system_exception.py b/inquiry-agent/tests/test_system_exception.py index f50b285..b30870c 100644 --- a/inquiry-agent/tests/test_system_exception.py +++ b/inquiry-agent/tests/test_system_exception.py @@ -495,7 +495,7 @@ class TestConfirmSystemException(unittest.TestCase): "quoteId": "QT-AIR-1", "airOptions": [{"optionNo": "AIR-OPT-01", "airlineCode": "CZ"}], }, - to_status="已报价", + to_status="已成交", ) self.ledger.mark_system_exception( work_order_no=air_wo, diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/domain/AirGroupActivateRule.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/domain/AirGroupActivateRule.java index 212e64d..276804c 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/domain/AirGroupActivateRule.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/domain/AirGroupActivateRule.java @@ -12,6 +12,7 @@ import java.util.Set; * 空运群「当前工单」能不能激活。纯内存规则,不写库。 *

* 可激活:业务线 AIR,且六态询价中 / 已报价 / 协商中。 + * 已成交只在锁舱补参数(allowDeal)时允许记到本群。 * 一张单同时只跟一个群;已经绑了别的群就 occupied。 */ public final class AirGroupActivateRule { @@ -27,13 +28,23 @@ public final class AirGroupActivateRule { * @return 空串表示可以激活;否则是给智能体的错误码 */ public static String rejectReason(InquiryTicket ticket, String chatId) { + return rejectReason(ticket, chatId, false); + } + + /** + * 锁舱补参数时允许把已成交单记在本群,方便下一句不再带工单号。 + * 未成交、已关闭、转人工仍然不能激活。普通激活不要传 allowDeal。 + */ + public static String rejectReason(InquiryTicket ticket, String chatId, boolean allowDeal) { if (ticket == null) { return "ticket_not_found"; } if (!"AIR".equalsIgnoreCase(StringUtils.trimToEmpty(ticket.getBusinessLine()))) { return "not_air"; } - if (!OPEN.contains(StringUtils.trimToEmpty(ticket.getStatus()))) { + String status = StringUtils.trimToEmpty(ticket.getStatus()); + boolean dealBind = allowDeal && TicketStatuses.DEAL.equals(status); + if (!OPEN.contains(status) && !dealBind) { return "terminal"; } String bound = StringUtils.trimToEmpty(ticket.getCollabChatId()); 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 c71d46d..fe513a9 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 @@ -1043,6 +1043,22 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl> lineMaps = new ArrayList<>(); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java index cb4efd8..84cf2d3 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java @@ -1307,7 +1307,15 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl out = new LinkedHashMap<>(); - String reject = AirGroupActivateRule.rejectReason(ticket, room); + Object rawAllow = body == null ? null : body.get("allowDeal"); + if (rawAllow == null && body != null) { + rawAllow = body.get("allow_deal"); + } + // 只有锁舱补参数才带这个标记。普通激活仍拒绝已成交。 + boolean allowDeal = Boolean.TRUE.equals(rawAllow) + || "true".equalsIgnoreCase(String.valueOf(rawAllow)) + || "1".equals(String.valueOf(rawAllow)); + String reject = AirGroupActivateRule.rejectReason(ticket, room, allowDeal); if (StringUtils.isNotBlank(reject)) { out.put("ok", false); out.put("error", reject); 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 00be2f4..6b20d2b 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 @@ -32,11 +32,18 @@ public final class LandQuoteTemplateFields { add("land_freight", "陆运费", "landFreight"); add("vehicle_42_fee", "4.2米/3T费用", "vehicle42Fee"); add("vehicle_50_fee", "5米/5T费用", "vehicle50Fee"); + add("vehicle_68_fee", "6.8/5T费用", "vehicle68Fee"); + add("vehicle_65_fee", "6.5米/8T费用", "vehicle65Fee"); add("vehicle_76_fee", "7.6米/8T费用", "vehicle76Fee"); + add("vehicle_78_fee", "7.8米/10T费用", "vehicle78Fee"); add("vehicle_82_fee", "8.2米/10T费用", "vehicle82Fee"); + add("vehicle_82_12_fee", "8.2米/12T费用", "vehicle8212Fee"); add("vehicle_96_fee", "9.6米/10T费用", "vehicle96Fee"); + add("vehicle_59_fee", "5.9米/20尺费用", "vehicle59Fee"); add("vehicle_12_fee", "12米/40尺费用", "vehicle12Fee"); add("vehicle_125_fee", "12.5米/40尺费用", "vehicle125Fee"); + add("vehicle_135_fee", "13.5米/45尺费用", "vehicle135Fee"); + add("vehicle_15_reefer_fee", "15米/冷柜费用", "vehicle15ReeferFee"); add("vehicle_165_fee", "16.5米/53尺费用", "vehicle165Fee"); add("vehicle_3t_fee", "4.1*2.1*2.3M/3T费用", "vehicle3TFee"); add("vehicle_5t_fee", "6.5*2.4*2.5M/5T费用", "vehicle5TFee"); @@ -51,9 +58,25 @@ public final class LandQuoteTemplateFields { LABEL_TO_CODE.put("时效", "transit_days"); LABEL_TO_CODE.put("干线费", "land_freight"); LABEL_TO_CODE.put("运费", "land_freight"); + // 20261012 国内整车 / 中港:短表头(无「费用」后缀)也要能对上填表码 LABEL_TO_CODE.put("4.2米/3T", "vehicle_42_fee"); + LABEL_TO_CODE.put("5米/5T", "vehicle_50_fee"); + LABEL_TO_CODE.put("6.8/5T", "vehicle_68_fee"); + LABEL_TO_CODE.put("6.8米/5T", "vehicle_68_fee"); + LABEL_TO_CODE.put("6.5米/8T", "vehicle_65_fee"); LABEL_TO_CODE.put("7.6米/8T", "vehicle_76_fee"); + LABEL_TO_CODE.put("7.8米/10T", "vehicle_78_fee"); + LABEL_TO_CODE.put("8.2米/10T", "vehicle_82_fee"); + LABEL_TO_CODE.put("8.2米/12T", "vehicle_82_12_fee"); LABEL_TO_CODE.put("9.6米/10T", "vehicle_96_fee"); + LABEL_TO_CODE.put("5.9米/20尺", "vehicle_59_fee"); + LABEL_TO_CODE.put("12米/40尺", "vehicle_12_fee"); + LABEL_TO_CODE.put("12.5米/40尺", "vehicle_125_fee"); + LABEL_TO_CODE.put("13.5米/45尺", "vehicle_135_fee"); + LABEL_TO_CODE.put("15米/冷柜", "vehicle_15_reefer_fee"); + LABEL_TO_CODE.put("16.5米/53尺", "vehicle_165_fee"); + LABEL_TO_CODE.put("13.75米平板", "vehicle_1375_fee"); + LABEL_TO_CODE.put("17.5米平板", "vehicle_175_fee"); LABEL_TO_CODE.put("元/KG", "price_per_kg"); } 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 119776a..f13cab3 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 @@ -123,13 +123,37 @@ public final class QuoteTemplateExcelSupport { FILL_BY_LABEL.put("4.2米/3T费用", "vehicle_42_fee"); FILL_BY_LABEL.put("4.2米/3T", "vehicle_42_fee"); FILL_BY_LABEL.put("5米/5T费用", "vehicle_50_fee"); + FILL_BY_LABEL.put("5米/5T", "vehicle_50_fee"); + FILL_BY_LABEL.put("6.8/5T费用", "vehicle_68_fee"); + FILL_BY_LABEL.put("6.8/5T", "vehicle_68_fee"); + FILL_BY_LABEL.put("6.8米/5T费用", "vehicle_68_fee"); + FILL_BY_LABEL.put("6.8米/5T", "vehicle_68_fee"); + FILL_BY_LABEL.put("6.5米/8T费用", "vehicle_65_fee"); + FILL_BY_LABEL.put("6.5米/8T", "vehicle_65_fee"); FILL_BY_LABEL.put("7.6米/8T费用", "vehicle_76_fee"); FILL_BY_LABEL.put("7.6米/8T", "vehicle_76_fee"); + FILL_BY_LABEL.put("7.8米/10T费用", "vehicle_78_fee"); + FILL_BY_LABEL.put("7.8米/10T", "vehicle_78_fee"); FILL_BY_LABEL.put("8.2米/10T费用", "vehicle_82_fee"); + FILL_BY_LABEL.put("8.2米/10T", "vehicle_82_fee"); + FILL_BY_LABEL.put("8.2米/12T费用", "vehicle_82_12_fee"); + FILL_BY_LABEL.put("8.2米/12T", "vehicle_82_12_fee"); + FILL_BY_LABEL.put("5.9米/20尺费用", "vehicle_59_fee"); + FILL_BY_LABEL.put("5.9米/20尺", "vehicle_59_fee"); FILL_BY_LABEL.put("9.6米/10T费用", "vehicle_96_fee"); + FILL_BY_LABEL.put("9.6米/10T", "vehicle_96_fee"); FILL_BY_LABEL.put("12米/40尺费用", "vehicle_12_fee"); + FILL_BY_LABEL.put("12米/40尺", "vehicle_12_fee"); FILL_BY_LABEL.put("12.5米/40尺费用", "vehicle_125_fee"); + FILL_BY_LABEL.put("12.5米/40尺", "vehicle_125_fee"); + FILL_BY_LABEL.put("13.5米/45尺费用", "vehicle_135_fee"); + FILL_BY_LABEL.put("13.5米/45尺", "vehicle_135_fee"); + FILL_BY_LABEL.put("15米/冷柜费用", "vehicle_15_reefer_fee"); + FILL_BY_LABEL.put("15米/冷柜", "vehicle_15_reefer_fee"); FILL_BY_LABEL.put("16.5米/53尺费用", "vehicle_165_fee"); + FILL_BY_LABEL.put("16.5米/53尺", "vehicle_165_fee"); + FILL_BY_LABEL.put("13.75米平板", "vehicle_1375_fee"); + FILL_BY_LABEL.put("17.5米平板", "vehicle_175_fee"); FILL_BY_LABEL.put("4.1*2.1*2.3M/3T费用", "vehicle_3t_fee"); FILL_BY_LABEL.put("6.5*2.4*2.5M/5T费用", "vehicle_5t_fee"); FILL_BY_LABEL.put("7.5*2.4*2.5M/8T费用", "vehicle_8t_fee"); @@ -161,11 +185,18 @@ public final class QuoteTemplateExcelSupport { LAND_TMS_KEY_TO_CODE.put("rateFlag", "tax_included"); LAND_TMS_KEY_TO_CODE.put("vehicle42Fee", "vehicle_42_fee"); LAND_TMS_KEY_TO_CODE.put("vehicle50Fee", "vehicle_50_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle68Fee", "vehicle_68_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle65Fee", "vehicle_65_fee"); LAND_TMS_KEY_TO_CODE.put("vehicle76Fee", "vehicle_76_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle78Fee", "vehicle_78_fee"); LAND_TMS_KEY_TO_CODE.put("vehicle82Fee", "vehicle_82_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle8212Fee", "vehicle_82_12_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle59Fee", "vehicle_59_fee"); LAND_TMS_KEY_TO_CODE.put("vehicle96Fee", "vehicle_96_fee"); LAND_TMS_KEY_TO_CODE.put("vehicle12Fee", "vehicle_12_fee"); LAND_TMS_KEY_TO_CODE.put("vehicle125Fee", "vehicle_125_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle135Fee", "vehicle_135_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle15ReeferFee", "vehicle_15_reefer_fee"); LAND_TMS_KEY_TO_CODE.put("vehicle165Fee", "vehicle_165_fee"); LAND_TMS_KEY_TO_CODE.put("vehicle3TFee", "vehicle_3t_fee"); LAND_TMS_KEY_TO_CODE.put("vehicle5TFee", "vehicle_5t_fee"); @@ -175,6 +206,8 @@ public final class QuoteTemplateExcelSupport { LAND_TMS_KEY_TO_CODE.put("vehicle45HQFee", "vehicle_45hq_fee"); LAND_TMS_KEY_TO_CODE.put("vehicle48RHFee", "vehicle_48rh_fee"); LAND_TMS_KEY_TO_CODE.put("vehicle53RFee", "vehicle_53r_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle13.75TFee", "vehicle_1375_fee"); + LAND_TMS_KEY_TO_CODE.put("vehicle17.5TFee", "vehicle_175_fee"); } private QuoteTemplateExcelSupport() { @@ -589,6 +622,8 @@ public final class QuoteTemplateExcelSupport { JSONObject fieldMeta = mapping.getJSONObject("fieldMeta"); JSONObject fields = mapping.getJSONObject("fields"); LandTemplateLook landLook = snapshotLandTemplateLook(wb); + // 车型尺寸副表头(如 3T 下 4.1*2.1*2.3M)出单时曾被误清;先快照再盖回。 + List vehicleDims = snapshotLandVehicleDims(wb); if (fields != null) { // 陆运拼车等模板常把「报价日期」与「有效期」误绑同一格(如 H5「报价日期:」)。 // 同格时只写出报价日期,避免 TMS valid_until 盖掉日期标签含义。 @@ -678,6 +713,8 @@ public final class QuoteTemplateExcelSupport { clearPortSpillInFeeNameColumn(wb, facts); // 跨境集拼:列头必须和空白模板一样(蓝底白字)。POI 写回后币种/燃油会变成大红字。 restoreLandTemplateLook(wb, landLook); + // 车型尺寸副表头盖回(WO014:模板有 3T 尺寸,出单后 D8 空)。 + restoreLandVehicleDims(wb, vehicleDims); // 列头盖回去时,数据行若被误当成表头,目的地会再次变成币种那套红字。 restyleLandCityValues(wb); // 拼车「元/CBM」整车车型格:跟「始发站」同一浅蓝,禁止盖成顶栏那种深蓝。 @@ -2250,43 +2287,39 @@ public final class QuoteTemplateExcelSupport { if (Boolean.TRUE.equals(region.getBoolean("dynamicContainerHeader"))) { applyDynamicContainerHeader(sheet, region, columns, facts, yellow); } + // 空运模板有「空运段/起运港/目的地/其它」骨架时:费用落到对应段,禁止顺填盖掉空运段首行 + int projectColIdx = StringUtils.isBlank(columns.getString("project")) + ? -1 + : CellReference.convertColStringToIndex(columns.getString("project").trim()); + boolean placeByProject = projectColIdx >= 0 + && hasAirProjectSkeleton(sheet, startRow, stopRow, projectColIdx); + List writeRows = placeByProject + ? assignAirFeeWriteRows(sheet, startRow, stopRow, projectColIdx, rows) + : null; + Set writtenRows = new HashSet<>(); for (int r = 0; r < rows.size(); r++) { - int rowIdx = startRow + r; - if (rowIdx >= stopRow) { + int rowIdx = writeRows != null && r < writeRows.size() + ? writeRows.get(r) + : startRow + r; + if (rowIdx < 0 || rowIdx >= stopRow) { log.warn("费用区行数仍越过 stop,截断 region={} wrote={}/{}", region.getString("id"), r, rows.size()); break; } + writtenRows.add(rowIdx); // 不整行跳过:备注「发生时收取」常标黄,整行跳过会把费用名留成模板样例(如 ZUH)。 // 黄格仍由 writeFeeCol 逐格跳过。 JSONObject rowObj = rows.getJSONObject(r); - String projectVal = firstNonBlank(rowObj.getString("project"), rowObj.getString("segment")); - // 增点/操作等禁止落在模板首行「空运段」骨架上:无 project 时按费目名归类 + String projectVal = normalizeAirProjectLabel( + firstNonBlank(rowObj.getString("project"), rowObj.getString("segment"))); + // 已有分段 project 原样用;无则按费目名兜底;附加费名禁止留在「空运段」 if (StringUtils.isBlank(projectVal)) { projectVal = isAirWeightFeeName(rowObj.getString("name")) ? "空运段" : "起运港"; - } else if (!isAirWeightFeeName(rowObj.getString("name")) - && ("空运段".equals(projectVal) || "空运".equals(projectVal))) { + } else if ("空运段".equals(projectVal) && !isAirWeightFeeName(rowObj.getString("name"))) { projectVal = "起运港"; } - String projectBefore = readCellText( - sheet.getRow(rowIdx) == null || StringUtils.isBlank(columns.getString("project")) - ? null - : sheet.getRow(rowIdx).getCell( - CellReference.convertColStringToIndex(columns.getString("project").trim()))); - writeFeeCol(sheet, rowIdx, columns.getString("project"), projectVal, yellow, sheet.getSheetName()); - String projectAfter = readCellText( - sheet.getRow(rowIdx) == null || StringUtils.isBlank(columns.getString("project")) - ? null - : sheet.getRow(rowIdx).getCell( - CellReference.convertColStringToIndex(columns.getString("project").trim()))); - // writeFeeCol 可能因黄底/保护跳过:项目骨架必须能改成费目归属(增点费→起运港) - if (StringUtils.isNotBlank(projectVal) && !projectVal.equals(projectAfter) - && StringUtils.isNotBlank(columns.getString("project"))) { - forceWriteProjectSkeleton(sheet, rowIdx, - CellReference.convertColStringToIndex(columns.getString("project").trim()), - projectVal); - projectAfter = readCellText(sheet.getRow(rowIdx).getCell( - CellReference.convertColStringToIndex(columns.getString("project").trim()))); - } + rowObj.put("project", projectVal); + // 项目列:已在正确分段时只保证骨架文案,禁止把「空运段」骨架改成「起运港」 + writeAirProjectCell(sheet, rowIdx, projectColIdx, projectVal, yellow, sheet.getSheetName()); writeFeeCol(sheet, rowIdx, columns.getString("name"), rowObj.getString("name"), yellow, sheet.getSheetName()); writeFeeCol(sheet, rowIdx, columns.getString("unit"), rowObj.getString("unit"), yellow, sheet.getSheetName()); writeFeeCol(sheet, rowIdx, columns.getString("quantity"), @@ -2325,13 +2358,230 @@ public final class QuoteTemplateExcelSupport { } } } - // 明细行数少于模板占位时,清掉后面样例行(含黄底「发生时收取」备注),只留有费用的行 - clearFeeRowsAfter(sheet, startRow + rows.size(), stopRow, columns, yellow, sheet.getSheetName(), true); + // 有分段落位:清未写入行的样例费目,保留空运段/起运港等骨架 + // 顺填:仍按「写完后的下一行」清样例 + if (placeByProject) { + clearFeeRowsExcept(sheet, startRow, stopRow, columns, yellow, sheet.getSheetName(), true, writtenRows); + } else { + clearFeeRowsAfter(sheet, startRow + rows.size(), stopRow, columns, yellow, sheet.getSheetName(), true); + } // 有明细时再扫一遍:防止 enrich 未覆盖到的空运费行单位仍空 - fillAirWeightUnitOnTemplateRows(sheet, startRow, startRow + rows.size(), columns, facts, yellow); + fillAirWeightUnitOnTemplateRows(sheet, startRow, stopRow, columns, facts, yellow); } } + /** + * 模板项目列是否已有空运分段骨架(空运段/起运港/目的地/其它)。 + * 有则费用必须落到对应段,不能从首行顺填。 + */ + static boolean hasAirProjectSkeleton(Sheet sheet, int startRow, int stopRow, int projectCol) { + if (sheet == null || projectCol < 0 || startRow < 0) { + return false; + } + int end = Math.min(stopRow, sheet.getLastRowNum() + 1); + int hits = 0; + Set seen = new HashSet<>(); + for (int r = startRow; r < end; r++) { + String label = normalizeAirProjectLabel(projectLabelAt(sheet, r, projectCol)); + if (StringUtils.isBlank(label)) { + continue; + } + if ("空运段".equals(label) || "起运港".equals(label) + || "目的地".equals(label) || "其它".equals(label)) { + if (seen.add(label)) { + hits++; + } + } + } + // 至少两段骨架才按段落位(避免海运/陆运偶发「起运港」二字误触发) + return hits >= 2; + } + + /** + * 按模板项目骨架为每条费用分配写入行。 + * 人工只有「起运港」费时落到起运港段,禁止占空运段首行。 + * 副作用:无。线程:调用方线程。 + */ + static List assignAirFeeWriteRows(Sheet sheet, int startRow, int stopRow, + int projectCol, JSONArray rows) { + List out = new ArrayList<>(); + if (rows == null || rows.isEmpty()) { + return out; + } + 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; + 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 = "起运港"; + } + List band = bands.get(projectVal); + Integer rowIdx = null; + if (band != null && !band.isEmpty()) { + int cur = cursors.getOrDefault(projectVal, 0); + if (cur < band.size()) { + rowIdx = band.get(cur); + 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; + } + } + if (rowIdx == null) { + rowIdx = startRow + i; + } + out.add(rowIdx); + } + return out; + } + + /** 扫描项目列,得到各分段可用行(含合并区内每一物理行)。 */ + static Map> buildAirProjectBands(Sheet sheet, int startRow, int stopRow, int projectCol) { + Map> bands = new LinkedHashMap<>(); + if (sheet == null || projectCol < 0) { + return bands; + } + int end = Math.min(stopRow, sheet.getLastRowNum() + 1); + for (int r = startRow; r < end; r++) { + String label = normalizeAirProjectLabel(projectLabelAt(sheet, r, projectCol)); + if (StringUtils.isBlank(label)) { + continue; + } + if (!("空运段".equals(label) || "起运港".equals(label) + || "目的地".equals(label) || "其它".equals(label))) { + continue; + } + bands.computeIfAbsent(label, k -> new ArrayList<>()).add(r); + } + return bands; + } + + private static boolean isRowClaimedByOtherBand(Map> bands, String project, int row) { + if (bands == null) { + return false; + } + for (Map.Entry> e : bands.entrySet()) { + if (e.getKey() == null || e.getKey().equals(project)) { + continue; + } + if (e.getValue() != null && e.getValue().contains(row)) { + return true; + } + } + return false; + } + + /** 读项目列文案(合并格取原点值)。 */ + static String projectLabelAt(Sheet sheet, int rowIdx, int projectCol) { + if (sheet == null || rowIdx < 0 || projectCol < 0) { + return ""; + } + int[] origin = resolveMergeOrigin(sheet, rowIdx, projectCol); + Row row = sheet.getRow(origin[0]); + if (row == null) { + return ""; + } + return StringUtils.trimToEmpty(readCellText(row.getCell(origin[1]))); + } + + /** + * 空运项目列归一:目的港→目的地,其他→其它;空运→空运段。 + */ + public static String normalizeAirProjectLabel(String raw) { + String t = StringUtils.trimToEmpty(raw); + if (t.isEmpty()) { + return ""; + } + if ("空运".equals(t) || "空运段".equals(t)) { + return "空运段"; + } + if ("起运港".equals(t) || "起运地".equals(t)) { + return "起运港"; + } + if ("目的地".equals(t) || "目的港".equals(t)) { + return "目的地"; + } + if ("其它".equals(t) || "其他".equals(t)) { + return "其它"; + } + return t; + } + + /** + * 写入项目列:落在正确分段时保留/补齐骨架;禁止把别的段骨架改掉。 + */ + static void writeAirProjectCell(Sheet sheet, int rowIdx, int projectCol, String projectVal, + Set yellow, String sheetName) { + if (sheet == null || rowIdx < 0 || projectCol < 0 || StringUtils.isBlank(projectVal)) { + return; + } + String colLetter = CellReference.convertNumToColString(projectCol); + int[] origin = resolveMergeOrigin(sheet, rowIdx, projectCol); + String skeleton = normalizeAirProjectLabel(projectLabelAt(sheet, rowIdx, projectCol)); + // 已在目标分段(含合并区):不必改项目列,避免拆合并/改错段 + if (projectVal.equals(skeleton)) { + return; + } + // 骨架是另一分段:禁止覆盖(落位错误时宁可不改项目列) + if (StringUtils.isNotBlank(skeleton) + && ("空运段".equals(skeleton) || "起运港".equals(skeleton) + || "目的地".equals(skeleton) || "其它".equals(skeleton)) + && !projectVal.equals(skeleton)) { + log.warn("跳过改写项目骨架 row={} skeleton={} want={}", rowIdx, skeleton, projectVal); + return; + } + writeFeeCol(sheet, origin[0], colLetter, projectVal, yellow, sheetName); + String after = normalizeAirProjectLabel(projectLabelAt(sheet, rowIdx, projectCol)); + if (!projectVal.equals(after)) { + forceWriteProjectSkeleton(sheet, origin[0], projectCol, projectVal); + } + } + + /** + * 清空费用区中「未写入」行的费目/金额等,保留项目骨架。 + * 用于按段落位后:空运段无费时清样例名,但「空运段」三字留下。 + */ + private static void clearFeeRowsExcept(Sheet sheet, int startRow, int stopRow, JSONObject columns, + Set yellow, String sheetName, boolean clearSampleRemarks, + Set keepRows) { + if (sheet == null || columns == null || startRow < 0) { + return; + } + int lastExisting = sheet.getLastRowNum(); + int end = stopRow; + if (end == Integer.MAX_VALUE || end > lastExisting + 1) { + end = lastExisting + 1; + } + end = Math.min(end, 1048575 + 1); + Set keep = keepRows == null ? Collections.emptySet() : keepRows; + for (int r = startRow; r < end; r++) { + if (keep.contains(r)) { + continue; + } + clearFeeRowsAfter(sheet, r, r + 1, columns, yellow, sheetName, clearSampleRemarks); + } + } + + /** * 只保留有单价或金额的费用行;纯名称占位不写出(有费用的才显示行)。 */ @@ -2473,6 +2723,11 @@ public final class QuoteTemplateExcelSupport { "container_rate", "remark"} : new String[]{"name", "unit", "quantity", "unit_price", "amount", "currency", "container_rate"}; for (int r = fromRow; r < end; r++) { + // 陆运车型表头/尺寸行/线路矩阵表头不是费用样例行,整行跳过(WO014 D8)。 + if (rowLooksLikeVehicleHeader(sheet, r) || rowLooksLikeVehicleDimensionRow(sheet, r) + || rowLooksLikeLandRouteMatrixHeader(sheet, r) || rowLooksLikeLandUnitSubHeader(sheet, r)) { + continue; + } for (String key : keys) { String colLetter = columns.getString(key); if (StringUtils.isBlank(colLetter)) { @@ -2548,6 +2803,11 @@ public final class QuoteTemplateExcelSupport { || "其他".equals(exist) || "其它".equals(exist)) { return; } + // 跨境整车车型尺寸副表头(D8=4.1*2.1*2.3M):feeRegions 清样例时不得误清(WO014)。 + if (looksLikeVehicleDimensionText(exist) || rowLooksLikeVehicleDimensionRow(sheet, rowIdx) + || rowLooksLikeVehicleHeader(sheet, rowIdx) || rowLooksLikeLandRouteMatrixHeader(sheet, rowIdx)) { + return; + } cell.setBlank(); } @@ -3107,7 +3367,7 @@ public final class QuoteTemplateExcelSupport { * 国内段 / 境外段各写对应 TMS 腿的完整起讫。 *

* 优先用 selected_land_route.legs(销售已选线路);否则从多条备选里挑一条多腿线路。 - * 只有一对「境内→境外」且无两段腿时:国内段只写境内点、境外段只写境外点,不发明口岸。 + * 单腿门到门(境内→境外)时:两段都写完整起讫(产品口径 A,WO202610140014),不发明口岸。 * WO202610120001:备选混在 route_rows 时若要求整表同 segmentSequence,会退回切片导致口岸空。 */ static JSONArray routesForLandRouteSection(JSONArray routes, Sheet sheet, JSONObject region, @@ -3268,7 +3528,12 @@ public final class QuoteTemplateExcelSupport { String origin = firstNonBlank(row.getString("origin"), row.getString("originCode")); String dest = firstNonBlank(row.getString("destination"), row.getString("destinationCode")); JSONObject out = new JSONObject(true); - if ("domestic".equals(kind)) { + // 产品口径 A:单腿门到门(境内→境外)时国内段/境外段都写完整起讫,避免半边空白。 + boolean doorToDoor = looksDomesticPlace(origin) && looksOverseasPlace(dest); + if (doorToDoor && ("domestic".equals(kind) || "overseas".equals(kind))) { + out.put("origin", origin); + out.put("destination", dest); + } else if ("domestic".equals(kind)) { if (looksDomesticPlace(origin)) { out.put("origin", origin); } @@ -3989,13 +4254,46 @@ public final class QuoteTemplateExcelSupport { } } + /** + * 询价车型文案是否对上矩阵表头。 + *

+ * 20261012 后国内有「6.8/5T」、中港有「5米/5T」:裸写「5T」只能对上「5米/5T」类, + * 不能靠子串误命中「6.8/5T」(小数米长且无「米」字的短表头须带米长才命中)。 + */ private static boolean vehicleTypeEquals(String actual, String headerKey) { if (StringUtils.isBlank(actual) || StringUtils.isBlank(headerKey)) { return false; } - String a = actual.replace(" ", "").toUpperCase(Locale.ROOT); - String b = headerKey.replace(" ", "").toUpperCase(Locale.ROOT); - return a.equals(b) || a.contains(b) || b.contains(a); + String a = actual.replace(" ", "").toUpperCase(Locale.ROOT).replace("T", "T"); + String b = headerKey.replace(" ", "").toUpperCase(Locale.ROOT).replace("T", "T"); + if (a.equals(b) || a.contains(b) || b.contains(a)) { + return vehicleTypeContainsSafe(a, b); + } + return false; + } + + /** + * 子串命中后再做一次防误伤:裸吨位不要写进「6.8/5T」这种小数米长列。 + */ + private static boolean vehicleTypeContainsSafe(String actualNorm, String headerNorm) { + // 裸「5T」「3T」「12T」等 + if (actualNorm.matches("^\\d+(\\.\\d+)?T$")) { + java.util.regex.Matcher hm = java.util.regex.Pattern + .compile("^(\\d+(?:\\.\\d+)?)(米)?/") + .matcher(headerNorm); + if (hm.find()) { + String meter = hm.group(1); + boolean hasMi = hm.group(2) != null; + // 「6.8/5T」:小数米长且无「米」→ 必须实际文案带 6.8 + if (!hasMi && meter.contains(".")) { + return actualNorm.contains(meter); + } + } + // 经典「4.2米/3T」「5米/5T」:吨位段对上即可 + return headerNorm.contains("/" + actualNorm) || headerNorm.endsWith(actualNorm) + || headerNorm.equals(actualNorm); + } + return true; } private static JSONArray resolveRegionFeeRows(JSONObject facts, JSONObject region) { @@ -4317,12 +4615,17 @@ public final class QuoteTemplateExcelSupport { } String exist = readCellText(cell); String header = columnHeaderText(sheet, rowIdx, col); + // 车型尺寸副表头(4.1*2.1*2.3M)禁止覆盖;空值更不能清掉(WO014 3T 尺寸)。 + if (looksLikeVehicleDimensionText(exist)) { + return; + } // 蓝底/费用表头格禁止当数据写:跨境集拼「始发地」被写成「上海」会拆掉主题蓝条 if (isFeeTableHeader(exist) || isLandInquiryHeaderBandLabel(exist) || isLandRouteMatrixHeaderCell(exist) || isDeliveryPriceHeaderCell(exist) || isSectionTitle(sheet, cell) || rowLooksLikeLandRouteMatrixHeader(sheet, rowIdx) - || rowLooksLikeDeliveryPriceHeader(sheet, rowIdx)) { + || rowLooksLikeDeliveryPriceHeader(sheet, rowIdx) + || rowLooksLikeVehicleDimensionRow(sheet, rowIdx)) { return; } // 报关费、提货费、口岸、重抛比、当日达是模板印好的,空值或运价不能清掉。 @@ -5161,6 +5464,143 @@ public final class QuoteTemplateExcelSupport { return units >= 1 && !originDest; } + /** + * 车型尺寸格快照:出单前记下,写完再盖回,防止 3T 等尺寸副表头被误清。 + */ + static final class VehicleDimCell { + final int sheet; + final int row; + final int col; + final String text; + + VehicleDimCell(int sheet, int row, int col, String text) { + this.sheet = sheet; + this.row = row; + this.col = col; + this.text = text; + } + } + + static List snapshotLandVehicleDims(Workbook wb) { + List out = new ArrayList<>(); + if (wb == null) { + return out; + } + DataFormatter fmt = new DataFormatter(); + for (int s = 0; s < wb.getNumberOfSheets(); s++) { + Sheet sheet = wb.getSheetAt(s); + if (sheet == null) { + continue; + } + int last = Math.min(sheet.getLastRowNum(), 80); + for (int r = 0; r <= last; r++) { + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + short lastCol = row.getLastCellNum(); + for (int c = 0; c < lastCol; c++) { + Cell cell = row.getCell(c); + if (cell == null) { + continue; + } + String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)); + if (looksLikeVehicleDimensionText(text)) { + out.add(new VehicleDimCell(s, r, c, text)); + } + } + } + } + return out; + } + + static void restoreLandVehicleDims(Workbook wb, List dims) { + if (wb == null || dims == null || dims.isEmpty()) { + return; + } + int restored = 0; + for (VehicleDimCell dim : dims) { + if (dim == null || StringUtils.isBlank(dim.text) || dim.sheet < 0 || dim.sheet >= wb.getNumberOfSheets()) { + continue; + } + Sheet sheet = wb.getSheetAt(dim.sheet); + if (sheet == null) { + continue; + } + Row row = sheet.getRow(dim.row); + if (row == null) { + row = sheet.createRow(dim.row); + } + Cell cell = row.getCell(dim.col); + if (cell == null) { + cell = row.createCell(dim.col); + } + String now = StringUtils.trimToEmpty(readCellText(cell)); + if (dim.text.equals(now)) { + continue; + } + cell.setCellValue(dim.text); + restored++; + } + } + + /** + * 调试:出单各阶段 3T 尺寸是否仍在(定位谁清掉 D8)。 + */ + static Map peekDimStage(Workbook wb, JSONObject mapping, String stage) { + Map out = new LinkedHashMap<>(); + out.put("stage", stage); + if (wb == null) { + return out; + } + try { + Sheet dbgSheet = mapping != null && StringUtils.isNotBlank(mapping.getString("sheet")) + ? wb.getSheet(mapping.getString("sheet")) : null; + if (dbgSheet == null && wb.getNumberOfSheets() > 0) { + dbgSheet = wb.getSheetAt(0); + } + out.put("dimUnder3T", peekVehicleDimUnderHeader(dbgSheet, "3T")); + } catch (Exception ignored) { + out.put("error", "peek_failed"); + } + return out; + } + + /** + * 调试:表上「3T」表头正下方尺寸格文案(空=模板缺尺寸或已被清)。 + */ + static List> peekVehicleDimUnderHeader(Sheet sheet, String vehicleHeader) { + List> out = new ArrayList<>(); + if (sheet == null || StringUtils.isBlank(vehicleHeader)) { + return out; + } + DataFormatter fmt = new DataFormatter(); + String want = vehicleHeader.replaceAll("\\s+", "").toUpperCase(Locale.ROOT); + int last = Math.min(sheet.getLastRowNum(), 60); + for (int r = 0; r <= last; r++) { + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + short lastCol = row.getLastCellNum(); + for (int c = 0; c < lastCol; c++) { + String text = StringUtils.trimToEmpty(fmt.formatCellValue(row.getCell(c))).replaceAll("\\s+", ""); + if (!want.equals(text.toUpperCase(Locale.ROOT)) && !text.toUpperCase(Locale.ROOT).endsWith("/" + want)) { + continue; + } + Row below = sheet.getRow(r + 1); + String dim = below == null ? "" : StringUtils.trimToEmpty(fmt.formatCellValue(below.getCell(c))); + Map one = new LinkedHashMap<>(); + one.put("headerCell", new CellReference(r, c).formatAsString()); + one.put("header", text); + one.put("dimCell", new CellReference(r + 1, c).formatAsString()); + one.put("dim", dim); + out.add(one); + } + } + return out; + } + /** * 跨境整车车型尺寸副表头:如 4.1*2.1*2.3M / 12.8*2.32*2.7M。 * 运价必须写在下一行,不能盖掉尺寸说明(WO125 曾把 4000 写进 D8)。 @@ -5380,15 +5820,24 @@ public final class QuoteTemplateExcelSupport { } /** - * 空运 airOptions[].departureCharges → 费用行(出单用)。 + * 空运 airOptions 费用桶 → 费用行(出单用)。 *

* 已落库 payload 常把 airOptions 存成 JSON 字符串。 - * TMS 常把「空运费」只放在舱位 salePrice/unitPrice,departureCharges 只有增点费/拆板费; - * 若 firstNonNull 只取 departureCharges,出单会缺空运费,且模板样例「空运段|增点费」会残留。 - * 这里合并 departureCharges+feeItems,缺运费时用舱位价注入「空运费」,并保证项目列归类。 + * TMS 分桶:{@code feeItems}=空运段,{@code departureCharges}=起运港, + * {@code destinationCharges}=目的地;禁止再按费目名把起运「运输费」误归空运段。 + * 缺空运段运费时用舱位价注入「空运费」。 */ public static JSONArray parseAirDepartureChargeRows(Object airOptions, Object airOptionsAlt, String chargeableWeightKg, String headerCurrency) { + return parseAirDepartureChargeRows(airOptions, airOptionsAlt, null, chargeableWeightKg, headerCurrency); + } + + /** + * @param quoteFeeItems 报价顶层 feeItems(TMS 常把空运费放在整单而非舱位上);可空 + */ + public static JSONArray parseAirDepartureChargeRows(Object airOptions, Object airOptionsAlt, + Object quoteFeeItems, + String chargeableWeightKg, String headerCurrency) { JSONArray out = new JSONArray(); Object raw = airOptions != null ? airOptions : airOptionsAlt; JSONArray opts = parseFeeRows(raw); @@ -5401,7 +5850,13 @@ public final class QuoteTemplateExcelSupport { if (opt == null) { continue; } - // 合并起运附加费与 feeItems,禁止 firstNonNull 只吃一边丢掉空运费 + // 舱位缺 feeItems 时补整单顶层,保证空运段有桶可归 + if (quoteFeeItems != null + && parseFeeRows(opt.get("feeItems")).isEmpty() + && parseFeeRows(opt.get("fee_items")).isEmpty()) { + opt.put("feeItems", quoteFeeItems); + } + // 按 TMS 桶合并,并带上 project(空运段/起运港/目的地/其它) JSONArray charges = mergeAirOptionChargeArrays(opt); String optCcy = firstNonBlank(opt.getString("currency"), headerCurrency); String optCw = firstNonBlank(opt.getString("chargeableWeightKg"), opt.getString("chargeable_weight_kg"), @@ -5417,22 +5872,26 @@ public final class QuoteTemplateExcelSupport { if (StringUtils.isBlank(name) || StringUtils.isBlank(unitPrice)) { continue; } + String project = firstNonBlank(ch.getString("project"), airProjectForBucket(ch.getString("_airBucket"))); + String amountRaw = firstNonBlank(ch.getString("amount"), unitPrice); Map line = new LinkedHashMap<>(); line.put("name", name); - line.put("amount", unitPrice); + line.put("amount", amountRaw); line.put("unit_price", unitPrice); - // 空运模板「项目」列:运费归空运段,其它归起运港 - line.put("project", isAirWeightFeeName(name) ? "空运段" : "起运港"); + line.put("project", project); + if (StringUtils.isNotBlank(ch.getString("_airBucket"))) { + line.put("source_bucket", ch.getString("_airBucket")); + } String unit = firstNonBlank(ch.getString("unit"), ch.getString("unitName")); if (StringUtils.isNotBlank(unit)) { line.put("unit", unit); } - // 固定费无数量时默认 1;运输费留给 enrich 写计费重 + // 空运段按计费重;起运/目的/其它固定费无数量时默认 1 String qty = firstNonBlank(ch.getString("quantity"), ch.getString("chargeQuantity"), ch.getString("charge_quantity")); if (StringUtils.isNotBlank(qty)) { line.put("quantity", qty); - } else if (!isAirWeightFeeName(name)) { + } else if (!isAirSegmentFreightLine(line)) { line.put("quantity", "1"); } String ccy = firstNonBlank(ch.getString("currency"), optCcy); @@ -5441,30 +5900,28 @@ public final class QuoteTemplateExcelSupport { } lines.add(line); } - // departureCharges 只有附加费时,用舱位 salePrice/unitPrice 补「空运费」行 + // 无空运段运费时,用舱位 salePrice/unitPrice 补「空运费」行 injectAirFreightLineIfMissing(lines, opt, optCw, optCcy); if (!lines.isEmpty()) { break; } } enrichAirWeightFeeRows(lines, chargeableWeightKg); - // 空运段(运费)在前,起运港附加费在后,避免盖住模板首行样例「空运段」 - lines.sort((a, b) -> { - boolean aAir = a != null && isAirWeightFeeName(a.get("name")); - boolean bAir = b != null && isAirWeightFeeName(b.get("name")); - if (aAir == bAir) { - return 0; - } - return aAir ? -1 : 1; - }); + // 项目列顺序:空运段 → 起运港 → 目的地 → 其它 + lines.sort((a, b) -> Integer.compare(airProjectSortRank(a), airProjectSortRank(b))); for (Map line : lines) { - out.add(JSON.parseObject(JSON.toJSONString(line))); + JSONObject row = JSON.parseObject(JSON.toJSONString(line)); + // 内部桶标记不进 Excel 事实 + row.remove("source_bucket"); + out.add(row); } return out; } /** - * 合并舱位上的 departureCharges 与 feeItems(去重:同名只留先出现的)。 + * 按 TMS 桶合并舱位费用,并标注 project / _airBucket。 + * 顺序:feeItems(空运段)→ departureCharges(起运港)→ destinationCharges(目的地)→ 其它。 + * 同名去重:先出现的桶优先(空运段优先于起运港)。 * 副作用:无。线程:调用方线程。 */ static JSONArray mergeAirOptionChargeArrays(JSONObject opt) { @@ -5473,8 +5930,42 @@ public final class QuoteTemplateExcelSupport { return out; } Set seen = new HashSet<>(); - for (Object key : new Object[]{"departureCharges", "departure_charges", "charges", "feeItems", "fee_items"}) { - JSONArray part = parseFeeRows(opt.get(String.valueOf(key))); + appendAirBucketCharges(out, seen, opt, "空运段", "feeItems", "fee_items"); + appendAirBucketCharges(out, seen, opt, "起运港", + "departureCharges", "departure_charges", "charges"); + appendAirBucketCharges(out, seen, opt, "目的地", + "destinationCharges", "destination_charges", "arrivalCharges", "destCharges"); + // 未知桶:归「其它」(TMS 通常不给,模板骨架仍保留) + for (String key : opt.keySet()) { + if (key == null) { + continue; + } + String k = key.trim(); + if (isKnownAirFeeBucketKey(k)) { + continue; + } + if (!k.toLowerCase(Locale.ROOT).contains("charge") + && !k.toLowerCase(Locale.ROOT).contains("fee") + && !k.toLowerCase(Locale.ROOT).contains("cost")) { + continue; + } + // 标量/非列表跳过 + if (!(opt.get(k) instanceof JSONArray) && !(opt.get(k) instanceof List) + && !(opt.get(k) instanceof String && String.valueOf(opt.get(k)).trim().startsWith("["))) { + continue; + } + appendAirBucketCharges(out, seen, opt, "其它", k); + } + return out; + } + + private static void appendAirBucketCharges(JSONArray out, Set seen, JSONObject opt, + String project, String... keys) { + if (out == null || opt == null || keys == null) { + return; + } + for (String key : keys) { + JSONArray part = parseFeeRows(opt.get(key)); for (int i = 0; i < part.size(); i++) { JSONObject ch = part.getJSONObject(i); if (ch == null) { @@ -5488,15 +5979,169 @@ public final class QuoteTemplateExcelSupport { if (!seen.add(dedupe)) { continue; } - out.add(ch); + JSONObject copy = JSON.parseObject(JSON.toJSONString(ch)); + copy.put("project", project); + copy.put("_airBucket", key); + out.add(copy); + } + } + } + + static boolean isKnownAirFeeBucketKey(String key) { + if (key == null) { + return true; + } + String k = key.trim(); + return "feeItems".equals(k) || "fee_items".equals(k) + || "departureCharges".equals(k) || "departure_charges".equals(k) || "charges".equals(k) + || "destinationCharges".equals(k) || "destination_charges".equals(k) + || "arrivalCharges".equals(k) || "destCharges".equals(k); + } + + /** TMS 桶键 → 报价单「项目」列文案。 */ + static String airProjectForBucket(String bucketKey) { + if (StringUtils.isBlank(bucketKey)) { + return "其它"; + } + String k = bucketKey.trim(); + if ("feeItems".equals(k) || "fee_items".equals(k)) { + return "空运段"; + } + if ("departureCharges".equals(k) || "departure_charges".equals(k) || "charges".equals(k)) { + return "起运港"; + } + if ("destinationCharges".equals(k) || "destination_charges".equals(k) + || "arrivalCharges".equals(k) || "destCharges".equals(k)) { + return "目的地"; + } + return "其它"; + } + + /** + * 空运出单:分桶行优先,再并入 flat fee_lines 里多出来的人工调价行。 + *

+ * 航线「在 TMS 上改」时,群摘要读 fee_lines(含增值费),模板若只用 airOptions 分桶会整表丢掉人工项 + * (WO202610140006:增值费在 flat、不在 departureCharges → Excel 起运港缺行)。 + * 同名费目保留分桶(单位/项目更完整);仅追加分桶没有的名字。 + * 副作用:无。线程:调用方线程。 + */ + public static JSONArray mergeAirBucketRowsWithFlatManualFees(JSONArray fromAir, JSONArray flat) { + JSONArray out = new JSONArray(); + Set seen = new HashSet<>(); + if (fromAir != null) { + for (int i = 0; i < fromAir.size(); i++) { + JSONObject one = fromAir.getJSONObject(i); + if (one == null) { + continue; + } + JSONObject copy = JSON.parseObject(JSON.toJSONString(one)); + out.add(copy); + String name = firstNonBlank(copy.getString("name"), copy.getString("feeName"), + copy.getString("chargeName")); + if (StringUtils.isNotBlank(name)) { + seen.add(name.trim().toLowerCase(Locale.ROOT)); + } + } + } + if (flat != null) { + for (int i = 0; i < flat.size(); i++) { + JSONObject one = flat.getJSONObject(i); + if (one == null) { + continue; + } + String name = firstNonBlank(one.getString("name"), one.getString("feeName"), + one.getString("chargeName")); + if (StringUtils.isBlank(name)) { + continue; + } + String key = name.trim().toLowerCase(Locale.ROOT); + if (!seen.add(key)) { + continue; + } + JSONObject copy = JSON.parseObject(JSON.toJSONString(one)); + String project = normalizeAirProjectLabel( + firstNonBlank(copy.getString("project"), copy.getString("segment"))); + if (StringUtils.isBlank(project)) { + project = isAirWeightFeeName(name) ? "空运段" : "起运港"; + copy.put("project", project); + } else { + copy.put("project", project); + } + out.add(copy); } } return out; } /** - * 明细里没有运输/空运费时,用舱位 unitPrice×计费重或 salePrice 注入一行「空运费」。 + * flat 行缺 unit 时,从报价 raw_text 回填(「增值费,板,单价…」/「增值费_板」)。 + * 须在 enrichFixedAirAddonFeeRows 默认「票」之前调用,避免 WO006 这类旧 payload 丢单位。 + * 副作用:改 rows 内 unit。线程:调用方线程。 + */ + public static void backfillMissingFeeUnitsFromRawText(JSONArray rows, String rawText) { + if (rows == null || rows.isEmpty() || StringUtils.isBlank(rawText)) { + return; + } + for (int i = 0; i < rows.size(); i++) { + JSONObject one = rows.getJSONObject(i); + if (one == null || StringUtils.isNotBlank(one.getString("unit"))) { + continue; + } + String name = firstNonBlank(one.getString("name"), one.getString("feeName"), + one.getString("chargeName")); + if (StringUtils.isBlank(name)) { + continue; + } + java.util.regex.Pattern p = java.util.regex.Pattern.compile( + java.util.regex.Pattern.quote(name) + + "(?:[_\\s,,、/]+)(板数|板|票|KG|KGS|公斤|柜|点)", + java.util.regex.Pattern.CASE_INSENSITIVE); + java.util.regex.Matcher m = p.matcher(rawText); + if (m.find()) { + one.put("unit", m.group(1)); + } + } + } + + static int airProjectSortRank(Map line) { + if (line == null) { + return 99; + } + String p = StringUtils.trimToEmpty(line.get("project")); + if ("空运段".equals(p) || "空运".equals(p)) { + return 0; + } + if ("起运港".equals(p)) { + return 1; + } + if ("目的地".equals(p) || "目的港".equals(p)) { + return 2; + } + if ("其它".equals(p) || "其他".equals(p)) { + return 3; + } + return 4; + } + + /** + * 是否空运段运费行(按计费重计价)。只认 project=空运段,避免起运港「运输费」误乘计费重。 + */ + public static boolean isAirSegmentFreightLine(Map line) { + if (line == null) { + return false; + } + String project = StringUtils.trimToEmpty(line.get("project")); + if (StringUtils.isNotBlank(project) + && !"空运段".equals(project) && !"空运".equals(project)) { + return false; + } + return isAirWeightFeeName(line.get("name")); + } + + /** + * 明细里没有空运段运费时,用舱位 unitPrice×计费重或 salePrice 注入一行「空运费」。 * WO202610090014:departureCharges 只有增点/拆板,salePrice=4750.95、计费重=500.1。 + * 起运港桶里的「运输费」不算已有空运段,避免挡住注入又把金额算成单价。 */ static void injectAirFreightLineIfMissing(List> lines, JSONObject opt, String chargeableWeightKg, String currency) { @@ -5504,7 +6149,7 @@ public final class QuoteTemplateExcelSupport { return; } for (Map line : lines) { - if (line != null && isAirWeightFeeName(line.get("name"))) { + if (line != null && isAirSegmentFreightLine(line)) { return; } } @@ -5584,7 +6229,8 @@ public final class QuoteTemplateExcelSupport { } String unitDefault = normalizeWeightUnit(weightUnit); for (Map line : lines) { - if (line == null || !isAirWeightFeeName(line.get("name"))) { + // 只对「空运段」运费乘计费重;起运港「运输费」等按固定费走 + if (line == null || !isAirSegmentFreightLine(line)) { continue; } // 无 TMS / 手工报价常缺单位:先补单位,再按计费重或金额补数量/单价 @@ -5656,15 +6302,22 @@ public final class QuoteTemplateExcelSupport { return; } for (Map line : lines) { - if (line == null || isAirWeightFeeName(line.get("name"))) { + if (line == null || isAirSegmentFreightLine(line)) { continue; } String name = StringUtils.trimToEmpty(line.get("name")); if (name.isEmpty()) { continue; } - // 增点费等禁止留在「空运段」(手工/产品报价可能误带 project=空运段) - line.put("project", "起运港"); + // 保留 TMS 桶:目的地/其它;空或误标空运段的附加费归起运港 + String project = StringUtils.trimToEmpty(line.get("project")); + if ("目的地".equals(project) || "目的港".equals(project) + || "其它".equals(project) || "其他".equals(project) + || "起运港".equals(project)) { + // 已按桶归类,不覆盖 + } else { + line.put("project", "起运港"); + } if (StringUtils.isBlank(line.get("unit"))) { line.put("unit", "票"); } @@ -6714,6 +7367,10 @@ public final class QuoteTemplateExcelSupport { String text = value == null ? "" : value; // 标签格「报价编号:」只换冒号后的值,避免整格被工单号顶掉看起来像残缺 String existing = readCellText(cell); + // 车型尺寸副表头(3T 下 4.1*2.1*2.3M)与 writeFeeCol 同规则:禁止覆盖/清空。 + if (looksLikeVehicleDimensionText(existing) || rowLooksLikeVehicleDimensionRow(sheet, dest[0])) { + return; + } // 映射点常落在合并标签格 / 20GP 表头上,不能把原字盖掉 if (isFeeTableHeader(existing) || isLabelLike(existing) || isSectionTitle(sheet, cell) || isLandInquiryHeaderBandLabel(existing) || isLandRouteMatrixHeaderCell(existing) @@ -7141,7 +7798,8 @@ public final class QuoteTemplateExcelSupport { if (StringUtils.isBlank(val) || "-".equals(val)) { continue; } - // 运价矩阵的起始地/目的地按国内段、境外段拆开写,禁止同一对城市填进两张表。 + // 运价矩阵起讫:有 TMS 线路行时由 writeRouteRegions 分区写; + // 无线路行时允许国内段/境外段都写完整门到门起讫(产品口径 A)。 if (("origin".equals(code) || "destination".equals(code)) && rowLooksLikeLandRouteMatrixHeader(sheet, r)) { // 有 TMS 线路行时由 writeRouteRegions 分区写 OD;标签回填会用短地名盖掉「广东省-深圳市」。 @@ -7149,14 +7807,6 @@ public final class QuoteTemplateExcelSupport { || !parseFeeRows(facts.get("landSegments")).isEmpty()) { continue; } - String kind = landRouteSectionKindNearRow(sheet, r); - boolean skipDomDest = "domestic".equals(kind) && "destination".equals(code) - && looksOverseasPlace(val); - boolean skipOsOrigin = "overseas".equals(kind) && "origin".equals(code) - && looksDomesticPlace(val); - if (skipDomDest || skipOsOrigin) { - continue; - } } String fillRef = resolveFillTarget(sheet, cell, sheetName, text, fmt); if (StringUtils.isBlank(fillRef)) { 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 9fa55aa..8fe90bb 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 @@ -32,7 +32,7 @@ import java.util.Map; * 有真实 TMS 编号才带 quoteId。有值才出站,缺航班不拦。 * SYSTEM 出站精简(quoteId + 航班等标量,不带 validUntil/feeItems); * MANUAL 费用名按 TMS 查价回包原样带回,有效期必须带时区 ISO8601。 - * 已成交再释放:本地拦住。测服打开 TMS_V2_LOCK_ENABLED;现网验收期保持关闭。 + * 只有已成交才能锁舱。成交并锁上之后仍可释放。测服打开 TMS_V2_LOCK_ENABLED;现网验收期保持关闭。 * 禁止:按 userid 伪造报价;单测打真实 TMS。 */ @Slf4j @@ -55,6 +55,27 @@ public class InquiryTmsCabinService { /** 多段询价锁舱的产品口径。智能体群回复与主账拒绝用同一句。 */ public static final String MULTI_LOCK_UNSUPPORTED = "多段询价,暂时不支持锁舱"; + /** + * 锁舱成交门禁。已成交返回空串,表示可以继续拼锁舱正文。 + * 协商中、未成交、已关闭/转人工、以及还没回复成交,各自一个错误码。 + */ + static String lockDealGate(String status) { + String st = StringUtils.trimToEmpty(status); + if (TicketStatuses.DEAL.equals(st)) { + return ""; + } + if (TicketStatuses.NEGOTIATING.equals(st)) { + return "lock_negotiating"; + } + if (TicketStatuses.LOST.equals(st)) { + return "lock_lost"; + } + if (TicketStatuses.CLOSED.equals(st) || TicketStatuses.HANDOFF.equals(st)) { + return "ticket_closed"; + } + return "lock_need_deal"; + } + /** * 还没到 TMS 之前的拒绝原因。空运单返回空,表示可以继续锁。 * 多段整单不是 AIR,但航线会按空运段来锁,单独说明暂不支持。 @@ -86,9 +107,18 @@ public class InquiryTmsCabinService { String code = MULTI_LOCK_UNSUPPORTED.equals(lockReject) ? "multi_no_lock" : "not_air"; return fail(code, lockReject); } - if (TicketStatuses.DEAL.equals(ticket.getStatus()) - || TicketStatuses.CLOSED.equals(ticket.getStatus())) { - return fail("ticket_closed", "工单已是「" + ticket.getStatus() + "」,不能锁舱"); + String dealGate = lockDealGate(ticket.getStatus()); + if ("lock_negotiating".equals(dealGate)) { + return fail(dealGate, "工单还在协商中,成交后才能锁舱"); + } + if ("lock_lost".equals(dealGate)) { + return fail(dealGate, "工单已是「未成交」,不能锁舱"); + } + if ("ticket_closed".equals(dealGate)) { + return fail(dealGate, "工单已是「" + ticket.getStatus() + "」,不能锁舱"); + } + if ("lock_need_deal".equals(dealGate)) { + return fail(dealGate, "请销售先确认工单「已成交」"); } List rows = quoteRows(no); if (rows.isEmpty()) { @@ -279,7 +309,7 @@ public class InquiryTmsCabinService { } /** - * 智能体释放入口。已成交不调 TMS。 + * 智能体释放入口。已成交仍可取消已锁舱位。没有成功锁舱记录则不调 TMS。 */ public Map releaseCabin(Map body) { String no = text(body, "workOrderNo", "work_order_no"); @@ -288,9 +318,6 @@ public class InquiryTmsCabinService { if (ticket == null) { return fail("ticket_not_found", "找不到工单"); } - if (TicketStatuses.DEAL.equals(ticket.getStatus())) { - return fail("deal_blocked", "工单已成交"); - } if (StringUtils.isBlank(lockId)) { return fail("release_need_lock", "本单还没有成功锁舱记录"); } @@ -1442,6 +1469,10 @@ public class InquiryTmsCabinService { return out; } + /** + * 调试会话 3ba460:手工报价锁舱死循环取证。写 NDJSON,不含密钥。 + */ + private static String text(Map body, String... keys) { if (body == null || keys == null) { return ""; diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java index 8f265d1..e90f9ed 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java @@ -786,8 +786,9 @@ public class InquiryTmsQueryService { } /** - * 空运舱位选项上的 departureCharges → 扁平费用行。 - * TMS 字段:chargeName / unitPrice / unit / currency;行金额出单时再按计费重补。 + * 空运舱位分桶费用 → 扁平费用行。 + * TMS:feeItems=空运段,departureCharges=起运港,destinationCharges=目的地; + * 行金额出单时再按计费重补(仅空运段)。 */ @SuppressWarnings("unchecked") static List> extractAirDepartureCharges(Map data, String headerCurrency) { @@ -806,15 +807,21 @@ public class InquiryTmsQueryService { if (!(raw instanceof List)) { return out; } + Object topFeeItems = first(data.get("feeItems"), data.get("fee_items")); for (Object optObj : (List) raw) { if (!(optObj instanceof Map)) { continue; } - Map opt = (Map) optObj; - // 合并 departureCharges + feeItems,避免只取附加费丢掉空运费 - List> charges = mergeAirOptionChargeMaps(opt); + Map opt = new LinkedHashMap<>((Map) optObj); + // 舱位缺 feeItems 时补整单顶层(TMS 常把空运费放在 data.feeItems) + if (topFeeItems instanceof List + && !(opt.get("feeItems") instanceof List) + && !(opt.get("fee_items") instanceof List)) { + opt.put("feeItems", topFeeItems); + } + List> charges = mergeAirOptionChargeMaps(opt); String optCcy = firstText(opt.get("currency"), headerCurrency); - for (Map ch : charges) { + for (Map ch : charges) { if (ch == null) { continue; } @@ -823,18 +830,24 @@ public class InquiryTmsQueryService { if (StringUtils.isBlank(name) || StringUtils.isBlank(unitPrice)) { continue; } + String project = firstText(ch.get("project"), "其它"); + String amountRaw = firstText(ch.get("amount"), unitPrice); String lineCcy = firstText(ch.get("currency"), optCcy, headerCurrency); Map extras = new LinkedHashMap<>(); putExtra(extras, "unit", firstText(ch.get("unit"), ch.get("unitName"))); putExtra(extras, "unit_price", unitPrice); - // 固定费无数量时默认 1;运输费 enrich 会改成计费重 + putExtra(extras, "project", project); String qty = firstText(ch.get("quantity"), ch.get("chargeQuantity"), ch.get("charge_quantity")); - putExtra(extras, "quantity", StringUtils.isBlank(qty) ? "1" : qty); - putExtra(extras, "project", isAirWeightFeeName(name) ? "空运段" : "起运港"); - // 先把单价写入 amount;运输费会在 enrich 里改成 单价×计费重 - addFeeLine(out, name, unitPrice, lineCcy, extras); + boolean airSeg = "空运段".equals(project) && isAirWeightFeeName(name); + if (StringUtils.isNotBlank(qty)) { + putExtra(extras, "quantity", qty); + } else if (!airSeg) { + putExtra(extras, "quantity", "1"); + } + // 空运段先写金额原文(或单价);enrich 再按计费重重算 + addFeeLine(out, name, amountRaw, lineCcy, extras); } - // departureCharges 只有附加费时,用舱位 salePrice 补空运费(与出单 parse 对齐) + // 无空运段运费时,用舱位 salePrice 补空运费(与出单 parse 对齐) injectAirFreightFeeLineIfMissing(out, opt, headerCurrency); // 通常只用首个舱位选项的费用明细 if (!out.isEmpty()) { @@ -844,15 +857,31 @@ public class InquiryTmsQueryService { return out; } - /** 合并舱位 departureCharges / feeItems,同名去重。 */ + /** + * 按 TMS 桶合并:feeItems→空运段,departureCharges→起运港,destinationCharges→目的地。 + * 同名去重,先出现的桶优先。 + */ @SuppressWarnings("unchecked") - static List> mergeAirOptionChargeMaps(Map opt) { - List> out = new ArrayList<>(); + static List> mergeAirOptionChargeMaps(Map opt) { + List> out = new ArrayList<>(); if (opt == null) { return out; } Set seen = new HashSet<>(); - for (Object key : new Object[]{"departureCharges", "departure_charges", "charges", "feeItems", "fee_items"}) { + appendAirBucketMaps(out, seen, opt, "空运段", "feeItems", "fee_items"); + appendAirBucketMaps(out, seen, opt, "起运港", "departureCharges", "departure_charges", "charges"); + appendAirBucketMaps(out, seen, opt, "目的地", + "destinationCharges", "destination_charges", "arrivalCharges", "destCharges"); + return out; + } + + @SuppressWarnings("unchecked") + private static void appendAirBucketMaps(List> out, Set seen, + Map opt, String project, String... keys) { + if (out == null || opt == null || keys == null) { + return; + } + for (String key : keys) { Object raw = opt.get(key); if (!(raw instanceof List)) { continue; @@ -870,22 +899,34 @@ public class InquiryTmsQueryService { if (!seen.add(dedupe)) { continue; } - out.add(ch); + Map copy = new LinkedHashMap<>(); + for (Map.Entry e : ch.entrySet()) { + if (e.getKey() != null) { + copy.put(String.valueOf(e.getKey()), e.getValue()); + } + } + copy.put("project", project); + out.add(copy); } } - return out; } /** - * 查价投影 fee_lines:明细无运输/空运费时,用舱位 salePrice/unitPrice 注入。 + * 查价投影 fee_lines:明细无空运段运费时,用舱位 salePrice/unitPrice 注入。 * 与 QuoteTemplateExcelSupport.injectAirFreightLineIfMissing 口径一致。 + * 起运港「运输费」不算已有空运段。 */ static void injectAirFreightFeeLineIfMissing(List> lines, Map opt, String headerCurrency) { if (lines == null || opt == null) { return; } for (Map line : lines) { - if (line != null && isAirWeightFeeName(line.get("name"))) { + if (line == null) { + continue; + } + String project = StringUtils.trimToEmpty(line.get("project")); + if ((project.isEmpty() || "空运段".equals(project) || "空运".equals(project)) + && isAirWeightFeeName(line.get("name"))) { return; } } @@ -950,16 +991,22 @@ public class InquiryTmsQueryService { if (line == null || !isAirWeightFeeName(line.get("name"))) { continue; } + // 已标明非空运段(起运港「运输费」等)禁止乘计费重 + String project = StringUtils.trimToEmpty(line.get("project")); + if (StringUtils.isNotBlank(project) + && !"空运段".equals(project) && !"空运".equals(project)) { + continue; + } String unitPrice = firstText(line.get("unit_price"), line.get("unitPrice")); String qty = firstText(line.get("quantity")); String amount = firstText(line.get("amount")); - String unit = firstText(line.get("unit")); if (StringUtils.isBlank(qty)) { line.put("quantity", chargeable); qty = chargeable; } // TMS 常给单位「板数」;按计费重计价时统一写成 KG line.put("unit", "KG"); + line.put("project", "空运段"); // 有数量、无单价:当前 amount 当作单价(TMS 把费率塞进 salePrice/amount) if (StringUtils.isBlank(unitPrice) && StringUtils.isNotBlank(amount)) { line.put("unit_price", amount); diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/domain/AirGroupActivateRuleTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/domain/AirGroupActivateRuleTest.java index 819b029..2c8a375 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/domain/AirGroupActivateRuleTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/domain/AirGroupActivateRuleTest.java @@ -26,6 +26,9 @@ public class AirGroupActivateRuleTest { public void dealRejected() { InquiryTicket ticket = ticket("AIR", TicketStatuses.DEAL, "wr_air_1"); assertEquals("terminal", AirGroupActivateRule.rejectReason(ticket, "wr_air_1")); + assertEquals("", AirGroupActivateRule.rejectReason(ticket, "wr_air_1", true)); + InquiryTicket lost = ticket("AIR", TicketStatuses.LOST, ""); + assertEquals("terminal", AirGroupActivateRule.rejectReason(lost, "wr_air_1", true)); } @Test diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java index 9a254e2..7e41a68 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 @@ -1,5 +1,6 @@ package org.jeecg.modules.inquiry.template; +import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import org.apache.poi.ss.usermodel.DataFormatter; @@ -920,6 +921,180 @@ public class QuoteTemplateFillTest { assertEquals("起运港", rows.getJSONObject(2).getString("project")); } + /** + * TMS 分桶:feeItems→空运段,departureCharges→起运港(含名称像「运输费」的), + * destinationCharges→目的地;起运「运输费」不得乘计费重把金额算成单价。 + */ + @Test + public void parseAirChargesAssignProjectByTmsBucket() { + System.setProperty("ytd.agent.debug.log", + java.nio.file.Paths.get("").toAbsolutePath().getParent() != null + ? java.nio.file.Paths.get("debug-4c500b.log").toAbsolutePath().toString() + : "debug-4c500b.log"); + System.setProperty("ytd.agent.debug.runId", "pre-fix-bucket"); + String airOptions = "[" + + "{\"optionNo\":\"AIR-OPT01\",\"salePrice\":183370.0,\"currency\":\"CNY\"," + + "\"chargeableWeightKg\":16670.0," + + "\"feeItems\":[{\"feeCode\":\"AIR_FREIGHT\",\"feeName\":\"空运费\"," + + "\"unitPrice\":11.0,\"quantity\":16670.0,\"amount\":183370.0,\"unit\":\"KG\",\"currency\":\"CNY\"}]," + + "\"departureCharges\":[" + + "{\"chargeName\":\"运输费\",\"unitPrice\":11.0,\"unit\":\"板数\",\"currency\":\"CNY\"}," + + "{\"chargeName\":\"提送货费\",\"unitPrice\":6.0,\"quantity\":1,\"unit\":\"板数\",\"currency\":\"CNY\"}," + + "{\"chargeName\":\"人工费\",\"unitPrice\":123.0,\"quantity\":1,\"unit\":\"柜\",\"currency\":\"CNY\"}" + + "]," + + "\"destinationCharges\":[" + + "{\"chargeName\":\"目的港操作费\",\"unitPrice\":50.0,\"quantity\":1,\"unit\":\"票\",\"currency\":\"CNY\"}" + + "]}]"; + JSONArray rows = QuoteTemplateExcelSupport.parseAirDepartureChargeRows( + airOptions, null, "16670", "CNY"); + assertEquals(5, rows.size()); + // WO006:flat 有增值费、分桶没有 → 合并后必须进起运港,不能被整表替换丢掉 + JSONArray flat = JSON.parseArray( + "[{\"name\":\"运输费\",\"amount\":\"11\"}," + + "{\"name\":\"提送货费\",\"amount\":\"6\"}," + + "{\"name\":\"人工费\",\"amount\":\"123\"}," + + "{\"name\":\"增值费\",\"amount\":\"1000\",\"unit\":\"板\",\"quantity\":\"100\"," + + "\"unit_price\":\"10\",\"project\":\"起运港\"}]"); + JSONArray merged = QuoteTemplateExcelSupport.mergeAirBucketRowsWithFlatManualFees(rows, flat); + boolean hasZz = false; + for (int i = 0; i < merged.size(); i++) { + JSONObject one = merged.getJSONObject(i); + if (one != null && "增值费".equals(one.getString("name"))) { + hasZz = true; + assertEquals("起运港", one.getString("project")); + assertEquals("1000", one.getString("amount")); + assertEquals("板", one.getString("unit")); + } + } + assertTrue("人工增值费必须并进分桶结果", hasZz); + assertEquals("空运费", rows.getJSONObject(0).getString("name")); + assertEquals("空运段", rows.getJSONObject(0).getString("project")); + assertTrue(rows.getJSONObject(0).getString("amount").startsWith("183370")); + assertEquals("运输费", rows.getJSONObject(1).getString("name")); + assertEquals("起运港", rows.getJSONObject(1).getString("project")); + assertEquals("1", rows.getJSONObject(1).getString("quantity")); + assertTrue(rows.getJSONObject(1).getString("amount").startsWith("11")); + assertEquals("提送货费", rows.getJSONObject(2).getString("name")); + assertEquals("起运港", rows.getJSONObject(2).getString("project")); + assertEquals("人工费", rows.getJSONObject(3).getString("name")); + assertEquals("起运港", rows.getJSONObject(3).getString("project")); + assertEquals("目的港操作费", rows.getJSONObject(4).getString("name")); + assertEquals("目的地", rows.getJSONObject(4).getString("project")); + } + + /** + * 顶层 feeItems + 舱位 departureCharges:空运段来自整单 feeItems,起运港来自舱位。 + */ + @Test + public void parseAirChargesUsesTopLevelFeeItemsAsAirSegment() { + String airOptions = "[" + + "{\"optionNo\":\"AIR-OPT01\",\"salePrice\":183370.0,\"currency\":\"CNY\"," + + "\"chargeableWeightKg\":16670.0," + + "\"departureCharges\":[" + + "{\"chargeName\":\"运输费\",\"unitPrice\":11.0,\"unit\":\"板数\",\"currency\":\"CNY\"}," + + "{\"chargeName\":\"提送货费\",\"unitPrice\":6.0,\"unit\":\"板数\",\"currency\":\"CNY\"}," + + "{\"chargeName\":\"人工费\",\"unitPrice\":123.0,\"unit\":\"柜\",\"currency\":\"CNY\"}" + + "]}]"; + String topFees = "[{\"feeCode\":\"AIR_FREIGHT\",\"feeName\":\"空运费\"," + + "\"unitPrice\":11.0,\"quantity\":16670.0,\"amount\":183370.0,\"unit\":\"KG\",\"currency\":\"CNY\"}]"; + JSONArray rows = QuoteTemplateExcelSupport.parseAirDepartureChargeRows( + airOptions, null, topFees, "16670", "CNY"); + assertEquals("空运费", rows.getJSONObject(0).getString("name")); + assertEquals("空运段", rows.getJSONObject(0).getString("project")); + assertEquals("运输费", rows.getJSONObject(1).getString("name")); + assertEquals("起运港", rows.getJSONObject(1).getString("project")); + // 起运「运输费」不得再被当成空运段去乘计费重 + assertFalse("16670".equals(rows.getJSONObject(1).getString("quantity")) + || "16670.0".equals(rows.getJSONObject(1).getString("quantity"))); + } + + /** + * 人工只报起运港费:必须落到模板「起运港」段,禁止占掉「空运段」首行并把骨架改成起运港。 + */ + @Test + public void airManualOriginFeeWritesIntoOriginBandNotAirSegment() throws Exception { + System.setProperty("ytd.agent.debug.log", + new java.io.File("debug-4c500b.log").getAbsolutePath()); + System.setProperty("ytd.agent.debug.runId", "post-fix-place"); + Path src = Files.createTempFile("quote-air-band-src", ".xlsx"); + Path out = Files.createTempFile("quote-air-band-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(3).setCellValue("币别"); + head.createCell(4).setCellValue("单价"); + head.createCell(5).setCellValue("数量"); + head.createCell(6).setCellValue("金额"); + org.apache.poi.ss.usermodel.Row r1 = sheet.createRow(16); + r1.createCell(0).setCellValue("空运段"); + r1.createCell(1).setCellValue("样例空运费"); + r1.createCell(6).setCellValue(1.0); + org.apache.poi.ss.usermodel.Row r2 = sheet.createRow(17); + r2.createCell(0).setCellValue("起运港"); + r2.createCell(1).setCellValue("样例增点"); + r2.createCell(6).setCellValue(1.0); + org.apache.poi.ss.usermodel.Row r3 = sheet.createRow(18); + r3.createCell(0).setCellValue("起运港"); + r3.createCell(1).setCellValue("样例2"); + org.apache.poi.ss.usermodel.Row r4 = sheet.createRow(19); + r4.createCell(0).setCellValue("其他"); + org.apache.poi.ss.usermodel.Row total = sheet.createRow(22); + total.createCell(0).setCellValue("预估费用"); + total.createCell(6).setCellFormula("SUM(G17:G22)"); + 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("预估费用")); + JSONObject cols = new JSONObject(true); + cols.put("project", "A"); + cols.put("name", "B"); + cols.put("unit", "C"); + cols.put("currency", "D"); + 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", "1000"); + facts.put("currency", "CNY"); + JSONArray feeRows = new JSONArray(); + JSONObject one = new JSONObject(true); + one.put("project", "起运港"); + one.put("name", "增值费"); + one.put("unit", "板"); + one.put("unit_price", "10"); + one.put("quantity", "100"); + one.put("amount", "1000"); + one.put("currency", "CNY"); + 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(); + // 空运段骨架保留,样例费目清掉 + assertEquals("空运段", fmt.formatCellValue(sheet.getRow(16).getCell(0))); + assertTrue(StringUtils.isBlank(fmt.formatCellValue(sheet.getRow(16).getCell(1)))); + // 增值费落在起运港段,不在首行 + assertEquals("起运港", fmt.formatCellValue(sheet.getRow(17).getCell(0))); + assertEquals("增值费", fmt.formatCellValue(sheet.getRow(17).getCell(1))); + assertEquals("1000", fmt.formatCellValue(sheet.getRow(17).getCell(6))); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + /** * 映射未标 project 列时,表头「项目」仍要写入,增点费不能留在模板样例「空运段」。 */ @@ -2028,11 +2203,12 @@ public class QuoteTemplateFillTest { Sheet sheet = filled.getSheetAt(0); assertEquals("4.1*2.1*2.3M", read(sheet, 8, 3)); assertEquals("广东省-深圳市", read(sheet, 9, 1)); - assertEquals("", read(sheet, 9, 2)); + // 产品口径 A:单腿门到门两段都写完整起讫 + assertEquals("谅山-春疆货场", read(sheet, 9, 2)); assertEquals("4000", normalizeMoney(read(sheet, 9, 3))); assertEquals("", read(sheet, 9, 4)); assertEquals("2.8x1.5x1.6M", read(sheet, 27, 3)); - assertEquals("", read(sheet, 28, 1)); + assertEquals("广东省-深圳市", read(sheet, 28, 1)); assertEquals("谅山-春疆货场", read(sheet, 28, 2)); assertEquals("", read(sheet, 28, 3)); assertEquals("", read(sheet, 28, 4)); @@ -2079,7 +2255,96 @@ public class QuoteTemplateFillTest { } /** - * 跨境整车国内段只写境内起点,境外段只写境外终点,不能两段都写成郑州→吉隆坡。 + * WO202610140014 复现:单腿广西→胡志明;模板 3T 有尺寸 vs 无尺寸。 + * 用于对照 debug-67d875:切片是否把目的地/起始地留空,以及填表是否清掉 3T 尺寸。 + */ + @Test + public void landCrossBorderFtlWo014OdAnd3tDimProbe() throws Exception { + System.setProperty("ytd.agent.debug.runId", "wo014-junit"); + Path src = Files.createTempFile("quote-land-wo014-src", ".xlsx"); + Path out = Files.createTempFile("quote-land-wo014-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("跨境整车"); + row(sheet, 5).createCell(0).setCellValue("一、国内段报价"); + org.apache.poi.ss.usermodel.Row head1 = row(sheet, 7); + head1.createCell(1).setCellValue("起始地"); + head1.createCell(2).setCellValue("目的地"); + head1.createCell(3).setCellValue("3T"); + head1.createCell(4).setCellValue("5T"); + org.apache.poi.ss.usermodel.Row dim1 = row(sheet, 8); + // 有尺寸:应保留;同时留一列空尺寸模拟「模板缺 3T 尺寸」时对照 + dim1.createCell(3).setCellValue("4.1*2.1*2.3M"); + dim1.createCell(4).setCellValue("6.5*2.4*2.5M"); + row(sheet, 24).createCell(0).setCellValue("二、境外报价"); + org.apache.poi.ss.usermodel.Row head2 = row(sheet, 26); + head2.createCell(1).setCellValue("起始地"); + head2.createCell(2).setCellValue("目的地"); + head2.createCell(3).setCellValue("3.5T"); + org.apache.poi.ss.usermodel.Row dim2 = row(sheet, 27); + dim2.createCell(3).setCellValue("4.7x1.8x1.8M"); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject mapping = new JSONObject(true); + mapping.put("sheet", "跨境整车"); + JSONArray regions = new JSONArray(); + JSONObject inland = new JSONObject(true); + inland.put("title", "国内段报价"); + inland.put("headerRow", 8); + inland.put("dataStartRow", 9); + JSONObject cols1 = new JSONObject(true); + cols1.put("origin", "B"); + cols1.put("destination", "C"); + JSONObject rates1 = new JSONObject(true); + rates1.put("3T", "D"); + rates1.put("5T", "E"); + cols1.put("vehicleRates", rates1); + inland.put("columns", cols1); + regions.add(inland); + JSONObject overseas = new JSONObject(true); + overseas.put("title", "境外报价"); + overseas.put("headerRow", 27); + overseas.put("dataStartRow", 28); + JSONObject cols2 = new JSONObject(true); + cols2.put("origin", "B"); + cols2.put("destination", "C"); + JSONObject rates2 = new JSONObject(true); + rates2.put("3.5T", "D"); + cols2.put("vehicleRates", rates2); + overseas.put("columns", cols2); + regions.add(overseas); + mapping.put("routeRegions", regions); + JSONObject facts = new JSONObject(true); + facts.put("origin", "广西壮族自治区"); + facts.put("destination", "胡志明"); + facts.put("currency", "CNY"); + facts.put("route_rows", + "[{\"origin\":\"广西壮族自治区\",\"destination\":\"胡志明\"," + + "\"vehicleType\":\"3T\",\"amount\":\"12500.00\",\"currency\":\"CNY\"," + + "\"segmentSequence\":\"1\",\"routeLegSequence\":\"1\"}]"); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = filled.getSheetAt(0); + // 有尺寸时应保留(排除「填表清掉尺寸」) + // 3T/5T 尺寸副表头必须保留(后台模板有值,出单不能清掉) + assertEquals("4.1*2.1*2.3M", read(sheet, 8, 3)); + assertEquals("6.5*2.4*2.5M", read(sheet, 8, 4)); + assertEquals("广西壮族自治区", read(sheet, 9, 1)); + // 产品口径 A:国内段/境外段都写完整广西→胡志明 + assertEquals("胡志明", read(sheet, 9, 2)); + assertEquals("12500", normalizeMoney(read(sheet, 9, 3))); + assertEquals("广西壮族自治区", read(sheet, 28, 1)); + assertEquals("胡志明", read(sheet, 28, 2)); + assertEquals("4.7x1.8x1.8M", read(sheet, 27, 3)); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + + /** + * 跨境整车单腿门到门:国内段与境外段都写完整起讫(产品口径 A)。 */ @Test public void landCrossBorderFtlSplitsDomesticAndOverseasOd() throws Exception { @@ -2143,9 +2408,9 @@ public class QuoteTemplateFillTest { Sheet sheet = filled.getSheetAt(0); assertEquals("起始地", read(sheet, 7, 1)); assertEquals("郑州", read(sheet, 8, 1)); - assertEquals("", read(sheet, 8, 2)); + assertEquals("吉隆坡", read(sheet, 8, 2)); assertEquals("起始地", read(sheet, 26, 1)); - assertEquals("", read(sheet, 27, 1)); + assertEquals("郑州", read(sheet, 27, 1)); assertEquals("吉隆坡", read(sheet, 27, 2)); assertEquals("N+3", read(sheet, 8, 15)); assertEquals("CNY", read(sheet, 8, 16)); @@ -2369,9 +2634,9 @@ public class QuoteTemplateFillTest { try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { Sheet sheet = filled.getSheetAt(0); assertEquals("起始地", read(sheet, 6, 1)); - // 国内段只写境内起点;春疆货场是境外终点,不能被「运输费」盖掉起点 + // 产品口径 A:单腿门到门两段都写完整起讫;运输费不能盖掉起点 assertEquals("深圳", read(sheet, 8, 1)); - assertEquals("", read(sheet, 8, 2)); + assertEquals("春疆货场", read(sheet, 8, 2)); assertEquals("N+1", read(sheet, 8, 13)); assertEquals("CNY", read(sheet, 8, 14)); assertEquals("8%", read(sheet, 8, 15)); @@ -2380,8 +2645,7 @@ public class QuoteTemplateFillTest { assertFalse("运输费".equals(read(sheet, 8, 1))); assertEquals("币种", read(sheet, 6, 14)); assertEquals("燃油附加费率", read(sheet, 5, 15)); - // 境外段只写境外终点 - assertEquals("", read(sheet, 27, 1)); + assertEquals("深圳", read(sheet, 27, 1)); assertEquals("春疆货场", read(sheet, 27, 2)); } finally { Files.deleteIfExists(src); 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 de459cb..6502745 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 @@ -15,6 +15,17 @@ import static org.junit.jupiter.api.Assertions.assertNull; */ public class InquiryTmsCabinServiceTest { + @Test + public void lockOnlyAfterDeal() { + assertEquals("", InquiryTmsCabinService.lockDealGate("已成交")); + assertEquals("lock_need_deal", InquiryTmsCabinService.lockDealGate("已报价")); + assertEquals("lock_need_deal", InquiryTmsCabinService.lockDealGate("询价中")); + assertEquals("lock_negotiating", InquiryTmsCabinService.lockDealGate("协商中")); + assertEquals("lock_lost", InquiryTmsCabinService.lockDealGate("未成交")); + assertEquals("ticket_closed", InquiryTmsCabinService.lockDealGate("已关闭")); + assertEquals("ticket_closed", InquiryTmsCabinService.lockDealGate("转人工")); + } + @Test public void multiInquiryCannotLockCabin() { assertEquals( diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java index ce46887..08ac79d 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java @@ -366,14 +366,19 @@ public class TmsQuoteCurrencyTest { tms.put("tmsData", data); Map quote = InquiryTmsQueryService.toAgentQuote(tms); String fees = quote.get("fee_lines"); + // 起运港桶保留运输费/提送货/人工;空运段由 salePrice 注入空运费 assertTrue(fees.contains("运输费")); assertTrue(fees.contains("提送货费")); assertTrue(fees.contains("人工费")); - assertTrue(fees.contains("\"amount\":\"183370\"")); + assertTrue(fees.contains("空运费")); + assertTrue(fees.contains("\"project\":\"空运段\"")); + assertTrue(fees.contains("\"project\":\"起运港\"")); + assertTrue(fees.contains("\"amount\":\"183370\"") || fees.contains("\"amount\":\"183370.0\"")); assertTrue(fees.contains("\"amount\":\"6.0\"") || fees.contains("\"amount\":\"6\"")); assertTrue(fees.contains("\"amount\":\"123.0\"") || fees.contains("\"amount\":\"123\"")); assertTrue(fees.contains("\"unit\":\"KG\"")); - assertTrue(fees.contains("\"quantity\":\"16670.0000\"")); + assertTrue(fees.contains("\"quantity\":\"16670\"") || fees.contains("\"quantity\":\"16670.0\"") + || fees.contains("\"quantity\":\"16670.0000\"")); } @Test diff --git a/inquiry-backend/src/views/inquiry/account/account.api.ts b/inquiry-backend/src/views/inquiry/account/account.api.ts index 8c1add3..e500d7d 100644 --- a/inquiry-backend/src/views/inquiry/account/account.api.ts +++ b/inquiry-backend/src/views/inquiry/account/account.api.ts @@ -24,9 +24,10 @@ export { /** * 按当前筛选导出账号 Excel(Jeecg /sys/user/exportXls,与列表 QueryGenerator 同源)。 + * 文件名兜底用中文:Jeecg AutoPoi 的 Content-Disposition 常把「用户列表」编成乱码。 */ export const exportAccounts = (params: Record) => - downloadAuthGet(getExportUrl, params, '账号列表.xlsx'); + downloadAuthGet(getExportUrl, params, '账号列表.xls'); export type AccountRow = { id: string; diff --git a/inquiry-backend/src/views/inquiry/shared/inquiryAuthDownload.ts b/inquiry-backend/src/views/inquiry/shared/inquiryAuthDownload.ts index f736298..a43b3af 100644 --- a/inquiry-backend/src/views/inquiry/shared/inquiryAuthDownload.ts +++ b/inquiry-backend/src/views/inquiry/shared/inquiryAuthDownload.ts @@ -1,17 +1,87 @@ /** * 带鉴权下载二进制文件(Excel 等)。 * 为何:window.open 带 token 遇 JSON 错误页难提示;统一走 fetch + blob。 + * 文件名:优先 RFC5987 filename*=UTF-8'';Jeecg AutoPoi 常把 UTF-8 字节塞进 filename=, + * 浏览器会显示成「ç…」乱码,需 latin1→utf8 还原,还原失败则用 fallback。 */ import { useGlobSetting } from '/@/hooks/setting'; import { getToken } from '/@/utils/auth'; export type AuthDownloadParams = Record; +/** + * 从 Content-Disposition 解析下载文件名。 + * @param cd Content-Disposition 头 + * @param fallbackName 解析失败或仍乱码时使用 + */ +export function parseDownloadFilename(cd: string, fallbackName: string): string { + const header = String(cd || ''); + if (!header) return fallbackName; + + const star = /filename\*=(?:UTF-8''|utf-8'')([^;]+)/i.exec(header); + if (star?.[1]) { + try { + return decodeURIComponent(star[1].trim().replace(/^"+|"+$/g, '')); + } catch { + /* 继续试 filename= */ + } + } + + const plain = /filename=\"?([^\";]+)\"?/i.exec(header); + if (!plain?.[1]) return fallbackName; + + let name = plain[1].trim(); + try { + name = decodeURIComponent(name); + } catch { + /* 非百分号编码则原样 */ + } + + const recovered = recoverUtf8Mojibake(name); + if (recovered) { + name = recovered; + } + + // 仍无汉字且 fallback 有中文 → 用业务侧给定名(账号导出「账号列表.xls」) + if (looksLikeMojibake(name) && /[\u4e00-\u9fff]/.test(fallbackName)) { + return fallbackName; + } + return name || fallbackName; +} + +/** + * UTF-8 字节被当成 Latin-1/ISO-8859-1 字符时的还原。 + * 「用户列表」→「稅」这类乱码可恢复。 + */ +function recoverUtf8Mojibake(s: string): string | null { + try { + const bytes = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) { + const code = s.charCodeAt(i); + if (code > 255) return null; + bytes[i] = code; + } + const decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + if (/[\u4e00-\u9fff]/.test(decoded)) { + return decoded; + } + } catch { + /* 不是可恢复的 UTF-8 乱码 */ + } + return null; +} + +/** 像乱码:无汉字,但有一串 Latin-1 高位字符 */ +function looksLikeMojibake(s: string): boolean { + if (/[\u4e00-\u9fff]/.test(s)) return false; + return /[\u00c0-\u00ff]{2,}/.test(s); +} + /** * GET 下载附件并触发浏览器保存。 * @param path 相对 API 路径,如 /inquiry/workOrder/view/export * @param params query 参数(空值跳过) - * @param fallbackName 无 Content-Disposition 时的文件名 + * @param fallbackName 无可靠 Content-Disposition 时的文件名 */ export async function downloadAuthGet( path: string, @@ -46,12 +116,32 @@ export async function downloadAuthGet( throw new Error(msg); } const blob = await res.blob(); - let name = fallbackName; const cd = res.headers.get('content-disposition') || ''; - const m = /filename\*=UTF-8''([^;]+)|filename=\"?([^\";]+)\"?/i.exec(cd); - if (m) { - name = decodeURIComponent((m[1] || m[2] || name).trim()); - } + 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;