落地陆运私聊文字询价:选项核对后再建单查价,并按线路拉产品进群。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 16:23:30 +08:00
co-authored by Cursor
parent 83b907c4b8
commit be95d823a2
52 changed files with 4534 additions and 76 deletions
+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
+175 -4
View File
@@ -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
+14 -5
View File
@@ -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:
+37 -9
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"
# 销售 + 产品之后,必须再拉会话存档账号;没有也不挡建群。
@@ -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 列表。