diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index f48904c..ce1d6a8 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -45,6 +45,8 @@ class FlowSession: history_work_order_no: str = "" quote: dict[str, Any] = field(default_factory=dict) tms_quote: dict[str, Any] = field(default_factory=dict) + # 陆运 H5 重选要用的整包线路。选定后 tms_quote 只留已选那条,供群摘要使用。 + land_route_quote: dict[str, Any] = field(default_factory=dict) status: str = "" allowed: tuple[str, ...] = () immutable_text: str = "" @@ -88,6 +90,7 @@ def _session_from_payload(raw: dict[str, Any]) -> FlowSession: history_work_order_no=str(raw.get("history_work_order_no") or ""), quote=dict(raw.get("quote") or {}), tms_quote=dict(raw.get("tms_quote") or raw.get("tmsQuote") or {}), + land_route_quote=dict(raw.get("land_route_quote") or raw.get("landRouteQuote") or {}), status=str(raw.get("status") or ""), allowed=tuple(str(x) for x in (raw.get("allowed") or ())), immutable_text=str(raw.get("immutable_text") or ""), @@ -228,6 +231,7 @@ class AirTextInquiryFlow: "status": sess.status, "quote": dict(sess.quote or {}), "tms_quote": dict(sess.tms_quote or {}), + "land_route_quote": dict(sess.land_route_quote or {}), "allowed": sess.allowed, "invited_userids": sess.invited_userids, "invited_sales_name": sess.invited_sales_name, diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 49956df..3e4ebb6 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -1005,7 +1005,8 @@ def sea_quote_detail_text( """ 完整价格明细文本:来源、工单号、费用行、总价。海运/陆运/空运共用。 - 陆运分段时效跟在各段费用后面,用 TMS 原文。海运时效、报价有效期仍只在卡片上。 + 陆运分段时效跟在各段费用后面,用 TMS 原文;报价有效期写在总成本价下面。 + 海运时效、报价有效期仍只在按钮卡副标题。 空运:业务确认没有时效,总销售价下面只留报价有效期。 不受卡片引用条字数限制。first_or_same 只兼容调用方。 """ @@ -1025,7 +1026,8 @@ def sea_quote_detail_text( if land_line: lines.append(land_line) lines.append(quote_area_fee_text(src, business_line=business_line or "SEA")) - if line == "AIR": + # 空运、陆运的价格明细正文都要带有效期。海运仍放在按钮卡副标题,避免和原口径打架。 + if line in {"AIR", "LAND"}: lines.append(f"报价有效期:{tms_valid_until_display(src)}") return "\n".join(lines) @@ -1698,6 +1700,40 @@ def resolve_tms_quote(sess: Any = None, ticket: Any = None) -> dict[str, Any]: return {} +def _land_quote_is_selected(quote: dict[str, Any] | None) -> bool: + """已收成一条陆运线路:有选定段,或只剩一条且写了线路名。""" + src = dict(quote or {}) + selected = src.get("selected_land_route") + if isinstance(selected, dict) and list(selected.get("legs") or []): + return True + if not str(src.get("route_line") or "").strip(): + return False + from agent.schema.land_route_options import needs_route_pick + + return not needs_route_pick(src) + + +def land_group_tms_quote( + tms: dict[str, Any] | None, + current: dict[str, Any] | None, +) -> dict[str, Any]: + """ + 群摘要陆运 TMS 段用已选线路,不用还没选的整包。 + + 选定前书签里的 TMS 原价含全部备选费用,总成本价和时效都还没汇总。 + 当前价已是选定投影时用当前价。当前价已是人工报价或调价时,仍用传入的 TMS 价。 + """ + raw = dict(tms or {}) + cur = dict(current or {}) + if not quote_is_tms(cur) or not _land_quote_is_selected(cur): + return raw + from agent.schema.land_route_options import needs_route_pick + + if _land_quote_is_selected(raw) and not needs_route_pick(raw): + return raw + return cur + + def sea_group_brief( *, work_order_no: str, @@ -1741,10 +1777,26 @@ def sea_group_brief( product_quote=product_quote, has_price=has_price, ) + if mode == "陆运": + # 拉群时书签仍可能是整包。当前价已选定时换成那一条,和私聊明细对齐。 + tms = land_group_tms_quote(tms, quote) lines.append("") lines.append("TMS报价:") if tms and (tms.get("total") or quote_fee_lines(tms)): - lines.extend(quote_detail_lines(tms, transport_mode=mode)) + if mode == "陆运": + # 与私聊价格明细同一套:线路名、分段费用、各段时效、总成本价、有效期。 + # 不用 quote_detail_lines,那会再补一行合并时效,也会在未选定时摊开全部费用。 + # 群里已有「TMS报价:」,不要再叠「TMS标准报价」和「工单号价格明细」。 + detail = sea_quote_detail_text( + work_order_no=no, + quote=tms, + business_line="LAND", + ).splitlines() + source = str(tms.get("source_label") or "TMS标准报价").replace(" ", "") + title = f"{no}价格明细" + lines.extend(ln for ln in detail if ln.strip() not in {source, title, "TMS标准报价"}) + else: + lines.extend(quote_detail_lines(tms, transport_mode=mode)) lines.append(group_tms_confirm_hint()) else: lines.append("TMS 暂无匹配报价") diff --git a/inquiry-agent/agent/policy/land_text_flow.py b/inquiry-agent/agent/policy/land_text_flow.py index b61de74..39ac497 100644 --- a/inquiry-agent/agent/policy/land_text_flow.py +++ b/inquiry-agent/agent/policy/land_text_flow.py @@ -418,7 +418,12 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): self._save(sess) return "ledger_fail" sess.quote = quote - copy.remember_tms_quote(sess, raw_quote) + # 整包留给 H5 重选。已只有一条时,群摘要用的 TMS 价必须是投影后的那条。 + sess.land_route_quote = dict(raw_quote) + if needs_route_pick(raw_quote): + copy.remember_tms_quote(sess, raw_quote) + else: + copy.remember_tms_quote(sess, quote) sess.status = "已报价" if needs_route_pick(raw_quote): return self._offer_land_routes(sess=sess, work_order_no=no, quote=raw_quote, reply=reply) @@ -485,7 +490,9 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): return "route_expired" from agent.schema.land_route_options import group_land_routes, project_selected_route - raw_quote = dict(sess.tms_quote or sess.quote or {}) + stored = dict(getattr(sess, "land_route_quote", None) or {}) + # 选定后 tms_quote 已收成一条,重选必须回到整包,否则第二条线路会丢。 + raw_quote = stored if group_land_routes(stored) else dict(sess.tms_quote or sess.quote or {}) routes = group_land_routes(raw_quote) if index < 0 or index >= len(routes): reply("没有这条线路,请重新打开页面选择。") @@ -502,6 +509,9 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): clear_quote_file_ready(sess.work_order_no) self._ledger.upsert_quote(work_order_no=sess.work_order_no, quote=quote, to_status="已报价") + if not stored: + sess.land_route_quote = dict(raw_quote) + copy.remember_tms_quote(sess, quote) sess.quote = quote sess.status = "已报价" sess.wait_version += 1 diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index 5f96972..83ec1a6 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -486,10 +486,16 @@ def send_brief_and_sheet( or "SEA" ).strip().upper() mode = "陆运" if line == "LAND" else "海运" + # 陆运要把当前生效价一并带上:选定投影和未选整包不是同一份,摘要里要能换成已选那条。 + brief_quote = dict(tms or {}) + if mode == "陆运": + current = dict(override or sess_q or view_q or {}) + if current: + brief_quote = current brief = copy.sea_group_brief( work_order_no=no, facts=facts, - quote=tms or None, + quote=brief_quote or None, collab_facts=collab, sales_name=sales_name, product_names=products, diff --git a/inquiry-agent/tests/test_land_route_options.py b/inquiry-agent/tests/test_land_route_options.py index 504b227..99752b0 100644 --- a/inquiry-agent/tests/test_land_route_options.py +++ b/inquiry-agent/tests/test_land_route_options.py @@ -240,6 +240,116 @@ class LandRouteOptionsTests(unittest.TestCase): self.assertLess(eta1, od2) self.assertGreater(eta2, fee2) self.assertIn("总成本价:CNY 17100", text) + valid = text.find("报价有效期:", text.find("总成本价:")) + self.assertGreater(valid, text.find("总成本价:")) + + def test_land_detail_and_group_use_selected_route_valid_until(self) -> None: + """选定广西这条后:私聊明细和群 TMS 段同一套正文,不摊开没选的线路。""" + from agent.policy import inquiry_copy as copy + + raw = { + "source": "TMS", + "source_label": "TMS标准报价", + "isMulti": "2", + "currency": "CNY", + "valid_until": "2026-10-31", + "landSegments": [ + { + "segmentSequence": 1, + "routeLegSequence": 1, + "origin": "上海", + "destination": "谅山", + "costPrice": 7500.0, + "currency": "CNY", + "transitDays": "N+2", + }, + { + "segmentSequence": 1, + "routeLegSequence": 2, + "origin": "谅山", + "destination": "胡志明", + "costPrice": 9600.0, + "currency": "CNY", + "transitDays": "N+3", + }, + { + "segmentSequence": 2, + "routeLegSequence": 1, + "origin": "上海", + "destination": "广西壮族自治区", + "costPrice": 7500.0, + "currency": "CNY", + "transitDays": "N+3", + }, + { + "segmentSequence": 2, + "routeLegSequence": 2, + "origin": "广西壮族自治区", + "destination": "胡志明", + "costPrice": 12500.0, + "currency": "CNY", + "transitDays": "N+3", + }, + ], + "fee_lines": [ + {"name": "运输费", "amount": "7500.00", "currency": "CNY"}, + {"name": "运输费", "amount": "9600.00", "currency": "CNY"}, + {"name": "运输费", "amount": "7500.00", "currency": "CNY"}, + {"name": "运输费", "amount": "12500.00", "currency": "CNY"}, + ], + } + routes = group_land_routes(raw) + projected = project_selected_route(raw, routes[1]) + detail = copy.sea_quote_detail_text( + work_order_no="WO202609230003", + quote=projected, + business_line="LAND", + ) + self.assertIn("TMS标准报价", detail) + self.assertIn("WO202609230003价格明细", detail) + self.assertIn("上海-广西壮族自治区-胡志明", detail) + self.assertIn("上海 → 广西壮族自治区", detail) + self.assertIn("运输费:CNY 7500.00", detail) + self.assertIn("广西壮族自治区 → 胡志明", detail) + self.assertIn("运输费:CNY 12500.00", detail) + self.assertIn("总成本价:CNY 20000", detail) + self.assertIn("报价有效期:2026-10-31", detail) + self.assertGreater(detail.find("报价有效期:"), detail.find("总成本价:")) + self.assertNotIn("9600", detail) + self.assertNotIn("时效:N+3 / N+3", detail) + brief = copy.sea_group_brief( + work_order_no="WO202609230003", + facts={ + "运输类型": "国内运输拼车", + "线路类别": "国内长途/零担", + "运输分类": "急件", + "起运港": "上海", + "目的港": "胡志明", + "品名": "衣服", + "体积": "1", + "毛重": "1", + "报价日期": "2026-09-23", + }, + quote=projected, + collab_facts={}, + sales_name="销", + product_names=["产"], + has_price=True, + tms_quote=raw, + transport_mode="陆运", + ) + tms_block = brief.split("TMS报价:", 1)[1].split("有TMS报价时", 1)[0] + self.assertIn("上海-广西壮族自治区-胡志明", tms_block) + self.assertIn("上海 → 广西壮族自治区", tms_block) + self.assertIn("运输费:CNY 12500.00", tms_block) + self.assertIn("总成本价:CNY 20000", tms_block) + self.assertIn("报价有效期:2026-10-31", tms_block) + self.assertNotIn("TMS标准报价", tms_block) + self.assertNotIn("价格明细", tms_block) + self.assertNotIn("9600", tms_block) + self.assertNotIn("总成本价:-", tms_block) + self.assertNotIn("时效:-", tms_block) + self.assertNotIn("时效:N+3 / N+3", tms_block) def test_h5_lists_segment_costs(self) -> None: from agent.channel.h5.land_route_page import render_land_route_page