""" 调价执行:私聊出新卡,群里出全量文本 + Excel + 成交跟进。 本文件职责:资格、抽项、落主账、出站。算价只调用 schema.quote_adjust。 禁止:回调线程同步跑;模型改价;群里出 PDF。 """ from __future__ import annotations import logging from typing import Any, Optional from agent.policy import inquiry_copy as copy from agent.schema.quote_adjust import ( adjustable_rows, apply_adjust, extract_adjust, private_session_can_adjust, quote_fee_rows, ) logger = logging.getLogger(__name__) _ADJUST_PHASES = frozenset({"wait_collab", "wait_adopt", "wait_adjust", "wait_deal"}) def looks_like_adjust(text: str) -> bool: """本地兜底:正式认调价走 DeepSeek A,这里只给无网单测。""" return extract_adjust(text, allow_model=False).is_adjust def session_quoted(sess: Any) -> bool: """当前书签是否处在可尝试调价的阶段。""" if sess is None: return False phase = str(getattr(sess, "phase", "") or "") status = str(getattr(sess, "status", "") or "") if phase in _ADJUST_PHASES: return True if status == "协商中" and (getattr(sess, "quote", None) or getattr(sess, "work_order_no", "")): return True return False def quoted_adjust_flow(sender_id: str): """ 三条线里谁有可调报价就交给谁;多条时用最新工单号。 私聊调价常被判成闲聊,选线不能再按「有海运书签就走海运」, 否则陆运刚出的卡会被旧海运书签抢走、回开场白。 """ from agent.policy.air_text_flow import get_air_text_flow from agent.policy.land_text_flow import get_land_text_flow from agent.policy.sea_text_flow import get_sea_text_flow hits: list[tuple[str, Any]] = [] for flow in (get_land_text_flow(), get_air_text_flow(), get_sea_text_flow()): sess = flow.session_of(sender_id) if session_quoted(sess): hits.append((str(getattr(sess, "work_order_no", "") or ""), flow)) if not hits: return None hits.sort(key=lambda item: item[0], reverse=True) return hits[0][1] def _baselines(quote: dict[str, Any]) -> tuple[str, str]: src = dict(quote or {}) cost = str(src.get("tms_baseline_cost") or src.get("cost") or src.get("costTotal") or "") sale = str(src.get("tms_baseline_sale") or src.get("total") or "") return cost, sale def _reply_apply_messages(result, reply) -> None: names = [str(x.get("name") or "") for x in quote_fee_rows(result.quote)] if result.applied: reply(copy.adjust_confirm(result.applied)) if result.unmatched: reply(copy.adjust_unmatched(result.unmatched, names)) if result.ambiguous: for name in result.ambiguous: reply(copy.adjust_ambiguous(name, names)) if result.lines_hint: reply(copy.adjust_need_lines()) if result.currency_hint: reply(copy.adjust_currency_hint()) def _emit_private_card(flow: Any, sess: Any, reply) -> None: """新价格明细卡:海运/陆运带拉群,空运只带跳过协同。""" line = (sess.business_line or "").upper() no = sess.work_order_no quote = dict(sess.quote or {}) if line: quote["business_line"] = line first = getattr(sess, "first_or_same", "first") or "first" history = getattr(sess, "history_work_order_no", "") or "" if line in {"SEA", "LAND"} and hasattr(flow, "_emit_sea_hit"): sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB) flow._emit_sea_hit( reply, work_order_no=no, quote=quote, first_or_same=first, history_work_order_no=history, status="已报价", business_line=line or "SEA", ) return sess.allowed = (copy.BTN_SKIP_COLLAB,) emit_air = getattr(flow, "_emit_air_hit", None) if callable(emit_air): emit_air( reply, work_order_no=no, quote=quote, first_or_same=first, history_work_order_no=history, status="已报价", ) return reply( copy.tms_hit_card( work_order_no=no, quote=quote, first_or_same=first, history_work_order_no=history, status="已报价", ), copy.tms_hit_wecom_payload( work_order_no=no, quote=quote, first_or_same=first, history_work_order_no=history, status="已报价", ), ) def try_private_adjust( flow: Any, sess: Any, text: str, reply, *, injected_adjust=None, ) -> Optional[str]: """ 私聊调价。不是调价或不够资格尝试则返回 None,让原流程继续。 副作用:确认话术、主账 upsert、新卡、书签 waitVersion+1。 """ if not session_quoted(sess): return None names = [str(r.get("name") or "") for r in adjustable_rows(getattr(sess, "quote", None))] extract = extract_adjust(text, fee_names=names, injected=injected_adjust) if not extract.is_adjust: return None reason = private_session_can_adjust( str(getattr(sess, "status", "") or ""), bool(quote_fee_rows(getattr(sess, "quote", None))), str(getattr(sess, "collab_chat_id", "") or ""), ) if reason: reply(copy.adjust_ineligible(reason)) return "adjust_blocked" result = apply_adjust( dict(sess.quote or {}), extract, business_line=str(getattr(sess, "business_line", "") or ""), tms_cost=_baselines(sess.quote)[0], tms_sale=_baselines(sess.quote)[1], ) if result.code == "need_fee": reply(copy.adjust_need_fee()) return "adjust_need_fee" if result.code == "need_lines": reply(copy.adjust_need_lines()) return "adjust_need_lines" if result.code == "none_matched": reply(copy.adjust_unmatched(result.unmatched, names)) return "adjust_none" if result.code == "ambiguous": reply(copy.adjust_ambiguous((extract.items[0].fee_name if extract.items else ""), names)) return "adjust_ambiguous" if not result.ok: return None copy.remember_tms_quote(sess, sess.quote) persist = dict(result.quote) persist["source"] = "SALES_ADJUST" persist["source_label"] = "销售调价" result.quote["source"] = persist["source"] result.quote["source_label"] = persist["source_label"] writer = getattr(flow.ledger, "upsert_product_quote", None) or flow.ledger.upsert_quote remark = copy.adjust_ledger_remark(result.applied) try: up = writer( work_order_no=sess.work_order_no, quote=persist, to_status="已报价", remark=remark, kind="adjust", ) or {} except TypeError: up = writer(work_order_no=sess.work_order_no, quote=persist, to_status="已报价") or {} if not up.get("ok"): reply("主账写入报价失败,调价未入账。") return "ledger_fail" _reply_apply_messages(result, reply) sess.quote = persist sess.status = "已报价" sess.phase = "wait_collab" sess.wait_version += 1 flow._save(sess) _emit_private_card(flow, sess, reply) return "wait_collab" def apply_group_adjust( *, flow: Any, ticket: object, sender_id: str, text: str, sess: Any = None, chat_id: str = "", injected_adjust=None, ) -> str: """ 群内销售调价。非销售只说明、不当报价。 至少改成一项:全量文本 + Excel + 成交跟进。一项都没有:只说明。 """ from agent.policy.sea_group_ops import ( _current_quote, _facts_for_fill, _render_and_send_sheet, _save_product_quote, emit_group_deal, group_client_of, halt_closed_group, hydrate_ticket, is_sales, send_group_text, ticket_no, ticket_status, ticket_view, ) ticket = hydrate_ticket(flow, ticket) no = ticket_no(ticket) view = ticket_view(ticket) room = (chat_id or str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")).strip() client = group_client_of(flow) stopped = halt_closed_group(flow=flow, ticket=ticket, sess=sess, chat_id=room) if stopped: return stopped status = ticket_status(ticket) if status not in {"已报价", "协商中"}: send_group_text(client, room, copy.adjust_ineligible("no_quote")) return "adjust_blocked" if not is_sales(ticket, sender_id): line = str(view.get("business_line") or view.get("businessLine") or "").upper() air_room = line == "AIR" if line == "MULTI": from agent.policy.multi_group_ops import load_segments from agent.policy.multi_segments import includes_air air_room = includes_air(load_segments(ticket)) send_group_text(client, room, copy.adjust_only_sales(air_room=air_room)) return "adjust_not_sales" current = _current_quote(view, sess) names = [str(r.get("name") or "") for r in adjustable_rows(current)] extract = extract_adjust(text, fee_names=names, injected=injected_adjust) if not extract.is_adjust: return "not_adjust" result = apply_adjust( current, extract, business_line=str(view.get("business_line") or getattr(sess, "business_line", "") or ""), tms_cost=_baselines(current)[0], tms_sale=_baselines(current)[1], ) if result.code == "need_fee": send_group_text(client, room, copy.adjust_need_fee()) return "adjust_need_fee" if result.code == "need_lines": send_group_text(client, room, copy.adjust_need_lines()) return "adjust_need_lines" if result.code == "none_matched": send_group_text(client, room, copy.adjust_unmatched(result.unmatched, names)) return "adjust_none" if not result.ok: send_group_text(client, room, copy.adjust_unmatched(result.unmatched or ["该项"], names)) return "adjust_none" copy.remember_tms_quote(sess, current) _save_product_quote( flow, no, result.quote, sess, remark=copy.adjust_ledger_remark(result.applied), kind="adjust", ) ticket2 = ticket_view(ticket) ticket2["quote"] = result.quote ticket2["status"] = "已报价" ticket2["work_order_no"] = no parts = [copy.adjust_confirm(result.applied)] if result.unmatched: parts.append(copy.adjust_unmatched(result.unmatched, [str(r.get("name") or "") for r in quote_fee_rows(result.quote)])) if result.lines_hint: parts.append(copy.adjust_need_lines()) if result.currency_hint: parts.append(copy.adjust_currency_hint()) line = str(view.get("business_line") or getattr(sess, "business_line", "") or "SEA") parts.append(copy.adjust_group_body(result.quote, business_line=line)) send_group_text(client, room, "\n".join(parts)) 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 {})) _render_and_send_sheet( flow=flow, chat_id=room, work_order_no=no, facts=_facts_for_fill( facts, collab, business_line=line, sales_name=str(view.get("sales_name") or view.get("salesName") or ""), ), quote=result.quote, sales_id=sender_id, business_line=line, ) emit_group_deal(flow=flow, ticket=ticket2, sess=sess, quote=result.quote) logger.info("group_adjust wo=%s applied=%s", no, [x.get("name") for x in result.applied]) return "group_adjusted"