空运人工价锁舱现拉补全舱位并拦清缺参;成交/报价填表与工单陆运费率展示同步收紧。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -40,8 +40,18 @@ class HttpLedger:
|
||||
def _post(self, path: str, body: dict[str, Any], *, idem: str = "") -> dict[str, Any]:
|
||||
resp = self._client.request_json("POST", path, json_body=body, idempotency_key=idem)
|
||||
if not resp.ok:
|
||||
logger.warning("http_ledger fail path=%s status=%s err=%s", path, resp.status_code, resp.error)
|
||||
return {"ok": False, "error": resp.error or f"http_{resp.status_code}"}
|
||||
# 500 时 Jeecg 仍可能带回 message(如六态非法);勿只留 http_500,
|
||||
# 否则成交补「已报价」重试认不出「询价中」(WO023)。
|
||||
data = resp.data if isinstance(resp.data, dict) else {}
|
||||
msg = ""
|
||||
if isinstance(data, dict):
|
||||
msg = str(data.get("message") or "").strip()
|
||||
unwrapped = _unwrap(data)
|
||||
if unwrapped.get("error"):
|
||||
msg = str(unwrapped.get("error") or msg)
|
||||
err = msg or resp.error or f"http_{resp.status_code}"
|
||||
logger.warning("http_ledger fail path=%s status=%s err=%s", path, resp.status_code, err)
|
||||
return {"ok": False, "error": err}
|
||||
return _unwrap(resp.data)
|
||||
|
||||
def find_similar_open(
|
||||
@@ -166,8 +176,12 @@ class HttpLedger:
|
||||
"""
|
||||
no = (work_order_no or "").strip()
|
||||
src = dict(quote or {})
|
||||
if not no or not src:
|
||||
return {"ok": True, "quote_version": 1, "status": to_status or "已报价"}
|
||||
if not no:
|
||||
return {"ok": False, "error": "work_order_blank"}
|
||||
# 空报价不打主账(避免抹掉已有查价),但禁止伪装成「已写入已报价」——
|
||||
# 否则会话以为已报价、主账仍停询价中,成交三态会失败(WO022)。
|
||||
if not src:
|
||||
return {"ok": False, "error": "quote_blank", "skipped": True}
|
||||
out = self._post(
|
||||
"/inquiry/agent/ticket/replaceTmsQuote",
|
||||
{"workOrderNo": no, "quote": src, "toStatus": to_status},
|
||||
@@ -423,6 +437,7 @@ class HttpLedger:
|
||||
operator_name: str = "",
|
||||
instruction_text: str = "",
|
||||
airline: str = "",
|
||||
flight_no: str = "",
|
||||
) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {
|
||||
"workOrderNo": work_order_no,
|
||||
@@ -433,6 +448,8 @@ class HttpLedger:
|
||||
}
|
||||
if (airline or "").strip():
|
||||
body["airline"] = airline.strip().upper()
|
||||
if (flight_no or "").strip():
|
||||
body["flightNo"] = flight_no.strip().upper()
|
||||
return self._post(
|
||||
"/inquiry/agent/tms/lockCabin",
|
||||
body,
|
||||
|
||||
@@ -827,9 +827,14 @@ class MemoryLedger:
|
||||
operator_name: str = "",
|
||||
instruction_text: str = "",
|
||||
airline: str = "",
|
||||
flight_no: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""单测可注入 _cabin_lock_result;默认成功。"""
|
||||
_ = sender_id, operator_name, instruction_text, airline
|
||||
"""单测可注入 _cabin_lock_result;默认成功。
|
||||
|
||||
纯人工且视图无 TMS 舱位时,缺 option/航司/航班 → lock_incomplete,
|
||||
对齐主账「现拉仍缺才回」的口径(内存账不会真查 TMS)。
|
||||
"""
|
||||
_ = sender_id, operator_name, instruction_text
|
||||
forced = getattr(self, "_cabin_lock_result", None)
|
||||
if isinstance(forced, dict):
|
||||
return dict(forced)
|
||||
@@ -845,6 +850,27 @@ class MemoryLedger:
|
||||
quote_id = str(quote.get("quoteId") or "").strip()
|
||||
if quote_id.upper().startswith("QT-WO"):
|
||||
quote_id = ""
|
||||
air_opts = quote.get("airOptions") or quote.get("air_options") or []
|
||||
has_tms_cabin = bool(quote_id) or bool(air_opts)
|
||||
opt = (option_no or "").strip()
|
||||
air = (airline or "").strip()
|
||||
fn = (flight_no or "").strip()
|
||||
if not has_tms_cabin:
|
||||
missing: list[str] = []
|
||||
if not opt:
|
||||
missing.append("舱位编号optionNo")
|
||||
if not air:
|
||||
missing.append("航司airline")
|
||||
if not fn:
|
||||
missing.append("航班号flightNo")
|
||||
if missing:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "lock_incomplete",
|
||||
"failReason": "锁舱参数不完整,缺少:"
|
||||
+ "、".join(missing)
|
||||
+ "。请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。",
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"status": "LOCKED",
|
||||
|
||||
@@ -757,6 +757,30 @@ _RE_MONEY = re.compile(
|
||||
r"|(?:USD|CNY|RMB|美金|美元)\s*(\d+(?:\.\d+)?)",
|
||||
re.I,
|
||||
)
|
||||
# 陆运集拼:「2-500KG 单价CNY990」是分档单价,不是「燃油附加费=2」。
|
||||
_RE_WEIGHT_BAND_PRICE = re.compile(
|
||||
r"(?P<min>\d+)\s*[-~~—–到至]\s*(?P<max>\d+)\s*K\s*G"
|
||||
r"(?:\s*以上)?"
|
||||
r"\s*(?:单价)?"
|
||||
r"\s*(?:(?P<ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
|
||||
r"(?P<price>\d+(?:\.\d+)?)",
|
||||
re.I,
|
||||
)
|
||||
_RE_WEIGHT_BAND_OPEN = re.compile(
|
||||
r"(?P<min>\d+)\s*K\s*G\s*以上"
|
||||
r"\s*(?:单价)?"
|
||||
r"\s*(?:(?P<ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
|
||||
r"(?P<price>\d+(?:\.\d+)?)",
|
||||
re.I,
|
||||
)
|
||||
_RE_FUEL_SURCHARGE_RATE = re.compile(
|
||||
r"燃油附加费率\s*[::=]?\s*(?P<rate>\d+(?:\.\d+)?)\s*%?",
|
||||
re.I,
|
||||
)
|
||||
_RE_TAX_RATE = re.compile(
|
||||
r"(?<![A-Za-z\u4e00-\u9fff])税率\s*[::=]?\s*(?P<rate>\d+(?:\.\d+)?)\s*%?",
|
||||
re.I,
|
||||
)
|
||||
_RE_FEE_LINE = re.compile(
|
||||
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费|[A-Za-z]{2,10})"
|
||||
r"\s*(?:改为|改成|调整为|调整成)?"
|
||||
@@ -867,6 +891,8 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
||||
认不出价格返回空 dict,调用方回「没有识别到报价」。
|
||||
群里常见「码头操作费100CNY / 报关费改为300CNY」,不能只把第一笔数当成海运费。
|
||||
「空运费 单价…金额…」必须保留空运费,金额用「金额」后面的数,不用单价。
|
||||
陆运集拼:「2-500KG 单价CNY990,燃油附加费率1%,税率6%」走分档单价+费率,
|
||||
禁止把公斤档下限「2」当成燃油附加费金额(WO021)。
|
||||
"""
|
||||
if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")):
|
||||
out = dict(injected)
|
||||
@@ -879,16 +905,22 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
||||
compact = stripped.replace(" ", "").replace(" ", "")
|
||||
if re.fullmatch(r"WO\d{12}", compact, re.I):
|
||||
return {}
|
||||
rows = _parse_fee_rows(raw)
|
||||
band = _parse_weight_band_quote(raw)
|
||||
if band:
|
||||
return band
|
||||
# 费率句若仍落到普通费用行,会把「燃油附加费」配上句首公斤数。先剥掉再抽。
|
||||
scrubbed = _strip_rate_and_band_noise(raw)
|
||||
rows = _parse_fee_rows(scrubbed) if scrubbed.strip() else []
|
||||
if rows:
|
||||
ccy = rows[0].get("currency") or _currency_of(raw)
|
||||
total = rows[0]["amount"]
|
||||
if len({x.get("currency") for x in rows}) == 1:
|
||||
money_rows = [x for x in rows if "%" not in str(x.get("amount") or "")]
|
||||
total = (money_rows[0] if money_rows else rows[0])["amount"]
|
||||
if money_rows and len({x.get("currency") for x in money_rows}) == 1:
|
||||
try:
|
||||
total = str(sum(float(x["amount"]) for x in rows))
|
||||
total = str(sum(float(x["amount"]) for x in money_rows))
|
||||
except ValueError:
|
||||
total = rows[0]["amount"]
|
||||
return {
|
||||
total = money_rows[0]["amount"]
|
||||
out = {
|
||||
"total": total,
|
||||
"currency": ccy,
|
||||
"source_label": "产品报价",
|
||||
@@ -896,9 +928,12 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
||||
"fee_rows": rows,
|
||||
"fee_lines": list(rows),
|
||||
}
|
||||
hit = _RE_MONEY.search(_ascii_amount(raw))
|
||||
out.update(_extract_rate_fields(raw))
|
||||
return out
|
||||
hit = _RE_MONEY.search(_ascii_amount(scrubbed or raw))
|
||||
if not hit:
|
||||
return {}
|
||||
rates = _extract_rate_fields(raw)
|
||||
return rates if rates else {}
|
||||
amount = _ascii_amount(hit.group(1) or hit.group(2) or "")
|
||||
if not amount:
|
||||
return {}
|
||||
@@ -906,12 +941,13 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
||||
# 句里已经点了费用名(空运费)时,不能退回默认海运费。
|
||||
named = re.search(
|
||||
r"([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)",
|
||||
raw,
|
||||
scrubbed or raw,
|
||||
)
|
||||
fee_name = (named.group(1) if named else "") or "海运费"
|
||||
if fee_name in {"费用"}:
|
||||
fee_name = "海运费"
|
||||
return {
|
||||
if fee_name in {"费用", "燃油附加费"}:
|
||||
# 「燃油附加费率」里的「燃油附加费」不是费用行名;无其它费名时用运费。
|
||||
fee_name = "海运费" if fee_name == "费用" else "陆运费"
|
||||
out = {
|
||||
"total": amount,
|
||||
"currency": ccy,
|
||||
"source_label": "产品报价",
|
||||
@@ -919,3 +955,113 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
||||
"fee_rows": [{"name": fee_name, "amount": amount, "currency": ccy}],
|
||||
"fee_lines": [{"name": fee_name, "amount": amount, "currency": ccy}],
|
||||
}
|
||||
out.update(_extract_rate_fields(raw))
|
||||
return out
|
||||
|
||||
|
||||
def _format_percent_rate(raw: str) -> str:
|
||||
"""费率展示统一带 %;已有 % 保留。"""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
if text.endswith("%"):
|
||||
return text
|
||||
return f"{text}%"
|
||||
|
||||
|
||||
def _extract_rate_fields(text: str) -> dict[str, str]:
|
||||
"""抽出燃油附加费率 / 税率;只返回出现过的键。"""
|
||||
out: dict[str, str] = {}
|
||||
fuel = _RE_FUEL_SURCHARGE_RATE.search(text or "")
|
||||
if fuel:
|
||||
out["fuel_surcharge_rate"] = _format_percent_rate(fuel.group("rate") or "")
|
||||
out["fuelSurchargeRate"] = out["fuel_surcharge_rate"]
|
||||
tax = _RE_TAX_RATE.search(text or "")
|
||||
if tax:
|
||||
out["tax_rate"] = _format_percent_rate(tax.group("rate") or "")
|
||||
out["taxRate"] = out["tax_rate"]
|
||||
return out
|
||||
|
||||
|
||||
def _strip_rate_and_band_noise(text: str) -> str:
|
||||
"""去掉分档价与费率短语,避免后续费用行/金额回退误伤。"""
|
||||
cleaned = _ascii_amount(text or "")
|
||||
cleaned = _RE_WEIGHT_BAND_PRICE.sub(" ", cleaned)
|
||||
cleaned = _RE_WEIGHT_BAND_OPEN.sub(" ", cleaned)
|
||||
cleaned = _RE_FUEL_SURCHARGE_RATE.sub(" ", cleaned)
|
||||
cleaned = _RE_TAX_RATE.sub(" ", cleaned)
|
||||
return cleaned
|
||||
|
||||
|
||||
def _parse_weight_band_quote(text: str) -> dict[str, Any]:
|
||||
"""
|
||||
陆运集拼分档单价:2-500KG 单价CNY990 + 可选燃油附加费率/税率。
|
||||
|
||||
产出 weightPrices,供主账出单按「2-500KG」表头对档;费率进顶层字段填 Excel。
|
||||
"""
|
||||
raw = _ascii_amount(text or "")
|
||||
tiers: list[dict[str, Any]] = []
|
||||
rows: list[dict[str, str]] = []
|
||||
ccy = ""
|
||||
for hit in _RE_WEIGHT_BAND_PRICE.finditer(raw):
|
||||
lo = int(hit.group("min") or 0)
|
||||
hi = int(hit.group("max") or 0)
|
||||
price = _ascii_amount(hit.group("price") or "")
|
||||
if not price:
|
||||
continue
|
||||
line_ccy = _currency_of(raw, hit.group("ccy") or "")
|
||||
ccy = ccy or line_ccy
|
||||
label = f"{lo}-{hi}KG"
|
||||
tiers.append({"min": lo, "max": hi, "price": price})
|
||||
rows.append(
|
||||
{
|
||||
"name": label,
|
||||
"amount": price,
|
||||
"currency": line_ccy,
|
||||
"unit_price": price,
|
||||
}
|
||||
)
|
||||
for hit in _RE_WEIGHT_BAND_OPEN.finditer(raw):
|
||||
lo = int(hit.group("min") or 0)
|
||||
price = _ascii_amount(hit.group("price") or "")
|
||||
if not price:
|
||||
continue
|
||||
line_ccy = _currency_of(raw, hit.group("ccy") or "")
|
||||
ccy = ccy or line_ccy
|
||||
label = f"{lo}KG以上"
|
||||
tiers.append({"min": lo, "max": None, "price": price})
|
||||
rows.append(
|
||||
{
|
||||
"name": label,
|
||||
"amount": price,
|
||||
"currency": line_ccy,
|
||||
"unit_price": price,
|
||||
}
|
||||
)
|
||||
if not tiers:
|
||||
return {}
|
||||
rates = _extract_rate_fields(raw)
|
||||
# 费率行进明细方便群摘要展示;金额带 %,合计时只加数字价。
|
||||
if rates.get("fuel_surcharge_rate"):
|
||||
rows.append(
|
||||
{
|
||||
"name": "燃油附加费率",
|
||||
"amount": rates["fuel_surcharge_rate"],
|
||||
"currency": "",
|
||||
}
|
||||
)
|
||||
if rates.get("tax_rate"):
|
||||
rows.append({"name": "税率", "amount": rates["tax_rate"], "currency": ""})
|
||||
# 分档是单价,不能把多档相加冒充费用合计。
|
||||
out: dict[str, Any] = {
|
||||
"total": tiers[0]["price"],
|
||||
"currency": ccy or "CNY",
|
||||
"source_label": "产品报价",
|
||||
"raw_text": (text or "").strip(),
|
||||
"fee_rows": rows,
|
||||
"fee_lines": list(rows),
|
||||
"weightPrices": tiers,
|
||||
"price_per_kg": tiers[0]["price"],
|
||||
}
|
||||
out.update(rates)
|
||||
return out
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
|
||||
本文件职责:群里副作用(主账 + BOT 回话)。海运应用群仍走 sea_group_ops。
|
||||
禁止:自动建群/拉人/解散;拿当前工单去猜锁舱工单号。
|
||||
锁舱按 TMS /air/cabin/lock 拼字段出站;没有 TMS 报价编号也要锁,用工单字段补 quoteId/optionNo。
|
||||
锁舱按 TMS /air/cabin/lock 拼字段出站;没有 TMS 报价编号也可走 MANUAL。
|
||||
禁止编造 optionNo;缺舱位编号/航司/计费重时先群里补问,禁止空壳打 TMS。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -776,22 +777,46 @@ def apply_air_deal(
|
||||
if stopped:
|
||||
return stopped
|
||||
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
|
||||
from agent.policy.sea_group_ops import facts_of_group
|
||||
from agent.policy.sea_group_ops import _quote_fee_rows, facts_of_group
|
||||
|
||||
view = ticket_view(ticket)
|
||||
no = ticket_no(ticket)
|
||||
quote = dict(view.get("quote") or {})
|
||||
status_now = ticket_status(ticket)
|
||||
usable_total = str(quote.get("total") or "").strip()
|
||||
if usable_total in {"", "-"}:
|
||||
usable_total = ""
|
||||
# 与海运一致:询价中且无报价时先说清原因,禁止笼统「主账状态更新失败」。
|
||||
if not (usable_total or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
|
||||
send_group_text(
|
||||
group_client_of(flow), chat_id, copy.deal_fail_no_quote(work_order_no=no)
|
||||
)
|
||||
return "deal_need_quote"
|
||||
inline = harvest_inline_lost_reason(text) if outcome == "未成交" else ""
|
||||
result = finish_deal_outcome(
|
||||
ledger=flow.ledger,
|
||||
work_order_no=ticket_no(ticket),
|
||||
work_order_no=no,
|
||||
outcome=outcome,
|
||||
facts=facts_of_group(view),
|
||||
quote=dict(view.get("quote") or {}),
|
||||
quote=quote,
|
||||
business_line="AIR",
|
||||
sales_name=str(view.get("sales_name") or ""),
|
||||
lost_reason=inline,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
send_group_text(group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。")
|
||||
# 迁态失败且仍无报价时,同样用「没有报价」说明,避免旧笼统句。
|
||||
err = str(result.get("error") or "")
|
||||
still_no_quote = not (
|
||||
usable_total or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}
|
||||
)
|
||||
if still_no_quote or "询价中" in err:
|
||||
send_group_text(
|
||||
group_client_of(flow), chat_id, copy.deal_fail_no_quote(work_order_no=no)
|
||||
)
|
||||
else:
|
||||
send_group_text(
|
||||
group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。"
|
||||
)
|
||||
return "ledger_fail"
|
||||
phase = str(result.get("phase") or "done")
|
||||
ack_reason = inline if phase == "done" and outcome == "未成交" else ""
|
||||
@@ -800,7 +825,7 @@ def apply_air_deal(
|
||||
chat_id,
|
||||
copy.deal_ack(
|
||||
outcome,
|
||||
ticket_no(ticket),
|
||||
no,
|
||||
str(result.get("next_followup_at") or ""),
|
||||
lost_reason=ack_reason,
|
||||
),
|
||||
@@ -818,6 +843,13 @@ def apply_air_lock(
|
||||
option_no: str = "",
|
||||
airline: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
空运群锁舱。
|
||||
|
||||
副作用:调主账 lockCabin(HMAC 出站 TMS);成功写协同 facts、推成交跟进。
|
||||
舱位/航司/航班交给主账现拉 TMS 补全;仅计费重全空时群里补问。
|
||||
主账仍缺则回 lock_incomplete,禁止空壳出站。
|
||||
"""
|
||||
if not is_airline(flow, sender_id):
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
|
||||
return "not_airline"
|
||||
@@ -825,14 +857,21 @@ def apply_air_lock(
|
||||
# 不能只回「TMS接口异常,IT运维排查中」。
|
||||
no = ticket_no(ticket)
|
||||
name = operator_name(flow, sender_id)
|
||||
view_quote = dict(ticket_view(ticket).get("quote") or {})
|
||||
view = ticket_view(ticket)
|
||||
view_quote = dict(view.get("quote") or {})
|
||||
air_quote = dict((view_quote.get("segment_quotes") or {}).get("AIR") or {})
|
||||
quote = air_quote or view_quote
|
||||
options = _air_options(quote)
|
||||
quote_id = _quote_id(quote)
|
||||
# 最新价常是人工覆盖:舱位/航司/航班要从本单 TMS 原价自动补,不能逼航线手打。
|
||||
tms_quote = copy.resolve_tms_quote(None, view)
|
||||
if not tms_quote:
|
||||
tms_quote = dict(view.get("tms_quote") or view.get("tmsQuote") or {})
|
||||
quote_options = _air_options(quote)
|
||||
tms_options = _air_options(tms_quote)
|
||||
options = quote_options or tms_options
|
||||
quote_id = _quote_id(quote) or _quote_id(tms_quote)
|
||||
picked = (option_no or "").strip()
|
||||
if not picked and len(options) > 1:
|
||||
facts = dict(ticket_view(ticket).get("collab_facts") or {})
|
||||
facts = dict(view.get("collab_facts") or {})
|
||||
if str(facts.get(_PENDING_LOCK) or "") != "1":
|
||||
flow.ledger.patch_collab_facts(
|
||||
work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id
|
||||
@@ -841,6 +880,42 @@ def apply_air_lock(
|
||||
return "wait_lock_option"
|
||||
if not picked and len(options) == 1:
|
||||
picked = str(options[0].get("optionNo") or options[0].get("option_no") or "").strip()
|
||||
# 没有任何可锁报价时先拒绝,不要把「无报价」误说成缺舱位。
|
||||
if not _quote_ready_for_lock(quote) and not _quote_ready_for_lock(tms_quote):
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no))
|
||||
return "no_quote"
|
||||
airline_code = (airline or "").strip().upper()
|
||||
if not airline_code:
|
||||
airline_code = _airline_from_quote_or_option(quote, quote_options, picked)
|
||||
if not airline_code:
|
||||
airline_code = _airline_from_quote_or_option(tms_quote, tms_options, picked)
|
||||
chargeable = _chargeable_weight_kg(quote, quote_options, picked, ticket)
|
||||
if not chargeable:
|
||||
chargeable = _chargeable_weight_kg(tms_quote, tms_options, picked, ticket)
|
||||
flight_no = _flight_no_from_quote_or_option(quote, quote_options, picked, instruction)
|
||||
if not flight_no:
|
||||
flight_no = _flight_no_from_quote_or_option(tms_quote, tms_options, picked, instruction)
|
||||
# 舱位/航司/航班交给主账:库里无 TMS 时会现拉补全;仍缺才回 lock_incomplete。
|
||||
# 这里只拦计费重全空(工单毛重也没有)——否则会逼航线手打航班。
|
||||
need_chargeable = not chargeable
|
||||
if need_chargeable:
|
||||
facts = dict(ticket_view(ticket).get("collab_facts") or {})
|
||||
if str(facts.get(_PENDING_LOCK) or "") != "1":
|
||||
flow.ledger.patch_collab_facts(
|
||||
work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id
|
||||
)
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
copy.air_ask_manual_lock_fields(
|
||||
need_option=False,
|
||||
need_airline=False,
|
||||
need_chargeable=True,
|
||||
need_flight=False,
|
||||
options=options,
|
||||
),
|
||||
)
|
||||
return "wait_lock_fields"
|
||||
locker = getattr(flow.ledger, "lock_cabin", None)
|
||||
if not callable(locker):
|
||||
send_group_text(
|
||||
@@ -857,6 +932,14 @@ def apply_air_lock(
|
||||
confirm_system_exception,
|
||||
)
|
||||
|
||||
def _lock_terminal(x: object) -> bool:
|
||||
"""成功,或业务上不必再重试(缺舱位/无报价)。"""
|
||||
row = x if isinstance(x, dict) else {}
|
||||
if bool(row.get("ok")):
|
||||
return True
|
||||
err = str(row.get("error") or "")
|
||||
return err in {"lock_incomplete", "no_quote"}
|
||||
|
||||
out = call_with_retries(
|
||||
lambda: locker(
|
||||
work_order_no=no,
|
||||
@@ -864,15 +947,30 @@ def apply_air_lock(
|
||||
sender_id=sender_id,
|
||||
operator_name=name,
|
||||
instruction_text=instruction,
|
||||
airline=(airline or "").strip(),
|
||||
airline=airline_code,
|
||||
flight_no=flight_no,
|
||||
)
|
||||
or {},
|
||||
is_ok=lambda x: bool((x or {}).get("ok")),
|
||||
is_ok=_lock_terminal,
|
||||
)
|
||||
if not out.get("ok"):
|
||||
if str(out.get("error") or "") == "no_quote":
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no))
|
||||
return "no_quote"
|
||||
if str(out.get("error") or "") == "lock_incomplete":
|
||||
# 主账现拉仍无舱位:业务补问,不算系统异常,不惊动 IT。
|
||||
why = str(out.get("failReason") or out.get("error") or "锁舱参数不完整")
|
||||
facts = dict(ticket_view(ticket).get("collab_facts") or {})
|
||||
if str(facts.get(_PENDING_LOCK) or "") != "1":
|
||||
flow.ledger.patch_collab_facts(
|
||||
work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id
|
||||
)
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
copy.air_lock_incomplete(work_order_no=no, operator=name, reason=why),
|
||||
)
|
||||
return "lock_incomplete"
|
||||
why = copy.air_cabin_fail_reason(
|
||||
tms_reason=str(out.get("failReason") or out.get("error") or ""),
|
||||
flight_date=_ticket_flight_date(ticket),
|
||||
@@ -895,6 +993,7 @@ def apply_air_lock(
|
||||
conversation_target=chat_id,
|
||||
extra={
|
||||
"option_no": picked,
|
||||
"airline": airline_code,
|
||||
"instruction": instruction,
|
||||
"sender_id": sender_id,
|
||||
"operator_name": name,
|
||||
@@ -1164,6 +1263,140 @@ def _quote_id(quote: dict[str, Any]) -> str:
|
||||
return str(quote.get("quoteId") or quote.get("quote_id") or "").strip()
|
||||
|
||||
|
||||
def _quote_ready_for_lock(quote: dict[str, Any]) -> bool:
|
||||
"""
|
||||
有没有可锁的报价:真实 TMS 编号,或人工总价/费用明细。
|
||||
与内存账本口径一致,禁止把空报价当成「缺舱位」去补问。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
label = str(src.get("source_label") or src.get("sourceLabel") or "")
|
||||
if "确认" in label and "TMS" in label.upper():
|
||||
qid = str(src.get("quoteId") or "").strip()
|
||||
return bool(qid) and not qid.upper().startswith("QT-")
|
||||
if str(src.get("total") or "").strip() not in {"", "-"}:
|
||||
return True
|
||||
if src.get("fee_rows") or src.get("fee_lines") or src.get("feeItems") or src.get("departureCharges"):
|
||||
return True
|
||||
if _air_options(src):
|
||||
return True
|
||||
qid = str(src.get("quoteId") or src.get("quote_id") or "").strip()
|
||||
return bool(qid) and not qid.upper().startswith("QT-WO")
|
||||
|
||||
|
||||
def _picked_option(
|
||||
options: list[dict[str, Any]], option_no: str
|
||||
) -> dict[str, Any]:
|
||||
"""按 optionNo 取舱位;对不上返回空 dict。"""
|
||||
want = (option_no or "").strip().upper()
|
||||
if not want:
|
||||
return {}
|
||||
for row in options:
|
||||
no = str(row.get("optionNo") or row.get("option_no") or "").strip().upper()
|
||||
if no == want:
|
||||
return row
|
||||
return {}
|
||||
|
||||
|
||||
def _airline_from_quote_or_option(
|
||||
quote: dict[str, Any],
|
||||
options: list[dict[str, Any]],
|
||||
option_no: str,
|
||||
) -> str:
|
||||
"""
|
||||
锁舱航司:舱位 airlineCode > 报价顶层 > 航班号前两位。
|
||||
只回两位大写字母;猜不到就空,交给群里补问。
|
||||
"""
|
||||
opt = _picked_option(options, option_no)
|
||||
raw = str(
|
||||
opt.get("airlineCode")
|
||||
or opt.get("airline")
|
||||
or quote.get("airlineCode")
|
||||
or quote.get("airline")
|
||||
or ""
|
||||
).strip().upper()
|
||||
if len(raw) >= 2 and raw[:2].isalpha():
|
||||
return raw[:2]
|
||||
flight = str(
|
||||
opt.get("flightNo") or opt.get("flight_no") or quote.get("flightNo") or ""
|
||||
).strip().upper()
|
||||
if len(flight) >= 2 and flight[0].isalpha() and flight[1].isalpha():
|
||||
return flight[:2]
|
||||
name = str(opt.get("airlineName") or quote.get("airlineName") or "").strip()
|
||||
# 名称里不硬猜二字码,避免「南航」编成乱码。
|
||||
_ = name
|
||||
return ""
|
||||
|
||||
|
||||
def _flight_no_from_quote_or_option(
|
||||
quote: dict[str, Any],
|
||||
options: list[dict[str, Any]],
|
||||
option_no: str,
|
||||
instruction: str = "",
|
||||
) -> str:
|
||||
"""航班号:舱位/报价字段,或句中「航班CZ327」「CZ327」形态。"""
|
||||
import re
|
||||
|
||||
opt = _picked_option(options, option_no)
|
||||
raw = str(opt.get("flightNo") or opt.get("flight_no") or quote.get("flightNo") or "").strip()
|
||||
if raw:
|
||||
return raw.upper()
|
||||
blob = (instruction or "").upper()
|
||||
m = re.search(r"(?:航班|FLIGHT)\s*([A-Z0-9]{2,8})", blob)
|
||||
if m:
|
||||
return m.group(1)
|
||||
m = re.search(r"\b([A-Z]{2}\d{2,4})\b", blob)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return ""
|
||||
|
||||
|
||||
def _chargeable_weight_kg(
|
||||
quote: dict[str, Any],
|
||||
options: list[dict[str, Any]],
|
||||
option_no: str,
|
||||
ticket: object,
|
||||
) -> str:
|
||||
"""
|
||||
计费重:舱位 > 报价 > 工单事实「计费重」> 毛重。
|
||||
有正数才返回;否则空串,门禁会补问。
|
||||
"""
|
||||
opt = _picked_option(options, option_no)
|
||||
candidates = [
|
||||
opt.get("chargeableWeightKg") or opt.get("chargeable_weight_kg"),
|
||||
quote.get("chargeableWeightKg") or quote.get("chargeable_weight_kg"),
|
||||
]
|
||||
view = ticket_view(ticket)
|
||||
facts = dict(view.get("facts") or {})
|
||||
candidates.extend(
|
||||
[
|
||||
facts.get("计费重"),
|
||||
facts.get("计费重量"),
|
||||
view.get("chargeableWeightKg"),
|
||||
facts.get("毛重"),
|
||||
view.get("weightKg") or view.get("weight_kg"),
|
||||
]
|
||||
)
|
||||
for raw in candidates:
|
||||
text = str(raw or "").strip().replace(",", "")
|
||||
if not text or text in {"-", "null", "None"}:
|
||||
continue
|
||||
# 去掉「公斤」「KGS」等后缀,只留数字。
|
||||
num = ""
|
||||
for ch in text:
|
||||
if ch.isdigit() or ch == ".":
|
||||
num += ch
|
||||
elif num:
|
||||
break
|
||||
if not num:
|
||||
continue
|
||||
try:
|
||||
if float(num) > 0:
|
||||
return num
|
||||
except ValueError:
|
||||
continue
|
||||
return ""
|
||||
|
||||
|
||||
def _air_options(quote: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
raw = quote.get("airOptions") or quote.get("air_options")
|
||||
if isinstance(raw, str) and raw.strip().startswith("["):
|
||||
|
||||
@@ -1780,8 +1780,12 @@ class AirTextInquiryFlow:
|
||||
extra,
|
||||
)
|
||||
from agent.redis_coord.quote_file import mark_quote_file_ready
|
||||
from agent.policy.deal_outcome import ensure_ticket_quoted
|
||||
|
||||
mark_quote_file_ready(sess.work_order_no)
|
||||
# 文件已出就必须让主账离开「询价中」,否则成交三态会被六态拒绝。
|
||||
ensure_ticket_quoted(self._ledger, work_order_no=sess.work_order_no)
|
||||
sess.status = sess.status or "已报价"
|
||||
sess.phase = "wait_deal"
|
||||
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||||
sess.wait_version += 1
|
||||
@@ -1926,20 +1930,37 @@ class AirTextInquiryFlow:
|
||||
"""点成交/未成交/协商中:先整卡置灰,再入主账。未成交同一句带原因则一次记完。"""
|
||||
self._grey_clicked_card(reply, card_meta, replace_name=status)
|
||||
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
|
||||
from agent.policy.sea_group_ops import _quote_fee_rows
|
||||
|
||||
quote = dict(sess.quote or {})
|
||||
status_now = str(sess.status or "").strip()
|
||||
usable_total = str(quote.get("total") or "").strip()
|
||||
if usable_total in {"", "-"}:
|
||||
usable_total = ""
|
||||
if not (
|
||||
usable_total
|
||||
or _quote_fee_rows(quote)
|
||||
or status_now in {"已报价", "协商中"}
|
||||
):
|
||||
reply(copy.deal_fail_no_quote(work_order_no=sess.work_order_no))
|
||||
return "deal_need_quote"
|
||||
inline = harvest_inline_lost_reason(source_text) if status == "未成交" else ""
|
||||
result = finish_deal_outcome(
|
||||
ledger=self._ledger,
|
||||
work_order_no=sess.work_order_no,
|
||||
outcome=status,
|
||||
facts=sess.facts,
|
||||
quote=sess.quote,
|
||||
quote=quote,
|
||||
business_line=sess.business_line,
|
||||
sales_name=sess.invited_sales_name,
|
||||
lost_reason=inline,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
reply("主账状态更新失败,成交结果未入账。")
|
||||
err = str(result.get("error") or "")
|
||||
if (not usable_total and not _quote_fee_rows(quote)) or "询价中" in err:
|
||||
reply(copy.deal_fail_no_quote(work_order_no=sess.work_order_no))
|
||||
else:
|
||||
reply("主账状态更新失败,成交结果未入账。")
|
||||
return "ledger_fail"
|
||||
phase = str(result.get("phase") or "done")
|
||||
sess.status = status
|
||||
@@ -1976,7 +1997,12 @@ class AirTextInquiryFlow:
|
||||
sess.phase = "done"
|
||||
sess.allowed = ()
|
||||
self._save(sess)
|
||||
reply(copy.lost_reason_ack(str(result.get("lost_reason") or text)))
|
||||
reply(
|
||||
copy.lost_reason_ack(
|
||||
str(result.get("lost_reason") or text),
|
||||
work_order_no=sess.work_order_no,
|
||||
)
|
||||
)
|
||||
return "done"
|
||||
|
||||
def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None:
|
||||
@@ -1990,8 +2016,10 @@ class AirTextInquiryFlow:
|
||||
if not no:
|
||||
return
|
||||
from agent.redis_coord.quote_file import mark_quote_file_ready
|
||||
from agent.policy.deal_outcome import ensure_ticket_quoted
|
||||
|
||||
mark_quote_file_ready(no)
|
||||
ensure_ticket_quoted(self._ledger, work_order_no=no)
|
||||
if not sender_id:
|
||||
return
|
||||
with self._lock:
|
||||
|
||||
@@ -210,7 +210,11 @@ def finish_deal_outcome(
|
||||
writer = getattr(ledger, "transition", None)
|
||||
if not callable(writer):
|
||||
return {"ok": False, "error": "no_transition", "phase": "ledger_fail"}
|
||||
result = writer(work_order_no=no, to_status=status, **extra) or {}
|
||||
# 报价文件已出、会话已进入成交跟进,但主账仍停在「询价中」时,
|
||||
# 直接迁「未成交/已成交/协商中」会被六态拒绝。先补「已报价」再迁目标态。
|
||||
result = _transition_deal_status(
|
||||
writer, work_order_no=no, to_status=status, extra=extra, ledger=ledger
|
||||
)
|
||||
if not result.get("ok"):
|
||||
return {"ok": False, "error": result.get("error") or "transition_failed", "phase": "ledger_fail"}
|
||||
from agent.policy.event_exception import WAIT_LOST_REASON, clear_wait, start_wait
|
||||
@@ -241,6 +245,79 @@ def finish_deal_outcome(
|
||||
}
|
||||
|
||||
|
||||
def _transition_deal_status(
|
||||
writer: Callable[..., dict[str, Any]],
|
||||
*,
|
||||
work_order_no: str,
|
||||
to_status: str,
|
||||
extra: dict[str, Any],
|
||||
ledger: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
写入成交三态。若主账仍停在询价中,先迁已报价再迁目标态。
|
||||
|
||||
运行证据:
|
||||
- WO022/WO023:文件已发仍停询价中,直迁未成交被六态拒绝;
|
||||
- HTTP 500 时 http_ledger 常只回 http_500(正文里的「询价中」丢失),
|
||||
不能只靠错误文案判断,要以工单当前状态为准。
|
||||
"""
|
||||
no = (work_order_no or "").strip()
|
||||
status = (to_status or "").strip()
|
||||
result = writer(work_order_no=no, to_status=status, **extra) or {}
|
||||
if result.get("ok"):
|
||||
return result
|
||||
if status not in {"已成交", "未成交", "协商中"}:
|
||||
return result
|
||||
err = str(result.get("error") or result.get("message") or "")
|
||||
cur = ""
|
||||
getter = getattr(ledger, "get_for_agent", None) if ledger is not None else None
|
||||
if callable(getter):
|
||||
try:
|
||||
snap = getter(work_order_no=no, sender_id="") or {}
|
||||
cur = str(snap.get("status") or "").strip()
|
||||
except Exception:
|
||||
cur = ""
|
||||
# 状态确认询价中,或错误文案带询价中,或仅有 http_5xx 时仍尝试补已报价。
|
||||
should_promote = (
|
||||
cur == "询价中"
|
||||
or "询价中" in err
|
||||
or err.startswith("http_5")
|
||||
or err in {"transition_failed", "ledger_error"}
|
||||
)
|
||||
if not should_promote:
|
||||
return result
|
||||
promoted = writer(
|
||||
work_order_no=no,
|
||||
to_status="已报价",
|
||||
remark="成交跟进前补已报价",
|
||||
) or {}
|
||||
if not promoted.get("ok"):
|
||||
return result
|
||||
return writer(work_order_no=no, to_status=status, **extra) or {}
|
||||
|
||||
|
||||
def ensure_ticket_quoted(ledger: Any, *, work_order_no: str) -> dict[str, Any]:
|
||||
"""
|
||||
报价文件发出 / 进入成交跟进时,保证主账至少是「已报价」。
|
||||
|
||||
幂等:已离开「询价中」则不再迁态(已报价→已报价会被六态拒绝)。
|
||||
"""
|
||||
no = (work_order_no or "").strip()
|
||||
writer = getattr(ledger, "transition", None)
|
||||
if not no or not callable(writer):
|
||||
return {"ok": False, "error": "no_transition"}
|
||||
getter = getattr(ledger, "get_for_agent", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
snap = getter(work_order_no=no, sender_id="") or {}
|
||||
except Exception:
|
||||
snap = {}
|
||||
st = str(snap.get("status") or "").strip()
|
||||
if st and st != "询价中":
|
||||
return {"ok": True, "status": st, "skipped": True}
|
||||
return writer(work_order_no=no, to_status="已报价", remark="报价文件已出") or {}
|
||||
|
||||
|
||||
def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict[str, Any]:
|
||||
"""把销售下一句写进未成交原因。@询价小助手 不是原因。"""
|
||||
text = strip_bot_mentions(reason or "")
|
||||
|
||||
@@ -1023,7 +1023,12 @@ def quote_fee_lines(quote: dict[str, Any] | None) -> list[tuple[str, str]]:
|
||||
amount = str(item.get("amount") or item.get("value") or "").strip()
|
||||
line_ccy = str(item.get("currency") or item.get("currencyCode") or header_ccy).strip()
|
||||
if name and amount and amount != "-":
|
||||
lines.append((name, money_text(amount, line_ccy)))
|
||||
# 燃油附加费率 / 税率是百分比,禁止套成「CNY 1%」。
|
||||
if "%" in amount or name.endswith("费率") or name in {"税率", "燃油附加费率"}:
|
||||
shown = amount if "%" in amount else f"{amount}%"
|
||||
lines.append((name, shown))
|
||||
else:
|
||||
lines.append((name, money_text(amount, line_ccy)))
|
||||
if lines:
|
||||
return lines
|
||||
freight = str(src.get("freight") or "").strip()
|
||||
@@ -1219,10 +1224,21 @@ def sea_quote_detail_text(
|
||||
|
||||
|
||||
def collab_hint(work_order_no: str) -> str:
|
||||
"""价格卡协同说明:无「内部协同」前缀,紧挨生成报价单按钮上方。"""
|
||||
return (
|
||||
f"若需要咨询内部航线人员,请手动拉新人与智能体到原有群,"
|
||||
f"并在群聊发送工单编号{work_order_no}。"
|
||||
"""
|
||||
空运价格卡协同说明:紧挨「不需要协同,直接生成报价单」上方。
|
||||
|
||||
后台 inquiring.air_collab_hint;库内模板可用 {{ticket_no}}。
|
||||
无后台模板时用本默认句(已带工单号)。销售侧示例:
|
||||
…发送“ WO202610060030@询价小助手”,谢谢。
|
||||
"""
|
||||
no = (work_order_no or "").strip()
|
||||
return _r(
|
||||
"inquiring.air_collab_hint",
|
||||
(
|
||||
f"若需要咨询内部航线人员,请手动将相关方拉到空运原有协同群,"
|
||||
f"并在空运协同群中发送“ {no}@询价小助手”,谢谢。"
|
||||
),
|
||||
ticket_no=no,
|
||||
)
|
||||
|
||||
|
||||
@@ -2661,8 +2677,24 @@ def group_deal_stale(*, work_order_no: str) -> str:
|
||||
return f"这张成交确认已过期。工单{no}已有更新的报价,请点击群里最新一条成交跟进。"
|
||||
|
||||
|
||||
def group_deal_need_quote() -> str:
|
||||
return "还没有可确认的报价,请先完成报价再确认成交状态。"
|
||||
def group_deal_need_quote(*, work_order_no: str = "") -> str:
|
||||
"""
|
||||
群里确认成交/未成交时,主账还没有可确认报价。
|
||||
|
||||
空运 WO027:询价中且无 inq_quote,旧句「主账状态更新失败」不说明原因。
|
||||
"""
|
||||
no = (work_order_no or "").strip()
|
||||
if no:
|
||||
return (
|
||||
f"工单{no}状态更新失败,原因:没有报价。\n"
|
||||
f"请先报价,然后再更新工单状态。"
|
||||
)
|
||||
return "工单状态更新失败,原因:没有报价。\n请先报价,然后再更新工单状态。"
|
||||
|
||||
|
||||
def deal_fail_no_quote(*, work_order_no: str = "") -> str:
|
||||
"""成交三态写入失败且判定为没有报价时的销售可见说明。与群门禁同句。"""
|
||||
return group_deal_need_quote(work_order_no=work_order_no)
|
||||
|
||||
|
||||
def group_deal_sales_only() -> str:
|
||||
@@ -2717,7 +2749,7 @@ def deal_ack(
|
||||
if outcome == "未成交":
|
||||
reason = (lost_reason or "").strip()
|
||||
if reason:
|
||||
return f"已记录为「未成交」。{lost_reason_ack(reason)}"
|
||||
return f"已记录为「未成交」。{lost_reason_ack(reason, work_order_no=no)}"
|
||||
return _r("lost.ask_reason", "已记录为「未成交」。请补充未成交原因。", ticket_no=no)
|
||||
when = _followup_month_day(next_followup_at) or "第四个工作日"
|
||||
return _r(
|
||||
@@ -2744,14 +2776,19 @@ def _followup_month_day(raw: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def lost_reason_ack(reason: str = "") -> str:
|
||||
"""收下未成交原因后回显原句。后台 lost.reason_ack。"""
|
||||
def lost_reason_ack(reason: str = "", work_order_no: str = "") -> str:
|
||||
"""
|
||||
收下未成交原因后回显。后台 lost.reason_ack。
|
||||
|
||||
测服文案含 {{ticket_no}} / {{lost_reason}};缺 ticket_no 会原样打出占位符。
|
||||
"""
|
||||
text = (reason or "").strip()
|
||||
no = (work_order_no or "").strip()
|
||||
if text.endswith(("。", ".", "!", "!", "?", "?")):
|
||||
default = f"已记下未成交原因:{text}"
|
||||
else:
|
||||
default = f"已记下未成交原因:{text}。" if text else "已记下未成交原因。"
|
||||
return _r("lost.reason_ack", default, lost_reason=text)
|
||||
return _r("lost.reason_ack", default, lost_reason=text, ticket_no=no)
|
||||
|
||||
|
||||
def deal_notify_card(
|
||||
@@ -3044,6 +3081,53 @@ def air_ask_lock_option(options: list) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def air_ask_manual_lock_fields(
|
||||
*,
|
||||
need_option: bool = False,
|
||||
need_airline: bool = False,
|
||||
need_chargeable: bool = False,
|
||||
need_flight: bool = False,
|
||||
options: list | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
手动报价锁舱缺 TMS MANUAL 必填项时,群里补问,禁止空壳出站。
|
||||
|
||||
例:请回复「AIR-OPT01 CZ 航班CZ327」。
|
||||
"""
|
||||
bits: list[str] = []
|
||||
if need_option:
|
||||
bits.append("舱位编号(如 AIR-OPT01)")
|
||||
if need_airline:
|
||||
bits.append("航司两位码(如 CZ)")
|
||||
if need_flight:
|
||||
bits.append("航班号(如 CZ327)")
|
||||
if need_chargeable:
|
||||
bits.append("计费重(公斤)")
|
||||
need = "、".join(bits) if bits else "舱位编号、航司和航班号"
|
||||
lines = [
|
||||
f"锁舱还缺:{need}。",
|
||||
"请按「AIR-OPT01 CZ 航班CZ327」回复;",
|
||||
]
|
||||
if need_chargeable:
|
||||
lines.append("若还缺计费重,请再写「计费重xx公斤」。")
|
||||
else:
|
||||
lines.append("例如:AIR-OPT01 CZ 航班CZ327")
|
||||
opts = options or []
|
||||
shown = False
|
||||
for item in opts:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
no = str(item.get("optionNo") or item.get("option_no") or "").strip()
|
||||
airline = str(item.get("airlineCode") or item.get("airlineName") or "").strip()
|
||||
if not no:
|
||||
continue
|
||||
if not shown:
|
||||
lines.append("可选舱位:")
|
||||
shown = True
|
||||
lines.append(f"{no} {airline}".strip() if airline else no)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def air_lock_ok(
|
||||
*,
|
||||
work_order_no: str,
|
||||
@@ -3072,7 +3156,7 @@ def air_lock_ok(
|
||||
|
||||
|
||||
def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
|
||||
"""空运锁舱失败。后台 inquiring.air_lock_fail。"""
|
||||
"""空运锁舱失败(接口/系统类)。后台 inquiring.air_lock_fail。"""
|
||||
why = (reason or "").strip() or "TMS未返回原因"
|
||||
default = (
|
||||
f"工单{work_order_no} 空运锁舱失败。\n"
|
||||
@@ -3089,6 +3173,25 @@ def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def air_lock_incomplete(*, work_order_no: str, operator: str, reason: str = "") -> str:
|
||||
"""
|
||||
锁舱参数不齐(纯手工价、运价库无舱位等):业务补问,不算系统异常,不惊动 IT。
|
||||
|
||||
航线按示例回舱位+航司+航班后即可再锁。
|
||||
"""
|
||||
why = (reason or "").strip()
|
||||
lines = [
|
||||
f"工单{work_order_no} 还不能锁舱。",
|
||||
f"操作人:{operator}",
|
||||
]
|
||||
if why:
|
||||
lines.append(f"原因:{why}")
|
||||
else:
|
||||
lines.append("原因:本单没有可引用的 TMS 舱位(手工价或运价库无匹配)。")
|
||||
lines.append("请直接回复「AIR-OPT01 CZ 航班CZ327」(换成实际舱位、航司、航班号)后再锁。")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def air_cabin_fail_reason(*, tms_reason: str = "", flight_date: str = "", today: str = "") -> str:
|
||||
"""
|
||||
锁舱/释放失败原因:原样回 TMS 的 failReason。
|
||||
|
||||
@@ -906,12 +906,14 @@ def apply_product_quote(
|
||||
logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200])
|
||||
send_group_text(client, chat_id, copy.group_quote_not_recognized())
|
||||
return "quote_not_recognized"
|
||||
quote = _attach_land_weight_band_segment(quote, view)
|
||||
base = _current_quote(view, sess)
|
||||
if _has_tms_quote(base):
|
||||
_park_pending_quote(no, quote, sess, flow)
|
||||
send_group_text(client, chat_id, copy.group_ask_tms_or_product())
|
||||
return "wait_tms_choice"
|
||||
if _quote_fee_rows(base):
|
||||
# 分档单价是完整人工价(含费率),禁止和旧错误行(如燃油附加费=2)相加。
|
||||
if _quote_fee_rows(base) and not (quote.get("weightPrices") or []):
|
||||
quote = merge_quote_fees(base, quote)
|
||||
_save_product_quote(flow, no, quote, sess)
|
||||
ticket2 = _reload(flow, chat_id, no) or ticket
|
||||
@@ -1231,8 +1233,11 @@ def apply_group_deal(
|
||||
return "not_sales"
|
||||
quote = _current_quote(view, sess)
|
||||
status_now = ticket_status(ticket) or (sess.status if sess else "")
|
||||
if not (quote.get("total") or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
|
||||
send_group_text(client, chat_id, copy.group_deal_need_quote())
|
||||
usable_total = str(quote.get("total") or "").strip()
|
||||
if usable_total in {"", "-"}:
|
||||
usable_total = ""
|
||||
if not (usable_total or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
|
||||
send_group_text(client, chat_id, copy.group_deal_need_quote(work_order_no=no))
|
||||
return "deal_need_quote"
|
||||
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
|
||||
|
||||
@@ -1309,7 +1314,14 @@ def capture_group_lost_reason(
|
||||
sess.phase = "done"
|
||||
sess.allowed = ()
|
||||
flow._save(sess)
|
||||
send_group_text(client, chat_id, copy.lost_reason_ack(str(result.get("lost_reason") or text)))
|
||||
send_group_text(
|
||||
client,
|
||||
chat_id,
|
||||
copy.lost_reason_ack(
|
||||
str(result.get("lost_reason") or text),
|
||||
work_order_no=no,
|
||||
),
|
||||
)
|
||||
return "done"
|
||||
|
||||
|
||||
@@ -1634,6 +1646,53 @@ def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None
|
||||
return stamp_prices_for_sheet(out)
|
||||
|
||||
|
||||
def _attach_land_weight_band_segment(
|
||||
quote: dict[str, Any], view: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
陆运产品分档价:把 weightPrices / 燃油税率挂到 landSegments,出单才能填「2-500KG」列。
|
||||
|
||||
无分档价时原样返回。OD 取工单询价事实,不另猜城市。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
tiers = src.get("weightPrices")
|
||||
if not isinstance(tiers, list) or not tiers:
|
||||
return src
|
||||
facts = dict(view.get("facts") or {})
|
||||
origin = str(
|
||||
facts.get("始发地")
|
||||
or facts.get("起运地")
|
||||
or facts.get("origin")
|
||||
or view.get("origin")
|
||||
or ""
|
||||
).strip()
|
||||
dest = str(
|
||||
facts.get("目的地")
|
||||
or facts.get("destination")
|
||||
or view.get("destination")
|
||||
or ""
|
||||
).strip()
|
||||
seg: dict[str, Any] = {
|
||||
"origin": origin,
|
||||
"destination": dest,
|
||||
"weightPrices": list(tiers),
|
||||
"currency": str(src.get("currency") or "CNY"),
|
||||
"salePrice": str(src.get("total") or tiers[0].get("price") or ""),
|
||||
"price_per_kg": str(src.get("price_per_kg") or tiers[0].get("price") or ""),
|
||||
}
|
||||
fuel = str(src.get("fuel_surcharge_rate") or src.get("fuelSurchargeRate") or "").strip()
|
||||
tax = str(src.get("tax_rate") or src.get("taxRate") or "").strip()
|
||||
if fuel:
|
||||
seg["fuel_surcharge_rate"] = fuel
|
||||
seg["fuelSurchargeRate"] = fuel
|
||||
if tax:
|
||||
seg["tax_rate"] = tax
|
||||
seg["taxRate"] = tax
|
||||
src["landSegments"] = [seg]
|
||||
src["route_rows"] = [seg]
|
||||
return src
|
||||
|
||||
|
||||
def _save_product_quote(
|
||||
flow: Any,
|
||||
no: str,
|
||||
|
||||
@@ -850,6 +850,7 @@ def _resume_cabin_step(
|
||||
work_order_no=no,
|
||||
lock_id=lock_id,
|
||||
sender_id=sender,
|
||||
operator_name=str(extra.get("operator_name") or "").strip(),
|
||||
instruction_text=instruction,
|
||||
)
|
||||
or {}
|
||||
@@ -860,13 +861,20 @@ def _resume_cabin_step(
|
||||
service = "空运舱位"
|
||||
reason = "空运释放舱位接口调用失败"
|
||||
else:
|
||||
# 与首次群锁舱对齐:重试必须带 operatorName / airline,否则 MANUAL 出站缺姓名。
|
||||
locker = getattr(ledger, "lock_cabin", None)
|
||||
op_name = str(extra.get("operator_name") or "").strip()
|
||||
airline = str(extra.get("airline") or "").strip()
|
||||
flight_no = str(extra.get("flight_no") or "").strip()
|
||||
out = call_with_retries(
|
||||
lambda: locker(
|
||||
work_order_no=no,
|
||||
option_no=option_no,
|
||||
sender_id=sender,
|
||||
operator_name=op_name,
|
||||
instruction_text=instruction,
|
||||
airline=airline,
|
||||
flight_no=flight_no,
|
||||
)
|
||||
or {}
|
||||
if callable(locker)
|
||||
|
||||
@@ -154,10 +154,17 @@ def _hint_lock_or_release(text: str) -> str:
|
||||
|
||||
|
||||
def _only_work_order(text: str) -> bool:
|
||||
"""去掉工单号和 @询价小助手 后几乎没字,当作激活/切单。"""
|
||||
"""
|
||||
去掉工单号和 @询价小助手 后几乎没字,当作激活/切单。
|
||||
|
||||
销售常按话术复制「工单编号WO…@询价小助手」;残留「工单编号」不算业务意图。
|
||||
运行证据:only_wo=false 后走 extract_product_quote→product_quote→航线岗拒绝。
|
||||
"""
|
||||
no = copy.extract_work_order_no(text)
|
||||
rest = (text or "").replace(no, "").replace(no.lower(), "")
|
||||
rest = rest.replace("@询价小助手", "").replace("询价小助手", "")
|
||||
for noise in ("工单编号", "工单号", "编号"):
|
||||
rest = rest.replace(noise, "")
|
||||
rest = "".join(ch for ch in rest if ch.isalnum() or "\u4e00" <= ch <= "\u9fff")
|
||||
return not rest.strip()
|
||||
|
||||
|
||||
@@ -421,6 +421,21 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
classify_air_group_text("@询价小助手 WO202609160002", allow_network=False),
|
||||
INTENT_ACTIVATE,
|
||||
)
|
||||
# 话术前缀「工单编号」不能当成报价(WO026 复现:误走 product_quote)。
|
||||
self.assertEqual(
|
||||
classify_air_group_text(
|
||||
"工单编号WO202610070026@询价小助手",
|
||||
allow_network=False,
|
||||
),
|
||||
INTENT_ACTIVATE,
|
||||
)
|
||||
self.assertEqual(
|
||||
classify_air_group_text(
|
||||
"工单编号 WO202610060026@询价小助手",
|
||||
allow_network=False,
|
||||
),
|
||||
INTENT_ACTIVATE,
|
||||
)
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch(
|
||||
@@ -434,6 +449,13 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
),
|
||||
INTENT_ACTIVATE,
|
||||
)
|
||||
self.assertEqual(
|
||||
classify_air_group_text(
|
||||
"工单编号WO202610070026@询价小助手",
|
||||
allow_network=True,
|
||||
),
|
||||
INTENT_ACTIVATE,
|
||||
)
|
||||
|
||||
def test_dispatch_aibot_goes_air(self) -> None:
|
||||
from agent.routing.dispatch import dispatch_inbound as _disp
|
||||
@@ -906,7 +928,8 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
self.assertFalse(any("AIR-OPT-01" in t for t in self._texts()))
|
||||
self.assertFalse(any("执行成功" in t for t in self._texts()))
|
||||
|
||||
def test_manual_quote_without_quote_id_can_lock(self) -> None:
|
||||
def test_manual_quote_without_option_asks_fields(self) -> None:
|
||||
"""纯人工价且口令无 AIR-OPT:交主账后仍缺 → lock_incomplete(不空壳成功)。"""
|
||||
wo = self._create_air(
|
||||
quote={
|
||||
"total": "100",
|
||||
@@ -921,6 +944,33 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.assertEqual(phase, "lock_incomplete")
|
||||
joined = "\n".join(self._texts())
|
||||
self.assertIn("舱位编号", joined)
|
||||
self.assertFalse(any("执行成功" in t for t in self._texts()))
|
||||
|
||||
def test_manual_quote_with_option_airline_can_lock(self) -> None:
|
||||
"""人工价补齐舱位+航司+航班后可锁;计费重用工单毛重兜底。"""
|
||||
wo = self._create_air(
|
||||
quote={
|
||||
"total": "100",
|
||||
"source": "manual",
|
||||
"source_label": "产品报价",
|
||||
"currency": "CNY",
|
||||
},
|
||||
status="已报价",
|
||||
)
|
||||
phase = handle_air_group(
|
||||
self._msg(
|
||||
sender="air1",
|
||||
text=f"{wo}锁舱,锁AIR-OPT01 CZ 航班CZ327",
|
||||
chat="wr_air_lock_manual2",
|
||||
mid="lm2",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.assertEqual(phase, "lock_ok")
|
||||
self.assertTrue(any("执行成功" in t for t in self._texts()))
|
||||
|
||||
@@ -1348,7 +1398,7 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
|
||||
def test_lock_without_quote_still_shows_tms_reason(self) -> None:
|
||||
"""TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。"""
|
||||
wo = self._create_air(quote={}, status="询价中")
|
||||
wo = self._create_air()
|
||||
self.ledger._cabin_lock_result = {
|
||||
"ok": False,
|
||||
"error": "lock_fail",
|
||||
|
||||
@@ -144,10 +144,75 @@ class TestDealOutcome(unittest.TestCase):
|
||||
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
|
||||
self.assertEqual(view.get("wait_phase"), "done")
|
||||
|
||||
def test_lost_from_inquiring_promotes_quoted_first(self) -> None:
|
||||
"""主账仍询价中时点未成交:先补已报价再入未成交,禁止直接失败。"""
|
||||
|
||||
class _StrictSix:
|
||||
def __init__(self) -> None:
|
||||
self.status = "询价中"
|
||||
self.lost_reason = ""
|
||||
self.event_wait_kind = ""
|
||||
|
||||
def transition(self, *, work_order_no, to_status, **kwargs):
|
||||
_ = work_order_no
|
||||
allowed = {
|
||||
"询价中": {"已报价", "协商中", "已关闭"},
|
||||
"已报价": {"协商中", "已成交", "未成交", "已关闭"},
|
||||
}
|
||||
if to_status not in allowed.get(self.status, set()):
|
||||
# 模拟 http_ledger 在 500 时只回 http_500(丢失正文)
|
||||
return {"ok": False, "error": "http_500"}
|
||||
self.status = to_status
|
||||
if to_status == "未成交" and kwargs.get("lost_reason"):
|
||||
self.lost_reason = str(kwargs.get("lost_reason") or "")
|
||||
return {"ok": True, "status": self.status}
|
||||
|
||||
def patch_lost_reason(self, *, work_order_no, lost_reason):
|
||||
_ = work_order_no
|
||||
self.lost_reason = lost_reason
|
||||
return {"ok": True}
|
||||
|
||||
def get_for_agent(self, *, work_order_no, sender_id):
|
||||
_ = (work_order_no, sender_id)
|
||||
return {"status": self.status, "lost_reason": self.lost_reason}
|
||||
|
||||
ledger = _StrictSix()
|
||||
out = finish_deal_outcome(
|
||||
ledger=ledger,
|
||||
work_order_no="WO202610070023",
|
||||
outcome="未成交",
|
||||
facts={},
|
||||
quote={},
|
||||
business_line="AIR",
|
||||
)
|
||||
self.assertTrue(out.get("ok"), out)
|
||||
self.assertEqual(ledger.status, "未成交")
|
||||
self.assertEqual(out.get("phase"), "wait_lost_reason")
|
||||
|
||||
def test_lost_reason_ack_repeats_user_text(self) -> None:
|
||||
self.assertEqual(copy.lost_reason_ack("客户不要了"), "已记下未成交原因:客户不要了。")
|
||||
self.assertEqual(copy.lost_reason_ack("价格高。"), "已记下未成交原因:价格高。")
|
||||
|
||||
def test_lost_reason_ack_fills_ticket_no_from_bot_reply(self) -> None:
|
||||
"""后台文案带 {{ticket_no}} 时必须填实值,禁止原样打出占位符。"""
|
||||
from agent.policy import bot_reply
|
||||
|
||||
bot_reply.override_catalog(
|
||||
{
|
||||
"lost.reason_ack": (
|
||||
"已将未成交原因“{{lost_reason}}”记录在后台,"
|
||||
"工单{{ticket_no}}已关闭,智能体不再跟进该工单,请悉知。"
|
||||
)
|
||||
}
|
||||
)
|
||||
try:
|
||||
text = copy.lost_reason_ack("换时间", work_order_no="WO202610070018")
|
||||
self.assertIn("WO202610070018", text)
|
||||
self.assertNotIn("{{ticket_no}}", text)
|
||||
self.assertIn("换时间", text)
|
||||
finally:
|
||||
bot_reply.override_catalog(None)
|
||||
|
||||
def test_harvest_inline_lost_reason(self) -> None:
|
||||
self.assertEqual(harvest_inline_lost_reason("未成交,原因客户不想要了"), "客户不想要了")
|
||||
self.assertEqual(
|
||||
|
||||
@@ -1341,6 +1341,28 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
|
||||
self.assertEqual(str(rows["海运费"].get("amount") or ""), "100.00")
|
||||
self.assertEqual(str(rows["燃油附加费"].get("amount") or ""), "15.00")
|
||||
|
||||
def test_land_weight_band_unit_price_and_rates(self) -> None:
|
||||
"""陆运集拼:2-500KG 单价 + 燃油附加费率/税率,不能把档下限 2 当成燃油附加费。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
quote = extract_product_quote(
|
||||
"2-500KG 单价CNY990,燃油附加费率1%,税率6%"
|
||||
)
|
||||
self.assertEqual(str(quote.get("total") or ""), "990")
|
||||
self.assertEqual(str(quote.get("fuel_surcharge_rate") or ""), "1%")
|
||||
self.assertEqual(str(quote.get("tax_rate") or ""), "6%")
|
||||
tiers = list(quote.get("weightPrices") or [])
|
||||
self.assertEqual(len(tiers), 1)
|
||||
self.assertEqual(int(tiers[0].get("min") or 0), 2)
|
||||
self.assertEqual(int(tiers[0].get("max") or 0), 500)
|
||||
self.assertEqual(str(tiers[0].get("price") or ""), "990")
|
||||
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
|
||||
self.assertIn("2-500KG", rows)
|
||||
self.assertEqual(str(rows["2-500KG"].get("amount") or ""), "990")
|
||||
self.assertNotEqual(str(rows.get("燃油附加费", {}).get("amount") or ""), "2")
|
||||
self.assertEqual(str(rows.get("燃油附加费率", {}).get("amount") or ""), "1%")
|
||||
self.assertEqual(str(rows.get("税率", {}).get("amount") or ""), "6%")
|
||||
|
||||
def test_air_unit_qty_amount_keeps_air_freight_name(self) -> None:
|
||||
"""空运费 单价/数量/金额:费用名是空运费,金额用「金额」不是单价。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
@@ -477,22 +477,45 @@ class TestConfirmSystemException(unittest.TestCase):
|
||||
self.assertTrue("USD" in joined or "拉产品" in joined or "已报价" in joined)
|
||||
|
||||
def test_admin_retry_cabin_lock_runs_lock(self) -> None:
|
||||
# 空运单 + 可锁报价;重试必须带上 operator_name / airline。
|
||||
air = self.ledger.create_ticket(
|
||||
sender_id="sales1",
|
||||
business_line="AIR",
|
||||
facts={"起运地": "上海", "目的地": "洛杉矶", "毛重": "100"},
|
||||
)
|
||||
air_wo = str(air.get("work_order_no") or "")
|
||||
self.ledger.upsert_quote(
|
||||
work_order_no=air_wo,
|
||||
quote={
|
||||
"total": "800",
|
||||
"source": "TMS",
|
||||
"quoteId": "QT-AIR-1",
|
||||
"airOptions": [{"optionNo": "AIR-OPT-01", "airlineCode": "CZ"}],
|
||||
},
|
||||
to_status="已报价",
|
||||
)
|
||||
self.ledger.mark_system_exception(
|
||||
work_order_no=self.wo,
|
||||
work_order_no=air_wo,
|
||||
system_exception=TYPE_TMS,
|
||||
system_exception_reason="空运锁舱接口调用失败",
|
||||
step=STEP_TMS_CABIN,
|
||||
payload={
|
||||
"conversation_kind": "group",
|
||||
"conversation_target": "chat-air",
|
||||
"extra": {"option_no": "AIR-OPT-01", "instruction": "锁舱"},
|
||||
"extra": {
|
||||
"option_no": "AIR-OPT-01",
|
||||
"instruction": "锁舱",
|
||||
"operator_name": "HangXian",
|
||||
"airline": "CZ",
|
||||
},
|
||||
},
|
||||
)
|
||||
hits = {"n": 0}
|
||||
hits = {"n": 0, "kwargs": {}}
|
||||
orig_lock = self.ledger.lock_cabin
|
||||
|
||||
def counted(**kwargs):
|
||||
hits["n"] += 1
|
||||
hits["kwargs"] = dict(kwargs)
|
||||
return orig_lock(**kwargs)
|
||||
|
||||
self.ledger.lock_cabin = counted # type: ignore[method-assign]
|
||||
@@ -504,12 +527,14 @@ class TestConfirmSystemException(unittest.TestCase):
|
||||
orig = se._send_to_conversation
|
||||
se._send_to_conversation = lambda **kwargs: talks.append(str(kwargs.get("text") or ""))
|
||||
try:
|
||||
out = run_admin_retry(self.wo, ledger=self.ledger)
|
||||
out = run_admin_retry(air_wo, ledger=self.ledger)
|
||||
finally:
|
||||
se._send_to_conversation = orig
|
||||
self.assertTrue(out.get("ok"))
|
||||
self.assertGreaterEqual(hits["n"], 1)
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
||||
self.assertEqual(hits["kwargs"].get("operator_name"), "HangXian")
|
||||
self.assertEqual(hits["kwargs"].get("airline"), "CZ")
|
||||
ticket = self.ledger.get_ticket(work_order_no=air_wo)
|
||||
self.assertFalse(ticket_is_paused(ticket))
|
||||
self.assertTrue(any("空运舱位接口已恢复" in x for x in talks))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user