落地海运私聊文字询价全流程,海运多项费用改走文本明细,避免企微卡片引用条截断。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -45,11 +45,16 @@ class FlowSession:
|
||||
status: str = ""
|
||||
allowed: tuple[str, ...] = ()
|
||||
immutable_text: str = ""
|
||||
collab_chat_id: str = ""
|
||||
collab_facts: dict[str, str] = field(default_factory=dict)
|
||||
invited_userids: tuple[str, ...] = ()
|
||||
invited_sales_name: str = ""
|
||||
invited_product_names: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class AirTextInquiryFlow:
|
||||
"""
|
||||
空运文字询价 Owner(本轮产品范围);海运/陆运先补问运输方式或陆运二级。
|
||||
空运文字询价 Owner。海运走 SeaTextInquiryFlow,不要再往本文件堆海运。陆运仍补问二级。
|
||||
|
||||
同一 sender 新开询价会覆盖「当前书签」(旧单留在主账)。
|
||||
价格卡按工单号另存一份当时会话:点哪张卡就生成哪张卡当时的报价单。
|
||||
@@ -69,6 +74,25 @@ class AirTextInquiryFlow:
|
||||
with self._lock:
|
||||
return self._by_sender.get(sender_id)
|
||||
|
||||
def session_by_ticket(self, sender_id: str, work_order_no: str) -> Optional[FlowSession]:
|
||||
"""
|
||||
只查进程内该书签,不读主账。
|
||||
|
||||
续办选空运/海运流程用:海运已跳过协同时必须找回 wait_adopt,
|
||||
不能因为主账把已报价海运一律收成 wait_collab 就套空运协同卡。
|
||||
"""
|
||||
no = (work_order_no or "").strip().upper()
|
||||
if not (sender_id or "").strip() or not no:
|
||||
return None
|
||||
with self._lock:
|
||||
hit = self._by_ticket.get(no)
|
||||
if hit and hit.sender_id == sender_id:
|
||||
return hit
|
||||
for key, sess in self._by_ticket.items():
|
||||
if (key or "").upper() == no and sess.sender_id == sender_id:
|
||||
return sess
|
||||
return None
|
||||
|
||||
def session_for_action(
|
||||
self,
|
||||
sender_id: str,
|
||||
@@ -152,6 +176,7 @@ class AirTextInquiryFlow:
|
||||
else:
|
||||
keep = None
|
||||
if keep:
|
||||
self._align_resume_phase(keep)
|
||||
return self._replay_wait(keep, reply, source="session")
|
||||
getter = getattr(self._ledger, "get_for_agent", None)
|
||||
if not callable(getter):
|
||||
@@ -165,6 +190,7 @@ class AirTextInquiryFlow:
|
||||
reply("这张工单不属于当前账号,不能继续。")
|
||||
return "ticket_forbidden"
|
||||
sess = self._session_from_ledger(sender_id=sender_id, view=view)
|
||||
self._align_resume_phase(sess)
|
||||
self._save(sess)
|
||||
return self._replay_wait(sess, reply, source="ledger")
|
||||
|
||||
@@ -204,6 +230,20 @@ class AirTextInquiryFlow:
|
||||
return "wait_adopt"
|
||||
return "tms_miss"
|
||||
|
||||
def _align_resume_phase(self, sess: FlowSession) -> None:
|
||||
"""
|
||||
报价文件已经发给销售,续办必须回到成交跟进。
|
||||
|
||||
主账六态仍是「已报价」,不能据此重放到协同卡或生成报价单。
|
||||
Redis 就绪键由出文件时写入;主账 wait_phase=wait_deal 也认。
|
||||
"""
|
||||
if sess.phase not in {"wait_collab", "wait_adopt", "wait_file"}:
|
||||
return
|
||||
issued = bool((sess.quote or {}).get("quoteFileIssued"))
|
||||
if issued or self._quote_file_already_out(sess.work_order_no):
|
||||
logger.info("resume 报价文件已出,回到成交跟进 wo=%s was=%s", sess.work_order_no, sess.phase)
|
||||
self._promote_wait_deal(sess)
|
||||
|
||||
def _replay_wait(self, sess: FlowSession, reply: ReplyFn, *, source: str) -> str:
|
||||
"""按书签重出当时节点,不改六态。"""
|
||||
logger.info(
|
||||
@@ -213,18 +253,21 @@ class AirTextInquiryFlow:
|
||||
source,
|
||||
)
|
||||
if sess.phase == "wait_adopt":
|
||||
mode = copy.scheme_transport_mode(sess.business_line)
|
||||
reply(
|
||||
copy.scheme_card(
|
||||
work_order_no=sess.work_order_no,
|
||||
status=sess.status or "已报价",
|
||||
facts=sess.facts,
|
||||
quote=sess.quote,
|
||||
transport_mode=mode,
|
||||
),
|
||||
copy.scheme_wecom_payload(
|
||||
work_order_no=sess.work_order_no,
|
||||
facts=sess.facts,
|
||||
quote=sess.quote,
|
||||
status=sess.status or "已报价",
|
||||
transport_mode=mode,
|
||||
),
|
||||
)
|
||||
return "wait_adopt"
|
||||
@@ -349,9 +392,23 @@ class AirTextInquiryFlow:
|
||||
if mode == "LAND":
|
||||
reply(copy.ASK_LAND_SUBTYPE)
|
||||
return "need_land_subtype"
|
||||
# 海运本轮只提示先走空运闭环(不半做海运查价)
|
||||
reply("本轮先开通空运文字询价。请按空运描述需求,或补充为「空运」。")
|
||||
return "sea_deferred"
|
||||
if mode == "SEA":
|
||||
# 海运交给海运 Owner,空运不再回「本轮先开通空运」
|
||||
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,
|
||||
)
|
||||
reply("请补充运输方式:空运、海运或陆运。")
|
||||
return "need_mode"
|
||||
|
||||
check = validate_required_fields(facts=facts, business_line="AIR")
|
||||
facts = dict(check["facts"])
|
||||
@@ -640,18 +697,21 @@ class AirTextInquiryFlow:
|
||||
sess.allowed = (copy.BTN_ADOPT_EXCEL, copy.BTN_ADOPT_PDF, copy.BTN_REJECT)
|
||||
sess.wait_version += 1
|
||||
self._save(sess)
|
||||
mode = copy.scheme_transport_mode(sess.business_line)
|
||||
reply(
|
||||
copy.scheme_card(
|
||||
work_order_no=sess.work_order_no,
|
||||
status=sess.status or "已报价",
|
||||
facts=sess.facts,
|
||||
quote=sess.quote,
|
||||
transport_mode=mode,
|
||||
),
|
||||
copy.scheme_wecom_payload(
|
||||
work_order_no=sess.work_order_no,
|
||||
facts=sess.facts,
|
||||
quote=sess.quote,
|
||||
status=sess.status or "已报价",
|
||||
transport_mode=mode,
|
||||
),
|
||||
)
|
||||
return "wait_adopt"
|
||||
@@ -763,6 +823,9 @@ class AirTextInquiryFlow:
|
||||
file_extra,
|
||||
)
|
||||
self._wait_outbound_sent(str(out_id or ""), work_order_no=sess.work_order_no, what="报价文件")
|
||||
from agent.redis_coord.quote_file import mark_quote_file_ready
|
||||
|
||||
mark_quote_file_ready(sess.work_order_no)
|
||||
sess.phase = "wait_deal"
|
||||
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||||
sess.wait_version += 1
|
||||
|
||||
@@ -13,7 +13,7 @@ import time
|
||||
import uuid
|
||||
from typing import Any, Iterable
|
||||
|
||||
from agent.schema.field_validate import AIR_REQUIRED_FIELDS
|
||||
from agent.schema.field_validate import AIR_REQUIRED_FIELDS, SEA_COLLAB_FIELDS, SEA_REQUIRED_FIELDS
|
||||
|
||||
# 空运补问:与合同 8 项一致。报价日期默认当天,不进待补充。
|
||||
_AIR_EXAMPLES = {
|
||||
@@ -30,11 +30,27 @@ _AIR_CLARIFY = tuple(
|
||||
(key, label, _AIR_EXAMPLES[key]) for key, label in AIR_REQUIRED_FIELDS
|
||||
)
|
||||
|
||||
# 海运补问:与合同 7 项一致。报价日期默认当天,不进待补充。
|
||||
_SEA_EXAMPLES = {
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
"品名": "普货",
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
"报价日期": "2026-09-15",
|
||||
}
|
||||
_SEA_CLARIFY = tuple(
|
||||
(key, label, _SEA_EXAMPLES[key]) for key, label in SEA_REQUIRED_FIELDS
|
||||
)
|
||||
|
||||
# 文字按钮(销售点卡或原样回复均可)
|
||||
BTN_SKIP_COLLAB = "不需要协同,直接生成报价单"
|
||||
BTN_SKIP_COLLAB_OLD = "不需要协同,直接生成基础报价单"
|
||||
BTN_SKIP_COLLAB_KEY = "skip_collab"
|
||||
BTN_SKIP_COLLAB_DONE = "已提交"
|
||||
BTN_PULL_COLLAB = "拉产品进群协同"
|
||||
BTN_PULL_COLLAB_KEY = "pull_collab"
|
||||
BTN_ADOPT_EXCEL = "采用,生成Excel报价单"
|
||||
BTN_ADOPT_PDF = "采用,生成PDF报价单"
|
||||
BTN_REJECT = "不采用"
|
||||
@@ -104,6 +120,14 @@ def _air_known_value(facts: dict[str, str], key: str) -> str:
|
||||
return (src.get(key) or "").strip()
|
||||
|
||||
|
||||
def _sea_known_value(facts: dict[str, str], key: str) -> str:
|
||||
"""海运已识别字段的销售侧展示值。"""
|
||||
src = facts or {}
|
||||
if key == "货量":
|
||||
return (src.get("货量") or src.get("货物数量") or "").strip()
|
||||
return (src.get(key) or "").strip()
|
||||
|
||||
|
||||
def ask_clarify(
|
||||
*,
|
||||
facts: dict[str, str] | None = None,
|
||||
@@ -119,11 +143,14 @@ def ask_clarify(
|
||||
skip = {"报价日期"}
|
||||
missing = [k for k in missing_keys if k and k not in skip]
|
||||
missing_set = set(missing)
|
||||
sea = (transport_mode or "").strip() == "海运"
|
||||
rows = _SEA_CLARIFY if sea else _AIR_CLARIFY
|
||||
known = _sea_known_value if sea else _air_known_value
|
||||
lines = ["请确认并补充以下信息:"]
|
||||
if transport_mode:
|
||||
lines.append(f"运输方式:{transport_mode}")
|
||||
for key, label, _example in _AIR_CLARIFY:
|
||||
val = _air_known_value(src, key)
|
||||
for key, label, _example in rows:
|
||||
val = known(src, key)
|
||||
if val:
|
||||
lines.append(f"{label}:{val}")
|
||||
lines.append("")
|
||||
@@ -132,7 +159,7 @@ def ask_clarify(
|
||||
if "运输方式" in missing_set:
|
||||
any_missing = True
|
||||
lines.append("运输方式:(如:空运)")
|
||||
for key, label, example in _AIR_CLARIFY:
|
||||
for key, label, example in rows:
|
||||
if key in missing_set:
|
||||
any_missing = True
|
||||
lines.append(f"{label}:(如:{example})")
|
||||
@@ -155,6 +182,10 @@ def ask_missing(display_names: Iterable[str]) -> str:
|
||||
"体积(CBM)": "体积",
|
||||
"包装类型": "包装方式",
|
||||
"包装方式": "包装方式",
|
||||
"货物数量": "货量",
|
||||
"货量": "货量",
|
||||
"整柜或拼柜": "整柜或拼柜",
|
||||
"箱型箱量": "箱型箱量",
|
||||
}
|
||||
keys: list[str] = []
|
||||
for name in display_names:
|
||||
@@ -207,6 +238,21 @@ def inquiry_field_lines(
|
||||
cargo = (facts.get("品名") or "-").strip() or "-"
|
||||
pack = (facts.get("包装方式") or facts.get("包装类型") or "-").strip() or "-"
|
||||
quote_date = (facts.get("报价日期") or "-").strip() or "-"
|
||||
if (transport_mode or "").strip() == "海运":
|
||||
qty = (facts.get("货量") or facts.get("货物数量") or "-").strip() or "-"
|
||||
load = (facts.get("整柜或拼柜") or "-").strip() or "-"
|
||||
box = (facts.get("箱型箱量") or "-").strip() or "-"
|
||||
return [
|
||||
f"工单号:{work_order_no}",
|
||||
f"运输方式:{transport_mode}",
|
||||
f"起运港:{origin}",
|
||||
f"目的港:{dest}",
|
||||
f"品名:{cargo}",
|
||||
f"货物数量:{qty}",
|
||||
f"整柜或拼柜:{load}",
|
||||
f"箱型箱量:{box}",
|
||||
f"报价日期:{quote_date}",
|
||||
]
|
||||
return [
|
||||
f"工单号:{work_order_no}",
|
||||
f"运输方式:{transport_mode}",
|
||||
@@ -221,14 +267,22 @@ def inquiry_field_lines(
|
||||
]
|
||||
|
||||
|
||||
def inquiry_card(*, work_order_no: str, facts: dict[str, str], first_or_same: str = "first") -> str:
|
||||
def inquiry_card(
|
||||
*,
|
||||
work_order_no: str,
|
||||
facts: dict[str, str],
|
||||
first_or_same: str = "first",
|
||||
transport_mode: str = "空运",
|
||||
) -> str:
|
||||
"""
|
||||
建单后的询价确认。
|
||||
|
||||
无标题、无 [test]。first_or_same 保留兼容,不再对销售展示。
|
||||
"""
|
||||
_ = first_or_same
|
||||
lines = inquiry_field_lines(work_order_no=work_order_no, facts=facts)
|
||||
lines = inquiry_field_lines(
|
||||
work_order_no=work_order_no, facts=facts, transport_mode=transport_mode
|
||||
)
|
||||
lines.append("工单已创建,正在查询TMS报价。")
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -241,6 +295,10 @@ def canonical_button(raw: str) -> str:
|
||||
# 新卡 EventKey 带工单号:skip_collab:WO…,避免点旧卡落到最新单
|
||||
if text.startswith(f"{BTN_SKIP_COLLAB_KEY}:"):
|
||||
return BTN_SKIP_COLLAB
|
||||
if text in {BTN_PULL_COLLAB, BTN_PULL_COLLAB_KEY} or text.startswith(
|
||||
f"{BTN_PULL_COLLAB_KEY}:"
|
||||
):
|
||||
return BTN_PULL_COLLAB
|
||||
if text in {BTN_ADOPT_EXCEL, BTN_ADOPT_EXCEL_CARD, BTN_ADOPT_EXCEL_KEY} or text.startswith(
|
||||
f"{BTN_ADOPT_EXCEL_KEY}:"
|
||||
):
|
||||
@@ -328,6 +386,7 @@ def _work_order_in_token(raw: str) -> str:
|
||||
return ""
|
||||
for prefix in (
|
||||
f"{BTN_SKIP_COLLAB_KEY}:",
|
||||
f"{BTN_PULL_COLLAB_KEY}:",
|
||||
f"{BTN_ADOPT_EXCEL_KEY}:",
|
||||
f"{BTN_ADOPT_PDF_KEY}:",
|
||||
f"{BTN_REJECT_KEY}:",
|
||||
@@ -416,6 +475,51 @@ def quote_area_fee_text(quote: dict[str, Any] | None) -> str:
|
||||
return "\n".join(fee_lines)
|
||||
|
||||
|
||||
# 企微引用条过长会被客户端裁成省略号。海运常有 6~8 项费用,必须走文本明细。
|
||||
_QUOTE_AREA_SAFE_CHARS = 80
|
||||
|
||||
|
||||
def quote_area_safe_text(quote: dict[str, Any] | None) -> str:
|
||||
"""
|
||||
卡片引用条:短报价原样;超长只留合计,避免半截费用名。
|
||||
|
||||
完整明细由 sea_quote_detail_text 另发一条文本。空运卡仍用 quote_area_fee_text。
|
||||
"""
|
||||
full = quote_area_fee_text(quote)
|
||||
if len(full) <= _QUOTE_AREA_SAFE_CHARS:
|
||||
return full
|
||||
src = dict(quote or {})
|
||||
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
|
||||
total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy)
|
||||
n = len(quote_fee_lines(src))
|
||||
if n:
|
||||
return f"总销售价合计:{total}\n共{n}项费用,详见上一条明细"
|
||||
return f"总销售价合计:{total}"
|
||||
|
||||
|
||||
def sea_quote_detail_text(
|
||||
*,
|
||||
work_order_no: str,
|
||||
quote: dict[str, Any],
|
||||
first_or_same: str = "first",
|
||||
history_work_order_no: str = "",
|
||||
status: str = "已报价",
|
||||
) -> str:
|
||||
"""海运完整价格明细文本。不受卡片引用条字数限制。"""
|
||||
src = dict(quote or {})
|
||||
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
|
||||
lines = [source, f"{work_order_no}价格明细"]
|
||||
lines.extend(
|
||||
quote_detail_lines(
|
||||
src,
|
||||
first_or_same=first_or_same,
|
||||
history_work_order_no=history_work_order_no,
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def collab_hint(work_order_no: str) -> str:
|
||||
"""价格卡协同说明:无「内部协同」前缀,紧挨生成报价单按钮上方。"""
|
||||
return (
|
||||
@@ -681,7 +785,230 @@ def skip_collab_ack() -> str:
|
||||
return "好的,已跳过内部协同。正在按 TMS 标准报价生成报价单草稿…"
|
||||
|
||||
|
||||
def scheme_card(*, work_order_no: str, status: str, facts: dict[str, str], quote: dict[str, Any]) -> str:
|
||||
def sea_collab_ask() -> str:
|
||||
"""海运有价卡协同问句。"""
|
||||
return "是否拉产品人员进群协同?"
|
||||
|
||||
|
||||
def sea_miss_ask() -> str:
|
||||
"""海运无价卡协同问句。"""
|
||||
return "未查到合适报价,是否需要拉内部产品人员进群,进行手工协同报价?"
|
||||
|
||||
|
||||
def sea_tms_hit_card(
|
||||
*,
|
||||
work_order_no: str,
|
||||
quote: dict[str, Any],
|
||||
first_or_same: str = "first",
|
||||
history_work_order_no: str = "",
|
||||
status: str = "已报价",
|
||||
) -> str:
|
||||
"""海运有价:价格明细 + 拉群问句 + 两个按钮。空运卡片函数保持不动。"""
|
||||
src = dict(quote or {})
|
||||
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
|
||||
lines = [source, f"{work_order_no}价格明细"]
|
||||
lines.extend(
|
||||
quote_detail_lines(
|
||||
src,
|
||||
first_or_same=first_or_same,
|
||||
history_work_order_no=history_work_order_no,
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(sea_collab_ask())
|
||||
lines.append(BTN_PULL_COLLAB)
|
||||
lines.append(BTN_SKIP_COLLAB)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def sea_tms_hit_wecom_payload(
|
||||
*,
|
||||
work_order_no: str,
|
||||
quote: dict[str, Any],
|
||||
first_or_same: str = "first",
|
||||
history_work_order_no: str = "",
|
||||
status: str = "已报价",
|
||||
) -> dict[str, Any]:
|
||||
"""海运有价企微卡:价格明细 + 问句 + 拉群 / 跳过协同。"""
|
||||
_ = first_or_same, history_work_order_no, status
|
||||
src = dict(quote or {})
|
||||
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
|
||||
meta = "\n".join(
|
||||
[
|
||||
f"时效:{tms_eta_display(src)}",
|
||||
f"报价有效期:{tms_valid_until_display(src)}",
|
||||
"",
|
||||
sea_collab_ask(),
|
||||
]
|
||||
)
|
||||
return {
|
||||
"msgtype": "template_card",
|
||||
"template_card": {
|
||||
"card_type": "button_interaction",
|
||||
"source": {"desc": source, "desc_color": 0},
|
||||
"main_title": {"title": f"{work_order_no}价格明细"},
|
||||
"quote_area": {
|
||||
"type": 0,
|
||||
"quote_text": quote_area_safe_text(src),
|
||||
},
|
||||
"sub_title_text": meta,
|
||||
"task_id": card_task_id("quote", work_order_no),
|
||||
"button_list": [
|
||||
{
|
||||
"text": BTN_PULL_COLLAB,
|
||||
"style": 1,
|
||||
"key": f"{BTN_PULL_COLLAB_KEY}:{work_order_no}",
|
||||
},
|
||||
{
|
||||
"text": BTN_SKIP_COLLAB,
|
||||
"style": 1,
|
||||
"key": f"{BTN_SKIP_COLLAB_KEY}:{work_order_no}",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def sea_tms_miss(*, work_order_no: str) -> str:
|
||||
"""海运无价文字兜底:工单号、暂无匹配、询价中、拉群问句。"""
|
||||
no = (work_order_no or "").strip()
|
||||
return "\n".join(
|
||||
[
|
||||
f"询价工单 {no}",
|
||||
"暂无匹配报价",
|
||||
"工单状态:询价中",
|
||||
sea_miss_ask(),
|
||||
BTN_PULL_COLLAB,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def sea_tms_miss_wecom_payload(*, work_order_no: str, action_url: str = "") -> dict[str, Any]:
|
||||
"""
|
||||
海运无价一张卡:工单号、暂无匹配、询价中、拉群问句、一个按钮。
|
||||
|
||||
禁止再写「请手动拉人到原有群」。
|
||||
"""
|
||||
no = (work_order_no or "").strip()
|
||||
card: dict[str, Any] = {
|
||||
"card_type": "button_interaction",
|
||||
"source": {"desc": "TMS查价结果", "desc_color": 0},
|
||||
"main_title": {
|
||||
"title": f"询价工单 {no}",
|
||||
"desc": "暂无匹配报价",
|
||||
},
|
||||
"sub_title_text": "\n".join(
|
||||
[
|
||||
"需求已解析并创建工单。",
|
||||
sea_miss_ask(),
|
||||
]
|
||||
),
|
||||
"horizontal_content_list": [
|
||||
{"keyname": "工单状态", "value": "询价中"},
|
||||
{"keyname": "查价结果", "value": "暂无匹配报价"},
|
||||
],
|
||||
"task_id": card_task_id("miss", no),
|
||||
"button_list": [
|
||||
{
|
||||
"text": BTN_PULL_COLLAB,
|
||||
"style": 1,
|
||||
"key": f"{BTN_PULL_COLLAB_KEY}:{no}",
|
||||
}
|
||||
],
|
||||
}
|
||||
url = (action_url or "").strip()
|
||||
if url:
|
||||
card["card_action"] = {"type": 1, "url": url}
|
||||
return {"msgtype": "template_card", "template_card": card}
|
||||
|
||||
|
||||
def _role_label(name: str, role: str) -> str:
|
||||
"""姓名前加岗位,避免「销售甲、乙」被读成两人都是销售。已带岗位前缀的不再重复加。"""
|
||||
text = (name or "").strip()
|
||||
if not text:
|
||||
return role
|
||||
if text.startswith(role):
|
||||
return text
|
||||
return f"{role}{text}"
|
||||
|
||||
|
||||
def sea_group_created(*, work_order_no: str, sales_name: str, product_names: list[str]) -> str:
|
||||
"""拉群成功后的私聊。已邀请写成「销售某某、产品某某」,岗位不能省略。"""
|
||||
no = (work_order_no or "").strip()
|
||||
sales = _role_label(sales_name, "销售")
|
||||
products = "、".join(
|
||||
_role_label(n, "产品") for n in (product_names or []) if (n or "").strip()
|
||||
) or "产品"
|
||||
return (
|
||||
f"协同群已创建,群名与工单号一致:{no}。"
|
||||
f"已邀请:{sales}、{products}。请在群聊中继续确认报价。"
|
||||
)
|
||||
|
||||
|
||||
def sea_group_exists(*, work_order_no: str) -> str:
|
||||
"""同一工单已有群,不再新建。"""
|
||||
no = (work_order_no or "").strip()
|
||||
return f"协同群已在,群名与工单号一致:{no}。请到该群继续确认报价。"
|
||||
|
||||
|
||||
def sea_group_no_staff() -> str:
|
||||
"""没有可拉的产品人员,不建空群。"""
|
||||
return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的海运产品岗后再试。"
|
||||
|
||||
|
||||
def sea_group_create_fail(*, reason: str = "") -> str:
|
||||
"""建群失败,按钮保持可点。"""
|
||||
extra = (reason or "").strip()
|
||||
if extra:
|
||||
return f"建群失败:{extra}。请稍后重试。"
|
||||
return "建群失败,请稍后重试。"
|
||||
|
||||
|
||||
def sea_group_brief(
|
||||
*,
|
||||
work_order_no: str,
|
||||
facts: dict[str, str],
|
||||
quote: dict[str, Any] | None,
|
||||
collab_facts: dict[str, str] | None,
|
||||
sales_name: str,
|
||||
product_names: list[str],
|
||||
has_price: bool,
|
||||
) -> str:
|
||||
"""
|
||||
群里自动摘要:@销售 @产品 + 工单号 + 询价字段 + 协同字段 + TMS 报价。
|
||||
|
||||
协同没填写 -。无价写明暂无匹配报价。
|
||||
"""
|
||||
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]
|
||||
lines = [" ".join(at_bits), f"工单号:{no}", "询价字段:"]
|
||||
lines.extend(
|
||||
inquiry_field_lines(work_order_no=no, facts=facts, transport_mode="海运")[1:]
|
||||
)
|
||||
lines.append("协同补充字段:")
|
||||
collab = dict(collab_facts or {})
|
||||
for key in SEA_COLLAB_FIELDS:
|
||||
val = (collab.get(key) or "").strip() or "-"
|
||||
lines.append(f"{key}:{val}")
|
||||
lines.append("TMS报价:")
|
||||
if has_price and quote:
|
||||
lines.extend(quote_detail_lines(quote))
|
||||
else:
|
||||
lines.append("TMS 暂无匹配报价 / 未查到合适报价")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def scheme_card(
|
||||
*,
|
||||
work_order_no: str,
|
||||
status: str,
|
||||
facts: dict[str, str],
|
||||
quote: dict[str, Any],
|
||||
transport_mode: str = "空运",
|
||||
) -> str:
|
||||
"""
|
||||
报价方案文本兜底(模板卡发失败时仍给销售看)。
|
||||
|
||||
@@ -692,7 +1019,9 @@ def scheme_card(*, work_order_no: str, status: str, facts: dict[str, str], quote
|
||||
# 方案卡不展示询价「报价日期」,改展示 TMS 报价有效期
|
||||
body = "\n".join(
|
||||
line
|
||||
for line in inquiry_field_lines(work_order_no=work_order_no, facts=facts)
|
||||
for line in inquiry_field_lines(
|
||||
work_order_no=work_order_no, facts=facts, transport_mode=transport_mode
|
||||
)
|
||||
if not line.startswith("报价日期:")
|
||||
)
|
||||
eta = tms_eta_display(src)
|
||||
@@ -718,16 +1047,31 @@ def _clip(raw: str, limit: int) -> str:
|
||||
return text if len(text) <= limit else text[: limit - 1] + "…"
|
||||
|
||||
|
||||
def scheme_transport_mode(business_line: str = "", fallback: str = "空运") -> str:
|
||||
"""会话业务线 → 方案卡运输方式。海运不得再写成空运。"""
|
||||
raw = (business_line or "").strip()
|
||||
line = raw.upper()
|
||||
if line == "SEA" or raw == "海" or "海运" in raw:
|
||||
return "海运"
|
||||
if line == "LAND" or raw == "陆" or "陆运" in raw:
|
||||
return "陆运"
|
||||
if line == "AIR" or raw == "空" or "空运" in raw:
|
||||
return "空运"
|
||||
return (fallback or "空运").strip() or "空运"
|
||||
|
||||
|
||||
def scheme_horizontal_rows(
|
||||
*,
|
||||
work_order_no: str,
|
||||
facts: dict[str, str],
|
||||
quote: dict[str, Any],
|
||||
transport_mode: str = "空运",
|
||||
) -> list[dict[str, str]]:
|
||||
"""
|
||||
卡片字段:一行一项,每格独立字数,不会像副标题整段截成 8CB…。
|
||||
|
||||
企微 horizontal 最多 6 条。运输方式写进航线行,避免丢字段。
|
||||
海运用货物数量/整柜或拼柜/箱型箱量,禁止套空运件数毛重体积包装。
|
||||
不用 sub_title 堆字段(它会排到列表前面,且超长必截断)。
|
||||
"""
|
||||
src = facts or {}
|
||||
@@ -735,6 +1079,19 @@ def scheme_horizontal_rows(
|
||||
origin = (src.get("起运港") or "-").strip() or "-"
|
||||
dest = (src.get("目的港") or "-").strip() or "-"
|
||||
cargo = (src.get("品名") or "-").strip() or "-"
|
||||
mode = (transport_mode or "空运").strip() or "空运"
|
||||
if mode == "海运":
|
||||
qty = (src.get("货量") or src.get("货物数量") or "-").strip() or "-"
|
||||
load = (src.get("整柜或拼柜") or "-").strip() or "-"
|
||||
box = (src.get("箱型箱量") or "-").strip() or "-"
|
||||
return [
|
||||
{"keyname": "工单号", "value": _clip(work_order_no, 26)},
|
||||
{"keyname": "航线", "value": _clip(f"海运 {origin}→{dest}", 26)},
|
||||
{"keyname": "货物", "value": _clip(f"{cargo};{qty}", 26)},
|
||||
{"keyname": "箱型", "value": _clip(f"{load};{box}", 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")
|
||||
@@ -755,11 +1112,13 @@ def scheme_wecom_payload(
|
||||
facts: dict[str, str],
|
||||
quote: dict[str, Any],
|
||||
status: str = "已报价",
|
||||
transport_mode: str = "空运",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
一张报价方案卡:标题带工单号、引用条、字段、三个短按钮。
|
||||
|
||||
按钮文案必须短:生成Excel / 生成PDF / 不采用。长文案横排会被裁。
|
||||
海运/空运字段分开,禁止海运卡再出件数毛重体积。
|
||||
"""
|
||||
_ = status
|
||||
src = dict(quote or {})
|
||||
@@ -772,10 +1131,17 @@ def scheme_wecom_payload(
|
||||
"main_title": {"title": f"{work_order_no}报价方案"},
|
||||
"quote_area": {
|
||||
"type": 0,
|
||||
"quote_text": quote_area_fee_text(src),
|
||||
"quote_text": (
|
||||
quote_area_safe_text(src)
|
||||
if (transport_mode or "").strip() == "海运"
|
||||
else quote_area_fee_text(src)
|
||||
),
|
||||
},
|
||||
"horizontal_content_list": scheme_horizontal_rows(
|
||||
work_order_no=work_order_no, facts=facts, quote=src
|
||||
work_order_no=work_order_no,
|
||||
facts=facts,
|
||||
quote=src,
|
||||
transport_mode=transport_mode,
|
||||
),
|
||||
"task_id": card_task_id("scheme", work_order_no),
|
||||
"button_list": [
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
"""
|
||||
海运文字询价确定性流程。
|
||||
|
||||
本文件职责:海运七项补问 → 建单出确认卡 → 主账查 TMS → 有价/无价卡 → 跳过协同或拉群。
|
||||
空运 Owner 仍在 air_text_flow,禁止把海运再堆回去。
|
||||
调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。
|
||||
禁止:直连 TMS;本地拆箱型;用户文字冒充群报价版本。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.ledger.http_ledger import HttpLedger
|
||||
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 (
|
||||
AirTextInquiryFlow,
|
||||
FlowSession,
|
||||
ReplyFn,
|
||||
build_ledger,
|
||||
)
|
||||
from agent.schema.field_validate import validate_required_fields
|
||||
from agent.schema.tms_sea_query import assemble_sea_query
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 会话存档账号:每个协同群必拉。私聊「已邀请」不写这些人。
|
||||
# 姓名与后台员工管理一致;userid 以员工表/环境变量为准,源码不写死。
|
||||
ARCHIVE_SEAT_NAMES = ("询价机器人",)
|
||||
|
||||
|
||||
class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
"""
|
||||
海运文字询价 Owner。
|
||||
|
||||
复用空运后半段(跳过协同 / 出方案卡 / Excel PDF / 成交),只改查价前与卡片、拉群。
|
||||
"""
|
||||
|
||||
def __init__(self, ledger: Any = None, group_client: Any = None) -> None:
|
||||
super().__init__(ledger=ledger)
|
||||
self._group_client = group_client
|
||||
|
||||
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:
|
||||
"""
|
||||
处理一句海运私聊文字。返回阶段名供单测断言。
|
||||
|
||||
先认按钮,再抽字段。运输方式必须是 SEA 才建单。
|
||||
"""
|
||||
if not (sender_id or "").strip():
|
||||
return "reject_no_sender"
|
||||
sess = self.session_for_action(sender_id, inbound_raw, text=text)
|
||||
btn = self.match_button(text, sess)
|
||||
if btn:
|
||||
return self.on_button(
|
||||
sender_id=sender_id,
|
||||
action=btn,
|
||||
reply=reply,
|
||||
card_meta=inbound_raw,
|
||||
)
|
||||
|
||||
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()
|
||||
facts = dict(snap.get("facts") or {})
|
||||
if sess and sess.phase in {"clarify", "need_mode"}:
|
||||
from agent.llm.extract_text import detect_transport_mode
|
||||
from agent.schema.field_validate import harvest_oral_measures
|
||||
|
||||
merged = dict(sess.facts)
|
||||
merged.update(facts)
|
||||
facts = harvest_oral_measures(text, merged)
|
||||
mode = mode or (sess.business_line or "").upper()
|
||||
if not mode:
|
||||
mode = detect_transport_mode(f"{sess.immutable_text or ''}\n{text}")
|
||||
|
||||
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 != "SEA":
|
||||
if mode == "LAND":
|
||||
reply(copy.ASK_LAND_SUBTYPE)
|
||||
return "need_land_subtype"
|
||||
# 空运交给空运 Owner,避免海运流程半做空运
|
||||
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,
|
||||
)
|
||||
|
||||
check = validate_required_fields(facts=facts, business_line="SEA")
|
||||
facts = dict(check["facts"])
|
||||
tms_ready = assemble_sea_query(facts)
|
||||
facts = dict(tms_ready["facts"])
|
||||
if not check["ok"] or not tms_ready["ok"]:
|
||||
keys = list(check["missing"] or [])
|
||||
for key in tms_ready.get("missing") or []:
|
||||
if key not in keys:
|
||||
keys.append(key)
|
||||
reply(
|
||||
copy.ask_clarify(
|
||||
facts=facts,
|
||||
missing_keys=keys,
|
||||
transport_mode="海运",
|
||||
)
|
||||
)
|
||||
self._save(
|
||||
FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase="clarify",
|
||||
business_line="SEA",
|
||||
facts=facts,
|
||||
immutable_text=sess.immutable_text if sess and sess.immutable_text else text,
|
||||
wait_version=(sess.wait_version + 1) if sess else 1,
|
||||
)
|
||||
)
|
||||
return "clarify"
|
||||
|
||||
return self._create_and_quote(
|
||||
sender_id=sender_id,
|
||||
facts=facts,
|
||||
text=text,
|
||||
reply=reply,
|
||||
)
|
||||
|
||||
def _create_and_quote(
|
||||
self,
|
||||
*,
|
||||
sender_id: str,
|
||||
facts: dict[str, str],
|
||||
text: str,
|
||||
reply: ReplyFn,
|
||||
) -> str:
|
||||
"""建海运工单、先出确认卡、再查 TMS。"""
|
||||
import uuid
|
||||
|
||||
self._save(
|
||||
FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase="quoting",
|
||||
business_line="SEA",
|
||||
facts=facts,
|
||||
immutable_text=text,
|
||||
)
|
||||
)
|
||||
created = self._ledger.create_ticket(
|
||||
sender_id=sender_id,
|
||||
business_line="SEA",
|
||||
facts=facts,
|
||||
idempotency_key=f"create:{sender_id}:{uuid.uuid4().hex}",
|
||||
)
|
||||
no = str(created.get("work_order_no") or "").strip()
|
||||
if not no:
|
||||
reply("建单失败,请稍后把刚才那条再发一次。海运字段已经记下。")
|
||||
return "ledger_fail"
|
||||
first, history_wo = self._resolve_similar(created, facts=facts, current_no=no)
|
||||
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="SEA")
|
||||
sess = FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
business_line="SEA",
|
||||
facts=facts,
|
||||
work_order_no=no,
|
||||
first_or_same=first,
|
||||
history_work_order_no=history_wo,
|
||||
immutable_text=text,
|
||||
wait_version=1,
|
||||
)
|
||||
if not tms.get("ok"):
|
||||
if tms.get("classification") == "TMS_QUERY_INCOMPLETE":
|
||||
logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification"))
|
||||
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
|
||||
sess.phase = "tms_tech"
|
||||
self._save(sess)
|
||||
return "tms_tech"
|
||||
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=first,
|
||||
history_work_order_no=history_wo,
|
||||
status="已报价",
|
||||
)
|
||||
return "wait_collab"
|
||||
|
||||
def _resolve_similar(
|
||||
self,
|
||||
created: dict[str, Any],
|
||||
*,
|
||||
facts: dict[str, str],
|
||||
current_no: str,
|
||||
) -> 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)
|
||||
if not callable(finder):
|
||||
return "first", ""
|
||||
try:
|
||||
similar = finder(
|
||||
facts=facts, business_line="SEA", exclude_work_order_no=current_no
|
||||
) or {}
|
||||
except TypeError:
|
||||
similar = finder(facts=facts, business_line="SEA") or {}
|
||||
except Exception:
|
||||
logger.warning("find_similar 失败,按首次询价展示 current=%s", current_no)
|
||||
return "first", ""
|
||||
history = str(similar.get("work_order_no") or "").strip()
|
||||
if similar.get("found") and history and history != current_no:
|
||||
return "same", history
|
||||
return "first", ""
|
||||
|
||||
def _emit_tms_miss(self, reply: ReplyFn, work_order_no: str) -> None:
|
||||
"""海运无价:一张带拉群按钮的卡,不再发手动拉人群句。"""
|
||||
from agent.config import get_settings
|
||||
|
||||
reply(
|
||||
copy.sea_tms_miss(work_order_no=work_order_no),
|
||||
copy.sea_tms_miss_wecom_payload(
|
||||
work_order_no=work_order_no,
|
||||
action_url=(get_settings().public_base_url or "").strip(),
|
||||
),
|
||||
)
|
||||
|
||||
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":
|
||||
sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB)
|
||||
if sess.business_line == "SEA" 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 {})
|
||||
return sess
|
||||
|
||||
def _emit_sea_hit(
|
||||
self,
|
||||
reply: ReplyFn,
|
||||
*,
|
||||
work_order_no: str,
|
||||
quote: dict[str, Any],
|
||||
first_or_same: str = "first",
|
||||
history_work_order_no: str = "",
|
||||
status: str = "已报价",
|
||||
) -> None:
|
||||
"""
|
||||
先发完整价格明细文本,再发带按钮的卡。
|
||||
|
||||
企微只发卡时,引用条超长会被截断;文本不受限。
|
||||
"""
|
||||
reply(
|
||||
copy.sea_quote_detail_text(
|
||||
work_order_no=work_order_no,
|
||||
quote=quote,
|
||||
first_or_same=first_or_same,
|
||||
history_work_order_no=history_work_order_no,
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
reply(
|
||||
copy.sea_tms_hit_card(
|
||||
work_order_no=work_order_no,
|
||||
quote=quote,
|
||||
first_or_same=first_or_same,
|
||||
history_work_order_no=history_work_order_no,
|
||||
status=status,
|
||||
),
|
||||
copy.sea_tms_hit_wecom_payload(
|
||||
work_order_no=work_order_no,
|
||||
quote=quote,
|
||||
first_or_same=first_or_same,
|
||||
history_work_order_no=history_work_order_no,
|
||||
status=status,
|
||||
),
|
||||
)
|
||||
|
||||
def _replay_wait(self, sess: FlowSession, reply: ReplyFn, *, source: str) -> str:
|
||||
if sess.business_line == "SEA" and sess.phase == "wait_collab":
|
||||
self._emit_sea_hit(
|
||||
reply,
|
||||
work_order_no=sess.work_order_no,
|
||||
quote=sess.quote,
|
||||
first_or_same=sess.first_or_same,
|
||||
history_work_order_no=sess.history_work_order_no,
|
||||
status=sess.status or "已报价",
|
||||
)
|
||||
return "wait_collab"
|
||||
if sess.business_line == "SEA" and sess.phase == "tms_miss":
|
||||
self._emit_tms_miss(reply, sess.work_order_no)
|
||||
return "tms_miss"
|
||||
return super()._replay_wait(sess, reply, source=source)
|
||||
|
||||
def on_button(
|
||||
self,
|
||||
*,
|
||||
sender_id: str,
|
||||
action: str,
|
||||
reply: ReplyFn,
|
||||
card_meta: Optional[dict[str, Any]] = None,
|
||||
) -> str:
|
||||
sess = self.session_for_action(sender_id, card_meta)
|
||||
if sess and action == copy.BTN_PULL_COLLAB and action in sess.allowed:
|
||||
return self._pull_collab_group(sess, reply, card_meta=card_meta)
|
||||
return super().on_button(
|
||||
sender_id=sender_id, action=action, reply=reply, card_meta=card_meta
|
||||
)
|
||||
|
||||
def _pull_collab_group(
|
||||
self,
|
||||
sess: FlowSession,
|
||||
reply: ReplyFn,
|
||||
card_meta: Optional[dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
点「拉产品进群协同」:按线路拉产品人,新建群,私聊回执,按钮置灰。
|
||||
|
||||
没人可拉 / 建群失败:说明原因,按钮保持可点。已有群:不新建,置灰。
|
||||
"""
|
||||
no = (sess.work_order_no or "").strip()
|
||||
existing = (sess.collab_chat_id or "").strip()
|
||||
if not existing:
|
||||
getter = getattr(self._ledger, "get_ticket", None)
|
||||
ticket = getter(work_order_no=no) if callable(getter) else None
|
||||
existing = str(getattr(ticket, "collab_chat_id", "") or "")
|
||||
if existing:
|
||||
sess.collab_chat_id = existing
|
||||
sess.allowed = ()
|
||||
sess.phase = "collab_group"
|
||||
self._save(sess)
|
||||
self._grey_clicked_card(reply, card_meta)
|
||||
self._ensure_archive_seats_in_group(existing)
|
||||
reply(copy.sea_group_exists(work_order_no=no))
|
||||
return "collab_group"
|
||||
|
||||
staff = self._match_sea_staff(sess)
|
||||
if not staff:
|
||||
reply(copy.sea_group_no_staff())
|
||||
return sess.phase or "wait_collab"
|
||||
|
||||
userids = [sess.sender_id]
|
||||
product_names: list[str] = []
|
||||
product_ids: list[str] = []
|
||||
for row in staff:
|
||||
uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
|
||||
name = str(row.get("name") or "").strip() or uid
|
||||
if uid and uid not in userids:
|
||||
userids.append(uid)
|
||||
product_ids.append(uid)
|
||||
product_names.append(name)
|
||||
if not product_ids:
|
||||
reply(copy.sea_group_no_staff())
|
||||
return sess.phase or "wait_collab"
|
||||
|
||||
# 销售 + 产品之后,必须再拉会话存档账号;没有也不挡建群。
|
||||
for uid in self._archive_seat_userids():
|
||||
if uid not in userids:
|
||||
userids.append(uid)
|
||||
|
||||
client = self._group_client or _default_group_client()
|
||||
created = client.create_group(name=no, userids=userids) or {}
|
||||
if not created.get("ok") or not created.get("chat_id"):
|
||||
reply(copy.sea_group_create_fail(reason=str(created.get("error") or "")))
|
||||
return sess.phase or "wait_collab"
|
||||
|
||||
chat_id = str(created.get("chat_id") or "").strip()
|
||||
sales_name = self._sales_display_name(sess.sender_id)
|
||||
binder = getattr(self._ledger, "bind_collab_group", None)
|
||||
if callable(binder):
|
||||
binder(
|
||||
work_order_no=no,
|
||||
chat_id=chat_id,
|
||||
member_ids=userids,
|
||||
product_names=product_names,
|
||||
)
|
||||
|
||||
brief = copy.sea_group_brief(
|
||||
work_order_no=no,
|
||||
facts=sess.facts,
|
||||
quote=sess.quote,
|
||||
collab_facts=sess.collab_facts,
|
||||
sales_name=sales_name,
|
||||
product_names=product_names,
|
||||
has_price=bool(sess.quote and sess.status == "已报价"),
|
||||
)
|
||||
client.send_group(chat_id=chat_id, content=brief)
|
||||
|
||||
sess.collab_chat_id = chat_id
|
||||
sess.invited_userids = tuple(userids)
|
||||
sess.invited_sales_name = sales_name
|
||||
sess.invited_product_names = tuple(product_names)
|
||||
sess.phase = "collab_group"
|
||||
sess.allowed = ()
|
||||
sess.wait_version += 1
|
||||
self._save(sess)
|
||||
self._grey_clicked_card(reply, card_meta)
|
||||
reply(
|
||||
copy.sea_group_created(
|
||||
work_order_no=no,
|
||||
sales_name=sales_name,
|
||||
product_names=product_names,
|
||||
)
|
||||
)
|
||||
return "collab_group"
|
||||
|
||||
def _match_sea_staff(self, sess: FlowSession) -> list[dict[str, Any]]:
|
||||
"""主账按起运/目的匹配启用的海运产品岗。"""
|
||||
matcher = getattr(self._ledger, "match_sea_staff", None)
|
||||
if not callable(matcher):
|
||||
return []
|
||||
try:
|
||||
rows = matcher(
|
||||
origin=str((sess.facts or {}).get("起运港") or ""),
|
||||
destination=str((sess.facts or {}).get("目的港") or ""),
|
||||
) or []
|
||||
except Exception:
|
||||
logger.exception("匹配海运产品人员失败 wo=%s", sess.work_order_no)
|
||||
return []
|
||||
return [dict(x) for x in rows if isinstance(x, dict)]
|
||||
|
||||
def _archive_seat_userids(self) -> list[str]:
|
||||
"""
|
||||
会话存档账号 userid 列表。
|
||||
|
||||
先读环境变量 WECOM_ARCHIVE_SEAT_USER_IDS,再按姓名「询价机器人」查员工管理。
|
||||
查不到只记日志,不把建群判失败。
|
||||
"""
|
||||
ids: list[str] = []
|
||||
from agent.config import get_settings
|
||||
|
||||
seat = str(getattr(get_settings(), "wecom_archive_seat_user_ids", "") or "").strip()
|
||||
for part in seat.replace(";", ",").split(","):
|
||||
uid = part.strip()
|
||||
if uid and uid not in ids:
|
||||
ids.append(uid)
|
||||
finder = getattr(self._ledger, "find_staff_by_name", None)
|
||||
if callable(finder):
|
||||
for name in ARCHIVE_SEAT_NAMES:
|
||||
try:
|
||||
row = finder(name=name) or {}
|
||||
except Exception:
|
||||
logger.exception("查会话存档账号失败 name=%s", name)
|
||||
continue
|
||||
uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
|
||||
if uid and uid not in ids:
|
||||
ids.append(uid)
|
||||
if not ids:
|
||||
logger.warning("协同群缺少会话存档账号:员工管理未找到「询价机器人」且未配置 WECOM_ARCHIVE_SEAT_USER_IDS")
|
||||
return ids
|
||||
|
||||
def _ensure_archive_seats_in_group(self, chat_id: str) -> None:
|
||||
"""已有群补拉会话存档账号;失败只记日志,不改销售侧已有群提示。"""
|
||||
seats = self._archive_seat_userids()
|
||||
if not chat_id or not seats:
|
||||
return
|
||||
client = self._group_client or _default_group_client()
|
||||
adder = getattr(client, "add_group_members", None)
|
||||
if not callable(adder):
|
||||
return
|
||||
try:
|
||||
adder(chat_id=chat_id, userids=seats)
|
||||
except Exception:
|
||||
logger.exception("已有协同群补拉会话存档失败 chat=%s", chat_id)
|
||||
|
||||
def _sales_display_name(self, sender_id: str) -> str:
|
||||
"""私聊「已邀请」用的销售名;没有展示名就用 userid。"""
|
||||
finder = getattr(self._ledger, "get_staff_by_wecom_id", None)
|
||||
if callable(finder):
|
||||
try:
|
||||
row = finder(wecom_id=sender_id) or {}
|
||||
name = str(row.get("name") or "").strip()
|
||||
if name:
|
||||
return name
|
||||
except Exception:
|
||||
logger.warning("查销售姓名失败 sender=%s", sender_id)
|
||||
return sender_id
|
||||
|
||||
def apply_collab_facts(
|
||||
self,
|
||||
*,
|
||||
work_order_no: str,
|
||||
facts: dict[str, str],
|
||||
sender_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
群里静默写入协同字段。不改六态、不回复、不出报价版本。
|
||||
|
||||
只合并非空协同键。
|
||||
"""
|
||||
from agent.schema.field_validate import SEA_COLLAB_FIELDS
|
||||
|
||||
no = (work_order_no or "").strip()
|
||||
cleaned = {
|
||||
k: str(v).strip()
|
||||
for k, v in (facts or {}).items()
|
||||
if k in SEA_COLLAB_FIELDS and str(v or "").strip()
|
||||
}
|
||||
if not no or not cleaned:
|
||||
return {"ok": False, "updated": []}
|
||||
with self._lock:
|
||||
sess = self._by_ticket.get(no)
|
||||
if sess:
|
||||
merged = dict(sess.collab_facts)
|
||||
merged.update(cleaned)
|
||||
sess.collab_facts = merged
|
||||
self._save(sess)
|
||||
writer = getattr(self._ledger, "patch_collab_facts", None)
|
||||
if callable(writer):
|
||||
writer(work_order_no=no, facts=cleaned, sender_id=sender_id)
|
||||
logger.info("sea.collab_fields wo=%s keys=%s by=%s", no, sorted(cleaned), sender_id)
|
||||
return {"ok": True, "updated": sorted(cleaned)}
|
||||
|
||||
|
||||
def _default_group_client() -> Any:
|
||||
"""生产走企微应用建群;未配置时返回会失败的空客户端。"""
|
||||
from agent.channel.outbox.sender import WeComAppClient
|
||||
from agent.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
return WeComAppClient(
|
||||
corp_id=settings.wecom_corp_id,
|
||||
secret=settings.wecom_secret,
|
||||
agent_id=int(settings.wecom_agent_id or "1000010"),
|
||||
)
|
||||
|
||||
|
||||
_FLOW: Optional[SeaTextInquiryFlow] = None
|
||||
_FLOW_GUARD = threading.Lock()
|
||||
|
||||
|
||||
def get_sea_text_flow() -> SeaTextInquiryFlow:
|
||||
"""进程内一份海运流程;测试可 new 独立实例。"""
|
||||
global _FLOW
|
||||
with _FLOW_GUARD:
|
||||
if _FLOW is None:
|
||||
_FLOW = SeaTextInquiryFlow()
|
||||
return _FLOW
|
||||
|
||||
|
||||
def reset_sea_text_flow_for_test() -> SeaTextInquiryFlow:
|
||||
"""单测重置全局海运实例(强制内存账本)。"""
|
||||
global _FLOW
|
||||
with _FLOW_GUARD:
|
||||
_FLOW = SeaTextInquiryFlow(MemoryLedger())
|
||||
return _FLOW
|
||||
Reference in New Issue
Block a user