Compare commits

...
36 changed files with 2795 additions and 176 deletions
@@ -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,
+13 -1
View File
@@ -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,
+10 -1
View File
@@ -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] = []
@@ -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
+3 -2
View File
@@ -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 {},
},
+21 -7
View File
@@ -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}",
)
+4 -1
View File
@@ -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
)
@@ -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
+8 -6
View File
@@ -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:
@@ -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",
),
)
+279 -60
View File
@@ -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 {},
)
@@ -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"
@@ -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"
+53 -13
View File
@@ -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:
+26 -4
View File
@@ -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)
+40 -5
View File
@@ -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,
@@ -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,
+49 -14
View File
@@ -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,
+5
View File
@@ -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
@@ -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,
@@ -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,
+653
View File
@@ -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,
)
+38 -10
View File
@@ -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",
},
+69
View File
@@ -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()
+30 -1
View File
@@ -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()
+496
View File
@@ -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()
+3 -3
View File
@@ -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(
@@ -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<InquiryQuote> quotes;
@ApiModelProperty("成本价展示")
private String costPrice;
@@ -65,6 +65,7 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
/**
* 智能体续办:按工单号取字段/报价/推断停点。只读。
* quote 是当前生效价(可能已是销售调价);tms_quote 永远是 TMS 原价,拉群摘要必须用这份。
* 必须校验 salesWecomId 与工单发起人一致。
*/
Map<String, Object> getForAgent(String workOrderNo, String salesWecomId);
@@ -101,7 +102,7 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
Map<String, Object> attachFile(Map<String, Object> body);
/**
* 产品报价写入新版本并视情况迁「已报价」。
* 手工报价 / 销售调价写入新版本。销售调价另记一条状态流转备注。
*/
Map<String, Object> upsertProductQuote(Map<String, Object> body);
@@ -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;
* 询价工单主账实现。
* <p>
* 副作用:改 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<InquiryTicketMapper
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
.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<InquiryAttachment> attachments = attachmentMapper.selectList(new LambdaQueryWrapper<InquiryAttachment>()
.eq(InquiryAttachment::getWorkOrderNo, workOrderNo)
.orderByAsc(InquiryAttachment::getCreateTime));
@@ -147,7 +157,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
}
/**
* 列表/详情响应补齐:发起人姓名(员工表)+ 询价需求摘要(结构化字段)。
* 列表/详情响应补齐:发起人姓名、询价需求摘要、成本价/销售价/利润点、报价信息行。
* 副作用:只改内存中的 ticket 展示字段,不 updateById。
*/
private void enrichDisplayFields(List<InquiryTicket> tickets) {
@@ -155,6 +165,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
return;
}
Map<String, String> staffNameByWecom = loadStaffNames(tickets);
Map<String, List<InquiryQuote>> quotesByNo = loadQuotesByWorkOrder(tickets);
for (InquiryTicket ticket : tickets) {
if (ticket == null) {
continue;
@@ -163,9 +174,47 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
ticket.setSalesName(InquiryTicketDisplaySupport.resolveSalesDisplayName(
ticket.getSalesName(), ticket.getSalesWecomId(), staffName));
InquiryTicketDisplaySupport.applyRequirementSummary(ticket);
List<InquiryQuote> 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<String, List<InquiryQuote>> loadQuotesByWorkOrder(List<InquiryTicket> tickets) {
List<String> 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<InquiryQuote> rows = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
.in(InquiryQuote::getWorkOrderNo, nos)
.orderByAsc(InquiryQuote::getCreateTime));
Map<String, List<InquiryQuote>> 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<String, String> loadStaffNames(List<InquiryTicket> tickets) {
Set<String> wecomIds = new HashSet<>();
@@ -513,6 +562,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
/**
* 智能体续办读单:只读。不属于该销售则 owned=false,不回字段。
* quote=当前生效价;tms_quote=TMS 原价。调价后二者不能混用。
*/
@Override
public Map<String, Object> getForAgent(String workOrderNo, String salesWecomId) {
@@ -572,15 +622,30 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
}
}
}
// 最新一条=当前生效价;TMS 原价单独留,拉群摘要禁止用调后价冒充 TMS。
Map<String, Object> quote = new LinkedHashMap<>();
Map<String, Object> tmsQuote = new LinkedHashMap<>();
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
.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<InquiryTicketMapper
out.put("salesName", StringUtils.defaultString(ticket.getSalesName()));
out.put("facts", facts);
out.put("quote", quote);
out.put("tms_quote", tmsQuote);
out.put("tmsQuote", tmsQuote);
out.put("wait_phase", waitPhase);
out.put("lost_reason", StringUtils.defaultString(ticket.getLostReason()));
out.put("lostReason", StringUtils.defaultString(ticket.getLostReason()));
@@ -853,8 +920,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
}
/**
* 产品报价盖 TMS 价:新插一条 inq_quote,主表售价跟着改。
* 询价中/协商中可迁已报价;已报价只加版本。终态由智能体先拦。
* 手工报价 / 销售调价:新插一条 inq_quote,来源固定为手工报价,不覆盖 TMS 那一行。
* 销售调价必须再写一条状态流转备注(当时时间点)。询价中/协商中可迁已报价。
*/
@Override
@Transactional(rollbackFor = Exception.class)
@@ -870,6 +937,10 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
}
Object raw = body == null ? null : body.get("quote");
Map<String, Object> quote = raw instanceof Map ? (Map<String, Object>) 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<InquiryQuote>()
.eq(InquiryQuote::getWorkOrderNo, no));
int ver = n == null ? 1 : n.intValue() + 1;
@@ -877,21 +948,29 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
row.setTicketId(ticket.getId());
row.setWorkOrderNo(no);
row.setQuoteVersion(ver);
row.setSource("PRODUCT");
row.setSourceLabel(String.valueOf(quote.getOrDefault("source_label", "产品报价")));
row.setPriceText(String.valueOf(quote.getOrDefault("total", "")));
row.setSource(salesAdjust ? "SALES_ADJUST" : "PRODUCT");
row.setSourceLabel(salesAdjust ? InquiryQuoteDisplaySupport.ADJUST_LABEL : InquiryQuoteDisplaySupport.MANUAL_LABEL);
Object total = quote.get("total");
Object ccy = quote.get("currency");
String sale = "";
if (total != null && StringUtils.isNotBlank(String.valueOf(total))) {
sale = (ccy == null ? "" : String.valueOf(ccy) + " ") + total;
row.setPriceText(sale.trim());
} else {
row.setPriceText(String.valueOf(quote.getOrDefault("total", "")));
}
row.setHighlighted(1);
row.setPayloadJson(JSON.toJSONString(quote));
row.setCreateTime(new Date());
quoteMapper.insert(row);
Object total = quote.get("total");
Object ccy = quote.get("currency");
if (total != null && StringUtils.isNotBlank(String.valueOf(total))) {
String sale = (ccy == null ? "" : String.valueOf(ccy) + " ") + total;
ticket.setSalePrice(sale.trim());
ticket.setUpdateTime(new Date());
updateById(ticket);
}
ticket.setManualQuote(1);
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
.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<InquiryTicketMapper
tr.setWorkOrderNo(no);
tr.setToStatus(to);
transition(tr, "agent");
ticket = findByWorkOrderNo(no);
}
if (salesAdjust) {
String note = StringUtils.isNotBlank(remark) ? remark : "销售调价";
appendLog(ticket, from, ticket.getStatus(), InquiryQuoteDisplaySupport.ACTION_ADJUST, "agent", note);
}
out.put("ok", true);
out.put("quote_version", ver);
@@ -0,0 +1,56 @@
package org.jeecg.modules.inquiry.support;
import org.apache.commons.lang3.StringUtils;
/**
* 后台报价来源展示:TMS 标准报价 / 手工报价 / 销售调价。
* <p>
* 职责:根据 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), "销售调价");
}
}
@@ -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;
/**
* 后台成本价 / 销售价 / 利润点:按业务线从报价记录重算。
* <p>
* 海运/陆运: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<InquiryQuote> 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() + "%";
}
}
@@ -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<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
.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<String, String> quote) {
static void applyQuoteToTicket(InquiryTicket ticket, Map<String, String> 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);
}
@@ -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"));
}
}
@@ -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;
}
}
@@ -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<string, string> = {
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() {
<div class="ticket-detail-item"><label>利润点</label><span>{{ current.profit }}</span></div>
</div>
<div v-if="current.quotes.length" class="ticket-detail-demand">
<div v-for="q in current.quotes" :key="q.source + q.price" class="quote-row" style="margin-bottom: 6px">
<div class="quote-source">{{ q.source }}</div>
<div v-for="q in current.quotes" :key="q.source + q.price + (q.time || '')" class="quote-row" style="margin-bottom: 6px">
<div class="quote-source">{{ q.source }}<span v-if="q.time && q.time !== '-'"> · {{ q.time }}</span></div>
<div class="quote-price" :style="q.highlight ? { color: '#1d4ed8' } : undefined">{{ q.price }}</div>
</div>
</div>