Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19acbb9156 | ||
|
|
b3b3cf2903 | ||
|
|
88a66d40bb | ||
|
|
698ac43878 | ||
|
|
79caddef0e | ||
|
|
91bde83e9b | ||
|
|
3a6884e27d | ||
|
|
27aeebb593 | ||
|
|
54b818915e | ||
|
|
7eedf28e8d | ||
|
|
26dd5d72a1 |
@@ -30,11 +30,13 @@ from agent.policy.air_text_flow import get_air_text_flow
|
||||
from agent.policy.sea_group_ops import (
|
||||
capture_group_lost_reason,
|
||||
group_client_of,
|
||||
is_handoff_ticket,
|
||||
send_group_text,
|
||||
ticket_view,
|
||||
)
|
||||
from agent.routing.air_group_intent import (
|
||||
INTENT_ACTIVATE,
|
||||
INTENT_ADJUST,
|
||||
INTENT_CONFIRM_TMS,
|
||||
INTENT_DEAL,
|
||||
INTENT_DROP_TMS,
|
||||
@@ -111,6 +113,12 @@ def handle_air_group(
|
||||
return "duplicate_inbound"
|
||||
mark_air_text(chat_id=chat_id, sender_id=message.sender_id, text=text)
|
||||
wo = copy.extract_work_order_no(text)
|
||||
if wo:
|
||||
from agent.policy.handoff_human import try_commit_air_group_handoff
|
||||
|
||||
handed = try_commit_air_group_handoff(engine, chat_id=chat_id, work_order_no=wo)
|
||||
if handed:
|
||||
return handed
|
||||
intent = classify_air_group_text(text, injected=injected_intent)
|
||||
logger.info(
|
||||
"air_group 文字 chat=%s sender=%s intent=%s wo=%s",
|
||||
@@ -137,6 +145,9 @@ def handle_air_group(
|
||||
)
|
||||
if ticket is None:
|
||||
return phase
|
||||
if is_handoff_ticket(ticket):
|
||||
logger.info("air_group 转人工不再跟 chat=%s", chat_id)
|
||||
return "handoff_ignore"
|
||||
if intent == INTENT_ACTIVATE:
|
||||
return phase
|
||||
else:
|
||||
@@ -144,6 +155,9 @@ def handle_air_group(
|
||||
if ticket is None:
|
||||
send_group_text(group_client_of(engine), chat_id, copy.air_need_work_order())
|
||||
return "need_work_order"
|
||||
if is_handoff_ticket(ticket):
|
||||
logger.info("air_group 转人工不再跟 chat=%s", chat_id)
|
||||
return "handoff_ignore"
|
||||
return _handle_bound(
|
||||
engine,
|
||||
message,
|
||||
@@ -176,6 +190,9 @@ def _handle_file(engine, message: InboundMessage) -> str:
|
||||
if ticket is None:
|
||||
send_group_text(group_client_of(engine), chat_id, copy.air_need_work_order())
|
||||
return "need_work_order"
|
||||
if is_handoff_ticket(ticket):
|
||||
logger.info("air_group 转人工不再跟 chat=%s", chat_id)
|
||||
return "handoff_ignore"
|
||||
return apply_air_file(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
@@ -293,6 +310,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,
|
||||
|
||||
@@ -22,6 +22,7 @@ from agent.policy.sea_group_ops import (
|
||||
group_client_of,
|
||||
handle_group_file,
|
||||
hydrate_ticket,
|
||||
is_handoff_ticket,
|
||||
peek_pending_product_quote,
|
||||
send_group_text,
|
||||
ticket_no,
|
||||
@@ -66,7 +67,7 @@ def handle_group_inbound(
|
||||
群里一条文字或附件。
|
||||
|
||||
文字走 DeepSeek A(单测可注入);附件只看扩展名。
|
||||
协同已结束或终态要回话,不能当没看见。
|
||||
已成交/未成交/已关闭要回话。真正转人工后群里不再跟、不回。
|
||||
"""
|
||||
if (message.chat_type or "") != "group":
|
||||
return "group_ignore"
|
||||
@@ -100,6 +101,13 @@ def handle_group_inbound(
|
||||
return "group_ignore"
|
||||
|
||||
sess = _session_of_ticket(engine, ticket)
|
||||
if is_handoff_ticket(ticket, sess):
|
||||
logger.info(
|
||||
"group_inbound 转人工不再跟 wo=%s chat=%s",
|
||||
ticket_no(ticket),
|
||||
chat_id,
|
||||
)
|
||||
return "handoff_ignore"
|
||||
view = ticket_view(ticket)
|
||||
if view.get("collab_ended") or (sess is not None and getattr(sess, "collab_ended", False)):
|
||||
from agent.policy import inquiry_copy as copy
|
||||
@@ -135,7 +143,8 @@ def handle_group_inbound(
|
||||
INTENT_KEEP_TMS,
|
||||
INTENT_LOST,
|
||||
INTENT_NEGOTIATE,
|
||||
INTENT_QUOTE,
|
||||
INTENT_ADJUST,
|
||||
INTENT_QUOTE,
|
||||
classify_group_text,
|
||||
)
|
||||
|
||||
@@ -193,6 +202,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,
|
||||
|
||||
@@ -22,9 +22,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _front_bookmark(sess) -> bool:
|
||||
"""补问/核对手稿优先于已出票旧书签。空的 need_mode 不算(那是丢会话后的误伤)。"""
|
||||
"""补问/核对手稿优先于已出票旧书签。空的 need_mode 不算(那是丢会话后的误伤)。已转人工的票也不再当当前询价。"""
|
||||
if sess is None:
|
||||
return False
|
||||
from agent.policy.handoff_human import is_handoff_frozen
|
||||
|
||||
if is_handoff_frozen(sess):
|
||||
return False
|
||||
phase = (sess.phase or "").strip()
|
||||
line = (sess.business_line or "").upper()
|
||||
if phase == "need_mode" and not line:
|
||||
@@ -32,14 +36,81 @@ 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 _drop_other_mode_drafts(*, owner: str, sender_id: str) -> None:
|
||||
"""
|
||||
本句已点名空运/海运/陆运:丢掉其它方式未出号的补问/选项草稿。
|
||||
|
||||
测服复现:旧陆运「选项未齐」书签还在,海运补「贸易条款 / 运输分类」会被陆运抢走,
|
||||
回「参数提取不全」并发送填写样例图。已出号会话留在工单号索引,不当当前草稿。
|
||||
"""
|
||||
kind = (owner or "").upper()
|
||||
sid = (sender_id or "").strip()
|
||||
if not sid or kind not in {"SEA", "AIR", "LAND"}:
|
||||
return
|
||||
land = get_land_text_flow()
|
||||
air = get_air_text_flow()
|
||||
sea = get_sea_text_flow()
|
||||
others = []
|
||||
if kind != "LAND":
|
||||
others.append(land)
|
||||
if kind != "AIR":
|
||||
others.append(air)
|
||||
if kind != "SEA":
|
||||
others.append(sea)
|
||||
for flow in others:
|
||||
sess = flow.session_of(sid)
|
||||
phase = (getattr(sess, "phase", None) or "").strip() if sess else ""
|
||||
if _front_bookmark(sess) or phase == "need_mode":
|
||||
flow.drop_current_bookmark(sid)
|
||||
logger.info(
|
||||
"text_inquiry 丢掉其它方式草稿 owner=%s dropped_phase=%s sender=%s",
|
||||
kind,
|
||||
phase or "-",
|
||||
sid,
|
||||
)
|
||||
if kind != "LAND":
|
||||
clearer = getattr(land, "_clear_parked_confirm", None)
|
||||
if callable(clearer):
|
||||
clearer(sid)
|
||||
|
||||
|
||||
def should_resume_other_as_inquiry(message: InboundMessage) -> bool:
|
||||
"""
|
||||
A 把补问短答标成闲聊时,仍续填当前询价。
|
||||
|
||||
只拦还在问字段/选项/核对、未成交原因、转人工入口。
|
||||
已报价协同中不拦,避免「谢谢」被当成新询价。
|
||||
"""
|
||||
if not message.has_sender():
|
||||
return False
|
||||
engine = _pick_text_flow(
|
||||
sender_id=message.sender_id, text=message.content or ""
|
||||
)
|
||||
sess = engine.session_for_action(
|
||||
message.sender_id, message.raw, text=message.content or ""
|
||||
)
|
||||
if sess is None:
|
||||
return False
|
||||
phase = (sess.phase or "").strip()
|
||||
if phase in {"wait_lost_reason", "handoff_offer"}:
|
||||
return True
|
||||
return _front_bookmark(sess)
|
||||
|
||||
|
||||
def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = "", prefer_quoted: bool = False):
|
||||
"""
|
||||
按当前书签或本句运输方式选空运/海运/陆运流程。
|
||||
|
||||
陆运核对卡上的确定优先于旧海运书签,避免「确定」被海运/空运吃掉。
|
||||
陆运核对卡上的「确定」优先于旧海运书签,避免「确定」被海运/空运吃掉。
|
||||
本句点名海运/空运/陆运时盖掉其它方式的旧补问草稿。
|
||||
核对卡优先于选项补问;海运/空运补问优先于旧陆运选项草稿,避免补贸易条款发陆运样例图。
|
||||
正在补问时,补问书签优先于已报价调价,避免第 3 次反问出不了转人工卡。
|
||||
A 已判成调价且当前不是补问:交给当前已报价工单,避免旧海运书签抢走。
|
||||
图片识图在 Worker 写下的补问书签,HTTP 必须从 Redis 读到,否则「托盘」会变成再问运输方式。
|
||||
"""
|
||||
from agent.schema.land_options import looks_like_confirm
|
||||
from agent.policy.quote_adjust_ops import quoted_adjust_flow
|
||||
from agent.policy.handoff_human import is_handoff_frozen
|
||||
|
||||
land = get_land_text_flow()
|
||||
if looks_like_confirm(text):
|
||||
@@ -51,20 +122,33 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = ""):
|
||||
return land
|
||||
sea = get_sea_text_flow()
|
||||
air = get_air_text_flow()
|
||||
spoken = (injected_mode or detect_transport_mode(text) or "").upper()
|
||||
# 本句点名运输方式:盖掉其它方式的旧补问,避免海运补字段走陆运样例图。
|
||||
if spoken in {"SEA", "AIR", "LAND"}:
|
||||
_drop_other_mode_drafts(owner=spoken, sender_id=sender_id)
|
||||
if spoken == "SEA":
|
||||
return sea
|
||||
if spoken == "LAND":
|
||||
return land
|
||||
return air
|
||||
sea_sess = sea.session_of(sender_id)
|
||||
land_sess = land.session_of(sender_id)
|
||||
air_sess = air.session_of(sender_id)
|
||||
# 核对卡仍优先:销售正在改体积/点确定,不能被另一方式的补问草稿抢走。
|
||||
for sess, flow in ((land_sess, land), (air_sess, air), (sea_sess, sea)):
|
||||
if sess is not None and (sess.phase or "").strip() == "wait_confirm" and not is_handoff_frozen(sess):
|
||||
return flow
|
||||
# 补问书签:海运/空运优先于旧陆运选项草稿。陆运「确定」已在上面拦过。
|
||||
for sess, flow in ((sea_sess, sea), (air_sess, air), (land_sess, land)):
|
||||
if _front_bookmark(sess):
|
||||
return flow
|
||||
if sea_sess and (sea_sess.business_line or "").upper() == "SEA":
|
||||
if prefer_quoted:
|
||||
quoted = quoted_adjust_flow(sender_id)
|
||||
if quoted is not None:
|
||||
return quoted
|
||||
if sea_sess and not is_handoff_frozen(sea_sess) and (sea_sess.business_line or "").upper() == "SEA":
|
||||
return sea
|
||||
if land_sess and (land_sess.business_line or "").upper() == "LAND":
|
||||
return land
|
||||
mode = (injected_mode or detect_transport_mode(text) or "").upper()
|
||||
if mode == "SEA":
|
||||
return sea
|
||||
if mode == "LAND":
|
||||
if land_sess and not is_handoff_frozen(land_sess) and (land_sess.business_line or "").upper() == "LAND":
|
||||
return land
|
||||
return air
|
||||
|
||||
@@ -133,10 +217,13 @@ def handle_text_inquiry(
|
||||
chat_fn=None,
|
||||
flow=None,
|
||||
store=None,
|
||||
prefer_quoted: bool = False,
|
||||
prefer_handoff: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
处理一条私聊文字询价。
|
||||
|
||||
prefer_quoted:A 已判成调价时交给当前已报价工单。
|
||||
须已有 sender_id。副作用:outbox 多条回复;主账经 flow.ledger。
|
||||
返回:流程阶段名。
|
||||
"""
|
||||
@@ -152,6 +239,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] = []
|
||||
|
||||
@@ -168,6 +256,15 @@ def handle_text_inquiry(
|
||||
logger.info("text_inquiry 出站 ok=%s info=%s phase_reply=%s", ok, info, body[:80])
|
||||
return info if ok else ""
|
||||
|
||||
if prefer_handoff:
|
||||
phase = engine.offer_handoff(
|
||||
sender_id=message.sender_id,
|
||||
text=message.content or "",
|
||||
reply=reply,
|
||||
)
|
||||
logger.info("text_inquiry handoff phase=%s sender=%s", phase, message.sender_id)
|
||||
return phase
|
||||
|
||||
phase = engine.on_text(
|
||||
sender_id=message.sender_id,
|
||||
text=message.content or "",
|
||||
@@ -185,21 +282,26 @@ def handle_text_inquiry(
|
||||
|
||||
def handle_text_other(message: InboundMessage, *, store=None) -> str:
|
||||
"""
|
||||
闲聊/其它:若有待点按钮则当按钮;否则回首次单聊话术。
|
||||
闲聊/其它:补问短答续填;待点按钮当按钮;否则回首次单聊话术。
|
||||
"""
|
||||
from agent.policy import inquiry_copy as copy
|
||||
|
||||
if not message.has_sender():
|
||||
return "reject_no_sender"
|
||||
msg_store = store or get_message_store()
|
||||
if should_resume_other_as_inquiry(message):
|
||||
logger.info(
|
||||
"text_other 续填当前询价 sender=%s chars=%s",
|
||||
message.sender_id,
|
||||
len((message.content or "").strip()),
|
||||
)
|
||||
return handle_text_inquiry(message, store=msg_store)
|
||||
engine = _pick_text_flow(
|
||||
sender_id=message.sender_id, text=message.content or ""
|
||||
)
|
||||
sess = engine.session_for_action(
|
||||
message.sender_id, message.raw, text=message.content or ""
|
||||
)
|
||||
if sess and sess.phase == "wait_lost_reason":
|
||||
return handle_text_inquiry(message, store=msg_store)
|
||||
btn = engine.match_button(message.content or "", sess)
|
||||
if btn:
|
||||
return handle_text_inquiry(message, store=msg_store)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {},
|
||||
},
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -22,13 +22,13 @@ _MODE_WORDS = (
|
||||
# 仅「键:值」一行一条,键必须是合同字段或展示名
|
||||
_LABEL_LINE = re.compile(
|
||||
r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
|
||||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型/数量|车型数量|"
|
||||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|"
|
||||
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
|
||||
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(.+)$"
|
||||
)
|
||||
_LABEL_INLINE = re.compile(
|
||||
r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
|
||||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型/数量|车型数量|"
|
||||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|"
|
||||
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
|
||||
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(\S+)"
|
||||
)
|
||||
@@ -246,7 +246,7 @@ def extract_collab_fields(
|
||||
"""
|
||||
群里协同字段:标签(冒号可省)+ 口语含油/含电/含磁/危险品、货好时间。
|
||||
|
||||
allowed_keys 传入则只返回本单该有的项。不传则海运六项 ∪ 陆运协同键,给意图分类。
|
||||
allowed_keys 传入则只返回本单该有的项。不传则海运协同五项 ∪ 贸易条款(陆运跨境仍要)∪ 陆运协同键,给意图分类。
|
||||
闲聊、只说「纸箱」不进这里。商品海关编码 / HS 折成 HS编码。
|
||||
"""
|
||||
from agent.schema.field_validate import (
|
||||
|
||||
@@ -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
|
||||
@@ -21,7 +21,9 @@ from agent.policy.sea_group_ops import (
|
||||
emit_group_deal,
|
||||
filename_is_excel_pdf,
|
||||
group_client_of,
|
||||
halt_closed_group,
|
||||
hydrate_ticket,
|
||||
is_handoff_ticket,
|
||||
is_sales,
|
||||
is_terminal,
|
||||
merge_quote_fees,
|
||||
@@ -35,7 +37,7 @@ from agent.policy.sea_group_ops import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TERMINAL = {"已成交", "未成交", "已关闭"}
|
||||
TERMINAL = {"已成交", "未成交", "已关闭", "转人工"}
|
||||
OPEN_ACTIVATE = {"询价中", "已报价", "协商中"}
|
||||
AIRLINE_ROLE = "air"
|
||||
|
||||
@@ -114,9 +116,12 @@ def activate_ticket(
|
||||
send_group_text(client, chat_id, copy.air_ticket_not_air(no))
|
||||
return None, "not_air"
|
||||
if err == "terminal":
|
||||
st = str(out.get("status") or "")
|
||||
if st == "转人工":
|
||||
return None, "handoff_ignore"
|
||||
if speak:
|
||||
send_group_text(
|
||||
client, chat_id, copy.air_ticket_closed(no, str(out.get("status") or ""))
|
||||
client, chat_id, copy.air_ticket_closed(no, st)
|
||||
)
|
||||
return None, "ticket_closed"
|
||||
if err == "occupied":
|
||||
@@ -128,6 +133,8 @@ def activate_ticket(
|
||||
send_group_text(client, chat_id, copy.air_ticket_not_found(no))
|
||||
return None, "ticket_missing"
|
||||
ticket = hydrate_ticket(flow, _ticket_from_activate(book, no, out))
|
||||
if is_handoff_ticket(ticket):
|
||||
return None, "handoff_ignore"
|
||||
if speak:
|
||||
send_air_brief_and_sheet(flow=flow, ticket=ticket, chat_id=chat_id)
|
||||
_watch_archive_room(chat_id, no)
|
||||
@@ -180,7 +187,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
|
||||
@@ -319,6 +327,8 @@ def resolve_cabin_ticket(
|
||||
if is_terminal(ticket):
|
||||
if allow_terminal:
|
||||
return ticket, "terminal_loaded"
|
||||
if ticket_status(ticket) == "转人工":
|
||||
return None, "handoff_ignore"
|
||||
send_group_text(
|
||||
client, chat_id, copy.air_ticket_closed(no, ticket_status(ticket))
|
||||
)
|
||||
@@ -333,9 +343,9 @@ def apply_air_fields(*, flow: Any, ticket: object, text: str, sender_id: str, ch
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
no = ticket_no(ticket)
|
||||
client = group_client_of(flow)
|
||||
if is_terminal(ticket):
|
||||
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
|
||||
return "ticket_locked"
|
||||
stopped = halt_closed_group(flow=flow, ticket=ticket, chat_id=chat_id)
|
||||
if stopped:
|
||||
return stopped
|
||||
if not can_fill_fields(flow, ticket, sender_id):
|
||||
send_group_text(client, chat_id, copy.air_fields_denied())
|
||||
return "fields_denied"
|
||||
@@ -370,9 +380,9 @@ def apply_air_quote(
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
no = ticket_no(ticket)
|
||||
client = group_client_of(flow)
|
||||
if is_terminal(ticket):
|
||||
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
|
||||
return "ticket_locked"
|
||||
stopped = halt_closed_group(flow=flow, ticket=ticket, chat_id=chat_id)
|
||||
if stopped:
|
||||
return stopped
|
||||
if not is_airline(flow, sender_id):
|
||||
send_group_text(client, chat_id, copy.air_airline_only_quote())
|
||||
return "not_airline"
|
||||
@@ -389,6 +399,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)
|
||||
|
||||
|
||||
@@ -409,9 +423,11 @@ def apply_air_file(
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
no = ticket_no(ticket)
|
||||
client = group_client_of(flow)
|
||||
if is_terminal(ticket):
|
||||
send_group_text(client, chat_id, copy.ticket_locked_file(work_order_no=no, status=ticket_status(ticket)))
|
||||
return "ticket_locked_file"
|
||||
stopped = halt_closed_group(
|
||||
flow=flow, ticket=ticket, chat_id=chat_id, file=True
|
||||
)
|
||||
if stopped:
|
||||
return stopped
|
||||
if not filename_is_excel_pdf(filename):
|
||||
send_group_text(client, chat_id, copy.group_only_excel_pdf())
|
||||
return "file_type_rejected"
|
||||
@@ -458,9 +474,9 @@ def apply_air_tms_choice(
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
client = group_client_of(flow)
|
||||
no = ticket_no(ticket)
|
||||
if is_terminal(ticket):
|
||||
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
|
||||
return "ticket_locked"
|
||||
stopped = halt_closed_group(flow=flow, ticket=ticket, chat_id=chat_id)
|
||||
if stopped:
|
||||
return stopped
|
||||
if not is_airline(flow, sender_id):
|
||||
send_group_text(client, chat_id, copy.air_airline_only_quote())
|
||||
return "not_airline"
|
||||
@@ -531,14 +547,9 @@ def apply_air_deal(
|
||||
if not is_sales(ticket, sender_id):
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_sales_only_deal())
|
||||
return "not_sales"
|
||||
if is_terminal(ticket) and ticket_status(ticket) != "协商中":
|
||||
if ticket_status(ticket) in {"已成交", "未成交", "已关闭"}:
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
copy.ticket_locked(work_order_no=ticket_no(ticket), status=ticket_status(ticket)),
|
||||
)
|
||||
return "ticket_locked"
|
||||
stopped = halt_closed_group(flow=flow, ticket=ticket, chat_id=chat_id)
|
||||
if stopped:
|
||||
return stopped
|
||||
from agent.policy.deal_outcome import finish_deal_outcome
|
||||
from agent.policy.sea_group_ops import facts_of_group
|
||||
|
||||
@@ -807,14 +818,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 = ""
|
||||
@@ -53,6 +54,12 @@ class FlowSession:
|
||||
deal_version: int = 0
|
||||
collab_ended: bool = False
|
||||
pending_product_quote: dict[str, Any] = field(default_factory=dict)
|
||||
clarify_missing_sig: str = ""
|
||||
clarify_retry: int = 0
|
||||
# clarify=3 次反问入口;intent=销售要求人来接管。空=没有待点的转人工卡。
|
||||
handoff_offer: str = ""
|
||||
handoff_committed: bool = False
|
||||
virtual_work_order_no: str = ""
|
||||
|
||||
|
||||
def _session_payload(sess: FlowSession) -> dict[str, Any]:
|
||||
@@ -78,6 +85,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 ""),
|
||||
@@ -89,6 +97,11 @@ def _session_from_payload(raw: dict[str, Any]) -> FlowSession:
|
||||
deal_version=int(raw.get("deal_version") or 0),
|
||||
collab_ended=bool(raw.get("collab_ended")),
|
||||
pending_product_quote=dict(raw.get("pending_product_quote") or {}),
|
||||
clarify_missing_sig=str(raw.get("clarify_missing_sig") or ""),
|
||||
clarify_retry=int(raw.get("clarify_retry") or 0),
|
||||
handoff_offer=str(raw.get("handoff_offer") or ""),
|
||||
handoff_committed=bool(raw.get("handoff_committed")),
|
||||
virtual_work_order_no=str(raw.get("virtual_work_order_no") or ""),
|
||||
)
|
||||
|
||||
|
||||
@@ -189,8 +202,224 @@ class AirTextInquiryFlow:
|
||||
no = (sess.work_order_no or "").strip()
|
||||
if no:
|
||||
self._by_ticket[no] = sess
|
||||
vt = (sess.virtual_work_order_no or "").strip()
|
||||
if vt:
|
||||
self._by_ticket[vt] = sess
|
||||
self._persist_bookmark(sess)
|
||||
|
||||
def _handoff_state(self, sess: Optional[FlowSession]) -> dict[str, Any]:
|
||||
"""新建书签时带上转人工计数,避免补问一轮就把 3 次清掉。已冻结的票不当新书签底。"""
|
||||
from agent.policy.handoff_human import is_handoff_frozen
|
||||
|
||||
if sess is None or is_handoff_frozen(sess):
|
||||
return {}
|
||||
return {
|
||||
"clarify_missing_sig": sess.clarify_missing_sig,
|
||||
"clarify_retry": sess.clarify_retry,
|
||||
"handoff_offer": sess.handoff_offer,
|
||||
"handoff_committed": sess.handoff_committed,
|
||||
"virtual_work_order_no": sess.virtual_work_order_no,
|
||||
"collab_chat_id": sess.collab_chat_id,
|
||||
"work_order_no": sess.work_order_no,
|
||||
"status": sess.status,
|
||||
"quote": dict(sess.quote or {}),
|
||||
"tms_quote": dict(sess.tms_quote or {}),
|
||||
"allowed": sess.allowed,
|
||||
"invited_userids": sess.invited_userids,
|
||||
"invited_sales_name": sess.invited_sales_name,
|
||||
"invited_product_names": sess.invited_product_names,
|
||||
}
|
||||
|
||||
def _block_if_handoff_committed(self, sess: Optional[FlowSession], reply: ReplyFn) -> bool:
|
||||
"""真正转人工之后这一票不再跟。返回 True 表示已经拦住。"""
|
||||
from agent.policy.handoff_human import is_handoff_frozen
|
||||
|
||||
if sess is None:
|
||||
return False
|
||||
if is_handoff_frozen(sess):
|
||||
no = (sess.work_order_no or sess.virtual_work_order_no or "").strip()
|
||||
reply(f"工单 {no} 已转人工,本单不再自动跟进。如需新询价请直接说明新的运输需求。")
|
||||
return True
|
||||
return False
|
||||
|
||||
def _detach_frozen_bookmark(
|
||||
self, sess: Optional[FlowSession], sender_id: str
|
||||
) -> Optional[FlowSession]:
|
||||
"""
|
||||
真正转人工后丢掉当前私聊书签,好开新询价。
|
||||
|
||||
旧单仍留在工单号索引,群里带号仍能对上。
|
||||
"""
|
||||
from agent.policy.handoff_human import is_handoff_frozen
|
||||
|
||||
if sess is None or not is_handoff_frozen(sess):
|
||||
return sess
|
||||
logger.info(
|
||||
"handoff.detach_frozen sender=%s wo=%s vt=%s",
|
||||
sender_id,
|
||||
sess.work_order_no or "-",
|
||||
sess.virtual_work_order_no or "-",
|
||||
)
|
||||
self.drop_current_bookmark(sender_id)
|
||||
return None
|
||||
|
||||
def offer_handoff(self, *, sender_id: str, text: str, reply: ReplyFn) -> str:
|
||||
"""
|
||||
路由判定销售明确要求人来接管。运输方式不明则继续补问运输方式。
|
||||
"""
|
||||
from agent.llm.extract_text import detect_transport_mode
|
||||
from agent.policy.handoff_human import is_closed_for_handoff
|
||||
|
||||
sess = self.session_of(sender_id)
|
||||
if sess and is_closed_for_handoff(sess.status):
|
||||
reply("该工单已结束,不能再转人工。")
|
||||
return "handoff_closed"
|
||||
if sess and self._block_if_handoff_committed(sess, reply):
|
||||
return "handoff_done"
|
||||
mode = (sess.business_line if sess else "") or detect_transport_mode(text) or ""
|
||||
mode = (mode or "").upper()
|
||||
if not mode:
|
||||
reply(copy.ASK_TRANSPORT)
|
||||
self._save(
|
||||
FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase="need_mode",
|
||||
facts=dict(sess.facts) if sess else {},
|
||||
immutable_text=(sess.immutable_text if sess and sess.immutable_text else text),
|
||||
**self._handoff_state(sess),
|
||||
)
|
||||
)
|
||||
return "need_mode"
|
||||
if mode == "SEA":
|
||||
from agent.policy.sea_text_flow import get_sea_text_flow
|
||||
|
||||
return get_sea_text_flow()._offer_handoff_now(
|
||||
sender_id=sender_id, reason="intent", reply=reply, text=text
|
||||
)
|
||||
if mode == "LAND":
|
||||
from agent.policy.land_text_flow import get_land_text_flow
|
||||
|
||||
return get_land_text_flow()._offer_handoff_now(
|
||||
sender_id=sender_id, reason="intent", reply=reply, text=text
|
||||
)
|
||||
return self._offer_handoff_now(
|
||||
sender_id=sender_id, reason="intent", reply=reply, text=text
|
||||
)
|
||||
|
||||
def _offer_handoff_now(
|
||||
self,
|
||||
*,
|
||||
sender_id: str,
|
||||
reason: str,
|
||||
reply: ReplyFn,
|
||||
text: str = "",
|
||||
sess: Optional[FlowSession] = None,
|
||||
) -> str:
|
||||
"""
|
||||
发出转人工入口。reason=clarify|intent。
|
||||
点卡/空运群发号之前不冻结。
|
||||
"""
|
||||
from agent.policy.handoff_human import (
|
||||
has_collab_group,
|
||||
ticket_no_for_handoff_offer,
|
||||
transport_label,
|
||||
)
|
||||
|
||||
sess = sess or self.session_of(sender_id)
|
||||
if sess is None:
|
||||
sess = FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase="handoff_offer",
|
||||
business_line=self.bookmark_kind.upper() if self.bookmark_kind != "sea" else "SEA",
|
||||
immutable_text=text,
|
||||
)
|
||||
if self.bookmark_kind == "air":
|
||||
sess.business_line = "AIR"
|
||||
elif self.bookmark_kind == "land":
|
||||
sess.business_line = "LAND"
|
||||
else:
|
||||
sess.business_line = "SEA"
|
||||
line = (sess.business_line or "").upper()
|
||||
if has_collab_group(sess):
|
||||
reply(copy.HANDOFF_ALREADY_COLLAB)
|
||||
return "handoff_already"
|
||||
why = copy.HANDOFF_CLARIFY_REASON if reason == "clarify" else copy.HANDOFF_INTENT_REASON
|
||||
if line == "AIR":
|
||||
no = ticket_no_for_handoff_offer(sess)
|
||||
reply(copy.handoff_air_hint(work_order_no=no))
|
||||
sess.handoff_offer = reason
|
||||
sess.phase = "handoff_offer"
|
||||
self._save(sess)
|
||||
return "handoff_offer"
|
||||
token = (sess.work_order_no or "").strip() or sender_id
|
||||
reply(
|
||||
copy.handoff_offer_text(reason=why, transport_mode=transport_label(line)),
|
||||
copy.handoff_offer_wecom_payload(reason=why, task_token=token),
|
||||
)
|
||||
sess.handoff_offer = reason
|
||||
sess.phase = "handoff_offer"
|
||||
sess.allowed = (copy.BTN_PULL_COLLAB,)
|
||||
self._save(sess)
|
||||
return "handoff_offer"
|
||||
|
||||
def _clarify_or_handoff(
|
||||
self,
|
||||
*,
|
||||
sess: Optional[FlowSession],
|
||||
keys: list[str],
|
||||
new_sess: FlowSession,
|
||||
reply: ReplyFn,
|
||||
clarify_text: str,
|
||||
) -> str:
|
||||
"""缺项:第一次不计数;满 3 次先发(3/3)再出转人工入口,否则照常补问。"""
|
||||
from agent.policy.handoff_human import inherit_pre_create_ticket, note_clarify_round
|
||||
|
||||
if sess is not None:
|
||||
new_sess.clarify_missing_sig = sess.clarify_missing_sig
|
||||
new_sess.clarify_retry = sess.clarify_retry
|
||||
new_sess.handoff_offer = sess.handoff_offer
|
||||
new_sess.handoff_committed = sess.handoff_committed
|
||||
new_sess.virtual_work_order_no = sess.virtual_work_order_no
|
||||
inherit_pre_create_ticket(sess, new_sess)
|
||||
decision = note_clarify_round(new_sess, keys)
|
||||
reply(copy.with_clarify_progress(clarify_text, new_sess.clarify_retry))
|
||||
if decision == "handoff":
|
||||
return self._offer_handoff_now(
|
||||
sender_id=new_sess.sender_id,
|
||||
reason="clarify",
|
||||
reply=reply,
|
||||
sess=new_sess,
|
||||
)
|
||||
self._save(new_sess)
|
||||
return new_sess.phase or "clarify"
|
||||
|
||||
def _detach_ticketed_for_new_inquiry(self, sess: Optional[FlowSession]) -> Optional[FlowSession]:
|
||||
"""
|
||||
已经出过正式工单号:这一句按新一轮收字段,不带上一单。
|
||||
|
||||
测服 WO202609200012 出号后,下一句海运把旧贸易条款写进 WO202609200013。
|
||||
点按钮、未成交原因、调价已在前面处理。虚拟号还在补问,要接着填。
|
||||
"""
|
||||
if sess is None:
|
||||
return None
|
||||
from agent.policy.handoff_human import is_virtual_no
|
||||
|
||||
no = (sess.work_order_no or "").strip()
|
||||
phase = (sess.phase or "").strip()
|
||||
if not no or is_virtual_no(no):
|
||||
return sess
|
||||
if phase in {"clarify", "need_mode", "handoff_offer", "wait_lost_reason"}:
|
||||
return sess
|
||||
logger.info(
|
||||
"%s.new_over_old phase=%s wo=%s",
|
||||
self.bookmark_kind,
|
||||
phase or "-",
|
||||
no,
|
||||
)
|
||||
return None
|
||||
|
||||
def drop_current_bookmark(self, sender_id: str) -> Optional[FlowSession]:
|
||||
"""
|
||||
发图新开:丢掉当前书签,已出号会话仍留在 _by_ticket。
|
||||
@@ -465,6 +694,7 @@ class AirTextInquiryFlow:
|
||||
if not (sender_id or "").strip():
|
||||
return "reject_no_sender"
|
||||
sess = self.session_for_action(sender_id, inbound_raw, text=text)
|
||||
sess = self._detach_frozen_bookmark(sess, sender_id)
|
||||
btn = self.match_button(text, sess)
|
||||
if btn:
|
||||
return self.on_button(
|
||||
@@ -480,6 +710,13 @@ 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
|
||||
sess = self._detach_ticketed_for_new_inquiry(sess)
|
||||
|
||||
snap = extract_inquiry_snapshot(
|
||||
text,
|
||||
@@ -523,6 +760,8 @@ class AirTextInquiryFlow:
|
||||
if mode == "LAND":
|
||||
from agent.policy.land_text_flow import get_land_text_flow
|
||||
|
||||
# 本句已是陆运:丢掉空运补问草稿。
|
||||
self.drop_current_bookmark(sender_id)
|
||||
return get_land_text_flow().on_text(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
@@ -538,6 +777,7 @@ class AirTextInquiryFlow:
|
||||
# 海运交给海运 Owner,空运不再回「本轮先开通空运」
|
||||
from agent.policy.sea_text_flow import get_sea_text_flow
|
||||
|
||||
self.drop_current_bookmark(sender_id)
|
||||
return get_sea_text_flow().on_text(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
@@ -557,30 +797,30 @@ class AirTextInquiryFlow:
|
||||
tms_ready = assemble_air_query(facts)
|
||||
facts = dict(tms_ready["facts"])
|
||||
if not check["ok"] or not tms_ready["ok"]:
|
||||
# 询价必填或 TMS 查价参数不齐:回显已识别 + 待补充示例,不出询价确认
|
||||
keys = list(check["missing"] or [])
|
||||
for key in tms_ready.get("missing") or []:
|
||||
if key not in keys:
|
||||
keys.append(key)
|
||||
reply(
|
||||
copy.ask_clarify(
|
||||
new_sess = FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase="clarify",
|
||||
business_line="AIR",
|
||||
facts=facts,
|
||||
immutable_text=sess.immutable_text if sess and sess.immutable_text else text,
|
||||
wait_version=(sess.wait_version + 1) if sess else 1,
|
||||
)
|
||||
return self._clarify_or_handoff(
|
||||
sess=sess,
|
||||
keys=keys,
|
||||
new_sess=new_sess,
|
||||
reply=reply,
|
||||
clarify_text=copy.ask_clarify(
|
||||
facts=facts,
|
||||
missing_keys=keys,
|
||||
transport_mode="空运",
|
||||
)
|
||||
),
|
||||
)
|
||||
self._save(
|
||||
FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase="clarify",
|
||||
business_line="AIR",
|
||||
facts=facts,
|
||||
immutable_text=sess.immutable_text if sess and sess.immutable_text else text,
|
||||
wait_version=(sess.wait_version + 1) if sess else 1,
|
||||
)
|
||||
)
|
||||
return "clarify"
|
||||
|
||||
return self._create_and_quote(
|
||||
sender_id=sender_id,
|
||||
@@ -598,6 +838,11 @@ class AirTextInquiryFlow:
|
||||
reply: ReplyFn,
|
||||
) -> str:
|
||||
# 先落已确认字段,避免建单失败后补问轮把毛重/体积弄丢
|
||||
from agent.policy.handoff_human import void_virtual_no
|
||||
|
||||
old = self.session_of(sender_id)
|
||||
if old:
|
||||
void_virtual_no(old)
|
||||
self._save(
|
||||
FlowSession(
|
||||
sender_id=sender_id,
|
||||
@@ -661,6 +906,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 +1212,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 +1225,7 @@ class AirTextInquiryFlow:
|
||||
status=sess.status or "已报价",
|
||||
quote=sess.quote,
|
||||
facts=sess.facts,
|
||||
business_line=sess.business_line or "AIR",
|
||||
),
|
||||
]
|
||||
),
|
||||
@@ -1003,11 +1251,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",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
转人工:缺项连问计数、虚拟工单号、入口/冻结判定。
|
||||
|
||||
本文件职责:只给流程提供纯函数与轻量序号;不发企微、不改主账六态。
|
||||
调用:Worker / 私聊流程;禁止回调线程生成号后同步建群。
|
||||
为何独立:海运/陆运/空运共用同一套计数与号码,避免三份 if。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
# 新询价第一次补问不计数;之后同一批缺项再问满 3 次仍不齐,第 3 次出入口。
|
||||
CLARIFY_LIMIT = 3
|
||||
VIRTUAL_PREFIX = "VT"
|
||||
|
||||
# 陆运类型+线路还对不上时,转人工摘要仍列出这一套协同项,全部为「-」。
|
||||
_LAND_HANDOFF_COLLAB_FALLBACK = (
|
||||
"客户名称",
|
||||
"HS编码",
|
||||
"贸易条款",
|
||||
"包装方式",
|
||||
"是否为危险品",
|
||||
)
|
||||
|
||||
_seq_lock = threading.Lock()
|
||||
_seq_by_day: dict[str, int] = {}
|
||||
|
||||
HANDOFF_STATUS = "转人工"
|
||||
TERMINAL_STATUSES = frozenset({"已成交", "未成交", "已关闭", HANDOFF_STATUS})
|
||||
FROZEN_STATUSES = frozenset({HANDOFF_STATUS})
|
||||
|
||||
|
||||
def missing_signature(keys: list[str] | tuple[str, ...] | None) -> str:
|
||||
"""缺项集合签名:顺序无关,用来判断是不是同一批。"""
|
||||
return "|".join(sorted({str(k).strip() for k in (keys or []) if str(k).strip()}))
|
||||
|
||||
|
||||
def note_clarify_round(sess: Any, missing_keys: list[str] | tuple[str, ...] | None) -> str:
|
||||
"""
|
||||
记下本轮缺项。返回 ask(继续补问)或 handoff(该出转人工入口)。
|
||||
|
||||
新询价(或缺项集合刚变)的第一次补问不计数、不打(1/3)。
|
||||
之后同一签名再问:1/3、2/3;第 3 次仍不齐返回 handoff(调用方先发 3/3 再出入口)。
|
||||
签名变了:计数清零,并作废「3 次反问」那张入口(handoff_offer=clarify)。
|
||||
副作用:改 sess.clarify_* / 可能清 handoff_offer。
|
||||
"""
|
||||
sig = missing_signature(missing_keys)
|
||||
if not sig:
|
||||
if getattr(sess, "handoff_offer", "") == "clarify":
|
||||
sess.handoff_offer = ""
|
||||
sess.clarify_missing_sig = ""
|
||||
sess.clarify_retry = 0
|
||||
return "ask"
|
||||
prev = str(getattr(sess, "clarify_missing_sig", "") or "")
|
||||
retry = int(getattr(sess, "clarify_retry", 0) or 0)
|
||||
if prev == sig:
|
||||
retry = retry + 1
|
||||
sess.clarify_retry = retry
|
||||
if retry >= CLARIFY_LIMIT:
|
||||
return "handoff"
|
||||
return "ask"
|
||||
if str(getattr(sess, "handoff_offer", "") or "") == "clarify":
|
||||
sess.handoff_offer = ""
|
||||
sess.clarify_missing_sig = sig
|
||||
sess.clarify_retry = 0
|
||||
return "ask"
|
||||
|
||||
|
||||
def next_virtual_no(*, now: datetime | None = None) -> str:
|
||||
"""
|
||||
虚拟工单号:VT + 上海日历日 8 位 + 当日 4 位流水。
|
||||
|
||||
不占正式 WO。进程内计数;有 Redis 时尽量用 INCR,失败则回退内存。
|
||||
"""
|
||||
day = (now or datetime.now(_SHANGHAI)).strftime("%Y%m%d")
|
||||
seq = _next_seq(day)
|
||||
return f"{VIRTUAL_PREFIX}{day}{seq:04d}"
|
||||
|
||||
|
||||
def _next_seq(day: str) -> int:
|
||||
try:
|
||||
from agent.config import get_settings
|
||||
from agent.redis_coord.keys import join_key
|
||||
|
||||
settings = get_settings()
|
||||
raw = getattr(settings, "redis_url", "") or ""
|
||||
prefix = getattr(settings, "redis_key_prefix", "") or "inquiry_robot:"
|
||||
if raw:
|
||||
import redis
|
||||
|
||||
client = redis.Redis.from_url(raw, socket_timeout=2, socket_connect_timeout=2)
|
||||
key = join_key(prefix, "vtseq", day)
|
||||
n = int(client.incr(key))
|
||||
client.expire(key, 3 * 24 * 3600)
|
||||
return n
|
||||
except Exception:
|
||||
logger.info("虚拟号 Redis 序号不可用,改用进程内计数 day=%s", day)
|
||||
with _seq_lock:
|
||||
n = _seq_by_day.get(day, 0) + 1
|
||||
_seq_by_day[day] = n
|
||||
return n
|
||||
|
||||
|
||||
def reset_virtual_seq_for_test() -> None:
|
||||
"""单测重置当日流水。"""
|
||||
with _seq_lock:
|
||||
_seq_by_day.clear()
|
||||
|
||||
|
||||
def is_virtual_no(work_order_no: str) -> bool:
|
||||
raw = (work_order_no or "").strip().upper()
|
||||
return raw.startswith(VIRTUAL_PREFIX) and len(raw) >= 10
|
||||
|
||||
|
||||
def is_frozen_status(status: str) -> bool:
|
||||
return (status or "").strip() in FROZEN_STATUSES
|
||||
|
||||
|
||||
def is_handoff_frozen(sess: Any) -> bool:
|
||||
"""这一票已经真正转人工,私聊不应再把它当当前询价。"""
|
||||
if sess is None:
|
||||
return False
|
||||
if bool(getattr(sess, "handoff_committed", False)):
|
||||
return True
|
||||
return is_frozen_status(str(getattr(sess, "status", "") or ""))
|
||||
|
||||
|
||||
def is_closed_for_handoff(status: str) -> bool:
|
||||
"""已成交/未成交/已关闭不再出转人工入口。转人工本身也不再重复入口。"""
|
||||
st = (status or "").strip()
|
||||
return st in {"已成交", "未成交", "已关闭", HANDOFF_STATUS}
|
||||
|
||||
|
||||
def display_ticket_no(sess: Any) -> str:
|
||||
"""群/卡片展示号:正式号优先,否则虚拟号。"""
|
||||
return (
|
||||
str(getattr(sess, "work_order_no", "") or "").strip()
|
||||
or str(getattr(sess, "virtual_work_order_no", "") or "").strip()
|
||||
)
|
||||
|
||||
|
||||
def ensure_virtual_no(sess: Any) -> str:
|
||||
"""还没有正式号时确保有虚拟号。已有正式号不生成。"""
|
||||
real = str(getattr(sess, "work_order_no", "") or "").strip()
|
||||
if real:
|
||||
return real
|
||||
vt = str(getattr(sess, "virtual_work_order_no", "") or "").strip()
|
||||
if vt:
|
||||
return vt
|
||||
vt = next_virtual_no()
|
||||
sess.virtual_work_order_no = vt
|
||||
return vt
|
||||
|
||||
|
||||
# 这些阶段还没为本票建正式单,转人工入口必须用虚拟号。
|
||||
_PRE_CREATE_PHASES = frozenset(
|
||||
{
|
||||
"",
|
||||
"idle",
|
||||
"clarify",
|
||||
"need_mode",
|
||||
"need_land_options",
|
||||
"wait_confirm",
|
||||
"handoff_offer",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def ticket_no_for_handoff_offer(sess: Any) -> str:
|
||||
"""
|
||||
转人工入口上给销售看的号码。
|
||||
|
||||
本票还在补问/核对本、没建正式单:给 VT,丢掉会话里挂着的旧 WO。
|
||||
已经出过正式单(已报价/协同中等):沿用 WO。
|
||||
"""
|
||||
phase = str(getattr(sess, "phase", "") or "").strip()
|
||||
real = str(getattr(sess, "work_order_no", "") or "").strip()
|
||||
if real and not is_virtual_no(real) and phase not in _PRE_CREATE_PHASES:
|
||||
return real
|
||||
if real and not is_virtual_no(real):
|
||||
sess.work_order_no = ""
|
||||
return ensure_virtual_no(sess)
|
||||
|
||||
|
||||
def inherit_pre_create_ticket(sess: Any, new_sess: Any) -> None:
|
||||
"""
|
||||
同一轮补问可以接着用号;从已出票旧书签开新补问则不带旧 WO/群。
|
||||
"""
|
||||
prev = str(getattr(sess, "phase", "") or "").strip()
|
||||
if prev not in _PRE_CREATE_PHASES:
|
||||
return
|
||||
if not str(getattr(new_sess, "work_order_no", "") or "").strip():
|
||||
new_sess.work_order_no = str(getattr(sess, "work_order_no", "") or "").strip()
|
||||
if not str(getattr(new_sess, "collab_chat_id", "") or "").strip():
|
||||
new_sess.collab_chat_id = str(getattr(sess, "collab_chat_id", "") or "").strip()
|
||||
|
||||
|
||||
def void_virtual_no(sess: Any) -> None:
|
||||
"""正式建单后作废虚拟号。"""
|
||||
sess.virtual_work_order_no = ""
|
||||
|
||||
|
||||
def has_collab_group(sess: Any) -> bool:
|
||||
return bool(str(getattr(sess, "collab_chat_id", "") or "").strip())
|
||||
|
||||
|
||||
def transport_label(business_line: str) -> str:
|
||||
code = (business_line or "").strip().upper()
|
||||
return {"SEA": "海运", "AIR": "空运", "LAND": "陆运"}.get(code, "")
|
||||
|
||||
|
||||
def try_commit_air_group_handoff(flow: Any, *, chat_id: str, work_order_no: str) -> str:
|
||||
"""
|
||||
空运群里出现待转人工的工单号:发转人工摘要并冻结。
|
||||
对不上或虚拟号已作废:返回空,交给原激活逻辑。
|
||||
"""
|
||||
no = (work_order_no or "").strip().upper()
|
||||
if not no:
|
||||
return ""
|
||||
getter = getattr(flow, "session_by_work_order", None)
|
||||
sess = getter(no) if callable(getter) else None
|
||||
if sess is None:
|
||||
return ""
|
||||
if not sess.handoff_offer or sess.handoff_committed:
|
||||
return ""
|
||||
display = display_ticket_no(sess).upper()
|
||||
if display != no:
|
||||
return ""
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.sea_group_ops import group_client_of, send_group_text
|
||||
|
||||
book = getattr(flow, "ledger", None)
|
||||
airline_names: list[str] = []
|
||||
lister = getattr(book, "list_staff_by_role", None) if book else None
|
||||
if callable(lister):
|
||||
try:
|
||||
for row in lister(role_code="air") or []:
|
||||
name = str(row.get("name") or "").strip()
|
||||
if name:
|
||||
airline_names.append(name)
|
||||
except Exception:
|
||||
logger.exception("空运转人工取航线名单失败")
|
||||
sales = ""
|
||||
namer = getattr(flow, "_sales_display_name", None)
|
||||
if callable(namer):
|
||||
sales = namer(sess.sender_id)
|
||||
brief = copy.handoff_group_brief(
|
||||
work_order_no=display_ticket_no(sess),
|
||||
facts=sess.facts,
|
||||
business_line="AIR",
|
||||
sales_name=sales,
|
||||
airline_names=airline_names,
|
||||
)
|
||||
send_group_text(group_client_of(flow), chat_id, brief)
|
||||
real = (sess.work_order_no or "").strip()
|
||||
if real and not is_virtual_no(real):
|
||||
trans = getattr(book, "transition", None) if book else None
|
||||
if callable(trans):
|
||||
trans(
|
||||
work_order_no=real,
|
||||
to_status=HANDOFF_STATUS,
|
||||
remark=copy.HANDOFF_CLARIFY_REASON
|
||||
if sess.handoff_offer == "clarify"
|
||||
else copy.HANDOFF_INTENT_REASON,
|
||||
)
|
||||
sess.status = HANDOFF_STATUS
|
||||
sess.handoff_committed = True
|
||||
sess.handoff_offer = ""
|
||||
sess.phase = "handoff_done"
|
||||
sess.collab_chat_id = chat_id
|
||||
saver = getattr(flow, "_save", None)
|
||||
if callable(saver):
|
||||
saver(sess)
|
||||
return "handoff_done"
|
||||
|
||||
|
||||
def collab_keys_for_handoff(facts: dict[str, str] | None, business_line: str) -> tuple[str, ...]:
|
||||
"""转人工摘要要列出的协同键;对不上陆运套餐时用兜底五项。"""
|
||||
from agent.schema.field_validate import SEA_COLLAB_FIELDS, collab_fields_for_ticket
|
||||
|
||||
line = (business_line or "").strip().upper()
|
||||
keys = collab_fields_for_ticket(facts, line)
|
||||
if keys:
|
||||
return keys
|
||||
if line == "LAND":
|
||||
return _LAND_HANDOFF_COLLAB_FALLBACK
|
||||
return SEA_COLLAB_FIELDS
|
||||
@@ -24,6 +24,8 @@ 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
|
||||
from agent.schema.sea_options import sea_class_prompt
|
||||
|
||||
# 空运补问:与合同 8 项一致。报价日期默认当天,不进待补充。
|
||||
_AIR_EXAMPLES = {
|
||||
@@ -40,7 +42,7 @@ _AIR_CLARIFY = tuple(
|
||||
(key, label, _AIR_EXAMPLES[key]) for key, label in AIR_REQUIRED_FIELDS
|
||||
)
|
||||
|
||||
# 海运补问:与合同 7 项一致。报价日期默认当天,不进待补充。
|
||||
# 海运补问:与合同 10 项一致。报价日期默认当天,不进待补充。
|
||||
_SEA_EXAMPLES = {
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
@@ -48,6 +50,8 @@ _SEA_EXAMPLES = {
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
"贸易条款": "FOB",
|
||||
"运输分类": "Port to Port",
|
||||
"报价日期": "2026-09-15",
|
||||
}
|
||||
_SEA_CLARIFY = tuple(
|
||||
@@ -80,7 +84,7 @@ BTN_NEGOTIATE_KEY = "negotiate"
|
||||
BTN_COPY_TICKET = "复制工单号"
|
||||
BTN_COPY_TICKET_KEY = "copy_ticket"
|
||||
|
||||
_WO_NO = re.compile(r"WO\d{12}", re.IGNORECASE)
|
||||
_WO_NO = re.compile(r"(?:WO|VT)\d{12}", re.IGNORECASE)
|
||||
# 仅给无网 stub / 工单号回填用,正式续办意图由 DeepSeek A 判断,不要求销售说这些词。
|
||||
_CONTINUE_HINT = (
|
||||
"继续",
|
||||
@@ -100,6 +104,10 @@ _CONTINUE_HINT = (
|
||||
|
||||
|
||||
ASK_TRANSPORT = "请补充运输方式:海运 / 空运 / 陆运。"
|
||||
HANDOFF_ALREADY_COLLAB = "该工单已拉产品进群协同。"
|
||||
HANDOFF_CLARIFY_REASON = "参数提取不全(连续 3 次反问仍不完整)"
|
||||
HANDOFF_INTENT_REASON = "已识别您希望转人工处理"
|
||||
CLARIFY_LIMIT_HINT = "连续 3 次反问仍不完整将触发转人工"
|
||||
# 私聊图片询价:先回识别中;看不清/失败与补问拼成一条,销售侧不加 [test]。
|
||||
IMAGE_RECOGNIZING = "正在识别图片"
|
||||
IMAGE_UNREADABLE = "图片没看清询价信息,请换一张更清楚的,或直接打字。"
|
||||
@@ -267,6 +275,28 @@ def _land_field_line(key: str, value: str, *, required: bool, example: str = "")
|
||||
return f"{label}({mark}):"
|
||||
|
||||
|
||||
def clarify_progress_line(retry: int) -> str:
|
||||
"""补问次数提醒:参数提取不全(1/3)。"""
|
||||
n = int(retry or 0)
|
||||
if n < 1:
|
||||
n = 1
|
||||
if n > 3:
|
||||
n = 3
|
||||
return f"参数提取不全({n}/3)。{CLARIFY_LIMIT_HINT}"
|
||||
|
||||
|
||||
def with_clarify_progress(body: str, retry: int) -> str:
|
||||
"""把次数提醒顶在补问正文前面。第一次补问(retry=0)不打(1/3)。"""
|
||||
text = (body or "").strip()
|
||||
n = int(retry or 0)
|
||||
if n < 1:
|
||||
return text
|
||||
head = clarify_progress_line(n)
|
||||
if not text:
|
||||
return head
|
||||
return head + "\n" + text
|
||||
|
||||
|
||||
def ask_land_fields(
|
||||
*,
|
||||
facts: dict[str, str] | None = None,
|
||||
@@ -377,7 +407,10 @@ def ask_clarify(
|
||||
for key, label, example in rows:
|
||||
if key in missing_set:
|
||||
any_missing = True
|
||||
lines.append(f"{label}:(如:{example})")
|
||||
if sea and key == "运输分类":
|
||||
lines.append(sea_class_prompt())
|
||||
else:
|
||||
lines.append(f"{label}:(如:{example})")
|
||||
if not any_missing:
|
||||
return ""
|
||||
return "\n".join(lines)
|
||||
@@ -458,6 +491,8 @@ def inquiry_field_lines(
|
||||
qty = (facts.get("货量") or facts.get("货物数量") or "-").strip() or "-"
|
||||
load = (facts.get("整柜或拼柜") or "-").strip() or "-"
|
||||
box = (facts.get("箱型箱量") or "-").strip() or "-"
|
||||
trade = (facts.get("贸易条款") or "-").strip() or "-"
|
||||
klass = (facts.get("运输分类") or "-").strip() or "-"
|
||||
return [
|
||||
f"工单号:{work_order_no}",
|
||||
f"运输方式:{transport_mode}",
|
||||
@@ -467,6 +502,8 @@ def inquiry_field_lines(
|
||||
f"货物数量:{qty}",
|
||||
f"整柜或拼柜:{load}",
|
||||
f"箱型箱量:{box}",
|
||||
f"贸易条款:{trade}",
|
||||
f"运输分类:{klass}",
|
||||
f"报价日期:{quote_date}",
|
||||
]
|
||||
if (transport_mode or "").strip() == "陆运":
|
||||
@@ -562,7 +599,7 @@ def canonical_button(raw: str) -> str:
|
||||
|
||||
|
||||
def extract_work_order_no(text: str) -> str:
|
||||
"""从任意原话里抽出 WO + 12 位数字;没有则空。不判断意图。"""
|
||||
"""从任意原话里抽出 WO/VT + 12 位数字;没有则空。不判断意图。"""
|
||||
hit = _WO_NO.search(text or "")
|
||||
return hit.group(0).upper() if hit else ""
|
||||
|
||||
@@ -671,6 +708,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,17 +776,78 @@ 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:
|
||||
_SEA_FEE_BUCKETS = (
|
||||
("polCostItems", "始发港费用", "始发港费用小计"),
|
||||
("oceanCostItems", "海运费", "海运费小计"),
|
||||
("podCostItems", "目的港费用", "目的港费用小计"),
|
||||
)
|
||||
|
||||
|
||||
def _parse_fee_bucket(quote: dict[str, Any], key: str) -> list[dict[str, Any]]:
|
||||
"""读 polCostItems / fee_rows_polCostItems,字符串 JSON 也认。"""
|
||||
raw = quote.get(key) or quote.get("fee_rows_" + key)
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [row for row in raw if isinstance(row, dict)]
|
||||
|
||||
|
||||
def sea_bucket_fee_lines(quote: dict[str, Any] | None) -> list[str]:
|
||||
"""
|
||||
卡片引用条正文:费用行 + 总销售价合计。
|
||||
海运三段正文。有任一段桶才用;否则返回空列表,调用方回退扁平 fee_lines。
|
||||
|
||||
费目按该条币种金额原样;小计用 fee_total_* + 表头结算币种。本地不折算。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
|
||||
sections: list[str] = []
|
||||
any_bucket = False
|
||||
for key, title, subtotal_label in _SEA_FEE_BUCKETS:
|
||||
rows = _parse_fee_bucket(src, key)
|
||||
subtotal = str(src.get("fee_total_" + key) or "").strip()
|
||||
if not rows and not subtotal:
|
||||
continue
|
||||
any_bucket = True
|
||||
sections.append(title)
|
||||
for row in rows:
|
||||
name = str(row.get("name") or row.get("label") or row.get("feeName") or "").strip()
|
||||
amount = str(row.get("amount") or row.get("value") or "").strip()
|
||||
line_ccy = str(row.get("currency") or row.get("currencyCode") or header_ccy).strip()
|
||||
if name and amount and amount != "-":
|
||||
sections.append(f"{name}:{money_text(amount, line_ccy)}")
|
||||
if subtotal and subtotal != "-":
|
||||
sections.append(f"{subtotal_label}:{money_text(subtotal, header_ccy)}")
|
||||
sections.append("")
|
||||
if not any_bucket:
|
||||
return []
|
||||
while sections and sections[-1] == "":
|
||||
sections.pop()
|
||||
return sections
|
||||
|
||||
|
||||
def quote_area_fee_text(
|
||||
quote: dict[str, Any] | None,
|
||||
business_line: str = "",
|
||||
transport_mode: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
卡片引用条正文:费用行 + 总销售价。
|
||||
|
||||
必须走 quote_text。塞进 quote_area.title 会被企微整段藏掉。
|
||||
"""
|
||||
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)
|
||||
fee_lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)]
|
||||
fee_lines.append(f"总销售价合计:{total}")
|
||||
bucket_lines = sea_bucket_fee_lines(src)
|
||||
if bucket_lines:
|
||||
fee_lines = list(bucket_lines)
|
||||
else:
|
||||
fee_lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)]
|
||||
fee_lines.append(f"{_total_label(src, business_line, transport_mode)}:{total}")
|
||||
return "\n".join(fee_lines)
|
||||
|
||||
|
||||
@@ -725,22 +855,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 +885,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 +897,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 +964,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 费用、总销售价、时效、有效期。
|
||||
|
||||
给文本兜底和企微引用条共用,禁止在这里编时效/有效期。
|
||||
价格卡不展示工单状态,也不展示相同询价内容检索。
|
||||
@@ -835,8 +977,15 @@ def quote_detail_lines(
|
||||
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)
|
||||
lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)]
|
||||
lines.append(f"总销售价合计:{total}")
|
||||
bucket_lines = sea_bucket_fee_lines(src) if (business_line or "").upper() == "SEA" or (
|
||||
transport_mode or ""
|
||||
).strip() == "海运" else []
|
||||
if bucket_lines:
|
||||
lines = list(bucket_lines)
|
||||
else:
|
||||
lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)]
|
||||
lines.append(f"{_total_label(src, business_line, transport_mode)}:{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 +1013,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 +1034,7 @@ def tms_hit_wecom_payload(
|
||||
一张价格卡,销售侧顺序:
|
||||
|
||||
价格明细
|
||||
┌ 引用条:空运费 / 其它费用 / 总销售价合计
|
||||
┌ 引用条:空运费 / 其它费用 / 总销售价
|
||||
时效、报价有效期
|
||||
协同说明(普通文字,贴按钮上方)
|
||||
按钮
|
||||
@@ -912,7 +1062,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 +1197,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 +1209,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 +1226,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 +1248,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),
|
||||
@@ -1168,6 +1321,96 @@ def sea_tms_miss_wecom_payload(*, work_order_no: str, action_url: str = "") -> d
|
||||
return {"msgtype": "template_card", "template_card": card}
|
||||
|
||||
|
||||
def handoff_air_hint(*, work_order_no: str) -> str:
|
||||
"""空运转人工:只发文字,不建群。"""
|
||||
no = (work_order_no or "").strip()
|
||||
if no:
|
||||
return f"触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号 {no}。"
|
||||
return "触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号。"
|
||||
|
||||
|
||||
def handoff_offer_text(*, reason: str, transport_mode: str) -> str:
|
||||
"""海运/陆运转人工卡片的文字兜底(点卡或原样回复按钮均可)。"""
|
||||
why = (reason or "").strip() or HANDOFF_INTENT_REASON
|
||||
mode = (transport_mode or "").strip()
|
||||
lines = [why]
|
||||
if mode:
|
||||
lines.append(f"运输方式:{mode}")
|
||||
lines.append("是否拉产品人员进群协同?")
|
||||
lines.append(BTN_PULL_COLLAB)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def handoff_offer_wecom_payload(*, reason: str, task_token: str) -> dict[str, Any]:
|
||||
"""海运/陆运一张卡、一个按钮:拉产品进群协同。"""
|
||||
token = (task_token or "handoff").strip() or "handoff"
|
||||
why = (reason or "").strip() or HANDOFF_INTENT_REASON
|
||||
return {
|
||||
"msgtype": "template_card",
|
||||
"template_card": {
|
||||
"card_type": "button_interaction",
|
||||
"source": {"desc": "转人工", "desc_color": 0},
|
||||
"main_title": {"title": why, "desc": "是否拉产品人员进群协同?"},
|
||||
"task_id": card_task_id("handoff", token),
|
||||
"button_list": [
|
||||
{
|
||||
"text": BTN_PULL_COLLAB,
|
||||
"style": 1,
|
||||
"key": f"{BTN_PULL_COLLAB_KEY}:{token}",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def handoff_group_brief(
|
||||
*,
|
||||
work_order_no: str,
|
||||
facts: dict[str, str] | None,
|
||||
business_line: str,
|
||||
product_names: list[str] | None = None,
|
||||
sales_name: str = "",
|
||||
airline_names: list[str] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
转人工群摘要:@人 +「工单号转人工」+ 已整理询价;未整理与协同全部为 -。
|
||||
不写 TMS、不写模板。
|
||||
"""
|
||||
from agent.policy.handoff_human import collab_keys_for_handoff, transport_label
|
||||
from agent.schema.field_validate import collab_display_name
|
||||
|
||||
no = (work_order_no or "").strip()
|
||||
line = (business_line or "").strip().upper()
|
||||
mode = transport_label(line) or "海运"
|
||||
names: list[str] = []
|
||||
if line == "AIR":
|
||||
if (sales_name or "").strip():
|
||||
names.append(sales_name.strip())
|
||||
for n in airline_names or []:
|
||||
if (n or "").strip() and n.strip() not in names:
|
||||
names.append(n.strip())
|
||||
else:
|
||||
for n in product_names or []:
|
||||
if (n or "").strip() and n.strip() not in names:
|
||||
names.append(n.strip())
|
||||
at = " ".join(f"@{n}" for n in names)
|
||||
src = dict(facts or {})
|
||||
field_lines = inquiry_field_lines(work_order_no=no, facts=src, transport_mode=mode)
|
||||
body = field_lines[1:] if field_lines else []
|
||||
lines: list[str] = []
|
||||
if at:
|
||||
lines.append(at)
|
||||
lines.append(f"{no}转人工")
|
||||
lines.append("询价字段:")
|
||||
lines.extend(body)
|
||||
lines.append("")
|
||||
lines.append("协同补充字段:")
|
||||
for key in collab_keys_for_handoff(src, line):
|
||||
label = collab_display_name(key, line)
|
||||
lines.append(f"{label}:-")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _role_label(name: str, role: str) -> str:
|
||||
"""姓名前加岗位,避免「销售甲、乙」被读成两人都是销售。已带岗位前缀的不再重复加。"""
|
||||
text = (name or "").strip()
|
||||
@@ -1217,10 +1460,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 +1554,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 +1575,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 +1658,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 +1799,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 +1833,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 +1925,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 +1951,7 @@ def deal_card(
|
||||
summary: str = "",
|
||||
quote: dict[str, Any] | None = None,
|
||||
facts: dict[str, str] | None = None,
|
||||
business_line: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
成交跟进文字兜底(模板卡发失败时仍给销售看)。
|
||||
@@ -1551,7 +1960,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 +1977,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 +2000,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": [
|
||||
{
|
||||
@@ -1679,12 +2091,12 @@ def group_fields_not_recognized(
|
||||
keys: tuple[str, ...] | None = None,
|
||||
business_line: str = "",
|
||||
) -> str:
|
||||
"""没认出协同字段。陆运列出本单可补项;海运保持原六项口。"""
|
||||
"""没认出协同字段。陆运列出本单可补项;海运列出协同五项(不含贸易条款)。"""
|
||||
if keys:
|
||||
line = (business_line or "").strip().upper() or "SEA"
|
||||
names = [collab_display_name(k, line) for k in keys]
|
||||
return "听到了。请直接说" + "、".join(names) + "。"
|
||||
return "听到了。请直接说贸易条款、货好时间、商品海关编码、是否含油/含电/含磁。"
|
||||
return "听到了。请直接说货好时间、商品海关编码、是否含油/含电/含磁。"
|
||||
|
||||
|
||||
def group_deal_stale(*, work_order_no: str) -> str:
|
||||
@@ -1900,30 +2312,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 +2354,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 {},
|
||||
)
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
retry = self.restore_tms_retry_session(sender_id)
|
||||
if retry:
|
||||
sess = retry
|
||||
sess = self._detach_frozen_bookmark(sess, sender_id)
|
||||
phase = (sess.phase if sess else "") or ""
|
||||
btn = self.match_button(text, sess)
|
||||
if btn:
|
||||
@@ -98,6 +99,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 转回来,递归到死、一句都不回。
|
||||
@@ -162,6 +169,8 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
if mode == "SEA":
|
||||
from agent.policy.sea_text_flow import get_sea_text_flow
|
||||
|
||||
# 抽成海运后丢掉陆运选项草稿,下一句补字段不能再发陆运样例图。
|
||||
self.drop_current_bookmark(sender_id)
|
||||
return get_sea_text_flow().on_text(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
@@ -175,6 +184,7 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
)
|
||||
from agent.policy.air_text_flow import get_air_text_flow
|
||||
|
||||
self.drop_current_bookmark(sender_id)
|
||||
return get_air_text_flow().on_text(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
@@ -362,6 +372,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 +385,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"
|
||||
|
||||
@@ -459,44 +471,67 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
self._clear_parked_confirm(sender_id)
|
||||
return "need_land_options"
|
||||
if state["stage"] == "options_missing":
|
||||
logger.info(
|
||||
"land.options_missing missing=%s",
|
||||
(state.get("opt") or {}).get("missing") or [],
|
||||
)
|
||||
self._reply_land_options(reply, facts)
|
||||
self._save(
|
||||
FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase="need_land_options",
|
||||
business_line="LAND",
|
||||
facts=facts,
|
||||
immutable_text=immutable,
|
||||
wait_version=(sess.wait_version + 1) if sess else 1,
|
||||
)
|
||||
)
|
||||
self._clear_parked_confirm(sender_id)
|
||||
return "need_land_options"
|
||||
reply(
|
||||
copy.ask_land_fields(
|
||||
facts=facts,
|
||||
missing_keys=state.get("missing") or [],
|
||||
land_subtype=state.get("land_type") or "",
|
||||
)
|
||||
)
|
||||
self._save(
|
||||
FlowSession(
|
||||
keys = list((state.get("opt") or {}).get("missing") or [])
|
||||
logger.info("land.options_missing missing=%s", keys)
|
||||
new_sess = FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase="clarify",
|
||||
phase="need_land_options",
|
||||
business_line="LAND",
|
||||
facts=facts,
|
||||
immutable_text=immutable,
|
||||
wait_version=(sess.wait_version + 1) if sess else 1,
|
||||
)
|
||||
from agent.policy.handoff_human import inherit_pre_create_ticket, note_clarify_round
|
||||
|
||||
if sess is not None:
|
||||
new_sess.clarify_missing_sig = sess.clarify_missing_sig
|
||||
new_sess.clarify_retry = sess.clarify_retry
|
||||
new_sess.handoff_offer = sess.handoff_offer
|
||||
new_sess.handoff_committed = sess.handoff_committed
|
||||
new_sess.virtual_work_order_no = sess.virtual_work_order_no
|
||||
inherit_pre_create_ticket(sess, new_sess)
|
||||
decision = note_clarify_round(new_sess, keys)
|
||||
self._reply_land_options(
|
||||
reply,
|
||||
facts,
|
||||
message=copy.with_clarify_progress(
|
||||
option_prompt(current=facts), new_sess.clarify_retry
|
||||
),
|
||||
)
|
||||
if decision == "handoff":
|
||||
self._clear_parked_confirm(sender_id)
|
||||
return self._offer_handoff_now(
|
||||
sender_id=sender_id,
|
||||
reason="clarify",
|
||||
reply=reply,
|
||||
sess=new_sess,
|
||||
)
|
||||
self._save(new_sess)
|
||||
self._clear_parked_confirm(sender_id)
|
||||
return "need_land_options"
|
||||
new_sess = FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase="clarify",
|
||||
business_line="LAND",
|
||||
facts=facts,
|
||||
immutable_text=immutable,
|
||||
wait_version=(sess.wait_version + 1) if sess else 1,
|
||||
)
|
||||
phase = self._clarify_or_handoff(
|
||||
sess=sess,
|
||||
keys=list(state.get("missing") or []),
|
||||
new_sess=new_sess,
|
||||
reply=reply,
|
||||
clarify_text=copy.ask_land_fields(
|
||||
facts=facts,
|
||||
missing_keys=state.get("missing") or [],
|
||||
land_subtype=state.get("land_type") or "",
|
||||
),
|
||||
)
|
||||
self._clear_parked_confirm(sender_id)
|
||||
return "clarify"
|
||||
return phase
|
||||
|
||||
def restore_confirm_session(self, sender_id: str) -> Optional[FlowSession]:
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
halt_closed_group,
|
||||
hydrate_ticket,
|
||||
is_sales,
|
||||
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)
|
||||
stopped = halt_closed_group(flow=flow, ticket=ticket, sess=sess, chat_id=room)
|
||||
if stopped:
|
||||
return stopped
|
||||
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"
|
||||
@@ -2,7 +2,7 @@
|
||||
海运协同群业务:填表进群、补字段重发、产品报价、附件、解散、成交跟进。
|
||||
|
||||
本文件职责:群里副作用(主账+企微群)。私聊按钮仍走 sea_text_flow.on_button。
|
||||
禁止:改询价七项;终态默默忽略;把销售附件当报价。
|
||||
禁止:改询价十项;终态默默忽略;把销售附件当报价。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -37,7 +37,9 @@ _COLLAB_FILL_ALIASES = {
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TERMINAL = {"已成交", "未成交", "已关闭"}
|
||||
TERMINAL = {"已成交", "未成交", "已关闭", "转人工"}
|
||||
CLOSED = {"已成交", "未成交", "已关闭"}
|
||||
HANDOFF_STATUS = "转人工"
|
||||
OPEN_EDIT = {"询价中", "已报价", "协商中"}
|
||||
EXCEL_PDF = {".xlsx", ".xls", ".xlsm", ".pdf"}
|
||||
|
||||
@@ -65,6 +67,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 +86,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 []),
|
||||
@@ -124,6 +128,57 @@ def is_terminal(ticket: object) -> bool:
|
||||
return ticket_status(ticket) in TERMINAL
|
||||
|
||||
|
||||
def is_handoff_ticket(ticket: object, sess: Any = None) -> bool:
|
||||
"""这一票已经真正转人工:群里不再跟、也不回锁定话术。"""
|
||||
if ticket_status(ticket) == HANDOFF_STATUS:
|
||||
return True
|
||||
if sess is None:
|
||||
return False
|
||||
if str(getattr(sess, "status", "") or "").strip() == HANDOFF_STATUS:
|
||||
return True
|
||||
return bool(getattr(sess, "handoff_committed", False))
|
||||
|
||||
|
||||
def halt_closed_group(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
sess: Any = None,
|
||||
chat_id: str = "",
|
||||
file: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
转人工:静默。已成交/未成交/已关闭:回不能改/不能传附件。
|
||||
返回空表示可以继续跟。
|
||||
"""
|
||||
no = ticket_no(ticket)
|
||||
room = (chat_id or "").strip() or str(
|
||||
(sess.collab_chat_id if sess else "")
|
||||
or ticket_view(ticket).get("collab_chat_id")
|
||||
or ""
|
||||
)
|
||||
if is_handoff_ticket(ticket, sess):
|
||||
logger.info("group.handoff_ignore wo=%s chat=%s", no or "-", room or "-")
|
||||
return "handoff_ignore"
|
||||
st = ticket_status(ticket) or (
|
||||
str(getattr(sess, "status", "") or "").strip() if sess is not None else ""
|
||||
)
|
||||
if st in CLOSED or is_terminal(ticket) or (
|
||||
sess is not None and str(getattr(sess, "status", "") or "").strip() in CLOSED
|
||||
):
|
||||
client = group_client_of(flow)
|
||||
if file:
|
||||
send_group_text(
|
||||
client, room, copy.ticket_locked_file(work_order_no=no, status=st or "已关闭")
|
||||
)
|
||||
return "ticket_locked_file"
|
||||
send_group_text(
|
||||
client, room, copy.ticket_locked(work_order_no=no, status=st or "已关闭")
|
||||
)
|
||||
return "ticket_locked"
|
||||
return ""
|
||||
|
||||
|
||||
def is_sales(ticket: object, sender_id: str) -> bool:
|
||||
uid = (sender_id or "").strip()
|
||||
view = ticket_view(ticket)
|
||||
@@ -327,7 +382,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 +409,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 +452,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
|
||||
@@ -609,9 +665,9 @@ def apply_collab_update(
|
||||
no = ticket_no(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or ticket_view(ticket).get("collab_chat_id") or "")
|
||||
client = group_client_of(flow)
|
||||
if is_terminal(ticket):
|
||||
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
|
||||
return "ticket_locked"
|
||||
stopped = halt_closed_group(flow=flow, ticket=ticket, sess=sess, chat_id=chat_id)
|
||||
if stopped:
|
||||
return stopped
|
||||
line, keys = _collab_keys_of(ticket, sess)
|
||||
fields = extract_collab_fields(text, allow_b=True, allowed_keys=keys)
|
||||
if not fields:
|
||||
@@ -662,9 +718,9 @@ def apply_product_quote(
|
||||
view = ticket_view(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
|
||||
client = group_client_of(flow)
|
||||
if is_terminal(ticket):
|
||||
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
|
||||
return "ticket_locked"
|
||||
stopped = halt_closed_group(flow=flow, ticket=ticket, sess=sess, chat_id=chat_id)
|
||||
if stopped:
|
||||
return stopped
|
||||
if not is_product(ticket, sender_id, flow=flow):
|
||||
send_group_text(client, chat_id, copy.group_only_product_can_quote())
|
||||
return "quote_not_product"
|
||||
@@ -691,6 +747,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(
|
||||
@@ -723,9 +781,9 @@ def apply_tms_quote_choice(
|
||||
view = ticket_view(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
|
||||
client = group_client_of(flow)
|
||||
if is_terminal(ticket):
|
||||
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
|
||||
return "ticket_locked"
|
||||
stopped = halt_closed_group(flow=flow, ticket=ticket, sess=sess, chat_id=chat_id)
|
||||
if stopped:
|
||||
return stopped
|
||||
if not is_product(ticket, sender_id, flow=flow):
|
||||
send_group_text(client, chat_id, copy.group_only_product_can_quote())
|
||||
return "quote_not_product"
|
||||
@@ -772,9 +830,9 @@ def apply_confirm_tms_quote(
|
||||
view = ticket_view(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
|
||||
client = group_client_of(flow)
|
||||
if is_terminal(ticket):
|
||||
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
|
||||
return "ticket_locked"
|
||||
stopped = halt_closed_group(flow=flow, ticket=ticket, sess=sess, chat_id=chat_id)
|
||||
if stopped:
|
||||
return stopped
|
||||
if not is_product(ticket, sender_id, flow=flow):
|
||||
send_group_text(client, chat_id, copy.group_only_product_can_quote())
|
||||
return "quote_not_product"
|
||||
@@ -876,9 +934,11 @@ def handle_group_file(
|
||||
view = ticket_view(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
|
||||
client = group_client_of(flow)
|
||||
if is_terminal(ticket):
|
||||
send_group_text(client, chat_id, copy.ticket_locked_file(work_order_no=no, status=ticket_status(ticket)))
|
||||
return "ticket_locked_file"
|
||||
stopped = halt_closed_group(
|
||||
flow=flow, ticket=ticket, sess=sess, chat_id=chat_id, file=True
|
||||
)
|
||||
if stopped:
|
||||
return stopped
|
||||
if not filename_is_excel_pdf(filename):
|
||||
send_group_text(client, chat_id, copy.group_only_excel_pdf())
|
||||
return "file_type_rejected"
|
||||
@@ -954,7 +1014,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 {})
|
||||
|
||||
@@ -977,10 +1048,9 @@ def apply_group_deal(
|
||||
view = ticket_view(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
|
||||
client = group_client_of(flow)
|
||||
if is_terminal(ticket) or (sess is not None and getattr(sess, "status", "") in TERMINAL):
|
||||
status = ticket_status(ticket) or (sess.status if sess else "")
|
||||
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=status))
|
||||
return "ticket_locked"
|
||||
stopped = halt_closed_group(flow=flow, ticket=ticket, sess=sess, chat_id=chat_id)
|
||||
if stopped:
|
||||
return stopped
|
||||
if not is_sales(ticket, sender_id):
|
||||
send_group_text(client, chat_id, copy.group_deal_sales_only())
|
||||
return "not_sales"
|
||||
@@ -1081,9 +1151,14 @@ def explain_deal_click(
|
||||
chat_id = (sess.collab_chat_id or "").strip()
|
||||
client = group_client_of(flow)
|
||||
no = sess.work_order_no
|
||||
if sess.status in TERMINAL:
|
||||
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=sess.status))
|
||||
return "ticket_locked"
|
||||
stopped = halt_closed_group(
|
||||
flow=flow,
|
||||
ticket={"work_order_no": no, "status": sess.status, "collab_chat_id": chat_id},
|
||||
sess=sess,
|
||||
chat_id=chat_id,
|
||||
)
|
||||
if stopped:
|
||||
return stopped
|
||||
if sender_id != sess.sender_id:
|
||||
send_group_text(client, chat_id, copy.group_deal_sales_only())
|
||||
return "not_sales"
|
||||
@@ -1104,14 +1179,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 +1450,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:
|
||||
|
||||
@@ -77,6 +77,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
if not (sender_id or "").strip():
|
||||
return "reject_no_sender"
|
||||
sess = self.session_for_action(sender_id, inbound_raw, text=text)
|
||||
sess = self._detach_frozen_bookmark(sess, sender_id)
|
||||
btn = self.match_button(text, sess)
|
||||
if btn:
|
||||
return self.on_button(
|
||||
@@ -92,6 +93,14 @@ 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
|
||||
# 上一单已出号:新一轮从本句重收,禁止把旧贸易条款/分类带进下一张工单。
|
||||
sess = self._detach_ticketed_for_new_inquiry(sess)
|
||||
|
||||
snap = extract_inquiry_snapshot(
|
||||
text,
|
||||
@@ -132,6 +141,8 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
if mode == "LAND":
|
||||
from agent.policy.land_text_flow import get_land_text_flow
|
||||
|
||||
# 本句已是陆运:丢掉海运补问草稿,避免下一句又被海运吃掉。
|
||||
self.drop_current_bookmark(sender_id)
|
||||
return get_land_text_flow().on_text(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
@@ -146,6 +157,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
# 空运交给空运 Owner,避免海运流程半做空运
|
||||
from agent.policy.air_text_flow import get_air_text_flow
|
||||
|
||||
self.drop_current_bookmark(sender_id)
|
||||
return get_air_text_flow().on_text(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
@@ -158,6 +170,11 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
inbound_raw=inbound_raw,
|
||||
)
|
||||
|
||||
from agent.schema.sea_options import drop_invented_door_trade_terms, harvest_sea_class
|
||||
|
||||
facts = harvest_sea_class(text, facts)
|
||||
# 旧陆运/补问草稿可能把「门到门」塞进贸易条款;原话没写条款则丢掉。
|
||||
facts = drop_invented_door_trade_terms(text, facts)
|
||||
check = validate_required_fields(facts=facts, business_line="SEA")
|
||||
facts = dict(check["facts"])
|
||||
tms_ready = assemble_sea_query(facts)
|
||||
@@ -167,25 +184,26 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
for key in tms_ready.get("missing") or []:
|
||||
if key not in keys:
|
||||
keys.append(key)
|
||||
reply(
|
||||
copy.ask_clarify(
|
||||
new_sess = FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase="clarify",
|
||||
business_line="SEA",
|
||||
facts=facts,
|
||||
immutable_text=sess.immutable_text if sess and sess.immutable_text else text,
|
||||
wait_version=(sess.wait_version + 1) if sess else 1,
|
||||
)
|
||||
return self._clarify_or_handoff(
|
||||
sess=sess,
|
||||
keys=keys,
|
||||
new_sess=new_sess,
|
||||
reply=reply,
|
||||
clarify_text=copy.ask_clarify(
|
||||
facts=facts,
|
||||
missing_keys=keys,
|
||||
transport_mode="海运",
|
||||
)
|
||||
),
|
||||
)
|
||||
self._save(
|
||||
FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase="clarify",
|
||||
business_line="SEA",
|
||||
facts=facts,
|
||||
immutable_text=sess.immutable_text if sess and sess.immutable_text else text,
|
||||
wait_version=(sess.wait_version + 1) if sess else 1,
|
||||
)
|
||||
)
|
||||
return "clarify"
|
||||
|
||||
return self._create_and_quote(
|
||||
sender_id=sender_id,
|
||||
@@ -205,6 +223,11 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
"""建海运工单、先出确认卡、再查 TMS。"""
|
||||
import uuid
|
||||
|
||||
from agent.policy.handoff_human import void_virtual_no
|
||||
|
||||
old = self.session_of(sender_id)
|
||||
if old:
|
||||
void_virtual_no(old)
|
||||
self._save(
|
||||
FlowSession(
|
||||
sender_id=sender_id,
|
||||
@@ -236,7 +259,8 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
) or ""
|
||||
self._wait_inquiry_sent(str(out_id), work_order_no=no)
|
||||
|
||||
tms = self._ledger.query_tms(work_order_no=no, facts=facts, business_line="SEA")
|
||||
tms_facts = dict(assemble_sea_query(facts).get("tms_facts") or facts)
|
||||
tms = self._ledger.query_tms(work_order_no=no, facts=tms_facts, business_line="SEA")
|
||||
sess = FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
@@ -271,6 +295,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 +308,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 +373,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 +423,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":
|
||||
@@ -405,6 +441,8 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
) -> str:
|
||||
sess = self.session_for_action(sender_id, card_meta)
|
||||
if sess and action == copy.BTN_PULL_COLLAB and action in sess.allowed:
|
||||
if sess.handoff_offer and not sess.handoff_committed:
|
||||
return self._commit_handoff_pull(sess, reply, card_meta=card_meta)
|
||||
return self._pull_collab_group(sess, reply, card_meta=card_meta)
|
||||
if action in {copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE}:
|
||||
from agent.policy.sea_group_ops import explain_deal_click
|
||||
@@ -541,6 +579,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 +588,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)
|
||||
@@ -578,6 +620,133 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
)
|
||||
return "collab_group"
|
||||
|
||||
def _match_handoff_staff(self, sess: FlowSession) -> list[dict[str, Any]]:
|
||||
"""
|
||||
转人工拉人:线路/港口已齐走原匹配;未齐则拉该运输方式全部启用产品。
|
||||
"""
|
||||
line = (sess.business_line or "SEA").upper()
|
||||
facts = dict(sess.facts or {})
|
||||
if line == "LAND":
|
||||
if str(facts.get("线路类别") or "").strip():
|
||||
return self._match_sea_staff(sess)
|
||||
lister = getattr(self._ledger, "list_staff_by_role", None)
|
||||
if not callable(lister):
|
||||
return []
|
||||
try:
|
||||
rows = lister(role_code="land") or []
|
||||
except Exception:
|
||||
logger.exception("转人工拉全部陆运产品失败")
|
||||
return []
|
||||
return [dict(x) for x in rows if isinstance(x, dict)]
|
||||
origin = str(facts.get("起运港") or facts.get("起运地") or "").strip()
|
||||
dest = str(facts.get("目的港") or facts.get("目的地") or "").strip()
|
||||
if origin or dest:
|
||||
return self._match_sea_staff(sess)
|
||||
lister = getattr(self._ledger, "list_staff_by_role", None)
|
||||
if not callable(lister):
|
||||
return []
|
||||
try:
|
||||
rows = lister(role_code="sea") or []
|
||||
except Exception:
|
||||
logger.exception("转人工拉全部海运产品失败")
|
||||
return []
|
||||
return [dict(x) for x in rows if isinstance(x, dict)]
|
||||
|
||||
def _commit_handoff_pull(
|
||||
self,
|
||||
sess: FlowSession,
|
||||
reply: ReplyFn,
|
||||
card_meta: Optional[dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
点转人工「拉产品进群」:建群、发转人工摘要、正式单改「转人工」。
|
||||
虚拟单不写主账。不发 TMS、不发模板、不跟成交。
|
||||
"""
|
||||
from agent.policy.handoff_human import (
|
||||
HANDOFF_STATUS,
|
||||
ensure_virtual_no,
|
||||
is_virtual_no,
|
||||
)
|
||||
|
||||
if sess.collab_ended or (sess.collab_chat_id or "").strip():
|
||||
reply(copy.HANDOFF_ALREADY_COLLAB)
|
||||
return "handoff_already"
|
||||
display_no = ensure_virtual_no(sess) if not (sess.work_order_no or "").strip() else sess.work_order_no
|
||||
staff = self._match_handoff_staff(sess)
|
||||
if not staff:
|
||||
reply(self._group_no_staff_copy())
|
||||
return sess.phase or "handoff_offer"
|
||||
userids = [sess.sender_id]
|
||||
product_names: list[str] = []
|
||||
product_ids: list[str] = []
|
||||
for row in staff:
|
||||
uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
|
||||
name = str(row.get("name") or "").strip() or uid
|
||||
if not uid:
|
||||
continue
|
||||
if uid not in userids:
|
||||
userids.append(uid)
|
||||
if uid not in product_ids:
|
||||
product_ids.append(uid)
|
||||
product_names.append(name)
|
||||
if not product_ids:
|
||||
reply(self._group_no_staff_copy())
|
||||
return sess.phase or "handoff_offer"
|
||||
for uid in self._archive_seat_userids():
|
||||
if uid not in userids:
|
||||
userids.append(uid)
|
||||
client = self.group_client()
|
||||
created = client.create_group(name=display_no, userids=userids) or {}
|
||||
if not created.get("ok") or not created.get("chat_id"):
|
||||
reply(copy.sea_group_create_fail(reason=str(created.get("error") or "")))
|
||||
return sess.phase or "handoff_offer"
|
||||
chat_id = str(created.get("chat_id") or "").strip()
|
||||
brief = copy.handoff_group_brief(
|
||||
work_order_no=display_no,
|
||||
facts=sess.facts,
|
||||
business_line=sess.business_line,
|
||||
product_names=product_names,
|
||||
)
|
||||
client.send_group(chat_id=chat_id, content=brief)
|
||||
real = (sess.work_order_no or "").strip()
|
||||
if real and not is_virtual_no(real):
|
||||
trans = getattr(self._ledger, "transition", None)
|
||||
if callable(trans):
|
||||
trans(
|
||||
work_order_no=real,
|
||||
to_status=HANDOFF_STATUS,
|
||||
remark=copy.HANDOFF_CLARIFY_REASON
|
||||
if sess.handoff_offer == "clarify"
|
||||
else copy.HANDOFF_INTENT_REASON,
|
||||
)
|
||||
binder = getattr(self._ledger, "bind_collab_group", None)
|
||||
if callable(binder):
|
||||
binder(
|
||||
work_order_no=real,
|
||||
chat_id=chat_id,
|
||||
member_ids=userids,
|
||||
product_names=product_names,
|
||||
product_ids=product_ids,
|
||||
)
|
||||
sess.status = HANDOFF_STATUS
|
||||
sess.collab_chat_id = chat_id
|
||||
sess.handoff_committed = True
|
||||
sess.handoff_offer = ""
|
||||
sess.phase = "handoff_done"
|
||||
sess.allowed = ()
|
||||
sess.invited_product_names = tuple(product_names)
|
||||
self._save(sess)
|
||||
self._grey_clicked_card(reply, card_meta)
|
||||
sales_name = self._sales_display_name(sess.sender_id)
|
||||
reply(
|
||||
copy.sea_group_created(
|
||||
work_order_no=display_no,
|
||||
sales_name=sales_name,
|
||||
product_names=product_names,
|
||||
)
|
||||
)
|
||||
return "handoff_done"
|
||||
|
||||
def _match_sea_staff(self, sess: FlowSession) -> list[dict[str, Any]]:
|
||||
"""主账按起运/目的匹配启用的海运产品岗。"""
|
||||
matcher = getattr(self._ledger, "match_sea_staff", None)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -67,6 +67,12 @@ 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"
|
||||
if intent == "handoff_human" or action_type == "HANDOFF_HUMAN":
|
||||
data["intent"] = "handoff_human"
|
||||
action["action_type"] = "HANDOFF_HUMAN"
|
||||
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 +104,17 @@ _SYSTEM = (
|
||||
"原话在描述新的运输需求(港口/机场、货物、件数重量体积、空运海运陆运等),"
|
||||
"即使偶尔拿旧单号当参考,也走新询价,work_order_no 留空。"
|
||||
"陆运核对卡之后回复确定、确认、查价、可以查了,仍是新询价,禁止标闲聊。"
|
||||
"机器人正在补问缺项时,销售只回件数、品名、重量、体积等短答(如 111、3件),仍是新询价续填,禁止标闲聊。"
|
||||
"【调价】intent=quote_adjust,action_type=QUOTE_ADJUST。"
|
||||
"inbound_facts 里 session_quoted=true 时,销售用自然语言改当前已有报价:"
|
||||
"涨、跌、加、减、上升、上浮、往上抬、改到某金额、在现有报价基础上调整,口令不写死。"
|
||||
"只说报价/总价、没点名费用,仍是调价,不是闲聊也不是新询价。"
|
||||
"【转人工】intent=handoff_human,action_type=HANDOFF_HUMAN。"
|
||||
"销售明确要求人来报价或接管:转人工、人工报价、找产品/航线、别自动查了、不要机器人了。"
|
||||
"口令不写死。抱怨慢、骂两句、只问进度不算,那些仍走新询价或闲聊。"
|
||||
"【闲聊】intent=ordinary_text_other,action_type=ORDINARY_TEXT_OTHER。"
|
||||
"问候、谢谢、无关闲聊。点卡片按钮字(生成Excel/生成PDF/不采用等)也标闲聊。"
|
||||
"禁止因为销售没说指定口令就把续办判成新询价。"
|
||||
"禁止因为销售没说指定口令就把续办或调价判成新询价或闲聊。"
|
||||
)
|
||||
|
||||
|
||||
@@ -134,22 +148,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 +233,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 +258,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,
|
||||
|
||||
@@ -16,7 +16,12 @@ from agent.handlers.echo_text import handle_echo_text
|
||||
from agent.handlers.group_collab import handle_group_collab
|
||||
from agent.handlers.image_inquiry import handle_image_inquiry
|
||||
from agent.handlers.attachment_inquiry import handle_attachment_inquiry
|
||||
from agent.handlers.text_inquiry import handle_text_inquiry, handle_text_other, is_land_confirm_action
|
||||
from agent.handlers.text_inquiry import (
|
||||
handle_text_inquiry,
|
||||
handle_text_other,
|
||||
is_land_confirm_action,
|
||||
should_resume_other_as_inquiry,
|
||||
)
|
||||
from agent.routing.decision import RouteDecision
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -123,6 +128,8 @@ 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,
|
||||
"handoff_human": _wrap_text_inquiry,
|
||||
"attachment_inquiry": _wrap_attachment,
|
||||
"image_inquiry": _wrap_image,
|
||||
"multi_segment_transport": _wrap_new,
|
||||
@@ -262,6 +269,18 @@ 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 == "ordinary_text_other" and should_resume_other_as_inquiry(message):
|
||||
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 == "handoff_human":
|
||||
logger.info("dispatch 转人工意图 sender=%s", message.sender_id)
|
||||
handle_text_inquiry(message, prefer_handoff=True)
|
||||
return "handoff_human"
|
||||
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)
|
||||
@@ -226,7 +231,8 @@ def _classify_with_model(text: str, settings) -> str:
|
||||
client = LlmHttpClient.from_settings(settings)
|
||||
system = (
|
||||
"你是海运协同群意图分类。只回复一个英文词,不要解释。"
|
||||
"collab_fields=在补贸易条款/货好时间/商品海关编码/含油含电含磁;"
|
||||
"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,8 @@ 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",
|
||||
"HANDOFF_HUMAN": "handoff_human",
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +104,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,
|
||||
|
||||
@@ -32,7 +32,7 @@ AIR_REQUIRED_FIELDS = (
|
||||
)
|
||||
AIR_DISPLAY = {key: label for key, label in AIR_REQUIRED_FIELDS}
|
||||
|
||||
# 海运询价必填 7 项:销售侧名称。内部键「货量」对外叫「货物数量」。
|
||||
# 海运询价必填 10 项(销售侧含运输方式):内部键「货量」对外叫「货物数量」。
|
||||
SEA_REQUIRED_FIELDS = (
|
||||
("起运港", "起运港"),
|
||||
("目的港", "目的港"),
|
||||
@@ -40,6 +40,8 @@ SEA_REQUIRED_FIELDS = (
|
||||
("货量", "货物数量"),
|
||||
("整柜或拼柜", "整柜或拼柜"),
|
||||
("箱型箱量", "箱型箱量"),
|
||||
("贸易条款", "贸易条款"),
|
||||
("运输分类", "运输分类"),
|
||||
("报价日期", "报价日期"),
|
||||
)
|
||||
SEA_DISPLAY = {key: label for key, label in SEA_REQUIRED_FIELDS}
|
||||
@@ -57,8 +59,8 @@ LAND_DISPLAY = {
|
||||
}
|
||||
|
||||
# 协同补充字段:拉群后可静默写入,不挡首次查价。内部键仍是 HS编码。
|
||||
SEA_COLLAB_FIELDS = ("贸易条款", "货好时间", "HS编码", "是否含油", "是否含电", "是否含磁")
|
||||
# 空运群只补含电/含磁,摘要不要把海运六项搬过去。
|
||||
SEA_COLLAB_FIELDS = ("货好时间", "HS编码", "是否含油", "是否含电", "是否含磁")
|
||||
# 空运群只补含电/含磁,摘要不要把海运协同五项搬过去。
|
||||
AIR_COLLAB_FIELDS = ("是否含电", "是否含磁")
|
||||
# 群摘要对外展示名。内部继续用 HS编码,避免改合同键。
|
||||
SEA_COLLAB_DISPLAY = {
|
||||
@@ -141,11 +143,11 @@ _LAND_COLLAB_BY_PAIR: dict[tuple[str, str], tuple[str, ...]] = {
|
||||
"是否为危险品",
|
||||
),
|
||||
}
|
||||
# 意图分类用:海运六项 ∪ 陆运全部协同键。
|
||||
# 意图分类用:海运协同五项 ∪ 贸易条款(陆运跨境仍要)∪ 陆运协同键。
|
||||
ALL_COLLAB_KEYS = tuple(
|
||||
dict.fromkeys(
|
||||
list(SEA_COLLAB_FIELDS)
|
||||
+ ["客户名称", "包装方式", "货值", "是否为危险品", "车型/数量"]
|
||||
+ ["贸易条款", "客户名称", "包装方式", "货值", "是否为危险品", "车型/数量"]
|
||||
)
|
||||
)
|
||||
|
||||
@@ -317,7 +319,7 @@ def land_collab_fields(land_type: str, route: str) -> tuple[str, ...]:
|
||||
"""
|
||||
本单陆运协同内部键,顺序与规格一致。
|
||||
|
||||
对不上填写样例返回空元组,调用方不得按海运六项去列。
|
||||
对不上填写样例返回空元组,调用方不得按海运协同五项去列。
|
||||
海关编码内部仍是 HS编码。副作用:无。
|
||||
"""
|
||||
from agent.schema.land_options import canonicalize_route, canonicalize_transport_type
|
||||
@@ -485,6 +487,13 @@ def validate_required_fields(
|
||||
line = (business_line or "").strip().upper()
|
||||
if line == "SEA":
|
||||
normalized = project_sea_cargo_qty(normalized)
|
||||
from agent.schema.sea_options import canonicalize_sea_class
|
||||
|
||||
klass = canonicalize_sea_class(str(normalized.get("运输分类") or ""))
|
||||
if klass:
|
||||
normalized["运输分类"] = klass
|
||||
else:
|
||||
normalized.pop("运输分类", None)
|
||||
if line not in {"SEA", "AIR", "LAND"}:
|
||||
return {
|
||||
"ok": False,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
海运运输分类:只认 Port to Port、其他。
|
||||
|
||||
本文件职责:从原话收成标准词、生成补问清单。
|
||||
禁止:发明第三项;把分类带去 TMS;改六态。
|
||||
线程:纯函数,可在 Worker 槽调用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
SEA_CLASSES = ("Port to Port", "其他")
|
||||
|
||||
# 长词优先,避免 P2P 误伤其它词;门到门不在名单里。
|
||||
_ALIASES = (
|
||||
("port to port", "Port to Port"),
|
||||
("港到港", "Port to Port"),
|
||||
("p2p", "Port to Port"),
|
||||
("其它", "其他"),
|
||||
("其他", "其他"),
|
||||
)
|
||||
|
||||
|
||||
def canonicalize_sea_class(raw: str) -> str:
|
||||
"""
|
||||
把销售原话收成 Port to Port 或 其他;对不上返回空串。
|
||||
|
||||
「门到门」不算。先匹配更长别名。副作用:无。
|
||||
"""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
lowered = text.lower()
|
||||
for needle, canon in _ALIASES:
|
||||
if needle in lowered or needle in text:
|
||||
return canon
|
||||
stripped = text.replace("运输分类", "").replace(":", "").replace(":", "").strip()
|
||||
if stripped in SEA_CLASSES:
|
||||
return stripped
|
||||
return ""
|
||||
|
||||
|
||||
def harvest_sea_class(text: str, current: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""
|
||||
当前句能认则覆盖;认不出则保留已有标准值。
|
||||
|
||||
参数:本轮原文、已有 facts。返回合并后的 facts 副本。
|
||||
非法旧值会丢掉,避免「门到门」一直占着必填位。
|
||||
"""
|
||||
merged = dict(current or {})
|
||||
found = canonicalize_sea_class(text or "")
|
||||
if found:
|
||||
merged["运输分类"] = found
|
||||
return merged
|
||||
kept = canonicalize_sea_class(str(merged.get("运输分类") or ""))
|
||||
if kept:
|
||||
merged["运输分类"] = kept
|
||||
else:
|
||||
merged.pop("运输分类", None)
|
||||
return merged
|
||||
|
||||
|
||||
# 运输分类用语,不能冒充贸易条款。销售原话写了「贸易条款」才保留。
|
||||
_DOOR_TRADE_TERMS = frozenset(
|
||||
{
|
||||
"门到门",
|
||||
"door to door",
|
||||
"door-to-door",
|
||||
"doortodoor",
|
||||
"d2d",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def drop_invented_door_trade_terms(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""
|
||||
丢掉冒充贸易条款的「门到门」。
|
||||
|
||||
测服 WO202609200013:原话没写贸易条款,确认卡却出现「贸易条款:门到门」。
|
||||
门到门只是运输分类说法,不是 FOB/CIF 这类条款;原话没有「贸易条款」四字时不准收下。
|
||||
销售明确写了「贸易条款:门到门」则保留原文,不改六态。
|
||||
"""
|
||||
merged = dict(facts or {})
|
||||
trade = str(merged.get("贸易条款") or "").strip()
|
||||
if not trade:
|
||||
return merged
|
||||
if "贸易条款" in (text or ""):
|
||||
return merged
|
||||
if trade.lower().replace(" ", "") in {x.replace(" ", "") for x in _DOOR_TRADE_TERMS} or trade in _DOOR_TRADE_TERMS:
|
||||
merged.pop("贸易条款", None)
|
||||
return merged
|
||||
|
||||
|
||||
def sea_class_prompt() -> str:
|
||||
"""缺运输分类时只列两项,不再追问「其他是什么」。"""
|
||||
return "运输分类(必填):请回复 Port to Port 或 其他。"
|
||||
@@ -1,8 +1,9 @@
|
||||
"""
|
||||
海运查价参数:对照 TMS V2 /quote/v2/query 是否带全。
|
||||
|
||||
本文件职责:询价卡片出现前,确认海运七项能拼出查价正文。
|
||||
本文件职责:询价卡片出现前,确认海运十项里查出站项能拼出查价正文。
|
||||
港口只带名称,不做港口字典对应;箱型箱量保持原文字段,containerItems 只透传模型投影。
|
||||
贸易条款必带出站;运输分类只留在工单 facts,不进 tms_facts。
|
||||
禁止:本地拆箱型;因对不上港口字典跳过 TMS;改六态。
|
||||
"""
|
||||
|
||||
@@ -62,6 +63,8 @@ def assemble_sea_query(facts: dict[str, Any] | None) -> dict[str, Any]:
|
||||
load = _load_type(str(src.get("整柜或拼柜") or ""))
|
||||
box = str(src.get("箱型箱量") or "").strip()
|
||||
quote_date = str(src.get("报价日期") or "").strip()
|
||||
trade = str(src.get("贸易条款") or src.get("trade_terms") or src.get("tradeTerms") or "").strip()
|
||||
klass = str(src.get("运输分类") or "").strip()
|
||||
|
||||
missing: list[str] = []
|
||||
missing_display: list[str] = []
|
||||
@@ -72,6 +75,7 @@ def assemble_sea_query(facts: dict[str, Any] | None) -> dict[str, Any]:
|
||||
("货量", "货物数量", qty),
|
||||
("整柜或拼柜", "整柜或拼柜", load),
|
||||
("箱型箱量", "箱型箱量", box),
|
||||
("贸易条款", "贸易条款", trade),
|
||||
("报价日期", "报价日期", quote_date),
|
||||
)
|
||||
for key, label, val in pairs:
|
||||
@@ -93,12 +97,18 @@ def assemble_sea_query(facts: dict[str, Any] | None) -> dict[str, Any]:
|
||||
out_facts["整柜或拼柜"] = load
|
||||
if box:
|
||||
out_facts["箱型箱量"] = box
|
||||
if trade:
|
||||
out_facts["贸易条款"] = trade
|
||||
if klass:
|
||||
out_facts["运输分类"] = klass
|
||||
if quote_date:
|
||||
out_facts["报价日期"] = quote_date
|
||||
items = parse_container_items(src)
|
||||
if items:
|
||||
out_facts["containerItems"] = items
|
||||
|
||||
tms_facts = dict(out_facts)
|
||||
tms_facts.pop("运输分类", None)
|
||||
logger.info(
|
||||
"tms.sea.assemble ok=%s origin=%s dest=%s load=%s missing=%s",
|
||||
ok,
|
||||
@@ -112,4 +122,5 @@ def assemble_sea_query(facts: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"missing": missing,
|
||||
"missing_display": missing_display,
|
||||
"facts": out_facts,
|
||||
"tms_facts": tms_facts,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -47,6 +47,8 @@ SEA_ORAL = """海运
|
||||
货量:20吨
|
||||
整柜或拼柜:整柜
|
||||
箱型箱量:1x40HQ
|
||||
贸易条款:FOB
|
||||
运输分类:Port to Port
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
"""
|
||||
转人工:缺项连问、虚拟号、摘要骨架。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
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 handoff_human as hh
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.air_text_flow import FlowSession
|
||||
|
||||
|
||||
class _Sess:
|
||||
def __init__(self) -> None:
|
||||
self.clarify_missing_sig = ""
|
||||
self.clarify_retry = 0
|
||||
self.handoff_offer = ""
|
||||
self.work_order_no = ""
|
||||
self.virtual_work_order_no = ""
|
||||
|
||||
|
||||
class HandoffCoreTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
hh.reset_virtual_seq_for_test()
|
||||
|
||||
def test_same_missing_set_offers_after_three_asks(self) -> None:
|
||||
sess = _Sess()
|
||||
keys = ["起运港", "目的港"]
|
||||
self.assertEqual(hh.note_clarify_round(sess, keys), "ask")
|
||||
self.assertEqual(sess.clarify_retry, 0)
|
||||
self.assertEqual(hh.note_clarify_round(sess, keys), "ask")
|
||||
self.assertEqual(sess.clarify_retry, 1)
|
||||
self.assertEqual(hh.note_clarify_round(sess, keys), "ask")
|
||||
self.assertEqual(sess.clarify_retry, 2)
|
||||
self.assertEqual(hh.note_clarify_round(sess, keys), "handoff")
|
||||
self.assertEqual(sess.clarify_retry, 3)
|
||||
|
||||
def test_changed_missing_resets_and_voids_clarify_offer(self) -> None:
|
||||
sess = _Sess()
|
||||
hh.note_clarify_round(sess, ["起运港"])
|
||||
hh.note_clarify_round(sess, ["起运港"])
|
||||
sess.handoff_offer = "clarify"
|
||||
self.assertEqual(hh.note_clarify_round(sess, ["目的港"]), "ask")
|
||||
self.assertEqual(sess.clarify_retry, 0)
|
||||
self.assertEqual(sess.handoff_offer, "")
|
||||
|
||||
def test_virtual_no_prefix_not_wo(self) -> None:
|
||||
no = hh.next_virtual_no(now=datetime(2026, 9, 20, tzinfo=ZoneInfo("Asia/Shanghai")))
|
||||
self.assertTrue(no.startswith("VT20260920"))
|
||||
self.assertEqual(len(no), 14)
|
||||
self.assertFalse(no.startswith("WO"))
|
||||
self.assertTrue(hh.is_virtual_no(no))
|
||||
self.assertFalse(hh.is_virtual_no("WO202609200001"))
|
||||
|
||||
def test_handoff_brief_dashes_collab_and_glues_ticket(self) -> None:
|
||||
text = copy.handoff_group_brief(
|
||||
work_order_no="WO202609190018",
|
||||
facts={
|
||||
"运输类型": "跨境集拼",
|
||||
"线路类别": "中港/中亚/中欧",
|
||||
"运输分类": "普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)",
|
||||
"起运地": "深圳市",
|
||||
"目的地": "南京市",
|
||||
"毛重": "1KG",
|
||||
"报价日期": "2026-09-19",
|
||||
},
|
||||
business_line="LAND",
|
||||
product_names=["林绵勋", "罗小华", "吴季林"],
|
||||
)
|
||||
self.assertIn("@林绵勋 @罗小华 @吴季林", text)
|
||||
self.assertIn("WO202609190018转人工", text)
|
||||
self.assertNotIn("工单号:", text.split("转人工")[0][-6:])
|
||||
self.assertIn("货物品名:-", text)
|
||||
self.assertIn("客户名称:-", text)
|
||||
self.assertNotIn("TMS报价", text)
|
||||
|
||||
def test_already_collab_copy(self) -> None:
|
||||
self.assertEqual(copy.HANDOFF_ALREADY_COLLAB, "该工单已拉产品进群协同。")
|
||||
|
||||
def test_air_hint_contains_virtual(self) -> None:
|
||||
text = copy.handoff_air_hint(work_order_no="VT202609200001")
|
||||
self.assertEqual(
|
||||
text,
|
||||
"触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号 VT202609200001。",
|
||||
)
|
||||
self.assertNotIn("拉产品进群", text)
|
||||
|
||||
def test_clarify_progress_and_stale_wo(self) -> None:
|
||||
self.assertTrue(copy.clarify_progress_line(1).startswith("参数提取不全(1/3)"))
|
||||
self.assertEqual(copy.with_clarify_progress("请确认并补充以下信息:", 0), "请确认并补充以下信息:")
|
||||
wrapped = copy.with_clarify_progress("请确认并补充以下信息:", 2)
|
||||
self.assertTrue(wrapped.startswith("参数提取不全(2/3)"))
|
||||
self.assertIn("请确认并补充以下信息", wrapped)
|
||||
sess = _Sess()
|
||||
sess.work_order_no = "WO202609180018"
|
||||
sess.phase = "clarify"
|
||||
no = hh.ticket_no_for_handoff_offer(sess)
|
||||
self.assertTrue(str(no).startswith("VT"))
|
||||
self.assertEqual(sess.work_order_no, "")
|
||||
kept = _Sess()
|
||||
kept.work_order_no = "WO202609180018"
|
||||
kept.phase = "wait_collab"
|
||||
self.assertEqual(hh.ticket_no_for_handoff_offer(kept), "WO202609180018")
|
||||
|
||||
|
||||
class HandoffFlowOfferTests(unittest.TestCase):
|
||||
"""海运:首次补问不计数,之后连问三次仍不齐出卡;点卡前仍可继续补字段。"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
hh.reset_virtual_seq_for_test()
|
||||
from agent.policy.sea_text_flow import reset_sea_text_flow_for_test
|
||||
|
||||
self.flow = reset_sea_text_flow_for_test()
|
||||
self.replies: list[str] = []
|
||||
|
||||
def reply(text: str, extra=None):
|
||||
self.replies.append(text)
|
||||
return "out"
|
||||
|
||||
self.reply = reply
|
||||
|
||||
def _partial(self, text: str) -> str:
|
||||
return self.flow.on_text(sender_id="s1", text=text, reply=self.reply)
|
||||
|
||||
def test_three_same_clarify_then_card(self) -> None:
|
||||
self._partial("海运询价")
|
||||
self._partial("还是那些")
|
||||
self._partial("不知道")
|
||||
phase = self._partial("还没有")
|
||||
self.assertEqual(phase, "handoff_offer")
|
||||
joined = "\n".join(self.replies)
|
||||
self.assertIn("参数提取不全(3/3)", joined)
|
||||
self.assertIn(copy.BTN_PULL_COLLAB, joined)
|
||||
idx3 = next(i for i, t in enumerate(self.replies) if "参数提取不全(3/3)" in t)
|
||||
self.assertLess(idx3 + 1, len(self.replies))
|
||||
self.assertIn(copy.BTN_PULL_COLLAB, self.replies[idx3 + 1])
|
||||
sess = self.flow.session_of("s1")
|
||||
self.assertEqual(sess.handoff_offer, "clarify")
|
||||
self.assertIn(copy.BTN_PULL_COLLAB, sess.allowed)
|
||||
|
||||
def test_click_pull_sends_handoff_brief_without_tms(self) -> None:
|
||||
class FakeGroup:
|
||||
def __init__(self) -> None:
|
||||
self.created: list[dict] = []
|
||||
self.sent: list[dict] = []
|
||||
|
||||
def create_group(self, *, name: str, userids: list[str]) -> dict:
|
||||
self.created.append({"name": name, "userids": list(userids)})
|
||||
return {"ok": True, "chat_id": "wr_h1", "error": ""}
|
||||
|
||||
def send_group(self, *, chat_id: str, content: str, mention_userids=None) -> dict:
|
||||
self.sent.append({"chat_id": chat_id, "content": content})
|
||||
return {"ok": True}
|
||||
|
||||
def send_group_file(self, **kwargs) -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
groups = FakeGroup()
|
||||
self.flow._group_client = groups
|
||||
self.flow.ledger.set_sea_staff(
|
||||
[
|
||||
{
|
||||
"name": "林绵勋",
|
||||
"wecomId": "prod1",
|
||||
"roleCode": "sea",
|
||||
"routes": "海运·全部线路",
|
||||
"status": "active",
|
||||
}
|
||||
]
|
||||
)
|
||||
for text in ("海运询价", "还是那些", "不知道", "还没有"):
|
||||
self._partial(text)
|
||||
phase = self.flow.on_button(
|
||||
sender_id="s1",
|
||||
action=copy.BTN_PULL_COLLAB,
|
||||
reply=self.reply,
|
||||
)
|
||||
self.assertEqual(phase, "handoff_done")
|
||||
self.assertTrue(groups.created)
|
||||
self.assertTrue(groups.created[0]["name"].startswith("VT"))
|
||||
brief = groups.sent[0]["content"]
|
||||
self.assertIn("转人工", brief)
|
||||
self.assertNotIn("TMS报价", brief)
|
||||
sess = self.flow.session_of("s1")
|
||||
self.assertTrue(sess.handoff_committed)
|
||||
self.assertTrue(sess.work_order_no == "" or sess.status == "转人工")
|
||||
|
||||
def test_new_inquiry_after_commit_starts_fresh(self) -> None:
|
||||
class FakeGroup:
|
||||
def create_group(self, *, name: str, userids: list[str]) -> dict:
|
||||
return {"ok": True, "chat_id": "wr_h1", "error": ""}
|
||||
|
||||
def send_group(self, *, chat_id: str, content: str, mention_userids=None) -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
def send_group_file(self, **kwargs) -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
self.flow._group_client = FakeGroup()
|
||||
self.flow.ledger.set_sea_staff(
|
||||
[
|
||||
{
|
||||
"name": "林绵勋",
|
||||
"wecomId": "prod1",
|
||||
"roleCode": "sea",
|
||||
"routes": "海运·全部线路",
|
||||
"status": "active",
|
||||
}
|
||||
]
|
||||
)
|
||||
for text in ("海运询价", "还是那些", "不知道", "还没有"):
|
||||
self._partial(text)
|
||||
self.flow.on_button(
|
||||
sender_id="s1",
|
||||
action=copy.BTN_PULL_COLLAB,
|
||||
reply=self.reply,
|
||||
)
|
||||
self.replies.clear()
|
||||
phase = self._partial("海运询价")
|
||||
self.assertNotEqual(phase, "handoff_done")
|
||||
joined = "\n".join(self.replies)
|
||||
self.assertNotIn("已转人工,本单不再自动跟进", joined)
|
||||
self.assertIn("请确认并补充以下信息", joined)
|
||||
sess = self.flow.session_of("s1")
|
||||
self.assertFalse(sess.handoff_committed)
|
||||
self.assertEqual(sess.phase, "clarify")
|
||||
|
||||
def test_already_has_group_only_prompts(self) -> None:
|
||||
self.flow._save(
|
||||
FlowSession(
|
||||
sender_id="s1",
|
||||
thread_id="s1",
|
||||
phase="collab_group",
|
||||
business_line="SEA",
|
||||
work_order_no="WO202609200099",
|
||||
collab_chat_id="wr_exist",
|
||||
status="已报价",
|
||||
)
|
||||
)
|
||||
phase = self.flow.offer_handoff(sender_id="s1", text="转人工", reply=self.reply)
|
||||
self.assertEqual(phase, "handoff_already")
|
||||
self.assertEqual(self.replies[-1], copy.HANDOFF_ALREADY_COLLAB)
|
||||
self.assertFalse(self.flow.session_of("s1").handoff_committed)
|
||||
|
||||
def test_route_contract_accepts_handoff_human(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("HANDOFF_HUMAN"), "handoff_human")
|
||||
parsed = parse_ordered_actions_arguments(
|
||||
{
|
||||
"decision_id": "d-h",
|
||||
"intent": "handoff_human",
|
||||
"ordered_action": {
|
||||
"action_type": "HANDOFF_HUMAN",
|
||||
"order": 1,
|
||||
"target": {"work_order_no": ""},
|
||||
"evidence": {"source_ref": "inbound:u1", "content_digest": "a" * 64},
|
||||
},
|
||||
}
|
||||
)
|
||||
self.assertEqual(parsed.to_route_decision().intent, "handoff_human")
|
||||
router = DeepSeekARouter.from_settings()
|
||||
sys_msg, _user = router.build_messages(text="帮我转人工", sender_id="u1")
|
||||
self.assertIn("handoff_human", sys_msg["content"])
|
||||
self.assertIn("口令不写死", sys_msg["content"])
|
||||
self.assertIn("抱怨慢", sys_msg["content"])
|
||||
|
||||
def test_first_clarify_does_not_count(self) -> None:
|
||||
self._partial("海运询价")
|
||||
self.assertTrue(self.replies)
|
||||
self.assertNotIn("参数提取不全(1/3)", self.replies[0])
|
||||
self.assertIn("请确认并补充以下信息", self.replies[0])
|
||||
self._partial("还是那些")
|
||||
self.assertTrue(self.replies[-1].startswith("参数提取不全(1/3)"))
|
||||
|
||||
def test_air_handoff_before_create_uses_virtual_not_old_wo(self) -> None:
|
||||
from agent.policy.air_text_flow import reset_air_text_flow_for_test
|
||||
|
||||
flow = reset_air_text_flow_for_test()
|
||||
replies: list[str] = []
|
||||
sess = FlowSession(
|
||||
sender_id="air1",
|
||||
thread_id="air1",
|
||||
phase="clarify",
|
||||
business_line="AIR",
|
||||
work_order_no="WO202609180018",
|
||||
facts={"起运港": "ZUH", "目的港": "CRK", "品名": "衣服"},
|
||||
)
|
||||
flow._save(sess)
|
||||
phase = flow._offer_handoff_now(
|
||||
sender_id="air1",
|
||||
reason="clarify",
|
||||
reply=lambda t, extra=None: replies.append(t) or "out",
|
||||
sess=sess,
|
||||
)
|
||||
self.assertEqual(phase, "handoff_offer")
|
||||
self.assertTrue(replies)
|
||||
self.assertIn("VT", replies[0])
|
||||
self.assertNotIn("WO202609180018", replies[0])
|
||||
self.assertTrue((sess.virtual_work_order_no or "").startswith("VT"))
|
||||
self.assertEqual(sess.work_order_no, "")
|
||||
|
||||
def test_third_clarify_sends_card_even_if_routed_as_adjust(self) -> None:
|
||||
from agent.channel.queue import get_message_store
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
from agent.handlers.text_inquiry import handle_text_inquiry
|
||||
from agent.routing.decision import RouteDecision
|
||||
from agent.routing.dispatch import dispatch_inbound
|
||||
|
||||
store = get_message_store()
|
||||
handle_text_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="h1", content="海运询价"),
|
||||
store=store,
|
||||
flow=self.flow,
|
||||
)
|
||||
handle_text_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="h2", content="还是那些"),
|
||||
store=store,
|
||||
flow=self.flow,
|
||||
)
|
||||
handle_text_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="h3", content="不知道"),
|
||||
store=store,
|
||||
flow=self.flow,
|
||||
)
|
||||
name = dispatch_inbound(
|
||||
InboundMessage(sender_id="s1", message_id="h4", content="1"),
|
||||
RouteDecision(intent="quote_adjust"),
|
||||
)
|
||||
self.assertEqual(name, "quote_adjust")
|
||||
sess = self.flow.session_of("s1")
|
||||
self.assertEqual(sess.phase, "handoff_offer")
|
||||
bodies = "\n".join(i.content for i in store._outbox.values())
|
||||
self.assertIn("参数提取不全(3/3)", bodies)
|
||||
self.assertIn(copy.BTN_PULL_COLLAB, bodies)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -45,6 +45,8 @@ SEA_ORAL = """海运
|
||||
货量:20吨
|
||||
整柜或拼柜:整柜
|
||||
箱型箱量:1x40HQ
|
||||
贸易条款:FOB
|
||||
运输分类:Port to Port
|
||||
"""
|
||||
|
||||
LAND_ORAL = """陆运
|
||||
|
||||
@@ -25,7 +25,7 @@ from agent.channel.wecom.models import InboundMessage
|
||||
from agent.handlers.text_inquiry import handle_text_inquiry
|
||||
from agent.policy.air_text_flow import reset_air_text_flow_for_test
|
||||
from agent.policy.land_text_flow import get_land_text_flow, reset_land_text_flow_for_test
|
||||
from agent.policy.sea_text_flow import reset_sea_text_flow_for_test
|
||||
from agent.policy.sea_text_flow import get_sea_text_flow, reset_sea_text_flow_for_test
|
||||
from agent.routing.decision import RouteDecision
|
||||
from agent.routing.dispatch import dispatch_inbound
|
||||
from tests.test_sea_text_inquiry import COMPLETE_SEA
|
||||
@@ -254,6 +254,153 @@ 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)
|
||||
|
||||
def test_sea_field_supplement_not_stolen_by_stale_land(self) -> None:
|
||||
"""
|
||||
旧陆运选项草稿不能抢走海运补问。
|
||||
|
||||
测服:先陆运缺选项,再发海运缺贸易条款/运输分类,下一句只补这两项,
|
||||
不能回陆运「参数提取不全」或填写样例图。
|
||||
"""
|
||||
store = get_message_store()
|
||||
handle_text_inquiry(
|
||||
InboundMessage(sender_id="mix-sea", message_id="m-land-old", content="陆运询价"),
|
||||
injected_facts={"起运港": "广州", "目的港": "深圳", "品名": "衣服"},
|
||||
injected_mode="LAND",
|
||||
store=store,
|
||||
)
|
||||
self.assertEqual(get_land_text_flow().session_of("mix-sea").phase, "need_land_options")
|
||||
incomplete = {
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
"品名": "普货",
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
}
|
||||
phase = handle_text_inquiry(
|
||||
InboundMessage(sender_id="mix-sea", message_id="m-sea-ask", content="海运询价"),
|
||||
injected_facts=incomplete,
|
||||
injected_mode="SEA",
|
||||
store=store,
|
||||
)
|
||||
self.assertEqual(phase, "clarify")
|
||||
self.assertEqual(get_sea_text_flow().session_of("mix-sea").phase, "clarify")
|
||||
self.assertIsNone(get_land_text_flow().session_of("mix-sea"))
|
||||
before = len(store._outbox)
|
||||
phase = handle_text_inquiry(
|
||||
InboundMessage(
|
||||
sender_id="mix-sea",
|
||||
message_id="m-sea-fill",
|
||||
content="贸易条款:FOB 运输分类:其他",
|
||||
),
|
||||
injected_facts={"贸易条款": "FOB", "运输分类": "其他"},
|
||||
store=store,
|
||||
)
|
||||
self.assertNotEqual(phase, "need_land_options")
|
||||
self.assertEqual(get_sea_text_flow().session_of("mix-sea").business_line, "SEA")
|
||||
added = "\n".join(i.content for i in list(store._outbox.values())[before:])
|
||||
self.assertNotIn("参数提取不全", added)
|
||||
self.assertNotIn("填写样例", added)
|
||||
self.assertNotIn("运输类型", added)
|
||||
|
||||
def test_clarify_short_reply_misrouted_as_other_stays_on_inquiry(self) -> None:
|
||||
"""补问阶段回「111」即使被标闲聊,也不能发开场白。"""
|
||||
store = get_message_store()
|
||||
facts = {
|
||||
"起运港": "广州",
|
||||
"目的港": "深圳",
|
||||
"运输类型": "国内运输拼车",
|
||||
"线路类别": "国内长途/零担",
|
||||
"运输分类": "急件",
|
||||
}
|
||||
phase = handle_text_inquiry(
|
||||
InboundMessage(sender_id="ld-111", message_id="m-111a", content="陆运广州到深圳"),
|
||||
injected_facts=facts,
|
||||
injected_mode="LAND",
|
||||
store=store,
|
||||
)
|
||||
self.assertEqual(phase, "clarify")
|
||||
name = dispatch_inbound(
|
||||
InboundMessage(sender_id="ld-111", message_id="m-111b", content="111"),
|
||||
RouteDecision(intent="ordinary_text_other"),
|
||||
)
|
||||
self.assertEqual(name, "ordinary_text_inquiry")
|
||||
sess = get_land_text_flow().session_of("ld-111")
|
||||
self.assertIsNotNone(sess)
|
||||
self.assertNotEqual(sess.phase, "")
|
||||
last = list(store._outbox.values())[-1].content
|
||||
self.assertNotIn("你好,我是询价机器人", last)
|
||||
self.assertNotIn("请直接描述客户需求", last)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
海运运输分类:只认 Port to Port、其他。
|
||||
"""
|
||||
|
||||
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.schema.sea_options import (
|
||||
canonicalize_sea_class,
|
||||
drop_invented_door_trade_terms,
|
||||
harvest_sea_class,
|
||||
sea_class_prompt,
|
||||
)
|
||||
|
||||
|
||||
class SeaOptionsTest(unittest.TestCase):
|
||||
def test_port_to_port_aliases(self):
|
||||
self.assertEqual(canonicalize_sea_class("Port to Port"), "Port to Port")
|
||||
self.assertEqual(canonicalize_sea_class("port to port"), "Port to Port")
|
||||
self.assertEqual(canonicalize_sea_class("P2P"), "Port to Port")
|
||||
self.assertEqual(canonicalize_sea_class("港到港"), "Port to Port")
|
||||
|
||||
def test_other_alias(self):
|
||||
self.assertEqual(canonicalize_sea_class("其他"), "其他")
|
||||
self.assertEqual(canonicalize_sea_class("其它"), "其他")
|
||||
|
||||
def test_door_to_door_rejected(self):
|
||||
self.assertEqual(canonicalize_sea_class("门到门"), "")
|
||||
|
||||
def test_harvest_keeps_current_when_text_has_none(self):
|
||||
got = harvest_sea_class("上海到洛杉矶整柜", {"运输分类": "Port to Port"})
|
||||
self.assertEqual(got["运输分类"], "Port to Port")
|
||||
|
||||
def test_harvest_overrides_from_text(self):
|
||||
got = harvest_sea_class("运输分类:其他", {"运输分类": "Port to Port"})
|
||||
self.assertEqual(got["运输分类"], "其他")
|
||||
|
||||
def test_door_to_door_not_kept_as_trade_terms(self):
|
||||
text = "海运、纽约港到巴生西港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件"
|
||||
got = drop_invented_door_trade_terms(text, {"贸易条款": "门到门", "品名": "机器设备"})
|
||||
self.assertNotIn("贸易条款", got)
|
||||
kept = drop_invented_door_trade_terms("贸易条款:门到门", {"贸易条款": "门到门"})
|
||||
self.assertEqual(kept["贸易条款"], "门到门")
|
||||
|
||||
def test_prompt_lists_two_options_only(self):
|
||||
text = sea_class_prompt()
|
||||
self.assertIn("Port to Port", text)
|
||||
self.assertIn("其他", text)
|
||||
self.assertNotIn("门到门", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -28,8 +28,9 @@ from agent.handlers.group_collab import handle_group_collab
|
||||
from agent.handlers.text_inquiry import handle_text_inquiry
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.air_text_flow import FlowSession
|
||||
from agent.policy.sea_text_flow import SeaTextInquiryFlow, reset_sea_text_flow_for_test
|
||||
from agent.schema.field_validate import validate_required_fields
|
||||
from agent.schema.field_validate import SEA_COLLAB_FIELDS, validate_required_fields
|
||||
from agent.schema.tms_sea_query import assemble_sea_query
|
||||
|
||||
|
||||
@@ -40,9 +41,20 @@ COMPLETE_SEA = {
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
"贸易条款": "FOB",
|
||||
"运输分类": "Port to Port",
|
||||
}
|
||||
|
||||
|
||||
class SeaCollabContractTests(unittest.TestCase):
|
||||
def test_collab_no_longer_asks_trade_terms(self):
|
||||
self.assertNotIn("贸易条款", SEA_COLLAB_FIELDS)
|
||||
self.assertEqual(
|
||||
SEA_COLLAB_FIELDS,
|
||||
("货好时间", "HS编码", "是否含油", "是否含电", "是否含磁"),
|
||||
)
|
||||
|
||||
|
||||
class FakeGroupClient:
|
||||
"""单测假企微建群:不打外网。"""
|
||||
|
||||
@@ -123,6 +135,8 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
"件数": "15件",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
"贸易条款": "FOB",
|
||||
"运输分类": "Port to Port",
|
||||
}
|
||||
check = validate_required_fields(facts=facts, business_line="SEA")
|
||||
self.assertTrue(check["ok"])
|
||||
@@ -136,6 +150,8 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
"品名": "茶叶",
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"贸易条款": "FOB",
|
||||
"运输分类": "Port to Port",
|
||||
}
|
||||
check = validate_required_fields(facts=facts, business_line="SEA")
|
||||
self.assertFalse(check["ok"])
|
||||
@@ -157,7 +173,164 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
self.assertNotIn("报价日期:(如", text)
|
||||
self.assertIn("报价日期:", text)
|
||||
|
||||
def test_complete_sea_inquiry_card_uses_seven_fields(self) -> None:
|
||||
def test_sea_requires_trade_terms_and_class(self) -> None:
|
||||
facts = {
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
"品名": "普货",
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
}
|
||||
check = validate_required_fields(facts=facts, business_line="SEA")
|
||||
self.assertFalse(check["ok"])
|
||||
self.assertIn("贸易条款", check["missing"])
|
||||
self.assertIn("运输分类", check["missing"])
|
||||
self.assertIn("贸易条款", check["missing_display"])
|
||||
self.assertIn("运输分类", check["missing_display"])
|
||||
|
||||
def test_sea_rejects_door_to_door_class(self) -> None:
|
||||
facts = {
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
"品名": "普货",
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
"贸易条款": "FOB",
|
||||
"运输分类": "门到门",
|
||||
}
|
||||
check = validate_required_fields(facts=facts, business_line="SEA")
|
||||
self.assertFalse(check["ok"])
|
||||
self.assertIn("运输分类", check["missing"])
|
||||
|
||||
def test_sea_canonicalizes_p2p(self) -> None:
|
||||
facts = {
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
"品名": "普货",
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
"贸易条款": "到岸",
|
||||
"运输分类": "p2p",
|
||||
}
|
||||
check = validate_required_fields(facts=facts, business_line="SEA")
|
||||
self.assertTrue(check["ok"])
|
||||
self.assertEqual(check["facts"]["运输分类"], "Port to Port")
|
||||
|
||||
def test_inquiry_card_lists_ten_fields(self) -> None:
|
||||
lines = copy.inquiry_field_lines(
|
||||
work_order_no="WO202609200001",
|
||||
facts={
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
"品名": "普货",
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
"贸易条款": "FOB",
|
||||
"运输分类": "Port to Port",
|
||||
"报价日期": "2026-09-20",
|
||||
},
|
||||
transport_mode="海运",
|
||||
)
|
||||
text = "\n".join(lines)
|
||||
self.assertIn("工单号:WO202609200001", text)
|
||||
self.assertIn("贸易条款:FOB", text)
|
||||
self.assertIn("运输分类:Port to Port", text)
|
||||
self.assertLess(text.index("箱型箱量"), text.index("贸易条款"))
|
||||
self.assertLess(text.index("贸易条款"), text.index("运输分类"))
|
||||
|
||||
def test_clarify_asks_class_as_two_options(self) -> None:
|
||||
text = copy.ask_clarify(
|
||||
facts={
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
"品名": "服装",
|
||||
"货量": "1柜",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
"贸易条款": "FOB",
|
||||
},
|
||||
missing_keys=["运输分类"],
|
||||
transport_mode="海运",
|
||||
)
|
||||
self.assertIn("Port to Port", text)
|
||||
self.assertIn("其他", text)
|
||||
self.assertIn("贸易条款:FOB", text)
|
||||
|
||||
def test_incomplete_without_trade_terms_does_not_create_ticket(self) -> None:
|
||||
facts = dict(COMPLETE_SEA)
|
||||
facts.pop("贸易条款")
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s-miss",
|
||||
text="海运缺条款",
|
||||
reply=self._reply,
|
||||
injected_facts=facts,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "clarify")
|
||||
self.assertIn("贸易条款", "\n".join(self.replies))
|
||||
self.assertFalse((self.flow.session_of("s-miss").work_order_no or "").startswith("WO"))
|
||||
|
||||
def test_leftover_door_to_door_not_written_as_trade_terms(self) -> None:
|
||||
"""旧草稿里的门到门不能写到确认卡贸易条款。测服 WO202609200013。"""
|
||||
leftover = {
|
||||
"起运港": "纽约港",
|
||||
"目的港": "巴生西港",
|
||||
"品名": "机器设备",
|
||||
"货量": "15件",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "40HQ*1",
|
||||
"贸易条款": "门到门",
|
||||
}
|
||||
self.flow._save(
|
||||
FlowSession(
|
||||
sender_id="s-door",
|
||||
thread_id="s-door",
|
||||
phase="clarify",
|
||||
business_line="SEA",
|
||||
facts=leftover,
|
||||
)
|
||||
)
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s-door",
|
||||
text="海运、纽约港到巴生西港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件",
|
||||
reply=self._reply,
|
||||
injected_facts=dict(leftover),
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "clarify")
|
||||
joined = "\n".join(self.replies)
|
||||
self.assertNotIn("贸易条款:门到门", joined)
|
||||
self.assertIn("贸易条款", joined)
|
||||
self.assertNotEqual(self.flow.session_of("s-door").facts.get("贸易条款"), "门到门")
|
||||
|
||||
def test_harvest_class_from_reply_then_quotes(self) -> None:
|
||||
first = dict(COMPLETE_SEA)
|
||||
first.pop("运输分类")
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s-class",
|
||||
text="先缺分类",
|
||||
reply=self._reply,
|
||||
injected_facts=first,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "clarify")
|
||||
self.replies.clear()
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s-class",
|
||||
text="Port to Port",
|
||||
reply=self._reply,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "wait_collab")
|
||||
joined = "\n".join(self.replies)
|
||||
self.assertIn("运输分类:Port to Port", joined)
|
||||
self.assertIn("贸易条款:FOB", joined)
|
||||
|
||||
def test_complete_sea_inquiry_card_uses_ten_fields(self) -> None:
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s1",
|
||||
text="海运完整",
|
||||
@@ -173,6 +346,8 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
self.assertIn("货物数量:20吨", joined)
|
||||
self.assertIn("整柜或拼柜:整柜", joined)
|
||||
self.assertIn("箱型箱量:1x40HQ", joined)
|
||||
self.assertIn("贸易条款:FOB", joined)
|
||||
self.assertIn("运输分类:Port to Port", joined)
|
||||
self.assertIn("正在查询TMS报价", joined)
|
||||
self.assertNotIn("件数:", joined.split("正在查询TMS报价")[0])
|
||||
self.assertNotIn("本轮先开通空运", joined)
|
||||
@@ -180,6 +355,44 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
self.assertEqual(sess.business_line, "SEA")
|
||||
self.assertEqual(sess.status, "已报价")
|
||||
|
||||
def test_new_round_after_ticket_does_not_reuse_old_facts(self) -> None:
|
||||
"""上一单已出号,新一轮不能带上旧贸易条款。"""
|
||||
first = self.flow.on_text(
|
||||
sender_id="s-round",
|
||||
text="海运第一轮",
|
||||
reply=self._reply,
|
||||
injected_facts=COMPLETE_SEA,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(first, "wait_collab")
|
||||
old_no = self.flow.session_of("s-round").work_order_no
|
||||
self.assertTrue((old_no or "").startswith("WO"))
|
||||
self.replies.clear()
|
||||
incomplete = {
|
||||
"起运港": "纽约港",
|
||||
"目的港": "巴生西港",
|
||||
"品名": "机器设备",
|
||||
"货量": "15件",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "40HQ*1",
|
||||
"运输分类": "Port to Port",
|
||||
}
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s-round",
|
||||
text="海运、纽约港到巴生西港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port",
|
||||
reply=self._reply,
|
||||
injected_facts=incomplete,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "clarify")
|
||||
joined = "\n".join(self.replies)
|
||||
self.assertNotIn("贸易条款:FOB", joined)
|
||||
self.assertNotIn("贸易条款:门到门", joined)
|
||||
self.assertIn("贸易条款", joined)
|
||||
sess = self.flow.session_of("s-round")
|
||||
self.assertNotEqual(sess.facts.get("贸易条款"), "FOB")
|
||||
self.assertNotEqual(sess.work_order_no, old_no)
|
||||
|
||||
def test_handler_routes_sea_not_air_deferred(self) -> None:
|
||||
reset_sea_text_flow_for_test()
|
||||
from agent.channel.queue import MemoryMessageStore
|
||||
@@ -214,11 +427,60 @@ class SeaTmsCardTests(unittest.TestCase):
|
||||
self.extras.append(dict(extra) if extra else {})
|
||||
|
||||
def test_assemble_sea_query_does_not_need_air_measures(self) -> None:
|
||||
ready = assemble_sea_query(COMPLETE_SEA)
|
||||
ready = assemble_sea_query({**COMPLETE_SEA, "贸易条款": "FOB", "运输分类": "Port to Port"})
|
||||
self.assertTrue(ready["ok"])
|
||||
self.assertEqual(ready["missing"], [])
|
||||
self.assertNotIn("件数", ready.get("missing") or [])
|
||||
|
||||
def test_assemble_requires_trade_terms_not_class(self) -> None:
|
||||
src = {
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
"品名": "普货",
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
"运输分类": "Port to Port",
|
||||
}
|
||||
missing = assemble_sea_query(src)
|
||||
self.assertFalse(missing["ok"])
|
||||
self.assertIn("贸易条款", missing["missing"])
|
||||
self.assertNotIn("运输分类", missing["missing"])
|
||||
|
||||
src["贸易条款"] = "FOB"
|
||||
ready = assemble_sea_query(src)
|
||||
self.assertTrue(ready["ok"])
|
||||
self.assertEqual(ready["tms_facts"]["贸易条款"], "FOB")
|
||||
self.assertNotIn("运输分类", ready["tms_facts"])
|
||||
self.assertEqual(ready["facts"]["运输分类"], "Port to Port")
|
||||
|
||||
def test_sea_quote_text_uses_three_buckets(self) -> None:
|
||||
quote = {
|
||||
"total": "6952.77",
|
||||
"currency": "CNY",
|
||||
"source_label": "TMS 标准报价",
|
||||
"polCostItems": [
|
||||
{"name": "操作费", "amount": "1020", "currency": "CNY"},
|
||||
{"name": "舱单", "amount": "50", "currency": "CNY"},
|
||||
],
|
||||
"oceanCostItems": [{"name": "海运费", "amount": "575", "currency": "USD"}],
|
||||
"podCostItems": [
|
||||
{"name": "文件费", "amount": "450", "currency": "CNY"},
|
||||
{"name": "设备费", "amount": "30", "currency": "CNY"},
|
||||
],
|
||||
"fee_total_polCostItems": "1070",
|
||||
"fee_total_oceanCostItems": "4038",
|
||||
"fee_total_podCostItems": "470",
|
||||
}
|
||||
text = copy.sea_quote_detail_text(work_order_no="WO202609200002", quote=quote)
|
||||
self.assertIn("始发港费用\n操作费:CNY 1020", text)
|
||||
self.assertIn("始发港费用小计:CNY 1070", text)
|
||||
self.assertIn("海运费:USD 575", text)
|
||||
self.assertIn("海运费小计:CNY 4038", text)
|
||||
self.assertIn("目的港费用小计:CNY 470", text)
|
||||
self.assertIn("总成本价:CNY 6952.77", text)
|
||||
self.assertNotIn("时效", text)
|
||||
|
||||
def test_long_fee_lines_go_to_text_not_clipped_card(self) -> None:
|
||||
"""海运多项费用:完整明细必须在文本里,卡片引用条不得半截。"""
|
||||
long_quote = {
|
||||
@@ -253,14 +515,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(
|
||||
@@ -441,6 +703,9 @@ class SeaSkipAndPullTests(unittest.TestCase):
|
||||
self.assertIn(wo, group_text)
|
||||
self.assertIn("起运港", group_text)
|
||||
self.assertIn("贸易条款", group_text)
|
||||
collab_block = group_text.split("协同补充字段:", 1)[-1].split("TMS报价:", 1)[0]
|
||||
self.assertNotIn("贸易条款", collab_block)
|
||||
self.assertIn("货好时间", collab_block)
|
||||
self.assertIn("商品海关编码", group_text)
|
||||
self.assertNotIn("HS编码", group_text)
|
||||
self.assertIn("TMS", group_text)
|
||||
@@ -652,7 +917,7 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
|
||||
def _reply(self, text: str, extra=None) -> None:
|
||||
self.replies.append(text)
|
||||
|
||||
def test_group_text_writes_incoterm_silently(self) -> None:
|
||||
def test_group_text_ignores_trade_terms_saves_ready_date(self) -> None:
|
||||
self.ledger.set_sea_staff(
|
||||
[
|
||||
{
|
||||
@@ -680,11 +945,24 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
chat_id = ticket.collab_chat_id
|
||||
n = len(self.replies)
|
||||
phase = handle_group_collab(
|
||||
ignored = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="prod4",
|
||||
message_id="g1",
|
||||
content="贸易条款:FOB",
|
||||
content="贸易条款:CIF",
|
||||
chat_id=chat_id,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(ignored, "collab_not_recognized")
|
||||
self.assertFalse(ticket.collab_facts.get("贸易条款"))
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="prod4",
|
||||
message_id="g2",
|
||||
content="货好时间:下周",
|
||||
chat_id=chat_id,
|
||||
chat_type="group",
|
||||
),
|
||||
@@ -693,7 +971,7 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(phase, "collab_field_saved")
|
||||
self.assertEqual(len(self.replies), n)
|
||||
self.assertEqual(ticket.collab_facts.get("贸易条款"), "FOB")
|
||||
self.assertEqual(ticket.collab_facts.get("货好时间"), "下周")
|
||||
|
||||
def test_group_price_chat_is_ignored(self) -> None:
|
||||
self.ledger.set_sea_staff(
|
||||
@@ -1051,6 +1329,9 @@ class SeaGroupCopyTests(unittest.TestCase):
|
||||
self.assertIn("询价字段:\n运输方式:海运", text)
|
||||
self.assertIn("\n\n协同补充字段:\n", text)
|
||||
self.assertIn("商品海关编码:-", text)
|
||||
self.assertIn("货好时间:-", text)
|
||||
collab_block = text.split("协同补充字段:", 1)[-1].split("TMS报价:", 1)[0]
|
||||
self.assertNotIn("贸易条款", collab_block)
|
||||
self.assertNotIn("HS编码", text)
|
||||
self.assertIn("\n\nTMS报价:\nTMS 暂无匹配报价", text)
|
||||
self.assertNotIn("未查到合适报价", text)
|
||||
@@ -1304,6 +1585,34 @@ class SeaGroupOpsTests(unittest.TestCase):
|
||||
self.assertTrue(any("不能再上传附件" in t for t in texts))
|
||||
self.assertFalse(self.ledger.get_ticket(work_order_no=wo).attachments)
|
||||
|
||||
def test_handoff_group_stays_silent(self) -> None:
|
||||
from agent.routing.group_intent import INTENT_FIELDS
|
||||
|
||||
wo, chat = self._open_group()
|
||||
self.ledger.transition(work_order_no=wo, to_status="转人工")
|
||||
sess = self.flow.session_of("sales1")
|
||||
sess.status = "转人工"
|
||||
sess.handoff_committed = True
|
||||
self.flow._save(sess)
|
||||
before = len(self.groups.sent)
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="prod4",
|
||||
message_id="h1",
|
||||
content="客户名称张三",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent=INTENT_FIELDS,
|
||||
)
|
||||
self.assertEqual(phase, "handoff_ignore")
|
||||
after = [x.get("content") or "" for x in self.groups.sent[before:] if x.get("kind") == "text"]
|
||||
self.assertFalse(any("不能修改工单信息" in t for t in after))
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertNotIn("客户名称", ticket.collab_facts or {})
|
||||
|
||||
def test_dissolve_then_cannot_recreate(self) -> None:
|
||||
wo, chat = self._open_group()
|
||||
phase = handle_group_collab(
|
||||
|
||||
+15
-6
@@ -8,7 +8,7 @@ import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 询价工单六态与允许流转规则。
|
||||
* 询价工单状态与允许流转规则(含转人工)。
|
||||
* <p>
|
||||
* 职责:集中定义状态字面量与 transition 合法性,避免 Controller/Service 散落魔法字符串。
|
||||
* 副作用:无;纯内存规则。线程安全(不可变 Map)。
|
||||
@@ -22,24 +22,26 @@ public final class TicketStatuses {
|
||||
public static final String DEAL = "已成交";
|
||||
public static final String LOST = "未成交";
|
||||
public static final String CLOSED = "已关闭";
|
||||
public static final String HANDOFF = "转人工";
|
||||
|
||||
private static final Set<String> ALL = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
|
||||
INQUIRING, QUOTED, NEGOTIATING, DEAL, LOST, CLOSED
|
||||
INQUIRING, QUOTED, NEGOTIATING, DEAL, LOST, CLOSED, HANDOFF
|
||||
)));
|
||||
|
||||
/**
|
||||
* from -> 允许的 to 集合。
|
||||
* 终态(已成交/未成交/已关闭)默认不可再流转,除非显式「关闭」从非终态进入已关闭。
|
||||
* 终态:已成交/未成交/已关闭不可再流转;转人工只能再关单。
|
||||
* 协商中允许再进协商中:只顺延再问,不重开询价。
|
||||
*/
|
||||
private static final Map<String, Set<String>> ALLOWED;
|
||||
|
||||
static {
|
||||
Map<String, Set<String>> m = new HashMap<>();
|
||||
m.put(INQUIRING, setOf(QUOTED, NEGOTIATING, CLOSED));
|
||||
m.put(QUOTED, setOf(NEGOTIATING, DEAL, LOST, CLOSED));
|
||||
m.put(INQUIRING, setOf(QUOTED, NEGOTIATING, CLOSED, HANDOFF));
|
||||
m.put(QUOTED, setOf(NEGOTIATING, DEAL, LOST, CLOSED, HANDOFF));
|
||||
// 协商中可再选协商中:顺延下一轮再问,次数不限。
|
||||
m.put(NEGOTIATING, setOf(QUOTED, DEAL, LOST, CLOSED, NEGOTIATING));
|
||||
m.put(NEGOTIATING, setOf(QUOTED, DEAL, LOST, CLOSED, NEGOTIATING, HANDOFF));
|
||||
m.put(HANDOFF, setOf(CLOSED));
|
||||
m.put(DEAL, Collections.emptySet());
|
||||
m.put(LOST, Collections.emptySet());
|
||||
m.put(CLOSED, Collections.emptySet());
|
||||
@@ -53,6 +55,13 @@ public final class TicketStatuses {
|
||||
return status != null && ALL.contains(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转人工后询价字段与报价不再更新。
|
||||
*/
|
||||
public static boolean isFrozen(String status) {
|
||||
return HANDOFF.equals(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验是否允许从 from 转到 to。
|
||||
*
|
||||
|
||||
+10
@@ -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;
|
||||
|
||||
|
||||
+2
-1
@@ -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);
|
||||
|
||||
|
||||
+2
-1
@@ -797,7 +797,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
out.put("destination", first(src, "目的地", "目的港", "destination", "pod"));
|
||||
out.put("commodity", first(src, "品名", "货物品名", "commodity"));
|
||||
out.put("vehicle_qty", first(src, "车型/数量", "车型"));
|
||||
// 海运七项:合同键是 quantity / fcl_or_lcl / container_type_qty,工单事实是中文键
|
||||
// 海运询价栏:合同键是 quantity / fcl_or_lcl / container_type_qty / trade_terms,工单事实是中文键
|
||||
out.put("quantity", first(src, "货量", "货物数量", "quantity"));
|
||||
out.put("fcl_or_lcl", first(src, "整柜或拼柜", "fcl_or_lcl"));
|
||||
out.put("container_type_qty", first(src, "箱型箱量", "container_type_qty"));
|
||||
@@ -806,6 +806,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
out.put("volume_cbm", first(src, "体积", "volume_cbm"));
|
||||
out.put("quote_date", first(src, "报价日期", "quote_date", "询价日期"));
|
||||
out.put("trade_terms", first(src, "贸易条款", "trade_terms"));
|
||||
out.put("运输分类", first(src, "运输分类"));
|
||||
out.put("cargo_ready_date", first(src, "货好时间", "cargo_ready_date"));
|
||||
out.put("hs_code", first(src, "商品海关编码", "HS编码", "海关编码", "hs_code"));
|
||||
String oil = first(src, "是否含油", "has_oil");
|
||||
|
||||
+116
-21
@@ -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()));
|
||||
@@ -718,6 +785,11 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
out.put("error", "ticket_not_found");
|
||||
return out;
|
||||
}
|
||||
if (TicketStatuses.isFrozen(ticket.getStatus())) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "handoff_frozen");
|
||||
return out;
|
||||
}
|
||||
Map<String, String> merged = new LinkedHashMap<>();
|
||||
if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) {
|
||||
JSONObject old = JSON.parseObject(ticket.getCollabFactsJson());
|
||||
@@ -853,8 +925,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品报价盖 TMS 价:新插一条 inq_quote,主表售价跟着改。
|
||||
* 询价中/协商中可迁已报价;已报价只加版本。终态由智能体先拦。
|
||||
* 手工报价 / 销售调价:新插一条 inq_quote,来源固定为手工报价,不覆盖 TMS 那一行。
|
||||
* 销售调价必须再写一条状态流转备注(当时时间点)。询价中/协商中可迁已报价。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -868,8 +940,18 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
out.put("error", "ticket_not_found");
|
||||
return out;
|
||||
}
|
||||
if (TicketStatuses.isFrozen(ticket.getStatus())) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "handoff_frozen");
|
||||
out.put("status", ticket.getStatus());
|
||||
return out;
|
||||
}
|
||||
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 +959,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 +991,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);
|
||||
|
||||
+56
@@ -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), "销售调价");
|
||||
}
|
||||
}
|
||||
+120
@@ -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() + "%";
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -2484,7 +2484,7 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
String[] markers = {
|
||||
"起运港", "目的港", "询价日期", "品名", "货物数量", "整柜或拼柜", "箱型箱量",
|
||||
"贸易条款", "货好时间", "商品海关编码", "是否含油", "提货地址", "送货地址", "报价有效期", "费用总计",
|
||||
"贸易条款", "运输分类", "货好时间", "商品海关编码", "是否含油", "提货地址", "送货地址", "报价有效期", "费用总计",
|
||||
"始发港", "目的地", "Inquiry Date", "Commodity", "Place of Receipt"
|
||||
};
|
||||
for (String m : markers) {
|
||||
|
||||
+27
-18
@@ -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;
|
||||
@@ -92,6 +93,7 @@ public class InquiryTmsQueryService {
|
||||
if (items != null) {
|
||||
segment.put("containerItems", items);
|
||||
}
|
||||
segment.put("tradeTerms", first(facts.get("贸易条款"), facts.get("trade_terms"), facts.get("tradeTerms")));
|
||||
} else if ("LAND".equals(line)) {
|
||||
// 陆运不套空运包装/件数;分类和中港装载只在工单,mapper 不会放进出站。
|
||||
segment.putAll(TmsLandSegmentMapper.build(facts));
|
||||
@@ -214,6 +216,10 @@ public class InquiryTmsQueryService {
|
||||
if (ticket == null) {
|
||||
return;
|
||||
}
|
||||
if (TicketStatuses.isFrozen(ticket.getStatus())) {
|
||||
log.info("tms.quote skip frozen wo={} status={}", workOrderNo, ticket.getStatus());
|
||||
return;
|
||||
}
|
||||
InquiryQuote row = new InquiryQuote();
|
||||
row.setTicketId(ticket.getId());
|
||||
row.setWorkOrderNo(workOrderNo);
|
||||
@@ -226,8 +232,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 +465,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);
|
||||
}
|
||||
|
||||
@@ -555,6 +557,10 @@ public class InquiryTmsQueryService {
|
||||
appendFeeLineFromRow(buckets.get(bucket), (Map<?, ?>) one, headerCurrency);
|
||||
}
|
||||
}
|
||||
String bucketTotal = firstText(row.get("totalCostPrice"), row.get("total_cost_price"));
|
||||
if (StringUtils.isNotBlank(bucketTotal)) {
|
||||
quote.put("fee_total_" + bucket, bucketTotal);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (Map.Entry<String, List<Map<String, String>>> e : buckets.entrySet()) {
|
||||
@@ -622,8 +628,8 @@ public class InquiryTmsQueryService {
|
||||
* 对不上机场也要查,TMS 自己回无价或 1003。
|
||||
*/
|
||||
/**
|
||||
* 海运出站必填:工单号、报价日期、起运/目的名称、品名、货量、整柜或拼柜、箱型箱量。
|
||||
* 不要求机场三字码、件数、重量、体积、包装。
|
||||
* 海运出站必填:工单号、报价日期、起运/目的名称、品名、货量、整柜或拼柜、箱型箱量、贸易条款。
|
||||
* 不要求机场三字码、件数、重量、体积、包装;运输分类不进出站。
|
||||
*/
|
||||
private static List<String> missingSeaQuery(String workOrderNo, String quoteDate,
|
||||
Map<String, Object> segment, Map<String, Object> facts) {
|
||||
@@ -648,6 +654,9 @@ public class InquiryTmsQueryService {
|
||||
if (blank(facts.get("箱型箱量"))) {
|
||||
missing.add("箱型箱量");
|
||||
}
|
||||
if (blank(facts.get("贸易条款")) && blank(facts.get("trade_terms")) && blank(facts.get("tradeTerms"))) {
|
||||
missing.add("贸易条款");
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -113,6 +113,7 @@ public final class TmsV2RequestMapper {
|
||||
}
|
||||
put(target, "containerType", type);
|
||||
put(target, "containerQty", asInteger(qty));
|
||||
put(target, "tradeTerms", first(source, "tradeTerms", "贸易条款", "trade_terms"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+30
@@ -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"));
|
||||
}
|
||||
}
|
||||
+93
@@ -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;
|
||||
}
|
||||
}
|
||||
+43
@@ -291,6 +291,8 @@ public class QuoteTemplateFillTest {
|
||||
facts.put("货量", "15件");
|
||||
facts.put("整柜或拼柜", "整柜");
|
||||
facts.put("箱型箱量", "20GP*1");
|
||||
facts.put("贸易条款", "FOB");
|
||||
facts.put("运输分类", "Port to Port");
|
||||
facts.put("报价日期", "2026-09-15");
|
||||
Map<String, Object> quote = new HashMap<>();
|
||||
quote.put("total", "2287.65");
|
||||
@@ -306,9 +308,50 @@ public class QuoteTemplateFillTest {
|
||||
assertEquals("15件", out.getString("quantity"));
|
||||
assertEquals("整柜", out.getString("fcl_or_lcl"));
|
||||
assertEquals("20GP*1", out.getString("container_type_qty"));
|
||||
assertEquals("FOB", out.getString("trade_terms"));
|
||||
assertEquals("Port to Port", out.getString("运输分类"));
|
||||
assertEquals("2287.65", out.getString("total_amount"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 海运询价栏:贸易条款、运输分类按标签右侧原文写入。
|
||||
* 标签格子本身不能被港口/分类值盖掉。
|
||||
*/
|
||||
@Test
|
||||
public void seaInquiryWritesTradeTermsAndClassByLabel() throws Exception {
|
||||
Path src = Files.createTempFile("quote-sea-class-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-sea-class-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("海运询价单");
|
||||
row(sheet, 8).createCell(0).setCellValue("贸易条款");
|
||||
row(sheet, 9).createCell(0).setCellValue("运输分类");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
Map<String, Object> ticket = new HashMap<>();
|
||||
ticket.put("贸易条款", "FOB");
|
||||
ticket.put("运输分类", "Port to Port");
|
||||
ticket.put("起运港", "DONGGUAN");
|
||||
JSONObject facts = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
|
||||
.buildFillFacts("WO202609200010", ticket, new HashMap<>(), "WuJiLin");
|
||||
assertEquals("FOB", facts.getString("trade_terms"));
|
||||
assertEquals("Port to Port", facts.getString("运输分类"));
|
||||
JSONObject mapping = new JSONObject();
|
||||
mapping.put("fields", new JSONObject());
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertEquals("贸易条款", read(sheet, 8, 0));
|
||||
assertEquals("FOB", read(sheet, 8, 1));
|
||||
assertEquals("运输分类", read(sheet, 9, 0));
|
||||
assertEquals("Port to Port", read(sheet, 9, 1));
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void buildFillFactsProjectsCollabFields() {
|
||||
Map<String, Object> facts = new HashMap<>();
|
||||
|
||||
+38
@@ -182,4 +182,42 @@ public class TmsQuoteCurrencyTest {
|
||||
assertTrue(quote.get("fee_lines").contains("运输费"));
|
||||
assertTrue(quote.get("fee_lines").contains("2800"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toAgentQuoteKeepsSeaBucketTotals() {
|
||||
Map<String, Object> pol = new HashMap<>();
|
||||
pol.put("feeName", "操作费");
|
||||
pol.put("amount", 1020);
|
||||
pol.put("currency", "CNY");
|
||||
Map<String, Object> polWrap = new java.util.LinkedHashMap<>();
|
||||
polWrap.put("polCostItems", Collections.singletonList(pol));
|
||||
polWrap.put("totalCostPrice", 1070);
|
||||
Map<String, Object> ocean = new HashMap<>();
|
||||
ocean.put("feeName", "海运费");
|
||||
ocean.put("amount", 575);
|
||||
ocean.put("currency", "USD");
|
||||
Map<String, Object> oceanWrap = new java.util.LinkedHashMap<>();
|
||||
oceanWrap.put("oceanCostItems", Collections.singletonList(ocean));
|
||||
oceanWrap.put("totalCostPrice", 4038);
|
||||
Map<String, Object> pod = new HashMap<>();
|
||||
pod.put("feeName", "文件费");
|
||||
pod.put("amount", 450);
|
||||
pod.put("currency", "CNY");
|
||||
Map<String, Object> podWrap = new java.util.LinkedHashMap<>();
|
||||
podWrap.put("podCostItems", Collections.singletonList(pod));
|
||||
podWrap.put("totalCostPrice", 470);
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("quoteFound", true);
|
||||
data.put("totalCostPrice", 6952.77);
|
||||
data.put("currency", "CNY");
|
||||
data.put("feeItems", java.util.Arrays.asList(polWrap, oceanWrap, podWrap));
|
||||
Map<String, Object> tms = new HashMap<>();
|
||||
tms.put("code", 1);
|
||||
tms.put("tmsData", data);
|
||||
Map<String, String> quote = InquiryTmsQueryService.toAgentQuote(tms);
|
||||
assertEquals("1070", quote.get("fee_total_polCostItems"));
|
||||
assertEquals("4038", quote.get("fee_total_oceanCostItems"));
|
||||
assertEquals("470", quote.get("fee_total_podCostItems"));
|
||||
assertTrue(quote.get("fee_rows_oceanCostItems").contains("575"));
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -130,4 +130,27 @@ public class TmsV2RequestMapperTest {
|
||||
assertFalse(segs.get(0).containsKey("landRouteCategory"));
|
||||
assertFalse(segs.get(0).containsKey("landClass"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void seaSendsTradeTermsOmitsClass() {
|
||||
Map<String, Object> seg = new HashMap<>();
|
||||
seg.put("origin", "上海");
|
||||
seg.put("destination", "洛杉矶");
|
||||
seg.put("cargoName", "普货");
|
||||
seg.put("cargoQty", "20吨");
|
||||
seg.put("loadType", "整柜");
|
||||
seg.put("containerSpec", "1x40HQ");
|
||||
seg.put("tradeTerms", "FOB");
|
||||
seg.put("运输分类", "Port to Port");
|
||||
Map<String, Object> internal = new HashMap<>();
|
||||
internal.put("transportMode", "SEA");
|
||||
internal.put("quoteDate", "2026-09-15");
|
||||
internal.put("segments", Collections.singletonList(seg));
|
||||
Map<String, Object> body = TmsV2RequestMapper.quote(internal);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
|
||||
assertEquals("FOB", segs.get(0).get("tradeTerms"));
|
||||
assertFalse(segs.get(0).containsKey("运输分类"));
|
||||
assertFalse(body.containsKey("运输分类"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -424,6 +442,7 @@ function refresh() {
|
||||
<option value="询价中">询价中</option>
|
||||
<option value="已报价">已报价</option>
|
||||
<option value="协商中">协商中</option>
|
||||
<option value="转人工">转人工</option>
|
||||
<option value="已成交">已成交</option>
|
||||
<option value="未成交">未成交</option>
|
||||
<option value="已关闭">已关闭</option>
|
||||
@@ -661,8 +680,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>
|
||||
|
||||
@@ -22,7 +22,7 @@ export const searchFormSchema: FormSchema[] = [
|
||||
component: 'Select',
|
||||
colProps: { span: 5 },
|
||||
componentProps: {
|
||||
options: ['询价中', '已报价', '协商中', '已成交', '未成交', '已关闭'].map((v) => ({ label: v, value: v })),
|
||||
options: ['询价中', '已报价', '协商中', '转人工', '已成交', '未成交', '已关闭'].map((v) => ({ label: v, value: v })),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user