1681 lines
60 KiB
Python
1681 lines
60 KiB
Python
"""
|
||
海运协同群业务:填表进群、补字段重发、产品报价、附件、解散、成交跟进。
|
||
|
||
本文件职责:群里副作用(主账+企微群)。私聊按钮仍走 sea_text_flow.on_button。
|
||
禁止:改询价十项;终态默默忽略;把销售附件当报价。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
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_display_name,
|
||
collab_fields_for_ticket,
|
||
)
|
||
|
||
# 主账填表按英文码取格子;中文键必须同时带上,否则货好时间/条款写不进表。
|
||
_COLLAB_FILL_ALIASES = {
|
||
"贸易条款": ("贸易条款", "trade_terms"),
|
||
"货好时间": ("货好时间", "cargo_ready_date"),
|
||
"HS编码": ("HS编码", "商品海关编码", "海关编码", "hs_code"),
|
||
"是否含油": ("是否含油", "has_oil"),
|
||
"是否含电": ("是否含电", "has_battery"),
|
||
"是否含磁": ("是否含磁", "has_magnet"),
|
||
"客户名称": ("客户名称", "customer_name"),
|
||
"货值": ("货值", "cargo_value"),
|
||
"是否为危险品": ("是否为危险品", "is_dangerous"),
|
||
"包装方式": ("包装方式", "包装类型"),
|
||
}
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
TERMINAL = {"已成交", "未成交", "已关闭", "转人工"}
|
||
CLOSED = {"已成交", "未成交", "已关闭"}
|
||
HANDOFF_STATUS = "转人工"
|
||
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 "")
|
||
view.setdefault("lost_reason", view.get("lostReason") or view.get("lost_reason") or "")
|
||
view.setdefault("tms_quote", view.get("tmsQuote") or view.get("tms_quote") 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 {}),
|
||
"tms_quote": dict(getattr(ticket, "tms_quote", None) 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 ""),
|
||
"lost_reason": str(getattr(ticket, "lost_reason", "") or ""),
|
||
"system_exception": str(getattr(ticket, "system_exception", "") or ""),
|
||
"systemException": str(getattr(ticket, "system_exception", "") or ""),
|
||
"system_exception_reason": str(getattr(ticket, "system_exception_reason", "") or ""),
|
||
"systemExceptionReason": str(getattr(ticket, "system_exception_reason", "") or ""),
|
||
"system_exception_step": str(getattr(ticket, "system_exception_step", "") or ""),
|
||
"system_exception_payload": dict(getattr(ticket, "system_exception_payload", {}) 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 _collab_keys_of(ticket: object, sess: Any = None) -> tuple[str, tuple[str, ...]]:
|
||
"""本单业务线 + 允许写入的协同内部键。"""
|
||
view = ticket_view(ticket)
|
||
facts = dict(view.get("facts") or {})
|
||
if sess is not None and getattr(sess, "facts", None):
|
||
facts.update(dict(sess.facts or {}))
|
||
line = str(
|
||
view.get("business_line")
|
||
or view.get("businessLine")
|
||
or (getattr(sess, "business_line", "") if sess is not None else "")
|
||
or "SEA"
|
||
).strip().upper() or "SEA"
|
||
keys = collab_fields_for_ticket(facts, line)
|
||
if not keys and line != "LAND":
|
||
keys = SEA_COLLAB_FIELDS
|
||
return line, keys
|
||
|
||
|
||
def is_terminal(ticket: object) -> bool:
|
||
return ticket_status(ticket) in TERMINAL
|
||
|
||
|
||
def is_handoff_ticket(ticket: object, sess: Any = None) -> bool:
|
||
"""这一票已经真正转人工:群里不再跟、也不回锁定话术。"""
|
||
if ticket_status(ticket) == HANDOFF_STATUS:
|
||
return True
|
||
if sess is None:
|
||
return False
|
||
if str(getattr(sess, "status", "") or "").strip() == HANDOFF_STATUS:
|
||
return True
|
||
return bool(getattr(sess, "handoff_committed", False))
|
||
|
||
|
||
def halt_closed_group(
|
||
*,
|
||
flow: Any,
|
||
ticket: object,
|
||
sess: Any = None,
|
||
chat_id: str = "",
|
||
file: bool = False,
|
||
) -> str:
|
||
"""
|
||
转人工:静默。已成交/未成交/已关闭:回不能改/不能传附件。
|
||
返回空表示可以继续跟。
|
||
"""
|
||
no = ticket_no(ticket)
|
||
room = (chat_id or "").strip() or str(
|
||
(sess.collab_chat_id if sess else "")
|
||
or ticket_view(ticket).get("collab_chat_id")
|
||
or ""
|
||
)
|
||
if is_handoff_ticket(ticket, sess):
|
||
logger.info("group.handoff_ignore wo=%s chat=%s", no or "-", room or "-")
|
||
return "handoff_ignore"
|
||
st = ticket_status(ticket) or (
|
||
str(getattr(sess, "status", "") or "").strip() if sess is not None else ""
|
||
)
|
||
if st in CLOSED or is_terminal(ticket) or (
|
||
sess is not None and str(getattr(sess, "status", "") or "").strip() in CLOSED
|
||
):
|
||
client = group_client_of(flow)
|
||
if file:
|
||
send_group_text(
|
||
client, room, copy.ticket_locked_file(work_order_no=no, status=st or "已关闭")
|
||
)
|
||
return "ticket_locked_file"
|
||
send_group_text(
|
||
client, room, copy.ticket_locked(work_order_no=no, status=st or "已关闭")
|
||
)
|
||
return "ticket_locked"
|
||
return ""
|
||
|
||
|
||
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, flow: Any = None) -> bool:
|
||
"""
|
||
产品岗:工单产品名单,或后台员工管理挂了对应业务线产品岗。
|
||
|
||
同一个人可以既是拉群销售又是产品。先当销售挡掉的话,自己报价群里会没反应。
|
||
海运认 role_code=sea,陆运认 land。
|
||
"""
|
||
uid = (sender_id or "").strip()
|
||
if not uid:
|
||
return False
|
||
view = ticket_view(ticket)
|
||
products = {str(x) for x in (view.get("product_ids") or view.get("productIds") or [])}
|
||
if uid in products:
|
||
return True
|
||
if _staff_is_line_product(flow, uid, ticket):
|
||
return True
|
||
if is_sales(ticket, uid):
|
||
return False
|
||
members = {str(x) for x in (view.get("collab_member_ids") or view.get("memberIds") or [])}
|
||
if products:
|
||
return uid in products
|
||
return uid in members
|
||
|
||
|
||
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):
|
||
return False
|
||
try:
|
||
row = finder(wecom_id=wecom_id) or {}
|
||
except Exception:
|
||
logger.exception("查产品岗失败 wecom=%s", wecom_id)
|
||
return False
|
||
role = str(row.get("roleCode") or row.get("role_code") or "").strip().lower()
|
||
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:
|
||
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,
|
||
fallback_sales_id: str = "",
|
||
) -> tuple[str, list[str], list[str]]:
|
||
"""
|
||
群摘要必须 @ 到具体销售和每位产品,禁止只写岗位词「销售」。
|
||
|
||
进程重启后内存会话没了,主账 byCollabChat / getForAgent 也可能不带姓名或企微号:
|
||
用销售 userid(没有则用发言人)+ 群成员(去掉存档账号)反查员工管理。
|
||
查不到姓名就用 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()
|
||
sales_id = sales_id or (fallback_sales_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 and looked != "销售":
|
||
sales_name = looked
|
||
if not sales_name or sales_name == "销售":
|
||
sales_name = sales_id
|
||
|
||
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 可能有港口却没有报价/状态;有字段就提前返回的话,
|
||
在TMS基础上改会丢掉原价,未成交也会被误判成还没报价。
|
||
"""
|
||
view = ticket_view(ticket)
|
||
facts = dict(view.get("facts") or {})
|
||
has_facts = any(
|
||
str(facts.get(k) or "").strip()
|
||
for k in (
|
||
"起运港",
|
||
"目的港",
|
||
"品名",
|
||
"货量",
|
||
"起运地",
|
||
"目的地",
|
||
"件数",
|
||
"毛重",
|
||
"运输类型",
|
||
"始发站",
|
||
"目的站",
|
||
)
|
||
)
|
||
quote = dict(view.get("quote") or {})
|
||
# total="-" 在 Python 里也是 truthy,不能当成已有总价;只有附加费明细也不算完整。
|
||
has_sale_total = _quote_has_usable_sale_total(quote)
|
||
has_fee_rows = bool(_quote_fee_rows(quote))
|
||
has_quote = has_sale_total or has_fee_rows
|
||
has_status = bool(ticket_status(ticket))
|
||
has_tms = bool(dict(view.get("tms_quote") or view.get("tmsQuote") or {}))
|
||
# 调价后 latest quote 仍有价,但不能因此跳过补 TMS 原价。
|
||
# 仅有 fee_rows、没有可用总价时仍要往下补全,否则空运出单预估费用会落成明细 SUM。
|
||
if has_facts and has_sale_total and has_status and has_tms:
|
||
return _soft_fill_quote_sale_fields(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 _soft_fill_quote_sale_fields(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 {}) or dict(full.get("quote") or {}) or full.get("status")
|
||
):
|
||
getter = getattr(book, "get_ticket", None)
|
||
if callable(getter):
|
||
full = getter(work_order_no=no)
|
||
if full is None:
|
||
return _soft_fill_quote_sale_fields(ticket)
|
||
merged = dict(view)
|
||
full_view = ticket_view(full)
|
||
if (not has_facts) and full_view.get("facts"):
|
||
merged["facts"] = dict(full_view.get("facts") or {})
|
||
if (not has_sale_total) and full_view.get("quote"):
|
||
# 薄报价保留已有明细,补上完整快照里的总价/线路。
|
||
rich = dict(full_view.get("quote") or {})
|
||
cur = dict(quote)
|
||
for key in (
|
||
"total",
|
||
"salePrice",
|
||
"totalSalePrice",
|
||
"airOptions",
|
||
"air_options",
|
||
"selected_air_option",
|
||
"selectedAirOption",
|
||
"chargeableWeightKg",
|
||
"chargeable_weight_kg",
|
||
"currency",
|
||
"valid_until",
|
||
):
|
||
if cur.get(key) in (None, "", "-", [], {}) and rich.get(key) not in (None, "", "-", [], {}):
|
||
cur[key] = rich[key]
|
||
if not has_fee_rows and (rich.get("fee_lines") or rich.get("fee_rows")):
|
||
cur["fee_lines"] = rich.get("fee_lines") or rich.get("fee_rows")
|
||
merged["quote"] = cur
|
||
if full_view.get("tms_quote") or full_view.get("tmsQuote"):
|
||
merged["tms_quote"] = dict(full_view.get("tms_quote") or full_view.get("tmsQuote") or {})
|
||
if (not has_status) and full_view.get("status"):
|
||
merged["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")
|
||
for key in (
|
||
"system_exception",
|
||
"systemException",
|
||
"system_exception_reason",
|
||
"systemExceptionReason",
|
||
"system_exception_step",
|
||
"system_exception_payload",
|
||
):
|
||
if full_view.get(key) and not merged.get(key):
|
||
merged[key] = full_view.get(key)
|
||
return _soft_fill_quote_sale_fields(merged)
|
||
|
||
|
||
def _quote_has_usable_sale_total(quote: dict[str, Any] | None) -> bool:
|
||
"""
|
||
是否已有可用于出单预估费用的总价。
|
||
total="-" 不算;仅有 fee_rows 也不算。
|
||
"""
|
||
src = dict(quote or {})
|
||
total = str(src.get("total") or src.get("total_amount") or src.get("salePrice") or "").strip()
|
||
if total and total != "-":
|
||
return True
|
||
selected = src.get("selected_air_option") or src.get("selectedAirOption")
|
||
if isinstance(selected, dict):
|
||
sale = str(selected.get("salePrice") or selected.get("sale_price") or "").strip()
|
||
if sale and sale != "-":
|
||
return True
|
||
opts = src.get("airOptions") or src.get("air_options") or []
|
||
if isinstance(opts, list) and opts and isinstance(opts[0], dict):
|
||
sale = str(opts[0].get("salePrice") or opts[0].get("sale_price") or "").strip()
|
||
if sale and sale != "-":
|
||
return True
|
||
return False
|
||
|
||
|
||
def _soft_fill_quote_sale_fields(ticket: object) -> object:
|
||
"""
|
||
从 tms_quote 补齐当前 quote 缺失的总销售价 / 线路对象。
|
||
群 findByCollabChat 常只有薄 latest quote,出单前必须能落到 salePrice。
|
||
"""
|
||
view = ticket_view(ticket)
|
||
cur = dict(view.get("quote") or {})
|
||
rich = dict(view.get("tms_quote") or view.get("tmsQuote") or {})
|
||
if not rich:
|
||
return ticket if isinstance(ticket, dict) else view
|
||
changed = False
|
||
for key in (
|
||
"total",
|
||
"salePrice",
|
||
"totalSalePrice",
|
||
"airOptions",
|
||
"air_options",
|
||
"selected_air_option",
|
||
"selectedAirOption",
|
||
"chargeableWeightKg",
|
||
"chargeable_weight_kg",
|
||
"currency",
|
||
"valid_until",
|
||
):
|
||
if cur.get(key) in (None, "", "-", [], {}) and rich.get(key) not in (None, "", "-", [], {}):
|
||
cur[key] = rich[key]
|
||
changed = True
|
||
if not changed:
|
||
return ticket if isinstance(ticket, dict) else view
|
||
if isinstance(ticket, dict):
|
||
out = dict(ticket)
|
||
out["quote"] = cur
|
||
return out
|
||
merged = dict(view)
|
||
merged["quote"] = cur
|
||
return merged
|
||
|
||
|
||
def send_brief_and_sheet(
|
||
*,
|
||
flow: Any,
|
||
ticket: object,
|
||
sess: Any = None,
|
||
quote_override: Optional[dict[str, Any]] = None,
|
||
tms_quote: Optional[dict[str, Any]] = None,
|
||
product_quote: Optional[dict[str, Any]] = None,
|
||
) -> str:
|
||
"""
|
||
群里再发一版摘要 + 填好的海运模板。旧消息不撤。
|
||
|
||
TMS 和人工报价分段写,无价也发表。
|
||
"""
|
||
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 {}))
|
||
view_q = dict(view.get("quote") or {})
|
||
sess_q = dict(sess.quote) if sess is not None and getattr(sess, "quote", None) else {}
|
||
override = dict(quote_override or {})
|
||
tms = dict(tms_quote) if tms_quote is not None else {}
|
||
manual = dict(product_quote or {})
|
||
if tms_quote is None:
|
||
tms = copy.resolve_tms_quote(sess, view)
|
||
if not manual:
|
||
if copy.quote_is_product(override):
|
||
manual = override
|
||
elif copy.quote_is_product(view_q):
|
||
manual = view_q
|
||
elif copy.quote_is_product(sess_q):
|
||
manual = sess_q
|
||
quote = dict(override or sess_q or view_q or tms or manual)
|
||
sales_name, products, mention_ids = resolve_group_mentions(flow, ticket, sess)
|
||
has_tms = bool(tms.get("total") or _quote_fee_rows(tms))
|
||
line = str(
|
||
view.get("business_line")
|
||
or view.get("businessLine")
|
||
or (sess.business_line if sess is not None else "")
|
||
or "SEA"
|
||
).strip().upper()
|
||
mode = "陆运" if line == "LAND" else "海运"
|
||
# 陆运要把当前生效价一并带上:选定投影和未选整包不是同一份,摘要里要能换成已选那条。
|
||
brief_quote = dict(tms or {})
|
||
if mode == "陆运":
|
||
current = dict(override or sess_q or view_q or {})
|
||
if current:
|
||
brief_quote = current
|
||
brief = copy.sea_group_brief(
|
||
work_order_no=no,
|
||
facts=facts,
|
||
quote=brief_quote or None,
|
||
collab_facts=collab,
|
||
sales_name=sales_name,
|
||
product_names=products,
|
||
has_price=has_tms,
|
||
tms_quote=tms,
|
||
product_quote=manual,
|
||
transport_mode=mode,
|
||
)
|
||
send_group_text(client, chat_id, brief, mention_userids=mention_ids)
|
||
send_filled_sheet(
|
||
flow=flow,
|
||
ticket=ticket,
|
||
sess=sess,
|
||
quote_override=quote,
|
||
business_line=line,
|
||
)
|
||
return "brief_and_sheet"
|
||
|
||
|
||
def _has_sheet_inquiry_facts(facts: dict[str, Any]) -> bool:
|
||
"""段票/填表票是否已带可匹配模板的询价字段。"""
|
||
src = dict(facts or {})
|
||
return any(
|
||
str(src.get(k) or "").strip()
|
||
for k in (
|
||
"起运港",
|
||
"目的港",
|
||
"品名",
|
||
"货量",
|
||
"起运地",
|
||
"目的地",
|
||
"件数",
|
||
"毛重",
|
||
"运输类型",
|
||
"线路类别",
|
||
"始发站",
|
||
"目的站",
|
||
)
|
||
)
|
||
|
||
|
||
def send_filled_sheet(
|
||
*,
|
||
flow: Any,
|
||
ticket: object,
|
||
sess: Any = None,
|
||
quote_override: Optional[dict[str, Any]] = None,
|
||
business_line: str = "",
|
||
chat_id: str = "",
|
||
) -> dict[str, Any]:
|
||
"""
|
||
只发填好的模板文件,不再发文字摘要。
|
||
|
||
business_line 决定匹配哪套后台模板;空运必须传 AIR,禁止落到海运默认模板。
|
||
"""
|
||
incoming_view = ticket_view(ticket) if ticket is not None else {}
|
||
incoming_facts = dict(incoming_view.get("facts") or {})
|
||
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)
|
||
room = str(
|
||
chat_id
|
||
or (sess.collab_chat_id if sess else "")
|
||
or view.get("collab_chat_id")
|
||
or ""
|
||
).strip()
|
||
facts = dict(view.get("facts") or {})
|
||
# 段票已带询价字段时,禁止被主账 MULTI 快照(往往只有 segments_json)盖掉。
|
||
if _has_sheet_inquiry_facts(incoming_facts):
|
||
merged = dict(facts)
|
||
merged.update({k: v for k, v in incoming_facts.items() if str(v or "").strip()})
|
||
facts = merged
|
||
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 {})
|
||
line = (business_line or str(view.get("business_line") or view.get("businessLine") or "SEA")).strip()
|
||
return _render_and_send_sheet(
|
||
flow=flow,
|
||
chat_id=room,
|
||
work_order_no=no,
|
||
facts=_facts_for_fill(facts, collab, business_line=line or "SEA"),
|
||
quote=quote,
|
||
sales_id=str(
|
||
(sess.sender_id if sess else "")
|
||
or view.get("sales_wecom_id")
|
||
or view.get("salesWecomId")
|
||
or ""
|
||
),
|
||
business_line=line or "SEA",
|
||
)
|
||
|
||
|
||
def _collab_value(collab: dict[str, str], key: str, business_line: str = "SEA") -> str:
|
||
"""协同项:内部键、对外展示名都能取到。"""
|
||
src = dict(collab or {})
|
||
val = str(src.get(key) or "").strip()
|
||
if val:
|
||
return val
|
||
label = collab_display_name(key, business_line)
|
||
if label:
|
||
val = str(src.get(label) or "").strip()
|
||
if val:
|
||
return val
|
||
sea_label = SEA_COLLAB_DISPLAY.get(key, "")
|
||
if sea_label:
|
||
val = str(src.get(sea_label) or "").strip()
|
||
return val
|
||
|
||
|
||
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],
|
||
business_line: str = "SEA",
|
||
) -> dict[str, str]:
|
||
"""
|
||
询价字段 + 本单协同项。中文键和填表英文码一起带上。
|
||
主账 writeFactsByLabel 只认 cargo_ready_date / trade_terms,只传「货好时间」会空着。
|
||
模板没有格子的新项照样带上,overlay 找不到标签就跳过。
|
||
"""
|
||
merged = dict(facts or {})
|
||
src = dict(collab or {})
|
||
line = (business_line or "SEA").strip().upper() or "SEA"
|
||
keys = collab_fields_for_ticket(facts, line) or SEA_COLLAB_FIELDS
|
||
for key in keys:
|
||
val = _collab_value(src, key, line)
|
||
if not val:
|
||
continue
|
||
for alias in _COLLAB_FILL_ALIASES.get(key, (key,)):
|
||
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,
|
||
business_line: str = "SEA",
|
||
) -> dict[str, Any]:
|
||
"""
|
||
按业务线匹配后台模板、填表、发群文件。
|
||
|
||
空运必须 business_line=AIR,否则会套上海运默认模板。
|
||
"""
|
||
renderer = getattr(flow.ledger, "render_quote", None)
|
||
client = group_client_of(flow)
|
||
if not callable(renderer) or not chat_id:
|
||
return {"ok": False, "error": "no_renderer_or_chat"}
|
||
line = (business_line or "SEA").strip() or "SEA"
|
||
result = renderer(
|
||
work_order_no=work_order_no,
|
||
business_line=line,
|
||
facts=facts,
|
||
quote=quote,
|
||
format="xlsx",
|
||
sales_wecom_id=sales_id,
|
||
) or {}
|
||
if not result.get("ok"):
|
||
logger.warning("群填表失败 wo=%s line=%s err=%s", work_order_no, line, result.get("error"))
|
||
return result
|
||
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 {"ok": False, "error": "no_file_client", "message": copy.group_sheet_send_fail()}
|
||
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 sent
|
||
logger.info("群已发模板 wo=%s line=%s file=%s", work_order_no, line, filename)
|
||
return {"ok": True, "fileName": filename, "templateId": result.get("templateId")}
|
||
|
||
|
||
def apply_collab_update(
|
||
*,
|
||
flow: Any,
|
||
ticket: object,
|
||
text: str,
|
||
sender_id: str,
|
||
sess: Any = None,
|
||
) -> str:
|
||
"""补本单协同项。终态要说明原因。不该有的项当没补到。"""
|
||
ticket = hydrate_ticket(flow, ticket)
|
||
no = ticket_no(ticket)
|
||
chat_id = str((sess.collab_chat_id if sess else "") or ticket_view(ticket).get("collab_chat_id") or "")
|
||
client = group_client_of(flow)
|
||
stopped = halt_closed_group(flow=flow, ticket=ticket, sess=sess, chat_id=chat_id)
|
||
if stopped:
|
||
return stopped
|
||
line, keys = _collab_keys_of(ticket, sess)
|
||
fields = extract_collab_fields(text, allow_b=True, allowed_keys=keys)
|
||
if not fields:
|
||
send_group_text(
|
||
client,
|
||
chat_id,
|
||
copy.group_fields_not_recognized(keys=keys or None, business_line=line),
|
||
)
|
||
return "collab_not_recognized"
|
||
flow.apply_collab_facts(
|
||
work_order_no=no,
|
||
facts=fields,
|
||
sender_id=sender_id,
|
||
allowed_keys=keys,
|
||
)
|
||
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 已有价时先问怎么用,不擅自清掉或盖上。
|
||
|
||
一句话里的协同字段一并写入。非产品或终态要回话。
|
||
"""
|
||
ticket = hydrate_ticket(flow, ticket)
|
||
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)
|
||
stopped = halt_closed_group(flow=flow, ticket=ticket, sess=sess, chat_id=chat_id)
|
||
if stopped:
|
||
return stopped
|
||
if not is_product(ticket, sender_id, flow=flow):
|
||
send_group_text(client, chat_id, copy.group_only_product_can_quote())
|
||
return "quote_not_product"
|
||
fields = extract_collab_fields(text, allowed_keys=_collab_keys_of(ticket, sess)[1])
|
||
if fields:
|
||
line, keys = _collab_keys_of(ticket, sess)
|
||
flow.apply_collab_facts(
|
||
work_order_no=no,
|
||
facts=fields,
|
||
sender_id=sender_id,
|
||
allowed_keys=keys,
|
||
)
|
||
if sess is not None:
|
||
merged = dict(sess.collab_facts)
|
||
merged.update(fields)
|
||
sess.collab_facts = merged
|
||
quote = extract_product_quote(text, injected=injected_quote)
|
||
if not quote:
|
||
logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200])
|
||
send_group_text(client, chat_id, copy.group_quote_not_recognized())
|
||
return "quote_not_recognized"
|
||
base = _current_quote(view, sess)
|
||
if _has_tms_quote(base):
|
||
_park_pending_quote(no, quote, sess, flow)
|
||
send_group_text(client, chat_id, copy.group_ask_tms_or_product())
|
||
return "wait_tms_choice"
|
||
if _quote_fee_rows(base):
|
||
quote = merge_quote_fees(base, quote)
|
||
_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,
|
||
tms_quote={},
|
||
product_quote=quote,
|
||
)
|
||
emit_group_deal(flow=flow, ticket=ticket2, sess=sess, quote=quote)
|
||
return "product_quote_sent"
|
||
|
||
|
||
def apply_tms_quote_choice(
|
||
*,
|
||
flow: Any,
|
||
ticket: object,
|
||
sender_id: str,
|
||
keep_tms: bool,
|
||
sess: Any = None,
|
||
) -> str:
|
||
"""
|
||
任一产品选:在 TMS 上改,或不用 TMS。
|
||
|
||
用刚才记下的那份价出 Excel,不再问第二遍价格。
|
||
"""
|
||
ticket = hydrate_ticket(flow, ticket)
|
||
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)
|
||
stopped = halt_closed_group(flow=flow, ticket=ticket, sess=sess, chat_id=chat_id)
|
||
if stopped:
|
||
return stopped
|
||
if not is_product(ticket, sender_id, flow=flow):
|
||
send_group_text(client, chat_id, copy.group_only_product_can_quote())
|
||
return "quote_not_product"
|
||
pending = _load_pending_quote(no, sess)
|
||
if not pending:
|
||
send_group_text(client, chat_id, copy.group_need_price_before_choice())
|
||
return "quote_choice_no_pending"
|
||
base = _current_quote(view, sess)
|
||
if keep_tms:
|
||
quote = merge_quote_fees(base, pending)
|
||
else:
|
||
quote = dict(pending)
|
||
quote.setdefault("source_label", "产品报价")
|
||
overlay = dict(pending)
|
||
_clear_pending_quote(no, sess, flow)
|
||
_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,
|
||
tms_quote=base if keep_tms else {},
|
||
product_quote=overlay if keep_tms else quote,
|
||
)
|
||
emit_group_deal(flow=flow, ticket=ticket2, sess=sess, quote=quote)
|
||
return "product_quote_sent"
|
||
|
||
|
||
def apply_confirm_tms_quote(
|
||
*,
|
||
flow: Any,
|
||
ticket: object,
|
||
sender_id: str,
|
||
sess: Any = None,
|
||
) -> str:
|
||
"""
|
||
产品确认沿用当前 TMS 价:模板入后台,不再问第二遍价格。
|
||
|
||
群里告诉大家附件已保存,再发成交跟进。
|
||
"""
|
||
ticket = hydrate_ticket(flow, ticket)
|
||
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)
|
||
stopped = halt_closed_group(flow=flow, ticket=ticket, sess=sess, chat_id=chat_id)
|
||
if stopped:
|
||
return stopped
|
||
if not is_product(ticket, sender_id, flow=flow):
|
||
send_group_text(client, chat_id, copy.group_only_product_can_quote())
|
||
return "quote_not_product"
|
||
quote = _current_quote(view, sess)
|
||
if not (quote.get("total") or _quote_fee_rows(quote)):
|
||
send_group_text(client, chat_id, copy.group_confirm_need_tms())
|
||
return "quote_confirm_no_tms"
|
||
_clear_pending_quote(no, sess, flow)
|
||
confirmed = dict(quote)
|
||
confirmed["source_label"] = "产品确认TMS"
|
||
_save_product_quote(flow, no, confirmed, sess)
|
||
_persist_quote_sheet(
|
||
flow=flow,
|
||
ticket=ticket,
|
||
sess=sess,
|
||
quote=confirmed,
|
||
sender_id=sender_id,
|
||
)
|
||
notify_quote_attachment_saved(flow, chat_id, no, count=1)
|
||
emit_group_deal(flow=flow, ticket=ticket, sess=sess, quote=confirmed)
|
||
return "quote_confirmed"
|
||
|
||
|
||
def notify_quote_attachment_saved(
|
||
flow: Any,
|
||
chat_id: str,
|
||
work_order_no: str,
|
||
count: int = 1,
|
||
) -> None:
|
||
"""群里告诉大家:模板或产品附件已进后台。"""
|
||
send_group_text(
|
||
group_client_of(flow),
|
||
chat_id,
|
||
copy.quote_attachment_saved_app(work_order_no, count),
|
||
)
|
||
|
||
|
||
def _persist_quote_sheet(
|
||
*,
|
||
flow: Any,
|
||
ticket: object,
|
||
sess: Any,
|
||
quote: dict[str, Any],
|
||
sender_id: str,
|
||
business_line: str = "",
|
||
) -> None:
|
||
"""把当前价填进匹配模板并写入主账附件,不重复往群里丢文件。"""
|
||
view = ticket_view(ticket)
|
||
no = ticket_no(ticket) or (sess.work_order_no if sess else "")
|
||
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 {}))
|
||
renderer = getattr(flow.ledger, "render_quote", None)
|
||
filename = f"{no}_quote.xlsx"
|
||
object_key = filename
|
||
line = str(
|
||
business_line
|
||
or view.get("business_line")
|
||
or view.get("businessLine")
|
||
or "SEA"
|
||
).strip() or "SEA"
|
||
if callable(renderer) and no:
|
||
result = renderer(
|
||
work_order_no=no,
|
||
business_line=line,
|
||
facts=_facts_for_fill(facts, collab, business_line=line or "SEA"),
|
||
quote=quote,
|
||
format="xlsx",
|
||
sales_wecom_id=str(
|
||
(sess.sender_id if sess else "")
|
||
or view.get("sales_wecom_id")
|
||
or view.get("salesWecomId")
|
||
or ""
|
||
),
|
||
) or {}
|
||
if result.get("ok"):
|
||
filename = str(result.get("fileName") or filename)
|
||
object_key = str(result.get("objectKey") or result.get("fileName") or filename)
|
||
_archive_file(flow, no, filename, {"media_id": object_key}, sender_id)
|
||
|
||
|
||
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)
|
||
stopped = halt_closed_group(
|
||
flow=flow, ticket=ticket, sess=sess, chat_id=chat_id, file=True
|
||
)
|
||
if stopped:
|
||
return stopped
|
||
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, flow=flow):
|
||
quote = {
|
||
"source_label": "产品附件报价",
|
||
"file_name": filename,
|
||
"total": str((view.get("quote") or {}).get("total") or "-"),
|
||
}
|
||
_save_product_quote(flow, no, quote, sess)
|
||
notify_quote_attachment_saved(flow, chat_id, no, count=1)
|
||
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 = copy.group_deal_followup(sales_name=sales_name, work_order_no=no)
|
||
send_group_text(client, chat_id, text)
|
||
carder = getattr(client, "send_group_card", None)
|
||
payload = copy.deal_wecom_payload(
|
||
work_order_no=no,
|
||
quote=q,
|
||
facts=facts,
|
||
card_version=ver,
|
||
business_line=str(
|
||
view.get("business_line")
|
||
or getattr(sess, "business_line", "")
|
||
or q.get("business_line")
|
||
or ""
|
||
),
|
||
)
|
||
if callable(carder):
|
||
carder(chat_id=chat_id, template_card=payload.get("template_card") or {})
|
||
from agent.policy.event_exception import WAIT_DEAL, start_wait
|
||
|
||
start_wait(getattr(flow, "ledger", None), work_order_no=no, wait_kind=WAIT_DEAL)
|
||
|
||
|
||
def apply_group_deal(
|
||
*,
|
||
flow: Any,
|
||
ticket: object,
|
||
sender_id: str,
|
||
outcome: str,
|
||
sess: Any = None,
|
||
text: str = "",
|
||
) -> str:
|
||
"""
|
||
群里文字确认成交/未成交/协商中。口令不写死,意图在 group_intent。
|
||
|
||
只有这单销售能确认。没有报价要说明,不能装没听见。
|
||
"""
|
||
ticket = hydrate_ticket(flow, ticket)
|
||
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)
|
||
stopped = halt_closed_group(flow=flow, ticket=ticket, sess=sess, chat_id=chat_id)
|
||
if stopped:
|
||
return stopped
|
||
if not is_sales(ticket, sender_id):
|
||
send_group_text(client, chat_id, copy.group_deal_sales_only())
|
||
return "not_sales"
|
||
quote = _current_quote(view, sess)
|
||
status_now = ticket_status(ticket) or (sess.status if sess else "")
|
||
if not (quote.get("total") or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
|
||
send_group_text(client, chat_id, copy.group_deal_need_quote())
|
||
return "deal_need_quote"
|
||
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
|
||
|
||
inline = harvest_inline_lost_reason(text) if outcome == "未成交" else ""
|
||
result = finish_deal_outcome(
|
||
ledger=flow.ledger,
|
||
work_order_no=no,
|
||
outcome=outcome,
|
||
facts=facts_of_group(view, sess),
|
||
quote=quote,
|
||
business_line=str(view.get("business_line") or (sess.business_line if sess else "SEA") or "SEA"),
|
||
sales_name=str(view.get("sales_name") or (getattr(sess, "invited_sales_name", "") if sess else "") or ""),
|
||
lost_reason=inline,
|
||
)
|
||
if not result.get("ok"):
|
||
send_group_text(client, chat_id, "主账状态更新失败,成交结果未入账。")
|
||
return "ledger_fail"
|
||
phase = str(result.get("phase") or "done")
|
||
if sess is not None:
|
||
sess.status = outcome
|
||
sess.phase = phase
|
||
if phase == "wait_deal":
|
||
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||
else:
|
||
sess.allowed = ()
|
||
flow._save(sess)
|
||
ack_reason = inline if phase == "done" and outcome == "未成交" else ""
|
||
send_group_text(
|
||
client,
|
||
chat_id,
|
||
copy.deal_ack(outcome, no, str(result.get("next_followup_at") or ""), lost_reason=ack_reason),
|
||
)
|
||
return phase
|
||
|
||
|
||
def facts_of_group(view: dict[str, Any], sess: Any = None) -> dict[str, str]:
|
||
"""主账字段打底,书签覆盖;给负责人匹配和成交通知卡用。"""
|
||
facts = dict(view.get("facts") or {})
|
||
if sess is not None and getattr(sess, "facts", None):
|
||
facts.update(dict(sess.facts or {}))
|
||
return facts
|
||
|
||
|
||
def capture_group_lost_reason(
|
||
*,
|
||
flow: Any,
|
||
ticket: object,
|
||
sender_id: str,
|
||
text: str,
|
||
sess: 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 "")
|
||
client = group_client_of(flow)
|
||
if ticket_status(ticket) != "未成交":
|
||
return ""
|
||
if str(view.get("lost_reason") or "").strip():
|
||
return ""
|
||
if not is_sales(ticket, sender_id):
|
||
return ""
|
||
from agent.policy.deal_outcome import capture_lost_reason
|
||
|
||
result = capture_lost_reason(ledger=flow.ledger, work_order_no=no, reason=text)
|
||
if not result.get("ok"):
|
||
send_group_text(client, chat_id, "未成交原因没有记下,请再发一句。")
|
||
return "wait_lost_reason"
|
||
if sess is not None:
|
||
sess.phase = "done"
|
||
sess.allowed = ()
|
||
flow._save(sess)
|
||
send_group_text(client, chat_id, copy.lost_reason_ack(str(result.get("lost_reason") or text)))
|
||
return "done"
|
||
|
||
|
||
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
|
||
stopped = halt_closed_group(
|
||
flow=flow,
|
||
ticket={"work_order_no": no, "status": sess.status, "collab_chat_id": chat_id},
|
||
sess=sess,
|
||
chat_id=chat_id,
|
||
)
|
||
if stopped:
|
||
return stopped
|
||
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 _current_quote(view: dict[str, Any], sess: Any) -> dict[str, Any]:
|
||
"""主账报价优先;书签只在主账还没费用行时兜底。"""
|
||
base = dict(view.get("quote") or {})
|
||
if not _quote_fee_rows(base) and sess is not None and getattr(sess, "quote", None):
|
||
return dict(sess.quote)
|
||
return base
|
||
|
||
|
||
def _has_tms_quote(quote: dict[str, Any] | None) -> bool:
|
||
"""当前价来自 TMS 才要问;产品价、销售调价不再替用户选。"""
|
||
src = dict(quote or {})
|
||
if not (src.get("total") or _quote_fee_rows(src)):
|
||
return False
|
||
return copy.quote_is_tms(src)
|
||
|
||
|
||
def _park_pending_quote(no: str, quote: dict[str, Any], sess: Any, flow: Any) -> None:
|
||
"""先记下产品刚报的价,不写主账。"""
|
||
if sess is not None:
|
||
sess.pending_product_quote = dict(quote)
|
||
if hasattr(flow, "_save"):
|
||
flow._save(sess)
|
||
try:
|
||
from agent.redis_coord.pending_quote import save_pending_product_quote
|
||
|
||
save_pending_product_quote(no, quote)
|
||
except Exception:
|
||
logger.exception("待确认报价写入失败 wo=%s", no)
|
||
|
||
|
||
def peek_pending_product_quote(no: str, sess: Any = None) -> dict[str, Any]:
|
||
"""群里还在等选 TMS 时,给入口判断要不要回话。"""
|
||
return _load_pending_quote(no, sess)
|
||
|
||
|
||
def _load_pending_quote(no: str, sess: Any) -> dict[str, Any]:
|
||
if sess is not None and isinstance(getattr(sess, "pending_product_quote", None), dict):
|
||
parked = dict(sess.pending_product_quote or {})
|
||
if parked:
|
||
return parked
|
||
try:
|
||
from agent.redis_coord.pending_quote import load_pending_product_quote
|
||
|
||
return load_pending_product_quote(no)
|
||
except Exception:
|
||
logger.exception("待确认报价读取失败 wo=%s", no)
|
||
return {}
|
||
|
||
|
||
def _clear_pending_quote(no: str, sess: Any, flow: Any) -> None:
|
||
if sess is not None:
|
||
sess.pending_product_quote = {}
|
||
if hasattr(flow, "_save"):
|
||
flow._save(sess)
|
||
try:
|
||
from agent.redis_coord.pending_quote import clear_pending_product_quote
|
||
|
||
clear_pending_product_quote(no)
|
||
except Exception:
|
||
logger.exception("待确认报价清理失败 wo=%s", no)
|
||
|
||
|
||
def _fee_name_key(name: str) -> str:
|
||
"""费用名对齐:去空格和括号,斜杠后的英文不参与比较。"""
|
||
raw = (name or "").replace(" ", "").replace("(", "(").replace(")", ")")
|
||
for sep in ("/", "/"):
|
||
if sep in raw:
|
||
raw = raw.split(sep, 1)[0]
|
||
return raw.strip()
|
||
|
||
|
||
def _fee_name_same(left: str, right: str) -> bool:
|
||
"""
|
||
和主账填表同一口径:海运费=海运费用;操作费 ≠ 码头操作费。
|
||
|
||
模板里码头操作费是单独一行,不能用「包含」对上操作费。
|
||
"""
|
||
a = _fee_name_key(left)
|
||
b = _fee_name_key(right)
|
||
if not a or not b:
|
||
return False
|
||
if a == b:
|
||
return True
|
||
if a == b + "用" or a == b + "费用" or b == a + "用" or b == a + "费用":
|
||
return True
|
||
if a + "费" == b or b + "费" == a:
|
||
return True
|
||
# 文件发 ≈ 文件费:末字口误,前面相同。
|
||
if len(a) >= 3 and len(a) == len(b) and a[:-1] == b[:-1] and (a.endswith("费") or b.endswith("费")):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _quote_fee_rows(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||
"""
|
||
抽出可盖价的费用行。TMS 常把 fee_lines 存成 JSON 字符串,不当空明细。
|
||
"""
|
||
src = dict(quote or {})
|
||
raw = src.get("fee_rows") or src.get("fee_lines") or src.get("feeItems") or []
|
||
if isinstance(raw, str) and raw.strip():
|
||
try:
|
||
raw = json.loads(raw)
|
||
except json.JSONDecodeError:
|
||
raw = []
|
||
if not isinstance(raw, list):
|
||
return []
|
||
rows: list[dict[str, Any]] = []
|
||
for item in raw:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
name = str(
|
||
item.get("name")
|
||
or item.get("label")
|
||
or item.get("feeName")
|
||
or item.get("chargeName")
|
||
or ""
|
||
).strip()
|
||
amount = str(
|
||
item.get("amount")
|
||
or item.get("value")
|
||
or item.get("salePrice")
|
||
or item.get("price")
|
||
or ""
|
||
).strip()
|
||
if name and amount:
|
||
row = dict(item)
|
||
row["name"] = name
|
||
row["amount"] = amount
|
||
rows.append(row)
|
||
return rows
|
||
|
||
|
||
def _match_fee_row(name: str, rows: list[dict[str, Any]]) -> int:
|
||
"""找到同名费用行。对不上返回 -1,不按第一行瞎盖。"""
|
||
for idx, row in enumerate(rows):
|
||
if _fee_name_same(name, str(row.get("name") or "")):
|
||
return idx
|
||
return -1
|
||
|
||
|
||
def _apply_fee_amount(row: dict[str, Any], amount: str) -> None:
|
||
"""
|
||
产品改的是这一行卖价。金额、单价、salePrice 一起覆盖,避免报价单单价列还留着 TMS 旧价。
|
||
"""
|
||
row["amount"] = amount
|
||
row["unit_price"] = amount
|
||
if row.get("salePrice") not in (None, ""):
|
||
row["salePrice"] = amount
|
||
if row.get("price") not in (None, ""):
|
||
row["price"] = amount
|
||
if row.get("value") not in (None, ""):
|
||
row["value"] = amount
|
||
|
||
|
||
def _parse_fee_amount(raw: Any) -> float | None:
|
||
"""抽出金额数字。带 USD/CNY 前缀或千分位也能读,读不了当没有。"""
|
||
text = str(raw or "").strip().replace(",", "")
|
||
if not text or text == "-":
|
||
return None
|
||
upper = text.upper()
|
||
for prefix in ("USD", "CNY", "EUR", "HKD", "JPY", "GBP", "RMB"):
|
||
if upper.startswith(prefix):
|
||
text = text[len(prefix) :].strip()
|
||
break
|
||
try:
|
||
return float(text)
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _row_currency(row: dict[str, Any] | None, fallback: str = "") -> str:
|
||
src = dict(row or {})
|
||
return str(src.get("currency") or src.get("currencyCode") or fallback or "").strip().upper()
|
||
|
||
|
||
def _format_fee_total(value: float, sample: str = "") -> str:
|
||
"""合计写法跟原 TMS 对齐:原来有小数就留两位。"""
|
||
if "." in str(sample or ""):
|
||
return f"{value:.2f}"
|
||
if abs(value - round(value)) < 1e-9:
|
||
return str(int(round(value)))
|
||
return f"{value:.2f}"
|
||
|
||
|
||
def _merged_total(
|
||
base: dict[str, Any] | None,
|
||
rows: list[dict[str, Any]],
|
||
incoming: dict[str, Any],
|
||
) -> tuple[str, str]:
|
||
"""
|
||
总销售价:有 TMS 原合计就按改价差额加减,不能只加产品改的那几行。
|
||
|
||
美元行已含在 TMS 人民币合计里,不擅自换汇;只调整和合计同币种的差额。
|
||
"""
|
||
src = dict(base or {})
|
||
header_ccy = str(src.get("currency") or incoming.get("currency") or "").strip()
|
||
base_total = _parse_fee_amount(src.get("total"))
|
||
if base_total is not None:
|
||
total_ccy = (header_ccy or "CNY").upper()
|
||
if total_ccy == "RMB":
|
||
total_ccy = "CNY"
|
||
old_rows = _quote_fee_rows(src)
|
||
delta = 0.0
|
||
for new_row in rows:
|
||
new_amt = _parse_fee_amount(new_row.get("amount"))
|
||
if new_amt is None:
|
||
continue
|
||
line_ccy = _row_currency(new_row, total_ccy)
|
||
if line_ccy == "RMB":
|
||
line_ccy = "CNY"
|
||
if line_ccy and line_ccy != total_ccy:
|
||
continue
|
||
idx = _match_fee_row(str(new_row.get("name") or ""), old_rows)
|
||
if idx < 0:
|
||
delta += new_amt
|
||
continue
|
||
old_amt = _parse_fee_amount(old_rows[idx].get("amount"))
|
||
if old_amt is None:
|
||
continue
|
||
delta += new_amt - old_amt
|
||
return _format_fee_total(base_total + delta, str(src.get("total") or "")), header_ccy
|
||
|
||
amounts: list[float] = []
|
||
ccys: set[str] = set()
|
||
for row in rows:
|
||
amt = _parse_fee_amount(row.get("amount"))
|
||
if amt is None:
|
||
continue
|
||
amounts.append(amt)
|
||
code = _row_currency(row, header_ccy)
|
||
if code:
|
||
ccys.add("CNY" if code == "RMB" else code)
|
||
if amounts and len(ccys) <= 1:
|
||
ccy = next(iter(ccys)) if ccys else header_ccy
|
||
return _format_fee_total(sum(amounts)), ccy
|
||
return str(src.get("total") or ""), header_ccy
|
||
|
||
|
||
def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None) -> dict[str, Any]:
|
||
"""
|
||
产品按行改价盖到原 TMS 明细上。没点名的费用原样保留。
|
||
|
||
单价和金额一起改:模板先写 unit_price,只改 amount 的话报价单上看着还是旧价。
|
||
合计按原 TMS 总价加减改过的行,不用产品两行之和冒充全单。
|
||
不编时效和有效期。
|
||
"""
|
||
incoming = dict(overlay or {})
|
||
rows = _quote_fee_rows(base)
|
||
for item in incoming.get("fee_rows") or incoming.get("fee_lines") or []:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
name = str(item.get("name") or "").strip()
|
||
amount = str(item.get("amount") or "").strip()
|
||
if not name or not amount:
|
||
continue
|
||
idx = _match_fee_row(name, rows)
|
||
if idx < 0:
|
||
rows.append(dict(item))
|
||
continue
|
||
old = dict(rows[idx])
|
||
_apply_fee_amount(old, amount)
|
||
if item.get("currency"):
|
||
old["currency"] = item.get("currency")
|
||
rows[idx] = old
|
||
out = dict(base or {})
|
||
for key, val in incoming.items():
|
||
if key in {"fee_rows", "fee_lines", "total"}:
|
||
continue
|
||
out[key] = val
|
||
out["fee_rows"] = rows
|
||
out["fee_lines"] = rows
|
||
out["source_label"] = incoming.get("source_label") or "产品报价"
|
||
total, currency = _merged_total(base, rows, incoming)
|
||
if total:
|
||
out["total"] = total
|
||
if currency:
|
||
out["currency"] = currency
|
||
return out
|
||
|
||
|
||
def _save_product_quote(
|
||
flow: Any,
|
||
no: str,
|
||
quote: dict[str, Any],
|
||
sess: Any,
|
||
*,
|
||
remark: str = "",
|
||
kind: str = "",
|
||
) -> None:
|
||
writer = getattr(flow.ledger, "upsert_product_quote", None) or getattr(
|
||
flow.ledger, "upsert_quote", None
|
||
)
|
||
persist = dict(quote or {})
|
||
if (kind or "").strip() == "adjust":
|
||
persist["source"] = "SALES_ADJUST"
|
||
persist["source_label"] = "销售调价"
|
||
quote["source"] = persist["source"]
|
||
quote["source_label"] = persist["source_label"]
|
||
else:
|
||
persist.setdefault("source", "PRODUCT")
|
||
label = str(persist.get("source_label") or "")
|
||
if (not label) or ("TMS" in label.upper()):
|
||
persist["source_label"] = "手工报价"
|
||
if callable(writer):
|
||
extras: dict[str, Any] = {}
|
||
if remark:
|
||
extras["remark"] = remark
|
||
if kind:
|
||
extras["kind"] = kind
|
||
try:
|
||
saved = writer(work_order_no=no, quote=persist, to_status="已报价", **extras) or {}
|
||
except TypeError:
|
||
saved = writer(work_order_no=no, quote=persist, to_status="已报价") or {}
|
||
if not saved.get("ok") and saved.get("error"):
|
||
logger.warning("产品报价未入主账 wo=%s err=%s", no, saved.get("error"))
|
||
elif (kind or "").strip() != "adjust":
|
||
from agent.policy.event_exception import clear_wait
|
||
|
||
clear_wait(flow.ledger, work_order_no=no)
|
||
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
|