""" 海运协同群业务:填表进群、补字段重发、产品报价、附件、解散、成交跟进。 本文件职责:群里副作用(主账+企微群)。私聊按钮仍走 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 {}) has_quote = bool(quote.get("total") or _quote_fee_rows(quote)) has_status = bool(ticket_status(ticket)) has_tms = bool(dict(view.get("tms_quote") or view.get("tmsQuote") or {})) # 调价后 latest quote 仍有价,但不能因此跳过补 TMS 原价。 if has_facts and has_quote and has_status and has_tms: return ticket no = ticket_no(ticket) sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip() book = getattr(flow, "ledger", None) if book is None or not no: return ticket full: Any = None getter = getattr(book, "get_for_agent", None) if callable(getter) and sales: full = getter(work_order_no=no, sender_id=sales) if not isinstance(full, dict) or not ( dict(full.get("facts") or {}) 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 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_quote) and full_view.get("quote"): merged["quote"] = dict(full_view.get("quote") or {}) 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 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