员工岗位可重新启用;调价认空运航线;会话存档跳号补拉;陆运报价按车型列回填

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-28 19:48:47 +08:00
co-authored by Cursor
parent 2b528758b4
commit 56ba8e93b5
62 changed files with 2960 additions and 793 deletions
@@ -546,6 +546,7 @@ class WeComAppClient:
return {}
info = data.get("chat_info") or data.get("chatinfo") or {}
return {
"name": str(info.get("name") or "").strip(),
"owner": str(info.get("owner") or "").strip(),
"userlist": [
str(x).strip()
@@ -278,6 +278,83 @@ def _poll_by_scan_users(
return merged
def _merge_records_by_seq(
*batches: Sequence[dict[str, Any]],
min_seq: int = 0,
) -> list[dict[str, Any]]:
"""按 seq 去重合并多页;同 seq 后写覆盖。只保留 seq > min_seq。"""
by_seq: dict[int, dict[str, Any]] = {}
for batch in batches:
for rec in batch or ():
seq = _record_seq(rec)
if seq <= min_seq:
continue
by_seq[seq] = dict(rec)
return [by_seq[s] for s in sorted(by_seq)]
def _backfill_room_seq_gap(
api: WshotoOpenClient,
*,
room: str,
last_seq: int,
rows: list[dict[str, Any]],
page_size: int,
lookback: int,
clock: Callable[[], float],
) -> list[dict[str, Any]]:
"""
增量结果跳号时,用时间窗补中间漏掉的消息。
现网:游标 9892,管家增量只回撤回 9899,把「解散群」9898 永久跨过。
若本页最小 seq > last_seq+1,再打一枪时间窗,按 seq 合并后返回。
补洞失败则原样返回(调用方仍按本页 max 推进,故补洞要尽量成功)。
"""
if last_seq <= 0 or not rows:
return rows
page_seqs = [_record_seq(r) for r in rows if _record_seq(r) > 0]
if not page_seqs:
return rows
min_page = min(page_seqs)
if min_page <= last_seq + 1:
return rows
now_i = int(clock())
window = min(max(60, int(lookback or 0)), _NEW_ROOM_LOOKBACK_SEC)
logger.warning(
"微盛房间增量跳号 chat=%s last=%s min_page=%s,时间窗补洞 window=%ss",
room,
last_seq,
min_page,
window,
)
try:
fill = api.list_group_messages(
userid="",
target_id=room,
last_seq=0,
page_size=page_size,
msg_time_start=now_i - window,
msg_time_end=now_i,
)
except WshotoError as exc:
logger.warning(
"微盛房间补洞失败 chat=%s code=%s msg=%s",
room,
exc.code,
exc.msg,
)
return rows
merged = _merge_records_by_seq(rows, fill or [], min_seq=last_seq)
if len(merged) > len(rows):
logger.info(
"微盛房间补洞成功 chat=%s added=%s total=%s",
room,
len(merged) - len(rows),
len(merged),
)
return merged
def _poll_by_rooms(
api: WshotoOpenClient,
*,
@@ -341,6 +418,17 @@ def _poll_by_rooms(
polled += 1
if not is_room_hot(room):
cold_polled += 1
# 增量若跳号:管家有时先吐出撤回等高 seq,游标一步跨过中间正文
# (现网:解散群 seq=9898 被撤回 9899 推过去后永远听不见)。跳号则时间窗补洞。
rows = _backfill_room_seq_gap(
api,
room=room,
last_seq=last,
rows=list(rows or []),
page_size=page_size,
lookback=lookback,
clock=clock,
)
max_seq = last
for rec in rows:
seq = _record_seq(rec)
+5 -2
View File
@@ -117,11 +117,14 @@ def handle_card_action(
wo = copy.work_order_from_card_meta(payload)
code = str((payload or {}).get("response_code") or extra.get("response_code") or "")[:24]
msgtype = str(extra.get("msgtype") or "text")
# 文本 ack 按工单+动作去重;置灰更新仍带 code(每次 ResponseCode 只能用一次)
# 文本/方案卡按工单+动作+waitVersion 去重。
# 调价后再点「生成」时正文前缀相同,若不带 waitVersion,第二次方案卡会被 PG 唯一键吃掉,
# 价格卡已置灰成「已提交」但销售收不到生成 Excel/PDF 的方案卡。
wait_ver = int(getattr(sess, "wait_version", 0) or 0) if sess is not None else 0
if msgtype == "update_template_card":
dedupe_key = f"card-grey:{sender_id}:{wo}:{code}:{action}"
else:
dedupe_key = f"card-ack:{sender_id}:{wo}:{action}:{body[:24]}"
dedupe_key = f"card-ack:{sender_id}:{wo}:{action}:v{wait_ver}:{body[:24]}"
ok, info = msg_store.enqueue_outbound(
touser=sender_id,
content=body,
@@ -19,6 +19,7 @@ from agent.policy.sea_group_ops import (
apply_tms_quote_choice,
capture_group_lost_reason,
dissolve_group,
dissolve_orphan_group,
group_client_of,
handle_group_file,
hydrate_ticket,
@@ -82,6 +83,24 @@ def handle_group_inbound(
ticket = _find_ticket(book, chat_id)
if ticket is None:
logger.info("group_inbound 对不上工单 chat=%s", chat_id)
# 老群/迁库丢绑定:对不上工单时,「解散群」仍按企微当前成员清人。
from agent.routing.group_intent import INTENT_DISSOLVE, classify_group_text
kind_orphan = (message.msg_type or "text").lower()
if kind_orphan in {"", "text"} and (
classify_group_text(message.content or "", injected=injected_intent)
== INTENT_DISSOLVE
):
logger.info(
"group_inbound 无工单仍解散 chat=%s sender=%s",
chat_id,
message.sender_id,
)
return dissolve_orphan_group(
flow=engine,
chat_id=chat_id,
sender_id=message.sender_id or "",
)
return "group_ignore"
if flow is None:
view0 = ticket_view(ticket)
@@ -156,6 +156,7 @@ def _drop_other_mode_drafts(*, owner: str, sender_id: str) -> None:
def _flow_waiting_lost_reason(sender_id: str, sea, air, land):
"""谁在等未成交原因就交给谁。刚记下未成交的那条优先。"""
from agent.policy.deal_outcome import ticket_missing_in_ledger
from agent.policy.handoff_human import is_handoff_frozen
waiting = []
@@ -165,6 +166,20 @@ def _flow_waiting_lost_reason(sender_id: str, sea, air, land):
continue
if (sess.phase or "").strip() != "wait_lost_reason":
continue
# 清数后主账无单:丢掉孤儿书签,不要继续抢走新询价。
if ticket_missing_in_ledger(
getattr(flow, "_ledger", None),
work_order_no=str(getattr(sess, "work_order_no", "") or ""),
sender_id=sender_id,
):
abandon = getattr(flow, "abandon_orphan_lost_wait", None)
if callable(abandon):
abandon(sess)
else:
drop = getattr(flow, "drop_current_bookmark", None)
if callable(drop):
drop(sender_id)
continue
waiting.append((int(getattr(sess, "wait_version", 0) or 0), flow))
if not waiting:
return None
@@ -202,10 +202,12 @@ def _handle_one(
watchers = [dict(x) for x in (row.get("watchers") or []) if isinstance(x, dict)]
sales = _ticket_sales(row, watchers)
people = pick_mentions(wait_kind=kind, sales=sales, members=watchers, business_line=line)
# 空运群往往只绑了群号,成员列表是空的,群里就找不到航线岗,话术会落成「相关人员」。
# 只补航线超时。销售超时不能改去 @ 航线,只 @ 本单销售。
if kind == WAIT_AIR_QUOTE and not people:
people = _airline_staff_mentions(book)
# 空运群往往只绑了群号;且航线超时必须按本单「负责线路」点名,禁止整岗广播。
# 与激活摘要 resolve_air_mentions 同口径。销售超时不能改去 @ 航线。
if kind == WAIT_AIR_QUOTE:
route_people = _airline_staff_mentions(book, facts=facts)
if route_people:
people = route_people
names = [str(p.get("name") or "").strip() for p in people if str(p.get("name") or "").strip()]
chat_id = str(row.get("collab_chat_id") or row.get("collabChatId") or "").strip()
where = conversation_of(wait_kind=kind, collab_chat_id=chat_id)
@@ -295,22 +297,42 @@ def _ticket_sales(row: dict[str, Any], watchers: list[dict[str, Any]]) -> dict[s
return matched
def _airline_staff_mentions(book: Any) -> list[dict[str, Any]]:
def _airline_staff_mentions(
book: Any,
*,
facts: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""
主账按岗位列出启用的航线同事。
主账列出启用航线·空运,再按本单负责线路过滤。
只在群成员里没有航线岗时调用。主账失败或没有这个接口时返回空,话术仍用「相关人员」。
不写工单。可与巡检线程并发,每次只读这一单的员工名单。
与空运群激活摘要同口径:对上起运/目的(或空运全线);对不上则只留全线。
主账失败或没有接口时返回空,话术仍用「相关人员」。不写工单。
"""
from agent.policy.deal_followup import air_staff_route_matches
lister = getattr(book, "list_staff_by_role", None)
if not callable(lister):
return []
try:
rows = list(lister(role_code="air") or [])
rows = [dict(x) for x in (lister(role_code="air") or []) if isinstance(x, dict)]
except Exception:
logger.exception("事件异常列航线岗失败")
return []
return mentions_for_role(rows, role_code="air")
matched = [
r
for r in rows
if air_staff_route_matches(routes=str(r.get("routes") or ""), facts=facts)
]
if not matched:
matched = [
r
for r in rows
if any(
t.strip() in {"空运全线", "全线"}
for t in str(r.get("routes") or "").replace(",", ",").replace("、", ",").split(",")
)
]
return mentions_for_role(matched, role_code="air")
def _send_app_cards(
+87 -1
View File
@@ -186,6 +186,20 @@ _TIGHTEN_LABELS = tuple(
)
)
_FEE_NAME = re.compile(r"^\*?[\u4e00-\u9fffA-Za-z0-9]{1,12}费$")
# 识图/标签错位:报价编号、工单号、长数字进了起运地/目的地。三字码航线不动。
_QUOTE_LIKE_PLACE = re.compile(
r"^(?:"
r"(?:WO|VT)\d{10,}"
r"|QT[-_]?\d{6,}"
r"|\d{8,}"
r"|[A-Za-z]{1,6}\d{8,}"
r")$",
re.I,
)
_QUOTE_NO_IN_TEXT = re.compile(
r"(?:报价编号|报价单编号|RFQ\s*No\.?|Quote\s*No\.?)\s*[::]?\s*([A-Za-z0-9\-_]{6,})",
re.I,
)
_SHEET_STOP = (
"报价明细",
"报价条件",
@@ -455,6 +469,64 @@ def _drop_fee_place_values(facts: dict[str, str]) -> dict[str, str]:
return out
def looks_like_quote_id_place(raw: str | None) -> bool:
"""
是否像报价编号/工单号,而不是起运地或目的地。
识图常把「报价编号:QT…」错贴到起运地。SZX、南京、SZX-CGK 不算。
"""
text = str(raw or "").strip()
if not text:
return False
if "报价编号" in text or "报价单编号" in text:
return True
# 去掉常见装饰后再判
cleaned = re.sub(r"[((]\s*(?:必填|非必填)\s*[))]", "", text).strip(" \t::,,。;;")
if not cleaned:
return False
if _QUOTE_LIKE_PLACE.match(cleaned):
return True
# 纯数字超过 7 位多半是系统编号,不是港口名
if re.fullmatch(r"\d{8,}", cleaned.replace(" ", "")):
return True
return False
def _quote_nos_mentioned(text: str) -> set[str]:
"""原话/识图正文里出现的报价编号值。"""
return {
m.group(1).strip()
for m in _QUOTE_NO_IN_TEXT.finditer(text or "")
if (m.group(1) or "").strip()
}
def _drop_quote_like_place_values(
facts: dict[str, str],
*,
source_text: str = "",
) -> dict[str, str]:
"""
起运地/目的地若是报价编号、工单号或原话「报价编号:」同一值,丢掉。
副作用:无写库;只清洗本轮 facts。
"""
out = dict(facts or {})
mentioned = _quote_nos_mentioned(source_text)
for key in ("起运港", "起运地", "目的港", "目的地"):
val = str(out.get(key) or "").strip()
if not val:
continue
if not (looks_like_quote_id_place(val) or val in mentioned):
continue
out.pop(key, None)
if key == "起运港":
out.pop("originCode", None)
if key == "目的港":
out.pop("destinationCode", None)
return out
def extract_inquiry_snapshot(
text: str,
*,
@@ -483,6 +555,7 @@ def extract_inquiry_snapshot(
if str(facts.get(key) or "").strip():
facts[key] = clip_inquiry_value(str(facts[key]))
facts = _drop_fee_place_values(_clip_sheet_inquiry_facts(facts))
facts = _drop_quote_like_place_values(facts, source_text=text)
facts = _apply_air_added_services(text, facts, mode)
subtype = str(facts.get("运输类型") or "").strip()
return {
@@ -539,6 +612,7 @@ def extract_inquiry_snapshot(
if str(facts.get(key) or "").strip():
facts[key] = clip_inquiry_value(str(facts[key]))
facts = _drop_fee_place_values(_clip_sheet_inquiry_facts(facts))
facts = _drop_quote_like_place_values(facts, source_text=text)
facts = _apply_air_added_services(text, facts, mode)
land_subtype = str((b_payload or {}).get("land_subtype") or facts.get("运输类型") or "").strip()
if land_subtype and not str(facts.get("运输类型") or "").strip():
@@ -1013,8 +1087,18 @@ _SKIP_FEE_NAMES = frozenset(
# 航线常写「空运费 单价CNY99,数量100,金额CNY199」。单价和金额不是同一个数,必须认金额。
# WO202610130003:协同句常见「空运费, KG, 单价CNY99.00, 数量100, 金额CNY9900」——费名与单价之间夹单位;
# WO202610140008:航线写「增值费_板,单价CNY10…」——下划线单位;漏认会落到金额误抽,出单默认单位「票」。
# WO202610190081:打板费,次,单价…——「次/人」等中文单位不在旧名单里时整句匹配失败,数量落成 1、金额=单价。
# 数量/金额前也可能只有空格无逗号。漏匹配会落到 _RE_FEE_LINE,把单价 99 当成行金额、数量被补成 1。
_FEE_UOM = r"KG|KGS|公斤|千克|TON|CBM|柜|票|板数|板|点|[A-Za-z]{1,8}"
# 业务单位表(长词/带斜杠优先,避免「次/人」被拆成「次」):
# 板数/点/人/天/货值/个/页数/柜/次/份/张/台/平方/件/计重/箱数/数量/千克/立方/卷/
# 单数/票数/次/人/车/小时/人/小时/公里数/月/趟/周/批/项 + 英文重量体积码。
# 末尾短中文通配:表外单位夹在费名与单价之间也不挡抽取。
_FEE_UOM = (
r"次/人|小时/人|板数|页数|箱数|单数|票数|公里数|货值|计重|平方|立方|千克|小时|"
r"数量|KG|KGS|公斤|TON|CBM|柜|票|板|点|次|人|天|个|份|张|台|件|卷|车|月|趟|周|批|项|"
r"托|套|袋|包|箱|"
r"[A-Za-z]{1,8}|[\u4e00-\u9fff]{1,3}"
)
_RE_FEE_PRICED = re.compile(
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)"
rf"(?:(?:\s*[,,、/]\s*|\s+|_)(?P<uom>{_FEE_UOM}))?"
@@ -1251,7 +1335,9 @@ def _parse_fee_rows(text: str, baseline_currency: str = "") -> list[dict[str, st
row[key] = val
rows.append(row)
priced_hits = 0
for hit in _RE_FEE_PRICED.finditer(raw):
priced_hits += 1
# 有「金额」用金额;只有单价时才用单价。禁止把单价误当成这一行的总金额。
amount = (hit.group("amount") or "").strip() or (hit.group("unit") or "").strip()
ccy = hit.group("amt_ccy") or hit.group("unit_ccy") or ""
@@ -117,6 +117,11 @@ def build_adjust_messages(text: str, fee_names: list[str]) -> list[dict[str, str
"只动费用合计(销售也可能说总销售价、总成本价、合计、总价、汇总价):wants_total=true,items 仍要一条(fee_name 可空),带 kind/direction/value。"
"kind 只能 percent 或 target;direction 只能 up/down/set。"
"value 必须来自原话数字;一成=10。禁止编造费用名和金额。"
"语义:上调/加/涨 X% → kind=percent direction=up;"
"下调/减/降 X% → kind=percent direction=down;"
"增加到/提升到/调整到/涨到/升到/增至/变为/上浮到/调到/改为 X% "
"→ kind=percent direction=set(改到原价的 X%,不是再上调 X%);"
"提升到/改为/变为 具体金额(无百分号)→ kind=target direction=set。"
"不是调价(问候、新询价、谢谢)时 is_adjust=false。"
f"当前可调费用名:{names}"
),
@@ -125,7 +125,8 @@ def build_extract_messages(text: str) -> list[dict[str, str]]:
"你是询价字段抽取器。只根据销售原话抽取字段,调用 extract_inquiry_fields。"
"未出现的字段必须空串,禁止编造港口、重量、日期。"
"空运件数/毛重/体积/包装方式;海运货量与箱型箱量;陆运按二级类型。"
"陆运整车/中港零担的车型写入车型/数量:45HQ(13.5厢车)、9米6/1、3T;"
"国内整车/跨境整车的车型写入车型/数量:45HQ(13.5厢车)、9米6/1、3T;"
"中港零担/集拼不要抽车型/数量;中港整车的车型写入车型/数量;"
"陆运不要把 45HQ/厢车 写成箱型箱量。"
"海运时 15件/20吨/3箱 必须写入货量,不要只写到件数把货量留空。"
"原话已带单位必须抽取,不要因为没写「毛重:」就留空:"
@@ -144,6 +145,7 @@ def build_extract_messages(text: str) -> list[dict[str, str]]:
"陆运「A到B」必须写入起运港和目的港。"
"原话是机场三字码或航线就原样记,禁止翻译成城市名:"
"SZX-CGK / SZX/CGK / SZX到CGK → 起运港=SZX、目的港=CGK,不要写成深圳、雅加达。"
"报价编号、报价单编号、工单号(WO/VT…)、长数字编号不是起运港或目的港,必须空串。"
"换城市必须覆盖旧值,不要沿用上一句的港口。"
"运输方式仅在原话能判断时填 AIR/SEA/LAND,否则 UNKNOWN。"
),
+126 -7
View File
@@ -13,7 +13,6 @@ import json
import logging
import re
import time
from pathlib import Path
from typing import Any, Optional
from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote
@@ -71,6 +70,60 @@ def _ticket_flight_date(ticket: object) -> str:
).strip()
def _quote_date_from_lock_instruction(instruction: str) -> str:
"""
锁舱补参句里的报价日期。
先认「报价日期:…」标签,再机读句末 yyyy-MM-dd(产品示例:
AIR-OPT01,南航,航班CZ327,2026-10-18)。不改主账。
"""
from agent.llm.extract_text import labeled_quote_date
from agent.schema.field_validate import _harvest_quote_date
labeled = labeled_quote_date(instruction or "")
if labeled:
return labeled
return _harvest_quote_date(instruction or "")
def _absorb_lock_quote_date(
*,
flow: Any,
ticket: object,
instruction: str,
work_order_no: str,
) -> tuple[object, str, str]:
"""
工单报价日空、锁舱句里又写了日期时,先写入主账再锁。
返回:(最新 ticket, 生效报价日, 本句抽出的日期)。
写失败不挡缺项提示;生效日仍可用本句日期做本地齐套判断。
"""
from agent.schema.field_validate import is_blank_quote_date
current = _ticket_flight_date(ticket)
said = _quote_date_from_lock_instruction(instruction)
if not said:
return ticket, current, ""
if not is_blank_quote_date(current):
return ticket, current, said
writer = getattr(flow.ledger, "update_facts", None)
write_ok = False
if callable(writer):
try:
out = writer(work_order_no=work_order_no, facts={"报价日期": said}) or {}
write_ok = bool(out.get("ok", True))
except Exception:
logger.exception("锁舱句写报价日期失败 wo=%s", work_order_no)
write_ok = False
if write_ok:
getter = getattr(flow.ledger, "get_ticket", None)
reloaded = getter(work_order_no=work_order_no) if callable(getter) else None
ticket2 = hydrate_ticket(flow, reloaded or ticket)
return ticket2, _ticket_flight_date(ticket2) or said, said
return ticket, said, said
def _lock_receipt_fields(
ticket: object,
quote: dict[str, Any],
@@ -451,6 +504,20 @@ def _total_matches_fee_row_sum(quote: dict[str, Any], total_text: str) -> bool:
return abs(total_num - summed) < 0.01
def _total_is_thin_vs_tms(cur_total: str, tms_total: str) -> bool:
"""
薄报价:明细加总明显小于 TMS 销售价(WO040:200 << 7918)才盖成销售价。
航线在舱位价上再加增值费/增点费时,total=明细之和会大于 salePrice(如 4966 > 3966),
此时必须保留航线总价,禁止再盖回 TMS。
"""
cur = _money_plain_number(cur_total)
tms = _money_plain_number(tms_total)
if cur is None or tms is None:
return False
return cur + 0.01 < tms
def _sheet_quote_with_tms_sale_total(
quote: dict[str, Any],
*,
@@ -490,7 +557,10 @@ def _sheet_quote_with_tms_sale_total(
out[key] = proj_val
if not tms_total:
return out
need_tms_total = (not cur_total) or cur_total == "-" or _total_matches_fee_row_sum(out, cur_total)
# 仅「空/薄报价加总」才盖 TMS;航线加出增值费后 total>salePrice 必须保留。
matches_sum = bool(cur_total) and cur_total != "-" and _total_matches_fee_row_sum(out, cur_total)
thin_vs_tms = matches_sum and _total_is_thin_vs_tms(cur_total, tms_total)
need_tms_total = (not cur_total) or cur_total == "-" or thin_vs_tms
if need_tms_total:
out["total"] = str(projected.get("total") or tms_total).strip()
if projected.get("currency") and not str(out.get("currency") or "").strip():
@@ -500,9 +570,13 @@ def _sheet_quote_with_tms_sale_total(
def resolve_air_mentions(flow: Any, ticket: object) -> tuple[str, list[str], list[str]]:
"""
空运群摘要必须 @ 到本单销售和航线·空运,对上海运 @ 销售/@ 产品。
空运群摘要 @ 本单销售 + 负责本单线路的航线·空运(按后台 routes 过滤)。
对上海运 @ 销售/@ 产品;禁止再按岗位整表广播。
"""
from agent.policy.deal_followup import air_staff_route_matches
view = ticket_view(ticket)
facts = dict(view.get("facts") or {})
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 == "销售":
@@ -519,7 +593,25 @@ def resolve_air_mentions(flow: Any, ticket: object) -> tuple[str, list[str], lis
rows = list(lister(role_code="air") or [])
except Exception:
logger.exception("列航线岗失败")
matched_rows: list[dict[str, Any]] = []
for row in rows:
if not isinstance(row, dict):
continue
if air_staff_route_matches(routes=str(row.get("routes") or ""), facts=facts):
matched_rows.append(row)
# 对不上具体线路时,只保留「空运全线」,避免再 @ 全岗
if not matched_rows:
for row in rows:
if not isinstance(row, dict):
continue
toks = [
t.strip()
for t in str(row.get("routes") or "").replace(",", ",").replace("、", ",").split(",")
if t.strip()
]
if any(t in {"空运全线", "全线"} for t in toks):
matched_rows.append(row)
for row in matched_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:
@@ -1062,6 +1154,13 @@ def apply_air_lock(
if blocked:
return blocked
no = ticket_no(ticket)
# 补参句带日期时先写入,避免舱位三项齐了仍反复要报价日期。
ticket, quote_date, _said_quote_date = _absorb_lock_quote_date(
flow=flow,
ticket=ticket,
instruction=instruction,
work_order_no=no,
)
name = operator_name(flow, sender_id)
view_quote = dict(ticket_view(ticket).get("quote") or {})
air_quote = dict((view_quote.get("segment_quotes") or {}).get("AIR") or {})
@@ -1079,7 +1178,9 @@ def apply_air_lock(
origin, destination, airline_label, flight_date = _lock_receipt_fields(
ticket, quote, picked, air_code
)
missing = _missing_lock_labels(picked, air_code, flight)
if not quote_date:
quote_date = _ticket_flight_date(ticket)
missing = _missing_lock_labels(picked, air_code, flight, quote_date=quote_date)
if missing and _quote_ready_for_lock(quote):
_remember_partial_lock(
flow,
@@ -1812,8 +1913,20 @@ def _option_row(quote: dict[str, Any], option_no: str) -> Optional[dict[str, Any
return None
def _missing_lock_labels(option_no: str, airline: str, flight_no: str) -> list[str]:
"""缺的中文名,顺序固定:舱位编号、航司、航班号。"""
def _missing_lock_labels(
option_no: str,
airline: str,
flight_no: str,
*,
quote_date: str = "",
) -> list[str]:
"""
缺的中文名,顺序固定:舱位编号、航司、航班号、报价日期。
报价日期空也要进缺项,避免先只补舱位三项、再被主账单独拦一次。
"""
from agent.schema.field_validate import is_blank_quote_date
missing: list[str] = []
if not (option_no or "").strip():
missing.append("舱位编号")
@@ -1821,12 +1934,18 @@ def _missing_lock_labels(option_no: str, airline: str, flight_no: str) -> list[s
missing.append("航司")
if not _FLIGHT_CODE_RE.match((flight_no or "").strip().upper()):
missing.append("航班号")
if is_blank_quote_date(quote_date):
missing.append("报价日期")
return missing
def _labels_from_incomplete_reason(reason: str) -> list[str]:
"""主账回了「缺少:…」时,按中文名收回缺项,丢掉英文字段名。"""
found = [label for label in ("舱位编号", "航司", "航班号") if label in (reason or "")]
found = [
label
for label in ("舱位编号", "航司", "航班号", "报价日期")
if label in (reason or "")
]
return found or ["舱位编号", "航司", "航班号"]
+14 -7
View File
@@ -216,15 +216,22 @@ def _fee_rows(quote: dict[str, Any] | None, facts: dict[str, str] | None) -> lis
)
src = dict(quote or {})
option = resolve_display_air_option(src, facts)
# 销售调价后扁平 fee_lines 是真相;舱位 feeItems 常仍是 TMS 原价/被合计盖住的单价。
source = str(src.get("source") or "").strip().upper()
label = str(src.get("source_label") or src.get("sourceLabel") or "").replace(" ", "")
prefer_flat = source == "SALES_ADJUST" or "销售调价" in label
rows: list[dict[str, Any]] = []
if option:
from agent.schema.air_route_options import option_fee_dicts
if prefer_flat:
rows = _named_rows(src.get("fee_lines") or src.get("fee_rows"))
if not rows:
option = resolve_display_air_option(src, facts)
if option:
from agent.schema.air_route_options import option_fee_dicts
# 与投影同一套:舱位缺 feeItems 时补整单顶层空运费,再拼起运/目的附加费。
rows = option_fee_dicts(option, src)
if not rows and len(parse_air_options(src)) > 1:
return []
# 与投影同一套:舱位缺 feeItems 时补整单顶层空运费,再拼起运/目的附加费。
rows = option_fee_dicts(option, src)
if not rows and len(parse_air_options(src)) > 1:
return []
if not rows:
rows = _named_rows(src.get("feeItems") or src.get("fee_items"))
if not rows:
@@ -2264,6 +2264,26 @@ class AirTextInquiryFlow:
)
return phase
def abandon_orphan_lost_wait(self, sess: FlowSession) -> None:
"""
主账已无此单,但仍停在「等未成交原因」:丢掉书签与进程内索引。
清数后常见:Redis 还挂着 WO,下一句永远 patch 失败,新询价也被抢走。
"""
sid = (sess.sender_id or "").strip()
no = (sess.work_order_no or "").strip()
if sid:
with self._lock:
self._by_sender.pop(sid, None)
self._delete_bookmark(sid)
if no:
with self._lock:
self._by_ticket.pop(no, None)
upper = no.upper()
for key in list(self._by_ticket.keys()):
if (key or "").upper() == upper:
self._by_ticket.pop(key, None)
def _capture_lost_reason(self, sess: FlowSession, text: str, reply: ReplyFn) -> str:
"""私聊未成交后收下原因,不再追问。"""
from agent.policy.deal_outcome import capture_lost_reason
@@ -2274,6 +2294,16 @@ class AirTextInquiryFlow:
reason=text,
)
if not result.get("ok"):
err = str(result.get("error") or "").strip()
# 清数后主账无单:不能再死循环「请再发一句」,否则挡死新询价。
if err in {"ticket_not_found", "not_found"}:
no = (sess.work_order_no or "").strip()
self.abandon_orphan_lost_wait(sess)
reply(
f"工单 {no or ''} 已不在系统中(可能已清数),无需再补未成交原因。"
"请直接发起新询价。"
)
return "orphan_cleared"
reply("未成交原因没有记下,请再发一句。")
return "wait_lost_reason"
sess.phase = "done"
+52 -9
View File
@@ -181,11 +181,33 @@ def _split_routes(raw: str) -> list[str]:
return out
def _air_od_codes(facts: dict[str, str] | None) -> tuple[str, str]:
"""从工单 facts 取空运起运/目的三字码(兼容港/地/投影字段)。"""
src = dict(facts or {})
origin = resolve_airport_code(
str(
src.get("起运港")
or src.get("起运地")
or src.get("originCode")
or src.get("origin_code")
or ""
)
)
dest = resolve_airport_code(
str(
src.get("目的港")
or src.get("目的地")
or src.get("destinationCode")
or src.get("destination_code")
or ""
)
)
return origin, dest
def air_route_keys(facts: dict[str, str] | None) -> set[str]:
"""本单可对上的空运线路名:三字码对 + 目的港。"""
src = dict(facts or {})
origin = resolve_airport_code(str(src.get("起运港") or src.get("originCode") or ""))
dest = resolve_airport_code(str(src.get("目的港") or src.get("destinationCode") or ""))
origin, dest = _air_od_codes(facts)
keys: set[str] = set()
if dest:
keys.add(dest)
@@ -194,6 +216,32 @@ def air_route_keys(facts: dict[str, str] | None) -> set[str]:
return keys
def air_staff_route_matches(*, routes: str, facts: dict[str, str] | None) -> bool:
"""
空运群 @ 航线:按员工「负责线路」是否对上本单。
与成交负责人推送同口径(全线 / 外采 / 三字码对),但不要求 isLeader。
仅有目的港、无起运时:后台常配 CGO-KUL,允许 token 以 -目的 结尾命中。
"""
tokens = _split_routes(routes)
if not tokens:
return False
if _has_all_line(tokens, line="AIR"):
return True
if any("外采-空运" in t or t == "外采-空运" for t in tokens) and _is_waicai(facts):
return True
origin, dest = _air_od_codes(facts)
keys = air_route_keys(facts)
if any(t in keys for t in tokens):
return True
# 只有目的港:KUL 应对上 CGO-KUL / SZX-KUL 等配线
if dest and not origin:
suffix = f"-{dest}"
if any(t == dest or t.endswith(suffix) for t in tokens):
return True
return False
def _is_waicai(facts: dict[str, str] | None) -> bool:
src = dict(facts or {})
for key in ("外采", "是否外采", "采购方式"):
@@ -251,12 +299,7 @@ def leader_matches(
if line == "AIR":
if code != "air":
return False
if _has_all_line(tokens, line="AIR"):
return True
if "外采-空运" in tokens and _is_waicai(facts):
return True
keys = air_route_keys(facts)
return any(t in keys for t in tokens)
return air_staff_route_matches(routes=routes, facts=facts)
return False
+30 -1
View File
@@ -7,8 +7,11 @@
from __future__ import annotations
import json
import logging
import re
import time
from pathlib import Path
from typing import Any, Callable, Optional
from agent.config import get_settings
@@ -16,12 +19,14 @@ from agent.policy import inquiry_copy as copy
from agent.policy.deal_followup import (
DEFAULT_NEGOTIATE_HOURS,
TIMEOUT_CODE_DEAL_NEGOTIATE,
air_route_keys,
next_followup_at,
pick_leaders,
)
logger = logging.getLogger(__name__)
EnqueueFn = Callable[..., tuple[bool, str]]
# 成交跟进里先认未成交,避免被「成交」误伤。
@@ -204,6 +209,7 @@ def finish_deal_outcome(
business_line=business_line,
)
enqueued = 0
enqueue_fails: list[str] = []
for row in leaders:
uid = str(row.get("wecomId") or "").strip()
if not uid:
@@ -218,9 +224,12 @@ def finish_deal_outcome(
if ok:
enqueued += 1
else:
enqueue_fails.append(f"{uid}:{info}")
logger.warning("成交通知入队失败 wo=%s uid=%s info=%s", no, uid, info)
except Exception:
except Exception as exc: # noqa: BLE001
enqueue_fails.append(f"{uid}:exc")
logger.exception("成交通知入队异常 wo=%s uid=%s", no, uid)
_ = exc
except Exception:
logger.exception("成交通知组装失败,仍继续入账 wo=%s", no)
summary = "通知未发出"
@@ -383,3 +392,23 @@ def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict
clear_wait(ledger, work_order_no=no)
return out
def ticket_missing_in_ledger(ledger: Any, *, work_order_no: str, sender_id: str = "") -> bool:
"""
主账已无此工单(清数/删单后)。
用于丢掉「等原因」孤儿书签,避免死循环「请再发一句」并挡住新询价。
"""
no = (work_order_no or "").strip()
if not no:
return True
getter = getattr(ledger, "get_for_agent", None)
if not callable(getter):
return False
try:
view = getter(work_order_no=no, sender_id=sender_id or "") or {}
except Exception:
logger.exception("查工单是否存在失败 wo=%s", no)
return False
return not bool(view.get("found"))
@@ -215,7 +215,7 @@ def mentions_for_role(
从员工行里取出启用、有企微、有姓名的指定岗位。
空运群激活只记群号,协同成员经常是空的。超时巡检在群成员里找不到航线岗时,
用本函数点名员工管理里的航线·空运,和进群摘要 @ 的是同一批人。
用本函数点名员工管理里已按本单线路筛过的航线·空运(与进群摘要同口径)。
不发消息、不写主账。
"""
want = (role_code or "").strip().lower()
+60 -41
View File
@@ -323,8 +323,10 @@ LAND_TMS_RETRYING = "正在重新查询TMS报价。工单:{wo}"
_LAND_LCL_KEYS = ("起运港", "目的港", "品名", "体积", "毛重", "报价日期")
_LAND_FTL_KEYS = ("起运港", "目的港", "品名", "车型/数量", "报价日期")
_LAND_HK_FULL_KEYS = ("起运港", "目的港", "品名", "报价日期")
_LAND_FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港零担/集拼"})
# 中港零担/集拼:核对卡/方案卡不展示车型/数量(中港整车要采车型/数量)
_LAND_HK_LCL_KEYS = ("起运港", "目的港", "品名", "报价日期")
_LAND_FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港整车"})
_LAND_HK_NO_VEHICLE_TYPES = frozenset({"中港零担/集拼"})
_LAND_CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港整车", "中港零担/集拼"})
_LAND_EXAMPLES = {
"起运港": "广州",
@@ -341,10 +343,10 @@ _LAND_EXAMPLES = {
def _land_cargo_keys(land_type: str, facts: dict[str, str] | None = None) -> tuple[str, ...]:
"""当前运输类型要展示的货物内部键。中港整车不要车型/数量。"""
"""当前运输类型要展示的货物内部键。仅中港零担/集拼不要车型/数量。"""
_ = facts
if land_type == "中港整车":
return _LAND_HK_FULL_KEYS
if land_type in _LAND_HK_NO_VEHICLE_TYPES:
return _LAND_HK_LCL_KEYS
if land_type in _LAND_FTL_TYPES:
return _LAND_FTL_KEYS
return _LAND_LCL_KEYS
@@ -629,7 +631,8 @@ def ask_clarify(
空运报价日期:说了就写成「报价日期:日期」,没说写成「报价日期(非必填):」,不进待补充。
海运报价日期写进已识别(销售说了用原话,没说用当天),不出现在待补充。
空运非必填一并列出,没填进待补充,不挡查价。
空运:仅当仍有必填缺口时,才把空着的非必填一并放进待补充;
必填已齐则返回空串,由核对卡引导回复「确定」,禁止只甩一堆非必填逼回复。
"""
src = dict(facts or {})
skip = {"报价日期"}
@@ -660,45 +663,59 @@ def ask_clarify(
val = known(src, key)
if val:
lines.append(f"{label}:{val}")
lines.append("")
lines.append(CLARIFY_PASTE_HINT)
any_missing = False
seq = 0
# 先判断有没有必填缺口。仅非必填空着时不发「请复制粘贴」,交给核对卡让销售直接回复确定。
required_items: list[tuple[str, str, str, str]] = []
if "运输方式" in missing_set:
any_missing = True
seq += 1
lines.append(_pending_field_block(seq, "运输方式", "空运"))
if not sea and not known(src, "起运港"):
any_missing = True
seq += 1
lines.append(_pending_field_block(seq, "起运地", _AIR_EXAMPLES["起运港"], optional=True))
required_items.append(("运输方式", "运输方式", "空运", ""))
for key, label, example in rows:
if key in missing_set:
any_missing = True
seq += 1
if sea and key == "运输分类":
lines.append(_pending_field_block(seq, "运输分类", "Port to Port、其他"))
required_items.append((key, "运输分类", "Port to Port、其他", ""))
else:
lines.append(
_pending_field_block(
seq,
label,
example,
unit=_PENDING_UNITS.get(key, ""),
)
required_items.append(
(key, label, example, _PENDING_UNITS.get(key, ""))
)
if not sea:
optional_pending = [
optional_pending: list[tuple[str, str, str]] = []
if not sea and required_items:
# 已有必填要补时,空运非必填一并列出;起运地排在目的地前面。
if not known(src, "起运港"):
optional_pending.append(
("起运港", "起运地", _AIR_EXAMPLES["起运港"])
)
optional_pending.extend(
(key, label, example)
for key, label, example in _AIR_OPTIONAL_CLARIFY
if key != "起运港" and not known(src, key)
]
for key, label, example in optional_pending:
any_missing = True
seq += 1
lines.append(_pending_field_block(seq, label, example, optional=True))
if not any_missing:
)
if not required_items:
return ""
lines.append("")
lines.append(CLARIFY_PASTE_HINT)
seq = 0
origin_block = ""
other_optional_blocks: list[str] = []
for key, label, example in optional_pending:
if key == "起运港":
seq += 1
origin_block = _pending_field_block(
seq, label, example, optional=True
)
# 起运地(非必填)紧挨目的地上面:有则先写起运地,再写必填缺口。
if origin_block:
lines.append(origin_block)
for _key, label, example, unit in required_items:
seq += 1
lines.append(
_pending_field_block(seq, label, example, unit=unit)
)
for key, label, example in optional_pending:
if key == "起运港":
continue
seq += 1
other_optional_blocks.append(
_pending_field_block(seq, label, example, optional=True)
)
lines.extend(other_optional_blocks)
return "\n".join(lines)
@@ -3179,19 +3196,21 @@ def air_no_tms_quote_to_lock(work_order_no: str) -> str:
def air_lock_need_params(missing=None) -> str:
"""
舱位编号、航司、航班号没齐时,只请航线补全。
舱位编号、航司、航班号(及报价日期空时)没齐时,只请航线补全。
这时还没锁舱,不能说 TMS 锁舱失败,也不能通知 IT。
缺哪项就点哪项;三项都缺时是产品指定的那一句。
缺哪项就点哪项;报价日期也空时示例末尾带日期。
"""
labels = [str(x).strip() for x in (missing or []) if str(x).strip()]
if not labels:
labels = ["舱位编号", "航司", "航班号"]
joined = "、".join(labels)
return (
f"锁舱参数不完整,缺少:{joined}。"
"请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。"
)
# 报价日期空:产品指定示例带日期,避免航线补完舱位三项后再被单独拦一次。
if "报价日期" in labels:
example = "AIR-OPT01,南航,航班CZ327,2026-10-18"
else:
example = "AIR-OPT01,南航,航班CZ327"
return f"锁舱参数不完整,缺少:{joined}。请补充后再锁,例如:{example}。"
def air_lock_need_quote(work_order_no: str) -> str:
+18 -1
View File
@@ -603,10 +603,27 @@ def _staff_rows(book: Any, seg: Segment) -> list[dict[str, Any]]:
return []
return [dict(x) for x in (lister(role_code="land") or []) if isinstance(x, dict)]
if seg.mode == "AIR":
from agent.policy.deal_followup import air_staff_route_matches
lister = getattr(book, "list_staff_by_role", None)
if not callable(lister):
return []
return [dict(x) for x in (lister(role_code="air") or []) if isinstance(x, dict)]
rows = [dict(x) for x in (lister(role_code="air") or []) if isinstance(x, dict)]
hits = [
r
for r in rows
if air_staff_route_matches(routes=str(r.get("routes") or ""), facts=seg.facts)
]
if not hits:
hits = [
r
for r in rows
if any(
t.strip() in {"空运全线", "全线"}
for t in str(r.get("routes") or "").replace(",", ",").split(",")
)
]
return hits
except Exception:
logger.exception("多段激活匹配产品失败 mode=%s", seg.mode)
return []
+13 -1
View File
@@ -87,7 +87,10 @@ def _reply_apply_messages(result, reply) -> None:
def _emit_private_card(flow: Any, sess: Any, reply) -> None:
"""新价格明细卡:海运/陆运带拉群,空运只带跳过协同。"""
"""新价格明细卡:海运/陆运带拉群,空运只带跳过协同。
空运货物信息用工单 facts(起运地/品名等);不传会整段变成「-」。
"""
line = (sess.business_line or "").upper()
no = sess.work_order_no
quote = dict(sess.quote or {})
@@ -95,6 +98,7 @@ def _emit_private_card(flow: Any, sess: Any, reply) -> None:
quote["business_line"] = line
first = getattr(sess, "first_or_same", "first") or "first"
history = getattr(sess, "history_work_order_no", "") or ""
facts = {str(k): str(v) for k, v in dict(getattr(sess, "facts", None) or {}).items()}
if line in {"SEA", "LAND"} and hasattr(flow, "_emit_sea_hit"):
sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB)
flow._emit_sea_hit(
@@ -114,6 +118,7 @@ def _emit_private_card(flow: Any, sess: Any, reply) -> None:
reply,
work_order_no=no,
quote=quote,
facts=facts,
first_or_same=first,
history_work_order_no=history,
status="已报价",
@@ -210,6 +215,13 @@ def try_private_adjust(
sess.quote = persist
sess.status = "已报价"
sess.phase = "wait_collab"
# 必须先写回允许点的按钮再存书签。若等 emit 后再改 allowed,
# Worker 重启后会读到空 allowed,销售点「生成报价单」会被静默丢掉。
line = (str(getattr(sess, "business_line", "") or "")).upper()
if line in {"SEA", "LAND"}:
sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB)
else:
sess.allowed = (copy.BTN_SKIP_COLLAB,)
sess.wait_version += 1
flow._save(sess)
_emit_private_card(flow, sess, reply)
+134 -2
View File
@@ -1200,11 +1200,132 @@ def handle_group_file(
return "file_archived"
def _live_appchat_member_ids(client: Any, chat_id: str) -> list[str]:
"""
读企微应用群当前成员。主账名单过期时必须靠这个清人。
失败回空列表,不抛。不写主账。
"""
raw = (chat_id or "").strip()
if not raw:
return []
info_fn = getattr(client, "_appchat_info", None)
if not callable(info_fn):
return []
try:
info = info_fn(chat_id=raw) or {}
except Exception:
logger.exception("读应用群成员失败 chat=%s", raw)
return []
return [
str(x).strip()
for x in (info.get("userlist") or [])
if str(x).strip()
]
def _merge_dismiss_userids(*groups: list[str]) -> list[str]:
"""合并待移出名单,去重保序。"""
out: list[str] = []
seen: set[str] = set()
for group in groups:
for uid in group or []:
u = str(uid or "").strip()
if not u or u in seen:
continue
seen.add(u)
out.append(u)
return out
def dissolve_orphan_group(
*,
flow: Any,
chat_id: str,
sender_id: str = "",
) -> str:
"""
主账对不上工单时仍解散应用群。
老群、迁库丢绑定、虚拟转人工未写主账都会走到这里。
以企微当前成员为准移人;群名像工单号则尽量标协同结束。
不改六态。先回话再移人。
"""
room = (chat_id or "").strip()
if not room:
return "group_ignore"
client = group_client_of(flow)
info_fn = getattr(client, "_appchat_info", None)
info: dict[str, Any] = {}
if callable(info_fn):
try:
info = info_fn(chat_id=room) or {}
except Exception:
logger.exception("孤儿群读成员失败 chat=%s", room)
info = {}
members = _merge_dismiss_userids(
list(info.get("userlist") or []),
[sender_id] if (sender_id or "").strip() else [],
)
name = str(info.get("name") or "").strip()
no = ""
try:
from agent.policy import inquiry_copy as _copy
hit = str(_copy.extract_work_order_no(name) or "").strip()
if hit:
no = hit
elif name.upper().startswith(("WO", "VT")):
no = name.strip()
except Exception:
no = name if name.upper().startswith(("WO", "VT")) else ""
display_no = no or name or "-"
send_group_text(client, room, copy.group_dissolved(work_order_no=display_no))
dismiss = getattr(client, "dismiss_group", None)
dismiss_ok = False
if callable(dismiss) and members:
out = dismiss(chat_id=room, userids=members) or {}
dismiss_ok = bool(out.get("ok"))
if not dismiss_ok:
logger.warning(
"孤儿群移人失败 chat=%s err=%s members=%s",
room,
out.get("error"),
len(members),
)
elif not members:
logger.warning("孤儿群无成员可移 chat=%s name=%s", room, name or "-")
try:
from agent.channel.wshoto.rooms import unwatch_collab_room
unwatch_collab_room(room)
except Exception:
logger.exception("孤儿群取消存档监听失败 chat=%s", room)
if no:
ender = getattr(getattr(flow, "ledger", None), "end_collab_group", None)
if callable(ender):
try:
ender(work_order_no=no)
except Exception:
logger.exception("孤儿群标协同结束失败 wo=%s", no)
logger.info(
"orphan_dissolve chat=%s name=%s wo=%s members=%s dismiss_ok=%s by=%s",
room,
name or "-",
no or "-",
len(members),
dismiss_ok,
(sender_id or "")[:32],
)
return "group_dissolved"
def dissolve_group(*, flow: Any, ticket: object, sess: Any = None) -> str:
"""
解散应用群,这单协同结束,不改六态。
先发「群已解散」再移人:移完人销售可能已不在群,收不到确认。
移人名单 = 主账/书签成员 ∪ 企微当前成员,避免名单过期清不干净。
"""
view = ticket_view(ticket)
no = ticket_no(ticket)
@@ -1213,11 +1334,22 @@ def dissolve_group(*, flow: Any, ticket: object, sess: Any = None) -> str:
members = list(view.get("collab_member_ids") or view.get("memberIds") or [])
if sess is not None:
members = list(sess.invited_userids or members)
live = _live_appchat_member_ids(client, chat_id)
members = _merge_dismiss_userids(members, live)
# 先回话再移人,避免销售被移出后看不到确认。
send_group_text(client, chat_id, copy.group_dissolved(work_order_no=no))
dismiss = getattr(client, "dismiss_group", None)
if callable(dismiss) and chat_id:
dismiss(chat_id=chat_id, userids=members)
if callable(dismiss) and chat_id and members:
out = dismiss(chat_id=chat_id, userids=members) or {}
if not out.get("ok"):
logger.warning(
"解散群移人失败 chat=%s wo=%s err=%s",
chat_id,
no,
out.get("error"),
)
elif chat_id and not members:
logger.warning("解散群无成员可移 chat=%s wo=%s", chat_id, no)
if chat_id:
try:
from agent.channel.wshoto.rooms import unwatch_collab_room
@@ -96,6 +96,18 @@ _CONFIRM_CHOICE = {
"沿用tms",
"沿用tms报价",
}
# 解散相近说法。禁止只认整句「解散群」,模型失败时也要能清群。
_DISSOLVE_HINTS = (
"解散群",
"解散这个群",
"解散该群",
"把群解散",
"这个群解散",
"群解散吧",
"解散吧",
"清群",
"散群",
)
_CONFIRM_HINTS = (
"确认报价",
"确认该报价",
@@ -195,6 +207,10 @@ def classify_group_text(
exact = _exact_tms_choice(raw)
if exact:
return exact
# 先认解散口令:避免模型关/失败时「解散群」掉进 other 静默无反应。
hinted_dissolve = _hint_dissolve(raw)
if hinted_dissolve:
return hinted_dissolve
try:
from agent.config import get_settings
@@ -303,6 +319,22 @@ def _hint_deal_outcome(text: str) -> str:
return ""
def _hint_dissolve(text: str) -> str:
"""
要把协同群解散。口令不写死整句「解散群」。
含金额的报价句不走这里(上层已先抽价)。整句很短且像清群时认。
"""
blob = _norm_intent_text(text)
if not blob:
return ""
if any(x in blob for x in _DISSOLVE_HINTS):
return INTENT_DISSOLVE
if "解散" in blob and "群" in blob:
return INTENT_DISSOLVE
return ""
def _classify_with_model(text: str, settings) -> str:
"""DeepSeek 只回一个意图词。失败空串。"""
from agent.llm.http_client import LlmHttpClient
+17 -10
View File
@@ -146,7 +146,6 @@ _LAND_COLLAB_BY_PAIR: dict[tuple[str, str], tuple[str, ...]] = {
("中港整车", "中港/中亚/中欧"): (
"客户名称",
"HS编码",
"车型/数量",
"贸易条款",
"货值",
"包装方式",
@@ -769,21 +768,29 @@ def _pricing_required_keys(
land_subtype: str,
facts: dict[str, str] | None = None,
) -> list[str]:
"""当前激活的首次查价内部键列表。中港整车/零担是两个运输类型。"""
"""
当前激活的首次查价内部键列表。中港整车/零担是两个运输类型。
合同里偶有「车型数量」等别名键,这里折成 normalize_facts 后的内部键,
避免销售已填「车型/数量」仍被判缺。
"""
_ = facts
line = (business_line or "").strip().upper()
pricing = contract.get("pricing_required") or {}
raw: list[str] = []
if line == "SEA":
return list((pricing.get("SEA") or {}).get("always") or [])
if line == "AIR":
return list((pricing.get("AIR") or {}).get("always") or [])
if line == "LAND":
raw = list((pricing.get("SEA") or {}).get("always") or [])
elif line == "AIR":
raw = list((pricing.get("AIR") or {}).get("always") or [])
# 空运选填键不得进首次查价必填:合同与 AIR_OPTIONAL_FIELDS 对齐,避免再逼销售补起运地。
optional_air = {key for key, _label in AIR_OPTIONAL_FIELDS} | {"报价日期"}
raw = [k for k in raw if k not in optional_air]
elif line == "LAND":
land = pricing.get("LAND") or {}
block = land.get(land_subtype) or {}
if not block:
return []
return list(block.get("always") or [])
return []
if block:
raw = list(block.get("always") or [])
return [_ALIASES.get(k, k) for k in raw]
_PLACE_KEYS = ("起运港", "目的港")
+170 -20
View File
@@ -23,9 +23,15 @@ _PERCENT_UP = re.compile(
r"(上调|上涨|上升|上浮|提高|提升|加|涨|升)\s*(\d+(?:\.\d+)?)\s*%"
)
_PERCENT_DOWN = re.compile(r"(下调|下降|降低|减|降)\s*(\d+(?:\.\d+)?)\s*%")
_TARGET = re.compile(
r"(提升到|减少到|上调到|下调到|加到|减到|调到|改为|改成)\s*(\d+(?:\.\d+)?)"
# 「增加到/调整到/涨到 110%」= 改到原价的 110%,不是上调 110%。必须先于绝对金额与相对上调。
_TO_VERBS = (
"增加到|提高到|提升到|减少到|上调到|下调到|调整到|上浮到|上涨到|上升到|"
"涨到|升到|加到|减到|调到|增至|变为|变成|改为|改成|调整为|调整成"
)
_PERCENT_TO = re.compile(rf"({_TO_VERBS})\s*(\d+(?:\.\d+)?)\s*%")
# (?!\d) 防止「增加到110%」回退成「加到11」;(?!\s*%) 把带百分号的留给 _PERCENT_TO。
_TARGET = re.compile(rf"({_TO_VERBS})\s*(\d+(?:\.\d+)?)(?!\d)(?!\s*%)")
_DAO_PERCENT = re.compile(rf"(?:{_TO_VERBS})\s*(\d+(?:\.\d+)?)\s*%")
_SPLIT = re.compile(r"[,,;;。!!??\n]|和(?=\S)")
_FEE_TOKEN = re.compile(r"([\u4e00-\u9fffA-Za-z0-9]{1,16}费)")
_TOTAL_WORDS = ("费用合计", "合计", "总价", "汇总价", "总销售价", "总成本价")
@@ -432,9 +438,74 @@ def row_currency(row: dict[str, Any] | None, fallback: str = "") -> str:
return prefix or (fallback or "").strip().upper().replace("RMB", "CNY")
def _parse_qty(raw: Any) -> Optional[float]:
"""数量/计费重转浮点;读不了返回 None。"""
if raw in (None, ""):
return None
try:
return float(str(raw).replace(",", "").strip())
except ValueError:
return None
def _row_or_quote_qty(row: dict[str, Any], quote: dict[str, Any] | None = None) -> Optional[float]:
"""
费用行数量;行上没有时用工单/舱位计费重兜底。
空运调价常只有 unitPrice+amount,quantity 在 chargeableWeightKg。
"""
qty = _parse_qty(row.get("quantity"))
if qty is not None:
return qty
qty = _parse_qty(row.get("chargeQuantity"))
if qty is not None:
return qty
src = dict(quote or {})
for key in ("chargeableWeightKg", "chargeable_weight_kg", "chargeableWeight"):
qty = _parse_qty(src.get(key))
if qty is not None:
return qty
opt = src.get("selected_air_option") or src.get("selectedAirOption")
if isinstance(opt, str) and opt.strip():
try:
opt = json.loads(opt)
except json.JSONDecodeError:
opt = None
if isinstance(opt, dict):
for key in ("chargeableWeightKg", "chargeable_weight_kg", "chargeableWeight"):
qty = _parse_qty(opt.get(key))
if qty is not None:
return qty
return None
def _apply_amount(row: dict[str, Any], amount: str) -> None:
"""
写入行金额;有数量时单价=金额÷数量,禁止把行合计原样塞进单价。
空运 KG:调价后金额是单价×计费重,展示读 unit_price;无数量(按票等)才单价=金额。
"""
row["amount"] = amount
row["unit_price"] = amount
qty_raw = row.get("quantity")
if qty_raw in (None, ""):
qty_raw = row.get("chargeQuantity")
qty_num: Optional[float] = None
if qty_raw not in (None, ""):
try:
qty_num = float(str(qty_raw).replace(",", "").strip())
except ValueError:
qty_num = None
amt_num, _ = parse_money(amount)
if qty_num is not None and abs(qty_num) > 1e-9 and amt_num is not None:
# 有计费数量:单价从行金额反推,避免 3385.8 被当成「每KG」
unit_plain = format_money(amt_num / qty_num, sample=str(row.get("unit_price") or row.get("unitPrice") or ""))
unit_plain = _plain_amount(unit_plain)
row["unit_price"] = unit_plain
row["unitPrice"] = unit_plain
else:
row["unit_price"] = amount
if row.get("unitPrice") not in (None, ""):
row["unitPrice"] = amount
for key in ("salePrice", "price", "value"):
if row.get(key) not in (None, ""):
row[key] = amount
@@ -518,7 +589,12 @@ def text_has_total_only_adjust(text: str) -> bool:
「总销售价改为180」仍是销售调价。点了「空运费」「报关费」则不是。不打模型。
"""
raw = text or ""
if not (_TARGET.search(raw) or _PERCENT_UP.search(raw) or _PERCENT_DOWN.search(raw)):
if not (
_TARGET.search(raw)
or _PERCENT_TO.search(raw)
or _PERCENT_UP.search(raw)
or _PERCENT_DOWN.search(raw)
):
return False
if not any(word in raw for word in _TOTAL_WORDS) and "报价" not in raw:
return False
@@ -546,27 +622,30 @@ def extract_adjust(
本地回退只给无网/模型失败用,不是正式识别。
allow_model=False 时禁止打模型。
"""
raw = (text or "").strip()
if injected is not None:
if isinstance(injected, AdjustExtract):
return injected
return _coerce_dao_percent(raw, injected)
items = []
for raw in injected.get("items") or []:
if not isinstance(raw, dict):
for row in injected.get("items") or []:
if not isinstance(row, dict):
continue
items.append(
AdjustItem(
fee_name=str(raw.get("fee_name") or "").strip(),
kind=str(raw.get("kind") or "percent"),
direction=str(raw.get("direction") or "up"),
value=float(raw.get("value") or 0),
fee_name=str(row.get("fee_name") or "").strip(),
kind=str(row.get("kind") or "percent"),
direction=str(row.get("direction") or "up"),
value=float(row.get("value") or 0),
)
)
return AdjustExtract(
is_adjust=bool(injected.get("is_adjust")),
items=items,
wants_total=bool(injected.get("wants_total")),
return _coerce_dao_percent(
raw,
AdjustExtract(
is_adjust=bool(injected.get("is_adjust")),
items=items,
wants_total=bool(injected.get("wants_total")),
),
)
raw = (text or "").strip()
if not raw:
return AdjustExtract(is_adjust=False)
names = [str(x).strip() for x in (fee_names or []) if str(x).strip()]
@@ -617,11 +696,16 @@ def extract_adjust(
items=merged,
wants_total=bool(modeled.wants_total),
)
return modeled
return _coerce_dao_percent(raw, modeled)
except Exception:
logger.exception("调价模型抽取失败,回退本地")
total_hit = any(w in raw for w in _TOTAL_WORDS)
has_op = bool(_PERCENT_UP.search(raw) or _PERCENT_DOWN.search(raw) or _TARGET.search(raw))
has_op = bool(
_PERCENT_UP.search(raw)
or _PERCENT_DOWN.search(raw)
or _PERCENT_TO.search(raw)
or _TARGET.search(raw)
)
fee_in_text = [n for n in names_sorted if n and n in raw]
if total_hit and has_op and not fee_in_text:
op = _item_from_chunk(raw, "")
@@ -634,7 +718,9 @@ def extract_adjust(
item = _item_from_chunk(raw, "")
if item:
items.append(item)
return AdjustExtract(is_adjust=bool(items or has_op), items=items)
return _coerce_dao_percent(
raw, AdjustExtract(is_adjust=bool(items or has_op), items=items)
)
def _local_fee_items(raw: str, names_sorted: list[str]) -> list[AdjustItem]:
@@ -664,10 +750,40 @@ def _local_fee_items(raw: str, names_sorted: list[str]) -> list[AdjustItem]:
return items
def _coerce_dao_percent(text: str, extract: AdjustExtract) -> AdjustExtract:
"""
「增加到110%」纠正为 percent/set。
模型常抽成 percent/up;本地旧正则会抽成绝对金额 target。两种都不能当上调。
"""
hit = _DAO_PERCENT.search(text or "")
if not hit or not extract.items:
return extract
val = float(hit.group(1))
fixed: list[AdjustItem] = []
changed = False
for item in extract.items:
same_num = abs(float(item.value) - val) < 1e-9
if item.kind == "percent" and item.direction in {"up", "down"} and same_num:
fixed.append(AdjustItem(item.fee_name, "percent", "set", val))
changed = True
elif item.kind == "target" and item.direction == "set" and same_num:
fixed.append(AdjustItem(item.fee_name, "percent", "set", val))
changed = True
else:
fixed.append(item)
if not changed:
return extract
return AdjustExtract(is_adjust=extract.is_adjust, items=fixed, wants_total=extract.wants_total)
def _item_from_chunk(chunk: str, fee_name: str) -> Optional[AdjustItem]:
to_pct = _PERCENT_TO.search(chunk)
up = _PERCENT_UP.search(chunk)
down = _PERCENT_DOWN.search(chunk)
target = _TARGET.search(chunk)
if to_pct:
return AdjustItem(fee_name=fee_name, kind="percent", direction="set", value=float(to_pct.group(2)))
if target:
return AdjustItem(fee_name=fee_name, kind="target", direction="set", value=float(target.group(2)))
if up:
@@ -1167,6 +1283,10 @@ def _apply_total_only(
if item.kind == "target":
new_amt = float(item.value)
how = f"改到 {item.value:g}"
elif item.kind == "percent" and item.direction == "set":
# 「费用合计增加到110%」= 改到原合计的 110%,不是上调 110%
new_amt = old_total * (float(item.value) / 100.0)
how = f"调到 {item.value:g}%"
elif item.direction == "down":
new_amt = old_total * (1 - float(item.value) / 100.0)
how = f"下调 {item.value:g}%"
@@ -1311,15 +1431,45 @@ def apply_adjust(
if old_amt is None:
continue
line_ccy = row_currency(row, header) or prefix
if item.kind == "target":
old_up, _ = parse_money(row.get("unit_price") or row.get("unitPrice"))
qty_num = _row_or_quote_qty(row, src)
# 有单价且有计费重:比例调价按「单价」缩放,再算行金额=单价×数量。
# 否则会把行合计×110% 后整笔写进单价(15→42633.53)。
scale_by_unit = (
item.kind == "percent"
and old_up is not None
and qty_num is not None
and abs(qty_num) > 1e-9
and abs(old_up - old_amt) > 1e-6
)
if scale_by_unit:
if item.direction == "set":
new_up = old_up * (float(item.value) / 100.0)
how = f"调到 {item.value:g}%"
elif item.direction == "down":
new_up = old_up * (1 - float(item.value) / 100.0)
how = f"下调 {item.value:g}%"
else:
new_up = old_up * (1 + float(item.value) / 100.0)
how = f"上调 {item.value:g}%"
new_amt = new_up * qty_num
if row.get("quantity") in (None, ""):
row["quantity"] = f"{qty_num:.10g}"
elif item.kind == "target":
new_amt = float(item.value)
how = f"改到 {item.value:g}"
elif item.kind == "percent" and item.direction == "set":
# 「运输费增加到110%」= 原价 × 110%,不是上调 110%(×210%)
new_amt = old_amt * (float(item.value) / 100.0)
how = f"调到 {item.value:g}%"
elif item.direction == "down":
new_amt = old_amt * (1 - float(item.value) / 100.0)
how = f"下调 {item.value:g}%"
else:
new_amt = old_amt * (1 + float(item.value) / 100.0)
how = f"上调 {item.value:g}%"
if qty_num is not None and row.get("quantity") in (None, ""):
row["quantity"] = f"{qty_num:.10g}"
_apply_amount(row, format_money(new_amt, sample=str(row.get("amount") or ""), currency=line_ccy or prefix))
rows[idx] = row
if not any_applied:
+10 -7
View File
@@ -4,7 +4,7 @@
本文件职责:核对卡通过后、建单查价前,确认当前运输类型的货物字段能拼出查价正文。
带去:运输类型、线路类别、始发/目的/品名/报价日期,以及该类型的体积重量或车型/数量;通关口岸有值才带。
出站类型/线路译成 TMS 用的值,线路键名 routeCategory。工单与卡片仍用销售中文。
中港整车、中港零担/集拼出站类型都只写「中港」。中港整车不带车型/数量。
中港整车、中港零担/集拼出站类型按细类原文传。中港整车带车型/数量;仅中港零担/集拼不带。
禁止:把运输分类放进出站;直连 TMS;改六态。
线程:纯函数。
"""
@@ -20,8 +20,10 @@ from agent.schema.field_validate import apply_quote_date_default, normalize_fact
logger = logging.getLogger(__name__)
LCL_TYPES = frozenset({"国内运输拼车", "跨境集拼"})
FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港零担/集拼"})
HK_FTL_TYPE = "中港整车"
FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港整车"})
# 仅中港零担/集拼:询价不采车型/数量,出站也不带。
HK_NO_VEHICLE_TYPES = frozenset({"中港零担/集拼"})
HK_FTL_TYPE = "中港整车" # 兼容旧调用名;现与国内/跨境整车同属 FTL_TYPES
CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港整车", "中港零担/集拼"})
_TYPE_ENUM = {
@@ -29,8 +31,8 @@ _TYPE_ENUM = {
"国内运输整车": "DOMESTIC_FTL",
"跨境集拼": "CROSS_BORDER_LTL",
"跨境整车": "CROSS_BORDER_FTL",
"中港整车": "中港",
"中港零担/集拼": "中港",
"中港整车": "中港整车",
"中港零担/集拼": "中港零担/集拼",
}
_ROUTE_ENUM = {
"国内长途/零担": "DOMESTIC_LONG_LTL",
@@ -45,7 +47,7 @@ _TAIL_QTY = re.compile(r"^(.*?)[\s,,]+(\d+)\s*(台|辆)?$")
def tms_land_type_code(raw: str) -> str:
"""销售中文运输类型 → 查价 landTransportType。中港两类都是「中港」。对不上空串。"""
"""销售中文运输类型 → 查价 landTransportType。中港两类按细类原文。对不上空串。"""
text = (raw or "").strip()
if text in _TYPE_ENUM:
return _TYPE_ENUM[text]
@@ -140,7 +142,8 @@ def assemble_land_query(facts: dict[str, Any] | None) -> dict[str, Any]:
missing.append("毛重")
else:
outbound["weightKg"] = wgt
elif land_type == HK_FTL_TYPE:
elif land_type in HK_NO_VEHICLE_TYPES:
# 中港零担/集拼:不要车型/数量,也不改要体积毛重
pass
elif land_type in FTL_TYPES:
combo = (