落地空运群内协同与锁舱出站。

群里按 BOT 通道激活当前工单、航线报价和成交跟进;没有 TMS 报价编号时用工单字段补齐仍调锁舱,避免被本地规则拦住。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 17:25:42 +08:00
co-authored by Cursor
parent 12219cacb1
commit d9029dec89
62 changed files with 6772 additions and 159 deletions
+620 -43
View File
@@ -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 = "已报价"