""" 海运协同群业务:填表进群、补字段重发、产品报价、附件、解散、成交跟进。 本文件职责:群里副作用(主账+企微群)。私聊按钮仍走 sea_text_flow.on_button。 禁止:改询价七项;终态默默忽略;把销售附件当报价。 """ from __future__ import annotations import logging from typing import Any, Optional from agent.llm.extract_text import extract_collab_fields, extract_product_quote from agent.policy.quote_overlay import overlay_labeled_facts from agent.policy import inquiry_copy as copy from agent.schema.field_validate import SEA_COLLAB_DISPLAY, SEA_COLLAB_FIELDS # 主账填表按英文码取格子;中文键必须同时带上,否则货好时间/条款写不进表。 _COLLAB_FILL_ALIASES = { "贸易条款": ("贸易条款", "trade_terms"), "货好时间": ("货好时间", "cargo_ready_date"), "HS编码": ("HS编码", "商品海关编码", "海关编码", "hs_code"), "是否含油": ("是否含油", "has_oil"), "是否含电": ("是否含电", "has_battery"), "是否含磁": ("是否含磁", "has_magnet"), } logger = logging.getLogger(__name__) TERMINAL = {"已成交", "未成交", "已关闭"} OPEN_EDIT = {"询价中", "已报价", "协商中"} EXCEL_PDF = {".xlsx", ".xls", ".xlsm", ".pdf"} def ticket_view(ticket: object) -> dict[str, Any]: """主账工单或内存票统一成 dict。""" if ticket is None: return {} if isinstance(ticket, dict): view = dict(ticket) view.setdefault("work_order_no", view.get("workOrderNo") or "") view.setdefault("sales_wecom_id", view.get("salesWecomId") or view.get("sales_wecom_id") or "") view.setdefault("salesWecomId", view.get("sales_wecom_id") or view.get("salesWecomId") or "") view.setdefault( "collab_member_ids", view.get("memberIds") or view.get("collabMemberIds") or view.get("collab_member_ids") or [], ) view.setdefault( "product_ids", view.get("productIds") or view.get("product_ids") or [], ) view.setdefault( "product_names", view.get("productNames") or view.get("product_names") or [], ) view.setdefault("sales_name", view.get("salesName") or view.get("sales_name") or "") facts = dict(view.get("collab_facts") or view.get("collabFacts") or {}) view.setdefault("collab_facts", facts) flag = str(facts.get("__ended") or view.get("collab_ended") or view.get("collabEnded") or "").strip().lower() if flag in {"1", "true", "yes"}: view["collab_ended"] = True return view facts = dict(getattr(ticket, "collab_facts", {}) or {}) ended = bool(getattr(ticket, "collab_ended", False)) or str( facts.get("__ended") or "" ).strip().lower() in {"1", "true", "yes"} return { "work_order_no": getattr(ticket, "work_order_no", "") or "", "status": getattr(ticket, "status", "") or "", "sales_wecom_id": getattr(ticket, "sales_wecom_id", "") or "", "salesWecomId": getattr(ticket, "sales_wecom_id", "") or "", "business_line": getattr(ticket, "business_line", "") or "SEA", "facts": dict(getattr(ticket, "facts", {}) or {}), "quote": dict(getattr(ticket, "quote", {}) or {}), "collab_facts": dict(getattr(ticket, "collab_facts", {}) or {}), "collab_chat_id": getattr(ticket, "collab_chat_id", "") or "", "collab_member_ids": list(getattr(ticket, "collab_member_ids", []) or []), "collab_ended": ended, "product_ids": list(getattr(ticket, "product_ids", []) or []), "product_names": list(getattr(ticket, "product_names", []) or []), "sales_name": str(getattr(ticket, "sales_name", "") or ""), } def ticket_status(ticket: object) -> str: return str(ticket_view(ticket).get("status") or "").strip() def ticket_no(ticket: object) -> str: return str(ticket_view(ticket).get("work_order_no") or "").strip() def is_terminal(ticket: object) -> bool: return ticket_status(ticket) in TERMINAL def is_sales(ticket: object, sender_id: str) -> bool: uid = (sender_id or "").strip() view = ticket_view(ticket) sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip() return bool(uid) and uid == sales def is_product(ticket: object, sender_id: str) -> bool: """产品岗:成员里且不是销售。""" uid = (sender_id or "").strip() if not uid or is_sales(ticket, uid): return False view = ticket_view(ticket) members = {str(x) for x in (view.get("collab_member_ids") or view.get("memberIds") or [])} products = {str(x) for x in (view.get("product_ids") or [])} if products: return uid in products return uid in members def filename_is_excel_pdf(name: str) -> bool: raw = (name or "").strip().lower() return any(raw.endswith(ext) for ext in EXCEL_PDF) def group_client_of(flow: Any) -> Any: """正式路径没有注入客户端时,走流程自己的默认企微客户端。""" getter = getattr(flow, "group_client", None) if callable(getter): return getter() return getattr(flow, "_group_client", None) def _archive_seat_ids(flow: Any) -> set[str]: """会话存档账号(测服 AI001 / 询价机器人)不能出现在群 @ 里。""" seats: set[str] = {"AI001", "询价机器人"} getter = getattr(flow, "_archive_seat_userids", None) if callable(getter): try: seats.update(str(x).strip() for x in (getter() or []) if str(x).strip()) except Exception: logger.warning("读会话存档账号失败,按默认 AI001 排除") return {x for x in seats if x} def _staff_display_name(flow: Any, wecom_id: str) -> str: """按企微 userid 取后台员工姓名;查不到就退回 userid,仍是具体人。""" uid = (wecom_id or "").strip() if not uid: return "" book = getattr(flow, "ledger", None) or getattr(flow, "_ledger", None) finder = getattr(book, "get_staff_by_wecom_id", None) if book is not None else None if callable(finder): try: row = finder(wecom_id=uid) or {} name = str(row.get("name") or "").strip() if name: return name except Exception: logger.warning("查群成员姓名失败 uid=%s", uid) return uid def resolve_group_mentions( flow: Any, ticket: object, sess: Any = None, ) -> tuple[str, list[str], list[str]]: """ 群摘要必须 @ 到具体销售和每位产品,禁止只写岗位词「销售」。 进程重启后内存会话没了,主账 byCollabChat 也可能不带姓名: 用销售 userid + 群成员(去掉存档账号)反查员工管理。 返回:(销售展示名, 产品展示名列表, 要点到的 userid)。 """ view = ticket_view(ticket) sales_id = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip() if sess is not None: sales_id = sales_id or str(getattr(sess, "sales_wecom_id", "") or getattr(sess, "sender_id", "") or "").strip() members = [ str(x).strip() for x in (view.get("collab_member_ids") or view.get("memberIds") or []) if str(x).strip() ] if sess is not None: for uid in getattr(sess, "invited_userids", ()) or (): raw = str(uid).strip() if raw and raw not in members: members.append(raw) seats = _archive_seat_ids(flow) product_ids = [ str(x).strip() for x in (view.get("product_ids") or view.get("productIds") or []) if str(x).strip() and str(x).strip() not in seats ] if not product_ids: product_ids = [m for m in members if m and m != sales_id and m not in seats] sales_name = "" if sess is not None: sales_name = str(getattr(sess, "invited_sales_name", "") or "").strip() sales_name = sales_name or str(view.get("sales_name") or view.get("salesName") or "").strip() if not sales_name or sales_name == "销售": looked = _staff_display_name(flow, sales_id) if sales_id else "" if looked: sales_name = looked if not sales_name: sales_name = sales_id or "销售" products: list[str] = [] if sess is not None: products = [ str(n).strip() for n in (getattr(sess, "invited_product_names", None) or []) if str(n or "").strip() ] if not products: products = [ str(n).strip() for n in (view.get("product_names") or view.get("productNames") or []) if str(n or "").strip() ] if not products: products = [name for name in (_staff_display_name(flow, pid) for pid in product_ids) if name] mention_ids: list[str] = [] if sales_id and sales_id not in seats: mention_ids.append(sales_id) for pid in product_ids: if pid and pid not in mention_ids and pid not in seats: mention_ids.append(pid) return sales_name, products, mention_ids def send_group_text( client: Any, chat_id: str, content: str, mention_userids: Optional[list[str]] = None, ) -> None: """群文本可带企微 userid,方便客户端真正点到人。旧客户端无此参数就只发正文。""" if not client or not chat_id or not content: return sender = getattr(client, "send_group", None) if not callable(sender): return mentions = [str(x).strip() for x in (mention_userids or []) if str(x).strip()] try: sender(chat_id=chat_id, content=content, mention_userids=mentions) except TypeError: sender(chat_id=chat_id, content=content) def hydrate_ticket(flow: Any, ticket: object) -> object: """ 按群找回的工单若缺询价七项,用工单号+销售再读主账补全。 测服 byCollabChat 可能只回协同字段;续办 get 空 sender 也不回 facts。 禁止用空 facts 把已确认港口冲成「-」。 """ view = ticket_view(ticket) facts = dict(view.get("facts") or {}) if any(str(facts.get(k) or "").strip() for k in ("起运港", "目的港", "品名", "货量")): return ticket no = ticket_no(ticket) sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip() book = getattr(flow, "ledger", None) if book is None or not no: return ticket full: Any = None getter = getattr(book, "get_for_agent", None) if callable(getter) and sales: full = getter(work_order_no=no, sender_id=sales) if not isinstance(full, dict) or not dict(full.get("facts") or {}): getter = getattr(book, "get_ticket", None) if callable(getter): full = getter(work_order_no=no) if full is None: return ticket merged = dict(view) full_view = ticket_view(full) if full_view.get("facts"): merged["facts"] = dict(full_view.get("facts") or {}) if full_view.get("quote") and not merged.get("quote"): merged["quote"] = dict(full_view.get("quote") or {}) if full_view.get("status"): merged.setdefault("status", full_view.get("status")) collab = dict(full_view.get("collab_facts") or {}) collab.update(dict(view.get("collab_facts") or {})) merged["collab_facts"] = collab if view.get("collab_chat_id"): merged["collab_chat_id"] = view.get("collab_chat_id") return merged def send_brief_and_sheet( *, flow: Any, ticket: object, sess: Any = None, quote_override: Optional[dict[str, Any]] = None, ) -> str: """ 群里再发一版摘要 + 填好的海运模板。旧消息不撤。 无价也发表,询价和协同能填的先填。 """ ticket = hydrate_ticket(flow, ticket) view = ticket_view(ticket) no = ticket_no(ticket) chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "").strip() client = group_client_of(flow) facts = dict(view.get("facts") or {}) if sess is not None and getattr(sess, "facts", None): facts.update(dict(sess.facts or {})) collab = dict(view.get("collab_facts") or {}) if sess is not None and getattr(sess, "collab_facts", None): collab.update(dict(sess.collab_facts or {})) quote = dict(quote_override or (sess.quote if sess else None) or view.get("quote") or {}) sales_name, products, mention_ids = resolve_group_mentions(flow, ticket, sess) has_price = bool(quote.get("total") or quote.get("fee_rows")) brief = copy.sea_group_brief( work_order_no=no, facts=facts, quote=quote, collab_facts=collab, sales_name=sales_name, product_names=products, has_price=has_price, ) send_group_text(client, chat_id, brief, mention_userids=mention_ids) send_filled_sheet(flow=flow, ticket=ticket, sess=sess, quote_override=quote_override) return "brief_and_sheet" def send_filled_sheet( *, flow: Any, ticket: object, sess: Any = None, quote_override: Optional[dict[str, Any]] = None, ) -> None: """只发填好的模板文件,不再发文字摘要。""" ticket = hydrate_ticket(flow, ticket) view = ticket_view(ticket) if ticket is not None else {} no = (sess.work_order_no if sess else "") or ticket_no(ticket) chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "").strip() facts = dict(view.get("facts") or {}) if sess is not None and getattr(sess, "facts", None): facts.update(dict(sess.facts or {})) collab = dict(view.get("collab_facts") or {}) if sess is not None and getattr(sess, "collab_facts", None): collab.update(dict(sess.collab_facts or {})) quote = dict(quote_override or (sess.quote if sess else None) or view.get("quote") or {}) _render_and_send_sheet( flow=flow, chat_id=chat_id, work_order_no=no, facts=_facts_for_fill(facts, collab), quote=quote, sales_id=str( (sess.sender_id if sess else "") or view.get("sales_wecom_id") or view.get("salesWecomId") or "" ), ) def _collab_value(collab: dict[str, str], key: str) -> str: """协同六项:内部键、对外展示名都能取到。""" src = dict(collab or {}) val = str(src.get(key) or "").strip() if val: return val label = SEA_COLLAB_DISPLAY.get(key, "") if label: val = str(src.get(label) or "").strip() return val def _oil_elec_mag_text(collab: dict[str, str]) -> str: """ 海运模板是一格「是否含油,电,磁」,三句话拼成一句再写入。 没说的写成 -,不编造。 """ oil = _collab_value(collab, "是否含油") elec = _collab_value(collab, "是否含电") mag = _collab_value(collab, "是否含磁") if not (oil or elec or mag): return "" return f"油:{oil or '-'};电:{elec or '-'};磁:{mag or '-'}" def _facts_for_fill(facts: dict[str, str], collab: dict[str, str]) -> dict[str, str]: """ 询价七项 + 协同六项。中文键和填表英文码一起带上。 主账 writeFactsByLabel 只认 cargo_ready_date / trade_terms,只传「货好时间」会空着。 """ merged = dict(facts or {}) src = dict(collab or {}) for key in SEA_COLLAB_FIELDS: val = _collab_value(src, key) if not val: continue for alias in _COLLAB_FILL_ALIASES.get(key, (key,)): merged[alias] = val oem = _oil_elec_mag_text(src) if oem: merged["oil_elec_mag"] = oem merged["是否含油,电,磁"] = oem return merged def _render_and_send_sheet( *, flow: Any, chat_id: str, work_order_no: str, facts: dict[str, Any], quote: dict[str, Any], sales_id: str, ) -> None: renderer = getattr(flow.ledger, "render_quote", None) client = group_client_of(flow) if not callable(renderer) or not chat_id: return result = renderer( work_order_no=work_order_no, business_line="SEA", facts=facts, quote=quote, format="xlsx", sales_wecom_id=sales_id, ) or {} if not result.get("ok"): logger.warning("群填表失败 wo=%s err=%s", work_order_no, result.get("error")) return filename = str(result.get("fileName") or f"{work_order_no}_quote.xlsx") sender = getattr(client, "send_group_file", None) if not callable(sender): logger.warning("群发文件无客户端 wo=%s chat=%s", work_order_no, chat_id) return painted = overlay_labeled_facts( filepath=str(result.get("filePath") or ""), file_b64=str(result.get("fileBase64") or ""), facts=facts, ) sent = sender( chat_id=chat_id, filename=filename, filepath=str(painted.get("filepath") or ""), file_b64=str(painted.get("file_b64") or ""), ) or {} if sent.get("ok") is False: logger.warning("群发文件失败 wo=%s err=%s", work_order_no, sent.get("error")) send_group_text(client, chat_id, copy.group_sheet_send_fail()) return logger.info("群已发模板 wo=%s file=%s", work_order_no, filename) def apply_collab_update( *, flow: Any, ticket: object, text: str, sender_id: str, sess: Any = None, ) -> str: """补协同六项。终态要说明原因。""" no = ticket_no(ticket) chat_id = str((sess.collab_chat_id if sess else "") or ticket_view(ticket).get("collab_chat_id") or "") client = group_client_of(flow) if is_terminal(ticket): send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket))) return "ticket_locked" fields = extract_collab_fields(text, allow_b=True) if not fields: send_group_text(client, chat_id, copy.group_fields_not_recognized()) return "collab_not_recognized" flow.apply_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id) if sess is not None: merged = dict(sess.collab_facts) merged.update(fields) sess.collab_facts = merged flow._save(sess) ticket2 = _reload(flow, chat_id, no) or ticket view = ticket_view(ticket2) collab = dict(view.get("collab_facts") or {}) collab.update(fields) view["collab_facts"] = collab if chat_id and not view.get("collab_chat_id"): view["collab_chat_id"] = chat_id send_brief_and_sheet(flow=flow, ticket=view, sess=sess) return "collab_field_saved" def apply_product_quote( *, flow: Any, ticket: object, text: str, sender_id: str, sess: Any = None, injected_quote: Optional[dict[str, Any]] = None, ) -> str: """ 产品文字报价:盖 TMS 价、填表发出、群里成交跟进。 一句话里的协同字段一并写入。非产品或终态要回话。 """ no = ticket_no(ticket) view = ticket_view(ticket) chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "") client = group_client_of(flow) if is_terminal(ticket): send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket))) return "ticket_locked" if not is_product(ticket, sender_id): return "group_ignore" fields = extract_collab_fields(text) if fields: flow.apply_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id) if sess is not None: merged = dict(sess.collab_facts) merged.update(fields) sess.collab_facts = merged quote = extract_product_quote(text, injected=injected_quote) if not quote: send_group_text(client, chat_id, copy.group_quote_not_recognized()) return "quote_not_recognized" _save_product_quote(flow, no, quote, sess) ticket2 = _reload(flow, chat_id, no) or ticket send_brief_and_sheet(flow=flow, ticket=ticket2, sess=sess, quote_override=quote) emit_group_deal(flow=flow, ticket=ticket2, sess=sess, quote=quote) return "product_quote_sent" def handle_group_file( *, flow: Any, ticket: object, sender_id: str, filename: str, media: dict[str, Any], sess: Any = None, ) -> str: """ 群附件:只收 Excel/PDF。终态不存要提醒。 产品 Excel/PDF 当报价并出成交跟进;别人的只存档。 """ no = ticket_no(ticket) view = ticket_view(ticket) chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "") client = group_client_of(flow) if is_terminal(ticket): send_group_text(client, chat_id, copy.ticket_locked_file(work_order_no=no, status=ticket_status(ticket))) return "ticket_locked_file" if not filename_is_excel_pdf(filename): send_group_text(client, chat_id, copy.group_only_excel_pdf()) return "file_type_rejected" _archive_file(flow, no, filename, media, sender_id) if is_product(ticket, sender_id): quote = { "source_label": "产品附件报价", "file_name": filename, "total": str((view.get("quote") or {}).get("total") or "-"), } _save_product_quote(flow, no, quote, sess) emit_group_deal(flow=flow, ticket=ticket, sess=sess, quote=quote) return "product_file_quote" return "file_archived" def dissolve_group(*, flow: Any, ticket: object, sess: Any = None) -> str: """解散应用群,这单协同结束,不改六态。""" view = ticket_view(ticket) no = ticket_no(ticket) chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "") client = group_client_of(flow) members = list(view.get("collab_member_ids") or view.get("memberIds") or []) if sess is not None: members = list(sess.invited_userids or members) dismiss = getattr(client, "dismiss_group", None) if callable(dismiss) and chat_id: dismiss(chat_id=chat_id, userids=members) if chat_id: try: from agent.channel.wshoto.rooms import unwatch_collab_room unwatch_collab_room(chat_id) except Exception: logger.exception("取消存档监听失败 chat=%s", chat_id) ender = getattr(flow.ledger, "end_collab_group", None) if callable(ender): ender(work_order_no=no) if sess is not None: sess.collab_ended = True flow._save(sess) send_group_text(client, chat_id, copy.group_dissolved(work_order_no=no)) return "group_dissolved" def emit_group_deal( *, flow: Any, ticket: object, sess: Any = None, quote: Optional[dict[str, Any]] = None, ) -> None: """群里只发一张成交跟进。版本 +1,旧卡点了要提示。""" view = ticket_view(ticket) no = ticket_no(ticket) chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "") client = group_client_of(flow) q = dict(quote or (sess.quote if sess else None) or view.get("quote") or {}) facts = dict((sess.facts if sess else None) or view.get("facts") or {}) ver = 1 if sess is not None: sess.deal_version = int(getattr(sess, "deal_version", 0) or 0) + 1 ver = sess.deal_version sess.phase = "wait_deal" sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE) sess.status = "已报价" sess.quote = q sess.wait_version += 1 flow._save(sess) sales_name, _products, mention_ids = resolve_group_mentions(flow, ticket, sess) text = f"@{sales_name}\n" + copy.deal_card(work_order_no=no, quote=q, facts=facts) # 成交跟进只点销售:产品不能点这张卡。 sales_mention = mention_ids[:1] if mention_ids else [] send_group_text(client, chat_id, text, mention_userids=sales_mention) carder = getattr(client, "send_group_card", None) payload = copy.deal_wecom_payload(work_order_no=no, quote=q, facts=facts, card_version=ver) if callable(carder): carder(chat_id=chat_id, template_card=payload.get("template_card") or {}) def explain_deal_click( *, flow: Any, sender_id: str, action: str, card_meta: Optional[dict[str, Any]], sess: Any, ) -> str: """ 成交卡点错人 / 旧版 / 终态:群里说清楚,不当没反应。 返回空表示可以继续走成交。 """ if action not in {copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE}: return "" if sess is None: return "" chat_id = (sess.collab_chat_id or "").strip() client = group_client_of(flow) no = sess.work_order_no if sess.status in TERMINAL: send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=sess.status)) return "ticket_locked" if sender_id != sess.sender_id: send_group_text(client, chat_id, copy.group_deal_sales_only()) return "not_sales" clicked = copy.deal_card_version(card_meta) current = int(getattr(sess, "deal_version", 0) or 0) if current and clicked and clicked != current: send_group_text(client, chat_id, copy.group_deal_stale(work_order_no=no)) return "stale_deal" return "" def _save_product_quote(flow: Any, no: str, quote: dict[str, Any], sess: Any) -> None: writer = getattr(flow.ledger, "upsert_product_quote", None) or getattr( flow.ledger, "upsert_quote", None ) if callable(writer): writer(work_order_no=no, quote=quote, to_status="已报价") if sess is not None: sess.quote = dict(quote) sess.status = "已报价" flow._save(sess) def _archive_file(flow: Any, no: str, filename: str, media: dict[str, Any], sender_id: str) -> None: saver = getattr(flow.ledger, "attach_file", None) if callable(saver): saver( work_order_no=no, file_name=filename, object_key=str(media.get("media_id") or media.get("MediaId") or filename), content_type=str(media.get("content_type") or ""), sender_id=sender_id, ) def _reload(flow: Any, chat_id: str, no: str) -> object: finder = getattr(flow.ledger, "find_by_collab_chat", None) if callable(finder) and chat_id: hit = finder(chat_id=chat_id) if hit is not None: return hit getter = getattr(flow.ledger, "get_ticket", None) if callable(getter) and no: return getter(work_order_no=no) return None