Compare commits

..
60 changed files with 5710 additions and 116 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
)
+6 -1
View File
@@ -13,17 +13,22 @@ from typing import Any
from agent.channel.queue import get_message_store
from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import get_air_text_flow
from agent.policy.land_text_flow import get_land_text_flow
from agent.policy.sea_text_flow import get_sea_text_flow
logger = logging.getLogger(__name__)
def _pick_card_flow(sender_id: str, payload: dict | None):
"""点卡:海运书签能找回工单就走海运,否则空运。"""
"""点卡:海运/陆运书签能找回工单就走对应流程,否则空运。"""
sea = get_sea_text_flow()
sess = sea.session_for_action(sender_id, payload)
if sess and (sess.business_line or "").upper() == "SEA":
return sea
land = get_land_text_flow()
land_sess = land.session_for_action(sender_id, payload)
if land_sess and (land_sess.business_line or "").upper() == "LAND":
return land
return get_air_text_flow()
@@ -14,6 +14,7 @@ from agent.channel.queue import get_message_store
from agent.channel.wecom.models import InboundMessage
from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import get_air_text_flow
from agent.policy.land_text_flow import get_land_text_flow
from agent.policy.sea_text_flow import get_sea_text_flow
logger = logging.getLogger(__name__)
@@ -34,6 +35,10 @@ def _pick_continue_flow(*, sender_id: str, work_order_no: str, flow=None):
hit = sea.session_by_ticket(sender_id, no) if no else None
if hit and (hit.business_line or "").upper() == "SEA":
return sea
land = get_land_text_flow()
land_hit = land.session_by_ticket(sender_id, no) if no else None
if land_hit and (land_hit.business_line or "").upper() == "LAND":
return land
getter = getattr(sea.ledger, "get_for_agent", None)
if callable(getter) and no:
try:
@@ -41,8 +46,11 @@ def _pick_continue_flow(*, sender_id: str, work_order_no: str, flow=None):
except Exception:
logger.warning("continue_thread 读主账业务线失败 wo=%s", no)
view = {}
if str(view.get("business_line") or "").upper() == "SEA":
line = str(view.get("business_line") or "").upper()
if line == "SEA":
return sea
if line == "LAND":
return land
return get_air_text_flow()
@@ -81,6 +81,18 @@ def handle_group_inbound(
if ticket is None:
logger.info("group_inbound 对不上工单 chat=%s", chat_id)
return "group_ignore"
if flow is None:
view0 = ticket_view(ticket)
line = str(
view0.get("business_line")
or view0.get("businessLine")
or getattr(ticket, "business_line", "")
or ""
).upper()
if line == "LAND":
from agent.policy.land_text_flow import get_land_text_flow
engine = get_land_text_flow()
ticket = hydrate_ticket(engine, ticket)
if not _sender_allowed(ticket, message.sender_id):
logger.info("group_inbound 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id)
+87 -2
View File
@@ -15,6 +15,7 @@ from agent.channel.wecom.models import InboundMessage
from agent.config import get_settings
from agent.llm.extract_text import detect_transport_mode
from agent.policy.air_text_flow import get_air_text_flow
from agent.policy.land_text_flow import get_land_text_flow
from agent.policy.sea_text_flow import get_sea_text_flow
logger = logging.getLogger(__name__)
@@ -22,20 +23,90 @@ logger = logging.getLogger(__name__)
def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = ""):
"""
按当前书签或本句运输方式选空运/海运流程。
按当前书签或本句运输方式选空运/海运/陆运流程。
已有海运书签优先;新句写了海运走海运;其余仍走空运(含补问运输方式)。
陆运核对卡上的确定优先于旧海运书签,避免「确定」被海运/空运吃掉。
"""
from agent.schema.land_options import looks_like_confirm
land = get_land_text_flow()
if looks_like_confirm(text):
restored = land.restore_confirm_session(sender_id)
if restored:
return land
retry = land.restore_tms_retry_session(sender_id)
if retry:
return land
sea = get_sea_text_flow()
sea_sess = sea.session_of(sender_id)
if sea_sess and (sea_sess.business_line or "").upper() == "SEA":
return sea
land_sess = land.session_of(sender_id)
if land_sess and (land_sess.business_line or "").upper() == "LAND":
return land
mode = (injected_mode or detect_transport_mode(text) or "").upper()
if mode == "SEA":
return sea
if mode == "LAND":
return land
return get_air_text_flow()
def is_pending_land_confirm(message: InboundMessage) -> bool:
"""
陆运核对卡已出、本句是确定查价。
测服实测「确定」两字会被 DeepSeek A 判成闲聊,必须在分发层拦住,
不能回首次问候、不能跳过建单。
"""
if not message.has_sender():
return False
from agent.schema.land_options import looks_like_confirm
if not looks_like_confirm(message.content or ""):
return False
sess = get_land_text_flow().restore_confirm_session(message.sender_id)
ok = bool(
sess
and (sess.business_line or "").upper() == "LAND"
and sess.phase == "wait_confirm"
)
if not ok:
logger.info(
"land.confirm miss sender=%s phase=%s line=%s",
message.sender_id,
getattr(sess, "phase", "-"),
getattr(sess, "business_line", "-"),
)
return ok
def is_pending_land_tms_retry(message: InboundMessage) -> bool:
"""
陆运工单已建、上次查价失败,本句是确定重查。
不能回首次问候、不能再开一张新工单。
"""
if not message.has_sender():
return False
from agent.schema.land_options import looks_like_confirm
if not looks_like_confirm(message.content or ""):
return False
sess = get_land_text_flow().restore_tms_retry_session(message.sender_id)
return bool(
sess
and (sess.business_line or "").upper() == "LAND"
and sess.phase == "tms_tech"
and (sess.work_order_no or "").strip()
)
def is_land_confirm_action(message: InboundMessage) -> bool:
"""核对卡确定建单,或查价失败后确定重查。"""
return is_pending_land_confirm(message) or is_pending_land_tms_retry(message)
def handle_text_inquiry(
message: InboundMessage,
*,
@@ -110,6 +181,20 @@ def handle_text_other(message: InboundMessage, *, store=None) -> str:
btn = engine.match_button(message.content or "", sess)
if btn:
return handle_text_inquiry(message, store=msg_store)
if is_land_confirm_action(message):
logger.info("text_other 陆运确定/重查,改走询价流程 sender=%s", message.sender_id)
return handle_text_inquiry(message, store=msg_store)
from agent.schema.land_options import looks_like_confirm
if looks_like_confirm(message.content or ""):
body = copy.LAND_CONFIRM_EXPIRED
msg_store.enqueue_outbound(
touser=message.sender_id,
content=body,
dedupe_key=f"land-confirm-expired:{message.message_id}",
)
return "land_confirm_expired"
body = copy.FIRST_CHAT
msg_store.enqueue_outbound(
+11
View File
@@ -213,6 +213,17 @@ class HttpLedger:
return [dict(x) for x in rows if isinstance(x, dict)]
return []
def match_land_staff(self, *, route_category: str) -> list[dict[str, Any]]:
"""主账按线路类别返回启用的陆运产品岗。"""
out = self._post(
"/inquiry/agent/staff/matchLand",
{"routeCategory": route_category, "线路类别": route_category},
)
rows = out.get("staff") or out.get("result") or []
if isinstance(rows, list):
return [dict(x) for x in rows if isinstance(x, dict)]
return []
def get_staff_by_wecom_id(self, *, wecom_id: str) -> dict[str, Any]:
out = self._post("/inquiry/agent/staff/byWecom", {"wecomId": wecom_id})
if out.get("name") or out.get("wecomId"):
@@ -66,6 +66,7 @@ class MemoryLedger:
]
self.last_render: dict[str, Any] = {}
self._sea_staff: list[dict[str, Any]] = []
self._land_staff: list[dict[str, Any]] = []
self._staff_by_wecom: dict[str, dict[str, Any]] = {}
self._staff_by_name: dict[str, dict[str, Any]] = {}
@@ -355,6 +356,42 @@ class MemoryLedger:
hits.append(dict(row))
return hits
def set_land_staff(self, rows: list[dict[str, Any]]) -> None:
"""单测注入陆运产品岗。"""
with self._lock:
self._land_staff = [dict(x) for x in rows]
for row in self._land_staff:
uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
name = str(row.get("name") or "").strip()
if uid:
self._staff_by_wecom[uid] = dict(row)
if name:
self._staff_by_name[name] = dict(row)
def match_land_staff(self, *, route_category: str) -> list[dict[str, Any]]:
"""
岗位=产品·陆运、启用:负责线路整词含本单线路类别才拉。
不按斜杠切开「中港/中亚/中欧」。
"""
want = (route_category or "").strip()
hits: list[dict[str, Any]] = []
if not want:
return hits
with self._lock:
for row in self._land_staff:
if str(row.get("roleCode") or row.get("role_code") or "") != "land":
continue
if str(row.get("status") or "active") != "active":
continue
tokens = [
p.strip()
for p in str(row.get("routes") or "").replace(",", ",").replace("、", ",").split(",")
if p.strip()
]
if want in tokens:
hits.append(dict(row))
return hits
def get_staff_by_wecom_id(self, *, wecom_id: str) -> dict[str, Any]:
"""按企微 userid 取员工展示名。"""
with self._lock:
+110 -14
View File
@@ -22,13 +22,32 @@ _MODE_WORDS = (
# 仅「键:值」一行一条,键必须是合同字段或展示名
_LABEL_LINE = re.compile(
r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型数量|"
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(.+)$"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型/数量|车型数量|"
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"客户名称|货值|是否为危险品)\s*[::]\s*(.+)$"
)
_LABEL_INLINE = re.compile(
r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型数量|"
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(\S+)"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型/数量|车型数量|"
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,14 +116,17 @@ 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,
"land_subtype": "",
"facts": harvest_oral_measures(text, normalize_facts(dict(injected_facts))),
"land_subtype": subtype,
"facts": facts,
"source": "injected",
}
mode = detect_transport_mode(text)
@@ -107,48 +146,105 @@ 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
if not mode and facts.get("起运港") and facts.get("目的港") and "飞" in (text or ""):
mode = "AIR"
logger.info("extract.snapshot mode=%s keys=%s src=%s", mode or "-", sorted(facts), source)
return {
"business_line": mode,
"land_subtype": (b_payload or {}).get("land_subtype") or "",
"land_subtype": land_subtype,
"facts": facts,
"source": source,
}
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
@@ -31,6 +31,7 @@ _FIELD_KEYS = (
"报价日期",
"整柜或拼柜",
"箱型箱量",
"车型/数量",
"车型数量",
)
@@ -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 {})
+14 -3
View File
@@ -57,7 +57,7 @@ class FlowSession:
class AirTextInquiryFlow:
"""
空运文字询价 Owner。海运走 SeaTextInquiryFlow,不要再往本文件堆海运。陆运仍补问二级。
空运文字询价 Owner。海运走 SeaTextInquiryFlow,陆运走 LandTextInquiryFlow。
同一 sender 新开询价会覆盖「当前书签」(旧单留在主账)。
价格卡按工单号另存一份当时会话:点哪张卡就生成哪张卡当时的报价单。
@@ -411,8 +411,19 @@ class AirTextInquiryFlow:
if mode != "AIR":
if mode == "LAND":
reply(copy.ASK_LAND_SUBTYPE)
return "need_land_subtype"
from agent.policy.land_text_flow import get_land_text_flow
return get_land_text_flow().on_text(
sender_id=sender_id,
text=text,
reply=reply,
injected_facts=facts,
injected_mode=mode,
b_result=b_result,
allow_b=allow_b,
chat_fn=chat_fn,
inbound_raw=inbound_raw,
)
if mode == "SEA":
# 海运交给海运 Owner,空运不再回「本轮先开通空运」
from agent.policy.sea_text_flow import get_sea_text_flow
+206 -8
View File
@@ -16,10 +16,14 @@ from typing import Any, Iterable
from agent.schema.field_validate import (
AIR_COLLAB_FIELDS,
AIR_REQUIRED_FIELDS,
SEA_COLLAB_DISPLAY,
LAND_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
# 空运补问:与合同 8 项一致。报价日期默认当天,不进待补充。
_AIR_EXAMPLES = {
@@ -96,9 +100,135 @@ _CONTINUE_HINT = (
ASK_TRANSPORT = "请补充运输方式:海运 / 空运 / 陆运。"
ASK_LAND_SUBTYPE = "请补充陆运类型:国内运输拼车 / 国内运输整车 / 跨境集拼 / 跨境整车 / 中港。"
# 旧一句只问运输类型;销售要一次看到类型、线路、分类。残留调用也走完整清单。
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查价,内容有误时,请重新发内容。"
LAND_CONFIRM_EXPIRED = "刚才的陆运核对已过期,请把陆运需求再发一次,出核对卡后再回复确定。"
LAND_TMS_RETRY = "工单 {wo} 上次查价没成功。回复「确定」按原需求再查一次,不用重新填。"
LAND_TMS_RETRYING = "正在重新查询TMS报价。工单:{wo}"
_LAND_LCL_KEYS = ("起运港", "目的港", "品名", "体积", "毛重", "报价日期")
_LAND_FTL_KEYS = ("起运港", "目的港", "品名", "车型/数量", "报价日期")
_LAND_HK_FULL_KEYS = ("起运港", "目的港", "品名", "报价日期")
_LAND_FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港零担/集拼"})
_LAND_CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港整车", "中港零担/集拼"})
_LAND_EXAMPLES = {
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
"车型/数量": "9米6/1",
"通关口岸": "皇岗",
}
def _land_cargo_keys(land_type: str, facts: dict[str, str] | None = None) -> tuple[str, ...]:
"""当前运输类型要展示的货物内部键。中港整车不要车型/数量。"""
_ = facts
if land_type == "中港整车":
return _LAND_HK_FULL_KEYS
if land_type in _LAND_FTL_TYPES:
return _LAND_FTL_KEYS
return _LAND_LCL_KEYS
def land_confirm_card(facts: dict[str, str] | None) -> str:
"""
确定查价前的核对卡。没有工单号。
行列出运输类型/线路/分类和该类型货物字段。
国内不出现通关口岸;跨境/中港通关口岸非必填,没填留空行。
不再单独列出中港装载方式。
"""
src = dict(facts or {})
land_type = (src.get("运输类型") or "").strip()
lines = [
f"运输类型(必填):{land_type}",
f"线路类别(必填):{(src.get('线路类别') or '').strip()}",
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()
lines.append(f"{label}(必填):{val}")
if land_type in _LAND_CUSTOMS_TYPES:
lines.append(f"通关口岸(非必填):{(src.get('通关口岸') or '').strip()}")
lines.append("")
lines.append(LAND_CONFIRM_TAIL)
return "\n".join(lines)
def land_group_no_staff() -> str:
"""没有对上线路的陆运产品,不建空群。"""
return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的陆运产品岗后再试。"
def land_group_pair_rejected() -> str:
"""类型+线路不在填写样例里,禁止拉群。"""
return "拉群失败,请排查填写内容,并重新发起工单。"
def ask_land_fields(
*,
facts: dict[str, str] | None = None,
missing_keys: Iterable[str] = (),
land_subtype: str = "",
) -> str:
"""
陆运货物缺项补问。不出核对卡、不出现确定句。
报价日期不进待补充。
"""
src = dict(facts or {})
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 = ["请确认并补充以下信息:", "运输方式:陆运"]
if land_type:
lines.append(f"运输类型:{land_type}")
for key in _land_cargo_keys(land_type, src):
val = (src.get(key) or "").strip()
if val:
lines.append(f"{LAND_DISPLAY.get(key, key)}:{val}")
lines.append("")
lines.append("待补充:")
if not missing:
return ""
for key in missing:
label = display_name(key, "LAND")
example = _LAND_EXAMPLES.get(key, "")
if example:
lines.append(f"{label}:(如:{example})")
else:
lines.append(f"{label}:")
return "\n".join(lines)
def _air_known_value(facts: dict[str, str], key: str) -> str:
@@ -259,6 +389,22 @@ def inquiry_field_lines(
f"箱型箱量:{box}",
f"报价日期:{quote_date}",
]
if (transport_mode or "").strip() == "陆运":
land_type = (facts.get("运输类型") or "").strip()
lines = [
f"工单号:{work_order_no}",
f"运输方式:{transport_mode}",
f"运输类型:{land_type or '-'}",
f"线路类别:{(facts.get('线路类别') or '-').strip() or '-'}",
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 "-"
lines.append(f"{label}:{val}")
if land_type in _LAND_CUSTOMS_TYPES:
lines.append(f"通关口岸:{(facts.get('通关口岸') or '').strip()}")
return lines
return [
f"工单号:{work_order_no}",
f"运输方式:{transport_mode}",
@@ -1006,25 +1152,30 @@ def sea_group_brief(
has_price: bool,
tms_quote: dict[str, Any] | None = None,
product_quote: dict[str, Any] | None = None,
transport_mode: str = "海运",
) -> str:
"""
群里自动摘要:询价 + 协同 + TMS 报价 + 人工报价。
TMS 无价只写「TMS 暂无匹配报价」,产品报的价单独放人工报价,不能混进 TMS。
陆运传入 transport_mode=陆运,始发站/货物字段跟核对卡同一套。
"""
no = (work_order_no or "").strip()
sales = (sales_name or "").strip() or "销售"
products = [n.strip() for n in (product_names or []) if (n or "").strip()]
at_bits = [f"@{sales}"] + [f"@{n}" for n in products]
mode = (transport_mode or "海运").strip() or "海运"
lines = [" ".join(at_bits), f"工单号:{no}", "询价字段:"]
lines.extend(
inquiry_field_lines(work_order_no=no, facts=facts, transport_mode="海运")[1:]
inquiry_field_lines(work_order_no=no, facts=facts, transport_mode=mode)[1:]
)
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 {}
@@ -1123,8 +1274,9 @@ def scheme_horizontal_rows(
"""
卡片字段:一行一项,每格独立字数,不会像副标题整段截成 8CB…。
企微 horizontal 最多 6 条。运输方式写进航线行,避免丢字段。
企微 horizontal 最多 6 条。运输方式写进行程行,避免丢字段。
海运用货物数量/整柜或拼柜/箱型箱量,禁止套空运件数毛重体积包装。
陆运用运输类型/线路类别/体积重量或车型数量,禁止套海运箱型或空运件数包装。
不用 sub_title 堆字段(它会排到列表前面,且超长必截断)。
"""
src = facts or {}
@@ -1145,6 +1297,15 @@ def scheme_horizontal_rows(
{"keyname": "时效", "value": _clip(tms_eta_display(q), 26)},
{"keyname": "报价有效期", "value": _clip(tms_valid_until_display(q), 26)},
]
if mode == "陆运":
return [
{"keyname": "工单号", "value": _clip(work_order_no, 26)},
{"keyname": "线路", "value": _clip(f"陆运 {origin}→{dest}", 26)},
{"keyname": "类型", "value": _clip(_land_scheme_type_line(src), 26)},
{"keyname": "货物", "value": _clip(_land_scheme_goods_line(src), 26)},
{"keyname": "时效", "value": _clip(tms_eta_display(q), 26)},
{"keyname": "报价有效期", "value": _clip(tms_valid_until_display(q), 26)},
]
pieces = _with_unit(str(src.get("件数") or ""), "件")
weight = _with_unit(str(src.get("毛重") or ""), "KGS")
volume = _with_unit(str(src.get("体积") or ""), "CBM")
@@ -1159,6 +1320,35 @@ 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]
route = (src.get("线路类别") or "-").strip() or "-"
klass = (src.get("运输分类") or "-").strip() or "-"
bits.extend([route, klass])
return ";".join(bits)
def _land_scheme_goods_line(facts: dict[str, str]) -> str:
"""方案卡「货物」格:品名 + 当前类型的体积重量或车型/数量。"""
src = facts or {}
land_type = (src.get("运输类型") or "").strip()
bits: list[str] = [(src.get("品名") or "-").strip() or "-"]
for key in _land_cargo_keys(land_type, src):
if key in {"起运港", "目的港", "品名", "报价日期"}:
continue
val = (src.get(key) or "").strip()
if val:
bits.append(val)
if land_type in _LAND_CUSTOMS_TYPES:
customs = (src.get("通关口岸") or "").strip()
if customs:
bits.append(customs)
return ";".join(bits)
def scheme_wecom_payload(
*,
work_order_no: str,
@@ -1171,7 +1361,7 @@ def scheme_wecom_payload(
一张报价方案卡:标题带工单号、引用条、字段、三个短按钮。
按钮文案必须短:生成Excel / 生成PDF / 不采用。长文案横排会被裁。
海运/空运字段分开,禁止海运卡再出件数毛重体积。
海运/空运/陆运字段分开,禁止陆运卡再出箱型或空运件数包装。
"""
_ = status
src = dict(quote or {})
@@ -1405,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 "听到了。请直接说贸易条款、货好时间、商品海关编码、是否含油/含电/含磁。"
@@ -0,0 +1,665 @@
"""
陆运文字询价确定性流程。
本文件职责:三项选项各自必填(不联动)→ 按类型补货物 → 无工单号核对卡 →(确定后)建单查 TMS。
查价后半段复用海运拉群/报价单/成交,拉人按线路类别,禁止陆运岗全员。
禁止:字段齐了就建单;直连 TMS;把海运「出卡即查价」改掉。
调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。
"""
from __future__ import annotations
import logging
import threading
from typing import Any, Optional
from agent.ledger.memory_ledger import MemoryLedger
from agent.llm.extract_text import extract_inquiry_snapshot
from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import FlowSession, ReplyFn
from agent.policy.sea_text_flow import SeaTextInquiryFlow
from agent.schema.field_validate import harvest_oral_measures, validate_required_fields
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
logger = logging.getLogger(__name__)
_LAND_FRONT = frozenset({"need_mode", "need_land_options", "clarify", "wait_confirm", "idle", ""})
class LandTextInquiryFlow(SeaTextInquiryFlow):
"""
陆运私聊 Owner。
前半段与海运不同:核对通过前不建单。后半段套海运建群,匹配改走线路类别。
"""
def on_text(
self,
*,
sender_id: str,
text: str,
reply: ReplyFn,
injected_facts: Optional[dict[str, Any]] = None,
injected_mode: str = "",
b_result: Optional[dict[str, Any]] = None,
allow_b: bool = False,
chat_fn: Optional[Any] = None,
inbound_raw: Optional[dict[str, Any]] = None,
) -> str:
"""处理一句陆运私聊。返回阶段名。无 sender 拒绝。"""
if not (sender_id or "").strip():
return "reject_no_sender"
sess = self.session_for_action(sender_id, inbound_raw, text=text)
if (not sess or sess.phase != "wait_confirm") and looks_like_confirm(text):
restored = self.restore_confirm_session(sender_id)
if restored:
sess = restored
else:
retry = self.restore_tms_retry_session(sender_id)
if retry:
sess = retry
phase = (sess.phase if sess else "") or ""
btn = self.match_button(text, sess)
if btn:
return self.on_button(
sender_id=sender_id,
action=btn,
reply=reply,
card_meta=inbound_raw,
)
if sess and sess.phase == "tms_tech" and (sess.work_order_no or "").strip():
if looks_like_confirm(text):
logger.info("land.tms_retry wo=%s sender=%s", sess.work_order_no, sender_id)
return self._quote_existing_land(sess, reply)
if sess and sess.phase == "wait_confirm" and looks_like_confirm(text):
# 确定查价不再抽字段,避免「确定」被模型改成空运/空 facts。
logger.info("land.confirm sender=%s", sender_id)
return self._confirm_or_hold(
sender_id=sender_id,
facts=dict(sess.facts),
text=text,
reply=reply,
sess=sess,
)
if sess and phase not in _LAND_FRONT:
# 查价后半段书签还在时,销售又发新陆运:只走陆运前半段。
# 禁止再 super 去海运 on_text,海运会把 LAND 转回来,递归到死、一句都不回。
logger.info(
"land.new_over_old phase=%s wo=%s",
phase,
sess.work_order_no or "-",
)
sess = None
snap = extract_inquiry_snapshot(
text,
injected_facts=injected_facts,
injected_mode=injected_mode,
b_result=b_result,
allow_b=allow_b,
chat_fn=chat_fn,
)
mode = (snap.get("business_line") or "").upper()
snap_facts = dict(snap.get("facts") or {})
facts = dict(snap_facts)
# DeepSeek B 把陆运类型放在 land_subtype,不在货物 facts 里。
subtype = str(snap.get("land_subtype") or facts.get("运输类型") or "").strip()
if subtype and not str(facts.get("运输类型") or "").strip():
facts["运输类型"] = subtype
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)
merged.update(cargo)
facts = harvest_oral_measures(text, merged)
# 选项只从原话+能对上枚举的模型值收;禁止模型「陆运」盖掉已选的国内运输拼车。
facts = harvest_land_options(text, facts)
b_opt = " ".join(
str(x).strip()
for x in (
snap.get("land_subtype"),
*(snap_facts.get(k) for k in option_keys),
)
if str(x or "").strip()
)
if b_opt:
facts = harvest_land_options(b_opt, facts)
if not mode:
mode = "LAND" if (sess and (sess.business_line or "").upper() == "LAND") else ""
if not mode:
reply(copy.ASK_TRANSPORT)
self._save(
FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="need_mode",
business_line="",
facts=facts,
immutable_text=sess.immutable_text if sess and sess.immutable_text else text,
)
)
return "need_mode"
if mode != "LAND":
if mode == "SEA":
from agent.policy.sea_text_flow import get_sea_text_flow
return get_sea_text_flow().on_text(
sender_id=sender_id,
text=text,
reply=reply,
injected_facts=facts,
injected_mode=mode,
b_result=b_result,
allow_b=allow_b,
chat_fn=chat_fn,
inbound_raw=inbound_raw,
)
from agent.policy.air_text_flow import get_air_text_flow
return get_air_text_flow().on_text(
sender_id=sender_id,
text=text,
reply=reply,
injected_facts=facts,
injected_mode=mode,
b_result=b_result,
allow_b=allow_b,
chat_fn=chat_fn,
inbound_raw=inbound_raw,
)
return self._advance_front(
sender_id=sender_id,
facts=facts,
text=text,
reply=reply,
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,
*,
sender_id: str,
facts: dict[str, str],
text: str,
reply: ReplyFn,
sess: FlowSession,
) -> str:
"""确定意图:字段仍齐则建单查价。"""
check = self._land_ready(facts)
if not check["ready"]:
return self._advance_front(
sender_id=sender_id,
facts=facts,
text=text,
reply=reply,
sess=sess,
)
return self._create_and_quote_land(
sender_id=sender_id,
facts=check["facts"],
text=sess.immutable_text or text,
reply=reply,
)
def _create_and_quote_land(
self,
*,
sender_id: str,
facts: dict[str, str],
text: str,
reply: ReplyFn,
) -> str:
"""
销售确定之后才建陆运工单并查 TMS。
核对卡阶段没有工单号。运输分类写入工单 facts,不进入 assemble 出站。
"""
import uuid
tms_ready = assemble_land_query(facts)
if not tms_ready.get("ok"):
return self._advance_front(
sender_id=sender_id,
facts=facts,
text=text,
reply=reply,
sess=self.session_of(sender_id),
)
self._save(
FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="quoting",
business_line="LAND",
facts=facts,
immutable_text=text,
)
)
created = self._ledger.create_ticket(
sender_id=sender_id,
business_line="LAND",
facts=facts,
idempotency_key=f"create:{sender_id}:{uuid.uuid4().hex}",
)
no = str(created.get("work_order_no") or "").strip()
logger.info("land.create wo=%s sender=%s", no or "-", sender_id)
if not no:
reply("建单失败,请稍后把刚才那条再发一次。陆运字段已经记下。")
return "ledger_fail"
self._clear_parked_confirm(sender_id)
first, history_wo = self._resolve_similar(
created, facts=facts, current_no=no, business_line="LAND"
)
# 与海运一样先出询价确认(工单号+字段),再查价;禁止只回一句「工单已创建」。
out_id = reply(
copy.inquiry_card(
work_order_no=no,
facts=facts,
first_or_same=first,
transport_mode="陆运",
)
) 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="LAND")
sess = FlowSession(
sender_id=sender_id,
thread_id=sender_id,
business_line="LAND",
facts=facts,
work_order_no=no,
first_or_same=first,
history_work_order_no=history_wo,
immutable_text=text,
wait_version=1,
)
return self._apply_land_tms(
sess=sess,
tms=tms,
reply=reply,
retrying=False,
)
def _quote_existing_land(self, sess: FlowSession, reply: ReplyFn) -> str:
"""工单已在:确定后只重查 TMS,禁止再开一单。"""
no = (sess.work_order_no or "").strip()
facts = dict(sess.facts or {})
if not no or not facts:
reply(copy.LAND_CONFIRM_EXPIRED)
return "land_confirm_expired"
reply(copy.LAND_TMS_RETRYING.format(wo=no))
tms = self._ledger.query_tms(work_order_no=no, facts=facts, business_line="LAND")
return self._apply_land_tms(sess=sess, tms=tms, reply=reply, retrying=True)
def _apply_land_tms(
self,
*,
sess: FlowSession,
tms: dict[str, Any],
reply: ReplyFn,
retrying: bool,
) -> str:
"""查价回包落到有价/无价/技术失败。失败时把工单号记进待重查。"""
_ = retrying
no = (sess.work_order_no or "").strip()
if not tms.get("ok"):
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
sess.phase = "tms_tech"
self._save(sess)
self._park_tms_retry(sess)
return "tms_tech"
self._clear_parked_confirm(sess.sender_id)
if not tms.get("has_price"):
self._emit_tms_miss(reply, no)
sess.phase = "tms_miss"
sess.status = "询价中"
sess.allowed = (copy.BTN_PULL_COLLAB,)
self._save(sess)
return "tms_miss"
quote = tms["quote"]
up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价")
if not up.get("ok"):
reply("主账写入报价失败,不能当作已报价。")
sess.phase = "ledger_fail"
self._save(sess)
return "ledger_fail"
sess.quote = quote
sess.status = "已报价"
sess.phase = "wait_collab"
sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB)
sess.wait_version += 1
self._save(sess)
self._emit_sea_hit(
reply,
work_order_no=no,
quote=quote,
first_or_same=sess.first_or_same,
history_work_order_no=sess.history_work_order_no,
status="已报价",
)
return "wait_collab"
def _land_ready(self, facts: dict[str, str]) -> dict[str, Any]:
"""选项+货物是否可出核对卡。"""
opt = validate_land_options(facts)
merged = dict(facts)
merged.update(opt.get("normalized") or {})
if opt.get("errors"):
return {"ready": False, "opt": opt, "facts": merged, "stage": "options_error"}
if not opt.get("ok"):
return {"ready": False, "opt": opt, "facts": merged, "stage": "options_missing"}
land_type = (merged.get("运输类型") or "").strip()
check = validate_required_fields(
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)})
tms = assemble_land_query(cargo)
cargo = dict(tms.get("facts") or cargo)
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 {"运输类型", "线路类别"}:
missing.append(key)
if not check["ok"] or not tms.get("ok"):
return {
"ready": False,
"opt": opt,
"facts": cargo,
"stage": "clarify",
"missing": missing,
"land_type": land_type,
}
return {"ready": True, "facts": cargo, "opt": opt, "land_type": land_type}
def _advance_front(
self,
*,
sender_id: str,
facts: dict[str, str],
text: str,
reply: ReplyFn,
sess: Optional[FlowSession],
) -> str:
"""选项与货物往前推,出补问或核对卡。不建单。"""
state = self._land_ready(facts)
facts = dict(state["facts"])
immutable = sess.immutable_text if sess and sess.immutable_text else text
if state.get("ready"):
reply(copy.land_confirm_card(facts))
self._save(
FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="wait_confirm",
business_line="LAND",
facts=facts,
immutable_text=immutable,
wait_version=(sess.wait_version + 1) if sess else 1,
)
)
self._park_confirm(sender_id, facts=facts, immutable_text=immutable)
return "wait_confirm"
if state.get("stage") == "options_error":
err = (state["opt"].get("errors") or [{}])[0]
self._reply_land_options(
reply,
facts,
message=str(err.get("message") or ""),
)
self._save(
FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="need_land_options",
business_line="LAND",
facts=facts,
immutable_text=immutable,
wait_version=(sess.wait_version + 1) if sess else 1,
)
)
self._clear_parked_confirm(sender_id)
return "need_land_options"
if state["stage"] == "options_missing":
logger.info(
"land.options_missing missing=%s",
(state.get("opt") or {}).get("missing") or [],
)
self._reply_land_options(reply, facts)
self._save(
FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="need_land_options",
business_line="LAND",
facts=facts,
immutable_text=immutable,
wait_version=(sess.wait_version + 1) if sess else 1,
)
)
self._clear_parked_confirm(sender_id)
return "need_land_options"
reply(
copy.ask_land_fields(
facts=facts,
missing_keys=state.get("missing") or [],
land_subtype=state.get("land_type") or "",
)
)
self._save(
FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="clarify",
business_line="LAND",
facts=facts,
immutable_text=immutable,
wait_version=(sess.wait_version + 1) if sess else 1,
)
)
self._clear_parked_confirm(sender_id)
return "clarify"
def restore_confirm_session(self, sender_id: str) -> Optional[FlowSession]:
"""
核对卡书签:先看进程内,没有再从短协调找回。
双进程抢 inbox 或 HTTP 刚重启时,销售手里的核对卡还在,内存可能是空的。
"""
hit = self.session_of(sender_id)
if (
hit
and (hit.business_line or "").upper() == "LAND"
and hit.phase == "wait_confirm"
):
return hit
from agent.redis_coord.pending_land_confirm import load_pending_land_confirm
parked = load_pending_land_confirm(sender_id)
if str(parked.get("phase") or "wait_confirm") == "tms_tech":
return None
facts_in = parked.get("facts") if isinstance(parked.get("facts"), dict) else {}
facts = {str(k): str(v).strip() for k, v in dict(facts_in).items() if str(v).strip()}
if not facts:
return None
sess = FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="wait_confirm",
business_line="LAND",
facts=facts,
immutable_text=str(parked.get("immutable_text") or ""),
wait_version=int(parked.get("wait_version") or 1),
)
self._save(sess)
logger.info("land.confirm restored sender=%s", sender_id)
return sess
def restore_tms_retry_session(self, sender_id: str) -> Optional[FlowSession]:
"""
查价失败后等确定重查。进程内存没有时从短协调找回工单号+字段。
"""
hit = self.session_of(sender_id)
if (
hit
and (hit.business_line or "").upper() == "LAND"
and hit.phase == "tms_tech"
and (hit.work_order_no or "").strip()
):
return hit
from agent.redis_coord.pending_land_confirm import load_pending_land_confirm
parked = load_pending_land_confirm(sender_id)
if str(parked.get("phase") or "") != "tms_tech":
return None
no = str(parked.get("work_order_no") or "").strip()
facts_in = parked.get("facts") if isinstance(parked.get("facts"), dict) else {}
facts = {str(k): str(v).strip() for k, v in dict(facts_in).items() if str(v).strip()}
if not no or not facts:
return None
sess = FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="tms_tech",
business_line="LAND",
facts=facts,
work_order_no=no,
immutable_text=str(parked.get("immutable_text") or ""),
wait_version=int(parked.get("wait_version") or 1),
)
self._save(sess)
logger.info("land.tms_retry restored sender=%s wo=%s", sender_id, no)
return sess
def _park_tms_retry(self, sess: FlowSession) -> None:
"""查价失败:记下工单号,下一句确定只重查不建单。"""
from agent.redis_coord.pending_land_confirm import save_pending_land_confirm
save_pending_land_confirm(
sess.sender_id,
facts=dict(sess.facts or {}),
immutable_text=sess.immutable_text or "",
wait_version=int(sess.wait_version or 1),
phase="tms_tech",
work_order_no=sess.work_order_no or "",
)
def _park_confirm(
self,
sender_id: str,
*,
facts: dict[str, str],
immutable_text: str,
) -> None:
"""出核对卡时记下字段。失败只打日志,不挡出卡。"""
from agent.redis_coord.pending_land_confirm import save_pending_land_confirm
save_pending_land_confirm(
sender_id,
facts=facts,
immutable_text=immutable_text,
phase="wait_confirm",
)
def _clear_parked_confirm(self, sender_id: str) -> None:
"""离开核对卡或已建单:清掉待确定,避免旧卡「确定」再开一单。"""
from agent.redis_coord.pending_land_confirm import clear_pending_land_confirm
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)
if not callable(matcher):
return []
route = str((sess.facts or {}).get("线路类别") or "").strip()
try:
rows = matcher(route_category=route) or []
except Exception:
logger.exception("匹配陆运产品人员失败 wo=%s route=%s", sess.work_order_no, route)
return []
return [dict(x) for x in rows if isinstance(x, dict)]
def _group_no_staff_copy(self) -> str:
"""本线路没有陆运产品岗,不建空群。"""
return copy.land_group_no_staff()
def _group_brief_mode(self, sess: FlowSession) -> str:
_ = sess
return "陆运"
_FLOW: Optional[LandTextInquiryFlow] = None
_FLOW_GUARD = threading.Lock()
def get_land_text_flow() -> LandTextInquiryFlow:
"""进程内一份陆运流程。"""
global _FLOW
with _FLOW_GUARD:
if _FLOW is None:
_FLOW = LandTextInquiryFlow()
return _FLOW
def reset_land_text_flow_for_test() -> LandTextInquiryFlow:
"""单测重置全局陆运实例。"""
global _FLOW
with _FLOW_GUARD:
_FLOW = LandTextInquiryFlow(MemoryLedger())
return _FLOW
@@ -24,6 +24,10 @@ _COLLAB_LABEL_MARKERS: tuple[tuple[str, tuple[str, ...]], ...] = (
("是否含油", ("是否含油", "has_oil")),
("是否含电", ("是否含电", "has_battery")),
("是否含磁", ("是否含磁", "has_magnet")),
("客户名称", ("客户名称", "customer_name")),
("货值", ("货值", "cargo_value")),
("是否为危险品", ("是否为危险品", "is_dangerous")),
("包装方式", ("包装方式", "包装类型")),
)
+101 -23
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
@@ -104,9 +131,10 @@ def is_sales(ticket: object, sender_id: str) -> bool:
def is_product(ticket: object, sender_id: str, flow: Any = None) -> bool:
"""
产品岗:工单产品名单,或后台员工管理挂了产品·海运。
产品岗:工单产品名单,或后台员工管理挂了对应业务线产品岗。
同一个人可以既是拉群销售又是产品。先当销售挡掉的话,自己报价群里会没反应。
海运认 role_code=sea,陆运认 land。
"""
uid = (sender_id or "").strip()
if not uid:
@@ -115,7 +143,7 @@ def is_product(ticket: object, sender_id: str, flow: Any = None) -> bool:
products = {str(x) for x in (view.get("product_ids") or view.get("productIds") or [])}
if uid in products:
return True
if _staff_is_sea_product(flow, uid):
if _staff_is_line_product(flow, uid, ticket):
return True
if is_sales(ticket, uid):
return False
@@ -125,8 +153,8 @@ def is_product(ticket: object, sender_id: str, flow: Any = None) -> bool:
return uid in members
def _staff_is_sea_product(flow: Any, wecom_id: str) -> bool:
"""后台员工管理:role_code=sea 且能查到,才算产品岗。"""
def _staff_is_line_product(flow: Any, wecom_id: str, ticket: object = None) -> bool:
"""后台员工管理:本单 SEA 认 sea,LAND 认 land。"""
book = getattr(flow, "ledger", None) if flow is not None else None
finder = getattr(book, "get_staff_by_wecom_id", None) if book is not None else None
if not callable(finder):
@@ -137,7 +165,15 @@ def _staff_is_sea_product(flow: Any, wecom_id: str) -> bool:
logger.exception("查产品岗失败 wecom=%s", wecom_id)
return False
role = str(row.get("roleCode") or row.get("role_code") or "").strip().lower()
return role == "sea"
view = ticket_view(ticket)
line = str(view.get("business_line") or view.get("businessLine") or "").strip().upper()
want = "land" if line == "LAND" else "sea"
return role == want
def _staff_is_sea_product(flow: Any, wecom_id: str) -> bool:
"""兼容旧调用:无工单时按海运岗认。"""
return _staff_is_line_product(flow, wecom_id, None)
def filename_is_excel_pdf(name: str) -> bool:
@@ -369,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,
@@ -379,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"
@@ -391,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]:
"""
@@ -420,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 "")
@@ -432,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
@@ -457,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,)):
@@ -540,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 "")
@@ -548,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)
@@ -594,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)
@@ -763,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(
+58 -10
View File
@@ -121,8 +121,19 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
if mode != "SEA":
if mode == "LAND":
reply(copy.ASK_LAND_SUBTYPE)
return "need_land_subtype"
from agent.policy.land_text_flow import get_land_text_flow
return get_land_text_flow().on_text(
sender_id=sender_id,
text=text,
reply=reply,
injected_facts=facts,
injected_mode=mode,
b_result=b_result,
allow_b=allow_b,
chat_fn=chat_fn,
inbound_raw=inbound_raw,
)
# 空运交给空运 Owner,避免海运流程半做空运
from agent.policy.air_text_flow import get_air_text_flow
@@ -272,20 +283,22 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
*,
facts: dict[str, str],
current_no: str,
business_line: str = "SEA",
) -> tuple[str, str]:
history = str(created.get("similar_work_order_no") or "").strip()
first = str(created.get("first_or_same") or "first")
if first == "same" and history and history != current_no:
return "same", history
finder = getattr(self._ledger, "find_similar_open", None)
line = (business_line or "SEA").strip() or "SEA"
if not callable(finder):
return "first", ""
try:
similar = finder(
facts=facts, business_line="SEA", exclude_work_order_no=current_no
facts=facts, business_line=line, exclude_work_order_no=current_no
) or {}
except TypeError:
similar = finder(facts=facts, business_line="SEA") or {}
similar = finder(facts=facts, business_line=line) or {}
except Exception:
logger.warning("find_similar 失败,按首次询价展示 current=%s", current_no)
return "first", ""
@@ -308,9 +321,9 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
def _session_from_ledger(self, *, sender_id: str, view: dict[str, Any]) -> FlowSession:
sess = super()._session_from_ledger(sender_id=sender_id, view=view)
if sess.business_line == "SEA" and sess.phase == "wait_collab":
if sess.business_line in {"SEA", "LAND"} and sess.phase == "wait_collab":
sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB)
if sess.business_line == "SEA" and sess.phase == "tms_miss":
if sess.business_line in {"SEA", "LAND"} and sess.phase == "tms_miss":
sess.allowed = (copy.BTN_PULL_COLLAB,)
sess.collab_chat_id = str(view.get("collab_chat_id") or "")
sess.collab_facts = dict(view.get("collab_facts") or {})
@@ -452,7 +465,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
staff = self._match_sea_staff(sess)
if not staff:
reply(copy.sea_group_no_staff())
reply(self._group_no_staff_copy())
return sess.phase or "wait_collab"
userids = [sess.sender_id]
@@ -469,7 +482,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
product_ids.append(uid)
product_names.append(name)
if not product_ids:
reply(copy.sea_group_no_staff())
reply(self._group_no_staff_copy())
return sess.phase or "wait_collab"
# 销售 + 产品之后,必须再拉会话存档账号;没有也不挡建群。
@@ -501,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,
@@ -509,12 +540,18 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
sales_name=sales_name,
product_names=product_names,
has_price=bool(sess.quote and sess.status == "已报价"),
transport_mode=self._group_brief_mode(sess),
)
client.send_group(chat_id=chat_id, content=brief)
sess.collab_chat_id = chat_id
from agent.policy.sea_group_ops import send_filled_sheet
send_filled_sheet(flow=self, ticket=None, sess=sess)
send_filled_sheet(
flow=self,
ticket=None,
sess=sess,
business_line=(sess.business_line or "SEA"),
)
sess.invited_userids = tuple(userids)
sess.invited_sales_name = sales_name
sess.invited_product_names = tuple(product_names)
@@ -547,6 +584,15 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
return []
return [dict(x) for x in rows if isinstance(x, dict)]
def _group_no_staff_copy(self) -> str:
"""没人可拉时的私聊。海运用海运岗话术;陆运覆盖成陆运岗。"""
return copy.sea_group_no_staff()
def _group_brief_mode(self, sess: FlowSession) -> str:
"""群摘要运输方式展示。"""
_ = sess
return "海运"
def _archive_seat_userids(self) -> list[str]:
"""
会话存档账号 userid 列表。
@@ -610,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]:
"""
群里静默写入协同字段。不改六态、不回复、不出报价版本。
@@ -619,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": []}
+39 -7
View File
@@ -132,8 +132,8 @@ def pick_quote_template(
quote: Optional[dict[str, Any]] = None,
) -> Optional[TemplatePick]:
"""
同业务线:先关键词命中,再默认模板。
同业务线:先按关键词打分(运输类型上的词优先),再退回默认模板。
「陆运/国内」不当关键词,避免中港模板抢走拼车单。
参数 templates:主账 listEnabled 行或 TemplateEntry。
返回 None 时销售侧用 FAIL_MESSAGE。
"""
@@ -141,7 +141,9 @@ def pick_quote_template(
haystack = build_inquiry_haystack(
business_line=business_line, facts=facts, quote=quote
).lower()
type_val = str((facts or {}).get("运输类型") or "").strip().lower()
keyword_hit: Optional[dict[str, Any]] = None
best: tuple[int, int] = (-1, -1)
default_hit: Optional[dict[str, Any]] = None
for item in templates or []:
row = _row_of(item)
@@ -150,11 +152,12 @@ def pick_quote_template(
continue
if default_hit is None and _is_default(row.get("isDefault")):
default_hit = row
if keyword_hit is None:
for kw in _as_keywords(row.get("keywords")):
if kw.lower() in haystack:
keyword_hit = row
break
score = _keyword_score(row.get("keywords"), haystack, type_val)
if score == (0, 0):
continue
if score > best:
best = score
keyword_hit = row
hit = keyword_hit or default_hit
if hit is None:
return None
@@ -165,3 +168,32 @@ def pick_quote_template(
match_type=match_type,
raw=hit,
)
_GENERIC_KEYWORDS = frozenset(
{"海运", "空运", "陆运", "sea", "air", "land", "国内"}
)
def _keyword_score(raw: Any, haystack: str, type_val: str) -> tuple[int, int]:
"""
运输类型上的命中优先,其次全文词长。
「陆运/国内」跳过,避免中港、国内整车抢走拼车单。
"""
type_hits = 0
length = 0
any_hit = False
for kw in _as_keywords(raw):
k = kw.lower()
if k in _GENERIC_KEYWORDS:
continue
if type_val and k in type_val:
type_hits += 1
length += len(k)
any_hit = True
elif k in haystack:
length += len(k)
any_hit = True
if not any_hit:
return (0, 0)
return (type_hits, length)
+2
View File
@@ -55,6 +55,8 @@ WSHOTO_ROOM_TICKET = "wshoto:room_ticket"
QUOTE_FILE_READY = "quote_file_ready"
# 产品刚报价、等选「在TMS上改 / 不用TMS」:短协调,不当六态
PENDING_PRODUCT_QUOTE = "pending_product_quote"
# 陆运核对卡已出、等确定查价:短协调,不当六态、不当工单号
PENDING_LAND_CONFIRM = "pending_land_confirm"
# 空运群同一句:BOT 与存档补听去重,不当六态
AIR_INBOUND_MSG = "air_inbound_msg"
AIR_INBOUND_TEXT = "air_inbound_text"
@@ -0,0 +1,89 @@
"""
陆运核对卡待确定:等销售回复确定后再建单。
短协调,不当六态、不当 checkpoint。进程重启或双进程抢 inbox 后仍能读到刚才那份字段。
禁止:把运输分类当已建单;写入现网 Redis 前缀。
"""
from __future__ import annotations
import json
import logging
from typing import Any
from agent.redis_coord.keys import PENDING_LAND_CONFIRM
from agent.redis_coord.runtime import get_redis_runtime
logger = logging.getLogger(__name__)
_TTL_SEC = 24 * 3600
def save_pending_land_confirm(
sender_id: str,
*,
facts: dict[str, str],
immutable_text: str = "",
wait_version: int = 1,
phase: str = "wait_confirm",
work_order_no: str = "",
) -> None:
"""出核对卡或查价失败时记下字段,等确定建单/重查。"""
sid = (sender_id or "").strip()
if not sid or not isinstance(facts, dict) or not facts:
return
payload = {
"facts": {str(k): str(v) for k, v in facts.items() if str(v).strip()},
"immutable_text": (immutable_text or "").strip(),
"wait_version": int(wait_version or 1),
"business_line": "LAND",
"phase": (phase or "wait_confirm").strip() or "wait_confirm",
"work_order_no": (work_order_no or "").strip(),
}
try:
client = get_redis_runtime().client
client.raw.set(
client.key(PENDING_LAND_CONFIRM, sid),
json.dumps(payload, ensure_ascii=False),
ex=_TTL_SEC,
)
logger.info(
"land.pending saved sender=%s phase=%s wo=%s keys=%s",
sid,
payload["phase"],
payload["work_order_no"] or "-",
sorted(payload["facts"]),
)
except Exception:
logger.exception("写陆运待确定失败 sender=%s", sid)
def load_pending_land_confirm(sender_id: str) -> dict[str, Any]:
"""取出待确定陆运字段。没有或过期返回空。"""
sid = (sender_id or "").strip()
if not sid:
return {}
try:
client = get_redis_runtime().client
raw = client.raw.get(client.key(PENDING_LAND_CONFIRM, sid))
if not raw:
return {}
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
data = json.loads(raw)
return dict(data) if isinstance(data, dict) else {}
except Exception:
logger.exception("读陆运待确定失败 sender=%s", sid)
return {}
def clear_pending_land_confirm(sender_id: str) -> None:
"""建单且查价成功后清掉。查价失败会改记 tms_tech,不要在失败路径调用。"""
sid = (sender_id or "").strip()
if not sid:
return
try:
client = get_redis_runtime().client
client.raw.delete(client.key(PENDING_LAND_CONFIRM, sid))
except Exception:
logger.exception("清陆运待确定失败 sender=%s", sid)
+15
View File
@@ -77,6 +77,21 @@ class IntentRouter:
kind = (event or "").strip().lower()
if kind in {"template_card_event", "click", "templatecardevent"}:
return RouteDecision(intent="card_action", confidence=1.0)
from agent.handlers.text_inquiry import is_land_confirm_action
from agent.channel.wecom.models import InboundMessage
from agent.schema.land_options import looks_like_confirm
if looks_like_confirm(text or ""):
if is_land_confirm_action(
InboundMessage(
sender_id=sender_id,
content=text or "",
message_id="route-land-confirm",
)
):
logger.info("route 陆运核对卡确定:跳过闲聊模型 sender=%s", sender_id)
return RouteDecision(intent="ordinary_text_inquiry", confidence=1.0)
logger.info("route 确定类短句但无陆运核对卡,仍走模型 sender=%s", sender_id)
return self.route(text=text, sender_id=sender_id)
def enqueue_ordered_actions(
+2 -1
View File
@@ -95,8 +95,9 @@ _SYSTEM = (
"那个单还没完、只贴一张已有工单号要你处理那张单,都算续办。"
"工单号形态是 WO 加 12 位数字,尽量填进 target.work_order_no;抽不出则填空字符串。"
"【新询价】intent=ordinary_text_inquiry,action_type=ORDINARY_TEXT_INQUIRY。"
"原话在描述新的运输需求(港口/机场、货物、件数重量体积、空运海运等),"
"原话在描述新的运输需求(港口/机场、货物、件数重量体积、空运海运陆运等),"
"即使偶尔拿旧单号当参考,也走新询价,work_order_no 留空。"
"陆运核对卡之后回复确定、确认、查价、可以查了,仍是新询价,禁止标闲聊。"
"【闲聊】intent=ordinary_text_other,action_type=ORDINARY_TEXT_OTHER。"
"问候、谢谢、无关闲聊。点卡片按钮字(生成Excel/生成PDF/不采用等)也标闲聊。"
"禁止因为销售没说指定口令就把续办判成新询价。"
+11 -2
View File
@@ -14,7 +14,7 @@ from agent.channel.wecom.models import InboundMessage
from agent.handlers import abnormal, card_action, close_deal, continue_thread, new_inquiry
from agent.handlers.echo_text import handle_echo_text
from agent.handlers.group_collab import handle_group_collab
from agent.handlers.text_inquiry import handle_text_inquiry, handle_text_other
from agent.handlers.text_inquiry import handle_text_inquiry, handle_text_other, is_land_confirm_action
from agent.routing.decision import RouteDecision
logger = logging.getLogger(__name__)
@@ -210,12 +210,21 @@ def dispatch_inbound(message: InboundMessage, decision: RouteDecision) -> str:
return "unknown_intent"
logger.info(
"dispatch intent=%s sender=%s msg=%s wo=%s",
"dispatch intent=%s sender=%s msg=%s wo=%s land_confirm=%s",
decision.intent,
message.sender_id,
message.message_id,
decision.thread_id or "-",
is_land_confirm_action(message) if message.has_sender() else False,
)
if decision.intent in {
"ordinary_text_other",
"route_model_failed",
"route_parse_failed",
} and is_land_confirm_action(message):
logger.info("dispatch 陆运核对卡确定:纠正闲聊路由 sender=%s", message.sender_id)
handle_text_inquiry(message)
return "ordinary_text_inquiry"
if decision.intent == "continue_thread":
continue_thread.handle_continue_thread(message, thread_id=decision.thread_id)
return decision.intent
+212 -6
View File
@@ -44,6 +44,18 @@ SEA_REQUIRED_FIELDS = (
)
SEA_DISPLAY = {key: label for key, label in SEA_REQUIRED_FIELDS}
# 陆运销售侧名称:卡片写始发站/货物品名;内部仍用合同键起运港/品名。
LAND_DISPLAY = {
"起运港": "始发站",
"目的港": "目的地",
"品名": "货物品名",
"毛重": "重量",
"体积": "体积",
"车型/数量": "车型/数量",
"通关口岸": "通关口岸",
"报价日期": "报价日期",
}
# 协同补充字段:拉群后可静默写入,不挡首次查价。内部键仍是 HS编码。
SEA_COLLAB_FIELDS = ("贸易条款", "货好时间", "HS编码", "是否含油", "是否含电", "是否含磁")
# 空运群只补含电/含磁,摘要不要把海运六项搬过去。
@@ -52,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 文档规定的单位时,补进空字段。
# 只认「数字+单位」,不猜港口,不把长宽高换算成立方。
@@ -83,11 +179,16 @@ _ALIASES = {
"重量(KG)": "毛重",
"重量": "毛重",
"体积(CBM)": "体积",
"车型/数量": "车型数量",
"车型/数量": "车型/数量",
"车型数量": "车型/数量",
"数量": "数量",
"HS": "HS编码",
"HS CODE": "HS编码",
"商品海关编码": "HS编码",
"海关编码": "HS编码",
"客户名称": "客户名称",
"货值": "货值",
"是否为危险品": "是否为危险品",
}
@@ -114,9 +215,33 @@ def normalize_facts(facts: dict[str, Any] | None) -> dict[str, str]:
if not val:
continue
out[key] = val
_fold_land_vehicle_qty(out)
return out
def _fold_land_vehicle_qty(merged: dict[str, str]) -> None:
"""
把拆开的车型、数量收成一个「车型/数量」。
销售侧只认这一项必填;模型若仍拆成两项,这里合并,禁止再分别补问。
不改主账。有「车型/数量」则保留原文,缺的再用车型、数量拼上。
"""
combo = (merged.get("车型/数量") or "").strip()
vehicle = (merged.get("车型") or "").strip()
qty = (merged.get("数量") or "").strip()
if not combo:
if vehicle and qty:
combo = f"{vehicle}/{qty}"
elif vehicle:
combo = vehicle
elif qty:
combo = qty
elif qty and qty not in combo:
combo = f"{combo}/{qty}"
if combo:
merged["车型/数量"] = combo
def _harvest_packaging(raw: str) -> str:
"""当前原话里最后出现的包装词;没有则空串。"""
last = ""
@@ -186,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] = {}
@@ -198,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):
@@ -235,8 +429,14 @@ def apply_quote_date_default(facts: dict[str, str]) -> dict[str, str]:
return merged
def _pricing_required_keys(contract: dict[str, Any], business_line: str, land_subtype: str) -> list[str]:
"""当前激活的首次查价内部键列表。"""
def _pricing_required_keys(
contract: dict[str, Any],
business_line: str,
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":
@@ -259,6 +459,8 @@ def display_name(internal_key: str, business_line: str = "AIR") -> str:
return AIR_DISPLAY.get(internal_key, internal_key)
if line == "SEA":
return SEA_DISPLAY.get(internal_key, internal_key)
if line == "LAND":
return LAND_DISPLAY.get(internal_key, internal_key)
return internal_key
@@ -292,6 +494,10 @@ def validate_required_fields(
"errors": [],
"business_line": line,
}
if line == "LAND":
land_subtype = (land_subtype or "").strip() or str(
normalized.get("运输类型") or ""
).strip()
if line == "LAND" and not (land_subtype or "").strip():
return {
"ok": False,
@@ -305,7 +511,7 @@ def validate_required_fields(
"business_line": line,
}
required = _pricing_required_keys(contract, line, land_subtype)
required = _pricing_required_keys(contract, line, land_subtype, normalized)
missing = [k for k in required if k != "报价日期" and not normalized.get(k)]
missing_display = [display_name(k, line) for k in missing]
ok = len(missing) == 0
+333
View File
@@ -0,0 +1,333 @@
"""
陆运选项规则:运输类型、线路类别、运输分类。
本文件职责:从销售原话收取标准项、校验是否在名单内、生成补问清单(含填写样例表)、判断「确定查价」意图。
三项各自必填,组合不拦截查价。拉群必须对上填写样例。运输分类句里的「中港/东南亚」不得冒充类型或线路。
禁止:改六态;发明枚举;把选项当货物字段;再要单独的中港装载方式。
线程:纯函数,无共享可变状态,可在 Worker 槽调用。
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
TRANSPORT_TYPES = (
"国内运输拼车",
"国内运输整车",
"跨境集拼",
"跨境整车",
"中港整车",
"中港零担/集拼",
)
ROUTES = (
"国内长途/零担",
"国内城配/拖车/打包",
"中港/中亚/中欧",
"东南亚",
)
LAND_CLASSES = (
"普件(东南亚、中港常用线路/常规车型)",
"普件(东南亚、中亚、中欧涉及国内+国外段)",
"普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)",
"急件",
)
# 填写样例,同时作为拉群门禁:对不上的类型+线路可以查价,但不能拉群。
SAMPLE_PAIRS = (
("国内运输拼车", "国内长途/零担"),
("国内运输拼车", "国内城配/拖车/打包"),
("国内运输整车", "国内长途/零担"),
("国内运输整车", "国内城配/拖车/打包"),
("跨境集拼", "中港/中亚/中欧"),
("跨境集拼", "东南亚"),
("跨境整车", "中港/中亚/中欧"),
("跨境整车", "东南亚"),
("中港整车", "中港/中亚/中欧"),
("中港零担/集拼", "中港/中亚/中欧"),
)
# 口语别名 → 标准运输类型。长词优先。禁止把单独「中港」收成类型。
_TYPE_ALIASES = (
("国内运输拼车", "国内运输拼车"),
("国内运输整车", "国内运输整车"),
("中港零担/集拼", "中港零担/集拼"),
("中港零担", "中港零担/集拼"),
("中港集拼", "中港零担/集拼"),
("中港整车", "中港整车"),
("国内拼车", "国内运输拼车"),
("国内整车", "国内运输整车"),
("跨境集拼", "跨境集拼"),
("跨境整车", "跨境整车"),
)
_ROUTE_ALIASES = (
("国内城配/拖车/打包", "国内城配/拖车/打包"),
("国内长途/零担", "国内长途/零担"),
("中港/中亚/中欧", "中港/中亚/中欧"),
("国内城配", "国内城配/拖车/打包"),
("国内长途", "国内长途/零担"),
("东南亚", "东南亚"),
)
# 分类全称从长到短;「急件」单独出现才算急件。
_CLASS_CANON = tuple(
sorted(LAND_CLASSES, key=len, reverse=True)
)
_CONFIRM_PHRASES = ("可以查了", "确定", "确认", "查价")
_CONFIRM_EXCLUDE = ("始发", "目的", "体积", "车型", "改成", "重量")
def canonicalize_transport_type(raw: str) -> str:
"""
把销售原话收成标准运输类型。
参数:任意一句。返回 TRANSPORT_TYPES 之一或空串。
只说「中港」不算选完,避免分类句里的中港把类型改掉。
副作用:无。先匹配更长别名。
"""
text = (raw or "").strip()
if not text:
return ""
for needle, canon in _TYPE_ALIASES:
if needle in text:
return canon
return ""
def canonicalize_route(raw: str) -> str:
"""把原话收成标准线路类别;对不上返回空串。不把单独「中港」收成中港/中亚/中欧。"""
text = (raw or "").strip()
if not text:
return ""
for needle, canon in _ROUTE_ALIASES:
if needle in text:
return canon
return ""
def canonicalize_land_class(raw: str) -> str:
"""
把原话收成标准运输分类。
先匹配完整分类句;整句只有「急件」才收急件,避免普件描述被截成急件。
"""
text = (raw or "").strip()
if not text:
return ""
for canon in _CLASS_CANON:
if canon == "急件":
continue
if canon in text:
return canon
stripped = text.replace("运输分类", "").replace(":", "").replace(":", "").strip()
if stripped == "急件" or text.strip() == "急件":
return "急件"
if "急件" in text and "普件" not in text:
return "急件"
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:
"""
旧中港装载方式不再收取。
保留函数名避免旧调用处报缺符号;恒返回空串。新单用运输类型「中港整车」「中港零担/集拼」。
"""
_ = raw
return ""
def harvest_land_options(text: str, current: dict[str, str] | None = None) -> dict[str, str]:
"""
从一句原话收取陆运选项,合并到已有值上。
先收运输分类并把命中句从原文挖掉,再收类型/线路,避免分类里的东南亚、中港冒充选项。
已有非空项默认保留;本句明确写出另一标准项时才覆盖。
已有值对不上标准枚举则丢掉。不改货物字段。返回新 dict,不修改 current。
"""
merged = {
k: str(v).strip()
for k, v in dict(current or {}).items()
if str(v).strip()
}
_keep_canonical_options(merged)
blob = text or ""
found_class = canonicalize_land_class(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
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
else:
merged.pop("运输类型", None)
r = canonicalize_route(merged.get("线路类别") or "")
if r:
merged["线路类别"] = r
else:
merged.pop("线路类别", None)
c = canonicalize_land_class(merged.get("运输分类") or "")
if c:
merged["运输分类"] = c
else:
merged.pop("运输分类", None)
merged.pop("中港装载方式", None)
def validate_land_options(opts: dict[str, str]) -> dict[str, Any]:
"""
校验三项是否都在名单内。组合不拦截。
返回 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 "")
r = canonicalize_route(src.get("线路类别") or "")
c = canonicalize_land_class(src.get("运输分类") or "")
missing: list[str] = []
if t not in TRANSPORT_TYPES:
missing.append("运输类型")
t = ""
if r not in ROUTES:
missing.append("线路类别")
r = ""
if c not in LAND_CLASSES:
missing.append("运输分类")
c = ""
ok = not missing
return {
"ok": ok,
"missing": missing,
"errors": [],
"normalized": {
"运输类型": t,
"线路类别": r,
"运输分类": c,
"中港装载方式": "",
},
}
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 "")
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 need_type and need_route and need_class:
lines.append("请一次选择:运输类型、线路类别、运输分类")
lines.append("")
lines.append("运输类型(必填):可选择回复," + "、".join(TRANSPORT_TYPES))
lines.append("")
lines.append("线路类别(必填):可选择回复," + "、".join(ROUTES))
lines.append("")
lines.extend(_class_prompt_lines())
lines.append("")
lines.append("填写样例见图。")
return "\n".join(lines).strip()
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()
def looks_like_confirm(text: str) -> bool:
"""
判断是否在表示确定查价。
「确定」「确认」「查价」「可以查了」及相近说法都算。
同一句像在改始发/目的/体积/车型/「改成」则不当确定,避免改内容被当成查价。
"""
raw = (text or "").strip()
if not raw:
return False
if any(x in raw for x in _CONFIRM_EXCLUDE):
return False
return any(p in raw for p in _CONFIRM_PHRASES)
@@ -0,0 +1,175 @@
"""
陆运查价参数:对照 TMS 出站是否带全。
本文件职责:核对卡通过后、建单查价前,确认当前运输类型的货物字段能拼出查价正文。
带去:运输类型、线路类别、始发/目的/品名/报价日期,以及该类型的体积重量或车型/数量;通关口岸有值才带。
出站类型/线路必须译成 TMS 6.1.1 英文枚举,线路键名 routeCategory。工单与卡片仍用中文。
中港整车、中港零担/集拼出站类型都是 CHINA_HK。中港整车不带车型/数量。
禁止:把运输分类放进出站;直连 TMS;改六态。
线程:纯函数。
"""
from __future__ import annotations
import logging
import re
from typing import Any
from agent.schema.field_validate import apply_quote_date_default, normalize_facts
logger = logging.getLogger(__name__)
LCL_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",
}
_ROUTE_ENUM = {
"国内长途/零担": "DOMESTIC_LONG_LTL",
"国内城配/拖车/打包": "DOMESTIC_CITY_DIST",
"中港/中亚/中欧": "CN_HK_CA_EU",
"东南亚": "SOUTHEAST_ASIA",
}
_NUMBER = re.compile(r"(\d+(?:\.\d+)?)")
_VEHICLE_SPLIT = re.compile(r"\s*[/xX×**]\s*")
_TAIL_QTY = re.compile(r"^(.*?)[\s,,]+(\d+)\s*(台|辆)?$")
def tms_land_type_code(raw: str) -> str:
"""销售中文运输类型 → TMS 6.1.1 landTransportType。对不上空串。"""
text = (raw or "").strip()
if text in _TYPE_ENUM:
return _TYPE_ENUM[text]
if text in set(_TYPE_ENUM.values()):
return text
return ""
def tms_land_route_code(raw: str) -> str:
"""销售中文线路类别 → TMS 6.1.1 routeCategory。对不上空串。"""
text = (raw or "").strip()
if text in _ROUTE_ENUM:
return _ROUTE_ENUM[text]
if text in set(_ROUTE_ENUM.values()):
return text
return ""
def _first_number(raw: str) -> str:
hit = _NUMBER.search((raw or "").strip())
return hit.group(1) if hit else ""
def split_vehicle_qty(raw: str) -> tuple[str, str]:
"""
把「车型/数量」拆成出站车型与台数。
例:9米6/1、9米6*2、9米6 1台。拆不出台数时台数默认 1,不回头补问。
不修改入参。
"""
text = (raw or "").strip()
if not text:
return "", ""
parts = _VEHICLE_SPLIT.split(text, maxsplit=1)
if len(parts) == 2 and parts[0].strip() and parts[1].strip():
return parts[0].strip(), _first_number(parts[1]) or parts[1].strip()
hit = _TAIL_QTY.match(text)
if hit and hit.group(1).strip():
return hit.group(1).strip(), hit.group(2)
return text, "1"
def assemble_land_query(facts: dict[str, Any] | None) -> dict[str, Any]:
"""
把已采集陆运字段拼成 TMS 查价参数。
不齐则 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()
origin = (src.get("起运港") or "").strip()
dest = (src.get("目的港") or "").strip()
cargo = (src.get("品名") or "").strip()
quote_date = (src.get("报价日期") or "").strip()
customs = (raw.get("通关口岸") or src.get("通关口岸") or "").strip()
missing: list[str] = []
if not land_type:
missing.append("运输类型")
if not route:
missing.append("线路类别")
if not origin:
missing.append("起运港")
if not dest:
missing.append("目的港")
if not cargo:
missing.append("品名")
outbound: dict[str, Any] = {
"transportMode": "LAND",
"landTransportType": tms_land_type_code(land_type),
"routeCategory": tms_land_route_code(route),
"origin": origin,
"destination": dest,
"cargoName": cargo,
"quoteDate": quote_date,
}
if land_type in LCL_TYPES:
vol = _first_number(src.get("体积") or "")
wgt = _first_number(src.get("毛重") or "")
if not vol:
missing.append("体积")
else:
outbound["volumeCbm"] = vol
if not wgt:
missing.append("毛重")
else:
outbound["weightKg"] = wgt
elif land_type == HK_FTL_TYPE:
pass
elif land_type in FTL_TYPES:
combo = (
src.get("车型/数量")
or raw.get("车型/数量")
or src.get("车型")
or raw.get("车型")
or ""
).strip()
if not combo:
missing.append("车型/数量")
else:
vehicle, qty = split_vehicle_qty(combo)
leftover_qty = (src.get("数量") or raw.get("数量") or "").strip()
if leftover_qty and leftover_qty not in combo:
qty = leftover_qty
outbound["vehicleType"] = vehicle
outbound["quantity"] = qty or "1"
elif land_type:
missing.append("运输类型")
if land_type in CUSTOMS_TYPES and customs:
outbound["customsPort"] = customs
ok = len(missing) == 0
logger.info("tms.land.assemble ok=%s missing=%s type=%s", ok, missing, land_type or "-")
return {
"ok": ok,
"missing": missing,
"facts": src,
"outbound": outbound,
}
@@ -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()
+266
View File
@@ -0,0 +1,266 @@
"""
陆运核对卡与无产品话术。
"""
from __future__ import annotations
import os
import sys
import unittest
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from agent.policy import inquiry_copy as copy
class LandCopyTest(unittest.TestCase):
def test_domestic_card_no_customs_no_wo(self):
text = copy.land_confirm_card(
{
"运输类型": "国内运输拼车",
"线路类别": "国内长途/零担",
"运输分类": "急件",
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
"报价日期": "2026-09-17",
}
)
self.assertIn("始发站(必填):广州", text)
self.assertNotIn("通关口岸", text)
self.assertNotIn("WO", text)
self.assertIn('请回复“确定”进行TMS查价,内容有误时,请重新发内容。', text)
self.assertIn("运输类型", text)
self.assertIn("国内运输拼车", text)
def test_cross_empty_customs(self):
text = copy.land_confirm_card(
{
"运输类型": "跨境集拼",
"线路类别": "东南亚",
"运输分类": "急件",
"起运港": "广州",
"目的港": "河内",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
"报价日期": "2026-09-17",
}
)
self.assertIn("通关口岸(非必填):", text)
def test_hk_lcl_shows_vehicle_not_load_field(self):
text = copy.land_confirm_card(
{
"运输类型": "中港零担/集拼",
"线路类别": "东南亚",
"运输分类": "急件",
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
"车型/数量": "9米6/1",
"报价日期": "2026-09-17",
}
)
self.assertIn("运输类型(必填):中港零担/集拼", text)
self.assertIn("车型/数量(必填):9米6/1", 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.assertNotIn("车型/数量", text)
self.assertNotIn("中港装载方式", text)
def test_domestic_ftl_one_field(self):
text = copy.land_confirm_card(
{
"运输类型": "国内运输整车",
"线路类别": "国内长途/零担",
"运输分类": "急件",
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"车型/数量": "9米6/1",
"报价日期": "2026-09-17",
}
)
self.assertIn("车型/数量(必填):9米6/1", text)
self.assertNotIn("\n数量(必填)", text)
def test_no_staff_copy(self):
self.assertIn("陆运产品岗", copy.land_group_no_staff())
def test_inquiry_card_lists_land_fields_and_wo(self):
text = copy.inquiry_card(
work_order_no="WO202609170006",
facts={
"运输类型": "国内运输拼车",
"线路类别": "国内长途/零担",
"运输分类": "急件",
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
"报价日期": "2026-09-17",
},
transport_mode="陆运",
)
self.assertIn("工单号:WO202609170006", text)
self.assertIn("运输方式:陆运", text)
self.assertIn("运输类型:国内运输拼车", text)
self.assertIn("始发站:广州", text)
self.assertIn("货物品名:衣服", text)
self.assertIn("工单已创建,正在查询TMS报价。", text)
self.assertNotIn("件数", text)
def test_scheme_card_uses_land_fields_not_sea(self):
facts = {
"运输类型": "国内运输拼车",
"线路类别": "国内城配/拖车/打包",
"运输分类": "普件(东南亚、中港常用线路/常规车型)",
"起运港": "泉州市",
"目的港": "南京市",
"品名": "通信机柜及线缆配件",
"体积": "10CBM",
"毛重": "10KG",
"报价日期": "2026-09-17",
}
quote = {"total": "CNY 1200", "eta": "3天", "valid_until": "2026-09-20"}
text = copy.scheme_card(
work_order_no="WO202609170011",
status="已报价",
facts=facts,
quote=quote,
transport_mode="陆运",
)
self.assertIn("运输方式:陆运", text)
self.assertIn("运输类型:国内运输拼车", text)
self.assertIn("始发站:泉州市", text)
self.assertIn("货物品名:通信机柜及线缆配件", text)
self.assertIn("体积:10CBM", text)
self.assertNotIn("整柜或拼柜", text)
self.assertNotIn("箱型箱量", text)
self.assertNotIn("件数:", text)
self.assertNotIn("包装类型:", text)
rows = {
x["keyname"]: x["value"]
for x in copy.scheme_horizontal_rows(
work_order_no="WO202609170011",
facts=facts,
quote=quote,
transport_mode="陆运",
)
}
self.assertIn("陆运 泉州市→南京市", rows["线路"])
self.assertIn("国内运输拼车", rows["类型"])
self.assertIn("国内城配/拖车/打包", rows["类型"])
self.assertIn("通信机柜及线缆配件", rows["货物"])
self.assertIn("10CBM", rows["货物"])
self.assertNotIn("航线", rows)
self.assertNotIn("箱型", rows)
self.assertNotIn("包装", rows)
self.assertNotIn("空运", "".join(rows.values()))
self.assertNotIn("海运", "".join(rows.values()))
def test_scheme_card_ftl_shows_vehicle_combo(self):
facts = {
"运输类型": "国内运输整车",
"线路类别": "国内长途/零担",
"运输分类": "急件",
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"车型/数量": "9米6/1",
}
rows = {
x["keyname"]: x["value"]
for x in copy.scheme_horizontal_rows(
work_order_no="WO202609170012",
facts=facts,
quote={},
transport_mode="陆运",
)
}
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()
+259
View File
@@ -0,0 +1,259 @@
"""
入站选路:陆运书签/本句陆运交给 LandTextInquiryFlow;海运立即建单不变。
"""
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.queue import MemoryMessageStore, get_message_store
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.routing.decision import RouteDecision
from agent.routing.dispatch import dispatch_inbound
from tests.test_sea_text_inquiry import COMPLETE_SEA
class LandDispatchTests(unittest.TestCase):
def setUp(self) -> None:
reset_sea_text_flow_for_test()
reset_land_text_flow_for_test()
reset_air_text_flow_for_test()
self.store = MemoryMessageStore()
def _outbox_text(self) -> str:
return "\n".join(i.content for i in self.store._outbox.values())
def test_handler_routes_land_to_option_sheet(self) -> None:
msg = InboundMessage(sender_id="ld1", message_id="m-land-1", content="陆运询价")
phase = handle_text_inquiry(
msg,
injected_facts={"起运港": "广州", "目的港": "深圳", "品名": "衣服"},
injected_mode="LAND",
store=self.store,
)
self.assertEqual(phase, "need_land_options")
body = self._outbox_text()
self.assertIn("运输类型", body)
self.assertIn("线路类别", body)
self.assertIn("运输分类", body)
self.assertNotIn("请补充陆运类型:", body)
def test_air_owner_forwards_land_to_option_sheet(self) -> None:
from agent.policy.air_text_flow import get_air_text_flow
replies: list[str] = []
phase = get_air_text_flow().on_text(
sender_id="ld-air",
text="陆运询价",
reply=lambda t, extra=None: replies.append(t) or "",
injected_facts={"起运港": "广州", "目的港": "深圳", "品名": "衣服"},
injected_mode="LAND",
)
self.assertEqual(phase, "need_land_options")
text = "\n".join(replies)
self.assertIn("线路类别", text)
self.assertIn("运输分类", text)
self.assertNotIn("请补充陆运类型:", text)
def test_land_session_survives_confirm(self) -> None:
msg1 = InboundMessage(sender_id="ld2", message_id="m-l2a", content="陆运")
facts = {
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
}
phase = handle_text_inquiry(
msg1,
injected_facts=facts,
injected_mode="LAND",
store=self.store,
)
# 补选项走同一 sender
msg2 = InboundMessage(
sender_id="ld2",
message_id="m-l2b",
content="国内运输拼车 国内长途/零担 急件",
)
phase = handle_text_inquiry(
msg2,
injected_facts=facts,
injected_mode="LAND",
store=self.store,
)
self.assertEqual(phase, "wait_confirm")
msg3 = InboundMessage(sender_id="ld2", message_id="m-l2c", content="确定")
phase = handle_text_inquiry(msg3, store=self.store)
self.assertEqual(phase, "wait_collab")
def test_confirm_misrouted_as_other_still_creates(self) -> None:
store = get_message_store()
facts = {
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
}
handle_text_inquiry(
InboundMessage(sender_id="ld3", message_id="m-l3a", content="陆运"),
injected_facts=facts,
injected_mode="LAND",
store=store,
)
handle_text_inquiry(
InboundMessage(
sender_id="ld3",
message_id="m-l3b",
content="国内运输拼车 国内长途/零担 急件",
),
injected_facts=facts,
injected_mode="LAND",
store=store,
)
self.assertEqual(get_land_text_flow().session_of("ld3").phase, "wait_confirm")
name = dispatch_inbound(
InboundMessage(sender_id="ld3", message_id="m-l3c", content="确定"),
RouteDecision(intent="ordinary_text_other"),
)
self.assertEqual(name, "ordinary_text_inquiry")
sess = get_land_text_flow().session_of("ld3")
self.assertEqual(sess.phase, "wait_collab")
self.assertTrue((sess.work_order_no or "").startswith("WO"))
bodies = "\n".join(i.content for i in store._outbox.values())
self.assertIn(sess.work_order_no, bodies)
last = list(store._outbox.values())[-1].content
self.assertNotIn("你好,我是询价机器人", last)
def test_confirm_after_memory_lost_still_creates(self) -> None:
store = get_message_store()
facts = {
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
}
handle_text_inquiry(
InboundMessage(sender_id="ld4", message_id="m-l4a", content="陆运"),
injected_facts=facts,
injected_mode="LAND",
store=store,
)
handle_text_inquiry(
InboundMessage(
sender_id="ld4",
message_id="m-l4b",
content="国内运输拼车 国内长途/零担 急件",
),
injected_facts=facts,
injected_mode="LAND",
store=store,
)
self.assertEqual(get_land_text_flow().session_of("ld4").phase, "wait_confirm")
reset_land_text_flow_for_test()
self.assertIsNone(get_land_text_flow().session_of("ld4"))
name = dispatch_inbound(
InboundMessage(sender_id="ld4", message_id="m-l4c", content="确定"),
RouteDecision(intent="ordinary_text_other"),
)
self.assertEqual(name, "ordinary_text_inquiry")
sess = get_land_text_flow().session_of("ld4")
self.assertEqual(sess.phase, "wait_collab")
self.assertTrue((sess.work_order_no or "").startswith("WO"))
last = list(store._outbox.values())[-1].content
self.assertNotIn("你好,我是询价机器人", last)
def test_tms_retry_misrouted_as_other(self) -> None:
from tests.test_land_text_inquiry import CountingLedger
class Flaky(CountingLedger):
def __init__(self) -> None:
super().__init__()
self.query_calls = 0
def query_tms(self, **kwargs):
self.query_calls += 1
if self.query_calls == 1:
return {
"ok": False,
"classification": "TMS_TECH_FAILURE",
"has_price": False,
"quote": {},
}
return super().query_tms(**kwargs)
land = reset_land_text_flow_for_test()
land.ledger = Flaky()
facts = {
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
}
handle_text_inquiry(
InboundMessage(sender_id="ld5", message_id="m-l5a", content="陆运"),
injected_facts=facts,
injected_mode="LAND",
store=self.store,
)
handle_text_inquiry(
InboundMessage(
sender_id="ld5",
message_id="m-l5b",
content="国内运输拼车 国内长途/零担 急件",
),
injected_facts=facts,
injected_mode="LAND",
store=self.store,
)
handle_text_inquiry(
InboundMessage(sender_id="ld5", message_id="m-l5c", content="确定"),
store=self.store,
)
self.assertEqual(get_land_text_flow().session_of("ld5").phase, "tms_tech")
wo = get_land_text_flow().session_of("ld5").work_order_no
name = dispatch_inbound(
InboundMessage(sender_id="ld5", message_id="m-l5d", content="确定"),
RouteDecision(intent="ordinary_text_other"),
)
self.assertEqual(name, "ordinary_text_inquiry")
sess = get_land_text_flow().session_of("ld5")
self.assertEqual(sess.phase, "wait_collab")
self.assertEqual(sess.work_order_no, wo)
last = list(self.store._outbox.values())[-1].content
self.assertNotIn("你好,我是询价机器人", last)
def test_sea_still_creates_immediately(self) -> None:
msg = InboundMessage(sender_id="s-sea", message_id="m-sea-keep", content="海运询价")
phase = handle_text_inquiry(
msg,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
store=self.store,
)
self.assertEqual(phase, "wait_collab")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,107 @@
"""
陆运五套询价必填校验。不连 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.schema.field_validate import display_name, validate_required_fields
class LandFieldValidateTest(unittest.TestCase):
def test_ltl_missing_volume(self):
got = validate_required_fields(
facts={"起运港": "广州", "目的港": "深圳", "品名": "衣服", "毛重": "50kg"},
business_line="LAND",
land_subtype="国内运输拼车",
)
self.assertFalse(got["ok"])
self.assertIn("体积", got["missing"])
def test_hk_lcl_still_needs_vehicle(self):
got = validate_required_fields(
facts={
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
"体积": "10CBM",
"毛重": "50kg",
},
business_line="LAND",
land_subtype="中港零担/集拼",
)
self.assertIn("车型/数量", got["missing"])
self.assertNotIn("数量", got["missing"])
self.assertNotIn("车型", got["missing"])
def test_hk_full_truck_skips_vehicle(self):
got = validate_required_fields(
facts={
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
},
business_line="LAND",
land_subtype="中港整车",
)
self.assertTrue(got["ok"])
self.assertNotIn("车型/数量", got["missing"])
def test_domestic_ftl_one_field(self):
got = validate_required_fields(
facts={
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"车型/数量": "9米6/1",
},
business_line="LAND",
land_subtype="国内运输整车",
)
self.assertTrue(got["ok"])
self.assertEqual(got["facts"]["车型/数量"], "9米6/1")
def test_cross_ftl_folds_split_keys(self):
got = validate_required_fields(
facts={
"起运港": "广州",
"目的港": "河内",
"品名": "衣服",
"车型": "9米6",
"数量": "1",
},
business_line="LAND",
land_subtype="跨境整车",
)
self.assertTrue(got["ok"])
self.assertEqual(got["facts"]["车型/数量"], "9米6/1")
def test_quote_date_defaults(self):
got = validate_required_fields(
facts={
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
},
business_line="LAND",
land_subtype="国内运输拼车",
)
self.assertTrue(got["ok"])
self.assertRegex(got["facts"]["报价日期"], r"^\d{4}-\d{2}-\d{2}$")
def test_land_display_names(self):
self.assertEqual(display_name("起运港", "LAND"), "始发站")
self.assertEqual(display_name("品名", "LAND"), "货物品名")
if __name__ == "__main__":
unittest.main()
@@ -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()
+180
View File
@@ -0,0 +1,180 @@
"""
陆运选项单测:三项各自必填、不联动;中港拆进运输类型;样例表跟类型/线路缺项走。
不连企微、主账、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.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_any_type_route_combo_ok(self):
got = validate_land_options(
{
"运输类型": "国内运输拼车",
"线路类别": "东南亚",
"运输分类": "急件",
}
)
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["运输分类"], "普件(东南亚、中港常用线路/常规车型)"
)
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)
self.assertEqual(junk["运输类型"], "国内运输拼车")
self.assertEqual(junk["线路类别"], "国内城配/拖车/打包")
cur = harvest_land_options("广州到深圳国内拼车衣服100方")
self.assertEqual(cur["运输类型"], "国内运输拼车")
self.assertFalse(cur.get("线路类别"))
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.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_new_types(self):
from agent.policy import inquiry_copy as copy
text = copy.ASK_LAND_SUBTYPE
self.assertIn("中港整车", text)
self.assertIn("填写样例", text)
self.assertNotIn("请补充陆运类型:国内运输拼车", text)
def test_sample_table_when_route_missing(self):
text = option_prompt(current={"运输类型": "国内运输拼车"})
self.assertIn("线路类别(必填)", text)
self.assertIn("填写样例见图", text)
self.assertTrue(option_prompt_needs_sample(current={"运输类型": "国内运输拼车"}))
self.assertNotIn("国内运输拼车 + 国内长途/零担", text)
self.assertNotIn("请一次选择:运输类型", text)
self.assertNotIn("运输类型(必填)", text)
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("运输分类(必填)", 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("确认"))
self.assertTrue(looks_like_confirm("可以查了"))
self.assertTrue(looks_like_confirm("查价"))
self.assertFalse(looks_like_confirm("广州到深圳衣服"))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,577 @@
"""
陆运私聊:选项补齐 → 货物补问 → 无工单号核对卡。确定建单见后续用例。
不连企微 / 真实 TMS / 8180。
"""
from __future__ import annotations
import os
import sys
import unittest
from datetime import datetime
from zoneinfo import ZoneInfo
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
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.ledger.memory_ledger import MemoryLedger
from agent.policy import inquiry_copy as copy
from agent.policy.land_text_flow import LandTextInquiryFlow
class CountingLedger(MemoryLedger):
def __init__(self) -> None:
super().__init__()
self.create_calls = 0
self.last_created_no = ""
def create_ticket(self, **kwargs):
self.create_calls += 1
out = super().create_ticket(**kwargs)
self.last_created_no = str(out.get("work_order_no") or "")
return out
class LandTextInquiryFrontTests(unittest.TestCase):
def setUp(self) -> None:
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:
return "\n".join(self.replies)
def test_all_options_missing_lists_sheet(self) -> None:
phase = self.flow.on_text(
sender_id="u1",
text="陆运广州到深圳衣服",
reply=self._reply,
injected_facts={"起运港": "广州", "目的港": "深圳", "品名": "衣服"},
injected_mode="LAND",
)
self.assertEqual(phase, "need_land_options")
self.assertEqual(self.ledger.create_calls, 0)
text = self._joined()
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(
sender_id="u1",
text="广州到深圳国内拼车衣服100方",
reply=self._reply,
injected_facts={"起运港": "广州", "目的港": "深圳", "品名": "衣服"},
injected_mode="LAND",
)
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_unlinked_route_still_keeps_type(self) -> None:
phase = self.flow.on_text(
sender_id="u1",
text="国内运输拼车,线路东南亚",
reply=self._reply,
injected_facts={"起运港": "广州", "目的港": "深圳", "品名": "衣服"},
injected_mode="LAND",
)
self.assertEqual(phase, "need_land_options")
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="中港零担/集拼 东南亚 急件",
reply=self._reply,
injected_facts={
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
"体积": "10CBM",
"毛重": "50kg",
},
injected_mode="LAND",
)
self.assertEqual(phase, "clarify")
self.assertIn("车型/数量", self._joined())
self.assertNotIn("\n数量:", self._joined())
self.assertNotIn(copy.LAND_CONFIRM_TAIL, self._joined())
self.assertEqual(self.ledger.create_calls, 0)
def test_hk_full_truck_skips_vehicle(self) -> None:
phase = self.flow.on_text(
sender_id="u1",
text="中港整车 东南亚 急件",
reply=self._reply,
injected_facts={
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
},
injected_mode="LAND",
)
self.assertEqual(phase, "wait_confirm")
self.assertNotIn("车型/数量", self._joined())
self.assertEqual(self.ledger.create_calls, 0)
def test_domestic_complete_card_no_ticket(self) -> None:
phase = self.flow.on_text(
sender_id="u1",
text="国内运输拼车 国内长途/零担 急件",
reply=self._reply,
injected_facts={
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
},
injected_mode="LAND",
)
self.assertEqual(phase, "wait_confirm")
self.assertEqual(self.ledger.create_calls, 0)
today = datetime.now(ZoneInfo("Asia/Shanghai")).date().isoformat()
joined = self._joined()
self.assertIn(today, joined)
self.assertNotIn("通关口岸", joined)
self.assertNotIn("WO", joined)
self.assertIn(copy.LAND_CONFIRM_TAIL, joined)
def test_cross_complete_shows_empty_customs(self) -> None:
phase = self.flow.on_text(
sender_id="u1",
text="跨境集拼 东南亚 急件",
reply=self._reply,
injected_facts={
"起运港": "广州",
"目的港": "河内",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
},
injected_mode="LAND",
)
self.assertEqual(phase, "wait_confirm")
self.assertIn("通关口岸(非必填):", self._joined())
def test_missing_volume_no_confirm(self) -> None:
phase = self.flow.on_text(
sender_id="u1",
text="国内运输拼车 国内长途/零担 急件",
reply=self._reply,
injected_facts={
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"毛重": "50kg",
},
injected_mode="LAND",
)
self.assertEqual(phase, "clarify")
self.assertNotIn(copy.LAND_CONFIRM_TAIL, self._joined())
class LandTextConfirmTests(unittest.TestCase):
def setUp(self) -> None:
self.ledger = CountingLedger()
self.flow = LandTextInquiryFlow(self.ledger)
self.replies: list[str] = []
def _reply(self, text: str, extra=None) -> str:
self.replies.append(text)
return ""
def _to_confirm(self) -> str:
return self.flow.on_text(
sender_id="u1",
text="国内运输拼车 国内长途/零担 急件",
reply=self._reply,
injected_facts={
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
},
injected_mode="LAND",
)
def test_confirm_creates_ticket_and_queries(self) -> None:
self.assertEqual(self._to_confirm(), "wait_confirm")
self.replies.clear()
phase = self.flow.on_text(sender_id="u1", text="确定", reply=self._reply)
self.assertEqual(phase, "wait_collab")
self.assertTrue(self.ledger.last_created_no.startswith("WO"))
self.assertIn(self.ledger.last_created_no, "\n".join(self.replies))
ticket = self.ledger.get_ticket(work_order_no=self.ledger.last_created_no)
self.assertEqual(ticket.facts.get("运输类型"), "国内运输拼车")
self.assertEqual(ticket.facts.get("线路类别"), "国内长途/零担")
joined = "\n".join(self.replies)
self.assertIn("工单号:" + self.ledger.last_created_no, joined)
self.assertIn("运输方式:陆运", joined)
self.assertIn("运输类型:国内运输拼车", joined)
self.assertIn("始发站:广州", joined)
self.assertIn("工单已创建,正在查询TMS报价。", joined)
self.assertNotIn("工单已创建:" + self.ledger.last_created_no, joined)
def test_tms_fail_then_confirm_retries_same_ticket(self) -> None:
class Flaky(CountingLedger):
def __init__(self) -> None:
super().__init__()
self.query_calls = 0
def query_tms(self, **kwargs):
self.query_calls += 1
if self.query_calls == 1:
return {
"ok": False,
"classification": "TMS_TECH_FAILURE",
"has_price": False,
"quote": {},
}
return super().query_tms(**kwargs)
ledger = Flaky()
flow = LandTextInquiryFlow(ledger)
replies: list[str] = []
def reply(text: str, extra=None) -> str:
replies.append(text)
return ""
flow.on_text(
sender_id="u1",
text="国内运输拼车 国内长途/零担 急件",
reply=reply,
injected_facts={
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
},
injected_mode="LAND",
)
phase = flow.on_text(sender_id="u1", text="确定", reply=reply)
self.assertEqual(phase, "tms_tech")
self.assertEqual(ledger.create_calls, 1)
wo = ledger.last_created_no
replies.clear()
flow._by_sender.clear()
flow._by_ticket.clear()
phase = flow.on_text(sender_id="u1", text="确定", reply=reply)
self.assertEqual(phase, "wait_collab")
self.assertEqual(ledger.create_calls, 1)
self.assertEqual(ledger.query_calls, 2)
self.assertEqual(flow.session_of("u1").work_order_no, wo)
self.assertIn(wo, "\n".join(replies))
self.assertNotIn("你好,我是询价机器人", "\n".join(replies))
def test_confirm_synonyms(self) -> None:
for word in ("确认", "查价", "可以查了"):
ledger = CountingLedger()
flow = LandTextInquiryFlow(ledger)
replies: list[str] = []
def reply(text: str, extra=None) -> str:
replies.append(text)
return ""
flow.on_text(
sender_id="u1",
text="国内运输拼车 国内长途/零担 急件",
reply=reply,
injected_facts={
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
},
injected_mode="LAND",
)
phase = flow.on_text(sender_id="u1", text=word, reply=reply)
self.assertEqual(phase, "wait_collab", msg=word)
def test_resend_changes_type_requeries_fields(self) -> None:
self.assertEqual(self._to_confirm(), "wait_confirm")
self.replies.clear()
phase = self.flow.on_text(
sender_id="u1",
text="改成国内整车",
reply=self._reply,
)
self.assertEqual(phase, "clarify")
self.assertTrue(any("车型" in x for x in self.replies))
self.assertEqual(self.ledger.create_calls, 0)
def test_new_land_after_quoted_does_not_recurse(self) -> None:
self.assertEqual(self._to_confirm(), "wait_confirm")
phase = self.flow.on_text(sender_id="u1", text="确定", reply=self._reply)
self.assertEqual(phase, "wait_collab")
self.replies.clear()
phase = self.flow.on_text(
sender_id="u1",
text="陆运,国内运输拼车,泉州市到南京市,通信机柜及线缆配件,10CBM,10KG",
reply=self._reply,
injected_facts={
"起运港": "泉州",
"目的港": "南京",
"品名": "通信机柜及线缆配件",
"体积": "10CBM",
"毛重": "10KG",
"运输类型": "国内运输拼车",
},
injected_mode="LAND",
)
self.assertEqual(phase, "need_land_options")
self.assertTrue(self.replies)
self.assertIn("线路类别", "\n".join(self.replies))
def test_new_land_after_tms_miss_does_not_recurse(self) -> None:
self.flow.on_text(
sender_id="u1",
text="国内运输拼车 国内长途/零担 急件",
reply=self._reply,
injected_facts={
"起运港": "广州",
"目的港": "无价",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
},
injected_mode="LAND",
)
phase = self.flow.on_text(sender_id="u1", text="确定", reply=self._reply)
self.assertEqual(phase, "tms_miss")
self.replies.clear()
phase = self.flow.on_text(
sender_id="u1",
text="陆运,国内运输拼车,泉州市到南京市,通信机柜及线缆配件,10CBM,10KG",
reply=self._reply,
injected_facts={
"起运港": "泉州",
"目的港": "南京",
"品名": "通信机柜及线缆配件",
"体积": "10CBM",
"毛重": "10KG",
"运输类型": "国内运输拼车",
},
injected_mode="LAND",
)
self.assertEqual(phase, "need_land_options")
self.assertIn("线路类别", "\n".join(self.replies))
def test_followup_city_route_does_not_reask_type(self) -> None:
"""
已选国内运输拼车后,用户只补线路+分类。
模型若把运输类型写成「陆运」,不得整张清单重问。
"""
self.flow.on_text(
sender_id="u1",
text="陆运,国内运输拼车,泉州市到南京市,通信机柜及线缆配件,10CBM,10KG",
reply=self._reply,
injected_facts={
"起运港": "泉州",
"目的港": "南京",
"品名": "通信机柜及线缆配件",
"体积": "10CBM",
"毛重": "10KG",
"运输类型": "国内运输拼车",
},
injected_mode="LAND",
)
self.assertEqual(self.flow.session_of("u1").phase, "need_land_options")
self.replies.clear()
phase = self.flow.on_text(
sender_id="u1",
text="国内城配/拖车/打包;普件(东南亚、中港常用线路/常规车型)",
reply=self._reply,
injected_facts={"品名": "通信机柜及线缆配件", "运输类型": "陆运"},
injected_mode="LAND",
)
self.assertEqual(phase, "wait_confirm")
text = "\n".join(self.replies)
self.assertNotIn("请一次选择:运输类型", text)
self.assertIn("国内运输拼车", text)
self.assertIn("国内城配/拖车/打包", text)
self.assertIn("普件(东南亚、中港常用线路/常规车型)", text)
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_1", "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 LandTextPullTests(unittest.TestCase):
def setUp(self) -> None:
self.ledger = CountingLedger()
self.ledger.set_quote_templates(
[
{
"templateId": "mem-default-land",
"name": "陆运默认",
"bizType": "陆运",
"keywords": [],
"isDefault": True,
}
]
)
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 _to_quoted(self) -> None:
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()
def test_pull_only_matching_route(self) -> None:
self.ledger.set_land_staff(
[
{"roleCode": "land", "status": "active", "wecomId": "p_sea", "name": "甲", "routes": "东南亚"},
{"roleCode": "land", "status": "active", "wecomId": "p_dom", "name": "乙", "routes": "国内长途/零担"},
]
)
self._to_quoted()
phase = self.flow.on_button(
sender_id="u1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
self.assertEqual(phase, "collab_group")
userids = self.groups.created[0]["userids"]
self.assertIn("p_dom", userids)
self.assertNotIn("p_sea", userids)
brief = "\n".join(x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text")
self.assertIn("运输类型", brief)
self.assertIn("国内运输拼车", brief)
self.assertIn("国内长途/零担", brief)
self.assertIn("急件", brief)
def test_pull_no_staff(self) -> None:
self.ledger.set_land_staff([])
self._to_quoted()
phase = self.flow.on_button(
sender_id="u1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
self.assertIn("陆运产品岗", "\n".join(self.replies))
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
self.ledger.set_land_staff(
[
{
"roleCode": "land",
"status": "active",
"wecomId": "u1",
"name": "销产",
"routes": "国内长途/零担",
}
]
)
self._to_quoted()
ticket = self.ledger.get_ticket(work_order_no=self.ledger.last_created_no)
self.assertTrue(is_product(ticket, "u1", flow=self.flow))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,44 @@
"""
内存账本:陆运按线路类别整词匹配产品岗。
"""
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("LEDGER_BACKEND", "memory")
from agent.ledger.memory_ledger import MemoryLedger
class MemoryLandStaffTest(unittest.TestCase):
def test_match_route_token(self):
book = MemoryLedger()
book.set_land_staff(
[
{"roleCode": "land", "status": "active", "wecomId": "a", "name": "甲", "routes": "东南亚"},
{"roleCode": "land", "status": "active", "wecomId": "b", "name": "乙", "routes": "国内长途/零担"},
{"roleCode": "land", "status": "active", "wecomId": "c", "name": "丙", "routes": "中港/中亚/中欧,东南亚"},
]
)
hits = book.match_land_staff(route_category="东南亚")
names = [x.get("name") for x in hits]
self.assertEqual(names, ["甲", "丙"])
def test_sea_staff_not_pulled(self):
book = MemoryLedger()
book.set_land_staff(
[{"roleCode": "sea", "status": "active", "wecomId": "s", "name": "海", "routes": "东南亚"}]
)
self.assertEqual(book.match_land_staff(route_category="东南亚"), [])
if __name__ == "__main__":
unittest.main()
@@ -81,6 +81,109 @@ class QuoteTemplateMatchTests(unittest.TestCase):
)
self.assertIsNone(hit)
def test_land_pinche_not_stolen_by_generic_land_keyword(self) -> None:
hit = pick_quote_template(
templates=[
{
"templateId": "hk",
"name": "中港",
"bizType": "陆运",
"keywords": ["中港", "陆运"],
"isDefault": False,
},
{
"templateId": "ltl",
"name": "国内拼车",
"bizType": "陆运",
"keywords": ["拼车", "零担"],
"isDefault": False,
},
{
"templateId": "d1",
"name": "陆运默认",
"bizType": "陆运",
"keywords": ["普件"],
"isDefault": True,
},
],
business_line="LAND",
facts={
"运输类型": "国内运输拼车",
"线路类别": "国内城配/拖车/打包",
"运输分类": "普件,常用线路/常规车型",
"起运港": "泉州市",
"目的港": "南京市",
},
)
self.assertIsNotNone(hit)
self.assertEqual(hit.match_type, "keyword")
self.assertEqual(hit.name, "国内拼车")
def test_land_cross_ftl_beats_domestic_ftl(self) -> None:
hit = pick_quote_template(
templates=[
{
"templateId": "dom",
"name": "国内整车",
"bizType": "陆运",
"keywords": ["整车", "国内"],
"isDefault": False,
},
{
"templateId": "xb",
"name": "跨境整车",
"bizType": "陆运",
"keywords": ["跨境", "整车"],
"isDefault": False,
},
],
business_line="LAND",
facts={"运输类型": "跨境整车", "线路类别": "东南亚"},
)
self.assertIsNotNone(hit)
self.assertEqual(hit.name, "跨境整车")
def test_land_falls_back_to_default(self) -> None:
hit = pick_quote_template(
templates=[
{
"templateId": "hk",
"name": "中港",
"bizType": "陆运",
"keywords": ["中港"],
"isDefault": False,
},
{
"templateId": "d1",
"name": "陆运默认",
"bizType": "陆运",
"keywords": ["急件"],
"isDefault": True,
},
],
business_line="LAND",
facts={"运输类型": "国内运输拼车", "运输分类": "普件,常用线路/常规车型"},
)
self.assertIsNotNone(hit)
self.assertEqual(hit.match_type, "default")
self.assertEqual(hit.name, "陆运默认")
def test_land_none_without_default(self) -> None:
hit = pick_quote_template(
templates=[
{
"templateId": "hk",
"name": "中港",
"bizType": "陆运",
"keywords": ["中港"],
"isDefault": False,
},
],
business_line="LAND",
facts={"运输类型": "国内运输拼车"},
)
self.assertIsNone(hit)
if __name__ == "__main__":
unittest.main()
@@ -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
+106
View File
@@ -0,0 +1,106 @@
"""
陆运 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.schema.tms_land_query import assemble_land_query
class TmsLandQueryTest(unittest.TestCase):
def test_outbound_omits_class_and_hk_load(self):
facts = {
"运输类型": "中港零担/集拼",
"线路类别": "东南亚",
"运输分类": "急件",
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
"车型/数量": "9米6/1",
"通关口岸": "皇岗",
}
got = assemble_land_query(facts)
self.assertTrue(got["ok"])
out = got["outbound"]
self.assertEqual(out["landTransportType"], "CHINA_HK")
self.assertEqual(out["routeCategory"], "SOUTHEAST_ASIA")
self.assertNotIn("landRouteCategory", out)
self.assertEqual(out["vehicleType"], "9米6")
self.assertEqual(str(out["quantity"]), "1")
self.assertEqual(out["customsPort"], "皇岗")
self.assertNotIn("运输分类", out)
self.assertNotIn("landClass", out)
self.assertNotIn("hkLoadType", out)
self.assertNotIn("中港装载方式", out)
def test_hk_full_truck_omits_vehicle(self):
facts = {
"运输类型": "中港整车",
"线路类别": "东南亚",
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
}
got = assemble_land_query(facts)
self.assertTrue(got["ok"])
out = got["outbound"]
self.assertNotIn("vehicleType", out)
self.assertNotIn("quantity", out)
def test_domestic_ftl_splits_combo(self):
facts = {
"运输类型": "国内运输整车",
"线路类别": "国内长途/零担",
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"车型/数量": "9米6*2",
}
out = assemble_land_query(facts)["outbound"]
self.assertEqual(out["vehicleType"], "9米6")
self.assertEqual(out["quantity"], "2")
def test_domestic_has_no_customs(self):
facts = {
"运输类型": "国内运输拼车",
"线路类别": "国内长途/零担",
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
}
out = assemble_land_query(facts)["outbound"]
self.assertNotIn("customsPort", out)
self.assertIn("volumeCbm", out)
self.assertEqual(out["landTransportType"], "DOMESTIC_LTL")
self.assertEqual(out["routeCategory"], "DOMESTIC_LONG_LTL")
def test_wo011_pinche_city_dist_uses_tms_enums(self):
facts = {
"运输类型": "国内运输拼车",
"线路类别": "国内城配/拖车/打包",
"起运港": "泉州市",
"目的港": "南京市",
"品名": "通信机柜及线缆配件",
"体积": "10CBM",
"毛重": "10KG",
}
got = assemble_land_query(facts)
self.assertTrue(got["ok"])
out = got["outbound"]
self.assertEqual(out["landTransportType"], "DOMESTIC_LTL")
self.assertEqual(out["routeCategory"], "DOMESTIC_CITY_DIST")
self.assertNotIn("landRouteCategory", out)
if __name__ == "__main__":
unittest.main()
@@ -160,6 +160,19 @@ public class InquiryAgentController {
return Result.OK(out);
}
@ApiOperation("陆运拉群:按线路类别匹配启用的产品·陆运")
@PostMapping("/staff/matchLand")
public Result<Map<String, Object>> matchLand(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
String route = firstStr(body, "routeCategory", "线路类别");
Map<String, Object> out = new java.util.LinkedHashMap<>();
out.put("staff", staffRoleService.matchLandStaff(route));
return Result.OK(out);
}
@ApiOperation("按岗位列出启用员工(空运群 @ 航线·空运)")
@PostMapping("/staff/listByRole")
public Result<Map<String, Object>> listStaffByRole(@RequestBody Map<String, Object> body,
@@ -36,6 +36,11 @@ public interface IInquiryStaffRoleService extends IService<InquiryStaffRole> {
*/
java.util.List<java.util.Map<String, Object>> matchSeaStaff(String origin, String destination);
/**
* 陆运拉群:启用的产品·陆运,按本单线路类别整词匹配负责线路。
*/
java.util.List<java.util.Map<String, Object>> matchLandStaff(String routeCategory);
/**
* 列出该岗位启用员工。空运群 @ 航线用 roleCode=air。
*/
@@ -534,10 +534,11 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
out.put("quote_no", StringUtils.defaultString(workOrderNo));
out.put("pol", first(src, "起运港", "pol", "origin"));
out.put("origin", first(src, "起运地", "起运港", "origin", "pol"));
out.put("origin", first(src, "起运地", "起运港", "始发站", "origin", "pol"));
out.put("pod", first(src, "目的地", "目的港", "pod", "destination"));
out.put("destination", first(src, "目的地", "目的港", "destination", "pod"));
out.put("commodity", first(src, "品名", "commodity"));
out.put("commodity", first(src, "品名", "货物品名", "commodity"));
out.put("vehicle_qty", first(src, "车型/数量", "车型"));
// 海运七项:合同键是 quantity / fcl_or_lcl / container_type_qty,工单事实是中文键
out.put("quantity", first(src, "货量", "货物数量", "quantity"));
out.put("fcl_or_lcl", first(src, "整柜或拼柜", "fcl_or_lcl"));
@@ -584,6 +585,16 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
out.put("currency", currency);
}
out.put("sales_name", StringUtils.defaultString(salesWecomId));
String eta = first(q, "eta", "transit_days", "transitDays");
if (StringUtils.isNotBlank(eta) && !"-".equals(eta)) {
out.put("transit_days", eta);
}
for (Map.Entry<String, String> e : QuoteTemplateExcelSupport.LAND_TMS_KEY_TO_CODE.entrySet()) {
String val = first(q, e.getKey(), e.getValue());
if (StringUtils.isNotBlank(val) && !"-".equals(val) && StringUtils.isBlank(out.getString(e.getValue()))) {
out.put(e.getValue(), val);
}
}
JSONArray rows = QuoteTemplateExcelSupport.parseFeeRows(q.get("fee_lines"));
if (rows.isEmpty()) {
rows = QuoteTemplateExcelSupport.parseFeeRows(q.get("fee_rows"));
@@ -592,7 +603,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
String freight = first(q, "freight");
if (StringUtils.isNotBlank(freight) && !"-".equals(freight)) {
JSONObject one = new JSONObject(true);
one.put("name", "空运费");
one.put("name", looksLandInquiry(src) ? "陆运费" : "空运费");
one.put("amount", freight);
if (StringUtils.isNotBlank(currency)) {
one.put("currency", currency);
@@ -608,12 +619,38 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
}
}
for (int i = 0; i < rows.size(); i++) {
JSONObject one = rows.getJSONObject(i);
if (one == null) {
continue;
}
String code = QuoteTemplateExcelSupport.landFactCodeForFeeName(one.getString("name"));
String amount = StringUtils.trimToEmpty(one.getString("amount"));
if (StringUtils.isNotBlank(code) && StringUtils.isNotBlank(amount) && !"-".equals(amount)
&& StringUtils.isBlank(out.getString(code))) {
out.put(code, amount);
}
}
if (!rows.isEmpty()) {
out.put("fee_rows", rows);
}
return out;
}
/**
* 询价事实里已有陆运选项/始发站,运费兜底名就不能再写成空运费。
*/
private static boolean looksLandInquiry(Map<String, Object> src) {
if (src == null || src.isEmpty()) {
return false;
}
String line = first(src, "业务线", "business_line", "businessLine");
if ("LAND".equalsIgnoreCase(line) || (line != null && line.contains("陆"))) {
return true;
}
return StringUtils.isNotBlank(first(src, "始发站", "运输类型", "线路类别"));
}
private static String first(Map<?, ?> src, String... keys) {
if (src == null) {
return "";
@@ -144,6 +144,17 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl<InquiryStaffRoleMap
return org.jeecg.modules.inquiry.staff.SeaStaffMatcher.match(rows, origin, destination);
}
/**
* 陆运拉群:库里启用的产品·陆运,再按线路类别整词过滤。
*/
@Override
public List<Map<String, Object>> matchLandStaff(String routeCategory) {
List<InquiryStaffRole> rows = list(new QueryWrapper<InquiryStaffRole>()
.eq("role_code", "land")
.eq("status", "active"));
return org.jeecg.modules.inquiry.staff.LandStaffMatcher.match(rows, routeCategory);
}
@Override
public List<Map<String, Object>> listActiveByRole(String roleCode) {
String code = StringUtils.trimToEmpty(roleCode).toLowerCase();
@@ -0,0 +1,84 @@
package org.jeecg.modules.inquiry.staff;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 陆运拉群人员匹配:只看员工管理里岗位=产品·陆运且启用的人。
* <p>
* 用本单线路类别对负责线路:按逗号/顿号拆整词,禁止把「中港/中亚/中欧」按斜杠切开。
* 没有「陆运全线」快捷项;四条都要跟就须后台四条都勾。
* 禁止:按销售 userid 伪造名单;不查企微通讯录。
*/
public final class LandStaffMatcher {
private LandStaffMatcher() {
}
/**
* @param rows 员工岗位行
* @param routeCategory 本单线路类别(与后台四项同字)
* @return 给智能体的 {name, wecomId, routes}
*/
public static List<Map<String, Object>> match(List<InquiryStaffRole> rows, String routeCategory) {
List<Map<String, Object>> hits = new ArrayList<>();
if (rows == null) {
return hits;
}
String want = StringUtils.trimToEmpty(routeCategory);
if (want.isEmpty()) {
return hits;
}
for (InquiryStaffRole row : rows) {
if (row == null) {
continue;
}
if (!"land".equalsIgnoreCase(StringUtils.trimToEmpty(row.getRoleCode()))) {
continue;
}
if (!"active".equalsIgnoreCase(StringUtils.defaultIfBlank(row.getStatus(), "active"))) {
continue;
}
if (StringUtils.isBlank(row.getWecomId())) {
continue;
}
if (!routeTokens(row.getRoutes()).contains(want)) {
continue;
}
Map<String, Object> item = new LinkedHashMap<>();
item.put("name", StringUtils.defaultString(row.getName()));
item.put("wecomId", row.getWecomId().trim());
item.put("roleCode", "land");
item.put("routes", StringUtils.defaultString(row.getRoutes()));
item.put("status", "active");
hits.add(item);
}
return hits;
}
/**
* 只按中英文逗号、顿号拆。斜杠是线路名称的一部分。
*/
static List<String> splitRoutes(String raw) {
List<String> out = new ArrayList<>();
if (raw == null || raw.trim().isEmpty()) {
return out;
}
for (String part : raw.split("[,,、]+")) {
String t = part.trim();
if (!t.isEmpty()) {
out.add(t);
}
}
return out;
}
private static List<String> routeTokens(String raw) {
return splitRoutes(raw);
}
}
@@ -0,0 +1,127 @@
package org.jeecg.modules.inquiry.template;
import org.apache.commons.lang3.StringUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* 陆运报价单字段词典(TMS 对接文档 9.5)。
* <p>
* 陆运 Excel 是「标签 + 右侧金额」,不是海运那种「费用名称 / 金额」表。
* 本类只做名称对照,不发明价格、不打 TMS。
*/
public final class LandQuoteTemplateFields {
private static final Map<String, String> LABEL_TO_CODE = new LinkedHashMap<>();
private static final Map<String, String> KEY_TO_CODE = new LinkedHashMap<>();
private static final Map<String, List<String>> CODE_TO_LABELS = new LinkedHashMap<>();
static {
add("price_per_cbm", "元/CBM", "pricePerCbm");
add("price_per_kg", "元/kg", "pricePerKg");
add("delivery_fee", "送货费", "deliveryFee");
add("pickup_fee", "提货费", "pickupFee");
add("min_charge", "最低收费", "minCharge");
add("transit_days", "时效(天)", "transitDays");
add("effective_time", "生效时间", "effectiveTime");
add("land_freight", "陆运费", "landFreight");
add("vehicle_42_fee", "4.2米/3T费用", "vehicle42Fee");
add("vehicle_50_fee", "5米/5T费用", "vehicle50Fee");
add("vehicle_76_fee", "7.6米/8T费用", "vehicle76Fee");
add("vehicle_82_fee", "8.2米/10T费用", "vehicle82Fee");
add("vehicle_96_fee", "9.6米/10T费用", "vehicle96Fee");
add("vehicle_12_fee", "12米/40尺费用", "vehicle12Fee");
add("vehicle_125_fee", "12.5米/40尺费用", "vehicle125Fee");
add("vehicle_165_fee", "16.5米/53尺费用", "vehicle165Fee");
add("vehicle_3t_fee", "4.1*2.1*2.3M/3T费用", "vehicle3TFee");
add("vehicle_5t_fee", "6.5*2.4*2.5M/5T费用", "vehicle5TFee");
add("vehicle_8t_fee", "7.5*2.4*2.5M/8T费用", "vehicle8TFee");
add("vehicle_10t_fee", "9.4*2.4*2.55M/10T费用", "vehicle10TFee");
add("vehicle_40hq_fee", "11.8*2.32*2.7M/40'HQ费用", "vehicle40HQFee");
add("vehicle_45hq_fee", "12.8*2.32*2.7M/45'HQ费用", "vehicle45HQFee");
add("vehicle_48rh_fee", "14.6*2.28*2.5M/48RH冷柜费用", "vehicle48RHFee");
add("vehicle_53r_fee", "16.3*2.7*2.8M/53尺费用", "vehicle53RFee");
add("vehicle_1375_fee", "13.5*3M/13.75平板费用", "vehicle13.75TFee");
add("vehicle_175_fee", "17.5*3M/17.5平板费用", "vehicle17.5TFee");
LABEL_TO_CODE.put("时效", "transit_days");
LABEL_TO_CODE.put("干线费", "land_freight");
LABEL_TO_CODE.put("运费", "land_freight");
LABEL_TO_CODE.put("4.2米/3T", "vehicle_42_fee");
LABEL_TO_CODE.put("7.6米/8T", "vehicle_76_fee");
LABEL_TO_CODE.put("9.6米/10T", "vehicle_96_fee");
LABEL_TO_CODE.put("元/KG", "price_per_kg");
}
private LandQuoteTemplateFields() {
}
/**
* Excel 标签 → 填表码,供按标签写右侧格子。
*/
public static Map<String, String> labelToCode() {
return Collections.unmodifiableMap(LABEL_TO_CODE);
}
/**
* TMS 扁平字段 → 填表码。
*/
public static Map<String, String> tmsKeyToCode() {
return Collections.unmodifiableMap(KEY_TO_CODE);
}
/**
* 费用名 / TMS key / 标签任一能对上则回填表码,对不上给空。
*/
public static String codeForFeeName(String raw) {
if (StringUtils.isBlank(raw)) {
return "";
}
String name = raw.trim();
if (LABEL_TO_CODE.containsKey(name)) {
return LABEL_TO_CODE.get(name);
}
if (KEY_TO_CODE.containsKey(name)) {
return KEY_TO_CODE.get(name);
}
String lower = name.toLowerCase(Locale.ROOT);
for (Map.Entry<String, String> e : LABEL_TO_CODE.entrySet()) {
String label = e.getKey();
if (label.length() >= 4 && (name.contains(label) || label.contains(name))) {
return e.getValue();
}
if ("元/CBM".equals(label) && (name.contains("CBM") || name.contains("cbm"))) {
return e.getValue();
}
}
for (Map.Entry<String, String> e : KEY_TO_CODE.entrySet()) {
if (e.getKey().equalsIgnoreCase(lower)) {
return e.getValue();
}
}
return "";
}
/**
* 一个填表码对应的 Excel 标签,找格子时按这个顺序试。
*/
public static List<String> labelsFor(String code) {
if (StringUtils.isBlank(code)) {
return Collections.emptyList();
}
List<String> labels = CODE_TO_LABELS.get(code);
return labels == null ? Collections.emptyList() : Collections.unmodifiableList(labels);
}
private static void add(String code, String label, String tmsKey) {
LABEL_TO_CODE.put(label, code);
if (StringUtils.isNotBlank(tmsKey)) {
KEY_TO_CODE.put(tmsKey, code);
}
CODE_TO_LABELS.computeIfAbsent(code, k -> new ArrayList<String>()).add(label);
}
}
@@ -69,11 +69,82 @@ public final class QuoteTemplateExcelSupport {
FILL_BY_LABEL.put("重量", "weight_kg");
FILL_BY_LABEL.put("体积", "volume_cbm");
FILL_BY_LABEL.put("始发地", "origin");
FILL_BY_LABEL.put("始发站", "origin");
FILL_BY_LABEL.put("目的地", "destination");
FILL_BY_LABEL.put("货物品名", "commodity");
FILL_BY_LABEL.put("车型/数量", "vehicle_qty");
FILL_BY_LABEL.put("运输类型", "运输类型");
FILL_BY_LABEL.put("线路类别", "线路类别");
FILL_BY_LABEL.put("运输分类", "运输分类");
FILL_BY_LABEL.put("中港装载方式", "中港装载方式");
FILL_BY_LABEL.put("币别", "currency");
FILL_BY_LABEL.put("业务员", "sales_name");
FILL_BY_LABEL.put("客户名称", "customer_name");
FILL_BY_LABEL.put("联系人", "contact_name");
// 陆运报价单是标签栏(TMS 9.5),金额写在标签右侧
FILL_BY_LABEL.put("元/CBM", "price_per_cbm");
FILL_BY_LABEL.put("元/kg", "price_per_kg");
FILL_BY_LABEL.put("元/KG", "price_per_kg");
FILL_BY_LABEL.put("送货费", "delivery_fee");
FILL_BY_LABEL.put("提货费", "pickup_fee");
FILL_BY_LABEL.put("最低收费", "min_charge");
FILL_BY_LABEL.put("时效(天)", "transit_days");
FILL_BY_LABEL.put("时效", "transit_days");
FILL_BY_LABEL.put("生效时间", "effective_time");
FILL_BY_LABEL.put("陆运费", "land_freight");
FILL_BY_LABEL.put("干线费", "land_freight");
FILL_BY_LABEL.put("4.2米/3T费用", "vehicle_42_fee");
FILL_BY_LABEL.put("4.2米/3T", "vehicle_42_fee");
FILL_BY_LABEL.put("5米/5T费用", "vehicle_50_fee");
FILL_BY_LABEL.put("7.6米/8T费用", "vehicle_76_fee");
FILL_BY_LABEL.put("7.6米/8T", "vehicle_76_fee");
FILL_BY_LABEL.put("8.2米/10T费用", "vehicle_82_fee");
FILL_BY_LABEL.put("9.6米/10T费用", "vehicle_96_fee");
FILL_BY_LABEL.put("12米/40尺费用", "vehicle_12_fee");
FILL_BY_LABEL.put("12.5米/40尺费用", "vehicle_125_fee");
FILL_BY_LABEL.put("16.5米/53尺费用", "vehicle_165_fee");
FILL_BY_LABEL.put("4.1*2.1*2.3M/3T费用", "vehicle_3t_fee");
FILL_BY_LABEL.put("6.5*2.4*2.5M/5T费用", "vehicle_5t_fee");
FILL_BY_LABEL.put("7.5*2.4*2.5M/8T费用", "vehicle_8t_fee");
FILL_BY_LABEL.put("9.4*2.4*2.55M/10T费用", "vehicle_10t_fee");
FILL_BY_LABEL.put("11.8*2.32*2.7M/40'HQ费用", "vehicle_40hq_fee");
FILL_BY_LABEL.put("12.8*2.32*2.7M/45'HQ费用", "vehicle_45hq_fee");
FILL_BY_LABEL.put("14.6*2.28*2.5M/48RH冷柜费用", "vehicle_48rh_fee");
FILL_BY_LABEL.put("16.3*2.7*2.8M/53尺费用", "vehicle_53r_fee");
FILL_BY_LABEL.put("13.5*3M/13.75平板费用", "vehicle_1375_fee");
FILL_BY_LABEL.put("17.5*3M/17.5平板费用", "vehicle_175_fee");
}
/**
* 陆运 TMS 扁平字段 → 填表码。费用名对不上时再按这个取。
*/
public static final Map<String, String> LAND_TMS_KEY_TO_CODE = new LinkedHashMap<>();
static {
LAND_TMS_KEY_TO_CODE.put("pricePerCbm", "price_per_cbm");
LAND_TMS_KEY_TO_CODE.put("pricePerKg", "price_per_kg");
LAND_TMS_KEY_TO_CODE.put("deliveryFee", "delivery_fee");
LAND_TMS_KEY_TO_CODE.put("pickupFee", "pickup_fee");
LAND_TMS_KEY_TO_CODE.put("minCharge", "min_charge");
LAND_TMS_KEY_TO_CODE.put("transitDays", "transit_days");
LAND_TMS_KEY_TO_CODE.put("effectiveTime", "effective_time");
LAND_TMS_KEY_TO_CODE.put("landFreight", "land_freight");
LAND_TMS_KEY_TO_CODE.put("vehicle42Fee", "vehicle_42_fee");
LAND_TMS_KEY_TO_CODE.put("vehicle50Fee", "vehicle_50_fee");
LAND_TMS_KEY_TO_CODE.put("vehicle76Fee", "vehicle_76_fee");
LAND_TMS_KEY_TO_CODE.put("vehicle82Fee", "vehicle_82_fee");
LAND_TMS_KEY_TO_CODE.put("vehicle96Fee", "vehicle_96_fee");
LAND_TMS_KEY_TO_CODE.put("vehicle12Fee", "vehicle_12_fee");
LAND_TMS_KEY_TO_CODE.put("vehicle125Fee", "vehicle_125_fee");
LAND_TMS_KEY_TO_CODE.put("vehicle165Fee", "vehicle_165_fee");
LAND_TMS_KEY_TO_CODE.put("vehicle3TFee", "vehicle_3t_fee");
LAND_TMS_KEY_TO_CODE.put("vehicle5TFee", "vehicle_5t_fee");
LAND_TMS_KEY_TO_CODE.put("vehicle8TFee", "vehicle_8t_fee");
LAND_TMS_KEY_TO_CODE.put("vehicle10TFee", "vehicle_10t_fee");
LAND_TMS_KEY_TO_CODE.put("vehicle40HQFee", "vehicle_40hq_fee");
LAND_TMS_KEY_TO_CODE.put("vehicle45HQFee", "vehicle_45hq_fee");
LAND_TMS_KEY_TO_CODE.put("vehicle48RHFee", "vehicle_48rh_fee");
LAND_TMS_KEY_TO_CODE.put("vehicle53RFee", "vehicle_53r_fee");
}
private QuoteTemplateExcelSupport() {
@@ -409,16 +480,35 @@ public final class QuoteTemplateExcelSupport {
continue;
}
Cell nameCell = findCellByFeeName(sheet, name, usedRows);
if (nameCell == null) {
nameCell = findCellByLandFeeAlias(sheet, name, usedRows);
}
if (nameCell == null) {
unmatched.add(rowObj);
continue;
}
int r = nameCell.getRowIndex();
usedRows.add(r);
if (shouldWriteLandFeeBesideLabel(sheet, name, nameCell)) {
// 陆运模板没有费用表头:金额写到标签右侧,禁止落到默认 G 列
writeAmountBesideLabel(sheet, nameCell, amount, yellow);
continue;
}
Map<String, Integer> cols = findFeeTableColumnsAbove(sheet, r);
writeFeeDetailRow(sheet, r, cols, rowObj, headerCurrency, boxType, yellow, false);
}
for (JSONObject rowObj : unmatched) {
if (shouldWriteLandFeeBesideLabel(sheet, rowObj.getString("name"), null)
&& findFeeTableColumns(sheet).isEmpty()) {
Cell alias = findCellByLandFeeAlias(sheet, rowObj.getString("name"), usedRows);
if (alias != null) {
usedRows.add(alias.getRowIndex());
writeAmountBesideLabel(sheet, alias, rowObj.getString("amount"), yellow);
continue;
}
log.warn("报价单没有空行可追加费用 {}", rowObj.getString("name"));
continue;
}
int empty = findEmptyFeeDataRow(sheet, usedRows);
if (empty < 0) {
log.warn("报价单没有空行可追加费用 {}", rowObj.getString("name"));
@@ -431,6 +521,96 @@ public final class QuoteTemplateExcelSupport {
}
}
/**
* 陆运标签栏且本表没有「费用名称+金额」表头时,金额写右侧。
* 空运「空运费」行即使没有完整表头,仍走原来的金额列兜底。
*/
private static boolean shouldWriteLandFeeBesideLabel(Sheet sheet, String feeName, Cell nameCell) {
if (!findFeeTableColumns(sheet).isEmpty()) {
return false;
}
String code = landFactCodeForFeeName(feeName);
if (!isLandQuoteFactCode(code) && nameCell != null) {
code = landFactCodeForFeeName(readCellText(nameCell));
}
return isLandQuoteFactCode(code);
}
/**
* 陆运标签栏:金额写到标签右侧空格,和询价字段同一套定位。
*/
private static void writeAmountBesideLabel(Sheet sheet, Cell labelCell, String amount, Set<String> yellow) {
if (sheet == null || labelCell == null || StringUtils.isBlank(amount) || "-".equals(amount.trim())) {
return;
}
String fillRef = resolveFillTarget(sheet, labelCell, sheet.getSheetName(),
readCellText(labelCell), new DataFormatter());
int rowIdx;
int colIdx;
if (StringUtils.isNotBlank(fillRef)) {
ParsedRef ref = ParsedRef.parse(fillRef);
rowIdx = ref.row;
colIdx = ref.col;
} else {
rowIdx = labelCell.getRowIndex();
colIdx = labelCell.getColumnIndex() + 1;
}
writeAmountCell(sheet, rowIdx, colIdx, amount, yellow);
}
/**
* TMS 费用名是 pricePerCbm / 4.2米/3T 时,按陆运标签再找一格。
*/
private static Cell findCellByLandFeeAlias(Sheet sheet, String name, Set<Integer> usedRows) {
String code = landFactCodeForFeeName(name);
if (StringUtils.isBlank(code)) {
return null;
}
for (Map.Entry<String, String> e : FILL_BY_LABEL.entrySet()) {
if (!code.equals(e.getValue())) {
continue;
}
Cell hit = findCellByFeeName(sheet, e.getKey(), usedRows);
if (hit != null) {
return hit;
}
}
return null;
}
/**
* 陆运费用名 / TMS key 对到填表码。对不上给空,不发明价格。
*/
public static String landFactCodeForFeeName(String raw) {
if (StringUtils.isBlank(raw)) {
return "";
}
String name = raw.trim();
if (FILL_BY_LABEL.containsKey(name)) {
String code = FILL_BY_LABEL.get(name);
if (isLandQuoteFactCode(code)) {
return code;
}
}
if (LAND_TMS_KEY_TO_CODE.containsKey(name)) {
return LAND_TMS_KEY_TO_CODE.get(name);
}
String matched = matchLabel(name, FILL_BY_LABEL);
if (isLandQuoteFactCode(matched)) {
return matched;
}
return "";
}
private static boolean isLandQuoteFactCode(String code) {
if (StringUtils.isBlank(code)) {
return false;
}
return code.startsWith("price_per_") || code.startsWith("vehicle_")
|| "delivery_fee".equals(code) || "pickup_fee".equals(code)
|| "min_charge".equals(code) || "transit_days".equals(code)
|| "effective_time".equals(code) || "land_freight".equals(code);
}
/**
* 写一行费用。writeName=false 时不碰已有费目名,避免把「文件费/Doc.」盖成数量 1。
*/
@@ -1088,7 +1268,7 @@ public final class QuoteTemplateExcelSupport {
String noColon = primary.replace(":", "").trim();
for (Map.Entry<String, String> e : dict.entrySet()) {
String dk = e.getKey().replace(":", ":").replace(":", "").trim();
if (dk.length() < 2 || noColon.length() < 2) {
if (dk.length() < 3 || noColon.length() < 3) {
continue;
}
if (dk.equalsIgnoreCase(noColon) || noColon.startsWith(dk) || dk.startsWith(noColon)) {
@@ -1257,8 +1437,26 @@ public final class QuoteTemplateExcelSupport {
if (primary.equals(want)) {
return true;
}
return primary.equals(want + "用") || primary.equals(want + "费用")
|| want.equals(primary + "用") || want.equals(primary + "费用");
if (primary.equals(want + "用") || primary.equals(want + "费用")
|| want.equals(primary + "用") || want.equals(primary + "费用")) {
return true;
}
// 陆运车型标签「4.2米/3T」与「4.2米/3T费用」视为同一格;「元」太短不能这样对
String wantPrimary = feePrimaryName(want);
if (StringUtils.isNotBlank(wantPrimary) && primary.equals(wantPrimary)
&& distinctiveLandFeeToken(primary)) {
return true;
}
return false;
}
private static boolean distinctiveLandFeeToken(String text) {
if (StringUtils.isBlank(text) || text.length() < 3) {
return false;
}
return text.contains("米") || text.contains("CBM") || text.contains("cbm")
|| text.toUpperCase(Locale.ROOT).contains("KG") || text.contains("HQ")
|| text.contains("T费用") || text.contains("平板");
}
static Integer pickContainerRateColumn(Map<String, Integer> cols, String boxType) {
@@ -78,7 +78,10 @@ public final class QuoteTemplateMatcher {
}
/**
* 先扫同业务线关键词(列表已按默认优先排序),再退回该业务默认模板。
* 同业务线:先按关键词打分(运输类型上的词优先),再退回该业务默认模板。
* <p>
* 「陆运/海运/空运/国内」不当关键词:业务线已过滤,haystack 里必有「陆运」,
* 否则中港模板会抢走国内拼车单。多模板同时命中时,类型命中更多者胜。
*
* @return 命中的模板 Map,并写入 matchType=keyword|default;都没有则 null
*/
@@ -90,7 +93,13 @@ public final class QuoteTemplateMatcher {
) {
String want = normalizeBiz(businessLine);
String haystack = buildHaystack(businessLine, facts, quote).toLowerCase(Locale.ROOT);
String typeVal = "";
if (facts != null && facts.get("运输类型") != null) {
typeVal = String.valueOf(facts.get("运输类型")).trim().toLowerCase(Locale.ROOT);
}
Map<String, Object> keywordHit = null;
int bestTypeHits = -1;
int bestLength = -1;
Map<String, Object> defaultHit = null;
if (templates == null) {
return null;
@@ -106,7 +115,13 @@ public final class QuoteTemplateMatcher {
if (defaultHit == null && isDefault(t.get("isDefault"))) {
defaultHit = t;
}
if (keywordHit == null && keywordsHit(t.get("keywords"), haystack)) {
int[] score = keywordScore(t.get("keywords"), haystack, typeVal);
if (score[0] == 0 && score[1] == 0) {
continue;
}
if (score[0] > bestTypeHits || (score[0] == bestTypeHits && score[1] > bestLength)) {
bestTypeHits = score[0];
bestLength = score[1];
keywordHit = t;
}
}
@@ -119,13 +134,46 @@ public final class QuoteTemplateMatcher {
return null;
}
private static boolean keywordsHit(Object raw, String haystack) {
/**
* 业务线名、过宽的「国内」不当关键词。
*/
static boolean skipKeyword(String kw) {
if (kw == null || kw.isEmpty()) {
return true;
}
String k = kw.toLowerCase(Locale.ROOT);
return "海运".equals(k) || "空运".equals(k) || "陆运".equals(k)
|| "sea".equals(k) || "air".equals(k) || "land".equals(k)
|| "国内".equals(k);
}
/**
* @return [运输类型命中数, 命中词总长];都是 0 表示未命中
*/
static int[] keywordScore(Object raw, String haystack, String typeVal) {
int typeHits = 0;
int length = 0;
boolean any = false;
String type = typeVal == null ? "" : typeVal;
String hay = haystack == null ? "" : haystack;
for (String kw : asKeywords(raw)) {
if (haystack.contains(kw.toLowerCase(Locale.ROOT))) {
return true;
if (skipKeyword(kw)) {
continue;
}
String k = kw.toLowerCase(Locale.ROOT);
if (!type.isEmpty() && type.contains(k)) {
typeHits++;
length += k.length();
any = true;
} else if (hay.contains(k)) {
length += k.length();
any = true;
}
}
return false;
if (!any) {
return new int[] {0, 0};
}
return new int[] {typeHits, length};
}
/**
@@ -27,7 +27,8 @@ import java.util.TimeZone;
/**
* 智能体查价门面:询价必填齐了就必须打真 TMS,再按 TMS 回包分类。
* <p>
* 职责:询价必填齐了就出站。空运看件数/重量/体积/包装;海运看货量/整柜或拼柜/箱型箱量,不拦空运量尺。
* 职责:询价必填齐了就出站。空运看件数/重量/体积/包装;海运看货量/整柜或拼柜/箱型箱量;
* 陆运看类型+线路+体积重量或车型数量,不拦包装,不把运输分类/中港装载送 TMS。
* 机场三字码能投影就带上;对不上也必须出站,禁止本地判「暂无报价」。
* 业务线以工单为准,禁止海运请求被默认成 AIR。
* 分类:1 且有价 → 报价;TMS 已回 1002 / 无 quoteFound / 1003 → 暂无报价;
@@ -91,6 +92,9 @@ public class InquiryTmsQueryService {
if (items != null) {
segment.put("containerItems", items);
}
} else if ("LAND".equals(line)) {
// 陆运不套空运包装/件数;分类和中港装载只在工单,mapper 不会放进出站。
segment.putAll(TmsLandSegmentMapper.build(facts));
} else {
segment.put("quantity", TmsV2RequestMapper.asInteger(facts.get("件数")));
segment.put("weightKg", TmsV2RequestMapper.asPositiveDecimal(facts.get("毛重")));
@@ -107,9 +111,14 @@ public class InquiryTmsQueryService {
quoteDate = fmt.format(new Date());
}
List<String> missing = "SEA".equals(line)
? missingSeaQuery(workOrderNo, quoteDate, segment, facts)
: missingAirQuery(workOrderNo, quoteDate, segment);
List<String> missing;
if ("SEA".equals(line)) {
missing = missingSeaQuery(workOrderNo, quoteDate, segment, facts);
} else if ("LAND".equals(line)) {
missing = TmsLandSegmentMapper.missingQuery(workOrderNo, quoteDate, segment, facts);
} else {
missing = missingAirQuery(workOrderNo, quoteDate, segment);
}
if (!missing.isEmpty()) {
log.warn("tms.query skip incomplete wo={} missing={}", workOrderNo, missing);
return incomplete(missing);
@@ -134,6 +143,10 @@ public class InquiryTmsQueryService {
String cacheKey = line + ":" + quoteDate + ":" + origin + ":" + destination
+ ":" + segment.get("weightKg") + ":" + segment.get("volumeCbm");
if ("LAND".equals(line)) {
cacheKey += ":" + segment.get("landTransportType") + ":" + segment.get("routeCategory")
+ ":" + segment.get("vehicleType") + ":" + segment.get("quantity");
}
try {
Map<String, Object> tms = gateway.query(internal, cacheKey);
return classifyAndPersist(workOrderNo, tms);
@@ -554,7 +567,7 @@ public class InquiryTmsQueryService {
}
/**
* 工单已落库的海运事实补进查价 facts,避免智能体漏传货量/箱型。
* 工单已落库的事实补进查价 facts。海运补港口货量箱型;陆运 factsJson 里的类型/线路/分类一并带回。
*/
static void mergeTicketFacts(Map<String, Object> facts, InquiryTicket ticket) {
if (facts == null || ticket == null) {
@@ -0,0 +1,68 @@
package org.jeecg.modules.inquiry.tms;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 陆运 TMS 6.1.1 枚举:销售中文 → 出站码。
* <p>
* 职责:只在打 TMS 前翻译运输类型、线路类别。工单/卡片/拉人仍用中文。
* 禁止:把运输分类、中港装载方式译进出站;发明表外码。
*/
public final class TmsLandEnumMapper {
private static final Map<String, String> TYPE = new LinkedHashMap<String, String>();
private static final Map<String, String> ROUTE = new LinkedHashMap<String, String>();
static {
TYPE.put("国内运输拼车", "DOMESTIC_LTL");
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");
TYPE.put("CROSS_BORDER_LTL", "CROSS_BORDER_LTL");
TYPE.put("CROSS_BORDER_FTL", "CROSS_BORDER_FTL");
TYPE.put("CHINA_HK", "CHINA_HK");
ROUTE.put("国内长途/零担", "DOMESTIC_LONG_LTL");
ROUTE.put("国内城配/拖车/打包", "DOMESTIC_CITY_DIST");
ROUTE.put("中港/中亚/中欧", "CN_HK_CA_EU");
ROUTE.put("东南亚", "SOUTHEAST_ASIA");
ROUTE.put("DOMESTIC_LONG_LTL", "DOMESTIC_LONG_LTL");
ROUTE.put("DOMESTIC_CITY_DIST", "DOMESTIC_CITY_DIST");
ROUTE.put("CN_HK_CA_EU", "CN_HK_CA_EU");
ROUTE.put("SOUTHEAST_ASIA", "SOUTHEAST_ASIA");
}
private TmsLandEnumMapper() {
}
/**
* @param raw 销售中文或已是 TMS 码
* @return 6.1.1 landTransportType;对不上返回空串,禁止用中文顶上
*/
public static String transportType(String raw) {
if (raw == null) {
return "";
}
String key = raw.trim();
String code = TYPE.get(key);
return code == null ? "" : code;
}
/**
* @param raw 销售中文或已是 TMS 码
* @return 6.1.1 routeCategory;对不上返回空串
*/
public static String routeCategory(String raw) {
if (raw == null) {
return "";
}
String key = raw.trim();
String code = ROUTE.get(key);
return code == null ? "" : code;
}
}
@@ -0,0 +1,206 @@
package org.jeecg.modules.inquiry.tms;
import org.apache.commons.lang3.StringUtils;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 陆运 TMS 查价段:按运输类型拼出站字段。
* <p>
* 职责:把工单 facts 里的中文键写成 TMS 段;运输分类只留在工单,不进出站。
* 拼车/跨境集拼带体积重量;国内整车/跨境整车/中港零担带「车型/数量」一项再拆成出站车型与台数;
* 中港整车不带车型/数量。通关口岸有值才放。
* 中港整车、中港零担/集拼出站类型都是 CHINA_HK。
* 类型/线路出站必须是 TMS 6.1.1 英文枚举,线路键名 routeCategory。
* 禁止:按空运要包装件数;把分类当查价条件;直连 TMS。
*/
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("国内运输整车", "跨境整车", "中港零担/集拼"));
private static final Pattern VEHICLE_SPLIT = Pattern.compile("\\s*[/xX×**]\\s*");
private static final Pattern TAIL_QTY = Pattern.compile("^(.*?)[\\s,,]+(\\d+)\\s*(台|辆)?$");
private TmsLandSegmentMapper() {
}
/**
* @param facts 工单/智能体中文 facts(含运输类型、线路类别、货物)
* @return 可并入查价 segment 的键;不含 landClass / hkLoadType
*/
public static Map<String, Object> build(Map<String, Object> facts) {
Map<String, Object> source = facts == null ? new LinkedHashMap<String, Object>() : facts;
Map<String, Object> seg = new LinkedHashMap<String, Object>();
String type = text(source.get("运输类型"));
String route = text(source.get("线路类别"));
put(seg, "landTransportType", TmsLandEnumMapper.transportType(type));
put(seg, "routeCategory", TmsLandEnumMapper.routeCategory(route));
put(seg, "origin", text(source.get("起运港")));
put(seg, "destination", text(source.get("目的港")));
put(seg, "cargoName", text(source.get("品名")));
if (LCL.contains(type)) {
put(seg, "volumeCbm", TmsV2RequestMapper.asPositiveDecimal(source.get("体积")));
put(seg, "weightKg", TmsV2RequestMapper.asPositiveDecimal(source.get("毛重")));
} else if ("中港整车".equals(type)) {
// 中港整车:询价不要车型/数量,出站也不带
} else if (FTL.contains(type)) {
putVehicleQty(source, seg);
}
String customs = text(source.get("通关口岸"));
if (StringUtils.isNotBlank(customs)) {
seg.put("customsPort", customs);
}
return seg;
}
/**
* 陆运出站拦网:工单号、报价日期、起运目的品名、类型、线路,再加当前类型货物。
* 不要包装、不要运输分类。中港整车不要车型/数量。
*
* @param facts 用于读运输类型;禁止把分类写进 segment
*/
public static List<String> missingQuery(String workOrderNo, String quoteDate,
Map<String, Object> segment,
Map<String, Object> facts) {
List<String> missing = new ArrayList<String>();
if (workOrderNo == null || !workOrderNo.matches("WO\\d{12}")) {
missing.add("workOrderNo");
}
if (quoteDate == null || quoteDate.isEmpty()) {
missing.add("quoteDate");
}
Map<String, Object> seg = segment == null ? new LinkedHashMap<String, Object>() : segment;
String[] required = {
"origin", "destination", "cargoName", "landTransportType", "routeCategory"
};
for (String key : required) {
if (blank(seg.get(key))) {
missing.add(key);
}
}
String typeEnum = text(seg.get("landTransportType"));
String cnType = text(facts == null ? null : facts.get("运输类型"));
boolean lcl = LCL.contains(cnType)
|| "DOMESTIC_LTL".equals(typeEnum)
|| "CROSS_BORDER_LTL".equals(typeEnum);
boolean hkFull = "中港整车".equals(cnType);
boolean ftl = FTL.contains(cnType)
|| "DOMESTIC_FTL".equals(typeEnum)
|| "CROSS_BORDER_FTL".equals(typeEnum)
|| "中港零担/集拼".equals(cnType);
if (lcl) {
if (seg.get("volumeCbm") == null) {
missing.add("volumeCbm");
}
if (seg.get("weightKg") == null) {
missing.add("weightKg");
}
} else if (hkFull) {
// 中港整车不拦车型/数量
} else if (ftl) {
if (blank(seg.get("vehicleType"))) {
missing.add("vehicleType");
}
if (seg.get("quantity") == null) {
missing.add("quantity");
}
}
return missing;
}
/**
* 兼容旧调用:没有 facts 时按段上的类型拦;中港整车必须走四参才能放过车型。
*/
public static List<String> missingQuery(String workOrderNo, String quoteDate,
Map<String, Object> segment) {
return missingQuery(workOrderNo, quoteDate, segment, null);
}
/**
* 销售侧「车型/数量」是一个字段,出站再拆成 vehicleType + quantity。
*/
private static void putVehicleQty(Map<String, Object> source, Map<String, Object> seg) {
String combo = firstNonBlank(
text(source.get("车型/数量")),
text(source.get("车型数量")));
String vehicle = text(source.get("车型"));
String qtyRaw = text(source.get("数量"));
if (combo.isEmpty()) {
if (!vehicle.isEmpty() && !qtyRaw.isEmpty()) {
combo = vehicle + "/" + qtyRaw;
} else if (!vehicle.isEmpty()) {
combo = vehicle;
}
}
if (combo.isEmpty()) {
return;
}
String[] parts = splitVehicleQty(combo);
String qty = parts[1];
if (StringUtils.isNotBlank(qtyRaw) && !combo.contains(qtyRaw)) {
qty = qtyRaw;
}
put(seg, "vehicleType", parts[0]);
Integer number = TmsV2RequestMapper.asInteger(qty);
put(seg, "quantity", number == null ? Integer.valueOf(1) : number);
}
/**
* 例:9米6/1、9米6*2、9米6 1台。拆不出台数时默认 1。
*/
static String[] splitVehicleQty(String raw) {
String text = text(raw);
if (text.isEmpty()) {
return new String[] {"", "1"};
}
String[] parts = VEHICLE_SPLIT.split(text, 2);
if (parts.length == 2 && StringUtils.isNotBlank(parts[0]) && StringUtils.isNotBlank(parts[1])) {
Integer n = TmsV2RequestMapper.asInteger(parts[1]);
return new String[] {parts[0].trim(), n == null ? parts[1].trim() : String.valueOf(n)};
}
Matcher tail = TAIL_QTY.matcher(text);
if (tail.matches() && StringUtils.isNotBlank(tail.group(1))) {
return new String[] {tail.group(1).trim(), tail.group(2)};
}
return new String[] {text, "1"};
}
private static String firstNonBlank(String... values) {
if (values == null) {
return "";
}
for (String value : values) {
if (StringUtils.isNotBlank(value)) {
return value.trim();
}
}
return "";
}
private static void put(Map<String, Object> target, String key, Object value) {
if (value != null && !"".equals(value)) {
target.put(key, value);
}
}
private static boolean blank(Object raw) {
return raw == null || String.valueOf(raw).trim().isEmpty();
}
private static String text(Object raw) {
return raw == null ? "" : String.valueOf(raw).trim();
}
}
@@ -80,6 +80,7 @@ public final class TmsV2RequestMapper {
// 海运出站:口语必须先整理。FCL 要 containerType+containerQty,loadType 只能 FCL/LCL,
// cargoQty 只能是数字。销售侧「整柜 / 1x40HQ / 15件」原样留在工单,不在这里改。
applySeaFields(source, target);
applyLandFields(source, target);
return compact(target);
}
@@ -114,6 +115,23 @@ public final class TmsV2RequestMapper {
put(target, "containerQty", asInteger(qty));
}
/**
* 陆运出站:类型+线路用 6.1.1 枚举;线路字段名是 routeCategory。
* 分类和中港装载即使误放在 source 也不抄走。中文不得原样出站。
*/
private static void applyLandFields(Map<String, Object> source, Map<String, Object> target) {
String type = textOf(first(source, "landTransportType"));
String route = textOf(first(source, "routeCategory", "landRouteCategory"));
put(target, "landTransportType", TmsLandEnumMapper.transportType(type));
put(target, "routeCategory", TmsLandEnumMapper.routeCategory(route));
put(target, "vehicleType", first(source, "vehicleType"));
put(target, "customsPort", first(source, "customsPort"));
}
private static String textOf(Object raw) {
return raw == null ? "" : String.valueOf(raw).trim();
}
/**
* 报价日期规范成 yyyy-MM-dd。解析不了则不传,避免 1001。
*/
@@ -0,0 +1,56 @@
package org.jeecg.modules.inquiry.staff;
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 陆运拉群人员匹配:不连库。
* <p>
* 只认产品·陆运启用岗;负责线路按逗号整词对线路类别,禁止按斜杠切开「中港/中亚/中欧」。
*/
public class LandStaffMatcherTest {
@Test
public void southeastOnly() {
InquiryStaffRole a = staff("land", "active", "a", "甲", "东南亚");
InquiryStaffRole b = staff("land", "active", "b", "乙", "国内长途/零担");
InquiryStaffRole c = staff("land", "active", "c", "丙", "中港/中亚/中欧,东南亚");
InquiryStaffRole d = staff("land", "active", "d", "丁", "中港/中亚/中欧");
List<Map<String, Object>> hits = LandStaffMatcher.match(
Arrays.asList(a, b, c, d), "东南亚");
assertEquals(2, hits.size());
assertEquals("甲", hits.get(0).get("name"));
assertEquals("丙", hits.get(1).get("name"));
}
@Test
public void slashNotSplit() {
InquiryStaffRole row = staff("land", "active", "x", "张", "中港/中亚/中欧");
assertEquals(1, LandStaffMatcher.match(Arrays.asList(row), "中港/中亚/中欧").size());
assertEquals(0, LandStaffMatcher.match(Arrays.asList(row), "东南亚").size());
}
@Test
public void ignoreSeaRole() {
InquiryStaffRole sea = staff("sea", "active", "s", "海", "东南亚");
assertTrue(LandStaffMatcher.match(Arrays.asList(sea), "东南亚").isEmpty());
}
private static InquiryStaffRole staff(String role, String status, String wecomId,
String name, String routes) {
InquiryStaffRole row = new InquiryStaffRole();
row.setRoleCode(role);
row.setStatus(status);
row.setWecomId(wecomId);
row.setName(name);
row.setRoutes(routes);
return row;
}
}
@@ -14,6 +14,7 @@ import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.apache.poi.ss.usermodel.CellType;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -487,6 +488,109 @@ public class QuoteTemplateFillTest {
}
}
/**
* 陆运国内拼车模板是标签栏,不是「费用名称+金额」表。
* TMS 的 元/CBM、送货费、时效必须写到标签右侧,不能落到 G 列或整张空着。
*/
@Test
public void landLtlTemplateWritesTmsRatesBesideLabels() throws Exception {
Path src = Files.createTempFile("quote-land-ltl-src", ".xlsx");
Path out = Files.createTempFile("quote-land-ltl-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("国内拼车");
row(sheet, 1).createCell(0).setCellValue("始发站");
row(sheet, 2).createCell(0).setCellValue("目的地");
row(sheet, 3).createCell(0).setCellValue("元/CBM");
row(sheet, 4).createCell(0).setCellValue("元/kg");
row(sheet, 5).createCell(0).setCellValue("送货费");
row(sheet, 6).createCell(0).setCellValue("时效(天)");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject facts = new JSONObject(true);
facts.put("origin", "泉州");
facts.put("destination", "南京");
facts.put("price_per_cbm", "280");
facts.put("price_per_kg", "1.2");
facts.put("delivery_fee", "50");
facts.put("transit_days", "3");
facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows(
"[{\"name\":\"元/CBM\",\"amount\":\"280\"},"
+ "{\"name\":\"元/kg\",\"amount\":\"1.2\"},"
+ "{\"name\":\"送货费\",\"amount\":\"50\"}]"));
QuoteTemplateExcelSupport.fillWorkbook(src, out, new JSONObject(), facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertEquals("泉州", read(sheet, 1, 1));
assertEquals("南京", read(sheet, 2, 1));
assertEquals(280.0, sheet.getRow(3).getCell(1).getNumericCellValue(), 0.01);
assertEquals(1.2, sheet.getRow(4).getCell(1).getNumericCellValue(), 0.01);
assertEquals(50.0, sheet.getRow(5).getCell(1).getNumericCellValue(), 0.01);
assertEquals("3", read(sheet, 6, 1).replace(".0", ""));
assertTrue(sheet.getRow(3).getCell(6) == null
|| StringUtils.isBlank(read(sheet, 3, 6)));
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* 国内整车:4.2米/3T 这类车型费用也是标签右侧,不是海运费目表。
*/
@Test
public void landFtlTemplateWritesVehicleFeeBesideLabel() throws Exception {
Path src = Files.createTempFile("quote-land-ftl-src", ".xlsx");
Path out = Files.createTempFile("quote-land-ftl-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("国内整车");
row(sheet, 2).createCell(0).setCellValue("4.2米/3T费用");
row(sheet, 3).createCell(0).setCellValue("7.6米/8T费用");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject facts = new JSONObject(true);
facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows(
"[{\"name\":\"4.2米/3T费用\",\"amount\":\"1800\"},"
+ "{\"name\":\"7.6米/8T费用\",\"amount\":\"2600\"}]"));
QuoteTemplateExcelSupport.fillWorkbook(src, out, new JSONObject(), facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertEquals(1800.0, sheet.getRow(2).getCell(1).getNumericCellValue(), 0.01);
assertEquals(2600.0, sheet.getRow(3).getCell(1).getNumericCellValue(), 0.01);
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* TMS 陆运 fee_lines 要投影成模板标签码,写出单时才能对上 元/CBM。
*/
@Test
public void buildFillFactsProjectsLandQuoteLabels() {
Map<String, Object> facts = new HashMap<>();
facts.put("始发站", "泉州");
facts.put("运输类型", "国内运输拼车");
Map<String, Object> quote = new HashMap<>();
quote.put("total", "1280");
quote.put("currency", "CNY");
quote.put("eta", "3");
quote.put("fee_lines",
"[{\"name\":\"元/CBM\",\"amount\":\"280\"},"
+ "{\"name\":\"送货费\",\"amount\":\"50\"}]");
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
.buildFillFacts("WO202609170012", facts, quote, "WuJiLin");
assertEquals("泉州", out.getString("origin"));
assertEquals("3", out.getString("transit_days"));
assertEquals("280", out.getString("price_per_cbm"));
assertEquals("50", out.getString("delivery_fee"));
assertEquals("1280", out.getString("total_amount"));
assertEquals(2, out.getJSONArray("fee_rows").size());
}
private static org.apache.poi.ss.usermodel.Row row(Sheet sheet, int r) {
org.apache.poi.ss.usermodel.Row exist = sheet.getRow(r);
return exist == null ? sheet.createRow(r) : exist;
@@ -82,6 +82,19 @@ public class QuoteTemplateMatcherTest {
assertEquals("海运默认", hit.get("name"));
}
@Test
public void landClassKeywordHits() {
List<Map<String, Object>> list = new ArrayList<>();
list.add(tpl("陆运急件", "陆运", false, "急件"));
list.add(tpl("陆运默认", "陆运", true, "普件"));
Map<String, Object> facts = new HashMap<>();
facts.put("运输分类", "急件");
Map<String, Object> hit = QuoteTemplateMatcher.pick(list, "LAND", facts, null);
assertNotNull(hit);
assertEquals("keyword", hit.get("matchType"));
assertEquals("陆运急件", hit.get("name"));
}
@Test
public void seaTemplateIgnoredForAir() {
List<Map<String, Object>> list = new ArrayList<>();
@@ -91,13 +104,60 @@ public class QuoteTemplateMatcherTest {
assertNull(QuoteTemplateMatcher.pick(list, "AIR", facts, null));
}
private static Map<String, Object> tpl(String name, String biz, boolean isDefault, String keyword) {
@Test
public void landPincheNotStolenByGenericLandKeyword() {
List<Map<String, Object>> list = new ArrayList<>();
list.add(tpl("中港", "陆运", false, "中港", "陆运"));
list.add(tpl("国内拼车", "陆运", false, "拼车", "零担"));
list.add(tpl("陆运默认", "陆运", true, "普件"));
Map<String, Object> facts = new HashMap<>();
facts.put("运输类型", "国内运输拼车");
facts.put("线路类别", "国内城配/拖车/打包");
facts.put("运输分类", "普件,常用线路/常规车型");
Map<String, Object> hit = QuoteTemplateMatcher.pick(list, "LAND", facts, null);
assertNotNull(hit);
assertEquals("keyword", hit.get("matchType"));
assertEquals("国内拼车", hit.get("name"));
}
@Test
public void landCrossFtlBeatsDomesticFtl() {
List<Map<String, Object>> list = new ArrayList<>();
list.add(tpl("国内整车", "陆运", false, "整车", "国内"));
list.add(tpl("跨境整车", "陆运", false, "跨境", "整车"));
Map<String, Object> facts = new HashMap<>();
facts.put("运输类型", "跨境整车");
facts.put("线路类别", "东南亚");
Map<String, Object> hit = QuoteTemplateMatcher.pick(list, "LAND", facts, null);
assertNotNull(hit);
assertEquals("跨境整车", hit.get("name"));
}
@Test
public void landFallsBackToDefault() {
List<Map<String, Object>> list = new ArrayList<>();
list.add(tpl("中港", "陆运", false, "中港"));
list.add(tpl("陆运默认", "陆运", true, "急件"));
Map<String, Object> facts = new HashMap<>();
facts.put("运输类型", "国内运输拼车");
facts.put("运输分类", "普件,常用线路/常规车型");
Map<String, Object> hit = QuoteTemplateMatcher.pick(list, "LAND", facts, null);
assertNotNull(hit);
assertEquals("default", hit.get("matchType"));
assertEquals("陆运默认", hit.get("name"));
}
private static Map<String, Object> tpl(String name, String biz, boolean isDefault, String... keywords) {
Map<String, Object> m = new HashMap<>();
m.put("name", name);
m.put("bizType", biz);
m.put("isDefault", isDefault);
List<String> kws = new ArrayList<>();
kws.add(keyword);
if (keywords != null) {
for (String keyword : keywords) {
kws.add(keyword);
}
}
m.put("keywords", kws);
return m;
}
@@ -47,4 +47,23 @@ public class InquiryTmsQueryServiceTest {
assertEquals("整柜", facts.get("整柜或拼柜"));
assertEquals("1x40HQ", facts.get("箱型箱量"));
}
@Test
public void mergeTicketFactsJsonKeepsLandOptions() {
InquiryTicket ticket = new InquiryTicket();
ticket.setFactsJson("{\"运输类型\":\"跨境集拼\",\"线路类别\":\"东南亚\",\"运输分类\":\"急件\"}");
java.util.Map<String, Object> facts = new java.util.HashMap<String, Object>();
InquiryTmsQueryService.mergeTicketFacts(facts, ticket);
assertEquals("跨境集拼", facts.get("运输类型"));
assertEquals("东南亚", facts.get("线路类别"));
assertEquals("急件", facts.get("运输分类"));
}
@Test
public void ticketLandWinsWhenBodyOmitsLine() {
java.util.Map<String, Object> body = new java.util.HashMap<String, Object>();
body.put("work_order_no", "WO202609170001");
String line = InquiryTmsQueryService.resolveTransportLine(body, new java.util.HashMap<String, Object>(), "LAND");
assertEquals("LAND", line);
}
}
@@ -0,0 +1,29 @@
package org.jeecg.modules.inquiry.tms;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* 陆运 TMS 枚举:销售中文不得原样出站。不打真实 TMS。
*/
public class TmsLandEnumMapperTest {
@Test
public void pincheCityDistMatchesTms61() {
assertEquals("DOMESTIC_LTL", TmsLandEnumMapper.transportType("国内运输拼车"));
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"));
assertEquals("SOUTHEAST_ASIA", TmsLandEnumMapper.routeCategory("SOUTHEAST_ASIA"));
}
}
@@ -0,0 +1,124 @@
package org.jeecg.modules.inquiry.tms;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* 陆运段拼参:不打真实 TMS。分类和中港装载不得出现在出站 map。
*/
public class TmsLandSegmentMapperTest {
@Test
public void landSegmentOmitsClass() {
Map<String, Object> facts = new HashMap<String, Object>();
facts.put("运输类型", "跨境集拼");
facts.put("线路类别", "东南亚");
facts.put("运输分类", "急件");
facts.put("起运港", "广州");
facts.put("目的港", "河内");
facts.put("品名", "衣服");
facts.put("体积", "100");
facts.put("毛重", "50");
facts.put("报价日期", "2026-09-17");
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
assertEquals("CROSS_BORDER_LTL", seg.get("landTransportType"));
assertEquals("SOUTHEAST_ASIA", seg.get("routeCategory"));
assertFalse(seg.containsKey("landRouteCategory"));
assertNull(seg.get("landClass"));
assertFalse(seg.containsKey("hkLoadType"));
assertFalse(seg.containsKey("运输分类"));
}
@Test
public void landFtlUsesVehicleNotVolume() {
Map<String, Object> facts = new HashMap<String, Object>();
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"));
assertEquals(Integer.valueOf(1), seg.get("quantity"));
}
@Test
public void hkFullTruckOmitsVehicle() {
Map<String, Object> facts = new HashMap<String, Object>();
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(
"WO202609170005", "2026-09-17", seg, facts);
assertEquals(java.util.Collections.emptyList(), missing);
}
@Test
public void domesticFtlSplitsCombinedField() {
Map<String, Object> facts = new HashMap<String, Object>();
facts.put("运输类型", "国内运输整车");
facts.put("线路类别", "国内长途/零担");
facts.put("起运港", "广州");
facts.put("目的港", "深圳");
facts.put("品名", "衣服");
facts.put("车型/数量", "9米6*2");
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
assertEquals("9米6", seg.get("vehicleType"));
assertEquals(Integer.valueOf(2), seg.get("quantity"));
}
@Test
public void wo011PincheCityDistUsesTmsEnums() {
Map<String, Object> facts = new HashMap<String, Object>();
facts.put("运输类型", "国内运输拼车");
facts.put("线路类别", "国内城配/拖车/打包");
facts.put("起运港", "泉州市");
facts.put("目的港", "南京市");
facts.put("品名", "通信机柜及线缆配件");
facts.put("体积", "10CBM");
facts.put("毛重", "10KG");
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
assertEquals("DOMESTIC_LTL", seg.get("landTransportType"));
assertEquals("DOMESTIC_CITY_DIST", seg.get("routeCategory"));
assertFalse(seg.containsKey("landRouteCategory"));
java.util.List<String> missing = TmsLandSegmentMapper.missingQuery(
"WO202609170011", "2026-09-17", seg, facts);
assertEquals(java.util.Collections.emptyList(), missing);
}
@Test
public void lclPincheDoesNotRequireQuantity() {
Map<String, Object> facts = new HashMap<String, Object>();
facts.put("运输类型", "国内运输拼车");
facts.put("线路类别", "国内长途/零担");
facts.put("起运港", "广州");
facts.put("目的港", "深圳");
facts.put("品名", "衣服");
facts.put("体积", "100CBM");
facts.put("毛重", "50kg");
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
java.util.List<String> missing = TmsLandSegmentMapper.missingQuery(
"WO202609170005", "2026-09-17", seg);
assertEquals("DOMESTIC_LTL", seg.get("landTransportType"));
assertEquals("DOMESTIC_LONG_LTL", seg.get("routeCategory"));
assertNull(seg.get("quantity"));
assertEquals(java.util.Collections.emptyList(), missing);
}
}
@@ -105,4 +105,29 @@ public class TmsV2RequestMapperTest {
assertEquals("40HQ", segs.get(0).get("containerType"));
assertEquals(Integer.valueOf(1), segs.get(0).get("containerQty"));
}
@Test
public void landFieldsPassThroughWithoutClass() {
Map<String, Object> seg = new HashMap<>();
seg.put("origin", "广州");
seg.put("destination", "河内");
seg.put("cargoName", "衣服");
seg.put("landTransportType", "跨境集拼");
seg.put("landRouteCategory", "东南亚");
seg.put("volumeCbm", "100");
seg.put("weightKg", "50");
seg.put("landClass", "急件");
Map<String, Object> internal = new HashMap<>();
internal.put("transportMode", "LAND");
internal.put("quoteDate", "2026-09-17");
internal.put("workOrderNo", "WO202609170001");
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("CROSS_BORDER_LTL", segs.get(0).get("landTransportType"));
assertEquals("SOUTHEAST_ASIA", segs.get(0).get("routeCategory"));
assertFalse(segs.get(0).containsKey("landRouteCategory"));
assertFalse(segs.get(0).containsKey("landClass"));
}
}
@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
/**
* 员工管理:对接主账 /inquiry/staffRole。
* 企微 userid 可手工维护,也可点「同步企业微信」由主账 upsert(姓名/手机/部门)。
@@ -19,6 +19,7 @@ import {
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
import { joinRoutes, parseRouteList } from "/@/views/inquiry/staffRole/staffRoutes";
type StaffRole = "sales" | "air" | "sea" | "land";
@@ -81,18 +82,6 @@ function isProductOrAir(role: StaffRole) {
return role === "air" || role === "sea" || role === "land";
}
function parseRouteList(raw: string): string[] {
if (!raw || raw === "-") return [];
return raw
.split(/[/·,、]/)
.map((s) => s.trim())
.filter(Boolean);
}
function joinRoutes(list: string[]) {
return list.map((s) => s.trim()).filter(Boolean).join(",");
}
const rows = ref<StaffRow[]>([]);
const loading = ref(false);
const total = ref(0);
@@ -0,0 +1,22 @@
/**
* 员工管理「负责线路」拆合。
*
* 只按逗号/中文逗号/顿号拆整词,禁止把「中港/中亚/中欧」「国内长途/零担」按斜杠切开。
* 陆运没有「陆运全线」快捷项。
*/
/** 把库里的逗号串还原成多选项。空或占位「-」当没有。 */
export function parseRouteList(raw: string): string[] {
if (!raw || raw === "-") {
return [];
}
return raw
.split(/[,,、]+/)
.map((s) => s.trim())
.filter(Boolean);
}
/** 多选结果写回库:英文逗号拼接,与主账匹配规则一致。 */
export function joinRoutes(list: string[]): string {
return list.map((s) => s.trim()).filter(Boolean).join(",");
}
+13
View File
@@ -0,0 +1,13 @@
import { parseRouteList, joinRoutes } from "/@/views/inquiry/staffRole/staffRoutes";
test("keeps hongkong token", () => {
expect(parseRouteList("中港/中亚/中欧,东南亚")).toEqual([
"中港/中亚/中欧",
"东南亚",
]);
});
test("join roundtrip", () => {
const list = ["中港/中亚/中欧", "国内长途/零担"];
expect(parseRouteList(joinRoutes(list))).toEqual(list);
});