Files
inquiry_robot/inquiry-agent/agent/policy/sea_group_ops.py
T

1681 lines
60 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
海运协同群业务:填表进群、补字段重发、产品报价、附件、解散、成交跟进。
本文件职责:群里副作用(主账+企微群)。私聊按钮仍走 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