Files
inquiry_robot/inquiry-agent/agent/policy/quote_adjust_ops.py
T

356 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
调价执行:私聊出新卡,群里出全量文本 + 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:
"""新价格明细卡:海运/陆运带拉群,空运只带跳过协同。
空运货物信息用工单 facts(起运地/品名等);不传会整段变成「-」。
"""
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 ""
facts = {str(k): str(v) for k, v in dict(getattr(sess, "facts", None) or {}).items()}
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,
facts=facts,
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"
# 必须先写回允许点的按钮再存书签。若等 emit 后再改 allowed,
# Worker 重启后会读到空 allowed,销售点「生成报价单」会被静默丢掉。
line = (str(getattr(sess, "business_line", "") or "")).upper()
if line in {"SEA", "LAND"}:
sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB)
else:
sess.allowed = (copy.BTN_SKIP_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,
persist_attachment=True,
)
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"