落地空运群内协同与锁舱出站。
群里按 BOT 通道激活当前工单、航线报价和成交跟进;没有 TMS 报价编号时用工单字段补齐仍调锁舱,避免被本地规则拦住。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,829 @@
|
||||
"""
|
||||
空运协同群业务:激活当前工单、补含电含磁、航线报价/附件、锁舱/释放、成交跟进。
|
||||
|
||||
本文件职责:群里副作用(主账 + BOT 回话)。海运应用群仍走 sea_group_ops。
|
||||
禁止:自动建群/拉人/解散;拿当前工单去猜锁舱工单号。
|
||||
锁舱按 TMS /air/cabin/lock 拼字段出站;没有 TMS 报价编号也要锁,用工单字段补 quoteId/optionNo。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.sea_group_ops import (
|
||||
_archive_file,
|
||||
_persist_quote_sheet,
|
||||
_staff_display_name,
|
||||
emit_group_deal,
|
||||
filename_is_excel_pdf,
|
||||
group_client_of,
|
||||
hydrate_ticket,
|
||||
is_sales,
|
||||
is_terminal,
|
||||
merge_quote_fees,
|
||||
notify_quote_attachment_saved,
|
||||
send_filled_sheet,
|
||||
send_group_text,
|
||||
ticket_no,
|
||||
ticket_status,
|
||||
ticket_view,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TERMINAL = {"已成交", "未成交", "已关闭"}
|
||||
OPEN_ACTIVATE = {"询价中", "已报价", "协商中"}
|
||||
AIRLINE_ROLE = "air"
|
||||
|
||||
_PENDING_QUOTE = "__pending_air_quote"
|
||||
_PENDING_LOCK = "__pending_air_lock"
|
||||
_LOCK_ID = "__air_lock_id"
|
||||
_LOCK_OPTION = "__air_lock_option"
|
||||
_LOCK_QUOTE = "__air_lock_quote"
|
||||
_LOCK_EXEC = "__air_lock_exec"
|
||||
|
||||
|
||||
def is_airline(flow: Any, sender_id: str) -> bool:
|
||||
"""员工管理岗位=航线·空运且启用。"""
|
||||
uid = (sender_id or "").strip()
|
||||
if not uid:
|
||||
return False
|
||||
book = getattr(flow, "ledger", None) or getattr(flow, "_ledger", None)
|
||||
getter = getattr(book, "get_staff_by_wecom_id", None) if book else None
|
||||
if not callable(getter):
|
||||
return False
|
||||
try:
|
||||
row = getter(wecom_id=uid) or {}
|
||||
except Exception:
|
||||
logger.exception("查航线岗失败 wecom=%s", uid)
|
||||
return False
|
||||
code = str(row.get("roleCode") or row.get("role_code") or "").strip().lower()
|
||||
status = str(row.get("status") or "active").strip().lower()
|
||||
return code == AIRLINE_ROLE and status not in {"disabled", "0", "停用"}
|
||||
|
||||
|
||||
def operator_name(flow: Any, sender_id: str) -> str:
|
||||
book = getattr(flow, "ledger", None) or getattr(flow, "_ledger", None)
|
||||
getter = getattr(book, "get_staff_by_wecom_id", None) if book else None
|
||||
if callable(getter):
|
||||
try:
|
||||
row = getter(wecom_id=sender_id) or {}
|
||||
name = str(row.get("name") or "").strip()
|
||||
if name:
|
||||
return name
|
||||
except Exception:
|
||||
logger.warning("取操作人姓名失败 wecom=%s", sender_id)
|
||||
return (sender_id or "").strip() or "航线"
|
||||
|
||||
|
||||
def can_fill_fields(flow: Any, ticket: object, sender_id: str) -> bool:
|
||||
return is_sales(ticket, sender_id) or is_airline(flow, sender_id)
|
||||
|
||||
|
||||
def activate_ticket(
|
||||
*,
|
||||
flow: Any,
|
||||
chat_id: str,
|
||||
work_order_no: str,
|
||||
speak: bool = True,
|
||||
) -> tuple[Optional[object], str]:
|
||||
"""
|
||||
把本群当前工单切到指定空运未结案单。
|
||||
|
||||
返回 (工单, phase)。失败时工单为 None。
|
||||
"""
|
||||
book = flow.ledger
|
||||
no = (work_order_no or "").strip()
|
||||
client = group_client_of(flow)
|
||||
getter = getattr(book, "activate_air_group", None)
|
||||
if callable(getter):
|
||||
out = getter(chat_id=chat_id, work_order_no=no) or {}
|
||||
else:
|
||||
out = _activate_fallback(book, chat_id=chat_id, work_order_no=no)
|
||||
err = str(out.get("error") or "")
|
||||
if err == "ticket_not_found":
|
||||
if speak:
|
||||
send_group_text(client, chat_id, copy.air_ticket_not_found(no))
|
||||
return None, "ticket_missing"
|
||||
if err == "not_air":
|
||||
if speak:
|
||||
send_group_text(client, chat_id, copy.air_ticket_not_air(no))
|
||||
return None, "not_air"
|
||||
if err == "terminal":
|
||||
if speak:
|
||||
send_group_text(
|
||||
client, chat_id, copy.air_ticket_closed(no, str(out.get("status") or ""))
|
||||
)
|
||||
return None, "ticket_closed"
|
||||
if err == "occupied":
|
||||
if speak:
|
||||
send_group_text(client, chat_id, copy.air_ticket_occupied(no))
|
||||
return None, "occupied"
|
||||
if not out.get("ok"):
|
||||
if speak:
|
||||
send_group_text(client, chat_id, copy.air_ticket_not_found(no))
|
||||
return None, "ticket_missing"
|
||||
ticket = hydrate_ticket(flow, _ticket_from_activate(book, no, out))
|
||||
if speak:
|
||||
send_air_brief_and_sheet(flow=flow, ticket=ticket, chat_id=chat_id)
|
||||
_watch_archive_room(chat_id, no)
|
||||
return ticket, "activated"
|
||||
|
||||
|
||||
def send_air_brief_and_sheet(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
chat_id: str = "",
|
||||
quote_override: Optional[dict[str, Any]] = None,
|
||||
tms_quote: Optional[dict[str, Any]] = None,
|
||||
product_quote: Optional[dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
空运群再发一版摘要 + 匹配到的空运模板。对齐海运 send_brief_and_sheet。
|
||||
|
||||
必须先发出工单信息,模板等摘要发出后再发,禁止后台线程抢先发文件。
|
||||
"""
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
view = ticket_view(ticket)
|
||||
no = ticket_no(ticket)
|
||||
room = str(chat_id or view.get("collab_chat_id") or view.get("collabChatId") or "").strip()
|
||||
if room and not view.get("collab_chat_id"):
|
||||
view["collab_chat_id"] = room
|
||||
facts = dict(view.get("facts") or {})
|
||||
collab = dict(view.get("collab_facts") or {})
|
||||
view["collab_facts"] = collab
|
||||
view_q = dict(view.get("quote") or {})
|
||||
override = dict(quote_override or {})
|
||||
tms = dict(tms_quote) if tms_quote is not None else {}
|
||||
manual = dict(product_quote or {})
|
||||
if tms_quote is None:
|
||||
if view_q and not copy.quote_is_product(view_q):
|
||||
tms = view_q
|
||||
if not manual and copy.quote_is_product(override or view_q):
|
||||
manual = override or view_q
|
||||
quote = dict(override or view_q or tms or manual)
|
||||
sales_name, airline_names, mention_ids = resolve_air_mentions(flow, view)
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
room,
|
||||
copy.air_group_brief(
|
||||
work_order_no=no,
|
||||
facts=facts,
|
||||
quote=quote,
|
||||
collab_facts=collab,
|
||||
has_price=bool(tms.get("total") or tms.get("quoteId")),
|
||||
tms_quote=tms,
|
||||
product_quote=manual,
|
||||
sales_name=sales_name,
|
||||
airline_names=airline_names,
|
||||
),
|
||||
mention_userids=mention_ids,
|
||||
)
|
||||
_send_air_activate_sheet(flow=flow, ticket=view, chat_id=room, quote=quote)
|
||||
return "brief_and_sheet"
|
||||
|
||||
|
||||
def resolve_air_mentions(flow: Any, ticket: object) -> tuple[str, list[str], list[str]]:
|
||||
"""
|
||||
空运群摘要必须 @ 到本单销售和航线·空运,对上海运 @ 销售/@ 产品。
|
||||
"""
|
||||
view = ticket_view(ticket)
|
||||
sales_id = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
|
||||
sales_name = str(view.get("sales_name") or view.get("salesName") or "").strip()
|
||||
if not sales_name or sales_name == "销售":
|
||||
sales_name = _staff_display_name(flow, sales_id) if sales_id else ""
|
||||
if not sales_name:
|
||||
sales_name = sales_id
|
||||
air_ids: list[str] = []
|
||||
air_names: list[str] = []
|
||||
book = getattr(flow, "ledger", None)
|
||||
lister = getattr(book, "list_staff_by_role", None) if book is not None else None
|
||||
rows: list[dict[str, Any]] = []
|
||||
if callable(lister):
|
||||
try:
|
||||
rows = list(lister(role_code="air") or [])
|
||||
except Exception:
|
||||
logger.exception("列航线岗失败")
|
||||
for row in rows:
|
||||
uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
|
||||
name = str(row.get("name") or "").strip() or _staff_display_name(flow, uid)
|
||||
if not uid:
|
||||
continue
|
||||
air_ids.append(uid)
|
||||
if name and name not in air_names:
|
||||
air_names.append(name)
|
||||
mention_ids: list[str] = []
|
||||
if sales_id:
|
||||
mention_ids.append(sales_id)
|
||||
for uid in air_ids:
|
||||
if uid not in mention_ids:
|
||||
mention_ids.append(uid)
|
||||
return sales_name, air_names, mention_ids
|
||||
|
||||
|
||||
def _send_air_activate_sheet(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
chat_id: str,
|
||||
quote: dict[str, Any],
|
||||
) -> None:
|
||||
"""填匹配到的空运模板并往群里发。失败只补一句,不回滚激活。"""
|
||||
client = group_client_of(flow)
|
||||
try:
|
||||
painted = send_filled_sheet(
|
||||
flow=flow,
|
||||
ticket=ticket,
|
||||
business_line="AIR",
|
||||
chat_id=chat_id,
|
||||
quote_override=quote,
|
||||
) or {}
|
||||
except Exception:
|
||||
logger.exception("空运群发模板失败 chat=%s", chat_id)
|
||||
send_group_text(client, chat_id, copy.group_sheet_send_fail())
|
||||
return
|
||||
if painted.get("ok"):
|
||||
return
|
||||
err = str(painted.get("error") or "")
|
||||
if err == "NO_TEMPLATE":
|
||||
send_group_text(client, chat_id, painted.get("message") or copy.QUOTE_TEMPLATE_MISSING)
|
||||
return
|
||||
if err in {"no_renderer_or_chat", "no_file_client"}:
|
||||
send_group_text(client, chat_id, copy.group_sheet_send_fail())
|
||||
|
||||
|
||||
def _watch_archive_room(chat_id: str, work_order_no: str) -> None:
|
||||
"""激活成功后听这个群的存档,否则发文件(不能 @)系统听不见。"""
|
||||
try:
|
||||
from agent.channel.wshoto.rooms import watch_collab_room
|
||||
|
||||
watch_collab_room(chat_id, work_order_no=work_order_no)
|
||||
except Exception:
|
||||
logger.exception("空运群登记存档监听失败 chat=%s wo=%s", chat_id, work_order_no)
|
||||
|
||||
|
||||
def resolve_cabin_ticket(
|
||||
*,
|
||||
flow: Any,
|
||||
chat_id: str,
|
||||
work_order_no: str,
|
||||
allow_terminal: bool,
|
||||
) -> tuple[Optional[object], str]:
|
||||
"""
|
||||
锁舱/释放按句中工单号取单,不猜当前单。
|
||||
|
||||
未结案:激活并切到本群。已在别的群:occupied,不锁不切。
|
||||
已成交/未成交:不能再激活,但释放仍要读到这张单(成交拦住、未成交放舱)。
|
||||
"""
|
||||
book = flow.ledger
|
||||
no = (work_order_no or "").strip()
|
||||
client = group_client_of(flow)
|
||||
getter = getattr(book, "get_ticket", None)
|
||||
raw = getter(work_order_no=no) if callable(getter) else None
|
||||
if raw is None:
|
||||
send_group_text(client, chat_id, copy.air_ticket_not_found(no))
|
||||
return None, "ticket_missing"
|
||||
ticket = hydrate_ticket(flow, raw)
|
||||
view = ticket_view(ticket)
|
||||
line = str(view.get("business_line") or view.get("businessLine") or "").upper()
|
||||
if line and line != "AIR":
|
||||
send_group_text(client, chat_id, copy.air_ticket_not_air(no))
|
||||
return None, "not_air"
|
||||
other = str(view.get("collab_chat_id") or view.get("collabChatId") or "").strip()
|
||||
if other and other != chat_id:
|
||||
send_group_text(client, chat_id, copy.air_ticket_occupied(no))
|
||||
return None, "occupied"
|
||||
if is_terminal(ticket):
|
||||
if allow_terminal:
|
||||
return ticket, "terminal_loaded"
|
||||
send_group_text(
|
||||
client, chat_id, copy.air_ticket_closed(no, ticket_status(ticket))
|
||||
)
|
||||
return None, "ticket_closed"
|
||||
return activate_ticket(flow=flow, chat_id=chat_id, work_order_no=no, speak=False)
|
||||
|
||||
|
||||
def apply_air_fields(*, flow: Any, ticket: object, text: str, sender_id: str, chat_id: str) -> str:
|
||||
"""
|
||||
补含电/含磁。对齐海运:写入后重发群摘要 + 空运模板,不当一句「已记下」。
|
||||
"""
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
no = ticket_no(ticket)
|
||||
client = group_client_of(flow)
|
||||
if is_terminal(ticket):
|
||||
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
|
||||
return "ticket_locked"
|
||||
if not can_fill_fields(flow, ticket, sender_id):
|
||||
send_group_text(client, chat_id, copy.air_fields_denied())
|
||||
return "fields_denied"
|
||||
fields = extract_air_collab_fields(text, allow_b=True)
|
||||
if not fields:
|
||||
send_group_text(client, chat_id, copy.air_fields_not_recognized())
|
||||
return "fields_empty"
|
||||
flow.ledger.patch_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
|
||||
ticket2 = current_ticket(flow, chat_id) or ticket
|
||||
view = ticket_view(ticket2)
|
||||
collab = dict(view.get("collab_facts") or {})
|
||||
collab.update(fields)
|
||||
view["collab_facts"] = collab
|
||||
view["collab_chat_id"] = chat_id
|
||||
send_air_brief_and_sheet(flow=flow, ticket=view, chat_id=chat_id)
|
||||
return "fields_saved"
|
||||
|
||||
|
||||
def apply_air_quote(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
text: str,
|
||||
sender_id: str,
|
||||
chat_id: str,
|
||||
injected_quote: Optional[dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
航线文字报价。对齐海运产品报价:同句可带含电含磁;有 TMS 价先问怎么用;
|
||||
落定后重发摘要 + 空运模板 + 成交跟进。
|
||||
"""
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
no = ticket_no(ticket)
|
||||
client = group_client_of(flow)
|
||||
if is_terminal(ticket):
|
||||
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
|
||||
return "ticket_locked"
|
||||
if not is_airline(flow, sender_id):
|
||||
send_group_text(client, chat_id, copy.air_airline_only_quote())
|
||||
return "not_airline"
|
||||
fields = extract_air_collab_fields(text)
|
||||
if fields:
|
||||
flow.ledger.patch_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
|
||||
quote = injected_quote or extract_product_quote(text) or {}
|
||||
if not quote:
|
||||
send_group_text(client, chat_id, copy.air_quote_not_recognized())
|
||||
return "quote_empty"
|
||||
view = ticket_view(ticket)
|
||||
current = dict(view.get("quote") or {})
|
||||
if _has_tms_quote(current) and not _is_product_quote(current):
|
||||
_park_air_pending(flow, no, quote, sender_id)
|
||||
send_group_text(client, chat_id, copy.group_ask_tms_or_product())
|
||||
return "wait_tms_choice"
|
||||
return _commit_quote(flow, ticket, quote, chat_id, sender_id)
|
||||
|
||||
|
||||
def apply_air_file(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
sender_id: str,
|
||||
filename: str,
|
||||
media: dict[str, Any],
|
||||
chat_id: str,
|
||||
) -> str:
|
||||
"""
|
||||
空运群附件:对齐海运。只收 Excel/PDF。航线的当正式报价并出成交跟进;别人的只存档。
|
||||
|
||||
发文件不能 @,马上回「附件已保存」。不拆文件金额。已有 TMS 报价编号要留下。
|
||||
"""
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
no = ticket_no(ticket)
|
||||
client = group_client_of(flow)
|
||||
if is_terminal(ticket):
|
||||
send_group_text(client, chat_id, copy.ticket_locked_file(work_order_no=no, status=ticket_status(ticket)))
|
||||
return "ticket_locked_file"
|
||||
if not filename_is_excel_pdf(filename):
|
||||
send_group_text(client, chat_id, copy.group_only_excel_pdf())
|
||||
return "file_type_rejected"
|
||||
_archive_file(flow, no, filename, media, sender_id)
|
||||
notify_quote_attachment_saved(flow, chat_id, no, count=1)
|
||||
if not is_airline(flow, sender_id):
|
||||
return "file_archived"
|
||||
view = ticket_view(ticket)
|
||||
quote = dict(view.get("quote") or {})
|
||||
quote["source_label"] = "航线附件报价"
|
||||
quote["file_name"] = filename
|
||||
if not quote.get("total"):
|
||||
quote["total"] = "-"
|
||||
saved = flow.ledger.upsert_product_quote(work_order_no=no, quote=quote, to_status="已报价") or {}
|
||||
if not saved.get("ok"):
|
||||
send_group_text(client, chat_id, "主账写入报价失败。")
|
||||
return "ledger_fail"
|
||||
view["collab_chat_id"] = chat_id
|
||||
view["quote"] = quote
|
||||
emit_group_deal(flow=flow, ticket=view, quote=quote)
|
||||
return "airline_file_quote"
|
||||
|
||||
|
||||
def bound_air_ticket(flow: Any, chat_id: str) -> Optional[object]:
|
||||
"""这群当前绑的是空运单才算空运协同群。海运应用群不要走这里。"""
|
||||
hit = current_ticket(flow, chat_id)
|
||||
if hit is None:
|
||||
return None
|
||||
line = str(ticket_view(hit).get("business_line") or "").upper()
|
||||
return hit if line == "AIR" else None
|
||||
|
||||
|
||||
def apply_air_tms_choice(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
sender_id: str,
|
||||
chat_id: str,
|
||||
keep_tms: bool,
|
||||
) -> str:
|
||||
"""
|
||||
航线选:在 TMS 上改,或不用 TMS。对齐海运:用刚记下的价出摘要 + 空运模板 + 成交跟进。
|
||||
"""
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
client = group_client_of(flow)
|
||||
no = ticket_no(ticket)
|
||||
if is_terminal(ticket):
|
||||
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
|
||||
return "ticket_locked"
|
||||
if not is_airline(flow, sender_id):
|
||||
send_group_text(client, chat_id, copy.air_airline_only_quote())
|
||||
return "not_airline"
|
||||
pending = _load_air_pending(ticket, no)
|
||||
if not pending:
|
||||
send_group_text(client, chat_id, copy.group_need_price_before_choice())
|
||||
return "quote_choice_no_pending"
|
||||
view = ticket_view(ticket)
|
||||
base = dict(view.get("quote") or {})
|
||||
if keep_tms:
|
||||
if pending.get("fee_rows") or pending.get("fee_lines"):
|
||||
merged = merge_quote_fees(base, pending)
|
||||
else:
|
||||
merged = dict(base)
|
||||
merged.update(pending)
|
||||
if base.get("quoteId") and not pending.get("quoteId"):
|
||||
merged["quoteId"] = base.get("quoteId")
|
||||
if base.get("airOptions") and not pending.get("airOptions"):
|
||||
merged["airOptions"] = base.get("airOptions")
|
||||
merged["source_label"] = "航线在TMS上改"
|
||||
else:
|
||||
merged = dict(pending)
|
||||
merged["source_label"] = "航线报价"
|
||||
_clear_air_pending(flow, no, sender_id)
|
||||
return _commit_quote(flow, ticket, merged, chat_id, sender_id, tms_quote=base if keep_tms else {})
|
||||
|
||||
|
||||
def apply_air_confirm_tms(*, flow: Any, ticket: object, sender_id: str, chat_id: str) -> str:
|
||||
"""航线确认沿用 TMS:模板入后台,回附件已保存,再出成交跟进。对齐海运。"""
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
if not is_airline(flow, sender_id):
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_quote())
|
||||
return "not_airline"
|
||||
view = ticket_view(ticket)
|
||||
quote = dict(view.get("quote") or {})
|
||||
if not _has_tms_quote(quote):
|
||||
send_group_text(group_client_of(flow), chat_id, copy.group_confirm_need_tms())
|
||||
return "need_tms"
|
||||
quote["source_label"] = "航线确认TMS"
|
||||
no = ticket_no(ticket)
|
||||
_clear_air_pending(flow, no, sender_id)
|
||||
saved = flow.ledger.upsert_product_quote(work_order_no=no, quote=quote, to_status="已报价") or {}
|
||||
if not saved.get("ok"):
|
||||
send_group_text(group_client_of(flow), chat_id, "主账写入报价失败。")
|
||||
return "ledger_fail"
|
||||
view["collab_chat_id"] = chat_id
|
||||
_persist_quote_sheet(
|
||||
flow=flow,
|
||||
ticket=view,
|
||||
sess=None,
|
||||
quote=quote,
|
||||
sender_id=sender_id,
|
||||
business_line="AIR",
|
||||
)
|
||||
notify_quote_attachment_saved(flow, chat_id, no, count=1)
|
||||
emit_group_deal(flow=flow, ticket=view, quote=quote)
|
||||
return "quote_confirmed"
|
||||
|
||||
|
||||
def apply_air_deal(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
sender_id: str,
|
||||
outcome: str,
|
||||
chat_id: str,
|
||||
) -> str:
|
||||
if not is_sales(ticket, sender_id):
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_sales_only_deal())
|
||||
return "not_sales"
|
||||
if is_terminal(ticket) and ticket_status(ticket) != "协商中":
|
||||
if ticket_status(ticket) in {"已成交", "未成交", "已关闭"}:
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
copy.ticket_locked(work_order_no=ticket_no(ticket), status=ticket_status(ticket)),
|
||||
)
|
||||
return "ticket_locked"
|
||||
result = flow.ledger.transition(work_order_no=ticket_no(ticket), to_status=outcome) or {}
|
||||
if not result.get("ok"):
|
||||
send_group_text(group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。")
|
||||
return "ledger_fail"
|
||||
send_group_text(group_client_of(flow), chat_id, copy.deal_ack(outcome, ticket_no(ticket)))
|
||||
return "done"
|
||||
|
||||
|
||||
def apply_air_lock(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
sender_id: str,
|
||||
chat_id: str,
|
||||
instruction: str,
|
||||
option_no: str = "",
|
||||
) -> str:
|
||||
if not is_airline(flow, sender_id):
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
|
||||
return "not_airline"
|
||||
no = ticket_no(ticket)
|
||||
name = operator_name(flow, sender_id)
|
||||
quote = ticket_view(ticket).get("quote") or {}
|
||||
options = _air_options(quote)
|
||||
quote_id = _quote_id(quote) or f"QT-{no}"
|
||||
if not options:
|
||||
options = [{"optionNo": "AIR-OPT-01"}]
|
||||
picked = (option_no or "").strip()
|
||||
if not picked and len(options) > 1:
|
||||
facts = dict(ticket_view(ticket).get("collab_facts") or {})
|
||||
if str(facts.get(_PENDING_LOCK) or "") != "1":
|
||||
flow.ledger.patch_collab_facts(
|
||||
work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id
|
||||
)
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_ask_lock_option(options))
|
||||
return "wait_lock_option"
|
||||
if not picked:
|
||||
picked = str(options[0].get("optionNo") or options[0].get("option_no") or "").strip()
|
||||
locker = getattr(flow.ledger, "lock_cabin", None)
|
||||
if not callable(locker):
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
copy.air_lock_fail(work_order_no=no, operator=name, reason="主账未开放锁舱"),
|
||||
)
|
||||
return "lock_unavailable"
|
||||
out = locker(
|
||||
work_order_no=no,
|
||||
option_no=picked,
|
||||
sender_id=sender_id,
|
||||
operator_name=name,
|
||||
instruction_text=instruction,
|
||||
) or {}
|
||||
if not out.get("ok"):
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
copy.air_lock_fail(
|
||||
work_order_no=no,
|
||||
operator=name,
|
||||
reason=str(out.get("failReason") or out.get("error") or "锁舱失败"),
|
||||
),
|
||||
)
|
||||
return "lock_fail"
|
||||
exec_no = str(out.get("lockId") or out.get("tmsRequestId") or "").strip()
|
||||
quote_no = str(out.get("quoteId") or quote_id).strip()
|
||||
flow.ledger.patch_collab_facts(
|
||||
work_order_no=no,
|
||||
facts={
|
||||
_LOCK_ID: exec_no,
|
||||
_LOCK_OPTION: picked,
|
||||
_LOCK_QUOTE: quote_no,
|
||||
_LOCK_EXEC: exec_no,
|
||||
_PENDING_LOCK: "",
|
||||
},
|
||||
sender_id=sender_id,
|
||||
)
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
copy.air_lock_ok(
|
||||
work_order_no=no,
|
||||
operator=name,
|
||||
option_no=picked,
|
||||
exec_no=exec_no,
|
||||
quote_no=quote_no,
|
||||
),
|
||||
)
|
||||
view = ticket_view(ticket)
|
||||
view["collab_chat_id"] = chat_id
|
||||
emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {}))
|
||||
return "lock_ok"
|
||||
|
||||
|
||||
def apply_air_release(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
sender_id: str,
|
||||
chat_id: str,
|
||||
instruction: str,
|
||||
) -> str:
|
||||
if not is_airline(flow, sender_id):
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
|
||||
return "not_airline"
|
||||
no = ticket_no(ticket)
|
||||
if ticket_status(ticket) == "已成交":
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_release_deal_blocked(no))
|
||||
return "release_deal_blocked"
|
||||
facts = dict(ticket_view(ticket).get("collab_facts") or {})
|
||||
lock_id = str(facts.get(_LOCK_ID) or "").strip()
|
||||
name = operator_name(flow, sender_id)
|
||||
if not lock_id:
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_release_need_lock(no))
|
||||
return "release_need_lock"
|
||||
releaser = getattr(flow.ledger, "release_cabin", None)
|
||||
if not callable(releaser):
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
copy.air_release_fail(work_order_no=no, operator=name, reason="主账未开放释放"),
|
||||
)
|
||||
return "release_unavailable"
|
||||
out = releaser(
|
||||
work_order_no=no,
|
||||
lock_id=lock_id,
|
||||
sender_id=sender_id,
|
||||
operator_name=name,
|
||||
instruction_text=instruction,
|
||||
) or {}
|
||||
if not out.get("ok"):
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
copy.air_release_fail(
|
||||
work_order_no=no,
|
||||
operator=name,
|
||||
reason=str(out.get("failReason") or out.get("error") or "释放失败"),
|
||||
),
|
||||
)
|
||||
return "release_fail"
|
||||
exec_no = str(out.get("releaseId") or out.get("tmsRequestId") or lock_id).strip()
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
copy.air_release_ok(
|
||||
work_order_no=no,
|
||||
operator=name,
|
||||
option_no=str(facts.get(_LOCK_OPTION) or ""),
|
||||
exec_no=exec_no,
|
||||
quote_no=str(facts.get(_LOCK_QUOTE) or ""),
|
||||
),
|
||||
)
|
||||
view = ticket_view(ticket)
|
||||
view["collab_chat_id"] = chat_id
|
||||
emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {}))
|
||||
return "release_ok"
|
||||
|
||||
|
||||
def current_ticket(flow: Any, chat_id: str) -> Optional[object]:
|
||||
book = flow.ledger
|
||||
hit = book.find_by_collab_chat(chat_id=chat_id)
|
||||
if hit is None:
|
||||
return None
|
||||
return hydrate_ticket(flow, hit)
|
||||
|
||||
|
||||
def _commit_quote(
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
quote: dict[str, Any],
|
||||
chat_id: str,
|
||||
sender_id: str,
|
||||
tms_quote: Optional[dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""报价落定:写入主账,重发摘要 + 空运模板,再出成交跟进。"""
|
||||
_ = sender_id
|
||||
no = ticket_no(ticket)
|
||||
up = flow.ledger.upsert_product_quote(work_order_no=no, quote=quote, to_status="已报价")
|
||||
if not up.get("ok"):
|
||||
send_group_text(group_client_of(flow), chat_id, "主账写入报价失败。")
|
||||
return "ledger_fail"
|
||||
ticket2 = current_ticket(flow, chat_id) or ticket
|
||||
view = ticket_view(ticket2)
|
||||
view["collab_chat_id"] = chat_id
|
||||
view["quote"] = quote
|
||||
send_air_brief_and_sheet(
|
||||
flow=flow,
|
||||
ticket=view,
|
||||
chat_id=chat_id,
|
||||
quote_override=quote,
|
||||
tms_quote=tms_quote if tms_quote is not None else {},
|
||||
product_quote=quote,
|
||||
)
|
||||
emit_group_deal(flow=flow, ticket=view, quote=quote)
|
||||
return "quoted"
|
||||
|
||||
|
||||
def peek_air_pending(ticket: object) -> dict[str, Any]:
|
||||
"""这张单是否还在等航线选怎么用 TMS。"""
|
||||
return _load_air_pending(ticket, ticket_no(ticket))
|
||||
|
||||
|
||||
def _park_air_pending(flow: Any, work_order_no: str, quote: dict[str, Any], sender_id: str) -> None:
|
||||
"""先记下航线刚报的价,不写主账报价版本。Redis 正式;协同字段兜底给单测。"""
|
||||
try:
|
||||
from agent.redis_coord.pending_quote import save_pending_product_quote
|
||||
|
||||
save_pending_product_quote(work_order_no, quote)
|
||||
except Exception:
|
||||
logger.exception("待确认航线报价写入失败 wo=%s", work_order_no)
|
||||
flow.ledger.patch_collab_facts(
|
||||
work_order_no=work_order_no,
|
||||
facts={_PENDING_QUOTE: json.dumps(quote, ensure_ascii=False)},
|
||||
sender_id=sender_id,
|
||||
)
|
||||
|
||||
|
||||
def _load_air_pending(ticket: object, work_order_no: str) -> dict[str, Any]:
|
||||
try:
|
||||
from agent.redis_coord.pending_quote import load_pending_product_quote
|
||||
|
||||
parked = load_pending_product_quote(work_order_no)
|
||||
if parked:
|
||||
return parked
|
||||
except Exception:
|
||||
logger.exception("待确认航线报价读取失败 wo=%s", work_order_no)
|
||||
raw = str((ticket_view(ticket).get("collab_facts") or {}).get(_PENDING_QUOTE) or "").strip()
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return dict(data) if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _clear_air_pending(flow: Any, work_order_no: str, sender_id: str) -> None:
|
||||
try:
|
||||
from agent.redis_coord.pending_quote import clear_pending_product_quote
|
||||
|
||||
clear_pending_product_quote(work_order_no)
|
||||
except Exception:
|
||||
logger.exception("待确认航线报价清理失败 wo=%s", work_order_no)
|
||||
flow.ledger.patch_collab_facts(
|
||||
work_order_no=work_order_no,
|
||||
facts={_PENDING_QUOTE: ""},
|
||||
sender_id=sender_id,
|
||||
)
|
||||
|
||||
|
||||
def _has_tms_quote(quote: dict[str, Any]) -> bool:
|
||||
"""当前价来自 TMS 才要问;已经是航线/产品价不再替用户选。"""
|
||||
src = dict(quote or {})
|
||||
label = str(src.get("source_label") or src.get("source") or "")
|
||||
if "航线" in label or "产品" in label:
|
||||
return False
|
||||
if not (src.get("total") or src.get("quoteId") or src.get("quote_id")):
|
||||
return False
|
||||
return "TMS" in label.upper() or "标准报价" in label or bool(src.get("quoteId") or src.get("quote_id"))
|
||||
|
||||
|
||||
def _is_product_quote(quote: dict[str, Any]) -> bool:
|
||||
src = str(quote.get("source_label") or "")
|
||||
return "航线" in src or "产品" in src
|
||||
|
||||
|
||||
def _quote_id(quote: dict[str, Any]) -> str:
|
||||
return str(quote.get("quoteId") or quote.get("quote_id") or "").strip()
|
||||
|
||||
|
||||
def _air_options(quote: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
raw = quote.get("airOptions") or quote.get("air_options")
|
||||
if isinstance(raw, str) and raw.strip().startswith("["):
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
raw = []
|
||||
if isinstance(raw, list):
|
||||
return [x for x in raw if isinstance(x, dict)]
|
||||
blob = str(quote.get("air_options_json") or "").strip()
|
||||
if blob.startswith("["):
|
||||
try:
|
||||
parsed = json.loads(blob)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return [x for x in parsed if isinstance(x, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def _activate_fallback(book: Any, *, chat_id: str, work_order_no: str) -> dict[str, Any]:
|
||||
"""内存账本未实现 activate 时的兜底,单测应走正式方法。"""
|
||||
getter = getattr(book, "get_ticket", None)
|
||||
ticket = getter(work_order_no=work_order_no) if callable(getter) else None
|
||||
if ticket is None:
|
||||
return {"ok": False, "error": "ticket_not_found"}
|
||||
return {"ok": True, "work_order_no": work_order_no}
|
||||
|
||||
|
||||
def _ticket_from_activate(book: Any, no: str, out: dict[str, Any]) -> object:
|
||||
getter = getattr(book, "get_ticket", None)
|
||||
if callable(getter):
|
||||
hit = getter(work_order_no=no)
|
||||
if hit is not None:
|
||||
return hit
|
||||
return out
|
||||
@@ -52,6 +52,7 @@ class FlowSession:
|
||||
invited_product_names: tuple[str, ...] = ()
|
||||
deal_version: int = 0
|
||||
collab_ended: bool = False
|
||||
pending_product_quote: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class AirTextInquiryFlow:
|
||||
@@ -62,16 +63,25 @@ class AirTextInquiryFlow:
|
||||
价格卡按工单号另存一份当时会话:点哪张卡就生成哪张卡当时的报价单。
|
||||
"""
|
||||
|
||||
def __init__(self, ledger: Any = None) -> None:
|
||||
def __init__(self, ledger: Any = None, group_client: Any = None) -> None:
|
||||
self._ledger = ledger or build_ledger()
|
||||
self._lock = threading.Lock()
|
||||
self._by_sender: dict[str, FlowSession] = {}
|
||||
self._by_ticket: dict[str, FlowSession] = {}
|
||||
self._group_client = group_client
|
||||
|
||||
@property
|
||||
def ledger(self) -> Any:
|
||||
return self._ledger
|
||||
|
||||
@ledger.setter
|
||||
def ledger(self, value: Any) -> None:
|
||||
self._ledger = value
|
||||
|
||||
def group_client(self) -> Any:
|
||||
"""空运群 BOT 出站;单测注入假客户端。"""
|
||||
return self._group_client
|
||||
|
||||
def session_of(self, sender_id: str) -> Optional[FlowSession]:
|
||||
with self._lock:
|
||||
return self._by_sender.get(sender_id)
|
||||
|
||||
@@ -14,6 +14,7 @@ import uuid
|
||||
from typing import Any, Iterable
|
||||
|
||||
from agent.schema.field_validate import (
|
||||
AIR_COLLAB_FIELDS,
|
||||
AIR_REQUIRED_FIELDS,
|
||||
SEA_COLLAB_DISPLAY,
|
||||
SEA_COLLAB_FIELDS,
|
||||
@@ -238,8 +239,8 @@ def inquiry_field_lines(
|
||||
|
||||
起运/目的展示销售原文(南京/北京),三字码只给出站 TMS,不对销售展示。
|
||||
"""
|
||||
origin = (facts.get("起运港") or "-").strip()
|
||||
dest = (facts.get("目的港") or "-").strip()
|
||||
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 "-"
|
||||
@@ -452,7 +453,7 @@ def quote_fee_lines(quote: dict[str, Any] | None) -> list[tuple[str, str]]:
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
|
||||
raw = src.get("fee_lines")
|
||||
raw = src.get("fee_lines") or src.get("fee_rows")
|
||||
parsed: Any = raw
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
@@ -988,6 +989,12 @@ def sea_group_create_fail(*, reason: str = "") -> str:
|
||||
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,
|
||||
@@ -997,12 +1004,13 @@ def sea_group_brief(
|
||||
sales_name: str,
|
||||
product_names: list[str],
|
||||
has_price: bool,
|
||||
tms_quote: dict[str, Any] | None = None,
|
||||
product_quote: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
群里自动摘要:@销售 @产品 + 工单号 + 询价字段 + 协同字段 + TMS 报价。
|
||||
群里自动摘要:询价 + 协同 + TMS 报价 + 人工报价。
|
||||
|
||||
三段之间空一行,方便群里扫。协同没填写 -。
|
||||
HS编码对外写成商品海关编码。无价只写「TMS 暂无匹配报价」。
|
||||
TMS 无价只写「TMS 暂无匹配报价」,产品报的价单独放人工报价,不能混进 TMS。
|
||||
"""
|
||||
no = (work_order_no or "").strip()
|
||||
sales = (sales_name or "").strip() or "销售"
|
||||
@@ -1019,12 +1027,30 @@ def sea_group_brief(
|
||||
label = SEA_COLLAB_DISPLAY.get(key, key)
|
||||
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 has_price and quote:
|
||||
lines.extend(quote_detail_lines(quote))
|
||||
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)
|
||||
|
||||
|
||||
@@ -1335,6 +1361,46 @@ 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"
|
||||
|
||||
@@ -1349,6 +1415,10 @@ def group_deal_stale(*, work_order_no: str) -> str:
|
||||
return f"这张成交确认已过期。工单{no}已有更新的报价,请点击群里最新一条成交跟进。"
|
||||
|
||||
|
||||
def group_deal_need_quote() -> str:
|
||||
return "还没有可确认的报价,请先完成报价再确认成交状态。"
|
||||
|
||||
|
||||
def group_deal_sales_only() -> str:
|
||||
return "只有负责该工单的销售可以确认成交状态。"
|
||||
|
||||
@@ -1364,14 +1434,263 @@ def group_cannot_recreate(*, work_order_no: str) -> str:
|
||||
return f"工单{no}协同已结束,不能再拉群。"
|
||||
|
||||
|
||||
def group_deal_mention(*, sales_name: str, work_order_no: str) -> str:
|
||||
def group_deal_followup(*, sales_name: str, work_order_no: str) -> str:
|
||||
"""
|
||||
群里文字成交跟进。只提示输入,不含报价合计、不含点选按钮。
|
||||
|
||||
私聊成交卡仍走 deal_card,不改这里。
|
||||
"""
|
||||
name = (sales_name or "").strip() or "销售"
|
||||
return f"@{name}\n{deal_card(work_order_no=work_order_no)}"
|
||||
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 f"已记录为「未成交」。请补充未成交原因,工单 {work_order_no} 将关闭智能体跟进。"
|
||||
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}还没有有效锁舱,不能释放。"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -101,19 +102,44 @@ def is_sales(ticket: object, sender_id: str) -> bool:
|
||||
return bool(uid) and uid == sales
|
||||
|
||||
|
||||
def is_product(ticket: object, sender_id: str) -> bool:
|
||||
"""产品岗:成员里且不是销售。"""
|
||||
def is_product(ticket: object, sender_id: str, flow: Any = None) -> bool:
|
||||
"""
|
||||
产品岗:工单产品名单,或后台员工管理挂了产品·海运。
|
||||
|
||||
同一个人可以既是拉群销售又是产品。先当销售挡掉的话,自己报价群里会没反应。
|
||||
"""
|
||||
uid = (sender_id or "").strip()
|
||||
if not uid or is_sales(ticket, uid):
|
||||
if not uid:
|
||||
return False
|
||||
view = ticket_view(ticket)
|
||||
products = {str(x) for x in (view.get("product_ids") or view.get("productIds") or [])}
|
||||
if uid in products:
|
||||
return True
|
||||
if _staff_is_sea_product(flow, uid):
|
||||
return True
|
||||
if is_sales(ticket, uid):
|
||||
return False
|
||||
members = {str(x) for x in (view.get("collab_member_ids") or view.get("memberIds") or [])}
|
||||
products = {str(x) for x in (view.get("product_ids") or [])}
|
||||
if products:
|
||||
return uid in products
|
||||
return uid in members
|
||||
|
||||
|
||||
def _staff_is_sea_product(flow: Any, wecom_id: str) -> bool:
|
||||
"""后台员工管理:role_code=sea 且能查到,才算产品岗。"""
|
||||
book = getattr(flow, "ledger", None) if flow is not None else None
|
||||
finder = getattr(book, "get_staff_by_wecom_id", None) if book is not None else None
|
||||
if not callable(finder):
|
||||
return False
|
||||
try:
|
||||
row = finder(wecom_id=wecom_id) or {}
|
||||
except Exception:
|
||||
logger.exception("查产品岗失败 wecom=%s", wecom_id)
|
||||
return False
|
||||
role = str(row.get("roleCode") or row.get("role_code") or "").strip().lower()
|
||||
return role == "sea"
|
||||
|
||||
|
||||
def filename_is_excel_pdf(name: str) -> bool:
|
||||
raw = (name or "").strip().lower()
|
||||
return any(raw.endswith(ext) for ext in EXCEL_PDF)
|
||||
@@ -249,14 +275,21 @@ def send_group_text(
|
||||
|
||||
def hydrate_ticket(flow: Any, ticket: object) -> object:
|
||||
"""
|
||||
按群找回的工单若缺询价七项,用工单号+销售再读主账补全。
|
||||
按群找回的工单补全询价字段、报价、六态。
|
||||
|
||||
测服 byCollabChat 可能只回协同字段;续办 get 空 sender 也不回 facts。
|
||||
禁止用空 facts 把已确认港口冲成「-」。
|
||||
byCollabChat 可能有港口却没有报价/状态;有字段就提前返回的话,
|
||||
在TMS基础上改会丢掉原价,未成交也会被误判成还没报价。
|
||||
"""
|
||||
view = ticket_view(ticket)
|
||||
facts = dict(view.get("facts") or {})
|
||||
if any(str(facts.get(k) or "").strip() for k in ("起运港", "目的港", "品名", "货量")):
|
||||
has_facts = any(
|
||||
str(facts.get(k) or "").strip()
|
||||
for k in ("起运港", "目的港", "品名", "货量", "起运地", "目的地", "件数", "毛重")
|
||||
)
|
||||
quote = dict(view.get("quote") or {})
|
||||
has_quote = bool(quote.get("total") or _quote_fee_rows(quote))
|
||||
has_status = bool(ticket_status(ticket))
|
||||
if has_facts and has_quote and has_status:
|
||||
return ticket
|
||||
no = ticket_no(ticket)
|
||||
sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
|
||||
@@ -267,7 +300,9 @@ def hydrate_ticket(flow: Any, ticket: object) -> object:
|
||||
getter = getattr(book, "get_for_agent", None)
|
||||
if callable(getter) and sales:
|
||||
full = getter(work_order_no=no, sender_id=sales)
|
||||
if not isinstance(full, dict) or not dict(full.get("facts") or {}):
|
||||
if not isinstance(full, dict) or not (
|
||||
dict(full.get("facts") or {}) or dict(full.get("quote") or {}) or full.get("status")
|
||||
):
|
||||
getter = getattr(book, "get_ticket", None)
|
||||
if callable(getter):
|
||||
full = getter(work_order_no=no)
|
||||
@@ -275,12 +310,12 @@ def hydrate_ticket(flow: Any, ticket: object) -> object:
|
||||
return ticket
|
||||
merged = dict(view)
|
||||
full_view = ticket_view(full)
|
||||
if full_view.get("facts"):
|
||||
if (not has_facts) and full_view.get("facts"):
|
||||
merged["facts"] = dict(full_view.get("facts") or {})
|
||||
if full_view.get("quote") and not merged.get("quote"):
|
||||
if (not has_quote) and full_view.get("quote"):
|
||||
merged["quote"] = dict(full_view.get("quote") or {})
|
||||
if full_view.get("status"):
|
||||
merged.setdefault("status", full_view.get("status"))
|
||||
if (not has_status) and full_view.get("status"):
|
||||
merged["status"] = full_view.get("status")
|
||||
collab = dict(full_view.get("collab_facts") or {})
|
||||
collab.update(dict(view.get("collab_facts") or {}))
|
||||
merged["collab_facts"] = collab
|
||||
@@ -295,11 +330,13 @@ def send_brief_and_sheet(
|
||||
ticket: object,
|
||||
sess: Any = None,
|
||||
quote_override: Optional[dict[str, Any]] = None,
|
||||
tms_quote: Optional[dict[str, Any]] = None,
|
||||
product_quote: Optional[dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
群里再发一版摘要 + 填好的海运模板。旧消息不撤。
|
||||
|
||||
无价也发表,询价和协同能填的先填。
|
||||
TMS 和人工报价分段写,无价也发表。
|
||||
"""
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
view = ticket_view(ticket)
|
||||
@@ -312,20 +349,39 @@ def send_brief_and_sheet(
|
||||
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 {}))
|
||||
quote = dict(quote_override or (sess.quote if sess else None) or view.get("quote") or {})
|
||||
view_q = dict(view.get("quote") or {})
|
||||
sess_q = dict(sess.quote) if sess is not None and getattr(sess, "quote", None) else {}
|
||||
override = dict(quote_override or {})
|
||||
tms = dict(tms_quote) if tms_quote is not None else {}
|
||||
manual = dict(product_quote or {})
|
||||
if tms_quote is None:
|
||||
if _has_tms_quote(view_q) and not copy.quote_is_product(view_q):
|
||||
tms = view_q
|
||||
elif _has_tms_quote(sess_q) and not copy.quote_is_product(sess_q):
|
||||
tms = sess_q
|
||||
if not manual:
|
||||
if copy.quote_is_product(override):
|
||||
manual = override
|
||||
elif copy.quote_is_product(view_q):
|
||||
manual = view_q
|
||||
elif copy.quote_is_product(sess_q):
|
||||
manual = sess_q
|
||||
quote = dict(override or sess_q or view_q or tms or manual)
|
||||
sales_name, products, mention_ids = resolve_group_mentions(flow, ticket, sess)
|
||||
has_price = bool(quote.get("total") or quote.get("fee_rows"))
|
||||
has_tms = bool(tms.get("total") or _quote_fee_rows(tms))
|
||||
brief = copy.sea_group_brief(
|
||||
work_order_no=no,
|
||||
facts=facts,
|
||||
quote=quote,
|
||||
quote=tms or None,
|
||||
collab_facts=collab,
|
||||
sales_name=sales_name,
|
||||
product_names=products,
|
||||
has_price=has_price,
|
||||
has_price=has_tms,
|
||||
tms_quote=tms,
|
||||
product_quote=manual,
|
||||
)
|
||||
send_group_text(client, chat_id, brief, mention_userids=mention_ids)
|
||||
send_filled_sheet(flow=flow, ticket=ticket, sess=sess, quote_override=quote_override)
|
||||
send_filled_sheet(flow=flow, ticket=ticket, sess=sess, quote_override=quote)
|
||||
return "brief_and_sheet"
|
||||
|
||||
|
||||
@@ -335,12 +391,23 @@ def send_filled_sheet(
|
||||
ticket: object,
|
||||
sess: Any = None,
|
||||
quote_override: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""只发填好的模板文件,不再发文字摘要。"""
|
||||
business_line: str = "SEA",
|
||||
chat_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
只发填好的模板文件,不再发文字摘要。
|
||||
|
||||
business_line 决定匹配哪套后台模板;空运必须传 AIR,禁止落到海运默认模板。
|
||||
"""
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
view = ticket_view(ticket) if ticket is not None else {}
|
||||
no = (sess.work_order_no if sess else "") or ticket_no(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "").strip()
|
||||
room = str(
|
||||
chat_id
|
||||
or (sess.collab_chat_id if sess else "")
|
||||
or view.get("collab_chat_id")
|
||||
or ""
|
||||
).strip()
|
||||
facts = dict(view.get("facts") or {})
|
||||
if sess is not None and getattr(sess, "facts", None):
|
||||
facts.update(dict(sess.facts or {}))
|
||||
@@ -348,9 +415,10 @@ def send_filled_sheet(
|
||||
if sess is not None and getattr(sess, "collab_facts", None):
|
||||
collab.update(dict(sess.collab_facts or {}))
|
||||
quote = dict(quote_override or (sess.quote if sess else None) or view.get("quote") or {})
|
||||
_render_and_send_sheet(
|
||||
line = (business_line or str(view.get("business_line") or view.get("businessLine") or "SEA")).strip()
|
||||
return _render_and_send_sheet(
|
||||
flow=flow,
|
||||
chat_id=chat_id,
|
||||
chat_id=room,
|
||||
work_order_no=no,
|
||||
facts=_facts_for_fill(facts, collab),
|
||||
quote=quote,
|
||||
@@ -360,6 +428,7 @@ def send_filled_sheet(
|
||||
or view.get("salesWecomId")
|
||||
or ""
|
||||
),
|
||||
business_line=line or "SEA",
|
||||
)
|
||||
|
||||
|
||||
@@ -416,27 +485,34 @@ def _render_and_send_sheet(
|
||||
facts: dict[str, Any],
|
||||
quote: dict[str, Any],
|
||||
sales_id: str,
|
||||
) -> None:
|
||||
business_line: str = "SEA",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
按业务线匹配后台模板、填表、发群文件。
|
||||
|
||||
空运必须 business_line=AIR,否则会套上海运默认模板。
|
||||
"""
|
||||
renderer = getattr(flow.ledger, "render_quote", None)
|
||||
client = group_client_of(flow)
|
||||
if not callable(renderer) or not chat_id:
|
||||
return
|
||||
return {"ok": False, "error": "no_renderer_or_chat"}
|
||||
line = (business_line or "SEA").strip() or "SEA"
|
||||
result = renderer(
|
||||
work_order_no=work_order_no,
|
||||
business_line="SEA",
|
||||
business_line=line,
|
||||
facts=facts,
|
||||
quote=quote,
|
||||
format="xlsx",
|
||||
sales_wecom_id=sales_id,
|
||||
) or {}
|
||||
if not result.get("ok"):
|
||||
logger.warning("群填表失败 wo=%s err=%s", work_order_no, result.get("error"))
|
||||
return
|
||||
logger.warning("群填表失败 wo=%s line=%s err=%s", work_order_no, line, result.get("error"))
|
||||
return result
|
||||
filename = str(result.get("fileName") or f"{work_order_no}_quote.xlsx")
|
||||
sender = getattr(client, "send_group_file", None)
|
||||
if not callable(sender):
|
||||
logger.warning("群发文件无客户端 wo=%s chat=%s", work_order_no, chat_id)
|
||||
return
|
||||
return {"ok": False, "error": "no_file_client", "message": copy.group_sheet_send_fail()}
|
||||
painted = overlay_labeled_facts(
|
||||
filepath=str(result.get("filePath") or ""),
|
||||
file_b64=str(result.get("fileBase64") or ""),
|
||||
@@ -451,8 +527,9 @@ def _render_and_send_sheet(
|
||||
if sent.get("ok") is False:
|
||||
logger.warning("群发文件失败 wo=%s err=%s", work_order_no, sent.get("error"))
|
||||
send_group_text(client, chat_id, copy.group_sheet_send_fail())
|
||||
return
|
||||
logger.info("群已发模板 wo=%s file=%s", work_order_no, filename)
|
||||
return sent
|
||||
logger.info("群已发模板 wo=%s line=%s file=%s", work_order_no, line, filename)
|
||||
return {"ok": True, "fileName": filename, "templateId": result.get("templateId")}
|
||||
|
||||
|
||||
def apply_collab_update(
|
||||
@@ -464,6 +541,7 @@ def apply_collab_update(
|
||||
sess: Any = None,
|
||||
) -> str:
|
||||
"""补协同六项。终态要说明原因。"""
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
no = ticket_no(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or ticket_view(ticket).get("collab_chat_id") or "")
|
||||
client = group_client_of(flow)
|
||||
@@ -501,10 +579,11 @@ def apply_product_quote(
|
||||
injected_quote: Optional[dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
产品文字报价:盖 TMS 价、填表发出、群里成交跟进。
|
||||
产品文字报价。TMS 已有价时先问怎么用,不擅自清掉或盖上。
|
||||
|
||||
一句话里的协同字段一并写入。非产品或终态要回话。
|
||||
"""
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
no = ticket_no(ticket)
|
||||
view = ticket_view(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
|
||||
@@ -512,8 +591,9 @@ def apply_product_quote(
|
||||
if is_terminal(ticket):
|
||||
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
|
||||
return "ticket_locked"
|
||||
if not is_product(ticket, sender_id):
|
||||
return "group_ignore"
|
||||
if not is_product(ticket, sender_id, flow=flow):
|
||||
send_group_text(client, chat_id, copy.group_only_product_can_quote())
|
||||
return "quote_not_product"
|
||||
fields = extract_collab_fields(text)
|
||||
if fields:
|
||||
flow.apply_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
|
||||
@@ -523,15 +603,182 @@ def apply_product_quote(
|
||||
sess.collab_facts = merged
|
||||
quote = extract_product_quote(text, injected=injected_quote)
|
||||
if not quote:
|
||||
logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200])
|
||||
send_group_text(client, chat_id, copy.group_quote_not_recognized())
|
||||
return "quote_not_recognized"
|
||||
base = _current_quote(view, sess)
|
||||
if _has_tms_quote(base):
|
||||
_park_pending_quote(no, quote, sess, flow)
|
||||
send_group_text(client, chat_id, copy.group_ask_tms_or_product())
|
||||
return "wait_tms_choice"
|
||||
_save_product_quote(flow, no, quote, sess)
|
||||
ticket2 = _reload(flow, chat_id, no) or ticket
|
||||
send_brief_and_sheet(flow=flow, ticket=ticket2, sess=sess, quote_override=quote)
|
||||
send_brief_and_sheet(
|
||||
flow=flow,
|
||||
ticket=ticket2,
|
||||
sess=sess,
|
||||
quote_override=quote,
|
||||
tms_quote={},
|
||||
product_quote=quote,
|
||||
)
|
||||
emit_group_deal(flow=flow, ticket=ticket2, sess=sess, quote=quote)
|
||||
return "product_quote_sent"
|
||||
|
||||
|
||||
def apply_tms_quote_choice(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
sender_id: str,
|
||||
keep_tms: bool,
|
||||
sess: Any = None,
|
||||
) -> str:
|
||||
"""
|
||||
任一产品选:在 TMS 上改,或不用 TMS。
|
||||
|
||||
用刚才记下的那份价出 Excel,不再问第二遍价格。
|
||||
"""
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
no = ticket_no(ticket)
|
||||
view = ticket_view(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
|
||||
client = group_client_of(flow)
|
||||
if is_terminal(ticket):
|
||||
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
|
||||
return "ticket_locked"
|
||||
if not is_product(ticket, sender_id, flow=flow):
|
||||
send_group_text(client, chat_id, copy.group_only_product_can_quote())
|
||||
return "quote_not_product"
|
||||
pending = _load_pending_quote(no, sess)
|
||||
if not pending:
|
||||
send_group_text(client, chat_id, copy.group_need_price_before_choice())
|
||||
return "quote_choice_no_pending"
|
||||
base = _current_quote(view, sess)
|
||||
if keep_tms:
|
||||
quote = merge_quote_fees(base, pending)
|
||||
else:
|
||||
quote = dict(pending)
|
||||
quote.setdefault("source_label", "产品报价")
|
||||
overlay = dict(pending)
|
||||
_clear_pending_quote(no, sess, flow)
|
||||
_save_product_quote(flow, no, quote, sess)
|
||||
ticket2 = _reload(flow, chat_id, no) or ticket
|
||||
send_brief_and_sheet(
|
||||
flow=flow,
|
||||
ticket=ticket2,
|
||||
sess=sess,
|
||||
quote_override=quote,
|
||||
tms_quote=base if keep_tms else {},
|
||||
product_quote=overlay if keep_tms else quote,
|
||||
)
|
||||
emit_group_deal(flow=flow, ticket=ticket2, sess=sess, quote=quote)
|
||||
return "product_quote_sent"
|
||||
|
||||
|
||||
def apply_confirm_tms_quote(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
sender_id: str,
|
||||
sess: Any = None,
|
||||
) -> str:
|
||||
"""
|
||||
产品确认沿用当前 TMS 价:模板入后台,不再问第二遍价格。
|
||||
|
||||
群里告诉大家附件已保存,再发成交跟进。
|
||||
"""
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
no = ticket_no(ticket)
|
||||
view = ticket_view(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
|
||||
client = group_client_of(flow)
|
||||
if is_terminal(ticket):
|
||||
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
|
||||
return "ticket_locked"
|
||||
if not is_product(ticket, sender_id, flow=flow):
|
||||
send_group_text(client, chat_id, copy.group_only_product_can_quote())
|
||||
return "quote_not_product"
|
||||
quote = _current_quote(view, sess)
|
||||
if not (quote.get("total") or _quote_fee_rows(quote)):
|
||||
send_group_text(client, chat_id, copy.group_confirm_need_tms())
|
||||
return "quote_confirm_no_tms"
|
||||
_clear_pending_quote(no, sess, flow)
|
||||
confirmed = dict(quote)
|
||||
confirmed["source_label"] = "产品确认TMS"
|
||||
_save_product_quote(flow, no, confirmed, sess)
|
||||
_persist_quote_sheet(
|
||||
flow=flow,
|
||||
ticket=ticket,
|
||||
sess=sess,
|
||||
quote=confirmed,
|
||||
sender_id=sender_id,
|
||||
)
|
||||
notify_quote_attachment_saved(flow, chat_id, no, count=1)
|
||||
emit_group_deal(flow=flow, ticket=ticket, sess=sess, quote=confirmed)
|
||||
return "quote_confirmed"
|
||||
|
||||
|
||||
def notify_quote_attachment_saved(
|
||||
flow: Any,
|
||||
chat_id: str,
|
||||
work_order_no: str,
|
||||
count: int = 1,
|
||||
) -> None:
|
||||
"""群里告诉大家:模板或产品附件已进后台。"""
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
copy.quote_attachment_saved_app(work_order_no, count),
|
||||
)
|
||||
|
||||
|
||||
def _persist_quote_sheet(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
sess: Any,
|
||||
quote: dict[str, Any],
|
||||
sender_id: str,
|
||||
business_line: str = "",
|
||||
) -> None:
|
||||
"""把当前价填进匹配模板并写入主账附件,不重复往群里丢文件。"""
|
||||
view = ticket_view(ticket)
|
||||
no = ticket_no(ticket) or (sess.work_order_no if sess else "")
|
||||
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 {}))
|
||||
renderer = getattr(flow.ledger, "render_quote", None)
|
||||
filename = f"{no}_quote.xlsx"
|
||||
object_key = filename
|
||||
line = str(
|
||||
business_line
|
||||
or view.get("business_line")
|
||||
or view.get("businessLine")
|
||||
or "SEA"
|
||||
).strip() or "SEA"
|
||||
if callable(renderer) and no:
|
||||
result = renderer(
|
||||
work_order_no=no,
|
||||
business_line=line,
|
||||
facts=_facts_for_fill(facts, collab),
|
||||
quote=quote,
|
||||
format="xlsx",
|
||||
sales_wecom_id=str(
|
||||
(sess.sender_id if sess else "")
|
||||
or view.get("sales_wecom_id")
|
||||
or view.get("salesWecomId")
|
||||
or ""
|
||||
),
|
||||
) or {}
|
||||
if result.get("ok"):
|
||||
filename = str(result.get("fileName") or filename)
|
||||
object_key = str(result.get("objectKey") or result.get("fileName") or filename)
|
||||
_archive_file(flow, no, filename, {"media_id": object_key}, sender_id)
|
||||
|
||||
|
||||
def handle_group_file(
|
||||
*,
|
||||
flow: Any,
|
||||
@@ -556,13 +803,14 @@ def handle_group_file(
|
||||
send_group_text(client, chat_id, copy.group_only_excel_pdf())
|
||||
return "file_type_rejected"
|
||||
_archive_file(flow, no, filename, media, sender_id)
|
||||
if is_product(ticket, sender_id):
|
||||
if is_product(ticket, sender_id, flow=flow):
|
||||
quote = {
|
||||
"source_label": "产品附件报价",
|
||||
"file_name": filename,
|
||||
"total": str((view.get("quote") or {}).get("total") or "-"),
|
||||
}
|
||||
_save_product_quote(flow, no, quote, sess)
|
||||
notify_quote_attachment_saved(flow, chat_id, no, count=1)
|
||||
emit_group_deal(flow=flow, ticket=ticket, sess=sess, quote=quote)
|
||||
return "product_file_quote"
|
||||
return "file_archived"
|
||||
@@ -621,17 +869,61 @@ def emit_group_deal(
|
||||
sess.quote = q
|
||||
sess.wait_version += 1
|
||||
flow._save(sess)
|
||||
sales_name, _products, mention_ids = resolve_group_mentions(flow, ticket, sess)
|
||||
text = f"@{sales_name}\n" + copy.deal_card(work_order_no=no, quote=q, facts=facts)
|
||||
# 成交跟进只点销售:产品不能点这张卡。
|
||||
sales_mention = mention_ids[:1] if mention_ids else []
|
||||
send_group_text(client, chat_id, text, mention_userids=sales_mention)
|
||||
sales_name, _products, _mention_ids = resolve_group_mentions(flow, ticket, sess)
|
||||
# 群里文字版只 @ 销售并请输入,不把私聊那套合计/请选择贴过来。
|
||||
text = copy.group_deal_followup(sales_name=sales_name, work_order_no=no)
|
||||
send_group_text(client, chat_id, text)
|
||||
carder = getattr(client, "send_group_card", None)
|
||||
payload = copy.deal_wecom_payload(work_order_no=no, quote=q, facts=facts, card_version=ver)
|
||||
if callable(carder):
|
||||
carder(chat_id=chat_id, template_card=payload.get("template_card") or {})
|
||||
|
||||
|
||||
def apply_group_deal(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
sender_id: str,
|
||||
outcome: str,
|
||||
sess: Any = None,
|
||||
) -> str:
|
||||
"""
|
||||
群里文字确认成交/未成交/协商中。口令不写死,意图在 group_intent。
|
||||
|
||||
只有这单销售能确认。没有报价要说明,不能装没听见。
|
||||
"""
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
no = ticket_no(ticket)
|
||||
view = ticket_view(ticket)
|
||||
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
|
||||
client = group_client_of(flow)
|
||||
if is_terminal(ticket) or (sess is not None and getattr(sess, "status", "") in TERMINAL):
|
||||
status = ticket_status(ticket) or (sess.status if sess else "")
|
||||
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=status))
|
||||
return "ticket_locked"
|
||||
if not is_sales(ticket, sender_id):
|
||||
send_group_text(client, chat_id, copy.group_deal_sales_only())
|
||||
return "not_sales"
|
||||
quote = _current_quote(view, sess)
|
||||
status_now = ticket_status(ticket) or (sess.status if sess else "")
|
||||
if not (quote.get("total") or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
|
||||
send_group_text(client, chat_id, copy.group_deal_need_quote())
|
||||
return "deal_need_quote"
|
||||
writer = getattr(flow.ledger, "transition", None)
|
||||
if callable(writer):
|
||||
result = writer(work_order_no=no, to_status=outcome) or {}
|
||||
if not result.get("ok"):
|
||||
send_group_text(client, chat_id, "主账状态更新失败,成交结果未入账。")
|
||||
return "ledger_fail"
|
||||
if sess is not None:
|
||||
sess.status = outcome
|
||||
sess.phase = "done"
|
||||
sess.allowed = ()
|
||||
flow._save(sess)
|
||||
send_group_text(client, chat_id, copy.deal_ack(outcome, no))
|
||||
return "done"
|
||||
|
||||
|
||||
def explain_deal_click(
|
||||
*,
|
||||
flow: Any,
|
||||
@@ -666,12 +958,297 @@ def explain_deal_click(
|
||||
return ""
|
||||
|
||||
|
||||
def _current_quote(view: dict[str, Any], sess: Any) -> dict[str, Any]:
|
||||
"""主账报价优先;书签只在主账还没费用行时兜底。"""
|
||||
base = dict(view.get("quote") or {})
|
||||
if not _quote_fee_rows(base) and sess is not None and getattr(sess, "quote", None):
|
||||
return dict(sess.quote)
|
||||
return base
|
||||
|
||||
|
||||
def _has_tms_quote(quote: dict[str, Any] | None) -> bool:
|
||||
"""当前价来自 TMS 才要问;已经是产品价不再替用户选。"""
|
||||
src = dict(quote or {})
|
||||
label = str(src.get("source_label") or src.get("source") or "")
|
||||
if "产品" in label:
|
||||
return False
|
||||
if not (src.get("total") or _quote_fee_rows(src)):
|
||||
return False
|
||||
return "TMS" in label.upper() or "标准报价" in label or not label
|
||||
|
||||
|
||||
def _park_pending_quote(no: str, quote: dict[str, Any], sess: Any, flow: Any) -> None:
|
||||
"""先记下产品刚报的价,不写主账。"""
|
||||
if sess is not None:
|
||||
sess.pending_product_quote = dict(quote)
|
||||
if hasattr(flow, "_save"):
|
||||
flow._save(sess)
|
||||
try:
|
||||
from agent.redis_coord.pending_quote import save_pending_product_quote
|
||||
|
||||
save_pending_product_quote(no, quote)
|
||||
except Exception:
|
||||
logger.exception("待确认报价写入失败 wo=%s", no)
|
||||
|
||||
|
||||
def peek_pending_product_quote(no: str, sess: Any = None) -> dict[str, Any]:
|
||||
"""群里还在等选 TMS 时,给入口判断要不要回话。"""
|
||||
return _load_pending_quote(no, sess)
|
||||
|
||||
|
||||
def _load_pending_quote(no: str, sess: Any) -> dict[str, Any]:
|
||||
if sess is not None and isinstance(getattr(sess, "pending_product_quote", None), dict):
|
||||
parked = dict(sess.pending_product_quote or {})
|
||||
if parked:
|
||||
return parked
|
||||
try:
|
||||
from agent.redis_coord.pending_quote import load_pending_product_quote
|
||||
|
||||
return load_pending_product_quote(no)
|
||||
except Exception:
|
||||
logger.exception("待确认报价读取失败 wo=%s", no)
|
||||
return {}
|
||||
|
||||
|
||||
def _clear_pending_quote(no: str, sess: Any, flow: Any) -> None:
|
||||
if sess is not None:
|
||||
sess.pending_product_quote = {}
|
||||
if hasattr(flow, "_save"):
|
||||
flow._save(sess)
|
||||
try:
|
||||
from agent.redis_coord.pending_quote import clear_pending_product_quote
|
||||
|
||||
clear_pending_product_quote(no)
|
||||
except Exception:
|
||||
logger.exception("待确认报价清理失败 wo=%s", no)
|
||||
|
||||
|
||||
def _fee_name_key(name: str) -> str:
|
||||
"""费用名对齐:去空格和括号,斜杠后的英文不参与比较。"""
|
||||
raw = (name or "").replace(" ", "").replace("(", "(").replace(")", ")")
|
||||
for sep in ("/", "/"):
|
||||
if sep in raw:
|
||||
raw = raw.split(sep, 1)[0]
|
||||
return raw.strip()
|
||||
|
||||
|
||||
def _fee_name_same(left: str, right: str) -> bool:
|
||||
"""
|
||||
和主账填表同一口径:海运费=海运费用;操作费 ≠ 码头操作费。
|
||||
|
||||
模板里码头操作费是单独一行,不能用「包含」对上操作费。
|
||||
"""
|
||||
a = _fee_name_key(left)
|
||||
b = _fee_name_key(right)
|
||||
if not a or not b:
|
||||
return False
|
||||
if a == b:
|
||||
return True
|
||||
if a == b + "用" or a == b + "费用" or b == a + "用" or b == a + "费用":
|
||||
return True
|
||||
if a + "费" == b or b + "费" == a:
|
||||
return True
|
||||
# 文件发 ≈ 文件费:末字口误,前面相同。
|
||||
if len(a) >= 3 and len(a) == len(b) and a[:-1] == b[:-1] and (a.endswith("费") or b.endswith("费")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _quote_fee_rows(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""
|
||||
抽出可盖价的费用行。TMS 常把 fee_lines 存成 JSON 字符串,不当空明细。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
raw = src.get("fee_rows") or src.get("fee_lines") or src.get("feeItems") or []
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
raw = []
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
rows: list[dict[str, Any]] = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = str(
|
||||
item.get("name")
|
||||
or item.get("label")
|
||||
or item.get("feeName")
|
||||
or item.get("chargeName")
|
||||
or ""
|
||||
).strip()
|
||||
amount = str(
|
||||
item.get("amount")
|
||||
or item.get("value")
|
||||
or item.get("salePrice")
|
||||
or item.get("price")
|
||||
or ""
|
||||
).strip()
|
||||
if name and amount:
|
||||
row = dict(item)
|
||||
row["name"] = name
|
||||
row["amount"] = amount
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _match_fee_row(name: str, rows: list[dict[str, Any]]) -> int:
|
||||
"""找到同名费用行。对不上返回 -1,不按第一行瞎盖。"""
|
||||
for idx, row in enumerate(rows):
|
||||
if _fee_name_same(name, str(row.get("name") or "")):
|
||||
return idx
|
||||
return -1
|
||||
|
||||
|
||||
def _apply_fee_amount(row: dict[str, Any], amount: str) -> None:
|
||||
"""
|
||||
产品改的是这一行卖价。金额、单价、salePrice 一起覆盖,避免报价单单价列还留着 TMS 旧价。
|
||||
"""
|
||||
row["amount"] = amount
|
||||
row["unit_price"] = amount
|
||||
if row.get("salePrice") not in (None, ""):
|
||||
row["salePrice"] = amount
|
||||
if row.get("price") not in (None, ""):
|
||||
row["price"] = amount
|
||||
if row.get("value") not in (None, ""):
|
||||
row["value"] = amount
|
||||
|
||||
|
||||
def _parse_fee_amount(raw: Any) -> float | None:
|
||||
"""抽出金额数字。带 USD/CNY 前缀或千分位也能读,读不了当没有。"""
|
||||
text = str(raw or "").strip().replace(",", "")
|
||||
if not text or text == "-":
|
||||
return None
|
||||
upper = text.upper()
|
||||
for prefix in ("USD", "CNY", "EUR", "HKD", "JPY", "GBP", "RMB"):
|
||||
if upper.startswith(prefix):
|
||||
text = text[len(prefix) :].strip()
|
||||
break
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _row_currency(row: dict[str, Any] | None, fallback: str = "") -> str:
|
||||
src = dict(row or {})
|
||||
return str(src.get("currency") or src.get("currencyCode") or fallback or "").strip().upper()
|
||||
|
||||
|
||||
def _format_fee_total(value: float, sample: str = "") -> str:
|
||||
"""合计写法跟原 TMS 对齐:原来有小数就留两位。"""
|
||||
if "." in str(sample or ""):
|
||||
return f"{value:.2f}"
|
||||
if abs(value - round(value)) < 1e-9:
|
||||
return str(int(round(value)))
|
||||
return f"{value:.2f}"
|
||||
|
||||
|
||||
def _merged_total(
|
||||
base: dict[str, Any] | None,
|
||||
rows: list[dict[str, Any]],
|
||||
incoming: dict[str, Any],
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
总销售价:有 TMS 原合计就按改价差额加减,不能只加产品改的那几行。
|
||||
|
||||
美元行已含在 TMS 人民币合计里,不擅自换汇;只调整和合计同币种的差额。
|
||||
"""
|
||||
src = dict(base or {})
|
||||
header_ccy = str(src.get("currency") or incoming.get("currency") or "").strip()
|
||||
base_total = _parse_fee_amount(src.get("total"))
|
||||
if base_total is not None:
|
||||
total_ccy = (header_ccy or "CNY").upper()
|
||||
if total_ccy == "RMB":
|
||||
total_ccy = "CNY"
|
||||
old_rows = _quote_fee_rows(src)
|
||||
delta = 0.0
|
||||
for new_row in rows:
|
||||
new_amt = _parse_fee_amount(new_row.get("amount"))
|
||||
if new_amt is None:
|
||||
continue
|
||||
line_ccy = _row_currency(new_row, total_ccy)
|
||||
if line_ccy == "RMB":
|
||||
line_ccy = "CNY"
|
||||
if line_ccy and line_ccy != total_ccy:
|
||||
continue
|
||||
idx = _match_fee_row(str(new_row.get("name") or ""), old_rows)
|
||||
if idx < 0:
|
||||
delta += new_amt
|
||||
continue
|
||||
old_amt = _parse_fee_amount(old_rows[idx].get("amount"))
|
||||
if old_amt is None:
|
||||
continue
|
||||
delta += new_amt - old_amt
|
||||
return _format_fee_total(base_total + delta, str(src.get("total") or "")), header_ccy
|
||||
|
||||
amounts: list[float] = []
|
||||
ccys: set[str] = set()
|
||||
for row in rows:
|
||||
amt = _parse_fee_amount(row.get("amount"))
|
||||
if amt is None:
|
||||
continue
|
||||
amounts.append(amt)
|
||||
code = _row_currency(row, header_ccy)
|
||||
if code:
|
||||
ccys.add("CNY" if code == "RMB" else code)
|
||||
if amounts and len(ccys) <= 1:
|
||||
ccy = next(iter(ccys)) if ccys else header_ccy
|
||||
return _format_fee_total(sum(amounts)), ccy
|
||||
return str(src.get("total") or ""), header_ccy
|
||||
|
||||
|
||||
def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""
|
||||
产品按行改价盖到原 TMS 明细上。没点名的费用原样保留。
|
||||
|
||||
单价和金额一起改:模板先写 unit_price,只改 amount 的话报价单上看着还是旧价。
|
||||
合计按原 TMS 总价加减改过的行,不用产品两行之和冒充全单。
|
||||
不编时效和有效期。
|
||||
"""
|
||||
incoming = dict(overlay or {})
|
||||
rows = _quote_fee_rows(base)
|
||||
for item in incoming.get("fee_rows") or incoming.get("fee_lines") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = str(item.get("name") or "").strip()
|
||||
amount = str(item.get("amount") or "").strip()
|
||||
if not name or not amount:
|
||||
continue
|
||||
idx = _match_fee_row(name, rows)
|
||||
if idx < 0:
|
||||
rows.append(dict(item))
|
||||
continue
|
||||
old = dict(rows[idx])
|
||||
_apply_fee_amount(old, amount)
|
||||
if item.get("currency"):
|
||||
old["currency"] = item.get("currency")
|
||||
rows[idx] = old
|
||||
out = dict(base or {})
|
||||
for key, val in incoming.items():
|
||||
if key in {"fee_rows", "fee_lines", "total"}:
|
||||
continue
|
||||
out[key] = val
|
||||
out["fee_rows"] = rows
|
||||
out["fee_lines"] = rows
|
||||
out["source_label"] = incoming.get("source_label") or "产品报价"
|
||||
total, currency = _merged_total(base, rows, incoming)
|
||||
if total:
|
||||
out["total"] = total
|
||||
if currency:
|
||||
out["currency"] = currency
|
||||
return out
|
||||
|
||||
|
||||
def _save_product_quote(flow: Any, no: str, quote: dict[str, Any], sess: Any) -> None:
|
||||
writer = getattr(flow.ledger, "upsert_product_quote", None) or getattr(
|
||||
flow.ledger, "upsert_quote", None
|
||||
)
|
||||
if callable(writer):
|
||||
writer(work_order_no=no, quote=quote, to_status="已报价")
|
||||
saved = writer(work_order_no=no, quote=quote, to_status="已报价") or {}
|
||||
if not saved.get("ok") and saved.get("error"):
|
||||
logger.warning("产品报价未入主账 wo=%s err=%s", no, saved.get("error"))
|
||||
if sess is not None:
|
||||
sess.quote = dict(quote)
|
||||
sess.status = "已报价"
|
||||
|
||||
@@ -461,8 +461,11 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
for row in staff:
|
||||
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 userids:
|
||||
if not uid:
|
||||
continue
|
||||
if uid not in userids:
|
||||
userids.append(uid)
|
||||
if uid not in product_ids:
|
||||
product_ids.append(uid)
|
||||
product_names.append(name)
|
||||
if not product_ids:
|
||||
|
||||
Reference in New Issue
Block a user