落地陆运群内协同:按填写样例拉群并只列本单协同项,进群把私聊已抽到的字段带上。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-18 13:38:02 +08:00
co-authored by Cursor
parent be95d823a2
commit 982b3a2317
25 changed files with 1451 additions and 315 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

+79 -6
View File
@@ -67,6 +67,21 @@ class WeComAppClient:
副作用:出站 HTTP 调企微;失败返回 ok=False,由 outbox 重试。
"""
extra = dict(payload or {})
if extra.get("msgtype") == "image":
image_result = self.send_image(
touser=touser,
filename=str(extra.get("filename") or "image.png"),
filepath=str(extra.get("filepath") or ""),
file_b64=str(extra.get("file_b64") or extra.get("fileBase64") or ""),
)
if image_result.ok:
return image_result
logger.warning(
"图片发送失败,回退文本 err=%s:%s",
image_result.errcode,
image_result.errmsg,
)
return self.send_text(touser=touser, content=content)
if extra.get("msgtype") == "file":
file_result = self.send_file(
touser=touser,
@@ -213,7 +228,9 @@ class WeComAppClient:
return WeComSendResult(ok=False, errcode=-1, errmsg=f"b64:{exc}")
if not raw:
return WeComSendResult(ok=False, errcode=-1, errmsg="file_empty")
media_id, up = self._upload_media(filename=filename or "quote.xlsx", content=raw)
media_id, up = self._upload_media(
filename=filename or "quote.xlsx", content=raw, media_type="file"
)
if not media_id:
return up
return self._post_message(
@@ -226,8 +243,64 @@ class WeComAppClient:
preview=f"[file]{filename}",
)
def _upload_media(self, *, filename: str, content: bytes) -> tuple[str, WeComSendResult]:
"""cgi-bin/media/upload;密钥不写日志。"""
def send_image(
self,
*,
touser: str,
filename: str,
filepath: str = "",
file_b64: str = "",
) -> WeComSendResult:
"""
先上传 image 素材再发图片消息,聊天里直接看图。
字节来自本机路径或 Base64。禁止在回调线程调用。
"""
if not touser:
return WeComSendResult(ok=False, errcode=-1, errmsg="touser 为空")
raw = b""
if filepath:
try:
from pathlib import Path
raw = Path(filepath).read_bytes()
except OSError as exc:
return WeComSendResult(ok=False, errcode=-1, errmsg=f"read_file:{exc}")
elif file_b64:
import base64
try:
raw = base64.b64decode(file_b64)
except Exception as exc: # noqa: BLE001
return WeComSendResult(ok=False, errcode=-1, errmsg=f"b64:{exc}")
if not raw:
return WeComSendResult(ok=False, errcode=-1, errmsg="image_empty")
name = filename or "image.png"
media_id, up = self._upload_media(
filename=name, content=raw, media_type="image"
)
if not media_id:
return up
return self._post_message(
{
"touser": touser,
"msgtype": "image",
"agentid": self._agent_id,
"image": {"media_id": media_id},
},
preview=f"[image]{name}",
)
def _upload_media(
self,
*,
filename: str,
content: bytes,
media_type: str = "file",
) -> tuple[str, WeComSendResult]:
"""cgi-bin/media/upload;type=file 或 image。密钥不写日志。"""
kind = (media_type or "file").strip() or "file"
mime = "image/png" if kind == "image" else "application/octet-stream"
if not self._corp_id or not self._secret:
return "", WeComSendResult(ok=False, errcode=-1, errmsg="WECOM_CORP_ID/SECRET 未配置")
try:
@@ -240,8 +313,8 @@ class WeComAppClient:
with httpx.Client(timeout=max(self._timeout, 30.0)) as client:
resp = client.post(
url,
params={"access_token": token, "type": "file"},
files={"media": (filename, content, "application/octet-stream")},
params={"access_token": token, "type": kind},
files={"media": (filename, content, mime)},
)
data = resp.json()
except Exception as exc: # noqa: BLE001
@@ -250,7 +323,7 @@ class WeComAppClient:
errcode = int(data.get("errcode") or 0)
media_id = str(data.get("media_id") or "")
if errcode != 0 or not media_id:
logger.warning("企微上传文件失败 errcode=%s", errcode)
logger.warning("企微上传素材失败 type=%s errcode=%s", kind, errcode)
return "", WeComSendResult(
ok=False, errcode=errcode, errmsg=str(data.get("errmsg") or "upload_fail"), raw=data
)
+100 -9
View File
@@ -23,12 +23,31 @@ _MODE_WORDS = (
_LABEL_LINE = re.compile(
r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型/数量|车型数量|"
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(.+)$"
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"客户名称|货值|是否为危险品)\s*[::]\s*(.+)$"
)
_LABEL_INLINE = re.compile(
r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型/数量|车型数量|"
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(\S+)"
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"客户名称|货值|是否为危险品)\s*[::]\s*(\S+)"
)
# 群里协同标签,冒号可省略:「客户名称张三」「货值 10万」。长词优先。
_COLLAB_LABELS = (
"是否为危险品",
"商品海关编码",
"车型/数量",
"包装方式",
"客户名称",
"海关编码",
"贸易条款",
"货好时间",
"是否含油",
"是否含电",
"是否含磁",
"HS编码",
"货值",
"HS",
)
@@ -64,6 +83,23 @@ def parse_labeled_facts(text: str) -> dict[str, str]:
return facts
def _merge_spoken_collab(text: str, facts: dict[str, str]) -> dict[str, str]:
"""
私聊已经说到的协同项写进事实,进群才能预填。
不覆盖已有询价键。不拿去补问、不挡查价。
"""
from agent.schema.field_validate import harvest_oral_collab, normalize_facts
merged = dict(facts or {})
extra = dict(harvest_oral_collab(text))
extra.update(normalize_facts(harvest_collab_labels(text)))
for key, val in extra.items():
if val and not str(merged.get(key) or "").strip():
merged[key] = val
return merged
def extract_inquiry_snapshot(
text: str,
*,
@@ -80,11 +116,12 @@ def extract_inquiry_snapshot(
标签行覆盖 B 的同名字段。B 未出现的字段保持空,不本地猜港口。
件数/毛重/体积若原话已带 TMS 单位(件/KGS/CBM),空字段可机读补上。
"""
from agent.schema.field_validate import harvest_oral_measures, normalize_facts
from agent.schema.field_validate import harvest_oral_collab, harvest_oral_measures, normalize_facts
if injected_facts is not None:
mode = (injected_mode or detect_transport_mode(text) or "").upper()
facts = harvest_oral_measures(text, normalize_facts(dict(injected_facts)))
facts = _merge_spoken_collab(text, facts)
subtype = str(facts.get("运输类型") or "").strip()
return {
"business_line": mode,
@@ -109,6 +146,7 @@ def extract_inquiry_snapshot(
mode = mode or str(b_payload.get("business_line") or "")
source = "deepseek_b"
facts = harvest_oral_measures(text, normalize_facts(facts))
facts = _merge_spoken_collab(text, facts)
land_subtype = str((b_payload or {}).get("land_subtype") or facts.get("运输类型") or "").strip()
if land_subtype and not str(facts.get("运输类型") or "").strip():
facts["运输类型"] = land_subtype
@@ -123,37 +161,90 @@ def extract_inquiry_snapshot(
}
def harvest_collab_labels(text: str) -> dict[str, str]:
"""
群里协同标签:有无冒号都收。
「客户名称张三 包装方式编织袋 货值 10万」切成三项。
只说「纸箱」没有标签则不收。值取到下一个标签之前。
"""
raw = text or ""
hits: list[tuple[int, str]] = []
for label in _COLLAB_LABELS:
start = 0
while True:
pos = raw.find(label, start)
if pos < 0:
break
hits.append((pos, label))
start = pos + len(label)
if not hits:
return {}
hits.sort()
# 同一起点只留最长标签,避免 HS 抢 HS编码。
kept: list[tuple[int, str]] = []
for pos, label in hits:
if kept and kept[-1][0] == pos and len(label) <= len(kept[-1][1]):
continue
if kept and kept[-1][0] == pos:
kept[-1] = (pos, label)
continue
kept.append((pos, label))
out: dict[str, str] = {}
for i, (pos, label) in enumerate(kept):
begin = pos + len(label)
end = kept[i + 1][0] if i + 1 < len(kept) else len(raw)
chunk = raw[begin:end]
chunk = re.sub(r"^[\s::,,、]+", "", chunk)
chunk = re.sub(r"[\s,,、]+$", "", chunk)
if chunk:
out[label] = chunk
return out
def extract_collab_fields(
text: str,
*,
allow_b: bool = False,
injected: Optional[dict[str, str]] = None,
allowed_keys: Optional[tuple[str, ...]] = None,
) -> dict[str, str]:
"""
群里协同字段:标签「键:值」+ 口语含油/含电/含磁、货好时间。
群里协同字段:标签(冒号可省)+ 口语含油/含电/含磁/危险品、货好时间。
闲聊、报价金额不进这里。商品海关编码 / HS 折成 HS编码。
allow_b 保留给调用方;协同六项不走询价七项的 B 合同。
allowed_keys 传入则只返回本单该有的项。不传则海运六项 ∪ 陆运协同键,给意图分类。
闲聊、只说「纸箱」不进这里。商品海关编码 / HS 折成 HS编码。
"""
from agent.schema.field_validate import (
SEA_COLLAB_FIELDS,
ALL_COLLAB_KEYS,
harvest_oral_collab,
normalize_facts,
)
del allow_b
keys = tuple(allowed_keys) if allowed_keys is not None else ALL_COLLAB_KEYS
if injected:
return {
k: str(v).strip()
for k, v in normalize_facts(dict(injected)).items()
if k in SEA_COLLAB_FIELDS and str(v or "").strip()
if k in keys and str(v or "").strip()
}
labeled = normalize_facts(parse_labeled_facts(text))
labeled.update(normalize_facts(harvest_collab_labels(text)))
oral = harvest_oral_collab(text)
if "是否为危险品" in oral:
if allowed_keys is not None and "是否为危险品" not in keys:
oral.pop("是否为危险品", None)
elif allowed_keys is None:
raw = text or ""
if "普货" in raw and not any(
w in raw for w in ("危险", "非危", "有危", "含危", "是否为危险品")
):
oral.pop("是否为危险品", None)
merged = dict(oral)
merged.update({k: v for k, v in labeled.items() if v})
out: dict[str, str] = {}
for key in SEA_COLLAB_FIELDS:
for key in keys:
val = (merged.get(key) or "").strip()
if val:
out[key] = val
@@ -155,7 +155,25 @@ def send_air_brief_and_sheet(
if room and not view.get("collab_chat_id"):
view["collab_chat_id"] = room
facts = dict(view.get("facts") or {})
from agent.schema.field_validate import AIR_COLLAB_FIELDS, pick_collab_from_facts
seeded = pick_collab_from_facts(facts, AIR_COLLAB_FIELDS)
collab = dict(view.get("collab_facts") or {})
if seeded:
changed = False
for k, v in seeded.items():
if v and not str(collab.get(k) or "").strip():
collab[k] = v
changed = True
if changed:
view["collab_facts"] = collab
patcher = getattr(getattr(flow, "ledger", None), "patch_collab_facts", None)
if callable(patcher):
patcher(
work_order_no=no,
facts=seeded,
sender_id=str(view.get("sales_wecom_id") or view.get("salesWecomId") or ""),
)
view["collab_facts"] = collab
view_q = dict(view.get("quote") or {})
override = dict(quote_override or {})
+52 -25
View File
@@ -17,9 +17,10 @@ from agent.schema.field_validate import (
AIR_COLLAB_FIELDS,
AIR_REQUIRED_FIELDS,
LAND_DISPLAY,
SEA_COLLAB_DISPLAY,
SEA_COLLAB_FIELDS,
SEA_REQUIRED_FIELDS,
collab_display_name,
collab_fields_for_ticket,
display_name,
)
from agent.schema.land_options import option_prompt as _land_option_prompt
@@ -101,6 +102,30 @@ _CONTINUE_HINT = (
ASK_TRANSPORT = "请补充运输方式:海运 / 空运 / 陆运。"
# 旧一句只问运输类型;销售要一次看到类型、线路、分类。残留调用也走完整清单。
ASK_LAND_SUBTYPE = _land_option_prompt(current={})
def land_sample_image_payload() -> dict[str, Any]:
"""
填写样例表图片出站。
走企微 image,不走文件气泡。路径随发布包,出站线程再读字节。
禁止在回调线程上传。
"""
from agent.schema.land_options import sample_image_path
path = sample_image_path()
return {
"msgtype": "image",
"filename": "填写样例.png",
"filepath": str(path),
}
def land_sample_image_fallback() -> str:
"""图片失败时回文字样例,避免销售什么都看不到。"""
from agent.schema.land_options import sample_fallback_text
return sample_fallback_text()
FIRST_CHAT = "你好,我是询价机器人。请直接描述客户需求,例如「上海到洛杉矶空运,3 件 180kg,下周三前到」,我会解析需求并查询 TMS 标准报价。"
ROUTE_MODEL_FAILED = "刚才没听清,请再说一次:是新询价,还是要接着办哪张工单?"
LAND_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。"
@@ -111,9 +136,8 @@ LAND_TMS_RETRYING = "正在重新查询TMS报价。工单:{wo}"
_LAND_LCL_KEYS = ("起运港", "目的港", "品名", "体积", "毛重", "报价日期")
_LAND_FTL_KEYS = ("起运港", "目的港", "品名", "车型/数量", "报价日期")
_LAND_HK_FULL_KEYS = ("起运港", "目的港", "品名", "报价日期")
_LAND_LCL_TYPES = frozenset({"国内运输拼车", "跨境集拼"})
_LAND_FTL_TYPES = frozenset({"国内运输整车", "跨境整车"})
_LAND_CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港"})
_LAND_FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港零担/集拼"})
_LAND_CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港整车", "中港零担/集拼"})
_LAND_EXAMPLES = {
"起运港": "广州",
"目的港": "深圳",
@@ -127,11 +151,8 @@ _LAND_EXAMPLES = {
def _land_cargo_keys(land_type: str, facts: dict[str, str] | None = None) -> tuple[str, ...]:
"""当前运输类型要展示的货物内部键。中港整车不要车型/数量。"""
if land_type == "中港":
load = str((facts or {}).get("中港装载方式") or "").strip()
if load == "零担/集拼":
return _LAND_FTL_KEYS
# 整车或还没选定装载:卡片/补问都不出现车型/数量
_ = facts
if land_type == "中港整车":
return _LAND_HK_FULL_KEYS
if land_type in _LAND_FTL_TYPES:
return _LAND_FTL_KEYS
@@ -142,8 +163,9 @@ def land_confirm_card(facts: dict[str, str] | None) -> str:
"""
确定查价前的核对卡。没有工单号。
行列出运输类型/线路/分类(中港再加装载)和该类型货物字段。
行列出运输类型/线路/分类和该类型货物字段。
国内不出现通关口岸;跨境/中港通关口岸非必填,没填留空行。
不再单独列出中港装载方式。
"""
src = dict(facts or {})
land_type = (src.get("运输类型") or "").strip()
@@ -152,8 +174,6 @@ def land_confirm_card(facts: dict[str, str] | None) -> str:
f"线路类别(必填):{(src.get('线路类别') or '').strip()}",
f"运输分类(必填):{(src.get('运输分类') or '').strip()}",
]
if land_type == "中港":
lines.append(f"中港装载方式(必填):{(src.get('中港装载方式') or '').strip()}")
for key in _land_cargo_keys(land_type, src):
label = LAND_DISPLAY.get(key, key)
val = (src.get(key) or "").strip()
@@ -170,6 +190,11 @@ def land_group_no_staff() -> str:
return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的陆运产品岗后再试。"
def land_group_pair_rejected() -> str:
"""类型+线路不在填写样例里,禁止拉群。"""
return "拉群失败,请排查填写内容,并重新发起工单。"
def ask_land_fields(
*,
facts: dict[str, str] | None = None,
@@ -182,7 +207,7 @@ def ask_land_fields(
报价日期不进待补充。
"""
src = dict(facts or {})
skip = {"报价日期", "运输类型", "线路类别", "运输分类", "中港装载方式"}
skip = {"报价日期", "运输类型", "线路类别", "运输分类"}
missing = [k for k in missing_keys if k and k not in skip]
land_type = (land_subtype or src.get("运输类型") or "").strip()
lines = ["请确认并补充以下信息:", "运输方式:陆运"]
@@ -373,10 +398,6 @@ def inquiry_field_lines(
f"线路类别:{(facts.get('线路类别') or '-').strip() or '-'}",
f"运输分类:{(facts.get('运输分类') or '-').strip() or '-'}",
]
if land_type == "中港":
lines.append(
f"中港装载方式:{(facts.get('中港装载方式') or '-').strip() or '-'}"
)
for key in _land_cargo_keys(land_type, facts):
label = LAND_DISPLAY.get(key, key)
val = (facts.get(key) or "-").strip() or "-"
@@ -1151,8 +1172,10 @@ def sea_group_brief(
lines.append("")
lines.append("协同补充字段:")
collab = dict(collab_facts or {})
for key in SEA_COLLAB_FIELDS:
label = SEA_COLLAB_DISPLAY.get(key, key)
line = "LAND" if mode == "陆运" else "SEA"
collab_keys = collab_fields_for_ticket(facts, line) or SEA_COLLAB_FIELDS
for key in collab_keys:
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 {}
@@ -1298,14 +1321,10 @@ def scheme_horizontal_rows(
def _land_scheme_type_line(facts: dict[str, str]) -> str:
"""方案卡「类型」格:运输类型、中港装载、线路类别、运输分类。"""
"""方案卡「类型」格:运输类型、线路类别、运输分类。"""
src = facts or {}
land_type = (src.get("运输类型") or "-").strip() or "-"
bits = [land_type]
if land_type == "中港":
load = (src.get("中港装载方式") or "").strip()
if load:
bits.append(load)
route = (src.get("线路类别") or "-").strip() or "-"
klass = (src.get("运输分类") or "-").strip() or "-"
bits.extend([route, klass])
@@ -1576,7 +1595,15 @@ def group_quote_not_recognized() -> str:
return "没有识别到报价,请产品再发一次价格或 Excel/PDF"
def group_fields_not_recognized() -> str:
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 "听到了。请直接说贸易条款、货好时间、商品海关编码、是否含油/含电/含磁。"
+56 -8
View File
@@ -1,7 +1,7 @@
"""
陆运文字询价确定性流程。
本文件职责:选项联动 → 按类型补货物 → 无工单号核对卡 →(确定后)建单查 TMS。
本文件职责:三项选项各自必填(不联动)→ 按类型补货物 → 无工单号核对卡 →(确定后)建单查 TMS。
查价后半段复用海运拉群/报价单/成交,拉人按线路类别,禁止陆运岗全员。
禁止:字段齐了就建单;直连 TMS;把海运「出卡即查价」改掉。
调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。
@@ -23,6 +23,8 @@ from agent.schema.land_options import (
harvest_land_options,
looks_like_confirm,
option_prompt,
option_prompt_needs_sample,
sample_pair_allowed,
validate_land_options,
)
from agent.schema.tms_land_query import assemble_land_query
@@ -112,7 +114,7 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
subtype = str(snap.get("land_subtype") or facts.get("运输类型") or "").strip()
if subtype and not str(facts.get("运输类型") or "").strip():
facts["运输类型"] = subtype
option_keys = ("运输类型", "线路类别", "运输分类", "中港装载方式")
option_keys = ("运输类型", "线路类别", "运输分类")
base = dict(sess.facts) if sess else {}
cargo = {k: v for k, v in facts.items() if k not in option_keys}
merged = dict(base)
@@ -184,6 +186,23 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
sess=sess,
)
def _reply_land_options(
self,
reply: ReplyFn,
facts: dict[str, str],
*,
message: str = "",
) -> None:
"""
选项补问:先发文字清单,需要样例时再发表格图。
图片失败由出站回退文字样例。不在本方法上传素材。
"""
body = (message or "").strip() or option_prompt(current=facts)
reply(body)
if option_prompt_needs_sample(current=facts):
reply(copy.land_sample_image_fallback(), copy.land_sample_image_payload())
def _confirm_or_hold(
self,
*,
@@ -221,7 +240,7 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
"""
销售确定之后才建陆运工单并查 TMS。
核对卡阶段没有工单号。运输分类、中港装载方式写入工单 facts,不进入 assemble 出站。
核对卡阶段没有工单号。运输分类写入工单 facts,不进入 assemble 出站。
"""
import uuid
@@ -363,10 +382,10 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
facts=merged, business_line="LAND", land_subtype=land_type
)
cargo = dict(check["facts"])
cargo.update({k: merged[k] for k in ("运输类型", "线路类别", "运输分类", "中港装载方式") if merged.get(k)})
cargo.update({k: merged[k] for k in ("运输类型", "线路类别", "运输分类") if merged.get(k)})
tms = assemble_land_query(cargo)
cargo = dict(tms.get("facts") or cargo)
cargo.update({k: merged[k] for k in ("运输类型", "线路类别", "运输分类", "中港装载方式") if merged.get(k)})
cargo.update({k: merged[k] for k in ("运输类型", "线路类别", "运输分类") if merged.get(k)})
missing = list(check.get("missing") or [])
for key in tms.get("missing") or []:
if key not in missing and key not in {"运输类型", "线路类别"}:
@@ -412,7 +431,11 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
return "wait_confirm"
if state.get("stage") == "options_error":
err = (state["opt"].get("errors") or [{}])[0]
reply(str(err.get("message") or option_prompt(current=facts)))
self._reply_land_options(
reply,
facts,
message=str(err.get("message") or ""),
)
self._save(
FlowSession(
sender_id=sender_id,
@@ -427,12 +450,11 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
self._clear_parked_confirm(sender_id)
return "need_land_options"
if state["stage"] == "options_missing":
body = option_prompt(current=facts)
logger.info(
"land.options_missing missing=%s",
(state.get("opt") or {}).get("missing") or [],
)
reply(body)
self._reply_land_options(reply, facts)
self._save(
FlowSession(
sender_id=sender_id,
@@ -574,6 +596,32 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
clear_pending_land_confirm(sender_id)
def _pull_collab_group(
self,
sess: FlowSession,
reply: ReplyFn,
card_meta: Optional[dict[str, Any]] = None,
) -> str:
"""
点「拉产品进群协同」:先核填写样例,再按线路拉人建群。
类型+线路对不上样例:不建群,告诉销售重新开单。按钮保持可点。
对得上则走海运同一套建群。
"""
facts = dict(sess.facts or {})
land_type = str(facts.get("运输类型") or "").strip()
route = str(facts.get("线路类别") or "").strip()
if not sample_pair_allowed(land_type, route):
logger.info(
"land.group.reject pair type=%s route=%s wo=%s",
land_type or "-",
route or "-",
sess.work_order_no or "-",
)
reply(copy.land_group_pair_rejected())
return sess.phase or "wait_collab"
return super()._pull_collab_group(sess, reply, card_meta=card_meta)
def _match_sea_staff(self, sess: FlowSession) -> list[dict[str, Any]]:
"""按本单线路类别整词匹配产品·陆运,禁止全员。"""
matcher = getattr(self._ledger, "match_land_staff", None)
@@ -24,6 +24,10 @@ _COLLAB_LABEL_MARKERS: tuple[tuple[str, tuple[str, ...]], ...] = (
("是否含油", ("是否含油", "has_oil")),
("是否含电", ("是否含电", "has_battery")),
("是否含磁", ("是否含磁", "has_magnet")),
("客户名称", ("客户名称", "customer_name")),
("货值", ("货值", "cargo_value")),
("是否为危险品", ("是否为危险品", "is_dangerous")),
("包装方式", ("包装方式", "包装类型")),
)
+87 -18
View File
@@ -14,7 +14,12 @@ from typing import Any, Optional
from agent.llm.extract_text import extract_collab_fields, extract_product_quote
from agent.policy.quote_overlay import overlay_labeled_facts
from agent.policy import inquiry_copy as copy
from agent.schema.field_validate import SEA_COLLAB_DISPLAY, SEA_COLLAB_FIELDS
from agent.schema.field_validate import (
SEA_COLLAB_DISPLAY,
SEA_COLLAB_FIELDS,
collab_display_name,
collab_fields_for_ticket,
)
# 主账填表按英文码取格子;中文键必须同时带上,否则货好时间/条款写不进表。
_COLLAB_FILL_ALIASES = {
@@ -24,6 +29,10 @@ _COLLAB_FILL_ALIASES = {
"是否含油": ("是否含油", "has_oil"),
"是否含电": ("是否含电", "has_battery"),
"是否含磁": ("是否含磁", "has_magnet"),
"客户名称": ("客户名称", "customer_name"),
"货值": ("货值", "cargo_value"),
"是否为危险品": ("是否为危险品", "is_dangerous"),
"包装方式": ("包装方式", "包装类型"),
}
logger = logging.getLogger(__name__)
@@ -91,6 +100,24 @@ def ticket_no(ticket: object) -> str:
return str(ticket_view(ticket).get("work_order_no") or "").strip()
def _collab_keys_of(ticket: object, sess: Any = None) -> tuple[str, tuple[str, ...]]:
"""本单业务线 + 允许写入的协同内部键。"""
view = ticket_view(ticket)
facts = dict(view.get("facts") or {})
if sess is not None and getattr(sess, "facts", None):
facts.update(dict(sess.facts or {}))
line = str(
view.get("business_line")
or view.get("businessLine")
or (getattr(sess, "business_line", "") if sess is not None else "")
or "SEA"
).strip().upper() or "SEA"
keys = collab_fields_for_ticket(facts, line)
if not keys and line != "LAND":
keys = SEA_COLLAB_FIELDS
return line, keys
def is_terminal(ticket: object) -> bool:
return ticket_status(ticket) in TERMINAL
@@ -378,6 +405,13 @@ def send_brief_and_sheet(
quote = dict(override or sess_q or view_q or tms or manual)
sales_name, products, mention_ids = resolve_group_mentions(flow, ticket, sess)
has_tms = bool(tms.get("total") or _quote_fee_rows(tms))
line = str(
view.get("business_line")
or view.get("businessLine")
or (sess.business_line if sess is not None else "")
or "SEA"
).strip().upper()
mode = "陆运" if line == "LAND" else "海运"
brief = copy.sea_group_brief(
work_order_no=no,
facts=facts,
@@ -388,9 +422,16 @@ def send_brief_and_sheet(
has_price=has_tms,
tms_quote=tms,
product_quote=manual,
transport_mode=mode,
)
send_group_text(client, chat_id, brief, mention_userids=mention_ids)
send_filled_sheet(flow=flow, ticket=ticket, sess=sess, quote_override=quote)
send_filled_sheet(
flow=flow,
ticket=ticket,
sess=sess,
quote_override=quote,
business_line=line,
)
return "brief_and_sheet"
@@ -400,7 +441,7 @@ def send_filled_sheet(
ticket: object,
sess: Any = None,
quote_override: Optional[dict[str, Any]] = None,
business_line: str = "SEA",
business_line: str = "",
chat_id: str = "",
) -> dict[str, Any]:
"""
@@ -429,7 +470,7 @@ def send_filled_sheet(
flow=flow,
chat_id=room,
work_order_no=no,
facts=_facts_for_fill(facts, collab),
facts=_facts_for_fill(facts, collab, business_line=line or "SEA"),
quote=quote,
sales_id=str(
(sess.sender_id if sess else "")
@@ -441,15 +482,20 @@ def send_filled_sheet(
)
def _collab_value(collab: dict[str, str], key: str) -> str:
"""协同六项:内部键、对外展示名都能取到。"""
def _collab_value(collab: dict[str, str], key: str, business_line: str = "SEA") -> str:
"""协同项:内部键、对外展示名都能取到。"""
src = dict(collab or {})
val = str(src.get(key) or "").strip()
if val:
return val
label = SEA_COLLAB_DISPLAY.get(key, "")
label = collab_display_name(key, business_line)
if label:
val = str(src.get(label) or "").strip()
if val:
return val
sea_label = SEA_COLLAB_DISPLAY.get(key, "")
if sea_label:
val = str(src.get(sea_label) or "").strip()
return val
@@ -466,15 +512,22 @@ def _oil_elec_mag_text(collab: dict[str, str]) -> str:
return f"油:{oil or '-'};电:{elec or '-'};磁:{mag or '-'}"
def _facts_for_fill(facts: dict[str, str], collab: dict[str, str]) -> dict[str, str]:
def _facts_for_fill(
facts: dict[str, str],
collab: dict[str, str],
business_line: str = "SEA",
) -> dict[str, str]:
"""
询价七项 + 协同六项。中文键和填表英文码一起带上。
询价字段 + 本单协同项。中文键和填表英文码一起带上。
主账 writeFactsByLabel 只认 cargo_ready_date / trade_terms,只传「货好时间」会空着。
模板没有格子的新项照样带上,overlay 找不到标签就跳过。
"""
merged = dict(facts or {})
src = dict(collab or {})
for key in SEA_COLLAB_FIELDS:
val = _collab_value(src, key)
line = (business_line or "SEA").strip().upper() or "SEA"
keys = collab_fields_for_ticket(facts, line) or SEA_COLLAB_FIELDS
for key in keys:
val = _collab_value(src, key, line)
if not val:
continue
for alias in _COLLAB_FILL_ALIASES.get(key, (key,)):
@@ -549,7 +602,7 @@ def apply_collab_update(
sender_id: str,
sess: Any = None,
) -> str:
"""补协同六项。终态要说明原因。"""
"""补本单协同项。终态要说明原因。不该有的项当没补到。"""
ticket = hydrate_ticket(flow, ticket)
no = ticket_no(ticket)
chat_id = str((sess.collab_chat_id if sess else "") or ticket_view(ticket).get("collab_chat_id") or "")
@@ -557,11 +610,21 @@ def apply_collab_update(
if is_terminal(ticket):
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
return "ticket_locked"
fields = extract_collab_fields(text, allow_b=True)
line, keys = _collab_keys_of(ticket, sess)
fields = extract_collab_fields(text, allow_b=True, allowed_keys=keys)
if not fields:
send_group_text(client, chat_id, copy.group_fields_not_recognized())
send_group_text(
client,
chat_id,
copy.group_fields_not_recognized(keys=keys or None, business_line=line),
)
return "collab_not_recognized"
flow.apply_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
flow.apply_collab_facts(
work_order_no=no,
facts=fields,
sender_id=sender_id,
allowed_keys=keys,
)
if sess is not None:
merged = dict(sess.collab_facts)
merged.update(fields)
@@ -603,9 +666,15 @@ def apply_product_quote(
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"
fields = extract_collab_fields(text)
fields = extract_collab_fields(text, allowed_keys=_collab_keys_of(ticket, sess)[1])
if fields:
flow.apply_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
line, keys = _collab_keys_of(ticket, sess)
flow.apply_collab_facts(
work_order_no=no,
facts=fields,
sender_id=sender_id,
allowed_keys=keys,
)
if sess is not None:
merged = dict(sess.collab_facts)
merged.update(fields)
@@ -772,7 +841,7 @@ def _persist_quote_sheet(
result = renderer(
work_order_no=no,
business_line=line,
facts=_facts_for_fill(facts, collab),
facts=_facts_for_fill(facts, collab, business_line=line or "SEA"),
quote=quote,
format="xlsx",
sales_wecom_id=str(
+21 -1
View File
@@ -514,6 +514,24 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
product_ids=product_ids,
)
from agent.schema.field_validate import collab_fields_for_ticket, pick_collab_from_facts
line = (sess.business_line or "SEA").upper()
keys = collab_fields_for_ticket(sess.facts, line)
seeded = pick_collab_from_facts(sess.facts, keys)
if seeded:
merged = dict(sess.collab_facts or {})
for k, v in seeded.items():
if v and not str(merged.get(k) or "").strip():
merged[k] = v
sess.collab_facts = merged
self.apply_collab_facts(
work_order_no=no,
facts=seeded,
sender_id=sess.sender_id,
allowed_keys=keys,
)
brief = copy.sea_group_brief(
work_order_no=no,
facts=sess.facts,
@@ -638,6 +656,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
work_order_no: str,
facts: dict[str, str],
sender_id: str,
allowed_keys: tuple[str, ...] | None = None,
) -> dict[str, Any]:
"""
群里静默写入协同字段。不改六态、不回复、不出报价版本。
@@ -647,10 +666,11 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
from agent.schema.field_validate import SEA_COLLAB_FIELDS
no = (work_order_no or "").strip()
keys = tuple(allowed_keys) if allowed_keys is not None else SEA_COLLAB_FIELDS
cleaned = {
k: str(v).strip()
for k, v in (facts or {}).items()
if k in SEA_COLLAB_FIELDS and str(v or "").strip()
if k in keys and str(v or "").strip()
}
if not no or not cleaned:
return {"ok": False, "updated": []}
+160 -11
View File
@@ -64,6 +64,90 @@ AIR_COLLAB_FIELDS = ("是否含电", "是否含磁")
SEA_COLLAB_DISPLAY = {
"HS编码": "商品海关编码",
}
# 陆运摘要标题:海关编码,不要写成海运的商品海关编码。
LAND_COLLAB_DISPLAY = {
"HS编码": "海关编码",
}
# 填写样例允许拉群的类型+线路 → 协同内部键(顺序与规格一致)。
_LAND_COLLAB_BY_PAIR: dict[tuple[str, str], tuple[str, ...]] = {
("国内运输拼车", "国内长途/零担"): (
"客户名称",
"包装方式",
"货值",
"是否为危险品",
),
("国内运输拼车", "国内城配/拖车/打包"): (
"客户名称",
"包装方式",
"货值",
"是否为危险品",
),
("国内运输整车", "国内长途/零担"): (
"客户名称",
"货值",
"包装方式",
"是否为危险品",
),
("国内运输整车", "国内城配/拖车/打包"): (
"客户名称",
"货值",
"包装方式",
"是否为危险品",
),
("跨境集拼", "中港/中亚/中欧"): (
"客户名称",
"HS编码",
"贸易条款",
"包装方式",
"是否为危险品",
),
("跨境集拼", "东南亚"): (
"客户名称",
"HS编码",
"贸易条款",
"包装方式",
"是否为危险品",
),
("跨境整车", "中港/中亚/中欧"): (
"客户名称",
"HS编码",
"贸易条款",
"货值",
"包装方式",
"是否为危险品",
),
("跨境整车", "东南亚"): (
"客户名称",
"HS编码",
"贸易条款",
"货值",
"包装方式",
"是否为危险品",
),
("中港整车", "中港/中亚/中欧"): (
"客户名称",
"HS编码",
"车型/数量",
"贸易条款",
"货值",
"包装方式",
"是否为危险品",
),
("中港零担/集拼", "中港/中亚/中欧"): (
"客户名称",
"HS编码",
"贸易条款",
"包装方式",
"是否为危险品",
),
}
# 意图分类用:海运六项 ∪ 陆运全部协同键。
ALL_COLLAB_KEYS = tuple(
dict.fromkeys(
list(SEA_COLLAB_FIELDS)
+ ["客户名称", "包装方式", "货值", "是否为危险品", "车型/数量"]
)
)
# 口语里已经带上 TMS 文档规定的单位时,补进空字段。
# 只认「数字+单位」,不猜港口,不把长宽高换算成立方。
@@ -102,6 +186,9 @@ _ALIASES = {
"HS CODE": "HS编码",
"商品海关编码": "HS编码",
"海关编码": "HS编码",
"客户名称": "客户名称",
"货值": "货值",
"是否为危险品": "是否为危险品",
}
@@ -224,11 +311,75 @@ def harvest_oral_measures(text: str, facts: dict[str, str] | None = None) -> dic
return harvest_named_air_route(raw, merged)
def land_collab_fields(land_type: str, route: str) -> tuple[str, ...]:
"""
本单陆运协同内部键,顺序与规格一致。
对不上填写样例返回空元组,调用方不得按海运六项去列。
海关编码内部仍是 HS编码。副作用:无。
"""
from agent.schema.land_options import canonicalize_route, canonicalize_transport_type
t = canonicalize_transport_type(land_type) or (land_type or "").strip()
r = canonicalize_route(route) or (route or "").strip()
return _LAND_COLLAB_BY_PAIR.get((t, r), ())
def collab_fields_for_ticket(facts: dict[str, str] | None, business_line: str) -> tuple[str, ...]:
"""
按业务线取出群摘要 / 写入允许的协同键。
LAND 看事实里的运输类型+线路类别;SEA/AIR 用固定名单。
"""
line = (business_line or "").strip().upper()
if line == "AIR":
return AIR_COLLAB_FIELDS
if line == "LAND":
src = dict(facts or {})
return land_collab_fields(
str(src.get("运输类型") or ""),
str(src.get("线路类别") or ""),
)
return SEA_COLLAB_FIELDS
def collab_display_name(key: str, business_line: str) -> str:
"""协同项对外标题。陆运 HS编码 → 海关编码;海运 → 商品海关编码。"""
line = (business_line or "").strip().upper()
if line == "LAND":
return LAND_COLLAB_DISPLAY.get(key, key)
return SEA_COLLAB_DISPLAY.get(key, key)
def pick_collab_from_facts(
facts: dict[str, str] | None,
keys: tuple[str, ...],
) -> dict[str, str]:
"""
从私聊事实里取出已有协同键,供进群预填。
只取 keys 里非空值。海关编码/商品海关编码经 normalize 折成 HS编码。
不在 keys 的询价项(如跨境整车车型/数量)不会被取出。
不覆盖调用方已有协同值;本函数只返回候选。
"""
allowed = set(keys or ())
if not allowed:
return {}
src = normalize_facts(facts)
out: dict[str, str] = {}
for key in keys:
val = (src.get(key) or "").strip()
if val:
out[key] = val
return out
def harvest_oral_collab(text: str) -> dict[str, str]:
"""
群里口语协同三项:含油/含电/含磁、货好时间。
群里口语协同:含油/含电/含磁、货好时间、是否为危险品。
只认原话里已经说死的词(不含电、随时可提),不猜贸易条款和海关编码。
只认原话里已经说死的词(不含电、随时可提、普货),不猜贸易条款和海关编码。
危险品:否词(不是危险品/非危/普货)先于是词,避免「不是危险品」收成是。
"""
raw = text or ""
out: dict[str, str] = {}
@@ -236,6 +387,11 @@ def harvest_oral_collab(text: str) -> dict[str, str]:
("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")),
("是否含油", ("不含油", "不带油", "无油"), ("含油", "带油")),
("是否含磁", ("不含磁", "不带磁", "无磁"), ("含磁", "带磁")),
(
"是否为危险品",
("不是危险品", "非危险品", "非危", "普货"),
("危险品", "有危", "含危"),
),
)
for key, no_words, yes_words in yes_no:
if any(w in raw for w in no_words):
@@ -279,7 +435,8 @@ def _pricing_required_keys(
land_subtype: str,
facts: dict[str, str] | None = None,
) -> list[str]:
"""当前激活的首次查价内部键列表。中港再按装载方式收窄。"""
"""当前激活的首次查价内部键列表。中港整车/零担是两个运输类型。"""
_ = facts
line = (business_line or "").strip().upper()
pricing = contract.get("pricing_required") or {}
if line == "SEA":
@@ -291,14 +448,6 @@ def _pricing_required_keys(
block = land.get(land_subtype) or {}
if not block:
return []
if land_subtype == "中港":
from agent.schema.land_options import canonicalize_hk_load
load = canonicalize_hk_load(str((facts or {}).get("中港装载方式") or ""))
by_load = block.get("by_hk_load") or {}
picked = by_load.get(load) if load else None
if isinstance(picked, list):
return list(picked)
return list(block.get("always") or [])
return []
+142 -153
View File
@@ -1,61 +1,64 @@
"""
陆运选项规则:运输类型、线路类别、运输分类、中港装载方式。
陆运选项规则:运输类型、线路类别、运输分类。
本文件职责:从销售原话收取标准项、校验联动、生成补问清单、判断「确定查价」意图。
联动错误只纠正那一项能选什么,不整单作废。
禁止:改六态;发明枚举;把选项当货物字段。
本文件职责:从销售原话收取标准项、校验是否在名单内、生成补问清单(含填写样例表)、判断「确定查价」意图。
三项各自必填,组合不拦截查价。拉群必须对上填写样例。运输分类句里的「中港/东南亚」不得冒充类型或线路。
禁止:改六态;发明枚举;把选项当货物字段;再要单独的中港装载方式。
线程:纯函数,无共享可变状态,可在 Worker 槽调用。
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
# 与后台员工管理陆运「负责线路」同一套字,禁止改字。
TRANSPORT_TYPES = (
"国内运输拼车",
"国内运输整车",
"跨境集拼",
"跨境整车",
"中港",
"中港整车",
"中港零担/集拼",
)
DOMESTIC_ROUTES = ("国内长途/零担", "国内城配/拖车/打包")
CROSS_ROUTES = ("中港/中亚/中欧", "东南亚")
ROUTES_BY_TYPE = {
"国内运输拼车": DOMESTIC_ROUTES,
"国内运输整车": DOMESTIC_ROUTES,
"跨境集拼": CROSS_ROUTES,
"跨境整车": CROSS_ROUTES,
"中港": CROSS_ROUTES,
}
CROSS_CLASSES = (
"普件,中港、东南亚常用线路/常规车型",
"普件,中亚、中欧、东南亚涉及国内+国外段",
"普件,中亚、中欧、东南亚涉及境外段清派等特殊要求",
ROUTES = (
"国内长途/零担",
"国内城配/拖车/打包",
"中港/中亚/中欧",
"东南亚",
)
LAND_CLASSES = (
"普件(东南亚、中港常用线路/常规车型)",
"普件(东南亚、中亚、中欧涉及国内+国外段)",
"普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)",
"急件",
)
DOMESTIC_CLASSES = (
"普件,常用线路/常规车型",
"普件,非常用线路/临时线路/特种车型",
"急件",
)
CLASSES_BY_ROUTE = {
"中港/中亚/中欧": CROSS_CLASSES,
"东南亚": CROSS_CLASSES,
"国内长途/零担": DOMESTIC_CLASSES,
"国内城配/拖车/打包": DOMESTIC_CLASSES,
}
HK_LOADS = ("整车", "零担/集拼")
# 口语别名 → 标准运输类型。长词优先,避免「拼车」误伤「整车」。
# 填写样例,同时作为拉群门禁:对不上的类型+线路可以查价,但不能拉群。
SAMPLE_PAIRS = (
("国内运输拼车", "国内长途/零担"),
("国内运输拼车", "国内城配/拖车/打包"),
("国内运输整车", "国内长途/零担"),
("国内运输整车", "国内城配/拖车/打包"),
("跨境集拼", "中港/中亚/中欧"),
("跨境集拼", "东南亚"),
("跨境整车", "中港/中亚/中欧"),
("跨境整车", "东南亚"),
("中港整车", "中港/中亚/中欧"),
("中港零担/集拼", "中港/中亚/中欧"),
)
# 口语别名 → 标准运输类型。长词优先。禁止把单独「中港」收成类型。
_TYPE_ALIASES = (
("国内运输拼车", "国内运输拼车"),
("国内运输整车", "国内运输整车"),
("中港零担/集拼", "中港零担/集拼"),
("中港零担", "中港零担/集拼"),
("中港集拼", "中港零担/集拼"),
("中港整车", "中港整车"),
("国内拼车", "国内运输拼车"),
("国内整车", "国内运输整车"),
("跨境集拼", "跨境集拼"),
("跨境整车", "跨境整车"),
("中港", "中港"),
)
_ROUTE_ALIASES = (
@@ -67,20 +70,9 @@ _ROUTE_ALIASES = (
("东南亚", "东南亚"),
)
# 分类全称从长到短;「急件」单独出现才算急件,避免普件句被误收。
# 分类全称从长到短;「急件」单独出现才算急件。
_CLASS_CANON = tuple(
sorted(
{*(CROSS_CLASSES + DOMESTIC_CLASSES)},
key=len,
reverse=True,
)
)
_HK_LOAD_ALIASES = (
("零担/集拼", "零担/集拼"),
("零担", "零担/集拼"),
("集拼", "零担/集拼"),
("整车", "整车"),
sorted(LAND_CLASSES, key=len, reverse=True)
)
_CONFIRM_PHRASES = ("可以查了", "确定", "确认", "查价")
@@ -92,6 +84,7 @@ def canonicalize_transport_type(raw: str) -> str:
把销售原话收成标准运输类型。
参数:任意一句。返回 TRANSPORT_TYPES 之一或空串。
只说「中港」不算选完,避免分类句里的中港把类型改掉。
副作用:无。先匹配更长别名。
"""
text = (raw or "").strip()
@@ -104,7 +97,7 @@ def canonicalize_transport_type(raw: str) -> str:
def canonicalize_route(raw: str) -> str:
"""把原话收成标准线路类别;对不上返回空串。"""
"""把原话收成标准线路类别;对不上返回空串。不把单独「中港」收成中港/中亚/中欧。"""
text = (raw or "").strip()
if not text:
return ""
@@ -136,14 +129,25 @@ def canonicalize_land_class(raw: str) -> str:
return ""
def sample_pair_allowed(land_type: str, route: str) -> bool:
"""
类型+线路是否在填写样例里。
查价不调用本函数;点「拉产品进群协同」时必须为真,否则不建群。
入参先收成标准项。副作用:无。
"""
t = canonicalize_transport_type(land_type) or (land_type or "").strip()
r = canonicalize_route(route) or (route or "").strip()
return (t, r) in SAMPLE_PAIRS
def canonicalize_hk_load(raw: str) -> str:
"""中港装载方式:整车或零担/集拼。"""
text = (raw or "").strip()
if not text:
return ""
for needle, canon in _HK_LOAD_ALIASES:
if needle in text:
return canon
"""
旧中港装载方式不再收取。
保留函数名避免旧调用处报缺符号;恒返回空串。新单用运输类型「中港整车」「中港零担/集拼」。
"""
_ = raw
return ""
@@ -151,9 +155,9 @@ def harvest_land_options(text: str, current: dict[str, str] | None = None) -> di
"""
从一句原话收取陆运选项,合并到已有值上。
先收运输分类并把命中句从原文挖掉,再收类型/线路,避免分类里的东南亚、中港冒充选项。
已有非空项默认保留;本句明确写出另一标准项时才覆盖。
已有值对不上标准枚举则丢掉,避免模型把「陆运」写成运输类型后整张清单重问。
不改货物字段。返回新 dict,不修改 current。
已有值对不上标准枚举则丢掉。不改货物字段。返回新 dict,不修改 current。
"""
merged = {
k: str(v).strip()
@@ -162,23 +166,22 @@ def harvest_land_options(text: str, current: dict[str, str] | None = None) -> di
}
_keep_canonical_options(merged)
blob = text or ""
found_type = canonicalize_transport_type(blob)
found_route = canonicalize_route(blob)
found_class = canonicalize_land_class(blob)
found_load = canonicalize_hk_load(blob)
remainder = blob.replace(found_class, " ") if found_class else blob
found_type = canonicalize_transport_type(remainder)
found_route = canonicalize_route(remainder)
if found_type:
merged["运输类型"] = found_type
if found_route:
merged["线路类别"] = found_route
if found_class:
merged["运输分类"] = found_class
if found_load:
merged["中港装载方式"] = found_load
merged.pop("中港装载方式", None)
return merged
def _keep_canonical_options(merged: dict[str, str]) -> None:
"""只留能对上枚举的选项;模型胡写的「陆运」不能冒充运输类型。"""
"""只留能对上枚举的选项;模型胡写的「陆运」不能冒充运输类型。旧中港两项作废。"""
t = canonicalize_transport_type(merged.get("运输类型") or "")
if t:
merged["运输类型"] = t
@@ -194,138 +197,124 @@ def _keep_canonical_options(merged: dict[str, str]) -> None:
merged["运输分类"] = c
else:
merged.pop("运输分类", None)
load = canonicalize_hk_load(merged.get("中港装载方式") or "")
if load:
merged["中港装载方式"] = load
else:
merged.pop("中港装载方式", None)
merged.pop("中港装载方式", None)
def validate_land_options(opts: dict[str, str]) -> dict[str, Any]:
"""
校验选项是否齐全且联动正确。
校验三项是否都在名单内。组合不拦截。
返回 ok / missing / errors。errors 每项 {field, message},只纠正错的那一项。
中港缺装载方式进 missing,不改货物字段。
返回 ok / missing / errors。对不上名单的项当缺项,不写联动纠正句。
副作用:无。
"""
src = {k: str(v).strip() for k, v in dict(opts or {}).items() if str(v).strip()}
t = canonicalize_transport_type(src.get("运输类型") or "") or src.get("运输类型") or ""
r = canonicalize_route(src.get("线路类别") or "") or src.get("线路类别") or ""
c = canonicalize_land_class(src.get("运输分类") or "") or src.get("运输分类") or ""
load = canonicalize_hk_load(src.get("中港装载方式") or "") or src.get("中港装载方式") or ""
t = canonicalize_transport_type(src.get("运输类型") or "")
r = canonicalize_route(src.get("线路类别") or "")
c = canonicalize_land_class(src.get("运输分类") or "")
missing: list[str] = []
errors: list[dict[str, str]] = []
if t not in TRANSPORT_TYPES:
missing.append("运输类型")
t = ""
if t:
allowed_routes = ROUTES_BY_TYPE.get(t) or ()
if not r:
missing.append("线路类别")
elif r not in allowed_routes:
errors.append(
{
"field": "线路类别",
"message": "线路类别只能选:" + "、".join(f"「{x}」" for x in allowed_routes),
}
)
r = ""
elif r:
# 类型还没有,线路先记下但不判联动
pass
if r:
allowed_cls = CLASSES_BY_ROUTE.get(r) or ()
if not c:
missing.append("运输分类")
elif c not in allowed_cls:
errors.append(
{
"field": "运输分类",
"message": "运输分类只能选:" + "、".join(f"「{x}」" for x in allowed_cls),
}
)
c = ""
elif "运输分类" not in missing and not c:
if r not in ROUTES:
missing.append("线路类别")
r = ""
if c not in LAND_CLASSES:
missing.append("运输分类")
c = ""
if t == "中港":
if load not in HK_LOADS:
missing.append("中港装载方式")
ok = not missing and not errors
ok = not missing
return {
"ok": ok,
"missing": missing,
"errors": errors,
"errors": [],
"normalized": {
"运输类型": t,
"线路类别": r,
"运输分类": c,
"中港装载方式": load if t == "中港" else "",
"中港装载方式": "",
},
}
def _class_prompt_lines() -> list[str]:
lines = ["运输分类(必填):可选择回复,"]
for i, name in enumerate(LAND_CLASSES, start=1):
lines.append(f"{i}.{name}")
return lines
def option_prompt_needs_sample(*, current: dict[str, str]) -> bool:
"""运输类型或线路类别缺任一,就要发填写样例图。只缺分类不发。"""
src = {k: str(v).strip() for k, v in dict(current or {}).items() if str(v).strip()}
t = canonicalize_transport_type(src.get("运输类型") or "")
r = canonicalize_route(src.get("线路类别") or "")
return t not in TRANSPORT_TYPES or r not in ROUTES
def sample_image_path() -> Path:
"""填写样例表 PNG。和源码放一起,随 agent 发布包走。"""
return Path(__file__).resolve().parent.parent / "assets" / "land_sample_pairs.png"
def sample_fallback_text() -> str:
"""
图片发不出去时的文字兜底。
一行一种搭配,不再画框线。
"""
lines = ["填写样例"]
for i, (typ, route) in enumerate(SAMPLE_PAIRS, start=1):
lines.append(f"{i}.{typ} + {route}")
return "\n".join(lines)
def _sample_table() -> str:
"""兼容旧名:补问正文不再内嵌样例,只作图片失败兜底。"""
return sample_fallback_text()
def option_prompt(*, current: dict[str, str]) -> str:
"""
按已识别项生成补问清单。
全缺:一次列出类型、线路、分类、中港装载。
类型已知:不再问类型,只列合法线路/分类。
缺哪项列哪项。运输类型或线路类别缺任一则提示见图,样例图另发出站。只缺分类不附图。
不修改 current。
"""
src = {k: str(v).strip() for k, v in dict(current or {}).items() if str(v).strip()}
t = canonicalize_transport_type(src.get("运输类型") or "") or src.get("运输类型") or ""
r = canonicalize_route(src.get("线路类别") or "") or src.get("线路类别") or ""
t = canonicalize_transport_type(src.get("运输类型") or "")
r = canonicalize_route(src.get("线路类别") or "")
c = canonicalize_land_class(src.get("运输分类") or "")
need_type = t not in TRANSPORT_TYPES
need_route = r not in ROUTES
need_class = c not in LAND_CLASSES
lines: list[str] = []
if t not in TRANSPORT_TYPES:
lines.append("请一次选择:运输类型、线路类别、运输分类。")
if need_type and need_route and need_class:
lines.append("请一次选择:运输类型、线路类别、运输分类")
lines.append("")
lines.append("运输类型:")
lines.append("、".join(TRANSPORT_TYPES))
lines.append("运输类型(必填):可选择回复," + "、".join(TRANSPORT_TYPES))
lines.append("")
lines.append("线路类别(跟运输类型联动):")
lines.append("国内运输拼车 / 国内运输整车:" + "、".join(DOMESTIC_ROUTES))
lines.append("跨境集拼 / 跨境整车 / 中港:" + "、".join(CROSS_ROUTES))
lines.append("线路类别(必填):可选择回复," + "、".join(ROUTES))
lines.append("")
lines.append("运输分类(跟线路类别联动):")
lines.append("中港/中亚/中欧、东南亚:" + ";".join(CROSS_CLASSES))
lines.append("国内长途/零担、国内城配/拖车/打包:" + ";".join(DOMESTIC_CLASSES))
lines.extend(_class_prompt_lines())
lines.append("")
lines.append("运输类型选中港时,还要选择:" + "、".join(HK_LOADS))
lines.append("填写样例见图。")
return "\n".join(lines).strip()
# 类型已认出来:不再问运输类型
allowed_routes = ROUTES_BY_TYPE.get(t) or ()
if r not in allowed_routes:
lines.append(f"运输类型已选「{t}」。请选择线路类别:")
lines.append("、".join(allowed_routes))
route_for_class = ""
else:
route_for_class = r
if route_for_class:
allowed_cls = CLASSES_BY_ROUTE.get(route_for_class) or ()
if src.get("运输分类") not in allowed_cls:
lines.append(f"线路类别已选「{route_for_class}」。请选择运输分类:")
lines.append(";".join(allowed_cls))
elif t in TRANSPORT_TYPES:
# 线路未定,把该类型下各线路的分类一并列出,方便一次粘贴
seen: set[str] = set()
lines.append("运输分类(跟线路类别联动):")
for route in allowed_routes:
if route in seen:
continue
seen.add(route)
lines.append(f"{route}:" + ";".join(CLASSES_BY_ROUTE.get(route) or ()))
if t == "中港" and (src.get("中港装载方式") not in HK_LOADS):
lines.append("中港请再选择装载方式:" + "、".join(HK_LOADS))
if need_type:
lines.append("运输类型(必填):可选择回复," + "、".join(TRANSPORT_TYPES))
if need_route:
if lines:
lines.append("")
lines.append("线路类别(必填):可选择回复," + "、".join(ROUTES))
if need_class:
if lines:
lines.append("")
lines.extend(_class_prompt_lines())
if need_type or need_route:
lines.append("")
lines.append("填写样例见图。")
return "\n".join(lines).strip()
+10 -12
View File
@@ -4,8 +4,8 @@
本文件职责:核对卡通过后、建单查价前,确认当前运输类型的货物字段能拼出查价正文。
带去:运输类型、线路类别、始发/目的/品名/报价日期,以及该类型的体积重量或车型/数量;通关口岸有值才带。
出站类型/线路必须译成 TMS 6.1.1 英文枚举,线路键名 routeCategory。工单与卡片仍用中文。
中港整车不带车型/数量。车型/数量是一个字段,出站再拆。
禁止:把运输分类、中港装载方式放进出站;直连 TMS;改六态。
中港整车、中港零担/集拼出站类型都是 CHINA_HK。中港整车不带车型/数量。
禁止:把运输分类放进出站;直连 TMS;改六态。
线程:纯函数。
"""
@@ -16,20 +16,21 @@ import re
from typing import Any
from agent.schema.field_validate import apply_quote_date_default, normalize_facts
from agent.schema.land_options import canonicalize_hk_load
logger = logging.getLogger(__name__)
LCL_TYPES = frozenset({"国内运输拼车", "跨境集拼"})
FTL_TYPES = frozenset({"国内运输整车", "跨境整车"})
CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港"})
FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港零担/集拼"})
HK_FTL_TYPE = "中港整车"
CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港整车", "中港零担/集拼"})
_TYPE_ENUM = {
"国内运输拼车": "DOMESTIC_LTL",
"国内运输整车": "DOMESTIC_FTL",
"跨境集拼": "CROSS_BORDER_LTL",
"跨境整车": "CROSS_BORDER_FTL",
"中港": "CHINA_HK",
"中港整车": "CHINA_HK",
"中港零担/集拼": "CHINA_HK",
}
_ROUTE_ENUM = {
"国内长途/零担": "DOMESTIC_LONG_LTL",
@@ -91,13 +92,12 @@ def assemble_land_query(facts: dict[str, Any] | None) -> dict[str, Any]:
"""
把已采集陆运字段拼成 TMS 查价参数。
不齐则 ok=False,missing 给销售补问。齐了也不带分类/中港装载。
不齐则 ok=False,missing 给销售补问。齐了也不带分类。
改了运输类型后,outbound 只带当前类型需要的键。
"""
src = apply_quote_date_default(
normalize_facts({str(k): v for k, v in dict(facts or {}).items() if v is not None})
)
# 选项键不走港口别名折叠,再从原文补回
raw = {str(k): str(v).strip() for k, v in dict(facts or {}).items() if v is not None}
land_type = (raw.get("运输类型") or src.get("运输类型") or "").strip()
route = (raw.get("线路类别") or src.get("线路类别") or "").strip()
@@ -106,7 +106,6 @@ def assemble_land_query(facts: dict[str, Any] | None) -> dict[str, Any]:
cargo = (src.get("品名") or "").strip()
quote_date = (src.get("报价日期") or "").strip()
customs = (raw.get("通关口岸") or src.get("通关口岸") or "").strip()
hk_load = canonicalize_hk_load(raw.get("中港装载方式") or src.get("中港装载方式") or "")
missing: list[str] = []
if not land_type:
@@ -141,10 +140,9 @@ def assemble_land_query(facts: dict[str, Any] | None) -> dict[str, Any]:
missing.append("毛重")
else:
outbound["weightKg"] = wgt
elif land_type == "中港" and hk_load != "零担/集拼":
# 中港整车(或尚未选定装载)不采集、不带出车型/数量
elif land_type == HK_FTL_TYPE:
pass
elif land_type in FTL_TYPES or land_type == "中港":
elif land_type in FTL_TYPES:
combo = (
src.get("车型/数量")
or raw.get("车型/数量")
@@ -226,6 +226,37 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertEqual(phase, "activated")
self.assertEqual(self.ledger.find_by_collab_chat(chat_id="wr_air_1").work_order_no, wo2)
def test_activate_prefills_magnet(self) -> None:
created = self.ledger.create_ticket(
sender_id="sales1",
business_line="AIR",
facts={
"起运地": "上海",
"目的地": "洛杉矶",
"品名": "配件",
"件数": "3",
"毛重": "100",
"体积": "2",
"包装类型": "纸箱",
"报价日期": "2026-09-16",
"是否含磁": "是",
},
)
wo = created["work_order_no"]
self.ledger.upsert_quote(
work_order_no=wo,
quote={"total": "USD 800", "source": "TMS", "quoteId": "QT-AIR-1"},
to_status="已报价",
)
handle_air_group(
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="pre1"),
flow=self.flow,
ledger=self.ledger,
)
joined = "\n".join(self._texts())
self.assertIn("是否含磁:是", joined)
self.assertIn("是否含电:-", joined)
def test_second_group_occupied(self) -> None:
wo = self._create_air()
handle_air_group(
@@ -0,0 +1,102 @@
"""
陆运群协同项:六套名单、展示名、预填、口语危险品。
不连企微 / 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.llm.extract_text import extract_collab_fields
from agent.schema.field_validate import (
collab_display_name,
collab_fields_for_ticket,
harvest_oral_collab,
land_collab_fields,
pick_collab_from_facts,
)
class LandCollabFieldsTest(unittest.TestCase):
def test_domestic_ltl_keys(self):
keys = land_collab_fields("国内运输拼车", "国内长途/零担")
self.assertEqual(keys, ("客户名称", "包装方式", "货值", "是否为危险品"))
def test_domestic_ftl_longhaul_keys(self):
keys = land_collab_fields("国内运输整车", "国内长途/零担")
self.assertEqual(keys, ("客户名称", "货值", "包装方式", "是否为危险品"))
def test_domestic_ftl_order(self):
keys = land_collab_fields("国内运输整车", "国内城配/拖车/打包")
self.assertEqual(keys, ("客户名称", "货值", "包装方式", "是否为危险品"))
def test_cross_ftl_no_vehicle(self):
keys = land_collab_fields("跨境整车", "东南亚")
self.assertIn("HS编码", keys)
self.assertNotIn("车型/数量", keys)
def test_hk_ftl_has_vehicle(self):
keys = land_collab_fields("中港整车", "中港/中亚/中欧")
self.assertIn("车型/数量", keys)
self.assertEqual(keys[0], "客户名称")
def test_unlisted_pair_empty(self):
self.assertEqual(land_collab_fields("国内运输拼车", "东南亚"), ())
def test_land_hs_display(self):
self.assertEqual(collab_display_name("HS编码", "LAND"), "海关编码")
self.assertEqual(collab_display_name("HS编码", "SEA"), "商品海关编码")
def test_ticket_sea_air(self):
self.assertIn("是否含电", collab_fields_for_ticket({}, "SEA"))
self.assertEqual(
collab_fields_for_ticket({}, "AIR"),
("是否含电", "是否含磁"),
)
def test_pick_skips_inquiry_vehicle(self):
got = pick_collab_from_facts(
{"包装方式": "纸箱", "车型/数量": "9米/1", "海关编码": "8479"},
land_collab_fields("跨境整车", "东南亚"),
)
self.assertEqual(got.get("包装方式"), "纸箱")
self.assertEqual(got.get("HS编码"), "8479")
self.assertNotIn("车型/数量", got)
def test_dg_oral(self):
self.assertEqual(harvest_oral_collab("普货")["是否为危险品"], "否")
self.assertEqual(harvest_oral_collab("危险品")["是否为危险品"], "是")
self.assertEqual(harvest_oral_collab("不是危险品")["是否为危险品"], "否")
def test_labeled_without_colon(self):
allowed = land_collab_fields("国内运输拼车", "国内长途/零担")
got = extract_collab_fields(
"客户名称张三 包装方式编织袋 货值 10万",
allowed_keys=allowed,
)
self.assertEqual(got.get("客户名称"), "张三")
self.assertEqual(got.get("包装方式"), "编织袋")
self.assertEqual(got.get("货值"), "10万")
def test_bare_pack_ignored(self):
allowed = land_collab_fields("国内运输拼车", "国内长途/零担")
self.assertEqual(extract_collab_fields("编织袋", allowed_keys=allowed), {})
def test_trade_terms_filtered_for_ltl(self):
allowed = land_collab_fields("国内运输拼车", "国内长途/零担")
got = extract_collab_fields("客户名称张三,贸易条款 FOB", allowed_keys=allowed)
self.assertEqual(got, {"客户名称": "张三"})
def test_sea_labeled_still_works(self):
got = extract_collab_fields("贸易条款:FOB")
self.assertEqual(got.get("贸易条款"), "FOB")
if __name__ == "__main__":
unittest.main()
+69 -9
View File
@@ -53,13 +53,12 @@ class LandCopyTest(unittest.TestCase):
)
self.assertIn("通关口岸(非必填):", text)
def test_hk_shows_load(self):
def test_hk_lcl_shows_vehicle_not_load_field(self):
text = copy.land_confirm_card(
{
"运输类型": "中港",
"运输类型": "中港零担/集拼",
"线路类别": "东南亚",
"运输分类": "急件",
"中港装载方式": "零担/集拼",
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
@@ -67,25 +66,26 @@ class LandCopyTest(unittest.TestCase):
"报价日期": "2026-09-17",
}
)
self.assertIn("零担/集拼", text)
self.assertIn("运输类型(必填):中港零担/集拼", text)
self.assertIn("车型/数量(必填):9米6/1", text)
self.assertNotIn("\n数量(必填)", text)
self.assertNotIn("中港装载方式", text)
self.assertIn("通关口岸(非必填)", text)
def test_hk_full_truck_hides_vehicle(self):
text = copy.land_confirm_card(
{
"运输类型": "中港",
"运输类型": "中港整车",
"线路类别": "东南亚",
"运输分类": "急件",
"中港装载方式": "整车",
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
"报价日期": "2026-09-17",
}
)
self.assertIn("整车", text)
self.assertIn("运输类型(必填):中港整车", text)
self.assertNotIn("车型/数量", text)
self.assertNotIn("中港装载方式", text)
def test_domestic_ftl_one_field(self):
text = copy.land_confirm_card(
@@ -134,7 +134,7 @@ class LandCopyTest(unittest.TestCase):
facts = {
"运输类型": "国内运输拼车",
"线路类别": "国内城配/拖车/打包",
"运输分类": "普件,常用线路/常规车型",
"运输分类": "普件(东南亚、中港常用线路/常规车型)",
"起运港": "泉州市",
"目的港": "南京市",
"品名": "通信机柜及线缆配件",
@@ -201,6 +201,66 @@ class LandCopyTest(unittest.TestCase):
self.assertIn("9米6/1", rows["货物"])
self.assertNotIn("CBM", rows["货物"])
def test_land_brief_only_ltl_collab(self):
text = copy.sea_group_brief(
work_order_no="WO1",
facts={
"运输类型": "国内运输拼车",
"线路类别": "国内长途/零担",
"运输分类": "急件",
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "1",
"毛重": "1",
"报价日期": "2026-09-18",
},
quote=None,
collab_facts={"包装方式": "纸箱"},
sales_name="销",
product_names=["产"],
has_price=False,
transport_mode="陆运",
)
self.assertIn("包装方式:纸箱", text)
self.assertIn("客户名称:-", text)
self.assertIn("货值:-", text)
self.assertIn("是否为危险品:-", text)
self.assertNotIn("贸易条款", text)
self.assertNotIn("商品海关编码", text)
self.assertNotIn("海关编码", text)
def test_land_brief_ftl_longhaul_order(self):
text = copy.sea_group_brief(
work_order_no="WO2",
facts={
"运输类型": "国内运输整车",
"线路类别": "国内长途/零担",
"运输分类": "急件",
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"车型/数量": "9米/1",
"报价日期": "2026-09-18",
},
quote=None,
collab_facts={},
sales_name="销",
product_names=["产"],
has_price=False,
transport_mode="陆运",
)
collab = text.split("协同补充字段")[1]
self.assertLess(collab.find("客户名称"), collab.find("货值"))
self.assertLess(collab.find("货值"), collab.find("包装方式"))
self.assertNotIn("贸易条款", text)
def test_land_unrecognized_lists_keys(self):
keys = ("客户名称", "包装方式", "货值", "是否为危险品")
text = copy.group_fields_not_recognized(keys=keys, business_line="LAND")
self.assertIn("客户名称", text)
self.assertNotIn("货好时间", text)
if __name__ == "__main__":
unittest.main()
@@ -33,10 +33,9 @@ class LandFieldValidateTest(unittest.TestCase):
"品名": "衣服",
"体积": "10CBM",
"毛重": "50kg",
"中港装载方式": "零担/集拼",
},
business_line="LAND",
land_subtype="中港",
land_subtype="中港零担/集拼",
)
self.assertIn("车型/数量", got["missing"])
self.assertNotIn("数量", got["missing"])
@@ -48,10 +47,9 @@ class LandFieldValidateTest(unittest.TestCase):
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
"中港装载方式": "整车",
},
business_line="LAND",
land_subtype="中港",
land_subtype="中港整车",
)
self.assertTrue(got["ok"])
self.assertNotIn("车型/数量", got["missing"])
@@ -0,0 +1,305 @@
"""
陆运群内协同:摘要只列本单协同项、补字段过滤、预填。
不连企微 / 真实 TMS / 8180。
"""
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)
os.environ.setdefault("YTD_ENV", "test")
os.environ.setdefault("REDIS_BACKEND", "memory")
os.environ.setdefault("CHECKPOINT_BACKEND", "memory")
os.environ.setdefault("MESSAGE_STORE_BACKEND", "memory")
os.environ.setdefault("LLM_DATA_USAGE_CONFIRMED", "false")
os.environ.setdefault("LLM_ALLOW_NETWORK", "false")
os.environ.setdefault("LEDGER_BACKEND", "memory")
from agent.channel.wecom.models import InboundMessage
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
class CountingLedger(MemoryLedger):
def __init__(self) -> None:
super().__init__()
self.last_created_no = ""
def create_ticket(self, **kwargs):
out = super().create_ticket(**kwargs)
self.last_created_no = str(out.get("work_order_no") or "")
return out
class FakeGroupClient:
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_land_collab", "error": ""}
def add_group_members(self, *, chat_id: str, userids: list[str]) -> dict:
return {"ok": True}
def send_group(self, *, chat_id: str, content: str, mention_userids: list | None = None) -> dict:
self.sent.append({"chat_id": chat_id, "content": content, "kind": "text"})
return {"ok": True}
def send_group_file(self, *, chat_id: str, filename: str, filepath: str = "", file_b64: str = "") -> dict:
self.sent.append({"chat_id": chat_id, "filename": filename, "kind": "file"})
return {"ok": True}
class LandGroupCollabTests(unittest.TestCase):
def setUp(self) -> None:
self.ledger = CountingLedger()
self.ledger.set_quote_templates(
[
{
"templateId": "mem-default-land",
"name": "陆运默认",
"bizType": "陆运",
"keywords": [],
"isDefault": True,
}
]
)
self.ledger.set_land_staff(
[
{
"roleCode": "land",
"status": "active",
"wecomId": "p_dom",
"name": "乙",
"routes": "国内长途/零担,国内城配/拖车/打包,中港/中亚/中欧,东南亚",
}
]
)
self.groups = FakeGroupClient()
self.flow = LandTextInquiryFlow(self.ledger, group_client=self.groups)
self.replies: list[str] = []
def _reply(self, text: str, extra=None) -> str:
self.replies.append(text)
return ""
def _group_texts(self) -> str:
return "\n".join(x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text")
def _open(
self,
*,
option_text: str,
extra_facts: dict | None = None,
cargo: dict | None = None,
) -> tuple[str, str]:
facts = {
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
}
facts.update(cargo or {})
facts.update(extra_facts or {})
self.flow.on_text(
sender_id="u1",
text=option_text,
reply=self._reply,
injected_facts=facts,
injected_mode="LAND",
)
self.flow.on_text(sender_id="u1", text="确定", reply=self._reply)
self.replies.clear()
phase = self.flow.on_button(
sender_id="u1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
self.assertEqual(phase, "collab_group")
wo = self.flow.session_of("u1").work_order_no
return wo, "wr_land_collab"
def _msg(self, *, sender: str, text: str, mid: str) -> InboundMessage:
return InboundMessage(
sender_id=sender,
message_id=mid,
content=text,
msg_type="text",
chat_id="wr_land_collab",
chat_type="group",
)
def test_ltl_brief_only_four_collab_and_prefill_pack(self) -> None:
self._open(
option_text="国内运输拼车 国内长途/零担 急件 包装方式纸箱",
extra_facts={"包装方式": "纸箱"},
)
brief = self._group_texts()
self.assertIn("包装方式:纸箱", brief)
self.assertIn("客户名称:-", brief)
self.assertIn("货值:-", brief)
self.assertIn("是否为危险品:-", brief)
self.assertNotIn("贸易条款", brief)
self.assertNotIn("商品海关编码", brief)
self.assertNotIn("海关编码", brief)
def test_domestic_ftl_longhaul_can_pull_and_brief_order(self) -> None:
"""国内整车配长途:允许拉群;协同顺序是客户名称、货值、包装方式、是否为危险品。"""
self._open(
option_text="国内运输整车 国内长途/零担 急件",
cargo={"车型/数量": "9米/1"},
)
self.assertTrue(self.groups.created)
brief = self._group_texts()
collab_part = brief.split("协同补充字段")[1]
self.assertIn("客户名称:-", collab_part)
self.assertIn("货值:-", collab_part)
self.assertIn("包装方式:-", collab_part)
self.assertIn("是否为危险品:-", collab_part)
self.assertLess(collab_part.find("客户名称"), collab_part.find("货值"))
self.assertLess(collab_part.find("货值"), collab_part.find("包装方式"))
self.assertLess(collab_part.find("包装方式"), collab_part.find("是否为危险品"))
self.assertNotIn("贸易条款", brief)
self.assertNotIn("海关编码", brief)
# 车型/数量只在询价字段,不进协同。
self.assertIn("车型/数量:9米/1", brief.split("协同补充字段")[0])
self.assertNotIn("车型/数量", collab_part)
def test_cross_ftl_vehicle_stays_inquiry(self) -> None:
wo, _ = self._open(
option_text="跨境整车 东南亚 急件",
cargo={
"目的港": "河内",
"车型/数量": "9米/1",
},
)
brief = self._group_texts()
self.assertIn("车型/数量:9米/1", brief.split("协同补充字段")[0])
self.assertNotIn("车型/数量", brief.split("协同补充字段")[1])
self.groups.sent.clear()
phase = handle_group_collab(
self._msg(sender="u1", text="车型/数量 12米/1", mid="v1"),
flow=self.flow,
ledger=self.ledger,
injected_intent=INTENT_FIELDS,
)
self.assertEqual(phase, "collab_not_recognized")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.facts.get("车型/数量"), "9米/1")
self.assertFalse(ticket.collab_facts.get("车型/数量"))
def test_hk_ftl_vehicle_is_collab(self) -> None:
wo, _ = self._open(
option_text="中港整车 中港/中亚/中欧 急件",
cargo={"目的港": "香港"},
)
brief = self._group_texts()
collab_part = brief.split("协同补充字段")[1]
self.assertIn("车型/数量:-", collab_part)
self.assertNotIn("车型/数量", brief.split("协同补充字段")[0])
self.groups.sent.clear()
phase = handle_group_collab(
self._msg(sender="u1", text="车型/数量 9米/1", mid="hk1"),
flow=self.flow,
ledger=self.ledger,
injected_intent=INTENT_FIELDS,
)
self.assertEqual(phase, "collab_field_saved")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.collab_facts.get("车型/数量"), "9米/1")
self.assertFalse(ticket.facts.get("车型/数量"))
self.assertIn("车型/数量:9米/1", self._group_texts())
def test_ltl_rejects_trade_terms_only(self) -> None:
self._open(option_text="国内运输拼车 国内长途/零担 急件")
self.groups.sent.clear()
phase = handle_group_collab(
self._msg(sender="u1", text="贸易条款 FOB", mid="t1"),
flow=self.flow,
ledger=self.ledger,
injected_intent=INTENT_FIELDS,
)
self.assertEqual(phase, "collab_not_recognized")
text = self._group_texts()
self.assertIn("客户名称", text)
self.assertNotIn("货好时间", text)
def test_ltl_keeps_name_drops_trade_terms(self) -> None:
wo, _ = self._open(option_text="国内运输拼车 国内长途/零担 急件")
self.groups.sent.clear()
phase = handle_group_collab(
self._msg(sender="u1", text="客户名称张三,贸易条款 FOB", mid="t2"),
flow=self.flow,
ledger=self.ledger,
injected_intent=INTENT_FIELDS,
)
self.assertEqual(phase, "collab_field_saved")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.collab_facts.get("客户名称"), "张三")
self.assertFalse(ticket.collab_facts.get("贸易条款"))
self.assertIn("客户名称:张三", self._group_texts())
def test_dg_and_value_and_bare_pack(self) -> None:
wo, _ = self._open(option_text="国内运输拼车 国内长途/零担 急件")
handle_group_collab(
self._msg(sender="u1", text="普货", mid="dg1"),
flow=self.flow,
ledger=self.ledger,
injected_intent=INTENT_FIELDS,
)
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.collab_facts.get("是否为危险品"), "否")
handle_group_collab(
self._msg(sender="u1", text="危险品", mid="dg2"),
flow=self.flow,
ledger=self.ledger,
injected_intent=INTENT_FIELDS,
)
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.collab_facts.get("是否为危险品"), "是")
handle_group_collab(
self._msg(sender="u1", text="货值 10万 包装方式编织袋", mid="v1"),
flow=self.flow,
ledger=self.ledger,
injected_intent=INTENT_FIELDS,
)
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.collab_facts.get("货值"), "10万")
self.assertEqual(ticket.collab_facts.get("包装方式"), "编织袋")
self.groups.sent.clear()
phase = handle_group_collab(
self._msg(sender="u1", text="编织袋", mid="v2"),
flow=self.flow,
ledger=self.ledger,
injected_intent=INTENT_FIELDS,
)
self.assertEqual(phase, "collab_not_recognized")
def test_product_can_quote_without_collab(self) -> None:
wo, _ = self._open(option_text="国内运输拼车 国内长途/零担 急件")
phase = handle_group_collab(
self._msg(sender="p_dom", text="运费 1000", mid="q1"),
flow=self.flow,
ledger=self.ledger,
injected_intent=INTENT_QUOTE,
injected_quote={"total": "1000", "source_label": "产品报价"},
)
self.assertIn(phase, {"product_quote_sent", "wait_tms_choice"})
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertTrue(ticket.quote)
if __name__ == "__main__":
unittest.main()
+103 -29
View File
@@ -1,5 +1,5 @@
"""
陆运选项联动单测:类型/线路/分类/中港装载,选错只纠正该项。
陆运选项单测:三项各自必填、不联动;中港拆进运输类型;样例表跟类型/线路缺项走。
不连企微、主账、TMS。
"""
@@ -18,12 +18,14 @@ from agent.schema.land_options import (
harvest_land_options,
looks_like_confirm,
option_prompt,
option_prompt_needs_sample,
sample_pair_allowed,
validate_land_options,
)
class LandOptionsTest(unittest.TestCase):
def test_domestic_ltl_rejects_sea_route(self):
def test_any_type_route_combo_ok(self):
got = validate_land_options(
{
"运输类型": "国内运输拼车",
@@ -31,28 +33,70 @@ class LandOptionsTest(unittest.TestCase):
"运输分类": "急件",
}
)
self.assertFalse(got["ok"])
msg = got["errors"][0]["message"]
self.assertIn("国内长途/零担", msg)
self.assertIn("国内城配/拖车/打包", msg)
self.assertTrue(got["ok"])
self.assertEqual(got["normalized"]["运输类型"], "国内运输拼车")
self.assertEqual(got["normalized"]["线路类别"], "东南亚")
def test_bare_hk_does_not_fill_type_or_route(self):
cur = harvest_land_options("中港 急件")
self.assertFalse(cur.get("运输类型"))
self.assertFalse(cur.get("线路类别"))
self.assertEqual(cur.get("运输分类"), "急件")
def test_hk_full_and_lcl_types(self):
self.assertEqual(
harvest_land_options("中港整车")["运输类型"], "中港整车"
)
self.assertEqual(
harvest_land_options("中港零担/集拼 东南亚")["运输类型"],
"中港零担/集拼",
)
self.assertEqual(
harvest_land_options("中港零担")["运输类型"], "中港零担/集拼"
)
def test_new_class_names(self):
cur = harvest_land_options(
"普件(东南亚、中港常用线路/常规车型)"
)
self.assertEqual(
cur["运输分类"], "普件(东南亚、中港常用线路/常规车型)"
)
self.assertFalse(
harvest_land_options("普件,常用线路/常规车型").get("运输分类")
)
def test_no_hk_load_field(self):
got = validate_land_options(
{
"运输类型": "中港整车",
"线路类别": "东南亚",
"运输分类": "急件",
}
)
self.assertTrue(got["ok"])
self.assertNotIn("中港装载方式", got["missing"])
self.assertFalse(got["normalized"].get("中港装载方式"))
def test_followup_route_class_keeps_type(self):
cur = harvest_land_options(
"国内城配/拖车/打包;普件,常用线路/常规车型",
"国内城配/拖车/打包;普件(东南亚、中港常用线路/常规车型)",
current={"运输类型": "国内运输拼车"},
)
self.assertEqual(cur["运输类型"], "国内运输拼车")
self.assertEqual(cur["线路类别"], "国内城配/拖车/打包")
self.assertEqual(cur["运输分类"], "普件,常用线路/常规车型")
self.assertEqual(
cur["运输分类"], "普件(东南亚、中港常用线路/常规车型)"
)
def test_model_land_does_not_wipe_type(self):
dropped = harvest_land_options(
"国内城配/拖车/打包;普件,常用线路/常规车型",
"国内城配/拖车/打包;急件",
current={"运输类型": "陆运"},
)
self.assertFalse(dropped.get("运输类型"))
cur = harvest_land_options(
"国内城配/拖车/打包;普件,常用线路/常规车型",
"国内城配/拖车/打包;急件",
current={"运输类型": "国内运输拼车"},
)
junk = harvest_land_options("陆运", current=cur)
@@ -64,36 +108,66 @@ class LandOptionsTest(unittest.TestCase):
def test_full_prompt_when_empty(self):
text = option_prompt(current={})
self.assertIn("运输类型", text)
self.assertIn("线路类别", text)
self.assertIn("运输分类", text)
self.assertIn("国内运输拼车", text)
self.assertIn("整车", text)
self.assertNotIn("请补充陆运类型:", text)
self.assertIn("请一次选择:运输类型、线路类别、运输分类", text)
self.assertIn("运输类型(必填):可选择回复,", text)
self.assertIn("中港整车", text)
self.assertIn("中港零担/集拼", text)
self.assertIn("线路类别(必填):可选择回复,", text)
self.assertIn("国内长途/零担", text)
self.assertIn("1.普件(东南亚、中港常用线路/常规车型)", text)
self.assertIn("填写样例见图", text)
self.assertNotIn("国内运输整车 + 国内城配/拖车/打包", text)
self.assertNotIn("┌", text)
self.assertNotIn("| 国内运输整车 |", text)
self.assertNotIn("仅供参考", text)
self.assertNotIn("中港装载方式", text)
self.assertNotIn("跟运输类型联动", text)
def test_copy_alias_lists_all_three(self):
def test_copy_alias_lists_new_types(self):
from agent.policy import inquiry_copy as copy
text = copy.ASK_LAND_SUBTYPE
self.assertIn("运输类型", text)
self.assertIn("线路类别", text)
self.assertIn("运输分类", text)
self.assertIn("中港整车", text)
self.assertIn("填写样例", text)
self.assertNotIn("请补充陆运类型:国内运输拼车", text)
def test_narrow_prompt_when_type_known(self):
def test_sample_table_when_route_missing(self):
text = option_prompt(current={"运输类型": "国内运输拼车"})
self.assertIn("国内长途/零担", text)
self.assertNotIn("请选择运输类型", text)
self.assertIn("线路类别(必填)", text)
self.assertIn("填写样例见图", text)
self.assertTrue(option_prompt_needs_sample(current={"运输类型": "国内运输拼车"}))
self.assertNotIn("国内运输拼车 + 国内长途/零担", text)
self.assertNotIn("请一次选择:运输类型", text)
self.assertNotIn("运输类型(必填)", text)
def test_hk_needs_load(self):
got = validate_land_options(
{
"运输类型": "中港",
def test_sample_pairs_go_to_image_not_text(self):
from agent.policy import inquiry_copy as copy
text = option_prompt(current={})
self.assertIn("填写样例见图", text)
self.assertNotIn("国内运输拼车 + 国内长途/零担", text)
extra = copy.land_sample_image_payload()
self.assertEqual(extra.get("msgtype"), "image")
self.assertTrue(os.path.isfile(str(extra.get("filepath") or "")))
self.assertGreater(os.path.getsize(str(extra["filepath"])), 1000)
def test_no_sample_when_only_class_missing(self):
text = option_prompt(
current={
"运输类型": "国内运输拼车",
"线路类别": "东南亚",
"运输分类": "急件",
}
)
self.assertIn("中港装载方式", got["missing"])
self.assertIn("运输分类(必填)", text)
self.assertNotIn("填写样例", text)
def test_sample_pair_gate(self):
self.assertTrue(sample_pair_allowed("国内运输拼车", "国内长途/零担"))
self.assertTrue(sample_pair_allowed("国内运输整车", "国内城配/拖车/打包"))
self.assertTrue(sample_pair_allowed("国内运输整车", "国内长途/零担"))
self.assertTrue(sample_pair_allowed("中港整车", "中港/中亚/中欧"))
self.assertFalse(sample_pair_allowed("国内运输拼车", "东南亚"))
self.assertFalse(sample_pair_allowed("中港整车", "东南亚"))
def test_confirm_intent(self):
self.assertTrue(looks_like_confirm("确认"))
+47 -6
View File
@@ -47,9 +47,11 @@ class LandTextInquiryFrontTests(unittest.TestCase):
self.ledger = CountingLedger()
self.flow = LandTextInquiryFlow(self.ledger)
self.replies: list[str] = []
self.extras: list[dict] = []
def _reply(self, text: str, extra=None) -> str:
self.replies.append(text)
self.extras.append(dict(extra or {}))
return ""
def _joined(self) -> str:
@@ -69,7 +71,12 @@ class LandTextInquiryFrontTests(unittest.TestCase):
self.assertIn("国内运输拼车", text)
self.assertIn("线路类别", text)
self.assertIn("运输分类", text)
self.assertIn("中港整车", text)
self.assertIn("填写样例见图", text)
self.assertNotIn("请补充陆运类型:", text)
img = [x for x in self.extras if x.get("msgtype") == "image"]
self.assertEqual(len(img), 1)
self.assertTrue(os.path.isfile(str(img[0].get("filepath") or "")))
def test_known_type_does_not_reask_type(self) -> None:
phase = self.flow.on_text(
@@ -82,8 +89,10 @@ class LandTextInquiryFrontTests(unittest.TestCase):
self.assertEqual(phase, "need_land_options")
self.assertNotIn("请选择运输类型", self._joined())
self.assertIn("国内长途/零担", self._joined())
self.assertIn("填写样例见图", self._joined())
self.assertTrue(any(x.get("msgtype") == "image" for x in self.extras))
def test_wrong_route_keeps_type(self) -> None:
def test_unlinked_route_still_keeps_type(self) -> None:
phase = self.flow.on_text(
sender_id="u1",
text="国内运输拼车,线路东南亚",
@@ -92,14 +101,16 @@ class LandTextInquiryFrontTests(unittest.TestCase):
injected_mode="LAND",
)
self.assertEqual(phase, "need_land_options")
self.assertIn("国内长途/零担", self._joined())
self.assertIn("运输分类", self._joined())
self.assertNotIn("填写样例", self._joined())
sess = self.flow.session_of("u1")
self.assertEqual(sess.facts.get("运输类型"), "国内运输拼车")
self.assertEqual(sess.facts.get("线路类别"), "东南亚")
def test_hk_lcl_asks_vehicle(self) -> None:
phase = self.flow.on_text(
sender_id="u1",
text="中港 东南亚 急件 零担",
text="中港零担/集拼 东南亚 急件",
reply=self._reply,
injected_facts={
"起运港": "广州",
@@ -119,7 +130,7 @@ class LandTextInquiryFrontTests(unittest.TestCase):
def test_hk_full_truck_skips_vehicle(self) -> None:
phase = self.flow.on_text(
sender_id="u1",
text="中港 东南亚 急件 整车",
text="中港整车 东南亚 急件",
reply=self._reply,
injected_facts={
"起运港": "广州",
@@ -403,7 +414,7 @@ class LandTextConfirmTests(unittest.TestCase):
self.replies.clear()
phase = self.flow.on_text(
sender_id="u1",
text="国内城配/拖车/打包;普件,常用线路/常规车型",
text="国内城配/拖车/打包;普件(东南亚、中港常用线路/常规车型)",
reply=self._reply,
injected_facts={"品名": "通信机柜及线缆配件", "运输类型": "陆运"},
injected_mode="LAND",
@@ -413,7 +424,7 @@ class LandTextConfirmTests(unittest.TestCase):
self.assertNotIn("请一次选择:运输类型", text)
self.assertIn("国内运输拼车", text)
self.assertIn("国内城配/拖车/打包", text)
self.assertIn("普件,常用线路/常规车型", text)
self.assertIn("普件(东南亚、中港常用线路/常规车型)", text)
class FakeGroupClient:
@@ -513,6 +524,36 @@ class LandTextPullTests(unittest.TestCase):
self.assertEqual(self.groups.created, [])
self.assertEqual(phase, "wait_collab")
def test_pull_rejects_unlisted_pair(self) -> None:
self.ledger.set_land_staff(
[
{"roleCode": "land", "status": "active", "wecomId": "p_sea", "name": "甲", "routes": "东南亚"},
]
)
self.flow.on_text(
sender_id="u1",
text="国内运输拼车 东南亚 急件",
reply=self._reply,
injected_facts={
"起运港": "广州",
"目的港": "河内",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
},
injected_mode="LAND",
)
self.flow.on_text(sender_id="u1", text="确定", reply=self._reply)
self.replies.clear()
phase = self.flow.on_button(
sender_id="u1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
self.assertEqual(self.groups.created, [])
self.assertIn("拉群失败,请排查填写内容,并重新发起工单。", "\n".join(self.replies))
self.assertNotEqual(phase, "collab_group")
def test_land_role_is_product_in_group(self) -> None:
from agent.policy.sea_group_ops import is_product
@@ -452,6 +452,39 @@ class SeaSkipAndPullTests(unittest.TestCase):
)
self.assertEqual(again, "card_discard")
def test_pull_prefills_spoken_no_battery(self) -> None:
self.ledger.set_sea_staff(
[
{
"name": "张产品",
"wecomId": "prod1",
"roleCode": "sea",
"routes": "海运·全部线路",
"status": "active",
}
]
)
facts = dict(COMPLETE_SEA)
facts["是否含电"] = "否"
self.flow.on_text(
sender_id="s1",
text="海运不含电",
reply=self._reply,
injected_facts=facts,
injected_mode="SEA",
)
private = "\n".join(self.replies)
self.assertNotIn("是否含电", private)
self.flow.on_button(
sender_id="s1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
group_text = "\n".join(
x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"
)
self.assertIn("是否含电:否", group_text)
def test_pull_sends_sheet_when_client_not_injected(self) -> None:
"""测服正式路径不注入 group_client,拉群仍要发出填好的模板。"""
from unittest.mock import patch
+2 -4
View File
@@ -18,10 +18,9 @@ from agent.schema.tms_land_query import assemble_land_query
class TmsLandQueryTest(unittest.TestCase):
def test_outbound_omits_class_and_hk_load(self):
facts = {
"运输类型": "中港",
"运输类型": "中港零担/集拼",
"线路类别": "东南亚",
"运输分类": "急件",
"中港装载方式": "零担/集拼",
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
@@ -44,9 +43,8 @@ class TmsLandQueryTest(unittest.TestCase):
def test_hk_full_truck_omits_vehicle(self):
facts = {
"运输类型": "中港",
"运输类型": "中港整车",
"线路类别": "东南亚",
"中港装载方式": "整车",
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
@@ -19,6 +19,8 @@ public final class TmsLandEnumMapper {
TYPE.put("国内运输整车", "DOMESTIC_FTL");
TYPE.put("跨境集拼", "CROSS_BORDER_LTL");
TYPE.put("跨境整车", "CROSS_BORDER_FTL");
TYPE.put("中港整车", "CHINA_HK");
TYPE.put("中港零担/集拼", "CHINA_HK");
TYPE.put("中港", "CHINA_HK");
TYPE.put("DOMESTIC_LTL", "DOMESTIC_LTL");
TYPE.put("DOMESTIC_FTL", "DOMESTIC_FTL");
@@ -15,9 +15,10 @@ import java.util.regex.Pattern;
/**
* 陆运 TMS 查价段:按运输类型拼出站字段。
* <p>
* 职责:把工单 facts 里的中文键写成 TMS 段;运输分类、中港装载方式只留在工单,不进出站。
* 拼车/跨境集拼带体积重量;国内整车/跨境整车带「车型/数量」一项再拆成出站车型与台数;
* 中港整车不带车型/数量,中港零担/集拼才带。通关口岸有值才放。
* 职责:把工单 facts 里的中文键写成 TMS 段;运输分类只留在工单,不进出站。
* 拼车/跨境集拼带体积重量;国内整车/跨境整车/中港零担带「车型/数量」一项再拆成出站车型与台数;
* 中港整车不带车型/数量。通关口岸有值才放。
* 中港整车、中港零担/集拼出站类型都是 CHINA_HK。
* 类型/线路出站必须是 TMS 6.1.1 英文枚举,线路键名 routeCategory。
* 禁止:按空运要包装件数;把分类当查价条件;直连 TMS。
*/
@@ -26,9 +27,9 @@ public final class TmsLandSegmentMapper {
/** 体积+重量那一套。 */
private static final Set<String> LCL = new HashSet<String>(
Arrays.asList("国内运输拼车", "跨境集拼"));
/** 车型/数量那一套。中港不在此列,按装载方式另判。 */
/** 车型/数量那一套。中港零担走这里;中港整车另判。 */
private static final Set<String> FTL = new HashSet<String>(
Arrays.asList("国内运输整车", "跨境整车"));
Arrays.asList("国内运输整车", "跨境整车", "中港零担/集拼"));
private static final Pattern VEHICLE_SPLIT = Pattern.compile("\\s*[/xX×**]\\s*");
private static final Pattern TAIL_QTY = Pattern.compile("^(.*?)[\\s,,]+(\\d+)\\s*(台|辆)?$");
@@ -37,7 +38,7 @@ public final class TmsLandSegmentMapper {
}
/**
* @param facts 工单/智能体中文 facts(含运输类型、线路类别、货物、中港装载方式)
* @param facts 工单/智能体中文 facts(含运输类型、线路类别、货物)
* @return 可并入查价 segment 的键;不含 landClass / hkLoadType
*/
public static Map<String, Object> build(Map<String, Object> facts) {
@@ -45,7 +46,6 @@ public final class TmsLandSegmentMapper {
Map<String, Object> seg = new LinkedHashMap<String, Object>();
String type = text(source.get("运输类型"));
String route = text(source.get("线路类别"));
String hkLoad = text(source.get("中港装载方式"));
put(seg, "landTransportType", TmsLandEnumMapper.transportType(type));
put(seg, "routeCategory", TmsLandEnumMapper.routeCategory(route));
put(seg, "origin", text(source.get("起运港")));
@@ -54,9 +54,9 @@ public final class TmsLandSegmentMapper {
if (LCL.contains(type)) {
put(seg, "volumeCbm", TmsV2RequestMapper.asPositiveDecimal(source.get("体积")));
put(seg, "weightKg", TmsV2RequestMapper.asPositiveDecimal(source.get("毛重")));
} else if ("中港".equals(type) && !"零担/集拼".equals(hkLoad)) {
} else if ("中港整车".equals(type)) {
// 中港整车:询价不要车型/数量,出站也不带
} else if (FTL.contains(type) || "中港".equals(type)) {
} else if (FTL.contains(type)) {
putVehicleQty(source, seg);
}
String customs = text(source.get("通关口岸"));
@@ -68,9 +68,9 @@ public final class TmsLandSegmentMapper {
/**
* 陆运出站拦网:工单号、报价日期、起运目的品名、类型、线路,再加当前类型货物。
* 不要包装、不要运输分类、不要中港装载。中港整车不要车型/数量。
* 不要包装、不要运输分类。中港整车不要车型/数量。
*
* @param facts 用于读中港装载方式;禁止把装载方式写进 segment
* @param facts 用于读运输类型;禁止把分类写进 segment
*/
public static List<String> missingQuery(String workOrderNo, String quoteDate,
Map<String, Object> segment,
@@ -93,14 +93,14 @@ public final class TmsLandSegmentMapper {
}
String typeEnum = text(seg.get("landTransportType"));
String cnType = text(facts == null ? null : facts.get("运输类型"));
String hkLoad = text(facts == null ? null : facts.get("中港装载方式"));
boolean lcl = LCL.contains(cnType)
|| "DOMESTIC_LTL".equals(typeEnum)
|| "CROSS_BORDER_LTL".equals(typeEnum);
boolean hk = "中港".equals(cnType) || "CHINA_HK".equals(typeEnum);
boolean hkFull = "中港整车".equals(cnType);
boolean ftl = FTL.contains(cnType)
|| "DOMESTIC_FTL".equals(typeEnum)
|| "CROSS_BORDER_FTL".equals(typeEnum);
|| "CROSS_BORDER_FTL".equals(typeEnum)
|| "中港零担/集拼".equals(cnType);
if (lcl) {
if (seg.get("volumeCbm") == null) {
missing.add("volumeCbm");
@@ -108,9 +108,9 @@ public final class TmsLandSegmentMapper {
if (seg.get("weightKg") == null) {
missing.add("weightKg");
}
} else if (hk && !"零担/集拼".equals(hkLoad)) {
} else if (hkFull) {
// 中港整车不拦车型/数量
} else if (ftl || hk) {
} else if (ftl) {
if (blank(seg.get("vehicleType"))) {
missing.add("vehicleType");
}
@@ -15,6 +15,12 @@ public class TmsLandEnumMapperTest {
assertEquals("DOMESTIC_CITY_DIST", TmsLandEnumMapper.routeCategory("国内城配/拖车/打包"));
}
@Test
public void hkSplitTypesBothMapChinaHk() {
assertEquals("CHINA_HK", TmsLandEnumMapper.transportType("中港整车"));
assertEquals("CHINA_HK", TmsLandEnumMapper.transportType("中港零担/集拼"));
}
@Test
public void alreadyEnumStays() {
assertEquals("DOMESTIC_LTL", TmsLandEnumMapper.transportType("DOMESTIC_LTL"));
@@ -38,15 +38,15 @@ public class TmsLandSegmentMapperTest {
@Test
public void landFtlUsesVehicleNotVolume() {
Map<String, Object> facts = new HashMap<String, Object>();
facts.put("运输类型", "中港");
facts.put("运输类型", "中港零担/集拼");
facts.put("线路类别", "中港/中亚/中欧");
facts.put("中港装载方式", "零担/集拼");
facts.put("起运港", "广州");
facts.put("目的港", "香港");
facts.put("品名", "衣服");
facts.put("车型/数量", "9米6/1");
facts.put("体积", "99");
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
assertEquals("CHINA_HK", seg.get("landTransportType"));
assertEquals("9米6", seg.get("vehicleType"));
assertNull(seg.get("volumeCbm"));
assertFalse(seg.containsKey("hkLoadType"));
@@ -56,13 +56,13 @@ public class TmsLandSegmentMapperTest {
@Test
public void hkFullTruckOmitsVehicle() {
Map<String, Object> facts = new HashMap<String, Object>();
facts.put("运输类型", "中港");
facts.put("运输类型", "中港整车");
facts.put("线路类别", "中港/中亚/中欧");
facts.put("中港装载方式", "整车");
facts.put("起运港", "广州");
facts.put("目的港", "香港");
facts.put("品名", "衣服");
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
assertEquals("CHINA_HK", seg.get("landTransportType"));
assertNull(seg.get("vehicleType"));
assertNull(seg.get("quantity"));
java.util.List<String> missing = TmsLandSegmentMapper.missingQuery(