356 lines
12 KiB
Python
356 lines
12 KiB
Python
"""
|
||
调价执行:私聊出新卡,群里出全量文本 + 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"
|