""" 多段联运的群内报价与成交跟进。 本文件职责:按发言人岗位把报价归到对应段;一段报完就出该段报价单; 全部段都出了报价单,才在群里发一张整票成交跟进,各段价格不加总。 含空运时,原有群发出工单号后在这里激活:@人、发各段需求和模板。 调用:群入站 Handler,在 Worker 线程。禁止查 TMS 运价;禁止按段各建工单。 """ from __future__ import annotations import json import logging from typing import Any, Callable, Optional from agent.llm.extract_text import extract_collab_fields, extract_product_quote from agent.policy import inquiry_copy as copy from agent.policy.multi_segments import Segment, group_brief, includes_air, segments_from_payload from agent.policy.sea_group_ops import ( apply_group_deal, group_client_of, is_sales, send_filled_sheet, send_group_text, ticket_no, ticket_view, ) from agent.schema.field_validate import collab_fields_for_ticket logger = logging.getLogger(__name__) _ROLE_MODE = {"sea": "SEA", "land": "LAND", "air": "AIR"} SheetFn = Callable[..., dict[str, Any]] def ticket_is_multi(ticket: object) -> bool: """主账业务线是多段联运。""" view = ticket_view(ticket) line = str(view.get("business_line") or view.get("businessLine") or "").upper() return line == "MULTI" def load_segments(ticket: object) -> list[Segment]: """从工单事实里的 segments_json 读回各段。坏数据当没有段。""" view = ticket_view(ticket) facts = dict(view.get("facts") or {}) raw = facts.get("segments_json") or "" if not raw: return [] try: payload = json.loads(raw) except json.JSONDecodeError: logger.warning("多段 segments_json 无法解析 wo=%s", ticket_no(ticket)) return [] return segments_from_payload(payload) def segment_quotes(ticket: object) -> dict[str, dict[str, Any]]: """已落到各段的报价。键是 SEA/LAND/AIR。""" quote = dict(ticket_view(ticket).get("quote") or {}) raw = quote.get("segment_quotes") or {} if not isinstance(raw, dict): return {} out: dict[str, dict[str, Any]] = {} for key, val in raw.items(): mode = str(key or "").upper() if mode in _ROLE_MODE.values() and isinstance(val, dict): out[mode] = dict(val) return out def sender_mode(flow: Any, sender_id: str) -> str: """ 发言人负责哪一段。 海运产品归海运,陆运产品归陆运,航线·空运归空运。对不上返回空。 """ 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 not callable(finder): return "" try: row = finder(wecom_id=sender_id) or {} except Exception: logger.exception("多段查岗位失败 wecom=%s", sender_id) return "" role = str(row.get("roleCode") or row.get("role_code") or "").strip().lower() return _ROLE_MODE.get(role, "") def handle_multi_inbound( *, flow: Any, ticket: object, chat_id: str, sender_id: str, text: str, injected_quote: Optional[dict[str, Any]] = None, sheet_fn: Optional[SheetFn] = None, ) -> str: """ 群里一条多段消息。 只含工单号:绑定本群并发摘要和各段模板。 有价格:归到发言人那一段并出该段报价单。全部出齐才成交跟进。 副作用:写主账报价、可能发群消息和模板。不查 TMS。 """ room = (chat_id or "").strip() no = ticket_no(ticket) segments = load_segments(ticket) if not segments: send_group_text(group_client_of(flow), room, f"工单{no}没有可报价的运输段。") return "multi_no_segments" only_no = copy.extract_work_order_no(text or "") == no and _only_work_order(text or "") if only_no or not (ticket_view(ticket).get("collab_chat_id") or ""): _bind_room(flow, ticket, room, sender_id) ticket = _reload(flow, no) or ticket cabin = _cabin_intent(text or "") if cabin and includes_air(segments): return _cabin(flow, ticket, room, sender_id, text or "", cabin) if only_no: _send_activation(flow, ticket, room, segments, sender_id=sender_id, sheet_fn=sheet_fn) return "multi_activated" mode = sender_mode(flow, sender_id) target = next((seg for seg in segments if seg.mode == mode), None) fields = {} if target is not None: keys = collab_fields_for_ticket(target.facts, target.mode) fields = extract_collab_fields(text or "", allowed_keys=keys) if fields: _save_collab(flow, ticket, target, fields) ticket = _reload(flow, no) or ticket segments = load_segments(ticket) target = next((seg for seg in segments if seg.mode == mode), target) quote = extract_product_quote(text or "", injected=injected_quote) if not quote: if fields: return "multi_collab_saved" if is_sales(ticket, sender_id): return _maybe_deal(flow, ticket, sender_id, text) send_group_text(group_client_of(flow), room, copy.group_quote_not_recognized()) return "quote_not_recognized" if target is None: send_group_text(group_client_of(flow), room, "没有对上你负责的运输段,请让对应岗位报价。") return "quote_wrong_role" return _save_segment_quote( flow=flow, ticket=ticket, chat_id=room, segment=target, segments=segments, quote=quote, sheet_fn=sheet_fn, ) def deal_text(*, work_order_no: str, segments: list[Segment], quotes: dict[str, dict[str, Any]]) -> str: """整票成交跟进文字。每段各自的价格、有效期,海运陆运再加时效。不加总。""" no = (work_order_no or "").strip() lines = [f"询价工单 {no}", copy.DEAL_SUBTITLE] for seg in segments: quote = quotes.get(seg.mode) or {} lines.append(f"段{seg.index}:{seg.word()}") rows = copy.deal_horizontal_rows(quote=quote, facts=seg.facts, business_line=seg.mode) lines.extend(f"{row['keyname']}:{row['value']}" for row in rows) lines.append(copy.DEAL_HINT) return "\n".join(lines) def deal_payload( *, work_order_no: str, segments: list[Segment], quotes: dict[str, dict[str, Any]], card_version: int = 1, ) -> dict[str, Any]: """成交卡一行一段报价,不出现各段加总。按钮仍是整票一次确认。""" base = copy.deal_wecom_payload( work_order_no=work_order_no, quote={"source_label": "分段报价"}, business_line="MULTI", card_version=card_version, ) rows = [] for seg in segments: quote = quotes.get(seg.mode) or {} total = copy.money_text( str(quote.get("total") or "-").strip() or "-", str(quote.get("currency") or ""), ) rows.append({"keyname": f"段{seg.index}{seg.word()}", "value": copy._clip(total, 26)}) card = dict(base.get("template_card") or {}) card["horizontal_content_list"] = rows card["source"] = {"desc": "分段报价", "desc_color": 0} return {"msgtype": "template_card", "template_card": card} def _save_segment_quote( *, flow: Any, ticket: object, chat_id: str, segment: Segment, segments: list[Segment], quote: dict[str, Any], sheet_fn: Optional[SheetFn], ) -> str: """写入这一段报价并发该段报价单。其他段未齐时不发成交跟进。""" no = ticket_no(ticket) stored = segment_quotes(ticket) stored[segment.mode] = dict(quote) version = int(dict(ticket_view(ticket).get("quote") or {}).get("deal_version") or 0) all_ready = all(seg.mode in stored and _has_money(stored[seg.mode]) for seg in segments) if all_ready: version += 1 payload = { "segment_quotes": stored, "source_label": "分段报价", "deal_version": version, } writer = getattr(flow.ledger, "upsert_quote", None) if callable(writer): writer( work_order_no=no, quote=payload, to_status="已报价" if all_ready else "", ) ticket = _reload(flow, no) or ticket _send_sheet( flow=flow, ticket=ticket, chat_id=chat_id, segment=segment, quote=quote, sheet_fn=sheet_fn, ) client = group_client_of(flow) if not all_ready: send_group_text(client, chat_id, f"段{segment.index}{segment.word()}报价单已发出。其他段报完后一起跟进成交。") return "segment_quoted" text = deal_text(work_order_no=no, segments=segments, quotes=stored) send_group_text(client, chat_id, text) carder = getattr(client, "send_group_card", None) if callable(carder): carder( chat_id=chat_id, template_card=deal_payload( work_order_no=no, segments=segments, quotes=stored, card_version=version, ).get("template_card") or {}, ) logger.info("multi.deal wo=%s segments=%s", no, [seg.mode for seg in segments]) return "multi_deal_sent" def _cabin(flow: Any, ticket: object, chat_id: str, sender_id: str, text: str, intent: str) -> str: """空运段的锁舱、释放走现有空运规则,不挡住其他段报价。""" from agent.policy.air_group_ops import apply_air_lock, apply_air_release if intent == "release": return apply_air_release( flow=flow, ticket=ticket, sender_id=sender_id, chat_id=chat_id, instruction=text, ) return apply_air_lock( flow=flow, ticket=ticket, sender_id=sender_id, chat_id=chat_id, instruction=text, ) def _cabin_intent(text: str) -> str: raw = text or "" if "释放" in raw and "舱" in raw: return "release" if "锁舱" in raw: return "lock" return "" def _send_activation( flow: Any, ticket: object, chat_id: str, segments: list[Segment], *, sender_id: str = "", sheet_fn: Optional[SheetFn], ) -> None: """原有群激活:@具体销售和各段产品,发核对同款字段,每段一份模板。不含 TMS。""" no = ticket_no(ticket) sales_name, products, mention_ids = _activation_mentions( flow, ticket, segments, sender_id=sender_id ) brief = group_brief( work_order_no=no, segments=segments, sales_name=sales_name, mention_names=products, ) send_group_text(group_client_of(flow), chat_id, brief, mention_userids=mention_ids) for seg in segments: _send_sheet(flow=flow, ticket=ticket, chat_id=chat_id, segment=seg, quote={}, sheet_fn=sheet_fn) def _activation_mentions( flow: Any, ticket: object, segments: list[Segment], sender_id: str = "", ) -> tuple[str, list[str], list[str]]: """激活时要 @ 的人:具体销售 + 各段对得上的产品岗;空运按航线岗名单。""" from agent.policy.sea_group_ops import resolve_group_mentions sales_name, products, mention_ids = resolve_group_mentions( flow, ticket, None, fallback_sales_id=sender_id ) book = getattr(flow, "ledger", None) or getattr(flow, "_ledger", None) for seg in segments: for row in _staff_rows(book, seg): uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip() name = str(row.get("name") or "").strip() or uid if uid and uid not in mention_ids: mention_ids.append(uid) if name and name not in products and name != sales_name: products.append(name) return sales_name, products, mention_ids def _staff_rows(book: Any, seg: Segment) -> list[dict[str, Any]]: """一段要对上的产品人员。线路没填时,陆运/空运按岗位名单,不猜港口。""" if book is None: return [] try: if seg.mode == "SEA": matcher = getattr(book, "match_sea_staff", None) if not callable(matcher): return [] rows = matcher( origin=str(seg.facts.get("起运港") or ""), destination=str(seg.facts.get("目的港") or ""), ) or [] return [dict(x) for x in rows if isinstance(x, dict)] if seg.mode == "LAND": matcher = getattr(book, "match_land_staff", None) rows = [] if callable(matcher): rows = matcher(route_category=str(seg.facts.get("线路类别") or "")) or [] hits = [dict(x) for x in rows if isinstance(x, dict)] if hits: return hits lister = getattr(book, "list_staff_by_role", None) if not callable(lister): return [] return [dict(x) for x in (lister(role_code="land") or []) if isinstance(x, dict)] if seg.mode == "AIR": lister = getattr(book, "list_staff_by_role", None) if not callable(lister): return [] return [dict(x) for x in (lister(role_code="air") or []) if isinstance(x, dict)] except Exception: logger.exception("多段激活匹配产品失败 mode=%s", seg.mode) return [] def _send_sheet( *, flow: Any, ticket: object, chat_id: str, segment: Segment, quote: dict[str, Any], sheet_fn: Optional[SheetFn], ) -> None: """按这一段的运输方式匹配模板。失败只记日志,不挡住报价归段。""" sender = sheet_fn or send_filled_sheet try: sender( flow=flow, ticket=_sheet_ticket(ticket, segment), quote_override=dict(quote or {}), business_line=segment.mode, chat_id=chat_id, ) except Exception: logger.exception("多段模板发送失败 wo=%s mode=%s", ticket_no(ticket), segment.mode) def _sheet_ticket(ticket: object, segment: Segment) -> object: """填表时用这一段的询价字段,避免海运模板吃到空运字段。""" view = ticket_view(ticket) facts = _facts_for_segment_sheet(segment) sales_id = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip() if isinstance(ticket, dict): cloned = dict(ticket) cloned["facts"] = facts cloned["business_line"] = segment.mode cloned["businessLine"] = segment.mode if sales_id: cloned["sales_wecom_id"] = sales_id cloned["salesWecomId"] = sales_id return cloned return { "work_order_no": view.get("work_order_no"), "workOrderNo": view.get("work_order_no"), "facts": facts, "business_line": segment.mode, "businessLine": segment.mode, "collab_chat_id": view.get("collab_chat_id"), "sales_wecom_id": sales_id, "salesWecomId": sales_id, "quote": {}, } def _facts_for_segment_sheet(segment: Segment) -> dict[str, str]: """ 一段填表用的字段。 多段不查 TMS,陆运常缺线路类别。 后台陆运拼车模板关键词是「国内拼车」,询价字段是「国内运输拼车」, 不补别名就套不上表,只会发出空运模板。 """ facts = {k: str(v).strip() for k, v in dict(segment.facts).items() if str(v or "").strip()} if segment.mode != "LAND": return facts from agent.schema.land_options import SAMPLE_PAIRS, canonicalize_transport_type land_type = canonicalize_transport_type(str(facts.get("运输类型") or "")) if land_type: facts["运输类型"] = land_type if land_type and not str(facts.get("线路类别") or "").strip(): for pair_type, pair_route in SAMPLE_PAIRS: if pair_type == land_type: facts["线路类别"] = pair_route break hint = _LAND_SHEET_HINTS.get(land_type, "") if hint: facts["模板匹配"] = hint return facts _LAND_SHEET_HINTS = { "国内运输拼车": "国内拼车 国内零担", "国内运输整车": "国内整车", "跨境整车": "跨境整车", "跨境集拼": "跨境集拼 跨境零担", "中港整车": "中港", "中港零担/集拼": "中港", } def _save_collab(flow: Any, ticket: object, segment: Segment, fields: dict[str, str]) -> None: """协同字段写进对应段,不挡报价。""" segments = load_segments(ticket) for seg in segments: if seg.mode != segment.mode: continue seg.facts.update({k: str(v) for k, v in fields.items() if str(v).strip()}) _write_segments(flow, ticket_no(ticket), segments) writer = getattr(flow.ledger, "patch_collab_facts", None) if callable(writer): writer(work_order_no=ticket_no(ticket), facts=fields, sender_id="") def _bind_room(flow: Any, ticket: object, chat_id: str, sender_id: str) -> None: """把原有群绑到这张多段工单。已绑同一个群则不重复建。""" view = ticket_view(ticket) if str(view.get("collab_chat_id") or "") == chat_id: return binder = getattr(flow.ledger, "bind_collab_group", None) members = [str(x) for x in (view.get("collab_member_ids") or [])] if sender_id and sender_id not in members: members.append(sender_id) sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or sender_id or "") if sales and sales not in members: members.append(sales) if callable(binder): binder( work_order_no=ticket_no(ticket), chat_id=chat_id, member_ids=members, product_names=list(view.get("product_names") or []), product_ids=list(view.get("product_ids") or []), ) def _maybe_deal(flow: Any, ticket: object, sender_id: str, text: str) -> str: """销售在群里确认成交。还有段没报价单时不确认。""" from agent.routing.group_intent import ( INTENT_DEAL, INTENT_LOST, INTENT_NEGOTIATE, classify_group_text, ) intent = classify_group_text(text or "") outcome = {INTENT_DEAL: "已成交", INTENT_LOST: "未成交", INTENT_NEGOTIATE: "协商中"}.get(intent) if not outcome: return "multi_noop" segments = load_segments(ticket) quotes = segment_quotes(ticket) if not all(seg.mode in quotes and _has_money(quotes[seg.mode]) for seg in segments): send_group_text( group_client_of(flow), str(ticket_view(ticket).get("collab_chat_id") or ""), "还有运输段没有报价单,全部报完后再确认成交。", ) return "deal_wait_segments" return apply_group_deal( flow=flow, ticket=ticket, sender_id=sender_id, outcome=outcome, text=text, ) def _write_segments(flow: Any, work_order_no: str, segments: list[Segment]) -> None: from agent.policy.multi_segments import segments_to_payload writer = getattr(flow.ledger, "update_facts", None) if not callable(writer): return current = {} getter = getattr(flow.ledger, "get_ticket", None) if callable(getter): ticket = getter(work_order_no=work_order_no) current = dict(ticket_view(ticket).get("facts") or {}) current["segments_json"] = json.dumps(segments_to_payload(segments), ensure_ascii=False) writer(work_order_no=work_order_no, facts=current) def _reload(flow: Any, work_order_no: str) -> object: getter = getattr(flow.ledger, "get_ticket", None) if callable(getter): return getter(work_order_no=work_order_no) return None def _has_money(quote: dict[str, Any]) -> bool: return bool(str(quote.get("total") or "").strip() or quote.get("fee_rows") or quote.get("fee_lines")) def _only_work_order(text: str) -> bool: """去掉工单号和 @询价小助手 后几乎没字,当作激活。与空运群同一套,避免 WO 数字被当成报价。""" from agent.routing.air_group_intent import _only_work_order as air_only return air_only(text) def send_segment_templates(*, flow: Any, ticket: object, chat_id: str, sheet_fn: Optional[SheetFn] = None) -> None: """拉群或激活后,按段各发一份匹配模板。没有段则不发。""" for seg in load_segments(ticket): _send_sheet( flow=flow, ticket=ticket, chat_id=chat_id, segment=seg, quote={}, sheet_fn=sheet_fn, ) def multi_has_air(ticket: object) -> bool: """这张多段单含空运段,锁舱和含电含磁才适用。""" return includes_air(load_segments(ticket))