落地海运协同群:测服靠会话存档听群话,报价单能带上协同字段。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -50,6 +50,8 @@ class FlowSession:
|
||||
invited_userids: tuple[str, ...] = ()
|
||||
invited_sales_name: str = ""
|
||||
invited_product_names: tuple[str, ...] = ()
|
||||
deal_version: int = 0
|
||||
collab_ended: bool = False
|
||||
|
||||
|
||||
class AirTextInquiryFlow:
|
||||
@@ -74,6 +76,14 @@ class AirTextInquiryFlow:
|
||||
with self._lock:
|
||||
return self._by_sender.get(sender_id)
|
||||
|
||||
def session_by_work_order(self, work_order_no: str) -> Optional[FlowSession]:
|
||||
"""用工单号找回书签,不校验 sender。群成交卡校验用。"""
|
||||
no = (work_order_no or "").strip()
|
||||
if not no:
|
||||
return None
|
||||
with self._lock:
|
||||
return self._by_ticket.get(no)
|
||||
|
||||
def session_by_ticket(self, sender_id: str, work_order_no: str) -> Optional[FlowSession]:
|
||||
"""
|
||||
只查进程内该书签,不读主账。
|
||||
@@ -298,7 +308,8 @@ class AirTextInquiryFlow:
|
||||
reply("报价单正在生成,请稍候。")
|
||||
return "wait_file"
|
||||
if sess.phase == "wait_deal":
|
||||
self._emit_deal(reply, sess)
|
||||
# 成交卡已经绑在报价文件那条出站上。这里再发一张独立卡,
|
||||
# 会在文件还在上传时被另一条出站线程抢走,销售又先看到成交跟进。
|
||||
return "wait_deal"
|
||||
if sess.phase == "done":
|
||||
reply(copy.deal_ack(sess.status or "已关闭", sess.work_order_no))
|
||||
@@ -814,15 +825,33 @@ class AirTextInquiryFlow:
|
||||
file_extra: dict[str, Any],
|
||||
) -> str:
|
||||
"""
|
||||
先发报价文件,等这条出站真正发出,再出成交跟进卡。
|
||||
报价文件与成交跟进走同一条出站:先传文件,成功后再发卡。
|
||||
|
||||
避免卡片比文件先到销售手机。单测 reply 不回 id 则不等待。
|
||||
两张消息分两条出站时,另一条消费线程会在上传期间把成交卡发出去。
|
||||
单测 reply 不回 id 则不等待。
|
||||
"""
|
||||
out_id = reply(
|
||||
copy.file_ready(kind=kind, work_order_no=sess.work_order_no, filename=filename),
|
||||
file_extra,
|
||||
extra = dict(file_extra or {})
|
||||
extra["followup_card"] = copy.deal_wecom_payload(
|
||||
work_order_no=sess.work_order_no,
|
||||
quote=sess.quote,
|
||||
facts=sess.facts,
|
||||
).get("template_card") or {}
|
||||
reply(
|
||||
"\n".join(
|
||||
[
|
||||
copy.file_ready(
|
||||
kind=kind, work_order_no=sess.work_order_no, filename=filename
|
||||
),
|
||||
copy.deal_card(
|
||||
work_order_no=sess.work_order_no,
|
||||
status=sess.status or "已报价",
|
||||
quote=sess.quote,
|
||||
facts=sess.facts,
|
||||
),
|
||||
]
|
||||
),
|
||||
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)
|
||||
@@ -830,7 +859,6 @@ class AirTextInquiryFlow:
|
||||
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||||
sess.wait_version += 1
|
||||
self._save(sess)
|
||||
self._emit_deal(reply, sess)
|
||||
return "wait_deal"
|
||||
|
||||
def _emit_deal(self, reply: ReplyFn, sess: FlowSession) -> None:
|
||||
|
||||
@@ -13,7 +13,12 @@ import time
|
||||
import uuid
|
||||
from typing import Any, Iterable
|
||||
|
||||
from agent.schema.field_validate import AIR_REQUIRED_FIELDS, SEA_COLLAB_FIELDS, SEA_REQUIRED_FIELDS
|
||||
from agent.schema.field_validate import (
|
||||
AIR_REQUIRED_FIELDS,
|
||||
SEA_COLLAB_DISPLAY,
|
||||
SEA_COLLAB_FIELDS,
|
||||
SEA_REQUIRED_FIELDS,
|
||||
)
|
||||
|
||||
# 空运补问:与合同 8 项一致。报价日期默认当天,不进待补充。
|
||||
_AIR_EXAMPLES = {
|
||||
@@ -379,6 +384,20 @@ def work_order_from_card_meta(
|
||||
return ""
|
||||
|
||||
|
||||
def deal_card_version(card_meta: dict[str, Any] | None = None, *, text: str = "") -> int:
|
||||
"""从成交卡 EventKey 取出 vN;没有则 0(旧私聊卡)。"""
|
||||
src = card_meta or {}
|
||||
for raw in (
|
||||
str(src.get("event_key") or ""),
|
||||
str(src.get("task_id") or ""),
|
||||
text or "",
|
||||
):
|
||||
hit = re.search(r":v(\d+)\s*$", raw)
|
||||
if hit:
|
||||
return int(hit.group(1))
|
||||
return 0
|
||||
|
||||
|
||||
def _work_order_in_token(raw: str) -> str:
|
||||
"""从 TaskId / EventKey 里抽出 WO 工单号;没有则空。"""
|
||||
text = (raw or "").strip()
|
||||
@@ -505,19 +524,18 @@ def sea_quote_detail_text(
|
||||
history_work_order_no: str = "",
|
||||
status: str = "已报价",
|
||||
) -> str:
|
||||
"""海运完整价格明细文本。不受卡片引用条字数限制。"""
|
||||
"""
|
||||
海运完整价格明细文本:来源、工单号、费用行、合计。
|
||||
|
||||
时效、报价有效期只在卡片上出现,这里不写,避免和卡重复。
|
||||
不受卡片引用条字数限制。first_or_same 只兼容调用方。
|
||||
"""
|
||||
_ = first_or_same, history_work_order_no, status
|
||||
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(
|
||||
[source, f"{work_order_no}价格明细", quote_area_fee_text(src)]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def collab_hint(work_order_no: str) -> str:
|
||||
@@ -957,6 +975,11 @@ def sea_group_no_staff() -> str:
|
||||
return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的海运产品岗后再试。"
|
||||
|
||||
|
||||
def group_sheet_send_fail() -> str:
|
||||
"""模板已填好但没发进群。"""
|
||||
return "报价单已生成,发到群里失败,请稍后重试。"
|
||||
|
||||
|
||||
def sea_group_create_fail(*, reason: str = "") -> str:
|
||||
"""建群失败,按钮保持可点。"""
|
||||
extra = (reason or "").strip()
|
||||
@@ -978,7 +1001,8 @@ def sea_group_brief(
|
||||
"""
|
||||
群里自动摘要:@销售 @产品 + 工单号 + 询价字段 + 协同字段 + TMS 报价。
|
||||
|
||||
协同没填写 -。无价写明暂无匹配报价。
|
||||
三段之间空一行,方便群里扫。协同没填写 -。
|
||||
HS编码对外写成商品海关编码。无价只写「TMS 暂无匹配报价」。
|
||||
"""
|
||||
no = (work_order_no or "").strip()
|
||||
sales = (sales_name or "").strip() or "销售"
|
||||
@@ -988,16 +1012,19 @@ def sea_group_brief(
|
||||
lines.extend(
|
||||
inquiry_field_lines(work_order_no=no, facts=facts, transport_mode="海运")[1:]
|
||||
)
|
||||
lines.append("")
|
||||
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}")
|
||||
label = SEA_COLLAB_DISPLAY.get(key, key)
|
||||
val = (collab.get(key) or collab.get(label) or "").strip() or "-"
|
||||
lines.append(f"{label}:{val}")
|
||||
lines.append("")
|
||||
lines.append("TMS报价:")
|
||||
if has_price and quote:
|
||||
lines.extend(quote_detail_lines(quote))
|
||||
else:
|
||||
lines.append("TMS 暂无匹配报价 / 未查到合适报价")
|
||||
lines.append("TMS 暂无匹配报价")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -1244,6 +1271,7 @@ def deal_wecom_payload(
|
||||
work_order_no: str,
|
||||
quote: dict[str, Any] | None = None,
|
||||
facts: dict[str, str] | None = None,
|
||||
card_version: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
成交跟进卡:对齐现网 button_interaction。
|
||||
@@ -1254,6 +1282,8 @@ def deal_wecom_payload(
|
||||
src = dict(quote or {})
|
||||
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
|
||||
no = (work_order_no or "").strip()
|
||||
ver = int(card_version or 0)
|
||||
suffix = f":v{ver}" if ver > 0 else ""
|
||||
return {
|
||||
"msgtype": "template_card",
|
||||
"template_card": {
|
||||
@@ -1270,23 +1300,75 @@ def deal_wecom_payload(
|
||||
{
|
||||
"text": BTN_DEAL,
|
||||
"style": 1,
|
||||
"key": f"{BTN_DEAL_KEY}:{no}",
|
||||
"key": f"{BTN_DEAL_KEY}:{no}{suffix}",
|
||||
},
|
||||
{
|
||||
"text": BTN_LOST,
|
||||
"style": 1,
|
||||
"key": f"{BTN_LOST_KEY}:{no}",
|
||||
"key": f"{BTN_LOST_KEY}:{no}{suffix}",
|
||||
},
|
||||
{
|
||||
"text": BTN_NEGOTIATE,
|
||||
"style": 1,
|
||||
"key": f"{BTN_NEGOTIATE_KEY}:{no}",
|
||||
"key": f"{BTN_NEGOTIATE_KEY}:{no}{suffix}",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def ticket_locked(*, work_order_no: str, status: str) -> str:
|
||||
"""已成交/未成交/已关闭后,群里改字段或再报价。"""
|
||||
no = (work_order_no or "").strip() or "该工单"
|
||||
st = (status or "").strip() or "已关闭"
|
||||
return f"工单{no}{st},不能修改工单信息"
|
||||
|
||||
|
||||
def ticket_locked_file(*, work_order_no: str, status: str) -> str:
|
||||
"""终态工单再发 Excel/PDF。"""
|
||||
no = (work_order_no or "").strip() or "该工单"
|
||||
st = (status or "").strip() or "已关闭"
|
||||
return f"工单{no}{st},不能再上传附件"
|
||||
|
||||
|
||||
def group_only_excel_pdf() -> str:
|
||||
return "群里只收 Excel 和 PDF,其它文件不会存入工单"
|
||||
|
||||
|
||||
def group_quote_not_recognized() -> str:
|
||||
return "没有识别到报价,请产品再发一次价格或 Excel/PDF"
|
||||
|
||||
|
||||
def group_fields_not_recognized() -> str:
|
||||
return "听到了。请直接说贸易条款、货好时间、商品海关编码、是否含油/含电/含磁。"
|
||||
|
||||
|
||||
def group_deal_stale(*, work_order_no: str) -> str:
|
||||
"""点了上一版成交卡。"""
|
||||
no = (work_order_no or "").strip()
|
||||
return f"这张成交确认已过期。工单{no}已有更新的报价,请点击群里最新一条成交跟进。"
|
||||
|
||||
|
||||
def group_deal_sales_only() -> str:
|
||||
return "只有负责该工单的销售可以确认成交状态。"
|
||||
|
||||
|
||||
def group_dissolved(*, work_order_no: str) -> str:
|
||||
no = (work_order_no or "").strip()
|
||||
return f"群已解散。工单{no}协同已结束。"
|
||||
|
||||
|
||||
def group_cannot_recreate(*, work_order_no: str) -> str:
|
||||
"""解散后再点拉群。"""
|
||||
no = (work_order_no or "").strip() or "该工单"
|
||||
return f"工单{no}协同已结束,不能再拉群。"
|
||||
|
||||
|
||||
def group_deal_mention(*, sales_name: str, work_order_no: str) -> str:
|
||||
name = (sales_name or "").strip() or "销售"
|
||||
return f"@{name}\n{deal_card(work_order_no=work_order_no)}"
|
||||
|
||||
|
||||
def deal_ack(outcome: str, work_order_no: str) -> str:
|
||||
if outcome == "已成交":
|
||||
return f"已记录为「已成交」。工单 {work_order_no} 已关闭智能体跟进。"
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
按海运询价单标签补写协同字段。
|
||||
|
||||
主账填表只认英文码,货好时间 / 海关编码 / 含油电磁常空着。
|
||||
本文件只改 Excel 字节,不打主账、不占回调线程。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 长的「含油,电,磁」必须排在「是否含油」前面,避免只写到油。
|
||||
_COLLAB_LABEL_MARKERS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("是否含油,电,磁", ("oil_elec_mag", "是否含油,电,磁")),
|
||||
("OIL,ELEC,MAG", ("oil_elec_mag", "是否含油,电,磁")),
|
||||
("贸易条款", ("贸易条款", "trade_terms")),
|
||||
("货好时间", ("货好时间", "cargo_ready_date")),
|
||||
("商品海关编码", ("商品海关编码", "HS编码", "海关编码", "hs_code")),
|
||||
("HS Code", ("商品海关编码", "HS编码", "海关编码", "hs_code")),
|
||||
("是否含油", ("是否含油", "has_oil")),
|
||||
("是否含电", ("是否含电", "has_battery")),
|
||||
("是否含磁", ("是否含磁", "has_magnet")),
|
||||
)
|
||||
|
||||
|
||||
def _fact_for_markers(facts: Mapping[str, Any], keys: tuple[str, ...]) -> str:
|
||||
for key in keys:
|
||||
val = str((facts or {}).get(key) or "").strip()
|
||||
if val and val != "-":
|
||||
return val
|
||||
return ""
|
||||
|
||||
|
||||
def overlay_labeled_facts(
|
||||
*,
|
||||
facts: Mapping[str, Any],
|
||||
filepath: str = "",
|
||||
file_b64: str = "",
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
按模板标签再写协同格。失败则原样交回。
|
||||
|
||||
副作用:可能写临时 xlsx。
|
||||
"""
|
||||
import base64
|
||||
import tempfile
|
||||
|
||||
raw = b""
|
||||
if filepath:
|
||||
try:
|
||||
raw = Path(filepath).read_bytes()
|
||||
except OSError as exc:
|
||||
logger.warning("overlay 读文件失败 err=%s", exc)
|
||||
if not raw and file_b64 and len(file_b64) > 64:
|
||||
try:
|
||||
raw = base64.b64decode(file_b64)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("overlay 解码失败 err=%s", exc)
|
||||
if not raw:
|
||||
return {"filepath": filepath, "file_b64": file_b64}
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
except ImportError:
|
||||
return {"filepath": filepath, "file_b64": file_b64}
|
||||
|
||||
tmp = Path(tempfile.mkdtemp(prefix="quote-overlay-")) / "filled.xlsx"
|
||||
tmp.write_bytes(raw)
|
||||
written = 0
|
||||
try:
|
||||
wb = load_workbook(tmp)
|
||||
ws = wb.worksheets[0]
|
||||
pending: list[tuple[int, int, str]] = []
|
||||
used_rows: set[int] = set()
|
||||
for row in ws.iter_rows(min_row=1, max_row=ws.max_row or 1, max_col=min(ws.max_column or 1, 16)):
|
||||
for cell in row:
|
||||
text = str(cell.value or "").strip()
|
||||
if not text or cell.row in used_rows:
|
||||
continue
|
||||
keys = _match_collab_label(text)
|
||||
if not keys:
|
||||
continue
|
||||
val = _fact_for_markers(facts, keys)
|
||||
if not val:
|
||||
continue
|
||||
dest = _label_value_cell(ws, cell)
|
||||
pending.append((dest.row, dest.column, val))
|
||||
used_rows.add(cell.row)
|
||||
break
|
||||
for r, c, val in pending:
|
||||
ws.cell(row=r, column=c, value=val)
|
||||
written += 1
|
||||
wb.save(tmp)
|
||||
wb.close()
|
||||
except Exception:
|
||||
logger.exception("overlay 协同字段失败")
|
||||
return {"filepath": filepath, "file_b64": file_b64}
|
||||
|
||||
out_b64 = base64.b64encode(tmp.read_bytes()).decode("ascii")
|
||||
logger.info("overlay 协同字段 written=%s", written)
|
||||
return {"filepath": str(tmp), "file_b64": out_b64}
|
||||
|
||||
|
||||
def _match_collab_label(text: str) -> tuple[str, ...] | None:
|
||||
raw = (text or "").replace(":", ":").strip()
|
||||
for marker, keys in _COLLAB_LABEL_MARKERS:
|
||||
if marker in raw:
|
||||
return keys
|
||||
return None
|
||||
|
||||
|
||||
def _label_value_cell(ws, label_cell):
|
||||
"""标签右侧格子;合并标签则从合并区右边开始。"""
|
||||
row_idx = label_cell.row
|
||||
start_col = label_cell.column + 1
|
||||
merged = getattr(ws, "merged_cells", None)
|
||||
if merged is not None:
|
||||
for rng in merged.ranges:
|
||||
if rng.min_row <= row_idx <= rng.max_row and rng.min_col <= label_cell.column <= rng.max_col:
|
||||
start_col = rng.max_col + 1
|
||||
break
|
||||
return ws.cell(row=row_idx, column=start_col)
|
||||
@@ -0,0 +1,702 @@
|
||||
"""
|
||||
海运协同群业务:填表进群、补字段重发、产品报价、附件、解散、成交跟进。
|
||||
|
||||
本文件职责:群里副作用(主账+企微群)。私聊按钮仍走 sea_text_flow.on_button。
|
||||
禁止:改询价七项;终态默默忽略;把销售附件当报价。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.llm.extract_text import extract_collab_fields, extract_product_quote
|
||||
from agent.policy.quote_overlay import overlay_labeled_facts
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.schema.field_validate import SEA_COLLAB_DISPLAY, SEA_COLLAB_FIELDS
|
||||
|
||||
# 主账填表按英文码取格子;中文键必须同时带上,否则货好时间/条款写不进表。
|
||||
_COLLAB_FILL_ALIASES = {
|
||||
"贸易条款": ("贸易条款", "trade_terms"),
|
||||
"货好时间": ("货好时间", "cargo_ready_date"),
|
||||
"HS编码": ("HS编码", "商品海关编码", "海关编码", "hs_code"),
|
||||
"是否含油": ("是否含油", "has_oil"),
|
||||
"是否含电": ("是否含电", "has_battery"),
|
||||
"是否含磁": ("是否含磁", "has_magnet"),
|
||||
}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TERMINAL = {"已成交", "未成交", "已关闭"}
|
||||
OPEN_EDIT = {"询价中", "已报价", "协商中"}
|
||||
EXCEL_PDF = {".xlsx", ".xls", ".xlsm", ".pdf"}
|
||||
|
||||
|
||||
def ticket_view(ticket: object) -> dict[str, Any]:
|
||||
"""主账工单或内存票统一成 dict。"""
|
||||
if ticket is None:
|
||||
return {}
|
||||
if isinstance(ticket, dict):
|
||||
view = dict(ticket)
|
||||
view.setdefault("work_order_no", view.get("workOrderNo") or "")
|
||||
view.setdefault("sales_wecom_id", view.get("salesWecomId") or view.get("sales_wecom_id") or "")
|
||||
view.setdefault("salesWecomId", view.get("sales_wecom_id") or view.get("salesWecomId") or "")
|
||||
view.setdefault(
|
||||
"collab_member_ids",
|
||||
view.get("memberIds") or view.get("collabMemberIds") or view.get("collab_member_ids") or [],
|
||||
)
|
||||
view.setdefault(
|
||||
"product_ids",
|
||||
view.get("productIds") or view.get("product_ids") or [],
|
||||
)
|
||||
view.setdefault(
|
||||
"product_names",
|
||||
view.get("productNames") or view.get("product_names") or [],
|
||||
)
|
||||
view.setdefault("sales_name", view.get("salesName") or view.get("sales_name") or "")
|
||||
facts = dict(view.get("collab_facts") or view.get("collabFacts") or {})
|
||||
view.setdefault("collab_facts", facts)
|
||||
flag = str(facts.get("__ended") or view.get("collab_ended") or view.get("collabEnded") or "").strip().lower()
|
||||
if flag in {"1", "true", "yes"}:
|
||||
view["collab_ended"] = True
|
||||
return view
|
||||
facts = dict(getattr(ticket, "collab_facts", {}) or {})
|
||||
ended = bool(getattr(ticket, "collab_ended", False)) or str(
|
||||
facts.get("__ended") or ""
|
||||
).strip().lower() in {"1", "true", "yes"}
|
||||
return {
|
||||
"work_order_no": getattr(ticket, "work_order_no", "") or "",
|
||||
"status": getattr(ticket, "status", "") or "",
|
||||
"sales_wecom_id": getattr(ticket, "sales_wecom_id", "") or "",
|
||||
"salesWecomId": getattr(ticket, "sales_wecom_id", "") or "",
|
||||
"business_line": getattr(ticket, "business_line", "") or "SEA",
|
||||
"facts": dict(getattr(ticket, "facts", {}) or {}),
|
||||
"quote": dict(getattr(ticket, "quote", {}) or {}),
|
||||
"collab_facts": dict(getattr(ticket, "collab_facts", {}) or {}),
|
||||
"collab_chat_id": getattr(ticket, "collab_chat_id", "") or "",
|
||||
"collab_member_ids": list(getattr(ticket, "collab_member_ids", []) or []),
|
||||
"collab_ended": ended,
|
||||
"product_ids": list(getattr(ticket, "product_ids", []) or []),
|
||||
"product_names": list(getattr(ticket, "product_names", []) or []),
|
||||
"sales_name": str(getattr(ticket, "sales_name", "") or ""),
|
||||
}
|
||||
|
||||
|
||||
def ticket_status(ticket: object) -> str:
|
||||
return str(ticket_view(ticket).get("status") or "").strip()
|
||||
|
||||
|
||||
def ticket_no(ticket: object) -> str:
|
||||
return str(ticket_view(ticket).get("work_order_no") or "").strip()
|
||||
|
||||
|
||||
def is_terminal(ticket: object) -> bool:
|
||||
return ticket_status(ticket) in TERMINAL
|
||||
|
||||
|
||||
def is_sales(ticket: object, sender_id: str) -> bool:
|
||||
uid = (sender_id or "").strip()
|
||||
view = ticket_view(ticket)
|
||||
sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
|
||||
return bool(uid) and uid == sales
|
||||
|
||||
|
||||
def is_product(ticket: object, sender_id: str) -> bool:
|
||||
"""产品岗:成员里且不是销售。"""
|
||||
uid = (sender_id or "").strip()
|
||||
if not uid or is_sales(ticket, uid):
|
||||
return False
|
||||
view = ticket_view(ticket)
|
||||
members = {str(x) for x in (view.get("collab_member_ids") or view.get("memberIds") or [])}
|
||||
products = {str(x) for x in (view.get("product_ids") or [])}
|
||||
if products:
|
||||
return uid in products
|
||||
return uid in members
|
||||
|
||||
|
||||
def filename_is_excel_pdf(name: str) -> bool:
|
||||
raw = (name or "").strip().lower()
|
||||
return any(raw.endswith(ext) for ext in EXCEL_PDF)
|
||||
|
||||
|
||||
def group_client_of(flow: Any) -> Any:
|
||||
"""正式路径没有注入客户端时,走流程自己的默认企微客户端。"""
|
||||
getter = getattr(flow, "group_client", None)
|
||||
if callable(getter):
|
||||
return getter()
|
||||
return getattr(flow, "_group_client", None)
|
||||
|
||||
|
||||
def _archive_seat_ids(flow: Any) -> set[str]:
|
||||
"""会话存档账号(测服 AI001 / 询价机器人)不能出现在群 @ 里。"""
|
||||
seats: set[str] = {"AI001", "询价机器人"}
|
||||
getter = getattr(flow, "_archive_seat_userids", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
seats.update(str(x).strip() for x in (getter() or []) if str(x).strip())
|
||||
except Exception:
|
||||
logger.warning("读会话存档账号失败,按默认 AI001 排除")
|
||||
return {x for x in seats if x}
|
||||
|
||||
|
||||
def _staff_display_name(flow: Any, wecom_id: str) -> str:
|
||||
"""按企微 userid 取后台员工姓名;查不到就退回 userid,仍是具体人。"""
|
||||
uid = (wecom_id or "").strip()
|
||||
if not uid:
|
||||
return ""
|
||||
book = getattr(flow, "ledger", None) or getattr(flow, "_ledger", None)
|
||||
finder = getattr(book, "get_staff_by_wecom_id", None) if book is not None else None
|
||||
if callable(finder):
|
||||
try:
|
||||
row = finder(wecom_id=uid) or {}
|
||||
name = str(row.get("name") or "").strip()
|
||||
if name:
|
||||
return name
|
||||
except Exception:
|
||||
logger.warning("查群成员姓名失败 uid=%s", uid)
|
||||
return uid
|
||||
|
||||
|
||||
def resolve_group_mentions(
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
sess: Any = None,
|
||||
) -> tuple[str, list[str], list[str]]:
|
||||
"""
|
||||
群摘要必须 @ 到具体销售和每位产品,禁止只写岗位词「销售」。
|
||||
|
||||
进程重启后内存会话没了,主账 byCollabChat 也可能不带姓名:
|
||||
用销售 userid + 群成员(去掉存档账号)反查员工管理。
|
||||
返回:(销售展示名, 产品展示名列表, 要点到的 userid)。
|
||||
"""
|
||||
view = ticket_view(ticket)
|
||||
sales_id = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
|
||||
if sess is not None:
|
||||
sales_id = sales_id or str(getattr(sess, "sales_wecom_id", "") or getattr(sess, "sender_id", "") or "").strip()
|
||||
members = [
|
||||
str(x).strip()
|
||||
for x in (view.get("collab_member_ids") or view.get("memberIds") or [])
|
||||
if str(x).strip()
|
||||
]
|
||||
if sess is not None:
|
||||
for uid in getattr(sess, "invited_userids", ()) or ():
|
||||
raw = str(uid).strip()
|
||||
if raw and raw not in members:
|
||||
members.append(raw)
|
||||
seats = _archive_seat_ids(flow)
|
||||
product_ids = [
|
||||
str(x).strip()
|
||||
for x in (view.get("product_ids") or view.get("productIds") or [])
|
||||
if str(x).strip() and str(x).strip() not in seats
|
||||
]
|
||||
if not product_ids:
|
||||
product_ids = [m for m in members if m and m != sales_id and m not in seats]
|
||||
|
||||
sales_name = ""
|
||||
if sess is not None:
|
||||
sales_name = str(getattr(sess, "invited_sales_name", "") or "").strip()
|
||||
sales_name = sales_name or str(view.get("sales_name") or view.get("salesName") or "").strip()
|
||||
if not sales_name or sales_name == "销售":
|
||||
looked = _staff_display_name(flow, sales_id) if sales_id else ""
|
||||
if looked:
|
||||
sales_name = looked
|
||||
if not sales_name:
|
||||
sales_name = sales_id or "销售"
|
||||
|
||||
products: list[str] = []
|
||||
if sess is not None:
|
||||
products = [
|
||||
str(n).strip()
|
||||
for n in (getattr(sess, "invited_product_names", None) or [])
|
||||
if str(n or "").strip()
|
||||
]
|
||||
if not products:
|
||||
products = [
|
||||
str(n).strip()
|
||||
for n in (view.get("product_names") or view.get("productNames") or [])
|
||||
if str(n or "").strip()
|
||||
]
|
||||
if not products:
|
||||
products = [name for name in (_staff_display_name(flow, pid) for pid in product_ids) if name]
|
||||
|
||||
mention_ids: list[str] = []
|
||||
if sales_id and sales_id not in seats:
|
||||
mention_ids.append(sales_id)
|
||||
for pid in product_ids:
|
||||
if pid and pid not in mention_ids and pid not in seats:
|
||||
mention_ids.append(pid)
|
||||
return sales_name, products, mention_ids
|
||||
|
||||
|
||||
def send_group_text(
|
||||
client: Any,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
mention_userids: Optional[list[str]] = None,
|
||||
) -> None:
|
||||
"""群文本可带企微 userid,方便客户端真正点到人。旧客户端无此参数就只发正文。"""
|
||||
if not client or not chat_id or not content:
|
||||
return
|
||||
sender = getattr(client, "send_group", None)
|
||||
if not callable(sender):
|
||||
return
|
||||
mentions = [str(x).strip() for x in (mention_userids or []) if str(x).strip()]
|
||||
try:
|
||||
sender(chat_id=chat_id, content=content, mention_userids=mentions)
|
||||
except TypeError:
|
||||
sender(chat_id=chat_id, content=content)
|
||||
|
||||
|
||||
def hydrate_ticket(flow: Any, ticket: object) -> object:
|
||||
"""
|
||||
按群找回的工单若缺询价七项,用工单号+销售再读主账补全。
|
||||
|
||||
测服 byCollabChat 可能只回协同字段;续办 get 空 sender 也不回 facts。
|
||||
禁止用空 facts 把已确认港口冲成「-」。
|
||||
"""
|
||||
view = ticket_view(ticket)
|
||||
facts = dict(view.get("facts") or {})
|
||||
if any(str(facts.get(k) or "").strip() for k in ("起运港", "目的港", "品名", "货量")):
|
||||
return ticket
|
||||
no = ticket_no(ticket)
|
||||
sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
|
||||
book = getattr(flow, "ledger", None)
|
||||
if book is None or not no:
|
||||
return ticket
|
||||
full: Any = None
|
||||
getter = getattr(book, "get_for_agent", None)
|
||||
if callable(getter) and sales:
|
||||
full = getter(work_order_no=no, sender_id=sales)
|
||||
if not isinstance(full, dict) or not dict(full.get("facts") or {}):
|
||||
getter = getattr(book, "get_ticket", None)
|
||||
if callable(getter):
|
||||
full = getter(work_order_no=no)
|
||||
if full is None:
|
||||
return ticket
|
||||
merged = dict(view)
|
||||
full_view = ticket_view(full)
|
||||
if full_view.get("facts"):
|
||||
merged["facts"] = dict(full_view.get("facts") or {})
|
||||
if full_view.get("quote") and not merged.get("quote"):
|
||||
merged["quote"] = dict(full_view.get("quote") or {})
|
||||
if full_view.get("status"):
|
||||
merged.setdefault("status", full_view.get("status"))
|
||||
collab = dict(full_view.get("collab_facts") or {})
|
||||
collab.update(dict(view.get("collab_facts") or {}))
|
||||
merged["collab_facts"] = collab
|
||||
if view.get("collab_chat_id"):
|
||||
merged["collab_chat_id"] = view.get("collab_chat_id")
|
||||
return merged
|
||||
|
||||
|
||||
def send_brief_and_sheet(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
sess: Any = None,
|
||||
quote_override: Optional[dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
群里再发一版摘要 + 填好的海运模板。旧消息不撤。
|
||||
|
||||
无价也发表,询价和协同能填的先填。
|
||||
"""
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
view = ticket_view(ticket)
|
||||
no = ticket_no(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "").strip()
|
||||
client = group_client_of(flow)
|
||||
facts = dict(view.get("facts") or {})
|
||||
if sess is not None and getattr(sess, "facts", None):
|
||||
facts.update(dict(sess.facts or {}))
|
||||
collab = dict(view.get("collab_facts") or {})
|
||||
if sess is not None and getattr(sess, "collab_facts", None):
|
||||
collab.update(dict(sess.collab_facts or {}))
|
||||
quote = dict(quote_override or (sess.quote if sess else None) or view.get("quote") or {})
|
||||
sales_name, products, mention_ids = resolve_group_mentions(flow, ticket, sess)
|
||||
has_price = bool(quote.get("total") or quote.get("fee_rows"))
|
||||
brief = copy.sea_group_brief(
|
||||
work_order_no=no,
|
||||
facts=facts,
|
||||
quote=quote,
|
||||
collab_facts=collab,
|
||||
sales_name=sales_name,
|
||||
product_names=products,
|
||||
has_price=has_price,
|
||||
)
|
||||
send_group_text(client, chat_id, brief, mention_userids=mention_ids)
|
||||
send_filled_sheet(flow=flow, ticket=ticket, sess=sess, quote_override=quote_override)
|
||||
return "brief_and_sheet"
|
||||
|
||||
|
||||
def send_filled_sheet(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
sess: Any = None,
|
||||
quote_override: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""只发填好的模板文件,不再发文字摘要。"""
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
view = ticket_view(ticket) if ticket is not None else {}
|
||||
no = (sess.work_order_no if sess else "") or ticket_no(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "").strip()
|
||||
facts = dict(view.get("facts") or {})
|
||||
if sess is not None and getattr(sess, "facts", None):
|
||||
facts.update(dict(sess.facts or {}))
|
||||
collab = dict(view.get("collab_facts") or {})
|
||||
if sess is not None and getattr(sess, "collab_facts", None):
|
||||
collab.update(dict(sess.collab_facts or {}))
|
||||
quote = dict(quote_override or (sess.quote if sess else None) or view.get("quote") or {})
|
||||
_render_and_send_sheet(
|
||||
flow=flow,
|
||||
chat_id=chat_id,
|
||||
work_order_no=no,
|
||||
facts=_facts_for_fill(facts, collab),
|
||||
quote=quote,
|
||||
sales_id=str(
|
||||
(sess.sender_id if sess else "")
|
||||
or view.get("sales_wecom_id")
|
||||
or view.get("salesWecomId")
|
||||
or ""
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _collab_value(collab: dict[str, str], key: str) -> str:
|
||||
"""协同六项:内部键、对外展示名都能取到。"""
|
||||
src = dict(collab or {})
|
||||
val = str(src.get(key) or "").strip()
|
||||
if val:
|
||||
return val
|
||||
label = SEA_COLLAB_DISPLAY.get(key, "")
|
||||
if label:
|
||||
val = str(src.get(label) or "").strip()
|
||||
return val
|
||||
|
||||
|
||||
def _oil_elec_mag_text(collab: dict[str, str]) -> str:
|
||||
"""
|
||||
海运模板是一格「是否含油,电,磁」,三句话拼成一句再写入。
|
||||
没说的写成 -,不编造。
|
||||
"""
|
||||
oil = _collab_value(collab, "是否含油")
|
||||
elec = _collab_value(collab, "是否含电")
|
||||
mag = _collab_value(collab, "是否含磁")
|
||||
if not (oil or elec or mag):
|
||||
return ""
|
||||
return f"油:{oil or '-'};电:{elec or '-'};磁:{mag or '-'}"
|
||||
|
||||
|
||||
def _facts_for_fill(facts: dict[str, str], collab: dict[str, str]) -> dict[str, str]:
|
||||
"""
|
||||
询价七项 + 协同六项。中文键和填表英文码一起带上。
|
||||
主账 writeFactsByLabel 只认 cargo_ready_date / trade_terms,只传「货好时间」会空着。
|
||||
"""
|
||||
merged = dict(facts or {})
|
||||
src = dict(collab or {})
|
||||
for key in SEA_COLLAB_FIELDS:
|
||||
val = _collab_value(src, key)
|
||||
if not val:
|
||||
continue
|
||||
for alias in _COLLAB_FILL_ALIASES.get(key, (key,)):
|
||||
merged[alias] = val
|
||||
oem = _oil_elec_mag_text(src)
|
||||
if oem:
|
||||
merged["oil_elec_mag"] = oem
|
||||
merged["是否含油,电,磁"] = oem
|
||||
return merged
|
||||
|
||||
|
||||
def _render_and_send_sheet(
|
||||
*,
|
||||
flow: Any,
|
||||
chat_id: str,
|
||||
work_order_no: str,
|
||||
facts: dict[str, Any],
|
||||
quote: dict[str, Any],
|
||||
sales_id: str,
|
||||
) -> None:
|
||||
renderer = getattr(flow.ledger, "render_quote", None)
|
||||
client = group_client_of(flow)
|
||||
if not callable(renderer) or not chat_id:
|
||||
return
|
||||
result = renderer(
|
||||
work_order_no=work_order_no,
|
||||
business_line="SEA",
|
||||
facts=facts,
|
||||
quote=quote,
|
||||
format="xlsx",
|
||||
sales_wecom_id=sales_id,
|
||||
) or {}
|
||||
if not result.get("ok"):
|
||||
logger.warning("群填表失败 wo=%s err=%s", work_order_no, result.get("error"))
|
||||
return
|
||||
filename = str(result.get("fileName") or f"{work_order_no}_quote.xlsx")
|
||||
sender = getattr(client, "send_group_file", None)
|
||||
if not callable(sender):
|
||||
logger.warning("群发文件无客户端 wo=%s chat=%s", work_order_no, chat_id)
|
||||
return
|
||||
painted = overlay_labeled_facts(
|
||||
filepath=str(result.get("filePath") or ""),
|
||||
file_b64=str(result.get("fileBase64") or ""),
|
||||
facts=facts,
|
||||
)
|
||||
sent = sender(
|
||||
chat_id=chat_id,
|
||||
filename=filename,
|
||||
filepath=str(painted.get("filepath") or ""),
|
||||
file_b64=str(painted.get("file_b64") or ""),
|
||||
) or {}
|
||||
if sent.get("ok") is False:
|
||||
logger.warning("群发文件失败 wo=%s err=%s", work_order_no, sent.get("error"))
|
||||
send_group_text(client, chat_id, copy.group_sheet_send_fail())
|
||||
return
|
||||
logger.info("群已发模板 wo=%s file=%s", work_order_no, filename)
|
||||
|
||||
|
||||
def apply_collab_update(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
text: str,
|
||||
sender_id: str,
|
||||
sess: Any = None,
|
||||
) -> str:
|
||||
"""补协同六项。终态要说明原因。"""
|
||||
no = ticket_no(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or ticket_view(ticket).get("collab_chat_id") or "")
|
||||
client = group_client_of(flow)
|
||||
if is_terminal(ticket):
|
||||
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
|
||||
return "ticket_locked"
|
||||
fields = extract_collab_fields(text, allow_b=True)
|
||||
if not fields:
|
||||
send_group_text(client, chat_id, copy.group_fields_not_recognized())
|
||||
return "collab_not_recognized"
|
||||
flow.apply_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
|
||||
if sess is not None:
|
||||
merged = dict(sess.collab_facts)
|
||||
merged.update(fields)
|
||||
sess.collab_facts = merged
|
||||
flow._save(sess)
|
||||
ticket2 = _reload(flow, chat_id, no) or ticket
|
||||
view = ticket_view(ticket2)
|
||||
collab = dict(view.get("collab_facts") or {})
|
||||
collab.update(fields)
|
||||
view["collab_facts"] = collab
|
||||
if chat_id and not view.get("collab_chat_id"):
|
||||
view["collab_chat_id"] = chat_id
|
||||
send_brief_and_sheet(flow=flow, ticket=view, sess=sess)
|
||||
return "collab_field_saved"
|
||||
|
||||
|
||||
def apply_product_quote(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
text: str,
|
||||
sender_id: str,
|
||||
sess: Any = None,
|
||||
injected_quote: Optional[dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
产品文字报价:盖 TMS 价、填表发出、群里成交跟进。
|
||||
|
||||
一句话里的协同字段一并写入。非产品或终态要回话。
|
||||
"""
|
||||
no = ticket_no(ticket)
|
||||
view = ticket_view(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
|
||||
client = group_client_of(flow)
|
||||
if is_terminal(ticket):
|
||||
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
|
||||
return "ticket_locked"
|
||||
if not is_product(ticket, sender_id):
|
||||
return "group_ignore"
|
||||
fields = extract_collab_fields(text)
|
||||
if fields:
|
||||
flow.apply_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
|
||||
if sess is not None:
|
||||
merged = dict(sess.collab_facts)
|
||||
merged.update(fields)
|
||||
sess.collab_facts = merged
|
||||
quote = extract_product_quote(text, injected=injected_quote)
|
||||
if not quote:
|
||||
send_group_text(client, chat_id, copy.group_quote_not_recognized())
|
||||
return "quote_not_recognized"
|
||||
_save_product_quote(flow, no, quote, sess)
|
||||
ticket2 = _reload(flow, chat_id, no) or ticket
|
||||
send_brief_and_sheet(flow=flow, ticket=ticket2, sess=sess, quote_override=quote)
|
||||
emit_group_deal(flow=flow, ticket=ticket2, sess=sess, quote=quote)
|
||||
return "product_quote_sent"
|
||||
|
||||
|
||||
def handle_group_file(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
sender_id: str,
|
||||
filename: str,
|
||||
media: dict[str, Any],
|
||||
sess: Any = None,
|
||||
) -> str:
|
||||
"""
|
||||
群附件:只收 Excel/PDF。终态不存要提醒。
|
||||
产品 Excel/PDF 当报价并出成交跟进;别人的只存档。
|
||||
"""
|
||||
no = ticket_no(ticket)
|
||||
view = ticket_view(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
|
||||
client = group_client_of(flow)
|
||||
if is_terminal(ticket):
|
||||
send_group_text(client, chat_id, copy.ticket_locked_file(work_order_no=no, status=ticket_status(ticket)))
|
||||
return "ticket_locked_file"
|
||||
if not filename_is_excel_pdf(filename):
|
||||
send_group_text(client, chat_id, copy.group_only_excel_pdf())
|
||||
return "file_type_rejected"
|
||||
_archive_file(flow, no, filename, media, sender_id)
|
||||
if is_product(ticket, sender_id):
|
||||
quote = {
|
||||
"source_label": "产品附件报价",
|
||||
"file_name": filename,
|
||||
"total": str((view.get("quote") or {}).get("total") or "-"),
|
||||
}
|
||||
_save_product_quote(flow, no, quote, sess)
|
||||
emit_group_deal(flow=flow, ticket=ticket, sess=sess, quote=quote)
|
||||
return "product_file_quote"
|
||||
return "file_archived"
|
||||
|
||||
|
||||
def dissolve_group(*, flow: Any, ticket: object, sess: Any = None) -> str:
|
||||
"""解散应用群,这单协同结束,不改六态。"""
|
||||
view = ticket_view(ticket)
|
||||
no = ticket_no(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
|
||||
client = group_client_of(flow)
|
||||
members = list(view.get("collab_member_ids") or view.get("memberIds") or [])
|
||||
if sess is not None:
|
||||
members = list(sess.invited_userids or members)
|
||||
dismiss = getattr(client, "dismiss_group", None)
|
||||
if callable(dismiss) and chat_id:
|
||||
dismiss(chat_id=chat_id, userids=members)
|
||||
if chat_id:
|
||||
try:
|
||||
from agent.channel.wshoto.rooms import unwatch_collab_room
|
||||
|
||||
unwatch_collab_room(chat_id)
|
||||
except Exception:
|
||||
logger.exception("取消存档监听失败 chat=%s", chat_id)
|
||||
ender = getattr(flow.ledger, "end_collab_group", None)
|
||||
if callable(ender):
|
||||
ender(work_order_no=no)
|
||||
if sess is not None:
|
||||
sess.collab_ended = True
|
||||
flow._save(sess)
|
||||
send_group_text(client, chat_id, copy.group_dissolved(work_order_no=no))
|
||||
return "group_dissolved"
|
||||
|
||||
|
||||
def emit_group_deal(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
sess: Any = None,
|
||||
quote: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""群里只发一张成交跟进。版本 +1,旧卡点了要提示。"""
|
||||
view = ticket_view(ticket)
|
||||
no = ticket_no(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
|
||||
client = group_client_of(flow)
|
||||
q = dict(quote or (sess.quote if sess else None) or view.get("quote") or {})
|
||||
facts = dict((sess.facts if sess else None) or view.get("facts") or {})
|
||||
ver = 1
|
||||
if sess is not None:
|
||||
sess.deal_version = int(getattr(sess, "deal_version", 0) or 0) + 1
|
||||
ver = sess.deal_version
|
||||
sess.phase = "wait_deal"
|
||||
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||||
sess.status = "已报价"
|
||||
sess.quote = q
|
||||
sess.wait_version += 1
|
||||
flow._save(sess)
|
||||
sales_name, _products, mention_ids = resolve_group_mentions(flow, ticket, sess)
|
||||
text = f"@{sales_name}\n" + copy.deal_card(work_order_no=no, quote=q, facts=facts)
|
||||
# 成交跟进只点销售:产品不能点这张卡。
|
||||
sales_mention = mention_ids[:1] if mention_ids else []
|
||||
send_group_text(client, chat_id, text, mention_userids=sales_mention)
|
||||
carder = getattr(client, "send_group_card", None)
|
||||
payload = copy.deal_wecom_payload(work_order_no=no, quote=q, facts=facts, card_version=ver)
|
||||
if callable(carder):
|
||||
carder(chat_id=chat_id, template_card=payload.get("template_card") or {})
|
||||
|
||||
|
||||
def explain_deal_click(
|
||||
*,
|
||||
flow: Any,
|
||||
sender_id: str,
|
||||
action: str,
|
||||
card_meta: Optional[dict[str, Any]],
|
||||
sess: Any,
|
||||
) -> str:
|
||||
"""
|
||||
成交卡点错人 / 旧版 / 终态:群里说清楚,不当没反应。
|
||||
|
||||
返回空表示可以继续走成交。
|
||||
"""
|
||||
if action not in {copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE}:
|
||||
return ""
|
||||
if sess is None:
|
||||
return ""
|
||||
chat_id = (sess.collab_chat_id or "").strip()
|
||||
client = group_client_of(flow)
|
||||
no = sess.work_order_no
|
||||
if sess.status in TERMINAL:
|
||||
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=sess.status))
|
||||
return "ticket_locked"
|
||||
if sender_id != sess.sender_id:
|
||||
send_group_text(client, chat_id, copy.group_deal_sales_only())
|
||||
return "not_sales"
|
||||
clicked = copy.deal_card_version(card_meta)
|
||||
current = int(getattr(sess, "deal_version", 0) or 0)
|
||||
if current and clicked and clicked != current:
|
||||
send_group_text(client, chat_id, copy.group_deal_stale(work_order_no=no))
|
||||
return "stale_deal"
|
||||
return ""
|
||||
|
||||
|
||||
def _save_product_quote(flow: Any, no: str, quote: dict[str, Any], sess: Any) -> None:
|
||||
writer = getattr(flow.ledger, "upsert_product_quote", None) or getattr(
|
||||
flow.ledger, "upsert_quote", None
|
||||
)
|
||||
if callable(writer):
|
||||
writer(work_order_no=no, quote=quote, to_status="已报价")
|
||||
if sess is not None:
|
||||
sess.quote = dict(quote)
|
||||
sess.status = "已报价"
|
||||
flow._save(sess)
|
||||
|
||||
|
||||
def _archive_file(flow: Any, no: str, filename: str, media: dict[str, Any], sender_id: str) -> None:
|
||||
saver = getattr(flow.ledger, "attach_file", None)
|
||||
if callable(saver):
|
||||
saver(
|
||||
work_order_no=no,
|
||||
file_name=filename,
|
||||
object_key=str(media.get("media_id") or media.get("MediaId") or filename),
|
||||
content_type=str(media.get("content_type") or ""),
|
||||
sender_id=sender_id,
|
||||
)
|
||||
|
||||
|
||||
def _reload(flow: Any, chat_id: str, no: str) -> object:
|
||||
finder = getattr(flow.ledger, "find_by_collab_chat", None)
|
||||
if callable(finder) and chat_id:
|
||||
hit = finder(chat_id=chat_id)
|
||||
if hit is not None:
|
||||
return hit
|
||||
getter = getattr(flow.ledger, "get_ticket", None)
|
||||
if callable(getter) and no:
|
||||
return getter(work_order_no=no)
|
||||
return None
|
||||
@@ -44,6 +44,16 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
super().__init__(ledger=ledger)
|
||||
self._group_client = group_client
|
||||
|
||||
def group_client(self) -> Any:
|
||||
"""
|
||||
企微应用群客户端。测服正式不注入,现场再取默认 WeCom 客户端。
|
||||
|
||||
单测可注入 FakeGroupClient。取到后挂在流程上,后续补字段/发文件共用。
|
||||
"""
|
||||
if self._group_client is None:
|
||||
self._group_client = _default_group_client()
|
||||
return self._group_client
|
||||
|
||||
def on_text(
|
||||
self,
|
||||
*,
|
||||
@@ -374,6 +384,23 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
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)
|
||||
if action in {copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE}:
|
||||
from agent.policy.sea_group_ops import explain_deal_click
|
||||
|
||||
ticket_sess = sess
|
||||
if ticket_sess is None:
|
||||
wo = copy.work_order_from_card_meta(card_meta)
|
||||
ticket_sess = self.session_by_work_order(wo)
|
||||
if ticket_sess and (ticket_sess.collab_chat_id or "").strip():
|
||||
reason = explain_deal_click(
|
||||
flow=self,
|
||||
sender_id=sender_id,
|
||||
action=action,
|
||||
card_meta=card_meta,
|
||||
sess=ticket_sess,
|
||||
)
|
||||
if reason:
|
||||
return reason
|
||||
return super().on_button(
|
||||
sender_id=sender_id, action=action, reply=reply, card_meta=card_meta
|
||||
)
|
||||
@@ -390,10 +417,22 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
没人可拉 / 建群失败:说明原因,按钮保持可点。已有群:不新建,置灰。
|
||||
"""
|
||||
no = (sess.work_order_no or "").strip()
|
||||
if sess.collab_ended:
|
||||
reply(copy.group_cannot_recreate(work_order_no=no))
|
||||
return "collab_ended"
|
||||
getter = getattr(self._ledger, "get_ticket", None)
|
||||
ticket = getter(work_order_no=no) if callable(getter) else None
|
||||
if ticket is not None:
|
||||
facts = dict(getattr(ticket, "collab_facts", {}) or {})
|
||||
if bool(getattr(ticket, "collab_ended", False)) or str(
|
||||
facts.get("__ended") or ""
|
||||
).strip() in {"1", "true"}:
|
||||
sess.collab_ended = True
|
||||
self._save(sess)
|
||||
reply(copy.group_cannot_recreate(work_order_no=no))
|
||||
return "collab_ended"
|
||||
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
|
||||
if not existing and ticket is not None:
|
||||
existing = str(getattr(ticket, "collab_chat_id", "") or "")
|
||||
if existing:
|
||||
sess.collab_chat_id = existing
|
||||
@@ -402,6 +441,12 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
self._save(sess)
|
||||
self._grey_clicked_card(reply, card_meta)
|
||||
self._ensure_archive_seats_in_group(existing)
|
||||
try:
|
||||
from agent.channel.wshoto.rooms import watch_collab_room
|
||||
|
||||
watch_collab_room(existing, work_order_no=no)
|
||||
except Exception:
|
||||
logger.exception("登记已有存档监听群失败 chat=%s", existing)
|
||||
reply(copy.sea_group_exists(work_order_no=no))
|
||||
return "collab_group"
|
||||
|
||||
@@ -429,13 +474,19 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
if uid not in userids:
|
||||
userids.append(uid)
|
||||
|
||||
client = self._group_client or _default_group_client()
|
||||
client = self.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()
|
||||
try:
|
||||
from agent.channel.wshoto.rooms import watch_collab_room
|
||||
|
||||
watch_collab_room(chat_id, work_order_no=no)
|
||||
except Exception:
|
||||
logger.exception("登记存档监听群失败 chat=%s", chat_id)
|
||||
sales_name = self._sales_display_name(sess.sender_id)
|
||||
binder = getattr(self._ledger, "bind_collab_group", None)
|
||||
if callable(binder):
|
||||
@@ -444,6 +495,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
chat_id=chat_id,
|
||||
member_ids=userids,
|
||||
product_names=product_names,
|
||||
product_ids=product_ids,
|
||||
)
|
||||
|
||||
brief = copy.sea_group_brief(
|
||||
@@ -456,8 +508,10 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
has_price=bool(sess.quote and sess.status == "已报价"),
|
||||
)
|
||||
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)
|
||||
sess.invited_userids = tuple(userids)
|
||||
sess.invited_sales_name = sales_name
|
||||
sess.invited_product_names = tuple(product_names)
|
||||
@@ -525,7 +579,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
seats = self._archive_seat_userids()
|
||||
if not chat_id or not seats:
|
||||
return
|
||||
client = self._group_client or _default_group_client()
|
||||
client = self.group_client()
|
||||
adder = getattr(client, "add_group_members", None)
|
||||
if not callable(adder):
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user