机器人显示名与站点标题可配;多段联运列表投影;协同群监听对账;工单附件落存;模板与工单后台补强
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user