机器人显示名与站点标题可配;多段联运列表投影;协同群监听对账;工单附件落存;模板与工单后台补强

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-19 09:41:59 +08:00
co-authored by Cursor
parent 277cdc1c28
commit 2b528758b4
119 changed files with 6760 additions and 3516 deletions
+206 -23
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import json
import logging
import re
from contextlib import contextmanager
from typing import Any, Callable, Iterator, Optional
@@ -18,10 +19,13 @@ from agent.llm.extract_text import extract_collab_fields, extract_product_quote
from agent.policy import inquiry_copy as copy
from agent.policy.multi_segments import Segment, group_brief, includes_air, segments_from_payload
from agent.policy.sea_group_ops import (
_archive_file,
_quote_currency_hint,
apply_group_deal,
filename_is_excel_pdf,
group_client_of,
is_sales,
notify_quote_attachment_saved,
override_group_client,
send_filled_sheet,
send_group_text,
@@ -109,6 +113,128 @@ def sender_mode(flow: Any, sender_id: str) -> str:
return _ROLE_MODE.get(role, "")
_RE_SEG_INDEX = re.compile(
r"(?:段\s*(\d+)|第\s*([一二三四五六七八九]|\d+)\s*段)",
re.I,
)
_CN_INDEX = {
"一": 1,
"二": 2,
"三": 3,
"四": 4,
"五": 5,
"六": 6,
"七": 7,
"八": 8,
"九": 9,
}
_FEE_MODE = (
("陆运费", "LAND"),
("空运费", "AIR"),
("海运费", "SEA"),
# 报价单文件名常写「空运到港报价单」无「费」字。
("陆运", "LAND"),
("空运", "AIR"),
("海运", "SEA"),
)
def resolve_quote_segment(
text: str,
segments: list[Segment],
role_mode: str,
) -> tuple[Optional[Segment], str]:
"""
多段报价归到哪一段。
1) 正文「段1 / 第一段」;2) 正文写了陆运费/空运费/海运费;3) 岗位。
运行证据:林绵勋岗位是 air,发「段1:陆运费」却出了段2空运单。
返回 (段, 选用原因)。
"""
if not segments:
return None, "no_segments"
raw = text or ""
hit = _RE_SEG_INDEX.search(raw)
if hit:
num = hit.group(1) or hit.group(2) or ""
try:
index = int(num) if str(num).isdigit() else _CN_INDEX.get(str(num), 0)
except ValueError:
index = 0
if index > 0:
for seg in segments:
if seg.index == index:
return seg, f"text_index:{index}"
fee_mode = ""
for word, mode in _FEE_MODE:
if word in raw:
fee_mode = mode
break
role = (role_mode or "").strip().upper()
if fee_mode:
for seg in segments:
if seg.mode == fee_mode:
return seg, f"text_fee:{fee_mode}"
if role:
for seg in segments:
if seg.mode == role:
return seg, f"role:{role}"
return None, "unmatched"
def apply_multi_file(
*,
flow: Any,
ticket: object,
chat_id: str,
sender_id: str,
filename: str,
media: Optional[dict[str, Any]] = None,
) -> str:
"""
多段群附件报价:落 inq_attachment,按文件名/岗位归段,不写正文抽价。
运行证据:石畅畅发「…空运到港报价单.xlsx」时,旧逻辑把文件名当文字报价,
回「没有识别到报价」,附件也没进后台。
副作用:写主账附件+分段报价;可能发群确认/成交跟进。Worker 线程。
"""
room = (chat_id or "").strip()
no = ticket_no(ticket)
client = group_client_of(flow)
name = (filename or "").strip()
if not filename_is_excel_pdf(name):
send_group_text(client, room, copy.group_only_excel_pdf())
return "file_type_rejected"
segments = load_segments(ticket)
if not segments:
send_group_text(client, room, f"工单{no}没有可报价的运输段。")
return "multi_no_segments"
role_mode = sender_mode(flow, sender_id)
target, reason = resolve_quote_segment(name, segments, role_mode)
if target is None:
send_group_text(client, room, "没有对上你负责的运输段,请让对应岗位报价。")
return "quote_wrong_role"
_archive_file(flow, no, name, dict(media or {}), sender_id)
notify_quote_attachment_saved(flow, room, no, count=1)
quote = {
"source_label": "产品附件报价",
"file_name": name,
"total": "-",
"raw_text": name,
}
# 附件本身就是报价单:只落段价,不再重发空白模板。
return _save_segment_quote(
flow=flow,
ticket=ticket,
chat_id=room,
segment=target,
segments=segments,
quote=quote,
sheet_fn=None,
skip_sheet=True,
)
def handle_multi_inbound(
*,
flow: Any,
@@ -142,8 +268,9 @@ def handle_multi_inbound(
if only_no:
_send_activation(flow, ticket, room, segments, sender_id=sender_id, sheet_fn=sheet_fn)
return "multi_activated"
mode = sender_mode(flow, sender_id)
target = next((seg for seg in segments if seg.mode == mode), None)
role_mode = sender_mode(flow, sender_id)
target, resolve_reason = resolve_quote_segment(text or "", segments, role_mode)
mode = (target.mode if target is not None else role_mode) or ""
fields = {}
if target is not None:
keys = collab_fields_for_ticket(target.facts, target.mode)
@@ -152,7 +279,8 @@ def handle_multi_inbound(
_save_collab(flow, ticket, target, fields)
ticket = _reload(flow, no) or ticket
segments = load_segments(ticket)
target = next((seg for seg in segments if seg.mode == mode), target)
target, resolve_reason = resolve_quote_segment(text or "", segments, role_mode)
mode = (target.mode if target is not None else role_mode) or mode
# 段报价币种优先;否则用工单顶层 quote(产品未写币种时继承 TMS)
baseline = ""
if target is not None:
@@ -190,10 +318,21 @@ def handle_multi_inbound(
)
def deal_text(*, work_order_no: str, segments: list[Segment], quotes: dict[str, dict[str, Any]]) -> str:
"""整票成交跟进文字。每段各自的价格、有效期,海运陆运再加时效。不加总。"""
def deal_text(
*,
work_order_no: str,
segments: list[Segment],
quotes: dict[str, dict[str, Any]],
sales_name: str = "",
) -> str:
"""
整票成交跟进文字。开头 @本单销售,与海运 group_deal_followup 一致。
每段各自的价格、有效期,海运陆运再加时效。不加总。
"""
no = (work_order_no or "").strip()
lines = [f"询价工单 {no}", copy.DEAL_SUBTITLE]
name = (sales_name or "").strip() or "销售"
lines = [f"@{name}", f"询价工单 {no}", copy.DEAL_SUBTITLE]
for seg in segments:
quote = quotes.get(seg.mode) or {}
lines.append(f"段{seg.index}:{seg.word()}")
@@ -240,6 +379,7 @@ def _save_segment_quote(
segments: list[Segment],
quote: dict[str, Any],
sheet_fn: Optional[SheetFn],
skip_sheet: bool = False,
) -> str:
"""写入这一段报价并发该段报价单。其他段未齐时不发成交跟进。"""
no = ticket_no(ticket)
@@ -254,28 +394,63 @@ def _save_segment_quote(
"source_label": "分段报价",
"deal_version": version,
}
writer = getattr(flow.ledger, "upsert_quote", None)
if callable(writer):
writer(
# 分段报价是产品手工价,必须走 upsertProductQuote。
# 旧代码误走 replaceTmsQuote,主账 inq_quote 不落、六态也不到「已报价」。
product_writer = getattr(flow.ledger, "upsert_product_quote", None)
if callable(product_writer):
product_writer(
work_order_no=no,
quote=payload,
to_status="已报价" if all_ready else "",
# 未齐段时保持询价中;空串会被主账默认成「已报价」。
to_status="已报价" if all_ready else "询价中",
remark="分段报价",
kind="product",
)
else:
writer = getattr(flow.ledger, "upsert_quote", None)
if callable(writer):
writer(
work_order_no=no,
quote=payload,
to_status="已报价" if all_ready else "",
)
ticket = _reload(flow, no) or ticket
_send_sheet(
flow=flow,
ticket=ticket,
chat_id=chat_id,
segment=segment,
quote=quote,
sheet_fn=sheet_fn,
)
if not skip_sheet:
_send_sheet(
flow=flow,
ticket=ticket,
chat_id=chat_id,
segment=segment,
quote=quote,
sheet_fn=sheet_fn,
)
client = group_client_of(flow)
if not all_ready:
send_group_text(client, chat_id, f"段{segment.index}{segment.word()}报价单已发出。其他段报完后一起跟进成交。")
tip = (
f"段{segment.index}{segment.word()}报价附件已收到。其他段报完后一起跟进成交。"
if skip_sheet
else f"段{segment.index}{segment.word()}报价单已发出。其他段报完后一起跟进成交。"
)
send_group_text(client, chat_id, tip)
return "segment_quoted"
text = deal_text(work_order_no=no, segments=segments, quotes=stored)
send_group_text(client, chat_id, text)
# 与海运一致:正文写 @销售姓名,并带 mention,企微才能真正点到人。
from agent.policy.sea_group_ops import resolve_group_mentions
sales_name, _products, _mention_ids = resolve_group_mentions(flow, ticket, None)
view = ticket_view(ticket)
sales_id = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
text = deal_text(
work_order_no=no,
segments=segments,
quotes=stored,
sales_name=sales_name,
)
send_group_text(
client,
chat_id,
text,
mention_userids=[sales_id] if sales_id else None,
)
carder = getattr(client, "send_group_card", None)
if callable(carder):
carder(
@@ -446,15 +621,23 @@ def _send_sheet(
quote: dict[str, Any],
sheet_fn: Optional[SheetFn],
) -> None:
"""按这一段的运输方式匹配模板。失败只记日志,不挡住报价归段。"""
"""
按这一段的运输方式匹配模板。失败只记日志,不挡住报价归段。
有价段报价单:发群同时写后台附件;激活空白模版(quote 无金额)不落库。
"""
sender = sheet_fn or send_filled_sheet
sheet_ticket = _sheet_ticket(ticket, segment)
# 段上已有金额/明细 → 正式出单,要进 inq_attachment;空 quote 是激活模版。
persist = _has_money(dict(quote or {}))
try:
sender(
flow=flow,
ticket=_sheet_ticket(ticket, segment),
ticket=sheet_ticket,
quote_override=dict(quote or {}),
business_line=segment.mode,
chat_id=chat_id,
persist_attachment=persist,
)
except Exception:
logger.exception("多段模板发送失败 wo=%s mode=%s", ticket_no(ticket), segment.mode)