""" 空运文字询价出站话术(销售可见)。 本文件职责:集中文案;按钮字面量必须与 handler 精确匹配。 禁止:在本文件调主账/TMS;禁止把密钥写进话术。 """ from __future__ import annotations import json import re import time import uuid from typing import Any, Iterable from agent.schema.field_validate import ( AIR_COLLAB_FIELDS, AIR_REQUIRED_FIELDS, LAND_DISPLAY, SEA_COLLAB_FIELDS, SEA_REQUIRED_FIELDS, collab_display_name, collab_fields_for_ticket, display_name, ) from agent.schema.land_options import option_prompt as _land_option_prompt # 空运补问:与合同 8 项一致。报价日期默认当天,不进待补充。 _AIR_EXAMPLES = { "起运港": "南京", "目的港": "吉隆坡", "品名": "茶叶", "件数": "10件", "毛重": "100KGS", "体积": "2CBM", "包装方式": "托盘", "报价日期": "2026-09-15", } _AIR_CLARIFY = tuple( (key, label, _AIR_EXAMPLES[key]) for key, label in AIR_REQUIRED_FIELDS ) # 海运补问:与合同 7 项一致。报价日期默认当天,不进待补充。 _SEA_EXAMPLES = { "起运港": "上海", "目的港": "洛杉矶", "品名": "普货", "货量": "20吨", "整柜或拼柜": "整柜", "箱型箱量": "1x40HQ", "报价日期": "2026-09-15", } _SEA_CLARIFY = tuple( (key, label, _SEA_EXAMPLES[key]) for key, label in SEA_REQUIRED_FIELDS ) # 文字按钮(销售点卡或原样回复均可) BTN_SKIP_COLLAB = "不需要协同,直接生成报价单" BTN_SKIP_COLLAB_OLD = "不需要协同,直接生成基础报价单" BTN_SKIP_COLLAB_KEY = "skip_collab" BTN_SKIP_COLLAB_DONE = "已提交" BTN_PULL_COLLAB = "拉产品进群协同" BTN_PULL_COLLAB_KEY = "pull_collab" BTN_ADOPT_EXCEL = "采用,生成Excel报价单" BTN_ADOPT_PDF = "采用,生成PDF报价单" BTN_REJECT = "不采用" # 企微一张卡三个按钮只能横排,长文案会被裁。卡片用短文案才能看全。 BTN_ADOPT_EXCEL_CARD = "生成Excel" BTN_ADOPT_PDF_CARD = "生成PDF" BTN_ADOPT_EXCEL_KEY = "adopt_excel" BTN_ADOPT_PDF_KEY = "adopt_pdf" BTN_REJECT_KEY = "reject" BTN_DEAL = "成交" BTN_LOST = "未成交" BTN_NEGOTIATE = "协商中" BTN_DEAL_KEY = "deal" BTN_LOST_KEY = "lost" BTN_NEGOTIATE_KEY = "negotiate" # 旧卡 EventKey 兼容;新暂无报价卡按现网图:无按钮。 BTN_COPY_TICKET = "复制工单号" BTN_COPY_TICKET_KEY = "copy_ticket" _WO_NO = re.compile(r"WO\d{12}", re.IGNORECASE) # 仅给无网 stub / 工单号回填用,正式续办意图由 DeepSeek A 判断,不要求销售说这些词。 _CONTINUE_HINT = ( "继续", "接着", "回到", "再看", "再出", "找回", "重开", "那单", "这单", "历史工单", "咨询工单", "刚才那", "上次那", ) ASK_TRANSPORT = "请补充运输方式:海运 / 空运 / 陆运。" # 旧一句只问运输类型;销售要一次看到类型、线路、分类。残留调用也走完整清单。 ASK_LAND_SUBTYPE = _land_option_prompt(current={}) def land_sample_image_payload() -> dict[str, Any]: """ 填写样例表图片出站。 走企微 image,不走文件气泡。路径随发布包,出站线程再读字节。 禁止在回调线程上传。 """ from agent.schema.land_options import sample_image_path path = sample_image_path() return { "msgtype": "image", "filename": "填写样例.png", "filepath": str(path), } def land_sample_image_fallback() -> str: """图片失败时回文字样例,避免销售什么都看不到。""" from agent.schema.land_options import sample_fallback_text return sample_fallback_text() FIRST_CHAT = "你好,我是询价机器人。请直接描述客户需求,例如「上海到洛杉矶空运,3 件 180kg,下周三前到」,我会解析需求并查询 TMS 标准报价。" ROUTE_MODEL_FAILED = "刚才没听清,请再说一次:是新询价,还是要接着办哪张工单?" LAND_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。" LAND_CONFIRM_EXPIRED = "刚才的陆运核对已过期,请把陆运需求再发一次,出核对卡后再回复确定。" LAND_TMS_RETRY = "工单 {wo} 上次查价没成功。回复「确定」按原需求再查一次,不用重新填。" LAND_TMS_RETRYING = "正在重新查询TMS报价。工单:{wo}" _LAND_LCL_KEYS = ("起运港", "目的港", "品名", "体积", "毛重", "报价日期") _LAND_FTL_KEYS = ("起运港", "目的港", "品名", "车型/数量", "报价日期") _LAND_HK_FULL_KEYS = ("起运港", "目的港", "品名", "报价日期") _LAND_FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港零担/集拼"}) _LAND_CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港整车", "中港零担/集拼"}) _LAND_EXAMPLES = { "起运港": "广州", "目的港": "深圳", "品名": "衣服", "体积": "100CBM", "毛重": "50kg", "车型/数量": "9米6/1", "通关口岸": "皇岗", } def _land_cargo_keys(land_type: str, facts: dict[str, str] | None = None) -> tuple[str, ...]: """当前运输类型要展示的货物内部键。中港整车不要车型/数量。""" _ = facts if land_type == "中港整车": return _LAND_HK_FULL_KEYS if land_type in _LAND_FTL_TYPES: return _LAND_FTL_KEYS return _LAND_LCL_KEYS def land_confirm_card(facts: dict[str, str] | None) -> str: """ 确定查价前的核对卡。没有工单号。 行列出运输类型/线路/分类和该类型货物字段。 国内不出现通关口岸;跨境/中港通关口岸非必填,没填留空行。 不再单独列出中港装载方式。 """ src = dict(facts or {}) land_type = (src.get("运输类型") or "").strip() lines = [ f"运输类型(必填):{land_type}", f"线路类别(必填):{(src.get('线路类别') or '').strip()}", f"运输分类(必填):{(src.get('运输分类') or '').strip()}", ] for key in _land_cargo_keys(land_type, src): label = LAND_DISPLAY.get(key, key) val = (src.get(key) or "").strip() lines.append(f"{label}(必填):{val}") if land_type in _LAND_CUSTOMS_TYPES: lines.append(f"通关口岸(非必填):{(src.get('通关口岸') or '').strip()}") lines.append("") lines.append(LAND_CONFIRM_TAIL) return "\n".join(lines) def land_group_no_staff() -> str: """没有对上线路的陆运产品,不建空群。""" return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的陆运产品岗后再试。" def land_group_pair_rejected() -> str: """类型+线路不在填写样例里,禁止拉群。""" return "拉群失败,请排查填写内容,并重新发起工单。" def ask_land_fields( *, facts: dict[str, str] | None = None, missing_keys: Iterable[str] = (), land_subtype: str = "", ) -> str: """ 陆运货物缺项补问。不出核对卡、不出现确定句。 报价日期不进待补充。 """ src = dict(facts or {}) skip = {"报价日期", "运输类型", "线路类别", "运输分类"} missing = [k for k in missing_keys if k and k not in skip] land_type = (land_subtype or src.get("运输类型") or "").strip() lines = ["请确认并补充以下信息:", "运输方式:陆运"] if land_type: lines.append(f"运输类型:{land_type}") for key in _land_cargo_keys(land_type, src): val = (src.get(key) or "").strip() if val: lines.append(f"{LAND_DISPLAY.get(key, key)}:{val}") lines.append("") lines.append("待补充:") if not missing: return "" for key in missing: label = display_name(key, "LAND") example = _LAND_EXAMPLES.get(key, "") if example: lines.append(f"{label}:(如:{example})") else: lines.append(f"{label}:") return "\n".join(lines) def _air_known_value(facts: dict[str, str], key: str) -> str: """已识别字段的销售侧展示值;没有则空串。""" src = facts or {} if key == "起运港": return (src.get("起运港") or "").strip() if key == "目的港": return (src.get("目的港") or "").strip() if key == "品名": return (src.get("品名") or "").strip() if key == "件数": raw = (src.get("件数") or "").strip() return _keep_or_add_unit(raw, ("件", "PCS"), "件") if raw else "" if key == "毛重": raw = (src.get("毛重") or "").strip() return _keep_or_add_unit(raw, ("KGS", "KG", "公斤", "千克"), "") if raw else "" if key == "体积": raw = (src.get("体积") or "").strip() return _keep_or_add_unit(raw, ("CBM", "M3", "M³", "立方米", "立方", "方"), "") if raw else "" if key == "包装方式": return (src.get("包装方式") or src.get("包装类型") or "").strip() if key == "报价日期": return (src.get("报价日期") or "").strip() return (src.get(key) or "").strip() def _sea_known_value(facts: dict[str, str], key: str) -> str: """海运已识别字段的销售侧展示值。""" src = facts or {} if key == "货量": return (src.get("货量") or src.get("货物数量") or "").strip() return (src.get(key) or "").strip() def ask_clarify( *, facts: dict[str, str] | None = None, missing_keys: Iterable[str] = (), transport_mode: str = "空运", ) -> str: """ 缺项补问:先回显已识别,再列待补充并给示例。 报价日期写进已识别(销售说了用原话,没说用当天),不出现在待补充。 """ src = dict(facts or {}) skip = {"报价日期"} missing = [k for k in missing_keys if k and k not in skip] missing_set = set(missing) sea = (transport_mode or "").strip() == "海运" rows = _SEA_CLARIFY if sea else _AIR_CLARIFY known = _sea_known_value if sea else _air_known_value lines = ["请确认并补充以下信息:"] if transport_mode: lines.append(f"运输方式:{transport_mode}") for key, label, _example in rows: val = known(src, key) if val: lines.append(f"{label}:{val}") lines.append("") lines.append("待补充:") any_missing = False if "运输方式" in missing_set: any_missing = True lines.append("运输方式:(如:空运)") for key, label, example in rows: if key in missing_set: any_missing = True lines.append(f"{label}:(如:{example})") if not any_missing: return "" return "\n".join(lines) def ask_missing(display_names: Iterable[str]) -> str: """兼容旧调用:仅有展示名时仍走补问,但没有已识别回显。""" alias = { "起运地": "起运港", "目的地": "目的港", "起运地(机场名或三字码)": "起运港", "目的地(机场名或三字码)": "目的港", "毛重": "毛重", "重量": "毛重", "重量(KG)": "毛重", "体积": "体积", "体积(CBM)": "体积", "包装类型": "包装方式", "包装方式": "包装方式", "货物数量": "货量", "货量": "货量", "整柜或拼柜": "整柜或拼柜", "箱型箱量": "箱型箱量", } keys: list[str] = [] for name in display_names: key = alias.get(name, name) if key and key not in keys and key != "报价日期": keys.append(key) return ask_clarify(facts={}, missing_keys=keys, transport_mode="空运") def _keep_or_add_unit(raw: str, known: tuple[str, ...], default: str) -> str: """ 销售侧单位原样回显。 写了 1.5KG 就回 1.5KG,禁止改成 1.5KGS。没写单位才补 default(可空)。 """ text = (raw or "").strip() if not text: return "-" upper = text.upper() for unit in known: if upper.endswith(unit.upper()): return text if default and not any(ch.isalpha() or ch in "立方件" for ch in text): return f"{text}{default}" return text def _with_unit(raw: str, suffix: str) -> str: """兼容旧调用:已带单位则原样。重量不要走这里补 KGS。""" if suffix.upper() == "KGS": return _keep_or_add_unit(raw, ("KGS", "KG", "公斤", "千克"), "") if suffix.upper() == "CBM": return _keep_or_add_unit(raw, ("CBM", "M3", "M³", "立方米", "立方", "方"), "") return _keep_or_add_unit(raw, (suffix,), suffix) def inquiry_field_lines( *, work_order_no: str, facts: dict[str, str], transport_mode: str = "空运", ) -> list[str]: """ 询价确认字段行(销售可见)。 起运/目的展示销售原文(南京/北京),三字码只给出站 TMS,不对销售展示。 """ origin = (facts.get("起运港") or facts.get("起运地") or "-").strip() dest = (facts.get("目的港") or facts.get("目的地") or "-").strip() cargo = (facts.get("品名") or "-").strip() or "-" pack = (facts.get("包装方式") or facts.get("包装类型") or "-").strip() or "-" quote_date = (facts.get("报价日期") or "-").strip() or "-" if (transport_mode or "").strip() == "海运": qty = (facts.get("货量") or facts.get("货物数量") or "-").strip() or "-" load = (facts.get("整柜或拼柜") or "-").strip() or "-" box = (facts.get("箱型箱量") or "-").strip() or "-" return [ f"工单号:{work_order_no}", f"运输方式:{transport_mode}", f"起运港:{origin}", f"目的港:{dest}", f"品名:{cargo}", f"货物数量:{qty}", f"整柜或拼柜:{load}", f"箱型箱量:{box}", f"报价日期:{quote_date}", ] if (transport_mode or "").strip() == "陆运": land_type = (facts.get("运输类型") or "").strip() lines = [ f"工单号:{work_order_no}", f"运输方式:{transport_mode}", f"运输类型:{land_type or '-'}", f"线路类别:{(facts.get('线路类别') or '-').strip() or '-'}", f"运输分类:{(facts.get('运输分类') or '-').strip() or '-'}", ] for key in _land_cargo_keys(land_type, facts): label = LAND_DISPLAY.get(key, key) val = (facts.get(key) or "-").strip() or "-" lines.append(f"{label}:{val}") if land_type in _LAND_CUSTOMS_TYPES: lines.append(f"通关口岸:{(facts.get('通关口岸') or '').strip()}") return lines return [ f"工单号:{work_order_no}", f"运输方式:{transport_mode}", f"起运地:{origin}", f"目的地:{dest}", f"品名:{cargo}", f"件数:{_with_unit(str(facts.get('件数') or ''), '件')}", f"重量:{_with_unit(str(facts.get('毛重') or ''), 'KGS')}", f"体积:{_with_unit(str(facts.get('体积') or ''), 'CBM')}", f"包装类型:{pack}", f"报价日期:{quote_date}", ] def inquiry_card( *, work_order_no: str, facts: dict[str, str], first_or_same: str = "first", transport_mode: str = "空运", ) -> str: """ 建单后的询价确认。 无标题、无 [test]。first_or_same 保留兼容,不再对销售展示。 """ _ = first_or_same lines = inquiry_field_lines( work_order_no=work_order_no, facts=facts, transport_mode=transport_mode ) lines.append("工单已创建,正在查询TMS报价。") return "\n".join(lines) def canonical_button(raw: str) -> str: """把销售点卡 / 原文 / 企微 EventKey 收成标准按钮文案;对不上返回空。""" text = (raw or "").strip().strip("【】") if text in {BTN_SKIP_COLLAB, BTN_SKIP_COLLAB_OLD, BTN_SKIP_COLLAB_KEY}: return BTN_SKIP_COLLAB # 新卡 EventKey 带工单号:skip_collab:WO…,避免点旧卡落到最新单 if text.startswith(f"{BTN_SKIP_COLLAB_KEY}:"): return BTN_SKIP_COLLAB if text in {BTN_PULL_COLLAB, BTN_PULL_COLLAB_KEY} or text.startswith( f"{BTN_PULL_COLLAB_KEY}:" ): return BTN_PULL_COLLAB if text in {BTN_ADOPT_EXCEL, BTN_ADOPT_EXCEL_CARD, BTN_ADOPT_EXCEL_KEY} or text.startswith( f"{BTN_ADOPT_EXCEL_KEY}:" ): return BTN_ADOPT_EXCEL if text in {BTN_ADOPT_PDF, BTN_ADOPT_PDF_CARD, BTN_ADOPT_PDF_KEY} or text.startswith( f"{BTN_ADOPT_PDF_KEY}:" ): return BTN_ADOPT_PDF if text in {BTN_REJECT_KEY} or text.startswith(f"{BTN_REJECT_KEY}:"): return BTN_REJECT if text in {BTN_COPY_TICKET, BTN_COPY_TICKET_KEY} or text.startswith( f"{BTN_COPY_TICKET_KEY}:" ): return BTN_COPY_TICKET if text in {BTN_DEAL, BTN_DEAL_KEY} or text.startswith(f"{BTN_DEAL_KEY}:"): return BTN_DEAL if text in {BTN_LOST, BTN_LOST_KEY} or text.startswith(f"{BTN_LOST_KEY}:"): return BTN_LOST if text in {BTN_NEGOTIATE, BTN_NEGOTIATE_KEY} or text.startswith( f"{BTN_NEGOTIATE_KEY}:" ): return BTN_NEGOTIATE if text == BTN_SKIP_COLLAB_DONE: return "" for btn in (BTN_ADOPT_EXCEL, BTN_ADOPT_PDF, BTN_REJECT, BTN_DEAL, BTN_LOST, BTN_NEGOTIATE): if text == btn: return btn return "" def extract_work_order_no(text: str) -> str: """从任意原话里抽出 WO + 12 位数字;没有则空。不判断意图。""" hit = _WO_NO.search(text or "") return hit.group(0).upper() if hit else "" def card_task_id(kind: str, work_order_no: str) -> str: """ 每次出卡一个新 task_id。企微 42014:同一 task_id 不能再发,会整卡失败只剩文字。 工单号仍嵌在前缀里,点卡回调靠 work_order_from_card_meta 找回。 """ stamp = f"{int(time.time() * 1000):x}{uuid.uuid4().hex[:4]}" return f"{kind}-{work_order_no}-{stamp}" def looks_like_continue_ticket(text: str) -> bool: """ 无网夹具用的弱提示。正式环境必须由大模型判续办意图。 禁止把本函数当成生产唯一路由。 """ raw = (text or "").strip() if not raw: return False return any(token in raw for token in _CONTINUE_HINT) def work_order_from_card_meta( card_meta: dict[str, Any] | None = None, *, text: str = "", ) -> str: """ 从企微点卡回调用找回「点的是哪一张卡当时的工单」。 优先 TaskId / EventKey 里的工单号(quote-WO…、scheme-adopt_excel-WO…)。 对不上返回空:调用方按该销售当前最新书签处理。 """ src = card_meta or {} for raw in ( str(src.get("task_id") or "").strip(), str(src.get("event_key") or "").strip(), (text or "").strip(), ): no = _work_order_in_token(raw) if no: return no return "" def deal_card_version(card_meta: dict[str, Any] | None = None, *, text: str = "") -> int: """从成交卡 EventKey 取出 vN;没有则 0(旧私聊卡)。""" src = card_meta or {} for raw in ( str(src.get("event_key") or ""), str(src.get("task_id") or ""), text or "", ): hit = re.search(r":v(\d+)\s*$", raw) if hit: return int(hit.group(1)) return 0 def _work_order_in_token(raw: str) -> str: """从 TaskId / EventKey 里抽出 WO 工单号;没有则空。""" text = (raw or "").strip() if not text: return "" for prefix in ( f"{BTN_SKIP_COLLAB_KEY}:", f"{BTN_PULL_COLLAB_KEY}:", f"{BTN_ADOPT_EXCEL_KEY}:", f"{BTN_ADOPT_PDF_KEY}:", f"{BTN_REJECT_KEY}:", f"{BTN_COPY_TICKET_KEY}:", f"{BTN_DEAL_KEY}:", f"{BTN_LOST_KEY}:", f"{BTN_NEGOTIATE_KEY}:", ): if text.startswith(prefix): rest = text[len(prefix) :].strip() if rest.upper().startswith("WO"): return extract_work_order_no(rest) or rest.split("-", 1)[0] # 卡片 task_id 会带时间后缀(scheme-WO…-abc),只取 WO+12 位。 return extract_work_order_no(text) _CURRENCY_PREFIX = re.compile(r"^[A-Za-z]{3}\b") def money_text(amount: Any, currency: str = "") -> str: """ 销售可见金额:TMS 有币种就写成「CNY 14669.60」。 金额已带三字码前缀则原样。没有币种不编造 CNY/USD。 """ raw = str(amount if amount not in (None, "") else "").strip() if not raw or raw == "-": return raw or "-" if _CURRENCY_PREFIX.match(raw): return raw code = str(currency or "").strip().upper() if code == "RMB": code = "CNY" if not code: return raw return f"{code} {raw}" def quote_fee_lines(quote: dict[str, Any] | None) -> list[tuple[str, str]]: """ 价格明细行:优先 TMS fee_lines,否则用运费/其它/合计兜底。 返回 (费用名, 金额) ,空金额不展示。金额带 TMS 币种,不编造。 """ src = dict(quote or {}) header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip() raw = src.get("fee_lines") or src.get("fee_rows") parsed: Any = raw if isinstance(raw, str) and raw.strip(): try: parsed = json.loads(raw) except json.JSONDecodeError: parsed = None lines: list[tuple[str, str]] = [] if isinstance(parsed, list): for item in parsed: if not isinstance(item, dict): continue name = str(item.get("name") or item.get("label") or "").strip() amount = str(item.get("amount") or item.get("value") or "").strip() line_ccy = str(item.get("currency") or item.get("currencyCode") or header_ccy).strip() if name and amount and amount != "-": lines.append((name, money_text(amount, line_ccy))) if lines: return lines freight = str(src.get("freight") or "").strip() other = str(src.get("other_fee") or "").strip() if freight and freight != "-": lines.append(("空运费", money_text(freight, header_ccy))) if other and other != "-": lines.append(("其他附加费", money_text(other, header_ccy))) return lines def quote_area_fee_text(quote: dict[str, Any] | None) -> str: """ 卡片引用条正文:费用行 + 总销售价合计。 必须走 quote_text。塞进 quote_area.title 会被企微整段藏掉。 """ src = dict(quote or {}) header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip() total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy) fee_lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)] fee_lines.append(f"总销售价合计:{total}") return "\n".join(fee_lines) # 企微引用条过长会被客户端裁成省略号。海运常有 6~8 项费用,必须走文本明细。 _QUOTE_AREA_SAFE_CHARS = 80 def quote_area_safe_text(quote: dict[str, Any] | None) -> str: """ 卡片引用条:短报价原样;超长只留合计,避免半截费用名。 完整明细由 sea_quote_detail_text 另发一条文本。空运卡仍用 quote_area_fee_text。 """ full = quote_area_fee_text(quote) if len(full) <= _QUOTE_AREA_SAFE_CHARS: return full src = dict(quote or {}) header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip() total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy) n = len(quote_fee_lines(src)) if n: return f"总销售价合计:{total}\n共{n}项费用,详见上一条明细" return f"总销售价合计:{total}" def sea_quote_detail_text( *, work_order_no: str, quote: dict[str, Any], first_or_same: str = "first", history_work_order_no: str = "", status: str = "已报价", ) -> str: """ 海运完整价格明细文本:来源、工单号、费用行、合计。 时效、报价有效期只在卡片上出现,这里不写,避免和卡重复。 不受卡片引用条字数限制。first_or_same 只兼容调用方。 """ _ = first_or_same, history_work_order_no, status src = dict(quote or {}) source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") return "\n".join( [source, f"{work_order_no}价格明细", quote_area_fee_text(src)] ) def collab_hint(work_order_no: str) -> str: """价格卡协同说明:无「内部协同」前缀,紧挨生成报价单按钮上方。""" return ( f"若需要咨询内部航线人员,请手动拉新人与智能体到原有群," f"并在群聊发送工单编号{work_order_no}。" ) def _strip_leading_dash(raw: str) -> str: """去掉时效前导 '-' / '—',避免销售看到「-3-5天」或「-;3-5天」。""" text = (raw or "").strip() while text.startswith(("-", "—", "–")): rest = text[1:].lstrip(" \t;;,,") if not rest: return "" text = rest return text def tms_eta_display(quote: dict[str, Any] | None) -> str: """时效只取 TMS 回的 eta / 预计送达;没有就「-」。禁止本地编。前导横杠会剥掉。""" src = dict(quote or {}) eta = _strip_leading_dash(str(src.get("eta") or "")) if eta: return eta dated = _strip_leading_dash( str(src.get("eta_date") or src.get("estimated_delivery") or "") ) return dated or "-" def tms_valid_until_display(quote: dict[str, Any] | None) -> str: """报价有效期只取 TMS 原文,没有就 "-"。""" src = dict(quote or {}) raw = str( src.get("valid_until") or src.get("validUntil") or src.get("quoteValidUntil") or "" ).strip() return raw if raw else "-" def similar_inquiry_line(*, first_or_same: str, history_work_order_no: str = "") -> str: """ 相同询价内容检索文案(主账仍会算,销售侧价格卡已隐藏,不再调用)。 """ no = (history_work_order_no or "").strip() if first_or_same == "same" and no: return f"相同询价内容检索:引用历史工单号{no}" return "相同询价内容检索:首次询价" def quote_detail_lines( quote: dict[str, Any] | None, *, first_or_same: str = "first", history_work_order_no: str = "", status: str = "已报价", ) -> list[str]: """ 价格明细正文:TMS 费用、合计、时效、有效期。 给文本兜底和企微引用条共用,禁止在这里编时效/有效期。 价格卡不展示工单状态,也不展示相同询价内容检索。 """ _ = status, first_or_same, history_work_order_no src = dict(quote or {}) header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip() total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy) lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)] lines.append(f"总销售价合计:{total}") lines.append(f"时效:{tms_eta_display(src)}") lines.append(f"报价有效期:{tms_valid_until_display(src)}") return lines def tms_hit_card( *, work_order_no: str, quote: dict[str, Any], first_or_same: str = "first", history_work_order_no: str = "", status: str = "已报价", ) -> str: """ 有价后的价格明细(文本兜底,模板卡发失败时仍给销售看)。 顺序:来源 → 价格明细 → 报价正文 → 协同说明 → 按钮。 """ src = dict(quote or {}) source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") lines = [source, f"{work_order_no}价格明细"] lines.extend( quote_detail_lines( src, first_or_same=first_or_same, history_work_order_no=history_work_order_no, status=status, ) ) lines.append("") lines.append(collab_hint(work_order_no)) lines.append(BTN_SKIP_COLLAB) return "\n".join(lines) def tms_hit_wecom_payload( *, work_order_no: str, quote: dict[str, Any], first_or_same: str = "first", history_work_order_no: str = "", status: str = "已报价", ) -> dict[str, Any]: """ 一张价格卡,销售侧顺序: 价格明细 ┌ 引用条:空运费 / 其它费用 / 总销售价合计 时效、报价有效期 协同说明(普通文字,贴按钮上方) 按钮 相同询价内容检索对销售隐藏;first_or_same 只兼容调用方,不展示。 企微 quote_area.title 只能很短,超长会被整段藏掉。报价必须放 quote_text。 不用 horizontal:它会排到副标题后面,把费用挤到按钮前。 """ _ = first_or_same, history_work_order_no src = dict(quote or {}) source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") meta = "\n".join( [ f"时效:{tms_eta_display(src)}", f"报价有效期:{tms_valid_until_display(src)}", "", collab_hint(work_order_no), ] ) return { "msgtype": "template_card", "template_card": { "card_type": "button_interaction", "source": {"desc": source, "desc_color": 0}, "main_title": {"title": f"{work_order_no}价格明细"}, "quote_area": { "type": 0, "quote_text": quote_area_fee_text(src), }, "sub_title_text": meta, "task_id": card_task_id("quote", work_order_no), "button_list": [ { "text": BTN_SKIP_COLLAB, "style": 1, # EventKey 也带工单号:TaskId 偶发缺失时仍能找回当时那一单 "key": f"{BTN_SKIP_COLLAB_KEY}:{work_order_no}", } ], }, } def skip_collab_card_update(*, response_code: str, replace_name: str = "") -> dict[str, Any]: """ 点过模板卡后更新原卡:被点按钮改成已提交,整卡置灰不可再点。 必须带企微回调 ResponseCode;无码则不要入出站。 成交卡三个按钮共用这一条,replace_name 默认「已提交」。 """ return { "msgtype": "update_template_card", "response_code": response_code, "button": {"replace_name": (replace_name or BTN_SKIP_COLLAB_DONE).strip() or BTN_SKIP_COLLAB_DONE}, } def tms_miss_quote_text(*, work_order_no: str) -> str: """文字兜底里的拉群句,与现网图一致。""" return f"请手动拉相关人员和智能体到原有群,并在群聊发送工单编号{work_order_no}" def tms_miss_hint() -> str: """卡片标题下灰字:与现网图一致。""" return "TMS 标准价库暂无匹配报价" def tms_miss_body() -> str: """卡片中间说明:需求已建单。""" return "需求已解析并创建工单。" def tms_miss(*, work_order_no: str) -> str: """ 卡片发送失败时的短兜底。拉群句另发,不写在这里。 禁止在没打 TMS 前用本句冒充无价。 """ no = (work_order_no or "").strip() return f"询价工单{no}已建立,TMS标准价库暂无匹配报价。" def tms_miss_followup(*, work_order_no: str) -> str: """出卡后再发的独立一句:拉人进群并带上工单号。""" return tms_miss_quote_text(work_order_no=work_order_no) def copy_ticket_text(*, work_order_no: str) -> str: """ 点「复制工单号」后回给销售的正文:只含工单号。 企微模板卡按钮不能写系统剪贴板。整条消息只有工单号,销售长按「复制」不会夹杂其它字。 """ return (work_order_no or "").strip() def tms_miss_wecom_payload( *, work_order_no: str, action_url: str = "", copy_url: str = "", ) -> dict[str, Any]: """ TMS 已查过、确认没价:一张文本通知卡(对齐现网图)。 询价工单号 / 灰字无价说明 / 建单说明 / 工单状态与查价结果。 不要「来源」行、不要按钮。企微 text_notice 必须带可信域名 card_action。 copy_url 忽略:不再跳复制页。 """ _ = copy_url no = (work_order_no or "").strip() card: dict[str, Any] = { "card_type": "text_notice", "source": {"desc": "TMS查价结果", "desc_color": 0}, "main_title": { "title": f"询价工单 {no}", "desc": tms_miss_hint(), }, "sub_title_text": tms_miss_body(), "horizontal_content_list": [ {"keyname": "工单状态", "value": "询价中"}, {"keyname": "查价结果", "value": "暂无匹配报价"}, ], "task_id": card_task_id("miss", no), } url = (action_url or "").strip() if url: card["card_action"] = {"type": 1, "url": url} return {"msgtype": "template_card", "template_card": card} def tms_contract_gap(*, work_order_no: str) -> str: """已废弃:不再因字典对不上拦查价;保留以免旧单测/引用报错。""" return tms_miss(work_order_no=work_order_no) def tms_need_more(*, work_order_no: str, hint: str = "") -> str: """已废弃:TMS 回 1003 按合同收成暂无报价。保留以免旧引用报错。""" _ = hint return tms_miss(work_order_no=work_order_no) def skip_collab_ack() -> str: return "好的,已跳过内部协同。正在按 TMS 标准报价生成报价单草稿…" def sea_collab_ask() -> str: """海运有价卡协同问句。""" return "是否拉产品人员进群协同?" def sea_miss_ask() -> str: """海运无价卡协同问句。""" return "未查到合适报价,是否需要拉内部产品人员进群,进行手工协同报价?" def sea_tms_hit_card( *, work_order_no: str, quote: dict[str, Any], first_or_same: str = "first", history_work_order_no: str = "", status: str = "已报价", ) -> str: """海运有价:价格明细 + 拉群问句 + 两个按钮。空运卡片函数保持不动。""" src = dict(quote or {}) source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") lines = [source, f"{work_order_no}价格明细"] lines.extend( quote_detail_lines( src, first_or_same=first_or_same, history_work_order_no=history_work_order_no, status=status, ) ) lines.append("") lines.append(sea_collab_ask()) lines.append(BTN_PULL_COLLAB) lines.append(BTN_SKIP_COLLAB) return "\n".join(lines) def sea_tms_hit_wecom_payload( *, work_order_no: str, quote: dict[str, Any], first_or_same: str = "first", history_work_order_no: str = "", status: str = "已报价", ) -> dict[str, Any]: """海运有价企微卡:价格明细 + 问句 + 拉群 / 跳过协同。""" _ = first_or_same, history_work_order_no, status src = dict(quote or {}) source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") meta = "\n".join( [ f"时效:{tms_eta_display(src)}", f"报价有效期:{tms_valid_until_display(src)}", "", sea_collab_ask(), ] ) return { "msgtype": "template_card", "template_card": { "card_type": "button_interaction", "source": {"desc": source, "desc_color": 0}, "main_title": {"title": f"{work_order_no}价格明细"}, "quote_area": { "type": 0, "quote_text": quote_area_safe_text(src), }, "sub_title_text": meta, "task_id": card_task_id("quote", work_order_no), "button_list": [ { "text": BTN_PULL_COLLAB, "style": 1, "key": f"{BTN_PULL_COLLAB_KEY}:{work_order_no}", }, { "text": BTN_SKIP_COLLAB, "style": 1, "key": f"{BTN_SKIP_COLLAB_KEY}:{work_order_no}", }, ], }, } def sea_tms_miss(*, work_order_no: str) -> str: """海运无价文字兜底:工单号、暂无匹配、询价中、拉群问句。""" no = (work_order_no or "").strip() return "\n".join( [ f"询价工单 {no}", "暂无匹配报价", "工单状态:询价中", sea_miss_ask(), BTN_PULL_COLLAB, ] ) def sea_tms_miss_wecom_payload(*, work_order_no: str, action_url: str = "") -> dict[str, Any]: """ 海运无价一张卡:工单号、暂无匹配、询价中、拉群问句、一个按钮。 禁止再写「请手动拉人到原有群」。 """ no = (work_order_no or "").strip() card: dict[str, Any] = { "card_type": "button_interaction", "source": {"desc": "TMS查价结果", "desc_color": 0}, "main_title": { "title": f"询价工单 {no}", "desc": "暂无匹配报价", }, "sub_title_text": "\n".join( [ "需求已解析并创建工单。", sea_miss_ask(), ] ), "horizontal_content_list": [ {"keyname": "工单状态", "value": "询价中"}, {"keyname": "查价结果", "value": "暂无匹配报价"}, ], "task_id": card_task_id("miss", no), "button_list": [ { "text": BTN_PULL_COLLAB, "style": 1, "key": f"{BTN_PULL_COLLAB_KEY}:{no}", } ], } url = (action_url or "").strip() if url: card["card_action"] = {"type": 1, "url": url} return {"msgtype": "template_card", "template_card": card} def _role_label(name: str, role: str) -> str: """姓名前加岗位,避免「销售甲、乙」被读成两人都是销售。已带岗位前缀的不再重复加。""" text = (name or "").strip() if not text: return role if text.startswith(role): return text return f"{role}{text}" def sea_group_created(*, work_order_no: str, sales_name: str, product_names: list[str]) -> str: """拉群成功后的私聊。已邀请写成「销售某某、产品某某」,岗位不能省略。""" no = (work_order_no or "").strip() sales = _role_label(sales_name, "销售") products = "、".join( _role_label(n, "产品") for n in (product_names or []) if (n or "").strip() ) or "产品" return ( f"协同群已创建,群名与工单号一致:{no}。" f"已邀请:{sales}、{products}。请在群聊中继续确认报价。" ) def sea_group_exists(*, work_order_no: str) -> str: """同一工单已有群,不再新建。""" no = (work_order_no or "").strip() return f"协同群已在,群名与工单号一致:{no}。请到该群继续确认报价。" def sea_group_no_staff() -> str: """没有可拉的产品人员,不建空群。""" return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的海运产品岗后再试。" def group_sheet_send_fail() -> str: """模板已填好但没发进群。""" return "报价单已生成,发到群里失败,请稍后重试。" def sea_group_create_fail(*, reason: str = "") -> str: """建群失败,按钮保持可点。""" extra = (reason or "").strip() if extra: return f"建群失败:{extra}。请稍后重试。" return "建群失败,请稍后重试。" def quote_is_product(quote: dict[str, Any] | None) -> bool: """来源是产品/航线人工,不能写进 TMS 报价段。""" label = str((quote or {}).get("source_label") or (quote or {}).get("source") or "") return "产品" in label or "航线" in label def sea_group_brief( *, work_order_no: str, facts: dict[str, str], quote: dict[str, Any] | None, collab_facts: dict[str, str] | None, sales_name: str, product_names: list[str], has_price: bool, tms_quote: dict[str, Any] | None = None, product_quote: dict[str, Any] | None = None, transport_mode: str = "海运", ) -> str: """ 群里自动摘要:询价 + 协同 + TMS 报价 + 人工报价。 TMS 无价只写「TMS 暂无匹配报价」,产品报的价单独放人工报价,不能混进 TMS。 陆运传入 transport_mode=陆运,始发站/货物字段跟核对卡同一套。 """ no = (work_order_no or "").strip() sales = (sales_name or "").strip() or "销售" products = [n.strip() for n in (product_names or []) if (n or "").strip()] at_bits = [f"@{sales}"] + [f"@{n}" for n in products] mode = (transport_mode or "海运").strip() or "海运" lines = [" ".join(at_bits), f"工单号:{no}", "询价字段:"] lines.extend( inquiry_field_lines(work_order_no=no, facts=facts, transport_mode=mode)[1:] ) lines.append("") lines.append("协同补充字段:") collab = dict(collab_facts or {}) line = "LAND" if mode == "陆运" else "SEA" collab_keys = collab_fields_for_ticket(facts, line) or SEA_COLLAB_FIELDS for key in collab_keys: label = collab_display_name(key, line) val = (collab.get(key) or collab.get(label) or "").strip() or "-" lines.append(f"{label}:{val}") tms = dict(tms_quote) if tms_quote is not None else {} manual = dict(product_quote or {}) if tms_quote is None: src = dict(quote or {}) if has_price and src and not quote_is_product(src): tms = src elif quote_is_product(src): manual = manual or src lines.append("") lines.append("TMS报价:") if tms and (tms.get("total") or quote_fee_lines(tms)): lines.extend(quote_detail_lines(tms)) lines.append(group_tms_confirm_hint()) else: lines.append("TMS 暂无匹配报价") if manual and (quote_fee_lines(manual) or str(manual.get("total") or "").strip() not in {"", "-"}): lines.append("") lines.append("人工报价:") fee_lines = quote_fee_lines(manual) if fee_lines: lines.extend(f"{name}:{amount}" for name, amount in fee_lines) else: header_ccy = str(manual.get("currency") or "").strip() lines.append(f"合计:{money_text(str(manual.get('total') or '-'), header_ccy)}") return "\n".join(lines) def scheme_card( *, work_order_no: str, status: str, facts: dict[str, str], quote: dict[str, Any], transport_mode: str = "空运", ) -> str: """ 报价方案文本兜底(模板卡发失败时仍给销售看)。 与卡片同一套字段,禁止漏项。不再展示工单状态。 """ _ = status src = dict(quote or {}) # 方案卡不展示询价「报价日期」,改展示 TMS 报价有效期 body = "\n".join( line for line in inquiry_field_lines( work_order_no=work_order_no, facts=facts, transport_mode=transport_mode ) if not line.startswith("报价日期:") ) eta = tms_eta_display(src) valid = tms_valid_until_display(src) lines = [ f"【{work_order_no}报价方案】", body, "价格明细", quote_area_fee_text(src), f"时效:{eta or '-'}", f"报价有效期:{valid}", "请确认是否采用该报价方案:", f"【{BTN_ADOPT_EXCEL_CARD}】", f"【{BTN_ADOPT_PDF_CARD}】", f"【{BTN_REJECT}】", ] return "\n".join(lines) def _clip(raw: str, limit: int) -> str: """企微列表格有字数建议;超长才裁,避免空白。""" text = (raw or "").strip() or "-" return text if len(text) <= limit else text[: limit - 1] + "…" def scheme_transport_mode(business_line: str = "", fallback: str = "空运") -> str: """会话业务线 → 方案卡运输方式。海运不得再写成空运。""" raw = (business_line or "").strip() line = raw.upper() if line == "SEA" or raw == "海" or "海运" in raw: return "海运" if line == "LAND" or raw == "陆" or "陆运" in raw: return "陆运" if line == "AIR" or raw == "空" or "空运" in raw: return "空运" return (fallback or "空运").strip() or "空运" def scheme_horizontal_rows( *, work_order_no: str, facts: dict[str, str], quote: dict[str, Any], transport_mode: str = "空运", ) -> list[dict[str, str]]: """ 卡片字段:一行一项,每格独立字数,不会像副标题整段截成 8CB…。 企微 horizontal 最多 6 条。运输方式写进行程行,避免丢字段。 海运用货物数量/整柜或拼柜/箱型箱量,禁止套空运件数毛重体积包装。 陆运用运输类型/线路类别/体积重量或车型数量,禁止套海运箱型或空运件数包装。 不用 sub_title 堆字段(它会排到列表前面,且超长必截断)。 """ src = facts or {} q = dict(quote or {}) origin = (src.get("起运港") or "-").strip() or "-" dest = (src.get("目的港") or "-").strip() or "-" cargo = (src.get("品名") or "-").strip() or "-" mode = (transport_mode or "空运").strip() or "空运" if mode == "海运": qty = (src.get("货量") or src.get("货物数量") or "-").strip() or "-" load = (src.get("整柜或拼柜") or "-").strip() or "-" box = (src.get("箱型箱量") or "-").strip() or "-" return [ {"keyname": "工单号", "value": _clip(work_order_no, 26)}, {"keyname": "航线", "value": _clip(f"海运 {origin}→{dest}", 26)}, {"keyname": "货物", "value": _clip(f"{cargo};{qty}", 26)}, {"keyname": "箱型", "value": _clip(f"{load};{box}", 26)}, {"keyname": "时效", "value": _clip(tms_eta_display(q), 26)}, {"keyname": "报价有效期", "value": _clip(tms_valid_until_display(q), 26)}, ] if mode == "陆运": return [ {"keyname": "工单号", "value": _clip(work_order_no, 26)}, {"keyname": "线路", "value": _clip(f"陆运 {origin}→{dest}", 26)}, {"keyname": "类型", "value": _clip(_land_scheme_type_line(src), 26)}, {"keyname": "货物", "value": _clip(_land_scheme_goods_line(src), 26)}, {"keyname": "时效", "value": _clip(tms_eta_display(q), 26)}, {"keyname": "报价有效期", "value": _clip(tms_valid_until_display(q), 26)}, ] pieces = _with_unit(str(src.get("件数") or ""), "件") weight = _with_unit(str(src.get("毛重") or ""), "KGS") volume = _with_unit(str(src.get("体积") or ""), "CBM") pack = (src.get("包装方式") or src.get("包装类型") or "-").strip() or "-" return [ {"keyname": "工单号", "value": _clip(work_order_no, 26)}, {"keyname": "航线", "value": _clip(f"空运 {origin}→{dest}", 26)}, {"keyname": "货物", "value": _clip(f"{cargo};{pieces};{weight};{volume}", 26)}, {"keyname": "包装", "value": _clip(pack, 26)}, {"keyname": "时效", "value": _clip(tms_eta_display(q), 26)}, {"keyname": "报价有效期", "value": _clip(tms_valid_until_display(q), 26)}, ] def _land_scheme_type_line(facts: dict[str, str]) -> str: """方案卡「类型」格:运输类型、线路类别、运输分类。""" src = facts or {} land_type = (src.get("运输类型") or "-").strip() or "-" bits = [land_type] route = (src.get("线路类别") or "-").strip() or "-" klass = (src.get("运输分类") or "-").strip() or "-" bits.extend([route, klass]) return ";".join(bits) def _land_scheme_goods_line(facts: dict[str, str]) -> str: """方案卡「货物」格:品名 + 当前类型的体积重量或车型/数量。""" src = facts or {} land_type = (src.get("运输类型") or "").strip() bits: list[str] = [(src.get("品名") or "-").strip() or "-"] for key in _land_cargo_keys(land_type, src): if key in {"起运港", "目的港", "品名", "报价日期"}: continue val = (src.get(key) or "").strip() if val: bits.append(val) if land_type in _LAND_CUSTOMS_TYPES: customs = (src.get("通关口岸") or "").strip() if customs: bits.append(customs) return ";".join(bits) def scheme_wecom_payload( *, work_order_no: str, facts: dict[str, str], quote: dict[str, Any], status: str = "已报价", transport_mode: str = "空运", ) -> dict[str, Any]: """ 一张报价方案卡:标题带工单号、引用条、字段、三个短按钮。 按钮文案必须短:生成Excel / 生成PDF / 不采用。长文案横排会被裁。 海运/空运/陆运字段分开,禁止陆运卡再出箱型或空运件数包装。 """ _ = status src = dict(quote or {}) source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") return { "msgtype": "template_card", "template_card": { "card_type": "button_interaction", "source": {"desc": source, "desc_color": 0}, "main_title": {"title": f"{work_order_no}报价方案"}, "quote_area": { "type": 0, "quote_text": ( quote_area_safe_text(src) if (transport_mode or "").strip() == "海运" else quote_area_fee_text(src) ), }, "horizontal_content_list": scheme_horizontal_rows( work_order_no=work_order_no, facts=facts, quote=src, transport_mode=transport_mode, ), "task_id": card_task_id("scheme", work_order_no), "button_list": [ { "text": BTN_ADOPT_EXCEL_CARD, "style": 1, "key": f"{BTN_ADOPT_EXCEL_KEY}:{work_order_no}", }, { "text": BTN_ADOPT_PDF_CARD, "style": 1, "key": f"{BTN_ADOPT_PDF_KEY}:{work_order_no}", }, { "text": BTN_REJECT, "style": 2, "key": f"{BTN_REJECT_KEY}:{work_order_no}", }, ], }, } def reject_ack() -> str: return "请问如何调整报价单,以满足您的需求?您可使用「报价加 10%」等调价指令。" def adopt_excel_ack() -> str: return "已确认采用该方案,正在生成 Excel 最终报价单…" def adopt_pdf_ack() -> str: return "已确认采用该方案,正在生成PDF最终报价单…" QUOTE_TEMPLATE_MISSING = "生成报价单失败,请联系IT运维维护后台报价模板。" def quote_template_missing() -> str: """关键词与默认模板都未维护时的销售提示(产品口径,勿改字)。""" return QUOTE_TEMPLATE_MISSING def file_ready(*, kind: str, work_order_no: str, filename: str) -> str: return f"已生成{kind}最终报价单:{filename}(工单 {work_order_no})" DEAL_HINT = "咨询成交状态:请选择成交 / 未成交 / 协商中(未成交需补充原因)。" DEAL_SUBTITLE = "报价单已发送,请跟进成交状态" def deal_horizontal_rows( *, quote: dict[str, Any] | None, facts: dict[str, str] | None = None, ) -> list[dict[str, str]]: """ 成交跟进卡字段:总销售价合计、时效、报价有效期。 不要历史报价 / 工单状态 / 来源。来源只留卡片顶部红字。 """ src = dict(quote or {}) header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip() total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy) return [ {"keyname": "总销售价合计", "value": _clip(total, 26)}, {"keyname": "时效", "value": _clip(tms_eta_display(src), 26)}, {"keyname": "报价有效期", "value": _clip(tms_valid_until_display(src), 26)}, ] def deal_card( *, work_order_no: str, status: str = "", summary: str = "", quote: dict[str, Any] | None = None, facts: dict[str, str] | None = None, ) -> str: """ 成交跟进文字兜底(模板卡发失败时仍给销售看)。 与卡片同一套字段;status / summary 只兼容旧调用,不再展示。 """ _ = status, summary no = (work_order_no or "").strip() rows = deal_horizontal_rows(quote=quote, facts=facts) lines = [ f"询价工单 {no}", DEAL_SUBTITLE, DEAL_HINT, ] lines.extend(f"{row['keyname']}:{row['value']}" for row in rows) lines.append(f"【{BTN_DEAL}】【{BTN_LOST}】【{BTN_NEGOTIATE}】") return "\n".join(lines) def deal_wecom_payload( *, work_order_no: str, quote: dict[str, Any] | None = None, facts: dict[str, str] | None = None, card_version: int = 0, ) -> dict[str, Any]: """ 成交跟进卡:对齐现网 button_interaction。 顶部来源红字;标题询价工单号;副标题跟进成交;三个按钮。 字段只要总销售价、时效、有效期。 """ src = dict(quote or {}) source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") no = (work_order_no or "").strip() ver = int(card_version or 0) suffix = f":v{ver}" if ver > 0 else "" return { "msgtype": "template_card", "template_card": { "card_type": "button_interaction", "source": {"desc": source, "desc_color": 0}, "main_title": { "title": f"询价工单 {no}", "desc": DEAL_SUBTITLE, }, "sub_title_text": DEAL_HINT, "horizontal_content_list": deal_horizontal_rows(quote=src, facts=facts), "task_id": card_task_id("deal", no), "button_list": [ { "text": BTN_DEAL, "style": 1, "key": f"{BTN_DEAL_KEY}:{no}{suffix}", }, { "text": BTN_LOST, "style": 1, "key": f"{BTN_LOST_KEY}:{no}{suffix}", }, { "text": BTN_NEGOTIATE, "style": 1, "key": f"{BTN_NEGOTIATE_KEY}:{no}{suffix}", }, ], }, } def ticket_locked(*, work_order_no: str, status: str) -> str: """已成交/未成交/已关闭后,群里改字段或再报价。""" no = (work_order_no or "").strip() or "该工单" st = (status or "").strip() or "已关闭" return f"工单{no}{st},不能修改工单信息" def ticket_locked_file(*, work_order_no: str, status: str) -> str: """终态工单再发 Excel/PDF。""" no = (work_order_no or "").strip() or "该工单" st = (status or "").strip() or "已关闭" return f"工单{no}{st},不能再上传附件" def group_only_excel_pdf() -> str: return "群里只收 Excel 和 PDF,其它文件不会存入工单" def group_only_product_can_quote() -> str: return "只有产品能报价格,请产品在群里发价格或 Excel/PDF" def group_tms_confirm_hint() -> str: """报价有效期下方:有 TMS 价时告诉产品还可以确认沿用。""" return "有TMS报价时可回复“确认报价”沿用该价;也可直接回复协同报价或上传报价附件。" def quote_attachment_saved_app(work_order_no: str, count: int = 1) -> str: """群里告诉大家:模板或产品附件已进后台。""" no = (work_order_no or "").strip() or "WO" n = count if count > 0 else 1 return f"工单{no}的{n}个附件已保存到后台工单" def group_confirm_need_tms() -> str: return "本单还没有可沿用的 TMS 报价,请直接回复协同报价或上传报价附件。" def group_ask_tms_or_product() -> str: return ( "本单已有 TMS 报价。\n" "1. 要在 TMS 基础上改,请回复:在TMS基础上改(或直接回 1 / 第一个)\n" "2. 不要 TMS 报价、只用你刚说的价格,请回复:不用TMS报价(或直接回 2 / 第二个)" ) def group_tms_choice_unclear() -> str: return "还没确定怎么用 TMS 报价。请回复:在TMS基础上改 / 不用TMS报价,也可以直接回 1 或 2。" def group_need_price_before_choice() -> str: return "请产品先发价格,再说在TMS基础上改或不用TMS报价" def group_file_saved() -> str: return "附件已保存后台" def group_quote_not_recognized() -> str: return "没有识别到报价,请产品再发一次价格或 Excel/PDF" def group_fields_not_recognized( keys: tuple[str, ...] | None = None, business_line: str = "", ) -> str: """没认出协同字段。陆运列出本单可补项;海运保持原六项口。""" if keys: line = (business_line or "").strip().upper() or "SEA" names = [collab_display_name(k, line) for k in keys] return "听到了。请直接说" + "、".join(names) + "。" return "听到了。请直接说贸易条款、货好时间、商品海关编码、是否含油/含电/含磁。" def group_deal_stale(*, work_order_no: str) -> str: """点了上一版成交卡。""" no = (work_order_no or "").strip() return f"这张成交确认已过期。工单{no}已有更新的报价,请点击群里最新一条成交跟进。" def group_deal_need_quote() -> str: return "还没有可确认的报价,请先完成报价再确认成交状态。" def group_deal_sales_only() -> str: return "只有负责该工单的销售可以确认成交状态。" def group_dissolved(*, work_order_no: str) -> str: no = (work_order_no or "").strip() return f"群已解散。工单{no}协同已结束。" def group_cannot_recreate(*, work_order_no: str) -> str: """解散后再点拉群。""" no = (work_order_no or "").strip() or "该工单" return f"工单{no}协同已结束,不能再拉群。" def group_deal_followup(*, sales_name: str, work_order_no: str) -> str: """ 群里文字成交跟进。只提示输入,不含报价合计、不含点选按钮。 私聊成交卡仍走 deal_card,不改这里。 """ name = (sales_name or "").strip() or "销售" no = (work_order_no or "").strip() return ( f"@{name}\n" f"询价工单 {no}\n" "报价单已发送,请确认成交状态\n" "咨询成交状态:请输入成交 / 未成交 / 协商中(未成交需补充原因)" ) def group_deal_mention(*, sales_name: str, work_order_no: str) -> str: return group_deal_followup(sales_name=sales_name, work_order_no=work_order_no) def deal_ack(outcome: str, work_order_no: str) -> str: if outcome == "已成交": return f"已记录为「已成交」。工单 {work_order_no} 已关闭智能体跟进。" if outcome == "未成交": return "已记录为「未成交」。请补充未成交原因。" return f"已记录为「协商中」。工单 {work_order_no} 跟进周期已顺延。" def air_need_work_order() -> str: """空运群还没有当前单,且这句话里没有工单号。""" return "请先 @我 并发送要跟的工单号,例如 WO202609160001。" def air_lock_need_work_order() -> str: """锁舱/释放必须自带工单号,不猜当前单。""" return "锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。" def air_ticket_not_found(work_order_no: str) -> str: no = (work_order_no or "").strip() return f"找不到工单{no},请核对工单号后再试。" def air_ticket_not_air(work_order_no: str) -> str: no = (work_order_no or "").strip() return f"工单{no}不是空运单,不能在空运群里跟。" def air_ticket_closed(work_order_no: str, status: str) -> str: no = (work_order_no or "").strip() st = (status or "").strip() or "已关闭" return f"工单{no}已是「{st}」,不能再激活。" def air_ticket_occupied(work_order_no: str) -> str: no = (work_order_no or "").strip() return f"工单{no}已经在另一个群跟了,请到那个群继续。" def air_group_brief( *, work_order_no: str, facts: dict[str, str], quote: dict[str, Any] | None, collab_facts: dict[str, str] | None, has_price: bool, tms_quote: dict[str, Any] | None = None, product_quote: dict[str, Any] | None = None, sales_name: str = "", airline_names: list[str] | None = None, ) -> str: """ 空运群激活摘要:与海运进群同一套结构,字段用空运。 先 @ 销售和航线,再工单号 + 询价字段 + 协同补充(含电/含磁)+ TMS报价。 不写「当前工单/询价要点/现在等到哪一步」。 """ no = (work_order_no or "").strip() at_bits: list[str] = [] sales = (sales_name or "").strip() if sales: at_bits.append(f"@{sales}") for name in airline_names or []: token = f"@{(name or '').strip()}" if (name or "").strip() and token not in at_bits: at_bits.append(token) lines = ([(" ".join(at_bits))] if at_bits else []) + [f"工单号:{no}", "询价字段:"] lines.extend( inquiry_field_lines(work_order_no=no, facts=facts, transport_mode="空运")[1:] ) lines.append("") lines.append("协同补充字段:") collab = dict(collab_facts or {}) for key in AIR_COLLAB_FIELDS: val = (collab.get(key) or "").strip() or "-" lines.append(f"{key}:{val}") tms = dict(tms_quote) if tms_quote is not None else {} manual = dict(product_quote or {}) if tms_quote is None: src = dict(quote or {}) if has_price and src and not quote_is_product(src): tms = src elif quote_is_product(src): manual = manual or src lines.append("") lines.append("TMS报价:") if tms and (tms.get("total") or quote_fee_lines(tms)): lines.extend(quote_detail_lines(tms)) lines.append(group_tms_confirm_hint()) else: lines.append("TMS 暂无匹配报价") if manual and (quote_fee_lines(manual) or str(manual.get("total") or "").strip() not in {"", "-"}): lines.append("") lines.append("人工报价:") fee_lines = quote_fee_lines(manual) if fee_lines: lines.extend(f"{name}:{amount}" for name, amount in fee_lines) else: header_ccy = str(manual.get("currency") or "").strip() lines.append(f"合计:{money_text(str(manual.get('total') or '-'), header_ccy)}") return "\n".join(lines) def air_activate_summary( *, work_order_no: str, facts: dict, status: str = "", wait_hint: str = "", quote: dict[str, Any] | None = None, collab_facts: dict[str, str] | None = None, ) -> str: """兼容旧名:激活成功改走海运同款群摘要。""" _ = status, wait_hint src = dict(quote or {}) return air_group_brief( work_order_no=work_order_no, facts=dict(facts or {}), quote=src, collab_facts=collab_facts, has_price=bool(src.get("total") or src.get("quoteId")), tms_quote=src if src and not quote_is_product(src) else {}, product_quote=src if quote_is_product(src) else {}, ) def air_wait_hint(*, status: str, has_quote: bool, pending_lock: bool = False) -> str: """激活摘要里的「现在等到哪一步」。""" if pending_lock: return "等待航线选择要锁的舱位选项" st = (status or "").strip() if st == "协商中": return "协商中,可继续改价或确认成交" if has_quote or st == "已报价": return "已有报价,可补含电/含磁、航线改价、锁舱或确认成交" return "等待航线报价或锁舱(须先有 TMS 报价)" def air_airline_only_quote() -> str: return "只有岗位为「航线·空运」的人能报价格。" def air_airline_only_cabin() -> str: return "只有岗位为「航线·空运」的人能锁舱或释放舱位。" def air_sales_only_deal() -> str: return "只有负责该工单的销售可以确认成交状态。" def air_fields_not_recognized() -> str: return "听到了。空运群里可补充是否含电、是否含磁。" def air_quote_not_recognized() -> str: return "没有识别到报价,请航线再发一次价格或 Excel/PDF。" def air_fields_denied() -> str: return "只有这张单的销售或航线可以补含电/含磁。" def air_no_tms_quote_to_lock(work_order_no: str) -> str: no = (work_order_no or "").strip() return f"工单{no}还没有可锁的 TMS 报价,不能锁舱。" def air_ask_lock_option(options: list) -> str: lines = ["这张单有多条舱位选项,请回复要锁哪一条,例如 AIR-OPT-01:"] for item in options: no = str(item.get("optionNo") or item.get("option_no") or "").strip() airline = str(item.get("airlineName") or item.get("airlineCode") or "").strip() if no and airline: lines.append(f"{no} {airline}") elif no: lines.append(no) return "\n".join(lines) def air_lock_ok( *, work_order_no: str, operator: str, option_no: str, exec_no: str, quote_no: str, ) -> str: return ( f"工单{work_order_no} 已通知TMS执行空运锁舱,执行成功。\n" f"操作人:{operator}\n" f"舱位选项:{option_no}\n" f"执行编号:{exec_no}\n" f"报价编号:{quote_no}" ) def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str: why = (reason or "").strip() or "TMS未返回原因" return ( f"工单{work_order_no} 空运锁舱失败。\n" f"操作人:{operator}\n" f"锁舱失败的原因:{why}\n" "已通知IT运维排查,请稍等。" ) def air_release_ok( *, work_order_no: str, operator: str, option_no: str, exec_no: str, quote_no: str, ) -> str: return ( f"工单{work_order_no} 已通知TMS执行空运释放舱位成功\n" f"操作人:{operator}\n" f"舱位选项:{option_no}\n" f"执行编号:{exec_no}\n" f"报价编号:{quote_no}" ) def air_release_fail(*, work_order_no: str, operator: str, reason: str) -> str: why = (reason or "").strip() or "TMS未返回原因" return ( f"工单{work_order_no} 空运释放舱位失败\n" f"操作人:{operator}\n" f"释放舱位失败的原因:{why}" ) def air_release_deal_blocked(work_order_no: str) -> str: no = (work_order_no or "").strip() return f"释放舱位失败,工单{no}已成交。" def air_release_need_lock(work_order_no: str) -> str: no = (work_order_no or "").strip() return f"工单{no}还没有有效锁舱,不能释放。"