From 26dd5d72a12ac87f715b629e33bed30cb498bc99 Mon Sep 17 00:00:00 2001 From: jillion <439912596@qq.com> Date: Sun, 20 Sep 2026 14:40:20 +0800 Subject: [PATCH] =?UTF-8?q?=E8=90=BD=E5=9C=B0=E9=94=80=E5=94=AE=E8=B0=83?= =?UTF-8?q?=E4=BB=B7=EF=BC=9A=E6=8C=89=E8=87=AA=E7=84=B6=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E6=94=B9=E4=BB=B7=E5=B9=B6=E5=8D=95=E7=8B=AC=E8=AE=B0=E9=94=80?= =?UTF-8?q?=E5=94=AE=E4=BB=B7=EF=BC=8C=E6=8B=89=E7=BE=A4=E5=92=8C=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E4=B8=8D=E5=86=8D=E6=8A=8A=E8=B0=83=E5=90=8E=E4=BB=B7?= =?UTF-8?q?=E5=BD=93=E6=88=90=20TMS=20=E5=8E=9F=E4=BB=B7=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../agent/handlers/air_group_collab.py | 12 + inquiry-agent/agent/handlers/group_collab.py | 14 +- inquiry-agent/agent/handlers/text_inquiry.py | 11 +- .../agent/jobs/deal_followup_poll.py | 8 +- inquiry-agent/agent/jobs/pdf_job.py | 5 +- inquiry-agent/agent/ledger/http_ledger.py | 28 +- inquiry-agent/agent/ledger/memory_ledger.py | 5 +- .../agent/llm/mode_extract_adjust.py | 170 +++++ inquiry-agent/agent/policy/air_group_ops.py | 14 +- inquiry-agent/agent/policy/air_text_flow.py | 13 + inquiry-agent/agent/policy/inquiry_copy.py | 339 +++++++-- inquiry-agent/agent/policy/land_text_flow.py | 8 + .../agent/policy/quote_adjust_ops.py | 317 +++++++++ inquiry-agent/agent/policy/sea_group_ops.py | 66 +- inquiry-agent/agent/policy/sea_text_flow.py | 30 +- inquiry-agent/agent/routing/__init__.py | 45 +- .../agent/routing/air_group_intent.py | 9 +- inquiry-agent/agent/routing/deepseek_a.py | 63 +- inquiry-agent/agent/routing/dispatch.py | 5 + inquiry-agent/agent/routing/group_intent.py | 7 + .../agent/routing/ordered_actions.py | 4 +- inquiry-agent/agent/schema/quote_adjust.py | 653 ++++++++++++++++++ inquiry-agent/tests/test_air_text_inquiry.py | 48 +- inquiry-agent/tests/test_land_dispatch.py | 69 ++ inquiry-agent/tests/test_land_group_collab.py | 31 +- inquiry-agent/tests/test_quote_adjust.py | 496 +++++++++++++ inquiry-agent/tests/test_sea_text_inquiry.py | 6 +- .../modules/inquiry/entity/InquiryTicket.java | 10 + .../service/IInquiryWorkOrderService.java | 3 +- .../impl/InquiryWorkOrderServiceImpl.java | 126 +++- .../support/InquiryQuoteDisplaySupport.java | 56 ++ .../support/InquiryTicketPriceSupport.java | 120 ++++ .../inquiry/tms/InquiryTmsQueryService.java | 29 +- .../InquiryQuoteDisplaySupportTest.java | 30 + .../InquiryTicketPriceSupportTest.java | 93 +++ .../src/views/inquiry/workOrder/index.vue | 28 +- 36 files changed, 2795 insertions(+), 176 deletions(-) create mode 100644 inquiry-agent/agent/llm/mode_extract_adjust.py create mode 100644 inquiry-agent/agent/policy/quote_adjust_ops.py create mode 100644 inquiry-agent/agent/schema/quote_adjust.py create mode 100644 inquiry-agent/tests/test_quote_adjust.py create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryQuoteDisplaySupport.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketPriceSupport.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryQuoteDisplaySupportTest.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryTicketPriceSupportTest.java diff --git a/inquiry-agent/agent/handlers/air_group_collab.py b/inquiry-agent/agent/handlers/air_group_collab.py index c087f82..941e43a 100644 --- a/inquiry-agent/agent/handlers/air_group_collab.py +++ b/inquiry-agent/agent/handlers/air_group_collab.py @@ -35,6 +35,7 @@ from agent.policy.sea_group_ops import ( ) from agent.routing.air_group_intent import ( INTENT_ACTIVATE, + INTENT_ADJUST, INTENT_CONFIRM_TMS, INTENT_DEAL, INTENT_DROP_TMS, @@ -293,6 +294,17 @@ def _handle_bound( sender_id=message.sender_id, chat_id=chat_id, ) + if intent == INTENT_ADJUST: + from agent.policy.quote_adjust_ops import apply_group_adjust + + return apply_group_adjust( + flow=engine, + ticket=ticket, + sender_id=message.sender_id, + text=message.content or "", + sess=None, + chat_id=chat_id, + ) if intent == INTENT_QUOTE: return apply_air_quote( flow=engine, diff --git a/inquiry-agent/agent/handlers/group_collab.py b/inquiry-agent/agent/handlers/group_collab.py index ecce1c7..10c281f 100644 --- a/inquiry-agent/agent/handlers/group_collab.py +++ b/inquiry-agent/agent/handlers/group_collab.py @@ -135,7 +135,8 @@ def handle_group_inbound( INTENT_KEEP_TMS, INTENT_LOST, INTENT_NEGOTIATE, - INTENT_QUOTE, + INTENT_ADJUST, + INTENT_QUOTE, classify_group_text, ) @@ -193,6 +194,17 @@ def handle_group_inbound( sender_id=message.sender_id, sess=sess, ) + if intent == INTENT_ADJUST: + from agent.policy.quote_adjust_ops import apply_group_adjust + + return apply_group_adjust( + flow=engine, + ticket=ticket, + sender_id=message.sender_id, + text=message.content or "", + sess=sess, + chat_id=chat_id, + ) if intent == INTENT_QUOTE: return apply_product_quote( flow=engine, diff --git a/inquiry-agent/agent/handlers/text_inquiry.py b/inquiry-agent/agent/handlers/text_inquiry.py index 4102d4d..d1eb04e 100644 --- a/inquiry-agent/agent/handlers/text_inquiry.py +++ b/inquiry-agent/agent/handlers/text_inquiry.py @@ -32,14 +32,16 @@ def _front_bookmark(sess) -> bool: return phase in {"clarify", "need_mode", "need_land_options", "wait_confirm"} -def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = ""): +def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = "", prefer_quoted: bool = False): """ 按当前书签或本句运输方式选空运/海运/陆运流程。 陆运核对卡上的确定优先于旧海运书签,避免「确定」被海运/空运吃掉。 + A 已判成调价时交给当前已报价工单,避免旧海运书签抢走。 图片识图在 Worker 写下的补问书签,HTTP 必须从 Redis 读到,否则「托盘」会变成再问运输方式。 """ from agent.schema.land_options import looks_like_confirm + from agent.policy.quote_adjust_ops import quoted_adjust_flow land = get_land_text_flow() if looks_like_confirm(text): @@ -49,6 +51,10 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = ""): retry = land.restore_tms_retry_session(sender_id) if retry: return land + if prefer_quoted: + quoted = quoted_adjust_flow(sender_id) + if quoted is not None: + return quoted sea = get_sea_text_flow() air = get_air_text_flow() sea_sess = sea.session_of(sender_id) @@ -133,10 +139,12 @@ def handle_text_inquiry( chat_fn=None, flow=None, store=None, + prefer_quoted: bool = False, ) -> str: """ 处理一条私聊文字询价。 + prefer_quoted:A 已判成调价时交给当前已报价工单。 须已有 sender_id。副作用:outbox 多条回复;主账经 flow.ledger。 返回:流程阶段名。 """ @@ -152,6 +160,7 @@ def handle_text_inquiry( sender_id=message.sender_id, text=message.content or "", injected_mode=injected_mode, + prefer_quoted=prefer_quoted, ) replies: list[str] = [] diff --git a/inquiry-agent/agent/jobs/deal_followup_poll.py b/inquiry-agent/agent/jobs/deal_followup_poll.py index 62175f0..bf7a7ad 100644 --- a/inquiry-agent/agent/jobs/deal_followup_poll.py +++ b/inquiry-agent/agent/jobs/deal_followup_poll.py @@ -74,8 +74,8 @@ def _reask_one(row: dict[str, Any], *, settings: Settings) -> bool: return False from agent.channel.queue import get_message_store - text = copy.deal_card(work_order_no=no, quote=quote, facts=facts) - payload = copy.deal_wecom_payload(work_order_no=no, quote=quote, facts=facts) + text = copy.deal_card(work_order_no=no, quote=quote, facts=facts, business_line=line) + payload = copy.deal_wecom_payload(work_order_no=no, quote=quote, facts=facts, business_line=line) ok, info = get_message_store().enqueue_outbound( touser=sales, content=text, @@ -115,6 +115,8 @@ def _send_group_reask( return False carder = getattr(client, "send_group_card", None) if callable(carder): - payload = copy.deal_wecom_payload(work_order_no=work_order_no, quote=quote, facts=facts) + payload = copy.deal_wecom_payload( + work_order_no=work_order_no, quote=quote, facts=facts, business_line=business_line + ) carder(chat_id=chat_id, template_card=payload.get("template_card") or {}) return True diff --git a/inquiry-agent/agent/jobs/pdf_job.py b/inquiry-agent/agent/jobs/pdf_job.py index 2734188..ea43fd3 100644 --- a/inquiry-agent/agent/jobs/pdf_job.py +++ b/inquiry-agent/agent/jobs/pdf_job.py @@ -138,6 +138,7 @@ def _deliver_quote_file(data: dict[str, Any], result: LibreOfficeResult) -> None store = get_message_store() quote = data.get("quote") if isinstance(data.get("quote"), dict) else {} facts = data.get("facts") if isinstance(data.get("facts"), dict) else {} + line = str(data.get("business_line") or (quote or {}).get("business_line") or "").strip() from agent.policy import inquiry_copy as copy store.enqueue_outbound( @@ -145,7 +146,7 @@ def _deliver_quote_file(data: dict[str, Any], result: LibreOfficeResult) -> None content="\n".join( [ file_ready(kind="PDF", work_order_no=wo, filename=filename), - copy.deal_card(work_order_no=wo, quote=quote, facts=facts), + copy.deal_card(work_order_no=wo, quote=quote, facts=facts, business_line=line), ] ), dedupe_key=f"pdf-file:{wo}:{filename}", @@ -154,7 +155,7 @@ def _deliver_quote_file(data: dict[str, Any], result: LibreOfficeResult) -> None "filepath": result.pdf_path, "filename": filename, "followup_card": copy.deal_wecom_payload( - work_order_no=wo, quote=quote, facts=facts + work_order_no=wo, quote=quote, facts=facts, business_line=line ).get("template_card") or {}, }, diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py index b4f1df4..59ce518 100644 --- a/inquiry-agent/agent/ledger/http_ledger.py +++ b/inquiry-agent/agent/ledger/http_ledger.py @@ -408,14 +408,28 @@ class HttpLedger: work_order_no: str, quote: dict[str, Any], to_status: str = "已报价", + remark: str = "", + kind: str = "", ) -> dict[str, Any]: - """产品报价写入主账并迁已报价。""" + """ + 手工报价 / 销售调价写入主账新版本。 + + 幂等键带合计和备注,避免连续调价被同一把钥匙吞掉。 + """ + src = dict(quote or {}) + note = (remark or "").strip() + body: dict[str, Any] = { + "workOrderNo": work_order_no, + "quote": src, + "toStatus": to_status, + } + if note: + body["remark"] = note + if (kind or "").strip(): + body["kind"] = kind.strip() + sig = f"{src.get('total') or ''}|{note}|{kind or ''}|{len(src.get('fee_rows') or src.get('fee_lines') or [])}" return self._post( "/inquiry/agent/ticket/upsertProductQuote", - { - "workOrderNo": work_order_no, - "quote": quote, - "toStatus": to_status, - }, - idem=f"product-quote:{work_order_no}:{sorted((quote or {}).keys())}", + body, + idem=f"manual-quote:{work_order_no}:{sig}", ) diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py index a7abb7b..0ad3b88 100644 --- a/inquiry-agent/agent/ledger/memory_ledger.py +++ b/inquiry-agent/agent/ledger/memory_ledger.py @@ -540,8 +540,11 @@ class MemoryLedger: work_order_no: str, quote: dict[str, Any], to_status: str = "已报价", + remark: str = "", + kind: str = "", ) -> dict[str, Any]: - """产品报价盖 TMS 价。""" + """手工报价 / 销售调价盖 TMS 价。remark/kind 只给 HTTP 主账记流水。""" + _ = remark, kind return self.upsert_quote( work_order_no=work_order_no, quote=quote, to_status=to_status ) diff --git a/inquiry-agent/agent/llm/mode_extract_adjust.py b/inquiry-agent/agent/llm/mode_extract_adjust.py new file mode 100644 index 0000000..5685f5c --- /dev/null +++ b/inquiry-agent/agent/llm/mode_extract_adjust.py @@ -0,0 +1,170 @@ +""" +DeepSeek B:调价意图与抽项。 + +本文件职责:判断是不是调价、抽出费用/方向/比例或改到金额。 +禁止:模型改价;禁止把某一句口令写成唯一识别;未确认数据用途不得出站。 +调用:私聊/群 Worker 或 inbox 线程在已开网时;禁止回调线程同步跑。 +""" + +from __future__ import annotations + +import json +import logging +from typing import Any, Optional + +from agent.llm.modes import LlmMode +from agent.schema.quote_adjust import AdjustExtract, AdjustItem + +logger = logging.getLogger(__name__) + +EXTRACT_ADJUST_TOOL = "extract_quote_adjust" + + +def extract_adjust_tool_definition() -> dict[str, Any]: + """B 抽调价项的 tool。金额只原样抽出,不算新价。""" + return { + "type": "function", + "function": { + "name": EXTRACT_ADJUST_TOOL, + "description": ( + "Decide if the sales message is adjusting an existing quote " + "and extract each fee change. Do not require a fixed phrase." + ), + "parameters": { + "type": "object", + "additionalProperties": False, + "properties": { + "is_adjust": {"type": "boolean"}, + "wants_total": {"type": "boolean"}, + "items": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "properties": { + "fee_name": {"type": "string"}, + "kind": {"type": "string", "enum": ["percent", "target"]}, + "direction": {"type": "string", "enum": ["up", "down", "set"]}, + "value": {"type": "number"}, + }, + "required": ["fee_name", "kind", "direction", "value"], + }, + }, + }, + "required": ["is_adjust", "wants_total", "items"], + }, + }, + } + + +def parse_adjust_payload(raw: Any) -> Optional[AdjustExtract]: + """把模型 JSON / tool 参数收成 AdjustExtract。结构不对返回 None,不猜。""" + data = raw + if isinstance(raw, str): + text = raw.strip() + if text.startswith("```"): + text = text.strip("`") + if text.lower().startswith("json"): + text = text[4:].strip() + try: + data = json.loads(text) + except json.JSONDecodeError: + return None + if not isinstance(data, dict): + return None + if "is_adjust" not in data: + return None + items: list[AdjustItem] = [] + for row in data.get("items") or []: + if not isinstance(row, dict): + continue + kind = str(row.get("kind") or "percent").strip() + direction = str(row.get("direction") or "up").strip() + if kind not in {"percent", "target"}: + continue + if direction not in {"up", "down", "set"}: + continue + try: + value = float(row.get("value")) + except (TypeError, ValueError): + continue + items.append( + AdjustItem( + fee_name=str(row.get("fee_name") or "").strip(), + kind=kind, + direction=direction, + value=value, + ) + ) + return AdjustExtract( + is_adjust=bool(data.get("is_adjust")), + items=items, + wants_total=bool(data.get("wants_total")), + ) + + +def build_adjust_messages(text: str, fee_names: list[str]) -> list[dict[str, str]]: + """B 的系统/用户消息。口令不写死,只抽项不算价。""" + names = "、".join(n for n in fee_names if n) or "(当前报价未列出费用名)" + return [ + { + "role": "system", + "content": ( + "你是询价调价抽取器。只根据销售原话判断是不是在调已有报价,并抽出每一项。" + "必须调用 extract_quote_adjust。不要对销售说话,不要改价。" + "口令不写死:上升、上浮、往上抬、加一成、涨一点、改到某金额、在现有报价基础上调整都算调价。" + "没点名费用、只说报价:is_adjust=true,wants_total=false,items 为空。" + "只动总销售价/总成本价/合计/总价/汇总价:wants_total=true,items 仍要一条(fee_name 可空),带 kind/direction/value。" + "kind 只能 percent 或 target;direction 只能 up/down/set。" + "value 必须来自原话数字;一成=10。禁止编造费用名和金额。" + "不是调价(问候、新询价、谢谢)时 is_adjust=false。" + f"当前可调费用名:{names}" + ), + }, + {"role": "user", "content": text}, + ] + + +def invoke_extract_adjust( + text: str, + *, + fee_names: list[str] | None = None, + allow_network: bool = False, + chat_fn: Optional[Any] = None, +) -> Optional[AdjustExtract]: + """ + 调用 DeepSeek B 抽调价。失败返回 None,由调用方走本地兜底。 + + chat_fn:单测注入。allow_network=False 且无 chat_fn:不打外网。 + """ + if chat_fn is None and not allow_network: + return None + from agent.llm.http_client import LlmHttpClient + + client = LlmHttpClient.from_settings() + chat = chat_fn or client.chat + result = chat( + mode=LlmMode.EXTRACT_FIELDS, + messages=build_adjust_messages(text, list(fee_names or [])), + tools=[extract_adjust_tool_definition()], + tool_choice={"type": "function", "function": {"name": EXTRACT_ADJUST_TOOL}}, + ) + if not getattr(result, "ok", False): + logger.info("extract.adjust 失败 err=%s", getattr(result, "error", "")) + return None + raw_args: Any = None + for call in getattr(result, "tool_calls", None) or []: + fn = (call or {}).get("function") or {} + if fn.get("name") == EXTRACT_ADJUST_TOOL: + raw_args = fn.get("arguments") + break + if raw_args is None and getattr(result, "content", ""): + raw_args = result.content + parsed = parse_adjust_payload(raw_args) + logger.info( + "extract.adjust ok=%s items=%s wants_total=%s", + None if parsed is None else parsed.is_adjust, + 0 if parsed is None else len(parsed.items), + False if parsed is None else parsed.wants_total, + ) + return parsed diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index 3ebd6dd..b39d436 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -180,7 +180,8 @@ def send_air_brief_and_sheet( tms = dict(tms_quote) if tms_quote is not None else {} manual = dict(product_quote or {}) if tms_quote is None: - if view_q and not copy.quote_is_product(view_q): + tms = copy.resolve_tms_quote(None, view) + if not tms and view_q and copy.quote_is_tms(view_q): tms = view_q if not manual and copy.quote_is_product(override or view_q): manual = override or view_q @@ -389,6 +390,10 @@ def apply_air_quote( _park_air_pending(flow, no, quote, sender_id) send_group_text(client, chat_id, copy.group_ask_tms_or_product()) return "wait_tms_choice" + from agent.schema.quote_adjust import quote_fee_rows + + if quote_fee_rows(current): + quote = merge_quote_fees(current, quote) return _commit_quote(flow, ticket, quote, chat_id, sender_id) @@ -807,14 +812,11 @@ def _clear_air_pending(flow: Any, work_order_no: str, sender_id: str) -> None: def _has_tms_quote(quote: dict[str, Any]) -> bool: - """当前价来自 TMS 才要问;已经是航线/产品价不再替用户选。""" + """当前价来自 TMS 才要问;航线/产品/销售调价不再替用户选。""" src = dict(quote or {}) - label = str(src.get("source_label") or src.get("source") or "") - if "航线" in label or "产品" in label: - return False if not (src.get("total") or src.get("quoteId") or src.get("quote_id")): return False - return "TMS" in label.upper() or "标准报价" in label or bool(src.get("quoteId") or src.get("quote_id")) + return copy.quote_is_tms(src) def _is_product_quote(quote: dict[str, Any]) -> bool: diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index 77e1e6a..fee357a 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -42,6 +42,7 @@ class FlowSession: first_or_same: str = "first" history_work_order_no: str = "" quote: dict[str, Any] = field(default_factory=dict) + tms_quote: dict[str, Any] = field(default_factory=dict) status: str = "" allowed: tuple[str, ...] = () immutable_text: str = "" @@ -78,6 +79,7 @@ def _session_from_payload(raw: dict[str, Any]) -> FlowSession: first_or_same=str(raw.get("first_or_same") or "first"), 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 {}), 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 ""), @@ -480,6 +482,12 @@ class AirTextInquiryFlow: and not (injected_facts and injected_mode) ): return self._capture_lost_reason(sess, text, reply) + if sess: + from agent.policy.quote_adjust_ops import try_private_adjust + + adjusted = try_private_adjust(self, sess, text, reply) + if adjusted: + return adjusted snap = extract_inquiry_snapshot( text, @@ -661,6 +669,7 @@ class AirTextInquiryFlow: self._save(sess) return "ledger_fail" sess.quote = quote + copy.remember_tms_quote(sess, quote) sess.status = "已报价" sess.phase = "wait_collab" sess.allowed = (copy.BTN_SKIP_COLLAB,) @@ -966,6 +975,7 @@ class AirTextInquiryFlow: work_order_no=sess.work_order_no, quote=sess.quote, facts=sess.facts, + business_line=sess.business_line or "AIR", ).get("template_card") or {} reply( "\n".join( @@ -978,6 +988,7 @@ class AirTextInquiryFlow: status=sess.status or "已报价", quote=sess.quote, facts=sess.facts, + business_line=sess.business_line or "AIR", ), ] ), @@ -1003,11 +1014,13 @@ class AirTextInquiryFlow: status=sess.status or "已报价", quote=sess.quote, facts=sess.facts, + business_line=sess.business_line or "AIR", ), copy.deal_wecom_payload( work_order_no=sess.work_order_no, quote=sess.quote, facts=sess.facts, + business_line=sess.business_line or "AIR", ), ) diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 6e8e8ff..9285848 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -24,6 +24,7 @@ from agent.schema.field_validate import ( display_name, ) from agent.schema.land_options import option_prompt as _land_option_prompt +from agent.schema.quote_adjust import total_price_label # 空运补问:与合同 8 项一致。报价日期默认当天,不进待补充。 _AIR_EXAMPLES = { @@ -671,6 +672,38 @@ def money_text(amount: Any, currency: str = "") -> str: return f"{code} {raw}" +def _total_label( + quote: dict[str, Any] | None = None, + business_line: str = "", + transport_mode: str = "", +) -> str: + """空运总销售价;海运/陆运看这版报价是成本还是销售调价。""" + src = dict(quote or {}) + return total_price_label( + business_line or str(src.get("business_line") or ""), + transport_mode, + quote=src, + ) + + +def _manual_quote_lines( + manual: dict[str, Any], + *, + business_line: str = "", + transport_mode: str = "", +) -> list[str]: + """人工报价段:费用行 + 总销售价,不写合计。""" + src = dict(manual or {}) + lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)] + header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip() + total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy) + if total and total != "-": + lines.append(f"{_total_label(src, business_line, transport_mode)}:{total}") + elif not lines: + lines.append(f"{_total_label(src, business_line, transport_mode)}:{total or '-'}") + return lines + + def quote_fee_lines(quote: dict[str, Any] | None) -> list[tuple[str, str]]: """ 价格明细行:优先 TMS fee_lines,否则用运费/其它/合计兜底。 @@ -707,9 +740,13 @@ def quote_fee_lines(quote: dict[str, Any] | None) -> list[tuple[str, str]]: return lines -def quote_area_fee_text(quote: dict[str, Any] | None) -> str: +def quote_area_fee_text( + quote: dict[str, Any] | None, + business_line: str = "", + transport_mode: str = "", +) -> str: """ - 卡片引用条正文:费用行 + 总销售价合计。 + 卡片引用条正文:费用行 + 总销售价。 必须走 quote_text。塞进 quote_area.title 会被企微整段藏掉。 """ @@ -717,7 +754,7 @@ def quote_area_fee_text(quote: dict[str, Any] | None) -> str: header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip() total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy) fee_lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)] - fee_lines.append(f"总销售价合计:{total}") + fee_lines.append(f"{_total_label(src, business_line, transport_mode)}:{total}") return "\n".join(fee_lines) @@ -725,22 +762,27 @@ def quote_area_fee_text(quote: dict[str, Any] | None) -> str: _QUOTE_AREA_SAFE_CHARS = 80 -def quote_area_safe_text(quote: dict[str, Any] | None) -> str: +def quote_area_safe_text( + quote: dict[str, Any] | None, + business_line: str = "", + transport_mode: str = "", +) -> str: """ - 卡片引用条:短报价原样;超长只留合计,避免半截费用名。 + 卡片引用条:短报价原样;超长只留总价名称,避免半截费用名。 完整明细由 sea_quote_detail_text 另发一条文本。空运卡仍用 quote_area_fee_text。 """ - full = quote_area_fee_text(quote) + full = quote_area_fee_text(quote, business_line=business_line, transport_mode=transport_mode) if len(full) <= _QUOTE_AREA_SAFE_CHARS: return full src = dict(quote or {}) header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip() total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy) n = len(quote_fee_lines(src)) + label = _total_label(src, business_line, transport_mode) if n: - return f"总销售价合计:{total}\n共{n}项费用,详见上一条明细" - return f"总销售价合计:{total}" + return f"{label}:{total}\n共{n}项费用,详见上一条明细" + return f"{label}:{total}" def sea_quote_detail_text( @@ -750,9 +792,10 @@ def sea_quote_detail_text( first_or_same: str = "first", history_work_order_no: str = "", status: str = "已报价", + business_line: str = "SEA", ) -> str: """ - 海运完整价格明细文本:来源、工单号、费用行、合计。 + 海运/陆运完整价格明细文本:来源、工单号、费用行、总销售价。 时效、报价有效期只在卡片上出现,这里不写,避免和卡重复。 不受卡片引用条字数限制。first_or_same 只兼容调用方。 @@ -761,7 +804,11 @@ def sea_quote_detail_text( src = dict(quote or {}) source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") return "\n".join( - [source, f"{work_order_no}价格明细", quote_area_fee_text(src)] + [ + source, + f"{work_order_no}价格明细", + quote_area_fee_text(src, business_line=business_line or "SEA"), + ] ) @@ -824,9 +871,11 @@ def quote_detail_lines( first_or_same: str = "first", history_work_order_no: str = "", status: str = "已报价", + business_line: str = "", + transport_mode: str = "", ) -> list[str]: """ - 价格明细正文:TMS 费用、合计、时效、有效期。 + 价格明细正文:TMS 费用、总销售价、时效、有效期。 给文本兜底和企微引用条共用,禁止在这里编时效/有效期。 价格卡不展示工单状态,也不展示相同询价内容检索。 @@ -836,7 +885,7 @@ def quote_detail_lines( header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip() total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy) lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)] - lines.append(f"总销售价合计:{total}") + lines.append(f"{_total_label(src, business_line, transport_mode)}:{total}") lines.append(f"时效:{tms_eta_display(src)}") lines.append(f"报价有效期:{tms_valid_until_display(src)}") return lines @@ -864,6 +913,7 @@ def tms_hit_card( first_or_same=first_or_same, history_work_order_no=history_work_order_no, status=status, + business_line="AIR", ) ) lines.append("") @@ -884,7 +934,7 @@ def tms_hit_wecom_payload( 一张价格卡,销售侧顺序: 价格明细 - ┌ 引用条:空运费 / 其它费用 / 总销售价合计 + ┌ 引用条:空运费 / 其它费用 / 总销售价 时效、报价有效期 协同说明(普通文字,贴按钮上方) 按钮 @@ -912,7 +962,7 @@ def tms_hit_wecom_payload( "main_title": {"title": f"{work_order_no}价格明细"}, "quote_area": { "type": 0, - "quote_text": quote_area_fee_text(src), + "quote_text": quote_area_fee_text(src, business_line="AIR"), }, "sub_title_text": meta, "task_id": card_task_id("quote", work_order_no), @@ -1047,8 +1097,9 @@ def sea_tms_hit_card( first_or_same: str = "first", history_work_order_no: str = "", status: str = "已报价", + business_line: str = "SEA", ) -> str: - """海运有价:价格明细 + 拉群问句 + 两个按钮。空运卡片函数保持不动。""" + """海运/陆运有价:价格明细 + 拉群问句 + 两个按钮。空运卡片函数保持不动。""" src = dict(quote or {}) source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") lines = [source, f"{work_order_no}价格明细"] @@ -1058,6 +1109,7 @@ def sea_tms_hit_card( first_or_same=first_or_same, history_work_order_no=history_work_order_no, status=status, + business_line=business_line or "SEA", ) ) lines.append("") @@ -1074,8 +1126,9 @@ def sea_tms_hit_wecom_payload( first_or_same: str = "first", history_work_order_no: str = "", status: str = "已报价", + business_line: str = "SEA", ) -> dict[str, Any]: - """海运有价企微卡:价格明细 + 问句 + 拉群 / 跳过协同。""" + """海运/陆运有价企微卡:价格明细 + 问句 + 拉群 / 跳过协同。""" _ = first_or_same, history_work_order_no, status src = dict(quote or {}) source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") @@ -1095,7 +1148,7 @@ def sea_tms_hit_wecom_payload( "main_title": {"title": f"{work_order_no}价格明细"}, "quote_area": { "type": 0, - "quote_text": quote_area_safe_text(src), + "quote_text": quote_area_safe_text(src, business_line=business_line or "SEA"), }, "sub_title_text": meta, "task_id": card_task_id("quote", work_order_no), @@ -1217,10 +1270,84 @@ def sea_group_create_fail(*, reason: str = "") -> str: def quote_is_product(quote: dict[str, Any] | None) -> bool: """来源是产品/航线人工,不能写进 TMS 报价段。""" - label = str((quote or {}).get("source_label") or (quote or {}).get("source") or "") + src = dict(quote or {}) + code = str(src.get("source") or "").strip().upper() + if code == "PRODUCT": + return True + if code in {"TMS", "SALES_ADJUST"}: + return False + label = str(src.get("source_label") or src.get("sourceLabel") or src.get("source") or "") return "产品" in label or "航线" in label +def quote_is_sales_adjust(quote: dict[str, Any] | None) -> bool: + """销售调过价的这一版,禁止再写进 TMS 报价段。""" + src = dict(quote or {}) + code = str(src.get("source") or "").strip().upper() + if code == "SALES_ADJUST": + return True + if code in {"TMS", "PRODUCT"}: + return False + label = str(src.get("source_label") or src.get("sourceLabel") or "").replace(" ", "") + return "销售调价" in label + + +def quote_is_tms(quote: dict[str, Any] | None) -> bool: + """ + 只有 TMS 原价能进 TMS 段。 + + 产品报价、销售调价即使金额还在,也不能冒充 TMS。 + """ + src = dict(quote or {}) + if not src: + return False + if quote_is_product(src) or quote_is_sales_adjust(src): + return False + code = str(src.get("source") or "").strip().upper() + if code == "TMS": + return True + label = str(src.get("source_label") or src.get("sourceLabel") or src.get("source") or "") + return "TMS" in label.upper() or "标准报价" in label or not label.strip() + + +def remember_tms_quote(sess: Any, quote: dict[str, Any] | None) -> None: + """ + 书签里单独记住 TMS 原价。调价只改 sess.quote,不准覆盖这一份。 + + 副作用:写 sess.tms_quote。调用方在 TMS 命中或调价前调用。 + """ + if sess is None: + return + src = dict(quote or {}) + if not quote_is_tms(src): + return + sess.tms_quote = dict(src) + + +def resolve_tms_quote(sess: Any = None, ticket: Any = None) -> dict[str, Any]: + """ + 拉群/群摘要用的 TMS 原价:书签 tms_quote → 主账 tms_quote → 当前价若仍是 TMS。 + + 调后最新一条是销售调价,不能拿来当 TMS。 + """ + candidates: list[dict[str, Any]] = [] + if sess is not None: + candidates.append(dict(getattr(sess, "tms_quote", None) or {})) + view = dict(ticket or {}) if isinstance(ticket, dict) else {} + if view: + candidates.append(dict(view.get("tms_quote") or view.get("tmsQuote") or {})) + elif ticket is not None: + candidates.append(dict(getattr(ticket, "tms_quote", None) or {})) + if sess is not None: + candidates.append(dict(getattr(sess, "quote", None) or {})) + if view: + candidates.append(dict(view.get("quote") or {})) + for item in candidates: + if quote_is_tms(item) and (item.get("total") or item.get("fee_rows") or item.get("fee_lines")): + return item + return {} + + def sea_group_brief( *, work_order_no: str, @@ -1237,8 +1364,8 @@ def sea_group_brief( """ 群里自动摘要:询价 + 协同 + TMS 报价 + 人工报价。 - TMS 无价只写「TMS 暂无匹配报价」,产品报的价单独放人工报价,不能混进 TMS。 - 陆运传入 transport_mode=陆运,始发站/货物字段跟核对卡同一套。 + TMS 无价只写「TMS 暂无匹配报价」,产品报的价单独放人工报价,销售调价另起一段。 + 禁止把调后价写进 TMS 段。陆运传入 transport_mode=陆运,始发站/货物字段跟核对卡同一套。 """ no = (work_order_no or "").strip() sales = (sales_name or "").strip() or "销售" @@ -1258,33 +1385,60 @@ def sea_group_brief( label = collab_display_name(key, line) val = (collab.get(key) or collab.get(label) or "").strip() or "-" lines.append(f"{label}:{val}") - tms = dict(tms_quote) if tms_quote is not None else {} - manual = dict(product_quote or {}) - if tms_quote is None: - src = dict(quote or {}) - if has_price and src and not quote_is_product(src): - tms = src - elif quote_is_product(src): - manual = manual or src + tms, manual, adjust = _split_brief_quotes( + quote=quote, + tms_quote=tms_quote, + product_quote=product_quote, + has_price=has_price, + ) lines.append("") lines.append("TMS报价:") if tms and (tms.get("total") or quote_fee_lines(tms)): - lines.extend(quote_detail_lines(tms)) + lines.extend(quote_detail_lines(tms, transport_mode=mode)) lines.append(group_tms_confirm_hint()) else: lines.append("TMS 暂无匹配报价") if manual and (quote_fee_lines(manual) or str(manual.get("total") or "").strip() not in {"", "-"}): lines.append("") lines.append("人工报价:") - fee_lines = quote_fee_lines(manual) - if fee_lines: - lines.extend(f"{name}:{amount}" for name, amount in fee_lines) - else: - header_ccy = str(manual.get("currency") or "").strip() - lines.append(f"合计:{money_text(str(manual.get('total') or '-'), header_ccy)}") + lines.extend(_manual_quote_lines(manual, transport_mode=mode)) + if adjust and (quote_fee_lines(adjust) or str(adjust.get("total") or "").strip() not in {"", "-"}): + lines.append("") + lines.append("销售调价:") + lines.extend(_manual_quote_lines(adjust, transport_mode=mode)) return "\n".join(lines) +def _split_brief_quotes( + *, + quote: dict[str, Any] | None, + tms_quote: dict[str, Any] | None, + product_quote: dict[str, Any] | None, + has_price: bool, +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + """ + 群摘要三段价:TMS 原价 / 人工 / 销售调价。调后价不准掉进 TMS。 + + tms_quote 显式传入(含空字典)时不再用当前 quote 兜底。 + """ + src = dict(quote or {}) + tms = dict(tms_quote) if tms_quote is not None else {} + manual = dict(product_quote or {}) + if tms_quote is None: + if has_price and src and quote_is_tms(src): + tms = src + elif quote_is_product(src): + manual = manual or src + if quote_is_sales_adjust(tms) or quote_is_product(tms): + tms = {} + if quote_is_sales_adjust(manual): + manual = {} + adjust = src if quote_is_sales_adjust(src) else {} + if quote_is_sales_adjust(product_quote) and not adjust: + adjust = dict(product_quote or {}) + return tms, manual, adjust + + def scheme_card( *, work_order_no: str, @@ -1314,7 +1468,7 @@ def scheme_card( f"【{work_order_no}报价方案】", body, "价格明细", - quote_area_fee_text(src), + quote_area_fee_text(src, transport_mode=transport_mode), f"时效:{eta or '-'}", f"报价有效期:{valid}", "请确认是否采用该报价方案:", @@ -1455,9 +1609,9 @@ def scheme_wecom_payload( "quote_area": { "type": 0, "quote_text": ( - quote_area_safe_text(src) - if (transport_mode or "").strip() == "海运" - else quote_area_fee_text(src) + quote_area_safe_text(src, transport_mode=transport_mode) + if (transport_mode or "").strip() in {"海运", "陆运"} + else quote_area_fee_text(src, transport_mode=transport_mode) ), }, "horizontal_content_list": scheme_horizontal_rows( @@ -1489,7 +1643,68 @@ def scheme_wecom_payload( def reject_ack() -> str: - return "请问如何调整报价单,以满足您的需求?您可使用「报价加 10%」等调价指令。" + return "请问如何调整报价单,以满足您的需求?请说明要调哪一项费用,例如「提货费加 10%」。" + + +def adjust_confirm(applied: list[dict[str, Any]]) -> str: + """识别成功确认。applied 每项含 name、how。""" + bits = [f"{x.get('name')}在当前报价基础上{x.get('how')}" for x in applied if x.get("name")] + body = ";".join(bits) or "已识别调价指令" + return f"已识别调价指令:{body}。正在刷新报价" + + +def adjust_ledger_remark(applied: list[dict[str, Any]]) -> str: + """ + 主账状态流转备注:每次销售调价一条,记下当时改了哪几项。 + + 后台「状态流转与备注」原样展示;不含利润点。 + """ + bits = [f"{x.get('name')}{x.get('how')}" for x in applied if x.get("name")] + return "销售调价:" + (";".join(bits) if bits else "已改价") + + +def adjust_need_fee() -> str: + return "请说明要调哪一项费用明细,例如「提货费加 10%」。只说报价或总价,系统不会改价。" + + +def adjust_need_lines() -> str: + return "请分别对明细价格进行调价,系统不分摊合计。" + + +def adjust_currency_hint() -> str: + return "请分别对不同币种进行调价,系统不做汇率换算。" + + +def adjust_unmatched(missing: list[str], available: list[str]) -> str: + miss = "、".join(x for x in missing if x) or "这些费用" + have = "、".join(available) if available else "(当前没有费用明细)" + return f"卡上没有{miss}。当前能调的费用:{have}" + + +def adjust_ambiguous(name: str, hits: list[str]) -> str: + return f"「{name}」对上了多项({'、'.join(hits)}),请说明要调哪一项。" + + +def adjust_ineligible(reason: str) -> str: + if reason == "already_collab": + return "这张单已经拉群协同,请到群里发送调价指令。" + if reason == "closed": + return "这张单已经结案,不能再按这套调价。" + return "当前工单还没有可调的报价,不能按这套调价。" + + +def adjust_only_sales() -> str: + return "只有这张单的销售能调价。航线/产品请继续报价,价格未改。" + + +def adjust_group_body(quote: dict[str, Any] | None, business_line: str = "") -> str: + """群里调后全量明细,利润点不写给销售。""" + src = dict(quote or {}) + lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)] + header = str(src.get("currency") or "") + total = money_text(str(src.get("total") or "-").strip() or "-", header) + lines.append(f"{_total_label(src, business_line)}:{total}") + return "调价后价格:\n" + "\n".join(lines) def adopt_excel_ack() -> str: @@ -1520,17 +1735,20 @@ def deal_horizontal_rows( *, quote: dict[str, Any] | None, facts: dict[str, str] | None = None, + business_line: str = "", + transport_mode: str = "", ) -> list[dict[str, str]]: """ - 成交跟进卡字段:总销售价合计、时效、报价有效期。 + 成交跟进卡字段:总销售价、时效、报价有效期。 不要历史报价 / 工单状态 / 来源。来源只留卡片顶部红字。 """ + _ = facts src = dict(quote or {}) header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip() total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy) return [ - {"keyname": "总销售价合计", "value": _clip(total, 26)}, + {"keyname": _total_label(src, business_line, transport_mode), "value": _clip(total, 26)}, {"keyname": "时效", "value": _clip(tms_eta_display(src), 26)}, {"keyname": "报价有效期", "value": _clip(tms_valid_until_display(src), 26)}, ] @@ -1543,6 +1761,7 @@ def deal_card( summary: str = "", quote: dict[str, Any] | None = None, facts: dict[str, str] | None = None, + business_line: str = "", ) -> str: """ 成交跟进文字兜底(模板卡发失败时仍给销售看)。 @@ -1551,7 +1770,7 @@ def deal_card( """ _ = status, summary no = (work_order_no or "").strip() - rows = deal_horizontal_rows(quote=quote, facts=facts) + rows = deal_horizontal_rows(quote=quote, facts=facts, business_line=business_line) lines = [ f"询价工单 {no}", DEAL_SUBTITLE, @@ -1568,6 +1787,7 @@ def deal_wecom_payload( quote: dict[str, Any] | None = None, facts: dict[str, str] | None = None, card_version: int = 0, + business_line: str = "", ) -> dict[str, Any]: """ 成交跟进卡:对齐现网 button_interaction。 @@ -1590,7 +1810,9 @@ def deal_wecom_payload( "desc": DEAL_SUBTITLE, }, "sub_title_text": DEAL_HINT, - "horizontal_content_list": deal_horizontal_rows(quote=src, facts=facts), + "horizontal_content_list": deal_horizontal_rows( + quote=src, facts=facts, business_line=business_line + ), "task_id": card_task_id("deal", no), "button_list": [ { @@ -1900,30 +2122,27 @@ def air_group_brief( for key in AIR_COLLAB_FIELDS: val = (collab.get(key) or "").strip() or "-" lines.append(f"{key}:{val}") - tms = dict(tms_quote) if tms_quote is not None else {} - manual = dict(product_quote or {}) - if tms_quote is None: - src = dict(quote or {}) - if has_price and src and not quote_is_product(src): - tms = src - elif quote_is_product(src): - manual = manual or src + tms, manual, adjust = _split_brief_quotes( + quote=quote, + tms_quote=tms_quote, + product_quote=product_quote, + has_price=has_price, + ) lines.append("") lines.append("TMS报价:") if tms and (tms.get("total") or quote_fee_lines(tms)): - lines.extend(quote_detail_lines(tms)) + lines.extend(quote_detail_lines(tms, business_line="AIR")) lines.append(group_tms_confirm_hint()) else: lines.append("TMS 暂无匹配报价") if manual and (quote_fee_lines(manual) or str(manual.get("total") or "").strip() not in {"", "-"}): lines.append("") lines.append("人工报价:") - fee_lines = quote_fee_lines(manual) - if fee_lines: - lines.extend(f"{name}:{amount}" for name, amount in fee_lines) - else: - header_ccy = str(manual.get("currency") or "").strip() - lines.append(f"合计:{money_text(str(manual.get('total') or '-'), header_ccy)}") + lines.extend(_manual_quote_lines(manual, business_line="AIR")) + if adjust and (quote_fee_lines(adjust) or str(adjust.get("total") or "").strip() not in {"", "-"}): + lines.append("") + lines.append("销售调价:") + lines.extend(_manual_quote_lines(adjust, business_line="AIR")) return "\n".join(lines) @@ -1945,7 +2164,7 @@ def air_activate_summary( quote=src, collab_facts=collab_facts, has_price=bool(src.get("total") or src.get("quoteId")), - tms_quote=src if src and not quote_is_product(src) else {}, + tms_quote=src if quote_is_tms(src) else {}, product_quote=src if quote_is_product(src) else {}, ) diff --git a/inquiry-agent/agent/policy/land_text_flow.py b/inquiry-agent/agent/policy/land_text_flow.py index 231b309..006a592 100644 --- a/inquiry-agent/agent/policy/land_text_flow.py +++ b/inquiry-agent/agent/policy/land_text_flow.py @@ -98,6 +98,12 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): and not (injected_facts and injected_mode) ): return self._capture_lost_reason(sess, text, reply) + if sess: + from agent.policy.quote_adjust_ops import try_private_adjust + + adjusted = try_private_adjust(self, sess, text, reply) + if adjusted: + return adjusted if sess and phase not in _LAND_FRONT: # 查价后半段书签还在时,销售又发新陆运:只走陆运前半段。 # 禁止再 super 去海运 on_text,海运会把 LAND 转回来,递归到死、一句都不回。 @@ -362,6 +368,7 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): self._save(sess) return "ledger_fail" sess.quote = quote + copy.remember_tms_quote(sess, quote) sess.status = "已报价" sess.phase = "wait_collab" sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB) @@ -374,6 +381,7 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): first_or_same=sess.first_or_same, history_work_order_no=sess.history_work_order_no, status="已报价", + business_line=sess.business_line or "LAND", ) return "wait_collab" diff --git a/inquiry-agent/agent/policy/quote_adjust_ops.py b/inquiry-agent/agent/policy/quote_adjust_ops.py new file mode 100644 index 0000000..61b732d --- /dev/null +++ b/inquiry-agent/agent/policy/quote_adjust_ops.py @@ -0,0 +1,317 @@ +""" +调价执行:私聊出新卡,群里出全量文本 + Excel + 成交跟进。 + +本文件职责:资格、抽项、落主账、出站。算价只调用 schema.quote_adjust。 +禁止:回调线程同步跑;模型改价;群里出 PDF。 +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +from agent.policy import inquiry_copy as copy +from agent.schema.quote_adjust import ( + apply_adjust, + extract_adjust, + private_session_can_adjust, + quote_fee_rows, +) + +logger = logging.getLogger(__name__) + +_ADJUST_PHASES = frozenset({"wait_collab", "wait_adopt", "wait_adjust", "wait_deal"}) + + +def looks_like_adjust(text: str) -> bool: + """本地兜底:正式认调价走 DeepSeek A,这里只给无网单测。""" + return extract_adjust(text, allow_model=False).is_adjust + + +def session_quoted(sess: Any) -> bool: + """当前书签是否处在可尝试调价的阶段。""" + if sess is None: + return False + phase = str(getattr(sess, "phase", "") or "") + status = str(getattr(sess, "status", "") or "") + if phase in _ADJUST_PHASES: + return True + if status == "协商中" and (getattr(sess, "quote", None) or getattr(sess, "work_order_no", "")): + return True + return False + + +def quoted_adjust_flow(sender_id: str): + """ + 三条线里谁有可调报价就交给谁;多条时用最新工单号。 + + 私聊调价常被判成闲聊,选线不能再按「有海运书签就走海运」, + 否则陆运刚出的卡会被旧海运书签抢走、回开场白。 + """ + from agent.policy.air_text_flow import get_air_text_flow + from agent.policy.land_text_flow import get_land_text_flow + from agent.policy.sea_text_flow import get_sea_text_flow + + hits: list[tuple[str, Any]] = [] + for flow in (get_land_text_flow(), get_air_text_flow(), get_sea_text_flow()): + sess = flow.session_of(sender_id) + if session_quoted(sess): + hits.append((str(getattr(sess, "work_order_no", "") or ""), flow)) + if not hits: + return None + hits.sort(key=lambda item: item[0], reverse=True) + return hits[0][1] + + +def _baselines(quote: dict[str, Any]) -> tuple[str, str]: + src = dict(quote or {}) + cost = str(src.get("tms_baseline_cost") or src.get("cost") or src.get("costTotal") or "") + sale = str(src.get("tms_baseline_sale") or src.get("total") or "") + return cost, sale + + +def _reply_apply_messages(result, reply) -> None: + names = [str(x.get("name") or "") for x in quote_fee_rows(result.quote)] + if result.applied: + reply(copy.adjust_confirm(result.applied)) + if result.unmatched: + reply(copy.adjust_unmatched(result.unmatched, names)) + if result.ambiguous: + for name in result.ambiguous: + reply(copy.adjust_ambiguous(name, names)) + if result.lines_hint: + reply(copy.adjust_need_lines()) + if result.currency_hint: + reply(copy.adjust_currency_hint()) + + +def _emit_private_card(flow: Any, sess: Any, reply) -> None: + """新价格明细卡:海运/陆运带拉群,空运只带跳过协同。""" + line = (sess.business_line or "").upper() + no = sess.work_order_no + quote = dict(sess.quote or {}) + if line: + quote["business_line"] = line + first = getattr(sess, "first_or_same", "first") or "first" + history = getattr(sess, "history_work_order_no", "") or "" + if line in {"SEA", "LAND"} and hasattr(flow, "_emit_sea_hit"): + sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB) + flow._emit_sea_hit( + reply, + work_order_no=no, + quote=quote, + first_or_same=first, + history_work_order_no=history, + status="已报价", + business_line=line or "SEA", + ) + return + sess.allowed = (copy.BTN_SKIP_COLLAB,) + reply( + copy.tms_hit_card( + work_order_no=no, + quote=quote, + first_or_same=first, + history_work_order_no=history, + status="已报价", + ), + copy.tms_hit_wecom_payload( + work_order_no=no, + quote=quote, + first_or_same=first, + history_work_order_no=history, + status="已报价", + ), + ) + + +def try_private_adjust( + flow: Any, + sess: Any, + text: str, + reply, + *, + injected_adjust=None, +) -> Optional[str]: + """ + 私聊调价。不是调价或不够资格尝试则返回 None,让原流程继续。 + + 副作用:确认话术、主账 upsert、新卡、书签 waitVersion+1。 + """ + if not session_quoted(sess): + return None + names = [str(r.get("name") or "") for r in quote_fee_rows(getattr(sess, "quote", None))] + extract = extract_adjust(text, fee_names=names, injected=injected_adjust) + if not extract.is_adjust: + return None + reason = private_session_can_adjust( + str(getattr(sess, "status", "") or ""), + bool(quote_fee_rows(getattr(sess, "quote", None))), + str(getattr(sess, "collab_chat_id", "") or ""), + ) + if reason: + reply(copy.adjust_ineligible(reason)) + return "adjust_blocked" + result = apply_adjust( + dict(sess.quote or {}), + extract, + business_line=str(getattr(sess, "business_line", "") or ""), + tms_cost=_baselines(sess.quote)[0], + tms_sale=_baselines(sess.quote)[1], + ) + if result.code == "need_fee": + reply(copy.adjust_need_fee()) + return "adjust_need_fee" + if result.code == "need_lines": + reply(copy.adjust_need_lines()) + return "adjust_need_lines" + if result.code == "none_matched": + reply(copy.adjust_unmatched(result.unmatched, names)) + return "adjust_none" + if result.code == "ambiguous": + reply(copy.adjust_ambiguous((extract.items[0].fee_name if extract.items else ""), names)) + return "adjust_ambiguous" + if not result.ok: + return None + copy.remember_tms_quote(sess, sess.quote) + persist = dict(result.quote) + persist["source"] = "SALES_ADJUST" + persist["source_label"] = "销售调价" + result.quote["source"] = persist["source"] + result.quote["source_label"] = persist["source_label"] + writer = getattr(flow.ledger, "upsert_product_quote", None) or flow.ledger.upsert_quote + remark = copy.adjust_ledger_remark(result.applied) + try: + up = writer( + work_order_no=sess.work_order_no, + quote=persist, + to_status="已报价", + remark=remark, + kind="adjust", + ) or {} + except TypeError: + up = writer(work_order_no=sess.work_order_no, quote=persist, to_status="已报价") or {} + if not up.get("ok"): + reply("主账写入报价失败,调价未入账。") + return "ledger_fail" + _reply_apply_messages(result, reply) + sess.quote = persist + sess.status = "已报价" + sess.phase = "wait_collab" + sess.wait_version += 1 + flow._save(sess) + _emit_private_card(flow, sess, reply) + return "wait_collab" + + +def apply_group_adjust( + *, + flow: Any, + ticket: object, + sender_id: str, + text: str, + sess: Any = None, + chat_id: str = "", + injected_adjust=None, +) -> str: + """ + 群内销售调价。非销售只说明、不当报价。 + + 至少改成一项:全量文本 + Excel + 成交跟进。一项都没有:只说明。 + """ + from agent.policy.sea_group_ops import ( + _current_quote, + _facts_for_fill, + _render_and_send_sheet, + _save_product_quote, + emit_group_deal, + group_client_of, + hydrate_ticket, + is_sales, + is_terminal, + send_group_text, + ticket_no, + ticket_status, + ticket_view, + ) + + ticket = hydrate_ticket(flow, ticket) + no = ticket_no(ticket) + view = ticket_view(ticket) + room = (chat_id or str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")).strip() + client = group_client_of(flow) + if is_terminal(ticket): + send_group_text(client, room, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket))) + return "ticket_locked" + status = ticket_status(ticket) + if status not in {"已报价", "协商中"}: + send_group_text(client, room, copy.adjust_ineligible("no_quote")) + return "adjust_blocked" + if not is_sales(ticket, sender_id): + send_group_text(client, room, copy.adjust_only_sales()) + return "adjust_not_sales" + current = _current_quote(view, sess) + names = [str(r.get("name") or "") for r in quote_fee_rows(current)] + extract = extract_adjust(text, fee_names=names, injected=injected_adjust) + if not extract.is_adjust: + return "not_adjust" + result = apply_adjust( + current, + extract, + business_line=str(view.get("business_line") or getattr(sess, "business_line", "") or ""), + tms_cost=_baselines(current)[0], + tms_sale=_baselines(current)[1], + ) + if result.code == "need_fee": + send_group_text(client, room, copy.adjust_need_fee()) + return "adjust_need_fee" + if result.code == "need_lines": + send_group_text(client, room, copy.adjust_need_lines()) + return "adjust_need_lines" + if result.code == "none_matched": + send_group_text(client, room, copy.adjust_unmatched(result.unmatched, names)) + return "adjust_none" + if not result.ok: + send_group_text(client, room, copy.adjust_unmatched(result.unmatched or ["该项"], names)) + return "adjust_none" + copy.remember_tms_quote(sess, current) + _save_product_quote( + flow, + no, + result.quote, + sess, + remark=copy.adjust_ledger_remark(result.applied), + kind="adjust", + ) + ticket2 = ticket_view(ticket) + ticket2["quote"] = result.quote + ticket2["status"] = "已报价" + ticket2["work_order_no"] = no + parts = [copy.adjust_confirm(result.applied)] + if result.unmatched: + parts.append(copy.adjust_unmatched(result.unmatched, [str(r.get("name") or "") for r in quote_fee_rows(result.quote)])) + if result.lines_hint: + parts.append(copy.adjust_need_lines()) + if result.currency_hint: + parts.append(copy.adjust_currency_hint()) + line = str(view.get("business_line") or getattr(sess, "business_line", "") or "SEA") + parts.append(copy.adjust_group_body(result.quote, business_line=line)) + send_group_text(client, room, "\n".join(parts)) + facts = dict(view.get("facts") or {}) + if sess is not None and getattr(sess, "facts", None): + facts.update(dict(sess.facts or {})) + collab = dict(view.get("collab_facts") or {}) + if sess is not None and getattr(sess, "collab_facts", None): + collab.update(dict(sess.collab_facts or {})) + _render_and_send_sheet( + flow=flow, + chat_id=room, + work_order_no=no, + facts=_facts_for_fill(facts, collab, business_line=line), + quote=result.quote, + sales_id=sender_id, + business_line=line, + ) + emit_group_deal(flow=flow, ticket=ticket2, sess=sess, quote=result.quote) + logger.info("group_adjust wo=%s applied=%s", no, [x.get("name") for x in result.applied]) + return "group_adjusted" diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index aff261a..44443d8 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -65,6 +65,7 @@ def ticket_view(ticket: object) -> dict[str, Any]: ) view.setdefault("sales_name", view.get("salesName") or view.get("sales_name") or "") view.setdefault("lost_reason", view.get("lostReason") or view.get("lost_reason") or "") + view.setdefault("tms_quote", view.get("tmsQuote") or view.get("tms_quote") or {}) facts = dict(view.get("collab_facts") or view.get("collabFacts") or {}) view.setdefault("collab_facts", facts) flag = str(facts.get("__ended") or view.get("collab_ended") or view.get("collabEnded") or "").strip().lower() @@ -83,6 +84,7 @@ def ticket_view(ticket: object) -> dict[str, Any]: "business_line": getattr(ticket, "business_line", "") or "SEA", "facts": dict(getattr(ticket, "facts", {}) or {}), "quote": dict(getattr(ticket, "quote", {}) or {}), + "tms_quote": dict(getattr(ticket, "tms_quote", None) or {}), "collab_facts": dict(getattr(ticket, "collab_facts", {}) or {}), "collab_chat_id": getattr(ticket, "collab_chat_id", "") or "", "collab_member_ids": list(getattr(ticket, "collab_member_ids", []) or []), @@ -327,7 +329,9 @@ def hydrate_ticket(flow: Any, ticket: object) -> object: quote = dict(view.get("quote") or {}) has_quote = bool(quote.get("total") or _quote_fee_rows(quote)) has_status = bool(ticket_status(ticket)) - if has_facts and has_quote and has_status: + has_tms = bool(dict(view.get("tms_quote") or view.get("tmsQuote") or {})) + # 调价后 latest quote 仍有价,但不能因此跳过补 TMS 原价。 + if has_facts and has_quote and has_status and has_tms: return ticket no = ticket_no(ticket) sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip() @@ -352,6 +356,8 @@ def hydrate_ticket(flow: Any, ticket: object) -> object: merged["facts"] = dict(full_view.get("facts") or {}) if (not has_quote) and full_view.get("quote"): merged["quote"] = dict(full_view.get("quote") or {}) + if full_view.get("tms_quote") or full_view.get("tmsQuote"): + merged["tms_quote"] = dict(full_view.get("tms_quote") or full_view.get("tmsQuote") or {}) if (not has_status) and full_view.get("status"): merged["status"] = full_view.get("status") collab = dict(full_view.get("collab_facts") or {}) @@ -393,10 +399,7 @@ def send_brief_and_sheet( tms = dict(tms_quote) if tms_quote is not None else {} manual = dict(product_quote or {}) if tms_quote is None: - if _has_tms_quote(view_q) and not copy.quote_is_product(view_q): - tms = view_q - elif _has_tms_quote(sess_q) and not copy.quote_is_product(sess_q): - tms = sess_q + tms = copy.resolve_tms_quote(sess, view) if not manual: if copy.quote_is_product(override): manual = override @@ -691,6 +694,8 @@ def apply_product_quote( _park_pending_quote(no, quote, sess, flow) send_group_text(client, chat_id, copy.group_ask_tms_or_product()) return "wait_tms_choice" + if _quote_fee_rows(base): + quote = merge_quote_fees(base, quote) _save_product_quote(flow, no, quote, sess) ticket2 = _reload(flow, chat_id, no) or ticket send_brief_and_sheet( @@ -954,7 +959,18 @@ def emit_group_deal( text = copy.group_deal_followup(sales_name=sales_name, work_order_no=no) send_group_text(client, chat_id, text) carder = getattr(client, "send_group_card", None) - payload = copy.deal_wecom_payload(work_order_no=no, quote=q, facts=facts, card_version=ver) + payload = copy.deal_wecom_payload( + work_order_no=no, + quote=q, + facts=facts, + card_version=ver, + business_line=str( + view.get("business_line") + or getattr(sess, "business_line", "") + or q.get("business_line") + or "" + ), + ) if callable(carder): carder(chat_id=chat_id, template_card=payload.get("template_card") or {}) @@ -1104,14 +1120,11 @@ def _current_quote(view: dict[str, Any], sess: Any) -> dict[str, Any]: def _has_tms_quote(quote: dict[str, Any] | None) -> bool: - """当前价来自 TMS 才要问;已经是产品价不再替用户选。""" + """当前价来自 TMS 才要问;产品价、销售调价不再替用户选。""" src = dict(quote or {}) - label = str(src.get("source_label") or src.get("source") or "") - if "产品" in label: - return False if not (src.get("total") or _quote_fee_rows(src)): return False - return "TMS" in label.upper() or "标准报价" in label or not label + return copy.quote_is_tms(src) def _park_pending_quote(no: str, quote: dict[str, Any], sess: Any, flow: Any) -> None: @@ -1378,12 +1391,39 @@ def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None return out -def _save_product_quote(flow: Any, no: str, quote: dict[str, Any], sess: Any) -> None: +def _save_product_quote( + flow: Any, + no: str, + quote: dict[str, Any], + sess: Any, + *, + remark: str = "", + kind: str = "", +) -> None: writer = getattr(flow.ledger, "upsert_product_quote", None) or getattr( flow.ledger, "upsert_quote", None ) + persist = dict(quote or {}) + if (kind or "").strip() == "adjust": + persist["source"] = "SALES_ADJUST" + persist["source_label"] = "销售调价" + quote["source"] = persist["source"] + quote["source_label"] = persist["source_label"] + else: + persist.setdefault("source", "PRODUCT") + label = str(persist.get("source_label") or "") + if (not label) or ("TMS" in label.upper()): + persist["source_label"] = "手工报价" if callable(writer): - saved = writer(work_order_no=no, quote=quote, to_status="已报价") or {} + extras: dict[str, Any] = {} + if remark: + extras["remark"] = remark + if kind: + extras["kind"] = kind + try: + saved = writer(work_order_no=no, quote=persist, to_status="已报价", **extras) or {} + except TypeError: + saved = writer(work_order_no=no, quote=persist, to_status="已报价") or {} if not saved.get("ok") and saved.get("error"): logger.warning("产品报价未入主账 wo=%s err=%s", no, saved.get("error")) if sess is not None: diff --git a/inquiry-agent/agent/policy/sea_text_flow.py b/inquiry-agent/agent/policy/sea_text_flow.py index 78cc529..fec08d9 100644 --- a/inquiry-agent/agent/policy/sea_text_flow.py +++ b/inquiry-agent/agent/policy/sea_text_flow.py @@ -92,6 +92,12 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): and not (injected_facts and injected_mode) ): return self._capture_lost_reason(sess, text, reply) + if sess: + from agent.policy.quote_adjust_ops import try_private_adjust + + adjusted = try_private_adjust(self, sess, text, reply) + if adjusted: + return adjusted snap = extract_inquiry_snapshot( text, @@ -271,6 +277,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): self._save(sess) return "ledger_fail" sess.quote = quote + copy.remember_tms_quote(sess, quote) sess.status = "已报价" sess.phase = "wait_collab" sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB) @@ -283,6 +290,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): first_or_same=first, history_work_order_no=history_wo, status="已报价", + business_line=sess.business_line or "SEA", ) return "wait_collab" @@ -347,35 +355,44 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): first_or_same: str = "first", history_work_order_no: str = "", status: str = "已报价", + business_line: str = "", ) -> None: """ 先发完整价格明细文本,再发带按钮的卡。 企微只发卡时,引用条超长会被截断;文本不受限。 """ + line = (business_line or getattr(self, "business_line", "") or "SEA").upper() + if line not in {"SEA", "LAND"}: + line = "SEA" + src = dict(quote or {}) + src["business_line"] = line reply( copy.sea_quote_detail_text( work_order_no=work_order_no, - quote=quote, + quote=src, first_or_same=first_or_same, history_work_order_no=history_work_order_no, status=status, + business_line=line, ) ) reply( copy.sea_tms_hit_card( work_order_no=work_order_no, - quote=quote, + quote=src, first_or_same=first_or_same, history_work_order_no=history_work_order_no, status=status, + business_line=line, ), copy.sea_tms_hit_wecom_payload( work_order_no=work_order_no, - quote=quote, + quote=src, first_or_same=first_or_same, history_work_order_no=history_work_order_no, status=status, + business_line=line, ), ) @@ -388,6 +405,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): first_or_same=sess.first_or_same, history_work_order_no=sess.history_work_order_no, status=sess.status or "已报价", + business_line=sess.business_line or "SEA", ) return "wait_collab" if sess.business_line == "SEA" and sess.phase == "tms_miss": @@ -541,6 +559,8 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): allowed_keys=keys, ) + ticket_view = ticket if isinstance(ticket, dict) else {} + tms = copy.resolve_tms_quote(sess, ticket_view) brief = copy.sea_group_brief( work_order_no=no, facts=sess.facts, @@ -548,7 +568,9 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): collab_facts=sess.collab_facts, sales_name=sales_name, product_names=product_names, - has_price=bool(sess.quote and sess.status == "已报价"), + has_price=bool(tms or sess.quote), + tms_quote=tms, + product_quote=sess.quote if copy.quote_is_product(sess.quote) else None, transport_mode=self._group_brief_mode(sess), ) client.send_group(chat_id=chat_id, content=brief) diff --git a/inquiry-agent/agent/routing/__init__.py b/inquiry-agent/agent/routing/__init__.py index c7b2a7f..37a53e6 100644 --- a/inquiry-agent/agent/routing/__init__.py +++ b/inquiry-agent/agent/routing/__init__.py @@ -55,9 +55,23 @@ class IntentRouter: """ return self._a().route_stub_fixture(text=text, sender_id=sender_id) - def route(self, *, text: str, sender_id: str) -> RouteDecision: - """一条入站一个 RouteDecision。""" - return self._a().route(text=text, sender_id=sender_id) + def route( + self, + *, + text: str, + sender_id: str, + session_quoted: bool = False, + fee_names: Optional[list[str]] = None, + work_order_no: str = "", + ) -> RouteDecision: + """一条入站一个 RouteDecision。已报价时把书签事实交给 A 认调价。""" + return self._a().route( + text=text, + sender_id=sender_id, + session_quoted=session_quoted, + fee_names=fee_names, + work_order_no=work_order_no, + ) def route_inbound( self, @@ -70,7 +84,7 @@ class IntentRouter: 入站后再路由。 点卡片是结构化事件,不进大模型。 - 自由文字交给 DeepSeek A 认意图(续办 / 新询价 / 闲聊),不要求固定口令。 + 自由文字交给 DeepSeek A 认意图(续办 / 新询价 / 调价 / 闲聊),不要求固定口令。 """ if not sender_id: return RouteDecision(intent="reject_no_sender", confidence=1.0) @@ -92,7 +106,28 @@ class IntentRouter: logger.info("route 陆运核对卡确定:跳过闲聊模型 sender=%s", sender_id) return RouteDecision(intent="ordinary_text_inquiry", confidence=1.0) logger.info("route 确定类短句但无陆运核对卡,仍走模型 sender=%s", sender_id) - return self.route(text=text, sender_id=sender_id) + quoted = False + names: list[str] = [] + wo = "" + try: + from agent.policy.quote_adjust_ops import quoted_adjust_flow + from agent.schema.quote_adjust import quote_fee_rows + + flow = quoted_adjust_flow(sender_id) + sess = flow.session_of(sender_id) if flow is not None else None + if sess is not None: + quoted = True + wo = str(getattr(sess, "work_order_no", "") or "") + names = [str(r.get("name") or "") for r in quote_fee_rows(getattr(sess, "quote", None))] + except Exception: + logger.exception("route 收集调价书签失败 sender=%s", sender_id) + return self.route( + text=text, + sender_id=sender_id, + session_quoted=quoted, + fee_names=names, + work_order_no=wo, + ) def enqueue_ordered_actions( self, diff --git a/inquiry-agent/agent/routing/air_group_intent.py b/inquiry-agent/agent/routing/air_group_intent.py index 1ee3203..a617404 100644 --- a/inquiry-agent/agent/routing/air_group_intent.py +++ b/inquiry-agent/agent/routing/air_group_intent.py @@ -17,6 +17,7 @@ from agent.routing.group_intent import ( INTENT_DROP_TMS, INTENT_FIELDS, INTENT_KEEP_TMS, + INTENT_ADJUST, INTENT_LOST, INTENT_NEGOTIATE, INTENT_OTHER, @@ -40,6 +41,7 @@ _VALID = { INTENT_PICK_OPTION, INTENT_FIELDS, INTENT_QUOTE, + INTENT_ADJUST, INTENT_KEEP_TMS, INTENT_DROP_TMS, INTENT_CONFIRM_TMS, @@ -111,6 +113,10 @@ def classify_air_group_text( except Exception: logger.exception("空运群意图模型失败,回退规则") from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote + from agent.schema.quote_adjust import extract_adjust + + if extract_adjust(raw).is_adjust: + return INTENT_ADJUST if extract_product_quote(raw): return INTENT_QUOTE confirmed = _hint_confirm_tms(raw) @@ -163,6 +169,7 @@ def _classify_with_model(text: str, settings) -> str: "air_activate=只是指定/切换工单号;" "air_pick_option=在选舱位选项编号;" "collab_fields=在补是否含电/是否含磁;" + "quote_adjust=销售在调已有费用,口令不写死(上升、往上抬、加一成、改到某金额都算),不是新产品报价;" "product_quote=航线在报价格或费用(含具体金额);" "quote_confirm_tms=确认沿用当前TMS报价;" "quote_keep_tms=要在已有TMS报价上改;" @@ -171,7 +178,7 @@ def _classify_with_model(text: str, settings) -> str: "deal_negotiate=销售要继续协商;" "other=闲聊。" "一句话里既有锁舱又有工单号,回 air_lock。" - "有金额就是 product_quote。" + "改已有费用优先 quote_adjust;新报价格才是 product_quote。" ) data = client.chat( mode=LlmMode.ORDERED_ACTIONS, diff --git a/inquiry-agent/agent/routing/deepseek_a.py b/inquiry-agent/agent/routing/deepseek_a.py index 1317134..51471d8 100644 --- a/inquiry-agent/agent/routing/deepseek_a.py +++ b/inquiry-agent/agent/routing/deepseek_a.py @@ -67,6 +67,9 @@ def _prepare_model_tool_args( if intent == "continue_thread" or action_type == "CONTINUE_HISTORICAL_TICKET": data["intent"] = "continue_thread" action["action_type"] = "CONTINUE_HISTORICAL_TICKET" + if intent == "quote_adjust" or action_type == "QUOTE_ADJUST": + data["intent"] = "quote_adjust" + action["action_type"] = "QUOTE_ADJUST" target = action.get("target") if isinstance(action.get("target"), dict) else {} wo = str(target.get("work_order_no") or "").strip() if data.get("intent") == "continue_thread" and not wo: @@ -98,9 +101,13 @@ _SYSTEM = ( "原话在描述新的运输需求(港口/机场、货物、件数重量体积、空运海运陆运等)," "即使偶尔拿旧单号当参考,也走新询价,work_order_no 留空。" "陆运核对卡之后回复确定、确认、查价、可以查了,仍是新询价,禁止标闲聊。" + "【调价】intent=quote_adjust,action_type=QUOTE_ADJUST。" + "inbound_facts 里 session_quoted=true 时,销售用自然语言改当前已有报价:" + "涨、跌、加、减、上升、上浮、往上抬、改到某金额、在现有报价基础上调整,口令不写死。" + "只说报价/总价、没点名费用,仍是调价,不是闲聊也不是新询价。" "【闲聊】intent=ordinary_text_other,action_type=ORDINARY_TEXT_OTHER。" "问候、谢谢、无关闲聊。点卡片按钮字(生成Excel/生成PDF/不采用等)也标闲聊。" - "禁止因为销售没说指定口令就把续办判成新询价。" + "禁止因为销售没说指定口令就把续办或调价判成新询价或闲聊。" ) @@ -134,22 +141,29 @@ class DeepSeekARouter: allow_network=allow_network, ) - def build_messages(self, *, text: str, sender_id: str) -> list[dict[str, Any]]: - """构造仅含 inbound_facts 的 messages(不做字段抽取)。""" - facts = { - "sender_id": sender_id, - "text": text, - "text_sha256": _digest_text(text or ""), - } + def build_messages( + self, + *, + text: str, + sender_id: str, + session_quoted: bool = False, + fee_names: Optional[list[str]] = None, + work_order_no: str = "", + ) -> list[dict[str, Any]]: + """构造仅含 inbound_facts 的 messages(不做字段抽取、不算价)。""" + names = "、".join(str(x).strip() for x in (fee_names or []) if str(x).strip()) return [ {"role": "system", "content": _SYSTEM}, { "role": "user", "content": ( "inbound_facts (JSON-like):\n" - f"sender_id={facts['sender_id']}\n" - f"text_sha256={facts['text_sha256']}\n" - f"text={facts['text']}\n" + f"sender_id={sender_id}\n" + f"text_sha256={_digest_text(text or '')}\n" + f"text={text}\n" + f"session_quoted={'true' if session_quoted else 'false'}\n" + f"work_order_no={work_order_no or '-'}\n" + f"fee_names={names or '-'}\n" "Call route_decision once." ), }, @@ -212,12 +226,21 @@ class DeepSeekARouter: logger.info("DeepSeekA stub fixture sender=%s", sender_id) return self.route_from_tool_args(args) - def route(self, *, text: str, sender_id: str) -> RouteDecision: + def route( + self, + *, + text: str, + sender_id: str, + session_quoted: bool = False, + fee_names: Optional[list[str]] = None, + work_order_no: str = "", + ) -> RouteDecision: """ 执行 A 路由。 无 sender_id → reject;未开网络或未确认数据用途 → stub fixture; 开网络 → tool_choice 强制 route_decision。 + session_quoted 告诉模型当前私聊已有可调报价,口令不写死。 """ if not sender_id: return RouteDecision(intent="reject_no_sender", confidence=1.0) @@ -228,8 +251,20 @@ class DeepSeekARouter: ) return self.route_stub_fixture(text=text, sender_id=sender_id) - logger.info("DeepSeekA 真网路由 sender=%s chars=%s", sender_id, len(text or "")) - messages = self.build_messages(text=text, sender_id=sender_id) + logger.info( + "DeepSeekA 真网路由 sender=%s chars=%s quoted=%s wo=%s", + sender_id, + len(text or ""), + session_quoted, + work_order_no or "-", + ) + messages = self.build_messages( + text=text, + sender_id=sender_id, + session_quoted=session_quoted, + fee_names=fee_names, + work_order_no=work_order_no, + ) tools = [ordered_actions_tool_definition()] result: LlmHttpResult = self.http.chat( mode=LlmMode.ORDERED_ACTIONS, diff --git a/inquiry-agent/agent/routing/dispatch.py b/inquiry-agent/agent/routing/dispatch.py index b37bcec..62e3283 100644 --- a/inquiry-agent/agent/routing/dispatch.py +++ b/inquiry-agent/agent/routing/dispatch.py @@ -123,6 +123,7 @@ HANDLER_REGISTRY: dict[str, HandlerFn] = { "echo_text": _wrap_echo, "ordinary_text_inquiry": _wrap_text_inquiry, "ordinary_text_other": _wrap_text_other, + "quote_adjust": _wrap_text_inquiry, "attachment_inquiry": _wrap_attachment, "image_inquiry": _wrap_image, "multi_segment_transport": _wrap_new, @@ -262,6 +263,10 @@ def dispatch_inbound(message: InboundMessage, decision: RouteDecision) -> str: logger.info("dispatch 陆运核对卡确定:纠正闲聊路由 sender=%s", message.sender_id) handle_text_inquiry(message) return "ordinary_text_inquiry" + if decision.intent == "quote_adjust": + logger.info("dispatch 调价意图 sender=%s", message.sender_id) + handle_text_inquiry(message, prefer_quoted=True) + return "quote_adjust" if decision.intent == "continue_thread": continue_thread.handle_continue_thread(message, thread_id=decision.thread_id) return decision.intent diff --git a/inquiry-agent/agent/routing/group_intent.py b/inquiry-agent/agent/routing/group_intent.py index 55ee22a..65ff27f 100644 --- a/inquiry-agent/agent/routing/group_intent.py +++ b/inquiry-agent/agent/routing/group_intent.py @@ -16,6 +16,7 @@ logger = logging.getLogger(__name__) INTENT_FIELDS = "collab_fields" INTENT_QUOTE = "product_quote" +INTENT_ADJUST = "quote_adjust" INTENT_DISSOLVE = "dissolve" INTENT_KEEP_TMS = "quote_keep_tms" INTENT_DROP_TMS = "quote_drop_tms" @@ -28,6 +29,7 @@ INTENT_OTHER = "other" _VALID = { INTENT_FIELDS, INTENT_QUOTE, + INTENT_ADJUST, INTENT_DISSOLVE, INTENT_KEEP_TMS, INTENT_DROP_TMS, @@ -161,7 +163,10 @@ def classify_group_text( except Exception: logger.exception("群意图模型失败,回退标签字段") from agent.llm.extract_text import extract_product_quote + from agent.schema.quote_adjust import extract_adjust + if extract_adjust(raw).is_adjust: + return INTENT_ADJUST if extract_product_quote(raw): return INTENT_QUOTE confirmed = _hint_confirm_tms(raw) @@ -227,6 +232,7 @@ def _classify_with_model(text: str, settings) -> str: system = ( "你是海运协同群意图分类。只回复一个英文词,不要解释。" "collab_fields=在补贸易条款/货好时间/商品海关编码/含油含电含磁;" + "quote_adjust=销售在调已有费用,口令不写死(上升、上浮、往上抬、加一成、减、改到某金额都算),不是新产品报价;" "product_quote=产品在报价格或费用(含具体金额);" "quote_confirm_tms=产品确认沿用当前TMS报价,没有报新金额;" "quote_keep_tms=要在已有TMS报价上改,没有报新金额;" @@ -248,6 +254,7 @@ def _classify_with_model(text: str, settings) -> str: ) content = str(getattr(data, "content", "") or "").strip().lower() for token in ( + INTENT_ADJUST, INTENT_QUOTE, INTENT_CONFIRM_TMS, INTENT_KEEP_TMS, diff --git a/inquiry-agent/agent/routing/ordered_actions.py b/inquiry-agent/agent/routing/ordered_actions.py index 78de462..2e4e1a8 100644 --- a/inquiry-agent/agent/routing/ordered_actions.py +++ b/inquiry-agent/agent/routing/ordered_actions.py @@ -32,6 +32,7 @@ ACTION_INTENTS: dict[str, str] = { "ORDINARY_TEXT_INQUIRY": "ordinary_text_inquiry", "ORDINARY_TEXT_OTHER": "ordinary_text_other", "CONTINUE_HISTORICAL_TICKET": "continue_thread", + "QUOTE_ADJUST": "quote_adjust", } @@ -102,7 +103,8 @@ def ordered_actions_tool_definition() -> dict[str, Any]: "Sales will speak naturally; do not require a command phrase. " "Resume any past ticket (any wording, or a bare WO+12-digit number) " "with CONTINUE_HISTORICAL_TICKET / continue_thread and fill " - "target.work_order_no. New shipping demand uses ORDINARY_TEXT_INQUIRY." + "target.work_order_no. New shipping demand uses ORDINARY_TEXT_INQUIRY. " + "Adjusting an existing quoted price (any wording) uses QUOTE_ADJUST." ), "strict": True, "parameters": parameters, diff --git a/inquiry-agent/agent/schema/quote_adjust.py b/inquiry-agent/agent/schema/quote_adjust.py new file mode 100644 index 0000000..809c541 --- /dev/null +++ b/inquiry-agent/agent/schema/quote_adjust.py @@ -0,0 +1,653 @@ +""" +调价规则:抽项结构、费用对齐、比例/改到、币种合计、利润点。 + +本文件职责:只算新价,不发企微、不改六态、不写主账。 +模型只负责「是不是调价、调哪几项」;金额必须走这里,禁止模型改价。 +调用:私聊/群 Worker 槽;禁止回调线程同步跑。 +""" + +from __future__ import annotations + +import json +import logging +import re +from dataclasses import dataclass, field +from typing import Any, Optional + +logger = logging.getLogger(__name__) + +_CLOSED = frozenset({"已成交", "未成交", "已关闭"}) +_QUOTED = frozenset({"已报价", "协商中"}) + +_PERCENT_UP = re.compile( + r"(上调|上涨|上升|上浮|提高|加|涨|升)\s*(\d+(?:\.\d+)?)\s*%" +) +_PERCENT_DOWN = re.compile(r"(下调|下降|降低|减|降)\s*(\d+(?:\.\d+)?)\s*%") +_TARGET = re.compile( + r"(提升到|减少到|上调到|下调到|加到|减到|调到|改为|改成)\s*(\d+(?:\.\d+)?)" +) +_SPLIT = re.compile(r"[,,;;。!!??\n]|和(?=\S)") +_FEE_TOKEN = re.compile(r"([\u4e00-\u9fffA-Za-z0-9]{1,16}费)") +_TOTAL_WORDS = ("合计", "总价", "汇总价", "总销售价", "总成本价") +_QUOTE_WORD = "报价" + + +@dataclass +class AdjustItem: + """一条抽出来的调价项。kind=percent 时 value 是比例;kind=target 时 value 是金额。""" + + fee_name: str + kind: str + direction: str + value: float + + +@dataclass +class AdjustExtract: + """一句话抽完的调价意图。wants_total=只动合计数字,不改明细。""" + + is_adjust: bool + items: list[AdjustItem] = field(default_factory=list) + wants_total: bool = False + + +@dataclass +class ApplyResult: + """规则算完的结果。quote 是新副本,入参原报价不被改。""" + + ok: bool + code: str + quote: dict[str, Any] + applied: list[dict[str, Any]] = field(default_factory=list) + unmatched: list[str] = field(default_factory=list) + ambiguous: list[str] = field(default_factory=list) + currency_hint: bool = False + lines_hint: bool = False + profit_point: Optional[float] = None + + +def private_session_can_adjust(status: str, has_quote: bool, collab_chat_id: str) -> str: + """ + 私聊能不能按未协同套走。空串=可以。 + + already_collab / closed / no_quote 给话术用。 + """ + if (collab_chat_id or "").strip(): + return "already_collab" + st = (status or "").strip() + if st in _CLOSED: + return "closed" + if st not in _QUOTED or not has_quote: + return "no_quote" + return "" + + +def _fee_key(name: str) -> str: + raw = (name or "").replace(" ", "").replace("(", "(").replace(")", ")") + for sep in ("/", "/"): + if sep in raw: + raw = raw.split(sep, 1)[0] + return raw.strip() + + +def fee_name_same(left: str, right: str) -> bool: + """费用名对齐:海运费=海运费用;操作费 ≠ 码头操作费。""" + a = _fee_key(left) + b = _fee_key(right) + if not a or not b: + return False + if a == b: + return True + if a == b + "用" or a == b + "费用" or b == a + "用" or b == a + "费用": + return True + if a + "费" == b or b + "费" == a: + return True + if len(a) >= 3 and len(a) == len(b) and a[:-1] == b[:-1] and (a.endswith("费") or b.endswith("费")): + return True + return False + + +def match_fee_names(spoken: str, rows: list[dict[str, Any]]) -> list[str]: + """对上当前明细里的费用名;0/1/多条都原样返回。""" + hits: list[str] = [] + for row in rows: + name = str(row.get("name") or "").strip() + if name and fee_name_same(spoken, name) and name not in hits: + hits.append(name) + if hits: + return hits + spoken_key = _fee_key(spoken) + if spoken_key: + for row in rows: + name = str(row.get("name") or "").strip() + key = _fee_key(name) + if key and (spoken_key in key or key in spoken_key) and name not in hits: + hits.append(name) + return hits + + +def quote_fee_rows(quote: dict[str, Any] | None) -> list[dict[str, Any]]: + """抽出费用行。TMS 常把 fee_lines 存成 JSON 字符串。""" + src = dict(quote or {}) + raw = src.get("fee_rows") or src.get("fee_lines") or src.get("feeItems") or [] + if isinstance(raw, str) and raw.strip(): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + raw = [] + if not isinstance(raw, list): + return [] + rows: list[dict[str, Any]] = [] + for item in raw: + if not isinstance(item, dict): + continue + name = str(item.get("name") or item.get("label") or "").strip() + amount = str(item.get("amount") or item.get("value") or item.get("salePrice") or "").strip() + if name and amount: + row = dict(item) + row["name"] = name + row["amount"] = amount + rows.append(row) + return rows + + +def parse_money(raw: Any) -> tuple[Optional[float], str]: + """抽出金额和币种前缀。读不了返回 (None, '')。""" + text = str(raw if raw not in (None, "") else "").strip() + if not text or text == "-": + return None, "" + ccy = "" + upper = text.upper().replace(",", "") + for prefix in ("USD", "CNY", "EUR", "HKD", "JPY", "GBP", "RMB"): + if upper.startswith(prefix): + ccy = "CNY" if prefix == "RMB" else prefix + text = text[len(prefix) :].strip() + break + cleaned = text.replace(",", "") + try: + return float(cleaned), ccy + except ValueError: + return None, ccy + + +def format_money(value: float, *, sample: str = "", currency: str = "") -> str: + """金额写法跟原行对齐:有小数留两位,整数也写两位方便对账。""" + body = f"{value:.2f}" + code = (currency or "").strip().upper() + if code == "RMB": + code = "CNY" + sample_text = str(sample or "") + if code and (sample_text.upper().startswith(code) or sample_text.upper().startswith("RMB")): + return f"{code} {body}" + return body + + +def row_currency(row: dict[str, Any] | None, fallback: str = "") -> str: + src = dict(row or {}) + code = str(src.get("currency") or src.get("currencyCode") or fallback or "").strip().upper() + if code == "RMB": + return "CNY" + if code: + return code + _, prefix = parse_money(src.get("amount")) + return prefix or (fallback or "").strip().upper().replace("RMB", "CNY") + + +def _apply_amount(row: dict[str, Any], amount: str) -> None: + row["amount"] = amount + row["unit_price"] = amount + for key in ("salePrice", "price", "value"): + if row.get(key) not in (None, ""): + row[key] = amount + + +def effective_quote(tms: dict[str, Any] | None, overlay: dict[str, Any] | None) -> dict[str, Any]: + """ + 人工盖 TMS:同名用人工,没有的项保留 TMS,人工新增的补上。 + + 合计先沿用 TMS,再按同币种差额加减。不做汇率。 + """ + base = dict(tms or {}) + rows = [dict(x) for x in quote_fee_rows(base)] + incoming = dict(overlay or {}) + for item in quote_fee_rows(incoming): + name = str(item.get("name") or "").strip() + if not name: + continue + idx = next((i for i, r in enumerate(rows) if fee_name_same(name, str(r.get("name") or ""))), -1) + if idx < 0: + rows.append(dict(item)) + continue + old = dict(rows[idx]) + _apply_amount(old, str(item.get("amount") or "")) + if item.get("currency"): + old["currency"] = item.get("currency") + rows[idx] = old + out = dict(base) + out["fee_rows"] = rows + out["fee_lines"] = rows + header = str(out.get("currency") or incoming.get("currency") or "").strip() + base_total, _ = parse_money(base.get("total")) + if base_total is not None: + total_ccy = row_currency({"currency": header, "amount": base.get("total")}, header) + old_rows = quote_fee_rows(base) + delta = 0.0 + for new_row in rows: + new_amt, _ = parse_money(new_row.get("amount")) + if new_amt is None: + continue + line_ccy = row_currency(new_row, total_ccy) + if line_ccy and total_ccy and line_ccy != total_ccy: + continue + idx = next( + (i for i, r in enumerate(old_rows) if fee_name_same(str(new_row.get("name") or ""), str(r.get("name") or ""))), + -1, + ) + if idx < 0: + delta += new_amt + continue + old_amt, _ = parse_money(old_rows[idx].get("amount")) + if old_amt is None: + continue + delta += new_amt - old_amt + out["total"] = format_money(base_total + delta, sample=str(base.get("total") or ""), currency=header) + return out + + +def extract_adjust( + text: str, + *, + fee_names: list[str] | None = None, + injected: AdjustExtract | dict[str, Any] | None = None, + allow_model: Optional[bool] = None, + chat_fn: Any = None, +) -> AdjustExtract: + """ + 从一句话抽出调价项。单测可注入;正式优先 DeepSeek B。 + + 本地回退只给无网/模型失败用,不是正式识别。 + allow_model=False 时禁止打模型。 + """ + if injected is not None: + if isinstance(injected, AdjustExtract): + return injected + items = [] + for raw in injected.get("items") or []: + if not isinstance(raw, dict): + continue + items.append( + AdjustItem( + fee_name=str(raw.get("fee_name") or "").strip(), + kind=str(raw.get("kind") or "percent"), + direction=str(raw.get("direction") or "up"), + value=float(raw.get("value") or 0), + ) + ) + return AdjustExtract( + is_adjust=bool(injected.get("is_adjust")), + items=items, + wants_total=bool(injected.get("wants_total")), + ) + raw = (text or "").strip() + if not raw: + return AdjustExtract(is_adjust=False) + names = [str(x).strip() for x in (fee_names or []) if str(x).strip()] + use_model = allow_model + if use_model is None: + try: + from agent.config import get_settings + + settings = get_settings() + use_model = bool( + getattr(settings, "llm_allow_network", False) + and getattr(settings, "llm_data_usage_confirmed", False) + ) + except Exception: + use_model = False + if use_model or chat_fn is not None: + try: + from agent.llm.mode_extract_adjust import invoke_extract_adjust + + modeled = invoke_extract_adjust( + raw, + fee_names=names, + allow_network=bool(use_model), + chat_fn=chat_fn, + ) + if modeled is not None: + if modeled.wants_total and not modeled.items: + op = _item_from_chunk(raw, "") + if op: + modeled.items = [op] + return modeled + except Exception: + logger.exception("调价模型抽取失败,回退本地") + names_sorted = sorted(names, key=len, reverse=True) + blob = raw.replace(" ", "") + total_hit = any(w in raw for w in _TOTAL_WORDS) + has_op = bool(_PERCENT_UP.search(raw) or _PERCENT_DOWN.search(raw) or _TARGET.search(raw)) + fee_in_text = [n for n in names_sorted if n and n in raw] + if total_hit and has_op and not fee_in_text: + op = _item_from_chunk(raw, "") + return AdjustExtract(is_adjust=True, wants_total=True, items=[op] if op else []) + if _QUOTE_WORD in raw and has_op and not fee_in_text and not total_hit: + return AdjustExtract(is_adjust=True, wants_total=False) + items: list[AdjustItem] = [] + for part in _SPLIT.split(raw) or [raw]: + chunk = (part or "").strip() + if not chunk: + continue + spoken = "" + for name in names_sorted: + if name and name in chunk: + spoken = name + break + if not spoken: + found = _FEE_TOKEN.findall(chunk) + spoken = found[-1] if found else "" + if not spoken: + continue + item = _item_from_chunk(chunk, spoken) + if item: + items.append(item) + if not items and has_op: + # 说了费用口语但和名单不完全一致:整句再扫一次 + item = _item_from_chunk(raw, "") + if item: + items.append(item) + return AdjustExtract(is_adjust=bool(items or has_op), items=items) + + +def _item_from_chunk(chunk: str, fee_name: str) -> Optional[AdjustItem]: + up = _PERCENT_UP.search(chunk) + down = _PERCENT_DOWN.search(chunk) + target = _TARGET.search(chunk) + if target: + return AdjustItem(fee_name=fee_name, kind="target", direction="set", value=float(target.group(2))) + if up: + return AdjustItem(fee_name=fee_name, kind="percent", direction="up", value=float(up.group(2))) + if down: + return AdjustItem(fee_name=fee_name, kind="percent", direction="down", value=float(down.group(2))) + return None + + +def total_price_label( + business_line: str = "", + transport_mode: str = "", + quote: dict[str, Any] | None = None, +) -> str: + """ + 销售可见总价名称。 + + 空运一律总销售价。海运/陆运:TMS、产品报价是总成本价,销售调价后才是总销售价。 + 禁止写「合计」。Excel/PDF 栏目名不走这里。 + """ + src = dict(quote or {}) + raw = f"{business_line or ''} {transport_mode or ''} {src.get('business_line') or ''}" + key = raw.upper() + if key.strip() in {"AIR"} or "AIR" in key or "空运" in raw: + return "总销售价" + source = str(src.get("source") or "").strip().upper() + label = str(src.get("source_label") or src.get("sourceLabel") or "").replace(" ", "") + if source == "SALES_ADJUST" or "销售调价" in label: + return "总销售价" + return "总成本价" + + +def _mark_sales_adjust(quote: dict[str, Any]) -> None: + """算价成功后这版就是销售调价,卡片和后台都按销售价展示。""" + quote["source"] = "SALES_ADJUST" + quote["source_label"] = "销售调价" + + +def compute_profit(*, x: Optional[float], baseline: str) -> Optional[float]: + """ + 只在销售调价后算一个利润点。 + + 基准=调价前生效价:有手工报价用手工,没有才用 TMS。 + """ + if x is None: + return None + base, _ = parse_money(baseline) + if base is None or abs(base) < 1e-9: + return None + return (x - base) / base * 100.0 + + +def _profit_baseline( + src: dict[str, Any], + *, + tms_cost: str, + tms_sale: str, + business_line: str, +) -> str: + """第一次调价锁住基准;之后连续调仍对这一档。""" + stored = str(src.get("profit_baseline") or "").strip() + if stored: + return stored + current = str(src.get("total") or "").strip() + if current: + return current + line = (business_line or "").upper() + if line == "AIR": + return str(tms_sale or "").strip() + return str(tms_cost or tms_sale or "").strip() + + +def _attach_profit( + out: dict[str, Any], + src: dict[str, Any], + *, + x: Optional[float], + tms_cost: str, + tms_sale: str, + business_line: str, +) -> Optional[float]: + """写入利润点和锁定基准。原报价没有利润点,销售没调过就不算。""" + baseline = _profit_baseline(src, tms_cost=tms_cost, tms_sale=tms_sale, business_line=business_line) + profit = compute_profit(x=x, baseline=baseline) + if profit is not None: + out["profit_point"] = profit + if baseline: + out.setdefault("profit_baseline", baseline) + line = (business_line or str(src.get("business_line") or "")).upper() + if line: + out["business_line"] = line + return profit + + +def _single_line_same_currency(rows: list[dict[str, Any]], total_ccy: str) -> Optional[int]: + """ + 价格明细只有一项,且该项币种与总价一致:总价和明细绑在一起。 + + 只看出项数和币种,不看出价是否相等。多项或币种不同:不算绑定。 + """ + if len(rows) != 1: + return None + line_ccy = row_currency(rows[0], "") + if line_ccy and total_ccy and line_ccy != total_ccy: + return None + return 0 + + +def _apply_total_only( + src: dict[str, Any], + extract: AdjustExtract, + *, + multi: bool, + total_ccy: str, + business_line: str, + tms_cost: str, + tms_sale: str, +) -> ApplyResult: + """ + 改合计。多项或明细币种与总价不同:只改合计数字。 + + 明细只有一项且币种与总价一致:明细跟着改,不再提醒分摊。 + """ + item = next((x for x in extract.items if x is not None), None) + old_total, prefix = parse_money(src.get("total")) + if item is None or old_total is None: + return ApplyResult(ok=False, code="need_lines", quote=src, lines_hint=not multi, currency_hint=multi) + if item.kind == "target": + new_amt = float(item.value) + how = f"改到 {item.value:g}" + elif item.direction == "down": + new_amt = old_total * (1 - float(item.value) / 100.0) + how = f"下调 {item.value:g}%" + else: + new_amt = old_total * (1 + float(item.value) / 100.0) + how = f"上调 {item.value:g}%" + out = dict(src) + rows = [dict(x) for x in quote_fee_rows(src)] + mirrored = _single_line_same_currency(rows, total_ccy or prefix) + label = total_price_label(business_line, quote={"source": "SALES_ADJUST"}) + applied = [{"name": label, "how": how, "amount": format_money(new_amt, sample=str(src.get("total") or ""), currency=total_ccy or prefix)}] + if mirrored is not None: + row = dict(rows[mirrored]) + line_ccy = row_currency(row, total_ccy) or prefix + _apply_amount(row, format_money(new_amt, sample=str(row.get("amount") or ""), currency=line_ccy)) + rows[mirrored] = row + applied.append({"name": str(row.get("name") or "明细"), "how": how, "amount": row["amount"]}) + out["fee_rows"] = rows + out["fee_lines"] = list(rows) + out["total"] = format_money(new_amt, sample=str(src.get("total") or ""), currency=total_ccy or prefix) + profit = _attach_profit( + out, + src, + x=new_amt, + tms_cost=tms_cost or str(src.get("tms_baseline_cost") or ""), + tms_sale=tms_sale or str(src.get("tms_baseline_sale") or ""), + business_line=business_line, + ) + _mark_sales_adjust(out) + return ApplyResult( + ok=True, + code="applied", + quote=out, + applied=applied, + currency_hint=multi, + lines_hint=bool((not multi) and mirrored is None), + profit_point=profit, + ) + + +def apply_adjust( + quote: dict[str, Any] | None, + extract: AdjustExtract, + *, + business_line: str = "", + tms_cost: str = "", + tms_sale: str = "", +) -> ApplyResult: + """ + 按抽项改当前生效报价副本。 + + 明细只有一项且币种与总价一致:改总价或改明细都一起调。 + 多项同币种改合计:只改合计数字。多币种:只改点名的那一格。 + 副作用:无。原 quote 不被修改。 + """ + src = dict(quote or {}) + rows = [dict(x) for x in quote_fee_rows(src)] + if not extract.is_adjust: + return ApplyResult(ok=False, code="not_adjust", quote=src) + header = str(src.get("currency") or "").strip() + total_ccy = row_currency({"currency": header, "amount": src.get("total")}, header) + currencies = {row_currency(r, header) for r in rows if row_currency(r, header)} + if total_ccy: + currencies.add(total_ccy) + multi = len(currencies) > 1 + if extract.wants_total: + return _apply_total_only(src, extract, multi=multi, total_ccy=total_ccy, business_line=business_line, tms_cost=tms_cost, tms_sale=tms_sale) + if not extract.items: + return ApplyResult(ok=False, code="need_fee", quote=src) + applied: list[dict[str, Any]] = [] + unmatched: list[str] = [] + ambiguous: list[str] = [] + currency_hint = bool(multi) + total_amt, _ = parse_money(src.get("total")) + new_total = total_amt + single_same_ccy = _single_line_same_currency(rows, total_ccy or header) is not None + + for item in extract.items: + spoken = (item.fee_name or "").strip() + if not spoken: + unmatched.append(spoken or "未点名费用") + continue + hits = match_fee_names(spoken, rows) + if len(hits) > 1: + ambiguous.append(spoken) + continue + if not hits: + unmatched.append(spoken) + continue + idx = next(i for i, r in enumerate(rows) if str(r.get("name") or "") == hits[0]) + row = dict(rows[idx]) + old_amt, prefix = parse_money(row.get("amount")) + if old_amt is None: + unmatched.append(spoken) + continue + line_ccy = row_currency(row, header) or prefix + if item.kind == "target": + new_amt = float(item.value) + how = f"改到 {item.value:g}" + elif item.direction == "down": + new_amt = old_amt * (1 - float(item.value) / 100.0) + how = f"下调 {item.value:g}%" + else: + new_amt = old_amt * (1 + float(item.value) / 100.0) + how = f"上调 {item.value:g}%" + _apply_amount(row, format_money(new_amt, sample=str(row.get("amount") or ""), currency=line_ccy or prefix)) + rows[idx] = row + applied.append({"name": hits[0], "how": how, "amount": row["amount"]}) + if (not multi) and new_total is not None: + if single_same_ccy: + new_total = new_amt + else: + new_total = new_total + (new_amt - old_amt) + + out = dict(src) + out["fee_rows"] = rows + out["fee_lines"] = rows + if applied and (not multi) and new_total is not None and new_total != total_amt: + out["total"] = format_money(new_total, sample=str(src.get("total") or ""), currency=total_ccy) + if not applied: + code = "ambiguous" if ambiguous else "none_matched" + return ApplyResult( + ok=False, + code=code, + quote=src, + unmatched=unmatched, + ambiguous=ambiguous, + ) + sale_now, _ = parse_money(out.get("total")) + profit = _attach_profit( + out, + src, + x=sale_now, + tms_cost=tms_cost or str(src.get("tms_baseline_cost") or ""), + tms_sale=tms_sale or str(src.get("tms_baseline_sale") or ""), + business_line=business_line, + ) + if tms_cost and "tms_baseline_cost" not in out: + out["tms_baseline_cost"] = tms_cost + if tms_sale and "tms_baseline_sale" not in out: + out["tms_baseline_sale"] = tms_sale + _mark_sales_adjust(out) + logger.info( + "quote_adjust applied=%s unmatched=%s hint=%s profit=%s", + [x.get("name") for x in applied], + unmatched, + currency_hint, + profit, + ) + return ApplyResult( + ok=True, + code="applied", + quote=out, + applied=applied, + unmatched=unmatched, + ambiguous=ambiguous, + currency_hint=currency_hint, + profit_point=profit, + ) diff --git a/inquiry-agent/tests/test_air_text_inquiry.py b/inquiry-agent/tests/test_air_text_inquiry.py index fe22c86..46c5b5b 100644 --- a/inquiry-agent/tests/test_air_text_inquiry.py +++ b/inquiry-agent/tests/test_air_text_inquiry.py @@ -265,7 +265,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertIn("已报价", self.flow.session_of("u1").status) self.assertIn(copy.BTN_SKIP_COLLAB, joined) self.assertIn("价格明细", joined) - self.assertIn("总销售价合计", joined) + self.assertIn("总销售价", joined) self.assertIn("时效:", joined) self.assertIn("报价有效期:", joined) self.assertNotIn("相同询价内容检索", joined) @@ -328,7 +328,7 @@ class AirTextInquiryTests(unittest.TestCase): follow = file_extras[-1].get("followup_card") or {} self.assertTrue(str(follow.get("task_id") or "").startswith("deal-")) deal_rows = {r["keyname"]: r["value"] for r in follow.get("horizontal_content_list") or []} - self.assertIn("总销售价合计", deal_rows) + self.assertIn("总销售价", deal_rows) self.assertNotIn("历史报价", deal_rows) self.assertNotIn("工单状态", deal_rows) self.assertNotIn("来源", deal_rows) @@ -616,7 +616,35 @@ class AirTextInquiryTests(unittest.TestCase): self.flow.on_button(sender_id="u5", action=copy.BTN_SKIP_COLLAB, reply=self._reply) phase = self.flow.on_button(sender_id="u5", action=copy.BTN_REJECT, reply=self._reply) self.assertEqual(phase, "wait_adjust") - self.assertIn("报价加 10%", self.replies[-1]) + self.assertIn("提货费加 10%", self.replies[-1]) + + def test_private_adjust_percent_refreshes_card(self) -> None: + self.flow.on_text( + sender_id="u-adj", + text="空运", + reply=self._reply, + injected_facts=COMPLETE_AIR, + injected_mode="AIR", + ) + self.replies.clear() + phase = self.flow.on_text( + sender_id="u-adj", + text="空运费加 10%", + reply=self._reply, + ) + self.assertEqual(phase, "wait_collab") + joined = "\n".join(self.replies) + self.assertIn("已识别调价指令", joined) + self.assertIn("空运费", joined) + sess = self.flow.session_of("u-adj") + self.assertEqual(sess.status, "已报价") + rows = sess.quote.get("fee_rows") or sess.quote.get("fee_lines") or [] + air = next(x for x in rows if "空运费" in str(x.get("name") or "")) + self.assertIn("1320", str(air.get("amount") or "")) + self.replies.clear() + self.flow.on_button(sender_id="u-adj", action=copy.BTN_SKIP_COLLAB, reply=self._reply) + scheme = "\n".join(self.replies) + self.assertIn("1320", scheme) def test_adopt_pdf(self) -> None: self.flow.on_text( @@ -748,7 +776,7 @@ class AirTextInquiryTests(unittest.TestCase): quote_text = card["quote_area"]["quote_text"] self.assertNotIn("title", card["quote_area"]) self.assertIn("空运费:USD 1,200", quote_text) - self.assertIn("总销售价合计:USD 1,280", quote_text) + self.assertIn("总销售价:USD 1,280", quote_text) self.assertNotIn("时效:", quote_text) numeric = copy.tms_hit_wecom_payload( work_order_no="WO202609140048", @@ -761,7 +789,7 @@ class AirTextInquiryTests(unittest.TestCase): }, )["template_card"]["quote_area"]["quote_text"] self.assertIn("空运费:CNY 14669.60", numeric) - self.assertIn("总销售价合计:CNY 14669.60", numeric) + self.assertIn("总销售价:CNY 14669.60", numeric) self.assertEqual(copy.money_text("14669.60", ""), "14669.60") self.assertNotIn(copy.collab_hint("WO202609140001"), quote_text) sub = card["sub_title_text"] @@ -816,7 +844,7 @@ class AirTextInquiryTests(unittest.TestCase): quote_text = card["quote_area"]["quote_text"] self.assertNotIn("title", card["quote_area"]) self.assertIn("空运费:USD 1,200", quote_text) - self.assertIn("总销售价合计:USD 1,280", quote_text) + self.assertIn("总销售价:USD 1,280", quote_text) self.assertNotIn("sub_title_text", card) rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]} self.assertEqual(rows["工单号"], "WO202609140001") @@ -864,11 +892,11 @@ class AirTextInquiryTests(unittest.TestCase): "valid_until": "2026-08-24", "source_label": "TMS 标准报价", } - text = copy.deal_card(work_order_no="WO202608210017", quote=quote) + text = copy.deal_card(work_order_no="WO202608210017", quote=quote, business_line="AIR") self.assertIn("询价工单 WO202608210017", text) self.assertIn("报价单已发送,请跟进成交状态", text) self.assertIn("咨询成交状态:请选择成交 / 未成交 / 协商中(未成交需补充原因)。", text) - self.assertIn("总销售价合计:CNY 722.5", text) + self.assertIn("总销售价:CNY 722.5", text) self.assertIn("时效:2天", text) self.assertNotIn("时效(要求/TMS)", text) self.assertNotIn("- / -", text) @@ -877,7 +905,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertNotIn("工单状态", text) self.assertNotIn("来源:", text) self.assertNotIn("总成本价合计", text) - payload = copy.deal_wecom_payload(work_order_no="WO202608210017", quote=quote) + payload = copy.deal_wecom_payload(work_order_no="WO202608210017", quote=quote, business_line="AIR") card = payload["template_card"] self.assertEqual(payload["msgtype"], "template_card") self.assertEqual(card["card_type"], "button_interaction") @@ -889,7 +917,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertEqual( rows, { - "总销售价合计": "CNY 722.5", + "总销售价": "CNY 722.5", "时效": "2天", "报价有效期": "2026-08-24", }, diff --git a/inquiry-agent/tests/test_land_dispatch.py b/inquiry-agent/tests/test_land_dispatch.py index dbb0445..743af12 100644 --- a/inquiry-agent/tests/test_land_dispatch.py +++ b/inquiry-agent/tests/test_land_dispatch.py @@ -254,6 +254,75 @@ class LandDispatchTests(unittest.TestCase): ) self.assertEqual(phase, "wait_collab") + def _land_to_quoted(self, sender_id: str) -> None: + store = get_message_store() + facts = { + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + } + handle_text_inquiry( + InboundMessage(sender_id=sender_id, message_id=f"{sender_id}-a", content="陆运"), + injected_facts=facts, + injected_mode="LAND", + store=store, + ) + handle_text_inquiry( + InboundMessage( + sender_id=sender_id, + message_id=f"{sender_id}-b", + content="国内运输拼车 国内长途/零担 急件", + ), + injected_facts=facts, + injected_mode="LAND", + store=store, + ) + handle_text_inquiry( + InboundMessage(sender_id=sender_id, message_id=f"{sender_id}-c", content="确定"), + store=store, + ) + self.assertEqual(get_land_text_flow().session_of(sender_id).phase, "wait_collab") + + def test_quoted_rise_percent_not_first_chat(self) -> None: + """已出 TMS 卡后,A 判成调价就必须追问费用名,不能回开场白。""" + store = get_message_store() + self._land_to_quoted("ld-rise") + name = dispatch_inbound( + InboundMessage(sender_id="ld-rise", message_id="m-rise", content="报价上升10%"), + RouteDecision(intent="quote_adjust"), + ) + self.assertEqual(name, "quote_adjust") + last = list(store._outbox.values())[-1].content + self.assertNotIn("你好,我是询价机器人", last) + self.assertIn("请说明要调哪一项费用", last) + + def test_quoted_land_wins_over_stale_sea(self) -> None: + """旧海运书签不能把陆运调价抢走。""" + store = get_message_store() + handle_text_inquiry( + InboundMessage(sender_id="ld-mix", message_id="m-mix-sea", content="海运"), + injected_facts=COMPLETE_SEA, + injected_mode="SEA", + store=store, + ) + self._land_to_quoted("ld-mix") + name = dispatch_inbound( + InboundMessage(sender_id="ld-mix", message_id="m-mix-rise", content="报价上升10%"), + RouteDecision(intent="quote_adjust"), + ) + self.assertEqual(name, "quote_adjust") + from agent.handlers.text_inquiry import _pick_text_flow + + self.assertIs( + _pick_text_flow(sender_id="ld-mix", text="报价上升10%", prefer_quoted=True), + get_land_text_flow(), + ) + last = list(store._outbox.values())[-1].content + self.assertNotIn("你好,我是询价机器人", last) + self.assertIn("请说明要调哪一项费用", last) + if __name__ == "__main__": unittest.main() diff --git a/inquiry-agent/tests/test_land_group_collab.py b/inquiry-agent/tests/test_land_group_collab.py index 579c0e1..462f14a 100644 --- a/inquiry-agent/tests/test_land_group_collab.py +++ b/inquiry-agent/tests/test_land_group_collab.py @@ -26,7 +26,7 @@ from agent.handlers.group_collab import handle_group_collab from agent.ledger.memory_ledger import MemoryLedger from agent.policy import inquiry_copy as copy from agent.policy.land_text_flow import LandTextInquiryFlow -from agent.routing.group_intent import INTENT_FIELDS, INTENT_QUOTE +from agent.routing.group_intent import INTENT_ADJUST, INTENT_FIELDS, INTENT_QUOTE class CountingLedger(MemoryLedger): @@ -300,6 +300,35 @@ class LandGroupCollabTests(unittest.TestCase): ticket = self.ledger.get_ticket(work_order_no=wo) self.assertTrue(ticket.quote) + def test_sales_adjust_sends_excel_and_deal(self) -> None: + wo, _ = self._open(option_text="国内运输拼车 国内长途/零担 急件") + self.groups.sent.clear() + phase = handle_group_collab( + self._msg(sender="u1", text="空运费加 10%", mid="adj1"), + flow=self.flow, + ledger=self.ledger, + injected_intent=INTENT_ADJUST, + ) + self.assertEqual(phase, "group_adjusted") + texts = self._group_texts() + self.assertIn("已识别调价指令", texts) + self.assertIn("调价后价格", texts) + self.assertTrue(any(x.get("kind") == "file" for x in self.groups.sent)) + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertEqual(ticket.status, "已报价") + + def test_product_cannot_adjust(self) -> None: + self._open(option_text="国内运输拼车 国内长途/零担 急件") + self.groups.sent.clear() + phase = handle_group_collab( + self._msg(sender="p_dom", text="空运费减 5%", mid="adj2"), + flow=self.flow, + ledger=self.ledger, + injected_intent=INTENT_ADJUST, + ) + self.assertEqual(phase, "adjust_not_sales") + self.assertIn("只有这张单的销售能调价", self._group_texts()) + if __name__ == "__main__": unittest.main() diff --git a/inquiry-agent/tests/test_quote_adjust.py b/inquiry-agent/tests/test_quote_adjust.py new file mode 100644 index 0000000..42c0693 --- /dev/null +++ b/inquiry-agent/tests/test_quote_adjust.py @@ -0,0 +1,496 @@ +""" +调价规则单测:比例、改到金额、币种合计、盖价、利润点。 +不连企微 / TMS / 主账。 +""" + +from __future__ import annotations + +import os +import sys +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +from agent.policy import inquiry_copy as copy +from agent.schema.quote_adjust import ( + apply_adjust, + effective_quote, + extract_adjust, + private_session_can_adjust, + total_price_label, +) + + +def _quote(*rows, total="", currency="CNY"): + lines = [{"name": n, "amount": a, "currency": c} for n, a, c in rows] + return { + "total": total, + "currency": currency, + "fee_lines": lines, + "fee_rows": lines, + } + + +class QuoteAdjustTests(unittest.TestCase): + def test_percent_up_single_currency_updates_total(self) -> None: + src = _quote(("提货费", "1000", "CNY"), ("送货费", "200", "CNY"), total="1200") + ext = extract_adjust("提货费加 10%", fee_names=["提货费", "送货费"]) + got = apply_adjust(src, ext, business_line="SEA", tms_cost="1200", tms_sale="1200") + self.assertTrue(got.ok) + self.assertEqual(got.code, "applied") + self.assertEqual(got.quote["fee_rows"][0]["amount"], "1100.00") + self.assertEqual(str(got.quote["total"]), "1300.00") + self.assertFalse(got.currency_hint) + + def test_percent_down_and_target_in_one_sentence(self) -> None: + src = _quote(("提货费", "1000", "CNY"), ("送货费", "200", "CNY"), total="1200") + ext = extract_adjust("提货费减 5%,送货费提升到 180 元", fee_names=["提货费", "送货费"]) + got = apply_adjust(src, ext, business_line="LAND", tms_cost="1200") + self.assertTrue(got.ok) + names = {r["name"]: r["amount"] for r in got.quote["fee_rows"]} + self.assertEqual(names["提货费"], "950.00") + self.assertEqual(names["送货费"], "180.00") + + def test_unspecified_quote_does_not_change(self) -> None: + src = _quote(("提货费", "1000", "CNY"), total="1000") + ext = extract_adjust("报价加 10%", fee_names=["提货费"]) + got = apply_adjust(src, ext, business_line="AIR") + self.assertFalse(got.ok) + self.assertEqual(got.code, "need_fee") + self.assertEqual(src["fee_rows"][0]["amount"], "1000") + + def test_single_line_same_currency_total_moves_line(self) -> None: + """明细只有一项且币种与总价一致:改总价也改明细,不看出价是否相等。""" + src = _quote(("运输费", "110", "CNY"), total="200") + ext = extract_adjust("总销售价改为180CNY", fee_names=["运输费"], allow_model=False) + got = apply_adjust(src, ext, business_line="LAND") + self.assertTrue(got.ok) + self.assertEqual(str(got.quote["total"]), "180.00") + self.assertEqual(got.quote["fee_rows"][0]["amount"], "180.00") + self.assertFalse(got.lines_hint) + + def test_single_line_same_currency_line_moves_total(self) -> None: + src = _quote(("运输费", "110", "CNY"), total="200") + ext = extract_adjust("运输费提升到 180", fee_names=["运输费"], allow_model=False) + got = apply_adjust(src, ext, business_line="LAND") + self.assertTrue(got.ok) + self.assertEqual(got.quote["fee_rows"][0]["amount"], "180.00") + self.assertEqual(str(got.quote["total"]), "180.00") + + def test_single_line_other_currency_does_not_sync(self) -> None: + src = _quote(("运输费", "110", "USD"), total="200", currency="CNY") + ext = extract_adjust("总销售价改为180CNY", fee_names=["运输费"], allow_model=False) + got = apply_adjust(src, ext, business_line="LAND") + self.assertTrue(got.ok) + self.assertEqual(str(got.quote["total"]), "180.00") + self.assertEqual(got.quote["fee_rows"][0]["amount"], "110") + self.assertTrue(got.currency_hint) + self.assertFalse(got.lines_hint) + + def test_multi_line_total_does_not_allocate(self) -> None: + src = _quote(("提货费", "1000", "CNY"), ("送货费", "200", "CNY"), total="1200") + ext = extract_adjust("总销售价改为180CNY", fee_names=["提货费", "送货费"], allow_model=False) + got = apply_adjust(src, ext, business_line="LAND") + self.assertTrue(got.ok) + self.assertEqual(str(got.quote["total"]), "180.00") + names = {r["name"]: r["amount"] for r in got.quote["fee_rows"]} + self.assertEqual(names["提货费"], "1000") + self.assertEqual(names["送货费"], "200") + self.assertTrue(got.lines_hint) + + def test_multi_currency_named_usd_does_not_change_cny_total(self) -> None: + src = _quote( + ("提货费", "100", "USD"), + ("空运费", "2000", "CNY"), + total="2000", + currency="CNY", + ) + ext = extract_adjust("提货费加 10%", fee_names=["提货费", "空运费"]) + got = apply_adjust(src, ext, business_line="AIR", tms_sale="2000") + self.assertTrue(got.ok) + names = {r["name"]: r["amount"] for r in got.quote["fee_rows"]} + self.assertEqual(names["提货费"], "110.00") + self.assertEqual(names["空运费"], "2000") + self.assertEqual(str(got.quote["total"]), "2000") + self.assertTrue(got.currency_hint) + + def test_multi_currency_line_does_not_change_total(self) -> None: + src = _quote( + ("提货费", "100", "USD"), + ("空运费", "2000", "CNY"), + total="2000", + currency="CNY", + ) + ext = extract_adjust("空运费加 10%", fee_names=["提货费", "空运费"], allow_model=False) + got = apply_adjust(src, ext, business_line="AIR") + self.assertTrue(got.ok) + names = {r["name"]: r["amount"] for r in got.quote["fee_rows"]} + self.assertEqual(names["空运费"], "2200.00") + self.assertEqual(names["提货费"], "100") + self.assertEqual(str(got.quote["total"]), "2000") + self.assertTrue(got.currency_hint) + + def test_multi_currency_total_only_changes_total(self) -> None: + src = _quote( + ("提货费", "100", "USD"), + ("空运费", "2000", "CNY"), + total="2000", + currency="CNY", + ) + ext = extract_adjust("总销售价改为180CNY", fee_names=["提货费", "空运费"], allow_model=False) + got = apply_adjust(src, ext, business_line="AIR") + self.assertTrue(got.ok) + names = {r["name"]: r["amount"] for r in got.quote["fee_rows"]} + self.assertEqual(names["提货费"], "100") + self.assertEqual(names["空运费"], "2000") + self.assertEqual(str(got.quote["total"]), "180.00") + self.assertTrue(got.currency_hint) + self.assertFalse(got.lines_hint) + + def test_partial_unmatched_still_applies_matched(self) -> None: + src = _quote(("卸货费", "300", "CNY"), total="300") + ext = extract_adjust("卸货费加 10%,报关费减 5%", fee_names=["卸货费"]) + got = apply_adjust(src, ext, business_line="SEA") + self.assertTrue(got.ok) + self.assertIn("报关费", got.unmatched) + self.assertEqual(got.quote["fee_rows"][0]["amount"], "330.00") + + def test_none_matched(self) -> None: + src = _quote(("卸货费", "300", "CNY"), total="300") + ext = extract_adjust("报关费减 5%", fee_names=["卸货费"]) + got = apply_adjust(src, ext, business_line="SEA") + self.assertFalse(got.ok) + self.assertEqual(got.code, "none_matched") + + def test_overlay_manual_wins(self) -> None: + tms = _quote(("运输费", "1000", "CNY"), ("卸货费", "200", "CNY"), total="1200") + manual = _quote(("卸货费", "300", "CNY"), ("配送费", "20", "CNY"), total="320") + merged = effective_quote(tms, manual) + names = {r["name"]: r["amount"] for r in merged["fee_rows"]} + self.assertEqual(names["运输费"], "1000") + self.assertEqual(names["卸货费"], "300") + self.assertEqual(names["配送费"], "20") + ext = extract_adjust("卸货费加 10%", fee_names=list(names)) + got = apply_adjust(merged, ext, business_line="SEA", tms_cost="1200") + after = {r["name"]: r["amount"] for r in got.quote["fee_rows"]} + self.assertEqual(after["卸货费"], "330.00") + self.assertEqual(after["运输费"], "1000") + self.assertEqual(after["配送费"], "20") + + def test_profit_sea_uses_cost(self) -> None: + src = _quote(("运输费", "1000", "CNY"), total="1000") + ext = extract_adjust("运输费提升到 1300 元", fee_names=["运输费"]) + got = apply_adjust(src, ext, business_line="SEA", tms_cost="1000", tms_sale="1100") + self.assertAlmostEqual(got.profit_point or 0, 30.0) + + def test_profit_air_uses_sale(self) -> None: + src = _quote(("空运费", "1000", "CNY"), total="1000") + ext = extract_adjust("空运费提升到 1100 元", fee_names=["空运费"]) + got = apply_adjust(src, ext, business_line="AIR", tms_cost="800", tms_sale="1000") + self.assertAlmostEqual(got.profit_point or 0, 10.0) + + def test_profit_uses_manual_not_tms_when_manual_exists(self) -> None: + """有手工报价:利润点对手工价,不对 TMS。""" + src = _quote(("运输费", "800", "CNY"), total="800") + ext = extract_adjust("运输费提升到 1040 元", fee_names=["运输费"]) + got = apply_adjust(src, ext, business_line="SEA", tms_cost="1000", tms_sale="1100") + self.assertAlmostEqual(got.profit_point or 0, 30.0) + + def test_profit_air_manual_uses_manual(self) -> None: + src = _quote(("空运费", "2000", "CNY"), total="2000") + ext = extract_adjust("空运费提升到 2200 元", fee_names=["空运费"]) + got = apply_adjust(src, ext, business_line="AIR", tms_cost="800", tms_sale="1000") + self.assertAlmostEqual(got.profit_point or 0, 10.0) + + def test_profit_second_adjust_keeps_first_baseline(self) -> None: + src = _quote(("运输费", "1000", "CNY"), total="1000") + first = apply_adjust( + src, + extract_adjust("运输费提升到 1100 元", fee_names=["运输费"]), + business_line="LAND", + tms_cost="1000", + ) + second = apply_adjust( + first.quote, + extract_adjust("运输费提升到 1300 元", fee_names=["运输费"]), + business_line="LAND", + ) + self.assertAlmostEqual(second.profit_point or 0, 30.0) + + def test_total_label_cost_until_sales_adjust(self) -> None: + self.assertEqual(total_price_label("AIR"), "总销售价") + self.assertEqual(total_price_label("SEA"), "总成本价") + self.assertEqual(total_price_label("LAND"), "总成本价") + self.assertEqual(total_price_label("", transport_mode="空运"), "总销售价") + self.assertEqual(total_price_label("", transport_mode="陆运"), "总成本价") + tms = {"source": "TMS", "source_label": "TMS标准报价"} + self.assertEqual(total_price_label("LAND", quote=tms), "总成本价") + self.assertEqual(total_price_label("SEA", quote={"source": "PRODUCT"}), "总成本价") + self.assertEqual(total_price_label("LAND", quote={"source": "SALES_ADJUST"}), "总销售价") + self.assertEqual(total_price_label("AIR", quote=tms), "总销售价") + + def test_tms_land_card_uses_cost_not_sale(self) -> None: + q = _quote(("运输费", "121.00", "CNY"), total="121.00") + q["source"] = "TMS" + q["source_label"] = "TMS标准报价" + detail = copy.sea_tms_hit_wecom_payload( + work_order_no="WO202609200005", quote=q, business_line="LAND" + ) + self.assertEqual(detail["template_card"]["source"]["desc"], "TMS标准报价") + self.assertIn("总成本价:CNY 121.00", detail["template_card"]["quote_area"]["quote_text"]) + self.assertNotIn("总销售价", detail["template_card"]["quote_area"]["quote_text"]) + scheme = copy.scheme_wecom_payload( + work_order_no="WO202609200005", + facts={"origin": "泉州市", "destination": "南京市"}, + quote=q, + transport_mode="陆运", + ) + self.assertEqual(scheme["template_card"]["source"]["desc"], "TMS标准报价") + self.assertIn("总成本价:CNY 121.00", scheme["template_card"]["quote_area"]["quote_text"]) + deal = copy.deal_horizontal_rows(quote=q, business_line="LAND") + self.assertEqual(deal[0]["keyname"], "总成本价") + + def test_adjust_confirm_uses_sale_total_name(self) -> None: + src = _quote(("运输费", "110", "CNY"), total="110") + land = apply_adjust( + src, + extract_adjust("总成本价改为100CNY", fee_names=["运输费"], allow_model=False), + business_line="LAND", + ) + self.assertTrue(land.ok) + names = [x.get("name") for x in land.applied] + self.assertIn("总销售价", names) + self.assertNotIn("合计", names) + text = copy.adjust_confirm(land.applied) + self.assertIn("总销售价在当前报价基础上", text) + self.assertNotIn("合计在当前报价基础上", text) + body = copy.adjust_group_body(land.quote, business_line="LAND") + self.assertIn("总销售价:", body) + self.assertNotIn("合计:", body) + air = apply_adjust( + src, + extract_adjust("总销售价改为100CNY", fee_names=["运输费"], allow_model=False), + business_line="AIR", + ) + self.assertIn("总销售价", [x.get("name") for x in air.applied]) + self.assertNotIn("合计", [x.get("name") for x in air.applied]) + note = copy.adjust_ledger_remark(land.applied) + self.assertTrue(note.startswith("销售调价:")) + self.assertIn("总销售价", note) + + def test_quote_copy_uses_line_and_source_total_name(self) -> None: + sea = { + "total": "2287.65", + "currency": "CNY", + "source": "TMS", + "fee_rows": [{"name": "海运费", "amount": "100", "currency": "USD"}], + } + air = { + "total": "722.5", + "currency": "CNY", + "source": "TMS", + "fee_rows": [{"name": "空运费", "amount": "700", "currency": "CNY"}], + } + self.assertIn("总成本价:CNY 2287.65", copy.quote_area_fee_text(sea, business_line="SEA")) + self.assertIn("总销售价:CNY 722.5", copy.quote_area_fee_text(air, business_line="AIR")) + self.assertNotIn("总销售价合计", copy.quote_area_fee_text(air, business_line="AIR")) + self.assertNotIn("总销售价:CNY 2287.65", copy.quote_area_fee_text(sea, business_line="SEA")) + self.assertIn("总成本价:CNY 2287.65", "\n".join(copy.quote_detail_lines(sea, business_line="SEA"))) + rows = copy.deal_horizontal_rows(quote=air, business_line="AIR") + self.assertEqual(rows[0]["keyname"], "总销售价") + land_rows = copy.deal_horizontal_rows(quote=sea, business_line="LAND") + self.assertEqual(land_rows[0]["keyname"], "总成本价") + + def test_adjust_cards_use_sales_adjust_not_tms(self) -> None: + """调价后价格明细卡、方案卡必须写销售调价,禁止再套 TMS 标准报价。""" + q = _quote(("运输费", "121.00", "CNY"), total="121.00") + q["source"] = "SALES_ADJUST" + q["source_label"] = "销售调价" + detail = copy.sea_tms_hit_wecom_payload( + work_order_no="WO202609200004", quote=q, business_line="LAND" + ) + self.assertEqual(detail["template_card"]["source"]["desc"], "销售调价") + self.assertIn("总销售价:CNY 121.00", detail["template_card"]["quote_area"]["quote_text"]) + self.assertNotIn("总成本价", detail["template_card"]["quote_area"]["quote_text"]) + scheme = copy.scheme_wecom_payload( + work_order_no="WO202609200004", + facts={"origin": "泉州市", "destination": "南京市"}, + quote=q, + transport_mode="陆运", + ) + self.assertEqual(scheme["template_card"]["source"]["desc"], "销售调价") + self.assertIn("总销售价:CNY 121.00", scheme["template_card"]["quote_area"]["quote_text"]) + + def test_private_eligibility(self) -> None: + self.assertEqual(private_session_can_adjust("已报价", True, ""), "") + self.assertEqual(private_session_can_adjust("协商中", True, ""), "") + self.assertEqual(private_session_can_adjust("已报价", True, "chat1"), "already_collab") + self.assertEqual(private_session_can_adjust("已成交", True, ""), "closed") + self.assertEqual(private_session_can_adjust("询价中", False, ""), "no_quote") + + def test_oral_aliases_not_hardcoded_phrase(self) -> None: + src = _quote(("提货费", "1000", "CNY"), total="1000") + ext = extract_adjust("提货费在现有报价基础上上调 8%", fee_names=["提货费"]) + self.assertTrue(ext.is_adjust) + got = apply_adjust(src, ext, business_line="SEA") + self.assertEqual(got.quote["fee_rows"][0]["amount"], "1080.00") + + def test_model_phrasing_not_in_local_fallback(self) -> None: + """口令不写死:模型抽「往上抬一成」也能算;没注入时本地不得靠固定词硬认。""" + spoken = "把运费往上抬一成" + local = extract_adjust(spoken, fee_names=["运输费"], allow_model=False) + self.assertFalse(local.is_adjust) + ext = extract_adjust( + spoken, + fee_names=["运输费"], + injected={ + "is_adjust": True, + "items": [{"fee_name": "运输费", "kind": "percent", "direction": "up", "value": 10}], + }, + ) + got = apply_adjust(_quote(("运输费", "1000", "CNY"), total="1000"), ext, business_line="LAND") + self.assertTrue(got.ok) + self.assertEqual(got.quote["fee_rows"][0]["amount"], "1100.00") + + def test_a_route_contract_accepts_quote_adjust(self) -> None: + from agent.routing.deepseek_a import DeepSeekARouter + from agent.routing.ordered_actions import ACTION_INTENTS, parse_ordered_actions_arguments + + self.assertEqual(ACTION_INTENTS.get("QUOTE_ADJUST"), "quote_adjust") + parsed = parse_ordered_actions_arguments( + { + "decision_id": "d-adj", + "intent": "quote_adjust", + "ordered_action": { + "action_type": "QUOTE_ADJUST", + "order": 1, + "target": {"work_order_no": "WO202609200002"}, + "evidence": {"source_ref": "inbound:u1", "content_digest": "a" * 64}, + }, + } + ) + self.assertEqual(parsed.to_route_decision().intent, "quote_adjust") + router = DeepSeekARouter.from_settings() + sys_msg, user_msg = router.build_messages( + text="报价上升10%", + sender_id="u1", + session_quoted=True, + fee_names=["运输费"], + work_order_no="WO202609200002", + ) + self.assertIn("quote_adjust", sys_msg["content"]) + self.assertIn("口令不写死", sys_msg["content"]) + self.assertIn("session_quoted=true", user_msg["content"]) + self.assertIn("运输费", user_msg["content"]) + self.assertIn("WO202609200002", user_msg["content"]) + + def test_private_adjust_stamps_sales_adjust_on_session_quote(self) -> None: + """书签和出卡必须带销售调价;不能只改主账、卡片仍用 TMS 来源。""" + from agent.policy.quote_adjust_ops import try_private_adjust + + class _Sess: + status = "已报价" + phase = "wait_collab" + work_order_no = "WO202609200004" + business_line = "LAND" + collab_chat_id = "" + wait_version = 1 + first_or_same = "first" + history_work_order_no = "" + quote = _quote(("运输费", "121.00", "CNY"), total="121.00") + tms_quote = {} + + class _Flow: + def __init__(self) -> None: + self.emitted = None + self.saved = None + + class _Ledger: + def upsert_product_quote(self, **kwargs): + _Flow.last = kwargs + return {"ok": True} + + self.ledger = _Ledger() + + def _save(self, sess) -> None: + self.saved = dict(sess.quote or {}) + + def _emit_sea_hit(self, reply, **kwargs) -> None: + self.emitted = kwargs + + _Sess.quote["source"] = "TMS" + _Sess.quote["source_label"] = "TMS标准报价" + sess = _Sess() + flow = _Flow() + replies: list[str] = [] + out = try_private_adjust( + flow, + sess, + "运输费提升到 150", + replies.append, + injected_adjust={ + "is_adjust": True, + "items": [{"fee_name": "运输费", "kind": "target", "direction": "up", "value": 150}], + }, + ) + self.assertEqual(out, "wait_collab") + self.assertEqual(sess.quote.get("source_label"), "销售调价") + self.assertEqual(sess.quote.get("source"), "SALES_ADJUST") + self.assertEqual(flow.saved.get("source_label"), "销售调价") + self.assertEqual(flow.last["quote"].get("source_label"), "销售调价") + self.assertEqual(flow.last.get("kind"), "adjust") + self.assertEqual(flow.emitted["quote"].get("source_label"), "销售调价") + self.assertNotIn("TMS", str(flow.emitted["quote"].get("source_label") or "")) + self.assertEqual(str(sess.tms_quote.get("total") or ""), "121.00") + self.assertTrue(copy.quote_is_tms(sess.tms_quote)) + self.assertFalse(copy.quote_is_tms(sess.quote)) + + def test_group_brief_keeps_tms_when_current_is_adjust(self) -> None: + """拉群摘要:TMS 段必须是原价,调后价另写销售调价,禁止替换。""" + tms = _quote(("运输费", "110.00", "CNY"), total="110.00") + tms["source"] = "TMS" + tms["source_label"] = "TMS标准报价" + adj = _quote(("运输费", "100.00", "CNY"), total="100.00") + adj["source"] = "SALES_ADJUST" + adj["source_label"] = "销售调价" + text = copy.sea_group_brief( + work_order_no="WO202609200006", + facts={"起运地": "泉州市", "目的地": "南京市", "品名": "普货"}, + quote=adj, + collab_facts={}, + sales_name="销售", + product_names=["产品"], + has_price=True, + tms_quote=tms, + transport_mode="陆运", + ) + tms_block = text.split("TMS报价:", 1)[1] + self.assertIn("总成本价:CNY 110.00", tms_block.split("销售调价:", 1)[0]) + self.assertIn("销售调价:", text) + self.assertIn("总销售价:CNY 100.00", text.split("销售调价:", 1)[1]) + self.assertNotIn("总销售价:CNY 100.00", tms_block.split("销售调价:", 1)[0]) + + def test_group_brief_adjust_alone_does_not_become_tms(self) -> None: + """没单独传入 TMS 时,调后价也不能写进 TMS 段。""" + adj = _quote(("运输费", "100.00", "CNY"), total="100.00") + adj["source"] = "SALES_ADJUST" + adj["source_label"] = "销售调价" + text = copy.sea_group_brief( + work_order_no="WO202609200006", + facts={"起运地": "泉州市", "目的地": "南京市"}, + quote=adj, + collab_facts={}, + sales_name="销售", + product_names=[], + has_price=True, + transport_mode="陆运", + ) + self.assertIn("TMS报价:\nTMS 暂无匹配报价", text) + self.assertIn("销售调价:", text) + self.assertIn("总销售价:CNY 100.00", text.split("销售调价:", 1)[1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py index e8e483e..63f5aef 100644 --- a/inquiry-agent/tests/test_sea_text_inquiry.py +++ b/inquiry-agent/tests/test_sea_text_inquiry.py @@ -253,14 +253,14 @@ class SeaTmsCardTests(unittest.TestCase): "封条费(SEAI):CNY 50.00", "设备费:CNY 50.00", "报关费:CNY 250.00", - "总销售价合计:CNY 2287.65", + "总成本价:CNY 2287.65", ] ), ) self.assertNotIn("时效", text) self.assertNotIn("报价有效期", text) - card_text = copy.quote_area_safe_text(long_quote) - self.assertIn("总销售价合计:CNY 2287.65", card_text) + card_text = copy.quote_area_safe_text(long_quote, business_line="SEA") + self.assertIn("总成本价:CNY 2287.65", card_text) self.assertNotIn("海运费:USD 100.00\n燃油附加费", card_text) self.assertLessEqual(len(card_text), 80) payload = copy.sea_tms_hit_wecom_payload( diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java index e386d73..844c99a 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java @@ -1,6 +1,7 @@ package org.jeecg.modules.inquiry.entity; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.fasterxml.jackson.annotation.JsonFormat; @@ -14,6 +15,7 @@ import org.springframework.format.annotation.DateTimeFormat; import java.io.Serializable; import java.math.BigDecimal; import java.util.Date; +import java.util.List; /** * 询价工单实体,对应表 inq_ticket。 @@ -88,6 +90,14 @@ public class InquiryTicket implements Serializable { @ApiModelProperty("六态") private String status; + /** + * 列表「报价信息」:本单全部报价行。不入库,enrich 时填。 + * TMS / 手工 / 销售调价各留一行,禁止只用最新一条冒充 TMS。 + */ + @TableField(exist = false) + @ApiModelProperty("报价信息(展示)") + private List quotes; + @ApiModelProperty("成本价展示") private String costPrice; diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryWorkOrderService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryWorkOrderService.java index b8c0991..29a5637 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryWorkOrderService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryWorkOrderService.java @@ -65,6 +65,7 @@ public interface IInquiryWorkOrderService extends IService { /** * 智能体续办:按工单号取字段/报价/推断停点。只读。 + * quote 是当前生效价(可能已是销售调价);tms_quote 永远是 TMS 原价,拉群摘要必须用这份。 * 必须校验 salesWecomId 与工单发起人一致。 */ Map getForAgent(String workOrderNo, String salesWecomId); @@ -101,7 +102,7 @@ public interface IInquiryWorkOrderService extends IService { Map attachFile(Map body); /** - * 产品报价写入新版本并视情况迁「已报价」。 + * 手工报价 / 销售调价写入新版本。销售调价另记一条状态流转备注。 */ Map upsertProductQuote(Map body); 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 959b5a1..d6b7219 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 @@ -23,7 +23,9 @@ import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper; import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper; import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper; import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService; +import org.jeecg.modules.inquiry.support.InquiryQuoteDisplaySupport; import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport; +import org.jeecg.modules.inquiry.support.InquiryTicketPriceSupport; import org.jeecg.modules.inquiry.tms.InquiryTmsQueryService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -47,7 +49,7 @@ import java.util.Set; * 询价工单主账实现。 *

* 副作用:改 inq_ticket.status、写 inq_ticket_status_log、改 inq_exception_case。 - * 列表/详情会就地补展示字段(salesName、demandTitle/Detail),只影响响应,不写回库。 + * 列表/详情会就地补展示字段(salesName、需求摘要、成本价/销售价/利润点),只影响响应,不写回库。 * 线程:单请求事务;只锁当前工单行(DB 行级),不串行其它工单。 * 为何:Graph/前端成功不能冒充主账六态成功,所有 transition 必须过本服务。 */ @@ -127,6 +129,14 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl quotes = quoteMapper.selectList(new LambdaQueryWrapper() .eq(InquiryQuote::getWorkOrderNo, workOrderNo) .orderByAsc(InquiryQuote::getCreateTime)); + if (quotes != null) { + for (InquiryQuote q : quotes) { + if (q == null) { + continue; + } + q.setSourceLabel(InquiryQuoteDisplaySupport.displayLabel(q.getSource(), q.getSourceLabel())); + } + } List attachments = attachmentMapper.selectList(new LambdaQueryWrapper() .eq(InquiryAttachment::getWorkOrderNo, workOrderNo) .orderByAsc(InquiryAttachment::getCreateTime)); @@ -147,7 +157,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl tickets) { @@ -155,6 +165,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl staffNameByWecom = loadStaffNames(tickets); + Map> quotesByNo = loadQuotesByWorkOrder(tickets); for (InquiryTicket ticket : tickets) { if (ticket == null) { continue; @@ -163,9 +174,47 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl quoteRows = quotesByNo.get(ticket.getWorkOrderNo()); + InquiryTicketPriceSupport.apply(ticket, quoteRows); + if (quoteRows != null) { + for (InquiryQuote q : quoteRows) { + if (q == null) { + continue; + } + q.setSourceLabel(InquiryQuoteDisplaySupport.displayLabel(q.getSource(), q.getSourceLabel())); + } + } + ticket.setQuotes(quoteRows); } } + /** 列表一次取出本页工单的报价记录,避免按单 N+1。 */ + private Map> loadQuotesByWorkOrder(List tickets) { + List nos = new ArrayList<>(); + for (InquiryTicket ticket : tickets) { + if (ticket != null && StringUtils.isNotBlank(ticket.getWorkOrderNo())) { + nos.add(ticket.getWorkOrderNo()); + } + } + if (nos.isEmpty()) { + return Collections.emptyMap(); + } + List rows = quoteMapper.selectList(new LambdaQueryWrapper() + .in(InquiryQuote::getWorkOrderNo, nos) + .orderByAsc(InquiryQuote::getCreateTime)); + Map> out = new HashMap<>(); + if (rows == null) { + return out; + } + for (InquiryQuote row : rows) { + if (row == null || StringUtils.isBlank(row.getWorkOrderNo())) { + continue; + } + out.computeIfAbsent(row.getWorkOrderNo(), k -> new ArrayList<>()).add(row); + } + return out; + } + /** 批量按企微 userid 取员工姓名,避免列表 N+1。 */ private Map loadStaffNames(List tickets) { Set wecomIds = new HashSet<>(); @@ -513,6 +562,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl getForAgent(String workOrderNo, String salesWecomId) { @@ -572,15 +622,30 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl quote = new LinkedHashMap<>(); + Map tmsQuote = new LinkedHashMap<>(); List quotes = quoteMapper.selectList(new LambdaQueryWrapper() .eq(InquiryQuote::getWorkOrderNo, no) - .orderByDesc(InquiryQuote::getCreateTime) - .last("LIMIT 1")); - if (quotes != null && !quotes.isEmpty() && StringUtils.isNotBlank(quotes.get(0).getPayloadJson())) { - JSONObject parsed = JSON.parseObject(quotes.get(0).getPayloadJson()); - if (parsed != null) { - quote.putAll(parsed); + .orderByDesc(InquiryQuote::getCreateTime)); + if (quotes != null) { + for (InquiryQuote row : quotes) { + if (row == null || StringUtils.isBlank(row.getPayloadJson())) { + continue; + } + JSONObject parsed = JSON.parseObject(row.getPayloadJson()); + if (parsed == null) { + continue; + } + if (quote.isEmpty()) { + quote.putAll(parsed); + } + if (tmsQuote.isEmpty() + && InquiryQuoteDisplaySupport.isTms(row.getSource(), row.getSourceLabel())) { + tmsQuote.putAll(parsed); + tmsQuote.putIfAbsent("source", "TMS"); + tmsQuote.putIfAbsent("source_label", InquiryQuoteDisplaySupport.TMS_LABEL); + } } } String status = StringUtils.defaultString(ticket.getStatus()); @@ -611,6 +676,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl quote = raw instanceof Map ? (Map) raw : new LinkedHashMap<>(); + String remark = text(body, "remark"); + String kind = text(body, "kind", "quoteKind"); + boolean salesAdjust = InquiryQuoteDisplaySupport.isSalesAdjust(kind, remark) + || "adjust".equalsIgnoreCase(kind); Long n = quoteMapper.selectCount(new LambdaQueryWrapper() .eq(InquiryQuote::getWorkOrderNo, no)); int ver = n == null ? 1 : n.intValue() + 1; @@ -877,21 +948,29 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl quotes = quoteMapper.selectList(new LambdaQueryWrapper() + .eq(InquiryQuote::getWorkOrderNo, no) + .orderByAsc(InquiryQuote::getCreateTime)); + InquiryTicketPriceSupport.apply(ticket, quotes); + ticket.setUpdateTime(new Date()); + updateById(ticket); + String from = ticket.getStatus(); String to = text(body, "toStatus", "to_status"); if (StringUtils.isBlank(to)) { to = TicketStatuses.QUOTED; @@ -901,6 +980,11 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl + * 职责:根据 source / sourceLabel / 备注判断来源,禁止把产品报价或销售调价再显示成 TMS。 + * 副作用:无。只算字符串,不写库。 + * 为何:inq_quote.source_label 历史上会原样带上 TMS 文案;后台必须以 source 为准。 + */ +public final class InquiryQuoteDisplaySupport { + + public static final String TMS_LABEL = "TMS标准报价"; + public static final String MANUAL_LABEL = "手工报价"; + public static final String ADJUST_LABEL = "销售调价"; + public static final String ACTION_ADJUST = "quote_adjust"; + public static final String ACTION_MANUAL = "manual_quote"; + + private InquiryQuoteDisplaySupport() { + } + + /** + * 后台「报价信息」一行的来源名。 + * + * @param source 库里的 source:TMS / PRODUCT / SALES_ADJUST + * @param sourceLabel 库里的 sourceLabel,可能被错误写成 TMS 标准报价 + */ + public static String displayLabel(String source, String sourceLabel) { + if (isSalesAdjust(source, sourceLabel)) { + return ADJUST_LABEL; + } + if (isTms(source, sourceLabel)) { + return TMS_LABEL; + } + return MANUAL_LABEL; + } + + /** + * TMS 行:只认 source=TMS。产品/调价即使 label 写成 TMS 也不算。 + */ + public static boolean isTms(String source, String sourceLabel) { + String src = StringUtils.trimToEmpty(source); + if ("PRODUCT".equalsIgnoreCase(src) || "SALES_ADJUST".equalsIgnoreCase(src)) { + return false; + } + return "TMS".equalsIgnoreCase(src) || StringUtils.containsIgnoreCase(sourceLabel, "TMS"); + } + + public static boolean isSalesAdjust(String source, String remark) { + if ("SALES_ADJUST".equalsIgnoreCase(StringUtils.trimToEmpty(source))) { + return true; + } + return StringUtils.startsWith(StringUtils.trimToEmpty(remark), "销售调价"); + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketPriceSupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketPriceSupport.java new file mode 100644 index 0000000..0434f6c --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketPriceSupport.java @@ -0,0 +1,120 @@ +package org.jeecg.modules.inquiry.support; + +import org.apache.commons.lang3.StringUtils; +import org.jeecg.modules.inquiry.entity.InquiryQuote; +import org.jeecg.modules.inquiry.entity.InquiryTicket; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 后台成本价 / 销售价 / 利润点:按业务线从报价记录重算。 + *

+ * 海运/陆运:TMS、产品报价是成本;销售调价后才有销售价,利润点=(销售-成本)/成本。 + * 空运:成本价始终空;TMS/航线/调价都是销售侧;利润点只在销售调过价后算。 + * 副作用:只改传入 ticket 的三个展示字段,不写库。调用方决定是否 updateById。 + */ +public final class InquiryTicketPriceSupport { + + private static final Pattern AMOUNT = Pattern.compile("(-?\\d+(?:\\.\\d+)?)"); + + private InquiryTicketPriceSupport() { + } + + /** + * 按报价记录就地写入成本价、销售价、利润点。 + * + * @param ticket 当前工单,读取 businessLine + * @param quotes 该单全部报价,按时间从旧到新 + */ + public static void apply(InquiryTicket ticket, List quotes) { + if (ticket == null) { + return; + } + boolean air = isAir(ticket.getBusinessLine()); + InquiryQuote lastTms = null; + InquiryQuote lastManual = null; + InquiryQuote lastAdjust = null; + if (quotes != null) { + for (InquiryQuote q : quotes) { + if (q == null) { + continue; + } + if (InquiryQuoteDisplaySupport.isSalesAdjust(q.getSource(), q.getSourceLabel())) { + lastAdjust = q; + } else if (InquiryQuoteDisplaySupport.isTms(q.getSource(), q.getSourceLabel())) { + lastTms = q; + } else { + lastManual = q; + } + } + } + String tmsPrice = priceOf(lastTms); + String manualPrice = priceOf(lastManual); + String adjustPrice = priceOf(lastAdjust); + if (air) { + ticket.setCostPrice(""); + if (StringUtils.isNotBlank(adjustPrice)) { + ticket.setSalePrice(adjustPrice); + String baseline = StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice; + ticket.setProfitRate(profitText(adjustPrice, baseline)); + } else { + ticket.setSalePrice(StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice); + ticket.setProfitRate(""); + } + return; + } + String cost = StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice; + ticket.setCostPrice(StringUtils.defaultString(cost)); + if (StringUtils.isNotBlank(adjustPrice)) { + ticket.setSalePrice(adjustPrice); + ticket.setProfitRate(profitText(adjustPrice, cost)); + } else { + ticket.setSalePrice(""); + ticket.setProfitRate(""); + } + } + + static boolean isAir(String businessLine) { + String raw = StringUtils.trimToEmpty(businessLine); + return "AIR".equalsIgnoreCase(raw) || raw.contains("空运"); + } + + static String priceOf(InquiryQuote quote) { + return quote == null ? "" : StringUtils.trimToEmpty(quote.getPriceText()); + } + + static BigDecimal parseAmount(String priceText) { + if (StringUtils.isBlank(priceText) || "-".equals(priceText.trim())) { + return null; + } + Matcher m = AMOUNT.matcher(priceText.replace(",", "")); + if (!m.find()) { + return null; + } + try { + return new BigDecimal(m.group(1)); + } catch (Exception e) { + return null; + } + } + + /** + * (销售 − 基准) / 基准 × 100%,没有基准或销售则空。 + */ + static String profitText(String saleText, String baselineText) { + BigDecimal sale = parseAmount(saleText); + BigDecimal base = parseAmount(baselineText); + if (sale == null || base == null || base.compareTo(BigDecimal.ZERO) == 0) { + return ""; + } + BigDecimal pct = sale.subtract(base) + .divide(base, 6, RoundingMode.HALF_UP) + .multiply(new BigDecimal("100")) + .setScale(2, RoundingMode.HALF_UP); + return pct.stripTrailingZeros().toPlainString() + "%"; + } +} 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 fc020b9..b2137e2 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 @@ -11,6 +11,7 @@ import org.jeecg.modules.inquiry.entity.InquiryQuote; import org.jeecg.modules.inquiry.entity.InquiryTicket; import org.jeecg.modules.inquiry.mapper.InquiryQuoteMapper; import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService; +import org.jeecg.modules.inquiry.support.InquiryTicketPriceSupport; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; @@ -226,8 +227,11 @@ public class InquiryTmsQueryService { row.setPayloadJson(JSON.toJSONString(quote)); row.setCreateTime(new Date()); quoteMapper.insert(row); - // 后台列表/详情读主表售价;只写子表的话工单页看起来是空的。 - applyQuoteToTicket(ticket, quote); + List quotes = quoteMapper.selectList(new LambdaQueryWrapper() + .eq(InquiryQuote::getWorkOrderNo, workOrderNo) + .orderByAsc(InquiryQuote::getCreateTime)); + InquiryTicketPriceSupport.apply(ticket, quotes); + ticket.setTmsHit(1); workOrderService.updateById(ticket); if (TicketStatuses.canTransit(ticket.getStatus(), TicketStatuses.QUOTED)) { TicketTransitionRequest tr = new TicketTransitionRequest(); @@ -456,24 +460,17 @@ public class InquiryTmsQueryService { /** * 把本次 TMS 报价写回工单主表,供后台列表/详情展示。 - * 没有的成本/毛利率不编;售价用销售合计。 + * 海运/陆运进成本价,空运进销售价;利润点必须等销售调价后才算。 */ - private static void applyQuoteToTicket(InquiryTicket ticket, Map quote) { + static void applyQuoteToTicket(InquiryTicket ticket, Map quote) { if (ticket == null || quote == null) { return; } - String sale = TmsQuoteCurrency.formatDisplay(quote.get("currency"), quote.get("total")); - if (StringUtils.isNotBlank(sale) && !"-".equals(sale)) { - ticket.setSalePrice(sale); - } - String cost = text(quote.get("cost")); - if (StringUtils.isNotBlank(cost) && !"-".equals(cost)) { - ticket.setCostPrice(cost); - } - String profit = text(quote.get("profit_rate")); - if (StringUtils.isNotBlank(profit) && !"-".equals(profit)) { - ticket.setProfitRate(profit); - } + InquiryQuote row = new InquiryQuote(); + row.setSource("TMS"); + row.setSourceLabel("TMS标准报价"); + row.setPriceText(TmsQuoteCurrency.formatDisplay(quote.get("currency"), quote.get("total"))); + InquiryTicketPriceSupport.apply(ticket, java.util.Collections.singletonList(row)); ticket.setTmsHit(1); } diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryQuoteDisplaySupportTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryQuoteDisplaySupportTest.java new file mode 100644 index 0000000..95bd7bc --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryQuoteDisplaySupportTest.java @@ -0,0 +1,30 @@ +package org.jeecg.modules.inquiry.support; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 后台报价来源:TMS 与手工必须分开,禁止把产品/调价再写成 TMS 标准报价。 + */ +public class InquiryQuoteDisplaySupportTest { + + @Test + public void tmsKeepsStandardLabel() { + assertEquals("TMS标准报价", InquiryQuoteDisplaySupport.displayLabel("TMS", "TMS 标准报价")); + assertEquals("TMS标准报价", InquiryQuoteDisplaySupport.displayLabel("TMS", "")); + assertTrue(InquiryQuoteDisplaySupport.isTms("TMS", "随便")); + } + + @Test + public void productAndAdjustAreNotTms() { + assertEquals("手工报价", InquiryQuoteDisplaySupport.displayLabel("PRODUCT", "产品报价")); + assertEquals("销售调价", InquiryQuoteDisplaySupport.displayLabel("SALES_ADJUST", "TMS 标准报价")); + assertEquals("手工报价", InquiryQuoteDisplaySupport.displayLabel("PRODUCT", "TMS标准报价")); + assertFalse(InquiryQuoteDisplaySupport.isTms("PRODUCT", "TMS 标准报价")); + assertTrue(InquiryQuoteDisplaySupport.isSalesAdjust("SALES_ADJUST", "销售调价:运输费改到 100")); + assertTrue(InquiryQuoteDisplaySupport.isSalesAdjust("PRODUCT", "销售调价:总成本价改到 180")); + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryTicketPriceSupportTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryTicketPriceSupportTest.java new file mode 100644 index 0000000..84a70f2 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryTicketPriceSupportTest.java @@ -0,0 +1,93 @@ +package org.jeecg.modules.inquiry.support; + +import org.jeecg.modules.inquiry.entity.InquiryQuote; +import org.jeecg.modules.inquiry.entity.InquiryTicket; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 后台三列:海运/陆运成本与销售分开,空运只走销售价。 + */ +public class InquiryTicketPriceSupportTest { + + @Test + public void landTmsOnlyGoesToCost() { + InquiryTicket ticket = ticket("LAND"); + InquiryTicketPriceSupport.apply(ticket, Collections.singletonList(quote("TMS", "TMS标准报价", "CNY 121.00"))); + assertEquals("CNY 121.00", ticket.getCostPrice()); + assertEquals("", ticket.getSalePrice()); + assertEquals("", ticket.getProfitRate()); + } + + @Test + public void landAdjustKeepsCostWritesSaleAndProfit() { + InquiryTicket ticket = ticket("LAND"); + InquiryTicketPriceSupport.apply(ticket, Arrays.asList( + quote("TMS", "TMS标准报价", "CNY 121.00"), + quote("SALES_ADJUST", "销售调价", "CNY 150.00"))); + assertEquals("CNY 121.00", ticket.getCostPrice()); + assertEquals("CNY 150.00", ticket.getSalePrice()); + assertEquals("23.97%", ticket.getProfitRate()); + } + + @Test + public void landProductBecomesCostAndKeepsSale() { + InquiryTicket ticket = ticket("SEA"); + InquiryTicketPriceSupport.apply(ticket, Arrays.asList( + quote("TMS", "TMS标准报价", "CNY 100.00"), + quote("SALES_ADJUST", "销售调价", "CNY 130.00"), + quote("PRODUCT", "手工报价", "CNY 110.00"))); + assertEquals("CNY 110.00", ticket.getCostPrice()); + assertEquals("CNY 130.00", ticket.getSalePrice()); + assertEquals("18.18%", ticket.getProfitRate()); + } + + @Test + public void airTmsGoesToSaleCostEmpty() { + InquiryTicket ticket = ticket("AIR"); + InquiryTicketPriceSupport.apply(ticket, Collections.singletonList(quote("TMS", "TMS标准报价", "USD 1280"))); + assertEquals("", ticket.getCostPrice()); + assertEquals("USD 1280", ticket.getSalePrice()); + assertEquals("", ticket.getProfitRate()); + } + + @Test + public void airRouteAfterAdjustOnlyReplacesBaseline() { + InquiryTicket ticket = ticket("AIR"); + InquiryTicketPriceSupport.apply(ticket, Arrays.asList( + quote("TMS", "TMS标准报价", "USD 1000"), + quote("SALES_ADJUST", "销售调价", "USD 1200"), + quote("PRODUCT", "手工报价", "USD 1100"))); + assertEquals("", ticket.getCostPrice()); + assertEquals("USD 1200", ticket.getSalePrice()); + assertEquals("9.09%", ticket.getProfitRate()); + } + + @Test + public void parseAmountReadsCurrencyPrefix() { + assertEquals("121.00", InquiryTicketPriceSupport.parseAmount("CNY 121.00").toPlainString()); + assertTrue(InquiryTicketPriceSupport.isAir("空运")); + } + + private static InquiryTicket ticket(String line) { + InquiryTicket t = new InquiryTicket(); + t.setBusinessLine(line); + t.setSalePrice("旧销售价"); + t.setCostPrice("旧成本价"); + t.setProfitRate("旧利润"); + return t; + } + + private static InquiryQuote quote(String source, String label, String price) { + InquiryQuote q = new InquiryQuote(); + q.setSource(source); + q.setSourceLabel(label); + q.setPriceText(price); + return q; + } +} diff --git a/inquiry-backend/src/views/inquiry/workOrder/index.vue b/inquiry-backend/src/views/inquiry/workOrder/index.vue index 127afb7..5815b5a 100644 --- a/inquiry-backend/src/views/inquiry/workOrder/index.vue +++ b/inquiry-backend/src/views/inquiry/workOrder/index.vue @@ -17,7 +17,7 @@ import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue"; import AdminModal from "/@/views/inquiry/components/AdminModal.vue"; import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue"; -type TicketQuote = { source: string; price: string; highlight?: boolean }; +type TicketQuote = { source: string; price: string; time?: string; highlight?: boolean }; type TicketAttach = { name: string; uploadedAt: string; uploader: string }; type TicketTimeline = { status: string; remark: string; time: string }; @@ -93,6 +93,21 @@ function dash(v: any) { return s ? s : "-"; } +/** + * 后台报价来源:TMS标准报价 / 手工报价 / 销售调价。 + * 禁止把产品报价或销售调价再显示成 TMS。 + */ +function quoteSourceLabel(q: any) { + const src = String(q?.source ?? "").trim().toUpperCase(); + if (src === "SALES_ADJUST") return "销售调价"; + if (src === "PRODUCT") return "手工报价"; + if (src === "TMS") return "TMS标准报价"; + const label = String(q?.sourceLabel || q?.source || "").replace(/\s/g, ""); + if (label.includes("销售调价")) return "销售调价"; + if (label.includes("TMS")) return "TMS标准报价"; + return label && label !== "报价" ? "手工报价" : "报价"; +} + /** * 主账 inq_ticket_status_log.action 是内部码,禁止当备注展示。 * transition 与「已报价」等 toStatus 重复,无用户备注时不再跟一段英文。 @@ -101,6 +116,8 @@ const STATUS_LOG_ACTION_LABEL: Record = { transition: "", retry: "系统重试", abnormal_handle: "异常已处理", + quote_adjust: "销售调价", + manual_quote: "手工报价", }; function isInternalActionCode(v: string) { @@ -239,8 +256,9 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline demandTitle: reqLines[0] || "—", demandDetail: reqLines.slice(1).join("\n"), quotes: (quotes || []).map((q) => ({ - source: q.sourceLabel || q.source || "报价", + source: quoteSourceLabel(q), price: q.priceText || "-", + time: fmtTime(q.createTime), highlight: q.highlighted === 1 || q.highlighted === true, })), attachments: attachRows.map((a) => a.name), @@ -306,7 +324,7 @@ async function loadList() { const page = res?.records ? res : res?.result || {}; const records = page.records || []; total.value = Number(page.total || records.length || 0); - rows.value = (records as any[]).map((t) => mapTicket(t)); + rows.value = (records as any[]).map((t) => mapTicket(t, t.quotes || [])); // 删减后当前页可能越界,回退到最后一页 const maxPage = Math.max(1, Math.ceil(total.value / pageSize.value) || 1); if (pageNo.value > maxPage) { @@ -661,8 +679,8 @@ function refresh() {

{{ current.profit }}
-
-
{{ q.source }}
+
+
{{ q.source }} · {{ q.time }}
{{ q.price }}