员工岗位可重新启用;调价认空运航线;会话存档跳号补拉;陆运报价按车型列回填
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 ["舱位编号", "航司", "航班号"]
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user