落地陆运私聊文字询价:选项核对后再建单查价,并按线路拉产品进群。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -22,12 +22,12 @@ _MODE_WORDS = (
|
||||
# 仅「键:值」一行一条,键必须是合同字段或展示名
|
||||
_LABEL_LINE = re.compile(
|
||||
r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
|
||||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型数量|"
|
||||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型/数量|车型数量|"
|
||||
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(.+)$"
|
||||
)
|
||||
_LABEL_INLINE = re.compile(
|
||||
r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
|
||||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型数量|"
|
||||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型/数量|车型数量|"
|
||||
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(\S+)"
|
||||
)
|
||||
|
||||
@@ -84,10 +84,12 @@ def extract_inquiry_snapshot(
|
||||
|
||||
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)))
|
||||
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,12 +109,15 @@ 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))
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ _FIELD_KEYS = (
|
||||
"报价日期",
|
||||
"整柜或拼柜",
|
||||
"箱型箱量",
|
||||
"车型/数量",
|
||||
"车型数量",
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,10 +16,13 @@ from typing import Any, Iterable
|
||||
from agent.schema.field_validate import (
|
||||
AIR_COLLAB_FIELDS,
|
||||
AIR_REQUIRED_FIELDS,
|
||||
LAND_DISPLAY,
|
||||
SEA_COLLAB_DISPLAY,
|
||||
SEA_COLLAB_FIELDS,
|
||||
SEA_REQUIRED_FIELDS,
|
||||
display_name,
|
||||
)
|
||||
from agent.schema.land_options import option_prompt as _land_option_prompt
|
||||
|
||||
# 空运补问:与合同 8 项一致。报价日期默认当天,不进待补充。
|
||||
_AIR_EXAMPLES = {
|
||||
@@ -96,9 +99,111 @@ _CONTINUE_HINT = (
|
||||
|
||||
|
||||
ASK_TRANSPORT = "请补充运输方式:海运 / 空运 / 陆运。"
|
||||
ASK_LAND_SUBTYPE = "请补充陆运类型:国内运输拼车 / 国内运输整车 / 跨境集拼 / 跨境整车 / 中港。"
|
||||
# 旧一句只问运输类型;销售要一次看到类型、线路、分类。残留调用也走完整清单。
|
||||
ASK_LAND_SUBTYPE = _land_option_prompt(current={})
|
||||
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_LCL_TYPES = frozenset({"国内运输拼车", "跨境集拼"})
|
||||
_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, ...]:
|
||||
"""当前运输类型要展示的货物内部键。中港整车不要车型/数量。"""
|
||||
if land_type == "中港":
|
||||
load = str((facts or {}).get("中港装载方式") or "").strip()
|
||||
if load == "零担/集拼":
|
||||
return _LAND_FTL_KEYS
|
||||
# 整车或还没选定装载:卡片/补问都不出现车型/数量
|
||||
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()}",
|
||||
]
|
||||
if land_type == "中港":
|
||||
lines.append(f"中港装载方式(必填):{(src.get('中港装载方式') or '').strip()}")
|
||||
for key in _land_cargo_keys(land_type, src):
|
||||
label = LAND_DISPLAY.get(key, key)
|
||||
val = (src.get(key) or "").strip()
|
||||
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 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 +364,26 @@ 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 '-'}",
|
||||
]
|
||||
if land_type == "中港":
|
||||
lines.append(
|
||||
f"中港装载方式:{(facts.get('中港装载方式') or '-').strip() or '-'}"
|
||||
)
|
||||
for key in _land_cargo_keys(land_type, facts):
|
||||
label = LAND_DISPLAY.get(key, key)
|
||||
val = (facts.get(key) or "-").strip() or "-"
|
||||
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,19 +1131,22 @@ 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("协同补充字段:")
|
||||
@@ -1123,8 +1251,9 @@ def scheme_horizontal_rows(
|
||||
"""
|
||||
卡片字段:一行一项,每格独立字数,不会像副标题整段截成 8CB…。
|
||||
|
||||
企微 horizontal 最多 6 条。运输方式写进航线行,避免丢字段。
|
||||
企微 horizontal 最多 6 条。运输方式写进行程行,避免丢字段。
|
||||
海运用货物数量/整柜或拼柜/箱型箱量,禁止套空运件数毛重体积包装。
|
||||
陆运用运输类型/线路类别/体积重量或车型数量,禁止套海运箱型或空运件数包装。
|
||||
不用 sub_title 堆字段(它会排到列表前面,且超长必截断)。
|
||||
"""
|
||||
src = facts or {}
|
||||
@@ -1145,6 +1274,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 +1297,39 @@ def scheme_horizontal_rows(
|
||||
]
|
||||
|
||||
|
||||
def _land_scheme_type_line(facts: dict[str, str]) -> str:
|
||||
"""方案卡「类型」格:运输类型、中港装载、线路类别、运输分类。"""
|
||||
src = facts or {}
|
||||
land_type = (src.get("运输类型") or "-").strip() or "-"
|
||||
bits = [land_type]
|
||||
if land_type == "中港":
|
||||
load = (src.get("中港装载方式") or "").strip()
|
||||
if load:
|
||||
bits.append(load)
|
||||
route = (src.get("线路类别") or "-").strip() or "-"
|
||||
klass = (src.get("运输分类") or "-").strip() or "-"
|
||||
bits.extend([route, klass])
|
||||
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 +1342,7 @@ def scheme_wecom_payload(
|
||||
一张报价方案卡:标题带工单号、引用条、字段、三个短按钮。
|
||||
|
||||
按钮文案必须短:生成Excel / 生成PDF / 不采用。长文案横排会被裁。
|
||||
海运/空运字段分开,禁止海运卡再出件数毛重体积。
|
||||
海运/空运/陆运字段分开,禁止陆运卡再出箱型或空运件数包装。
|
||||
"""
|
||||
_ = status
|
||||
src = dict(quote or {})
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
"""
|
||||
陆运文字询价确定性流程。
|
||||
|
||||
本文件职责:选项联动 → 按类型补货物 → 无工单号核对卡 →(确定后)建单查 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,
|
||||
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 _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]
|
||||
reply(str(err.get("message") or option_prompt(current=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"
|
||||
if state["stage"] == "options_missing":
|
||||
body = option_prompt(current=facts)
|
||||
logger.info(
|
||||
"land.options_missing missing=%s",
|
||||
(state.get("opt") or {}).get("missing") or [],
|
||||
)
|
||||
reply(body)
|
||||
self._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 _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
|
||||
@@ -104,9 +104,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 +116,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 +126,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 +138,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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
# 销售 + 产品之后,必须再拉会话存档账号;没有也不挡建群。
|
||||
@@ -509,12 +522,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 +566,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 列表。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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/不采用等)也标闲聊。"
|
||||
"禁止因为销售没说指定口令就把续办判成新询价。"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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编码", "是否含油", "是否含电", "是否含磁")
|
||||
# 空运群只补含电/含磁,摘要不要把海运六项搬过去。
|
||||
@@ -83,7 +95,9 @@ _ALIASES = {
|
||||
"重量(KG)": "毛重",
|
||||
"重量": "毛重",
|
||||
"体积(CBM)": "体积",
|
||||
"车型/数量": "车型数量",
|
||||
"车型/数量": "车型/数量",
|
||||
"车型数量": "车型/数量",
|
||||
"数量": "数量",
|
||||
"HS": "HS编码",
|
||||
"HS CODE": "HS编码",
|
||||
"商品海关编码": "HS编码",
|
||||
@@ -114,9 +128,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 = ""
|
||||
@@ -235,8 +273,13 @@ 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]:
|
||||
"""当前激活的首次查价内部键列表。中港再按装载方式收窄。"""
|
||||
line = (business_line or "").strip().upper()
|
||||
pricing = contract.get("pricing_required") or {}
|
||||
if line == "SEA":
|
||||
@@ -248,6 +291,14 @@ def _pricing_required_keys(contract: dict[str, Any], business_line: str, land_su
|
||||
block = land.get(land_subtype) or {}
|
||||
if not block:
|
||||
return []
|
||||
if land_subtype == "中港":
|
||||
from agent.schema.land_options import canonicalize_hk_load
|
||||
|
||||
load = canonicalize_hk_load(str((facts or {}).get("中港装载方式") or ""))
|
||||
by_load = block.get("by_hk_load") or {}
|
||||
picked = by_load.get(load) if load else None
|
||||
if isinstance(picked, list):
|
||||
return list(picked)
|
||||
return list(block.get("always") or [])
|
||||
return []
|
||||
|
||||
@@ -259,6 +310,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 +345,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 +362,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
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
"""
|
||||
陆运选项规则:运输类型、线路类别、运输分类、中港装载方式。
|
||||
|
||||
本文件职责:从销售原话收取标准项、校验联动、生成补问清单、判断「确定查价」意图。
|
||||
联动错误只纠正那一项能选什么,不整单作废。
|
||||
禁止:改六态;发明枚举;把选项当货物字段。
|
||||
线程:纯函数,无共享可变状态,可在 Worker 槽调用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# 与后台员工管理陆运「负责线路」同一套字,禁止改字。
|
||||
TRANSPORT_TYPES = (
|
||||
"国内运输拼车",
|
||||
"国内运输整车",
|
||||
"跨境集拼",
|
||||
"跨境整车",
|
||||
"中港",
|
||||
)
|
||||
DOMESTIC_ROUTES = ("国内长途/零担", "国内城配/拖车/打包")
|
||||
CROSS_ROUTES = ("中港/中亚/中欧", "东南亚")
|
||||
ROUTES_BY_TYPE = {
|
||||
"国内运输拼车": DOMESTIC_ROUTES,
|
||||
"国内运输整车": DOMESTIC_ROUTES,
|
||||
"跨境集拼": CROSS_ROUTES,
|
||||
"跨境整车": CROSS_ROUTES,
|
||||
"中港": CROSS_ROUTES,
|
||||
}
|
||||
CROSS_CLASSES = (
|
||||
"普件,中港、东南亚常用线路/常规车型",
|
||||
"普件,中亚、中欧、东南亚涉及国内+国外段",
|
||||
"普件,中亚、中欧、东南亚涉及境外段清派等特殊要求",
|
||||
"急件",
|
||||
)
|
||||
DOMESTIC_CLASSES = (
|
||||
"普件,常用线路/常规车型",
|
||||
"普件,非常用线路/临时线路/特种车型",
|
||||
"急件",
|
||||
)
|
||||
CLASSES_BY_ROUTE = {
|
||||
"中港/中亚/中欧": CROSS_CLASSES,
|
||||
"东南亚": CROSS_CLASSES,
|
||||
"国内长途/零担": DOMESTIC_CLASSES,
|
||||
"国内城配/拖车/打包": DOMESTIC_CLASSES,
|
||||
}
|
||||
HK_LOADS = ("整车", "零担/集拼")
|
||||
|
||||
# 口语别名 → 标准运输类型。长词优先,避免「拼车」误伤「整车」。
|
||||
_TYPE_ALIASES = (
|
||||
("国内运输拼车", "国内运输拼车"),
|
||||
("国内运输整车", "国内运输整车"),
|
||||
("国内拼车", "国内运输拼车"),
|
||||
("国内整车", "国内运输整车"),
|
||||
("跨境集拼", "跨境集拼"),
|
||||
("跨境整车", "跨境整车"),
|
||||
("中港", "中港"),
|
||||
)
|
||||
|
||||
_ROUTE_ALIASES = (
|
||||
("国内城配/拖车/打包", "国内城配/拖车/打包"),
|
||||
("国内长途/零担", "国内长途/零担"),
|
||||
("中港/中亚/中欧", "中港/中亚/中欧"),
|
||||
("国内城配", "国内城配/拖车/打包"),
|
||||
("国内长途", "国内长途/零担"),
|
||||
("东南亚", "东南亚"),
|
||||
)
|
||||
|
||||
# 分类全称从长到短;「急件」单独出现才算急件,避免普件句被误收。
|
||||
_CLASS_CANON = tuple(
|
||||
sorted(
|
||||
{*(CROSS_CLASSES + DOMESTIC_CLASSES)},
|
||||
key=len,
|
||||
reverse=True,
|
||||
)
|
||||
)
|
||||
|
||||
_HK_LOAD_ALIASES = (
|
||||
("零担/集拼", "零担/集拼"),
|
||||
("零担", "零担/集拼"),
|
||||
("集拼", "零担/集拼"),
|
||||
("整车", "整车"),
|
||||
)
|
||||
|
||||
_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 canonicalize_hk_load(raw: str) -> str:
|
||||
"""中港装载方式:整车或零担/集拼。"""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
for needle, canon in _HK_LOAD_ALIASES:
|
||||
if needle in text:
|
||||
return canon
|
||||
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_type = canonicalize_transport_type(blob)
|
||||
found_route = canonicalize_route(blob)
|
||||
found_class = canonicalize_land_class(blob)
|
||||
found_load = canonicalize_hk_load(blob)
|
||||
if found_type:
|
||||
merged["运输类型"] = found_type
|
||||
if found_route:
|
||||
merged["线路类别"] = found_route
|
||||
if found_class:
|
||||
merged["运输分类"] = found_class
|
||||
if found_load:
|
||||
merged["中港装载方式"] = found_load
|
||||
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)
|
||||
load = canonicalize_hk_load(merged.get("中港装载方式") or "")
|
||||
if load:
|
||||
merged["中港装载方式"] = load
|
||||
else:
|
||||
merged.pop("中港装载方式", None)
|
||||
|
||||
|
||||
def validate_land_options(opts: dict[str, str]) -> dict[str, Any]:
|
||||
"""
|
||||
校验选项是否齐全且联动正确。
|
||||
|
||||
返回 ok / missing / errors。errors 每项 {field, message},只纠正错的那一项。
|
||||
中港缺装载方式进 missing,不改货物字段。
|
||||
副作用:无。
|
||||
"""
|
||||
src = {k: str(v).strip() for k, v in dict(opts or {}).items() if str(v).strip()}
|
||||
t = canonicalize_transport_type(src.get("运输类型") or "") or src.get("运输类型") or ""
|
||||
r = canonicalize_route(src.get("线路类别") or "") or src.get("线路类别") or ""
|
||||
c = canonicalize_land_class(src.get("运输分类") or "") or src.get("运输分类") or ""
|
||||
load = canonicalize_hk_load(src.get("中港装载方式") or "") or src.get("中港装载方式") or ""
|
||||
|
||||
missing: list[str] = []
|
||||
errors: list[dict[str, str]] = []
|
||||
|
||||
if t not in TRANSPORT_TYPES:
|
||||
missing.append("运输类型")
|
||||
t = ""
|
||||
|
||||
if t:
|
||||
allowed_routes = ROUTES_BY_TYPE.get(t) or ()
|
||||
if not r:
|
||||
missing.append("线路类别")
|
||||
elif r not in allowed_routes:
|
||||
errors.append(
|
||||
{
|
||||
"field": "线路类别",
|
||||
"message": "线路类别只能选:" + "、".join(f"「{x}」" for x in allowed_routes),
|
||||
}
|
||||
)
|
||||
r = ""
|
||||
elif r:
|
||||
# 类型还没有,线路先记下但不判联动
|
||||
pass
|
||||
|
||||
if r:
|
||||
allowed_cls = CLASSES_BY_ROUTE.get(r) or ()
|
||||
if not c:
|
||||
missing.append("运输分类")
|
||||
elif c not in allowed_cls:
|
||||
errors.append(
|
||||
{
|
||||
"field": "运输分类",
|
||||
"message": "运输分类只能选:" + "、".join(f"「{x}」" for x in allowed_cls),
|
||||
}
|
||||
)
|
||||
c = ""
|
||||
elif "运输分类" not in missing and not c:
|
||||
missing.append("运输分类")
|
||||
|
||||
if t == "中港":
|
||||
if load not in HK_LOADS:
|
||||
missing.append("中港装载方式")
|
||||
ok = not missing and not errors
|
||||
return {
|
||||
"ok": ok,
|
||||
"missing": missing,
|
||||
"errors": errors,
|
||||
"normalized": {
|
||||
"运输类型": t,
|
||||
"线路类别": r,
|
||||
"运输分类": c,
|
||||
"中港装载方式": load if t == "中港" else "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def option_prompt(*, current: dict[str, str]) -> str:
|
||||
"""
|
||||
按已识别项生成补问清单。
|
||||
|
||||
全缺:一次列出类型、线路、分类、中港装载。
|
||||
类型已知:不再问类型,只列合法线路/分类。
|
||||
不修改 current。
|
||||
"""
|
||||
src = {k: str(v).strip() for k, v in dict(current or {}).items() if str(v).strip()}
|
||||
t = canonicalize_transport_type(src.get("运输类型") or "") or src.get("运输类型") or ""
|
||||
r = canonicalize_route(src.get("线路类别") or "") or src.get("线路类别") or ""
|
||||
lines: list[str] = []
|
||||
|
||||
if t not in TRANSPORT_TYPES:
|
||||
lines.append("请一次选择:运输类型、线路类别、运输分类。")
|
||||
lines.append("")
|
||||
lines.append("运输类型:")
|
||||
lines.append("、".join(TRANSPORT_TYPES))
|
||||
lines.append("")
|
||||
lines.append("线路类别(跟运输类型联动):")
|
||||
lines.append("国内运输拼车 / 国内运输整车:" + "、".join(DOMESTIC_ROUTES))
|
||||
lines.append("跨境集拼 / 跨境整车 / 中港:" + "、".join(CROSS_ROUTES))
|
||||
lines.append("")
|
||||
lines.append("运输分类(跟线路类别联动):")
|
||||
lines.append("中港/中亚/中欧、东南亚:" + ";".join(CROSS_CLASSES))
|
||||
lines.append("国内长途/零担、国内城配/拖车/打包:" + ";".join(DOMESTIC_CLASSES))
|
||||
lines.append("")
|
||||
lines.append("运输类型选中港时,还要选择:" + "、".join(HK_LOADS))
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
# 类型已认出来:不再问运输类型
|
||||
allowed_routes = ROUTES_BY_TYPE.get(t) or ()
|
||||
if r not in allowed_routes:
|
||||
lines.append(f"运输类型已选「{t}」。请选择线路类别:")
|
||||
lines.append("、".join(allowed_routes))
|
||||
route_for_class = ""
|
||||
else:
|
||||
route_for_class = r
|
||||
|
||||
if route_for_class:
|
||||
allowed_cls = CLASSES_BY_ROUTE.get(route_for_class) or ()
|
||||
if src.get("运输分类") not in allowed_cls:
|
||||
lines.append(f"线路类别已选「{route_for_class}」。请选择运输分类:")
|
||||
lines.append(";".join(allowed_cls))
|
||||
elif t in TRANSPORT_TYPES:
|
||||
# 线路未定,把该类型下各线路的分类一并列出,方便一次粘贴
|
||||
seen: set[str] = set()
|
||||
lines.append("运输分类(跟线路类别联动):")
|
||||
for route in allowed_routes:
|
||||
if route in seen:
|
||||
continue
|
||||
seen.add(route)
|
||||
lines.append(f"{route}:" + ";".join(CLASSES_BY_ROUTE.get(route) or ()))
|
||||
|
||||
if t == "中港" and (src.get("中港装载方式") not in HK_LOADS):
|
||||
lines.append("中港请再选择装载方式:" + "、".join(HK_LOADS))
|
||||
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,177 @@
|
||||
"""
|
||||
陆运查价参数:对照 TMS 出站是否带全。
|
||||
|
||||
本文件职责:核对卡通过后、建单查价前,确认当前运输类型的货物字段能拼出查价正文。
|
||||
带去:运输类型、线路类别、始发/目的/品名/报价日期,以及该类型的体积重量或车型/数量;通关口岸有值才带。
|
||||
出站类型/线路必须译成 TMS 6.1.1 英文枚举,线路键名 routeCategory。工单与卡片仍用中文。
|
||||
中港整车不带车型/数量。车型/数量是一个字段,出站再拆。
|
||||
禁止:把运输分类、中港装载方式放进出站;直连 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
|
||||
from agent.schema.land_options import canonicalize_hk_load
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LCL_TYPES = frozenset({"国内运输拼车", "跨境集拼"})
|
||||
FTL_TYPES = frozenset({"国内运输整车", "跨境整车"})
|
||||
CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港"})
|
||||
|
||||
_TYPE_ENUM = {
|
||||
"国内运输拼车": "DOMESTIC_LTL",
|
||||
"国内运输整车": "DOMESTIC_FTL",
|
||||
"跨境集拼": "CROSS_BORDER_LTL",
|
||||
"跨境整车": "CROSS_BORDER_FTL",
|
||||
"中港": "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()
|
||||
hk_load = canonicalize_hk_load(raw.get("中港装载方式") or src.get("中港装载方式") or "")
|
||||
|
||||
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 == "中港" and hk_load != "零担/集拼":
|
||||
# 中港整车(或尚未选定装载)不采集、不带出车型/数量
|
||||
pass
|
||||
elif land_type in FTL_TYPES or land_type == "中港":
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user