Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5e73c01ae | ||
|
|
20c27ae293 | ||
|
|
6140eaf932 | ||
|
|
19acbb9156 | ||
|
|
b3b3cf2903 | ||
|
|
88a66d40bb | ||
|
|
698ac43878 | ||
|
|
79caddef0e | ||
|
|
91bde83e9b | ||
|
|
3a6884e27d | ||
|
|
27aeebb593 | ||
|
|
54b818915e |
@@ -36,6 +36,44 @@ def _front_bookmark(sess) -> bool:
|
||||
return phase in {"clarify", "need_mode", "need_land_options", "wait_confirm"}
|
||||
|
||||
|
||||
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 把补问短答标成闲聊时,仍续填当前询价。
|
||||
@@ -63,7 +101,9 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = "", prefe
|
||||
"""
|
||||
按当前书签或本句运输方式选空运/海运/陆运流程。
|
||||
|
||||
陆运核对卡上的确定优先于旧海运书签,避免「确定」被海运/空运吃掉。
|
||||
陆运核对卡上的「确定」优先于旧海运书签,避免「确定」被海运/空运吃掉。
|
||||
本句点名海运/空运/陆运时盖掉其它方式的旧补问草稿。
|
||||
核对卡优先于选项补问;海运/空运补问优先于旧陆运选项草稿,避免补贸易条款发陆运样例图。
|
||||
正在补问时,补问书签优先于已报价调价,避免第 3 次反问出不了转人工卡。
|
||||
A 已判成调价且当前不是补问:交给当前已报价工单,避免旧海运书签抢走。
|
||||
图片识图在 Worker 写下的补问书签,HTTP 必须从 Redis 读到,否则「托盘」会变成再问运输方式。
|
||||
@@ -82,11 +122,24 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = "", prefe
|
||||
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)
|
||||
# 补问/核对手稿优先于「已报价调价」,避免第 3 次反问被调价路由冲掉、出不了转人工卡。
|
||||
# 核对卡仍优先:销售正在改体积/点确定,不能被另一方式的补问草稿抢走。
|
||||
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 prefer_quoted:
|
||||
@@ -97,11 +150,6 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = "", prefe
|
||||
return sea
|
||||
if land_sess and not is_handoff_frozen(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":
|
||||
return land
|
||||
return air
|
||||
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -395,6 +395,31 @@ class AirTextInquiryFlow:
|
||||
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。
|
||||
@@ -691,6 +716,7 @@ class AirTextInquiryFlow:
|
||||
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,
|
||||
@@ -734,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,
|
||||
@@ -749,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,
|
||||
|
||||
@@ -25,6 +25,7 @@ from agent.schema.field_validate import (
|
||||
)
|
||||
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 = {
|
||||
@@ -41,7 +42,7 @@ _AIR_CLARIFY = tuple(
|
||||
(key, label, _AIR_EXAMPLES[key]) for key, label in AIR_REQUIRED_FIELDS
|
||||
)
|
||||
|
||||
# 海运补问:与合同 7 项一致。报价日期默认当天,不进待补充。
|
||||
# 海运补问:与合同 10 项一致。报价日期默认当天,不进待补充。
|
||||
_SEA_EXAMPLES = {
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
@@ -49,6 +50,8 @@ _SEA_EXAMPLES = {
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
"贸易条款": "FOB",
|
||||
"运输分类": "Port to Port",
|
||||
"报价日期": "2026-09-15",
|
||||
}
|
||||
_SEA_CLARIFY = tuple(
|
||||
@@ -404,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)
|
||||
@@ -485,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}",
|
||||
@@ -494,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() == "陆运":
|
||||
@@ -766,6 +776,59 @@ def quote_fee_lines(quote: dict[str, Any] | None) -> list[tuple[str, str]]:
|
||||
return lines
|
||||
|
||||
|
||||
_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 = "",
|
||||
@@ -779,7 +842,11 @@ def quote_area_fee_text(
|
||||
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)]
|
||||
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)
|
||||
|
||||
@@ -910,7 +977,14 @@ 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)]
|
||||
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)}")
|
||||
@@ -2017,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:
|
||||
|
||||
@@ -169,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,
|
||||
@@ -182,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,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
海运协同群业务:填表进群、补字段重发、产品报价、附件、解散、成交跟进。
|
||||
|
||||
本文件职责:群里副作用(主账+企微群)。私聊按钮仍走 sea_text_flow.on_button。
|
||||
禁止:改询价七项;终态默默忽略;把销售附件当报价。
|
||||
禁止:改询价十项;终态默默忽略;把销售附件当报价。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.ledger.http_ledger import HttpLedger
|
||||
@@ -41,6 +42,8 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
"""
|
||||
|
||||
bookmark_kind = "sea"
|
||||
# 价格明细文字发出后稍等再发卡,避免企微先画出卡片。
|
||||
_detail_before_card_gap_sec = 0.8
|
||||
|
||||
def __init__(self, ledger: Any = None, group_client: Any = None) -> None:
|
||||
super().__init__(ledger=ledger)
|
||||
@@ -99,6 +102,8 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
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,
|
||||
@@ -139,6 +144,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,
|
||||
@@ -153,6 +160,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,
|
||||
@@ -165,6 +173,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)
|
||||
@@ -249,7 +262,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,
|
||||
@@ -365,16 +379,18 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
business_line: str = "",
|
||||
) -> None:
|
||||
"""
|
||||
先发完整价格明细文本,再发带按钮的卡。
|
||||
先发完整价格明细文本,等它真正发出,再发带按钮的卡。
|
||||
|
||||
企微只发卡时,引用条超长会被截断;文本不受限。
|
||||
测服 WO202609200020:文字和卡同一拍入队,销售先看到卡,
|
||||
「详见上一条明细」对不上。只等这一条出站,不锁其它工单。
|
||||
"""
|
||||
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(
|
||||
out_id = reply(
|
||||
copy.sea_quote_detail_text(
|
||||
work_order_no=work_order_no,
|
||||
quote=src,
|
||||
@@ -383,7 +399,13 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
status=status,
|
||||
business_line=line,
|
||||
)
|
||||
) or ""
|
||||
self._wait_outbound_sent(
|
||||
str(out_id), work_order_no=work_order_no, what="价格明细文本"
|
||||
)
|
||||
gap = float(getattr(self, "_detail_before_card_gap_sec", 0.8) or 0)
|
||||
if out_id and gap > 0:
|
||||
time.sleep(gap)
|
||||
reply(
|
||||
copy.sea_tms_hit_card(
|
||||
work_order_no=work_order_no,
|
||||
|
||||
@@ -231,7 +231,7 @@ 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报价,没有报新金额;"
|
||||
|
||||
@@ -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,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,
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ SEA_ORAL = """海运
|
||||
货量:20吨
|
||||
整柜或拼柜:整柜
|
||||
箱型箱量:1x40HQ
|
||||
贸易条款:FOB
|
||||
运输分类:Port to Port
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -323,6 +323,55 @@ class LandDispatchTests(unittest.TestCase):
|
||||
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()
|
||||
|
||||
@@ -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 = {
|
||||
@@ -273,6 +535,37 @@ class SeaTmsCardTests(unittest.TestCase):
|
||||
self.assertIn("时效:1天", sub)
|
||||
self.assertIn("报价有效期:2026-09-30", sub)
|
||||
|
||||
def test_sea_hit_sends_detail_text_before_card(self) -> None:
|
||||
"""
|
||||
有价必须先发出价格明细文字,再发卡。
|
||||
|
||||
测服 WO202609200020:卡上写「详见上一条明细」,销售却先看到卡。
|
||||
"""
|
||||
steps: list[str] = []
|
||||
|
||||
def reply(text: str, extra=None) -> str:
|
||||
kind = "card" if extra else "text"
|
||||
steps.append(kind)
|
||||
return f"out-{len(steps)}"
|
||||
|
||||
def wait(out_id: str, *, work_order_no: str, what: str) -> None:
|
||||
steps.append(f"wait:{out_id}")
|
||||
|
||||
self.flow._wait_outbound_sent = wait # type: ignore[method-assign]
|
||||
self.flow._detail_before_card_gap_sec = 0.0
|
||||
self.flow._emit_sea_hit(
|
||||
reply,
|
||||
work_order_no="WO202609200020",
|
||||
quote={
|
||||
"total": "14298.53",
|
||||
"currency": "CNY",
|
||||
"source_label": "TMS标准报价",
|
||||
"fee_lines": [{"name": "操作费", "amount": "1220", "currency": "CNY"}],
|
||||
},
|
||||
)
|
||||
self.assertEqual(steps[:3], ["text", "wait:out-1", "card"])
|
||||
self.assertIn("card", steps)
|
||||
|
||||
def test_sea_hit_card_has_price_and_two_buttons(self) -> None:
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s1",
|
||||
@@ -441,6 +734,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 +948,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 +976,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 +1002,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 +1360,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)
|
||||
|
||||
+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");
|
||||
|
||||
+2
-15
@@ -41,6 +41,7 @@ public class InquiryResponseTimeoutServiceImpl
|
||||
private static final Map<String, String> ID_TO_TIMEOUT_CODE = new LinkedHashMap<>();
|
||||
|
||||
static {
|
||||
// 陆运产品仅保留 4 项运输分类(与线路解耦);旧逗号写法一次性纠偏
|
||||
LAND_PRODUCT_TYPE_RENAMES.put(
|
||||
"普件,中港、东南亚常用线路/常规车型",
|
||||
"普件(东南亚、中港常用线路/常规车型)");
|
||||
@@ -50,12 +51,6 @@ public class InquiryResponseTimeoutServiceImpl
|
||||
LAND_PRODUCT_TYPE_RENAMES.put(
|
||||
"普件,中亚、中欧、东南亚涉及境外段清派等特殊要求",
|
||||
"普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)");
|
||||
LAND_PRODUCT_TYPE_RENAMES.put(
|
||||
"普件,常用线路/常规车型",
|
||||
"普件(常用线路/常规车型)");
|
||||
LAND_PRODUCT_TYPE_RENAMES.put(
|
||||
"普件,非常用线路/临时线路/特种车型",
|
||||
"普件(非常用线路/临时线路/特种车型)");
|
||||
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000001", "air_sales");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000002", "air_route");
|
||||
@@ -63,19 +58,11 @@ public class InquiryResponseTimeoutServiceImpl
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000004", "land_sales");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000005", "sea_product_ptp");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000006", "sea_product_other");
|
||||
// 陆运产品运输分类 4 项(编码沿用 land_hk_*,勿再按线路拆 ltl/city/sea)
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000007", "land_hk_common");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000008", "land_hk_cross");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000009", "land_hk_special");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000010", "land_hk_express");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000011", "land_ltl_common");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000012", "land_ltl_special");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000013", "land_ltl_express");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000014", "land_city_common");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000015", "land_city_special");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000016", "land_city_express");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000017", "land_sea_common");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000018", "land_sea_special");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000019", "land_sea_express");
|
||||
}
|
||||
|
||||
/** 本进程内只纠偏一次,避免每次打开系统设置都打 UPDATE */
|
||||
|
||||
+1
-1
@@ -2484,7 +2484,7 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
String[] markers = {
|
||||
"起运港", "目的港", "询价日期", "品名", "货物数量", "整柜或拼柜", "箱型箱量",
|
||||
"贸易条款", "货好时间", "商品海关编码", "是否含油", "提货地址", "送货地址", "报价有效期", "费用总计",
|
||||
"贸易条款", "运输分类", "货好时间", "商品海关编码", "是否含油", "提货地址", "送货地址", "报价有效期", "费用总计",
|
||||
"始发港", "目的地", "Inquiry Date", "Commodity", "Place of Receipt"
|
||||
};
|
||||
for (String m : markers) {
|
||||
|
||||
+15
-2
@@ -93,6 +93,11 @@ public class InquiryTmsQueryService {
|
||||
if (items != null) {
|
||||
segment.put("containerItems", items);
|
||||
}
|
||||
segment.put("transportTerms", first(
|
||||
facts.get("贸易条款"),
|
||||
facts.get("trade_terms"),
|
||||
facts.get("transportTerms"),
|
||||
facts.get("tradeTerms")));
|
||||
} else if ("LAND".equals(line)) {
|
||||
// 陆运不套空运包装/件数;分类和中港装载只在工单,mapper 不会放进出站。
|
||||
segment.putAll(TmsLandSegmentMapper.build(facts));
|
||||
@@ -556,6 +561,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()) {
|
||||
@@ -623,8 +632,8 @@ public class InquiryTmsQueryService {
|
||||
* 对不上机场也要查,TMS 自己回无价或 1003。
|
||||
*/
|
||||
/**
|
||||
* 海运出站必填:工单号、报价日期、起运/目的名称、品名、货量、整柜或拼柜、箱型箱量。
|
||||
* 不要求机场三字码、件数、重量、体积、包装。
|
||||
* 海运出站必填:工单号、报价日期、起运/目的名称、品名、货量、整柜或拼柜、箱型箱量、贸易条款。
|
||||
* 不要求机场三字码、件数、重量、体积、包装;运输分类不进出站。
|
||||
*/
|
||||
private static List<String> missingSeaQuery(String workOrderNo, String quoteDate,
|
||||
Map<String, Object> segment, Map<String, Object> facts) {
|
||||
@@ -649,6 +658,10 @@ public class InquiryTmsQueryService {
|
||||
if (blank(facts.get("箱型箱量"))) {
|
||||
missing.add("箱型箱量");
|
||||
}
|
||||
if (blank(facts.get("贸易条款")) && blank(facts.get("trade_terms"))
|
||||
&& blank(facts.get("transportTerms")) && blank(facts.get("tradeTerms"))) {
|
||||
missing.add("贸易条款");
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -113,6 +113,8 @@ public final class TmsV2RequestMapper {
|
||||
}
|
||||
put(target, "containerType", type);
|
||||
put(target, "containerQty", asInteger(qty));
|
||||
// TMS 查询字段是 transportTerms。工单/卡片仍写「贸易条款」,这里只改出站名。
|
||||
put(target, "transportTerms", first(source, "transportTerms", "tradeTerms", "贸易条款", "trade_terms"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-9
@@ -19,15 +19,7 @@ UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_common' WHERE `id`='rt
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_cross' WHERE `id`='rt100000000000000000000000000008' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_special' WHERE `id`='rt100000000000000000000000000009' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_express' WHERE `id`='rt100000000000000000000000000010' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_ltl_common' WHERE `id`='rt100000000000000000000000000011' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_ltl_special' WHERE `id`='rt100000000000000000000000000012' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_ltl_express' WHERE `id`='rt100000000000000000000000000013' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_city_common' WHERE `id`='rt100000000000000000000000000014' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_city_special' WHERE `id`='rt100000000000000000000000000015' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_city_express' WHERE `id`='rt100000000000000000000000000016' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_sea_common' WHERE `id`='rt100000000000000000000000000017' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_sea_special' WHERE `id`='rt100000000000000000000000000018' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_sea_express' WHERE `id`='rt100000000000000000000000000019' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
-- 陆运产品仅 4 项;ltl/city/sea 重复行已由 migration_inq_response_timeout_land_class_v2.sql 删除
|
||||
|
||||
-- 3) 唯一索引(列已全部非空后再建;若仍有 NULL 会失败)
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`=CONCAT('rt_', `id`) WHERE `timeout_code` IS NULL OR `timeout_code`='';
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
-- 陆运产品运输分类:只保留产品确认的 4 项,删除按线路重复的 ltl/city/sea 共 9 条。
|
||||
-- 保留 land_hk_common / land_hk_cross / land_hk_special / land_hk_express,清空 route_category(分类与线路解耦)。
|
||||
-- 仅改 inquiry_robot;幂等可重复执行。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- 1) 删除按线路拆开的重复产品行
|
||||
DELETE FROM `inq_response_timeout`
|
||||
WHERE `timeout_code` IN (
|
||||
'land_ltl_common','land_ltl_special','land_ltl_express',
|
||||
'land_city_common','land_city_special','land_city_express',
|
||||
'land_sea_common','land_sea_special','land_sea_express'
|
||||
);
|
||||
|
||||
DELETE FROM `inq_response_timeout`
|
||||
WHERE `id` IN (
|
||||
'rt100000000000000000000000000011',
|
||||
'rt100000000000000000000000000012',
|
||||
'rt100000000000000000000000000013',
|
||||
'rt100000000000000000000000000014',
|
||||
'rt100000000000000000000000000015',
|
||||
'rt100000000000000000000000000016',
|
||||
'rt100000000000000000000000000017',
|
||||
'rt100000000000000000000000000018',
|
||||
'rt100000000000000000000000000019'
|
||||
);
|
||||
|
||||
-- 2) 保留的 4 项:分类与线路解耦,route_category 置空;文案对齐产品确认
|
||||
UPDATE `inq_response_timeout`
|
||||
SET `route_category`='',
|
||||
`transport_type`='普件(东南亚、中港常用线路/常规车型)',
|
||||
`update_by`='system',
|
||||
`update_time`=NOW()
|
||||
WHERE `id`='rt100000000000000000000000000007'
|
||||
OR `timeout_code`='land_hk_common';
|
||||
|
||||
UPDATE `inq_response_timeout`
|
||||
SET `route_category`='',
|
||||
`transport_type`='普件(东南亚、中亚、中欧涉及国内+国外段)',
|
||||
`update_by`='system',
|
||||
`update_time`=NOW()
|
||||
WHERE `id`='rt100000000000000000000000000008'
|
||||
OR `timeout_code`='land_hk_cross';
|
||||
|
||||
UPDATE `inq_response_timeout`
|
||||
SET `route_category`='',
|
||||
`transport_type`='普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)',
|
||||
`update_by`='system',
|
||||
`update_time`=NOW()
|
||||
WHERE `id`='rt100000000000000000000000000009'
|
||||
OR `timeout_code`='land_hk_special';
|
||||
|
||||
UPDATE `inq_response_timeout`
|
||||
SET `route_category`='',
|
||||
`transport_type`='急件',
|
||||
`update_by`='system',
|
||||
`update_time`=NOW()
|
||||
WHERE `id`='rt100000000000000000000000000010'
|
||||
OR `timeout_code`='land_hk_express';
|
||||
+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"));
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -130,4 +130,30 @@ public class TmsV2RequestMapperTest {
|
||||
assertFalse(segs.get(0).containsKey("landRouteCategory"));
|
||||
assertFalse(segs.get(0).containsKey("landClass"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void seaSendsTransportTermsOmitsClass() {
|
||||
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");
|
||||
// 测服 WO202609200019:工单仍是贸易条款,出站必须写成 transportTerms,不能写 tradeTerms。
|
||||
seg.put("贸易条款", "4");
|
||||
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("4", segs.get(0).get("transportTerms"));
|
||||
assertFalse(segs.get(0).containsKey("tradeTerms"));
|
||||
assertFalse(segs.get(0).containsKey("贸易条款"));
|
||||
assertFalse(segs.get(0).containsKey("运输分类"));
|
||||
assertFalse(body.containsKey("运输分类"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 海运 / 空运 / 陆运同页自上而下分块展示,只允许改超时小时;无增删、无 Tab。
|
||||
* 「编码」= timeoutCode,每条唯一(如 sea_sales / land_hk_express),供智能体单码调用;只读。
|
||||
* roleCode(销售/产品)仅为角色维度,可重复,不是调用主键。
|
||||
* 不展示线路类别(维度仍在主账,仅后台列表隐藏)。
|
||||
* 陆运产品运输分类仅 4 项(与线路解耦);不展示线路类别。
|
||||
* 数据来自主账 inq_response_timeout;本页不做工单超时判定。
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
Reference in New Issue
Block a user