Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0c705683f | ||
|
|
617c5e2619 | ||
|
|
9ffba70741 |
@@ -31,6 +31,8 @@ def _front_bookmark(sess) -> bool:
|
||||
return False
|
||||
phase = (sess.phase or "").strip()
|
||||
line = (sess.business_line or "").upper()
|
||||
if phase == "sensitive_block":
|
||||
return True
|
||||
if phase == "need_mode" and not line:
|
||||
return False
|
||||
return phase in {"clarify", "need_mode", "need_land_options", "wait_confirm"}
|
||||
@@ -87,7 +89,9 @@ def _carry_waiting_inquiry_to_owner(*, owner: str, sender_id: str) -> None:
|
||||
raw = (sess.immutable_text or "").strip()
|
||||
if raw and raw not in texts:
|
||||
texts.append(raw)
|
||||
if not facts and not texts:
|
||||
holds = [str(getattr(item, "sensitive_hold", "") or "").strip() for item in drafts]
|
||||
holds = [item for item in holds if item]
|
||||
if not facts and not texts and not holds:
|
||||
return
|
||||
from agent.policy.air_text_flow import FlowSession
|
||||
|
||||
@@ -99,6 +103,7 @@ def _carry_waiting_inquiry_to_owner(*, owner: str, sender_id: str) -> None:
|
||||
business_line="",
|
||||
facts=facts,
|
||||
immutable_text="\n".join(texts),
|
||||
sensitive_hold="\n".join(holds),
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
@@ -245,7 +250,7 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = "", prefe
|
||||
if (
|
||||
multi_draft
|
||||
and (multi_draft.business_line or "").upper() == "MULTI"
|
||||
and (multi_draft.phase or "") == "clarify"
|
||||
and (multi_draft.phase or "") in {"clarify", "sensitive_block"}
|
||||
):
|
||||
return multi
|
||||
_drop_other_mode_drafts(owner=spoken, sender_id=sender_id)
|
||||
@@ -265,7 +270,7 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = "", prefe
|
||||
if (
|
||||
multi_sess
|
||||
and (multi_sess.business_line or "").upper() == "MULTI"
|
||||
and (multi_sess.phase or "") in {"clarify", "wait_confirm"}
|
||||
and (multi_sess.phase or "") in {"clarify", "wait_confirm", "sensitive_block"}
|
||||
and not (looks_like_confirm(text) and (multi_sess.phase or "") != "wait_confirm")
|
||||
):
|
||||
return multi
|
||||
|
||||
@@ -69,6 +69,42 @@ def _ticket_flight_date(ticket: object) -> str:
|
||||
).strip()
|
||||
|
||||
|
||||
def _lock_receipt_fields(
|
||||
ticket: object,
|
||||
quote: dict[str, Any],
|
||||
option_no: str,
|
||||
airline: str,
|
||||
) -> tuple[str, str, str, str]:
|
||||
"""
|
||||
锁舱成功回执的起运地、目的地、航司、航班日期。
|
||||
|
||||
优先级与主账出站一致:舱位三字码/航班日优先,没有再用工单事实。
|
||||
航司用本次实际锁出去的两位码。不编造。
|
||||
"""
|
||||
from agent.schema.air_route_options import airport_code_for_display, option_flight_date
|
||||
|
||||
row = _option_row(quote, option_no) or _selected_cabin_row(quote) or {}
|
||||
facts = dict(ticket_view(ticket).get("facts") or {})
|
||||
origin = airport_code_for_display(
|
||||
str(row.get("originAirportCode") or row.get("origin") or facts.get("originCode") or ""),
|
||||
str(facts.get("起运地") or facts.get("起运港") or ""),
|
||||
)
|
||||
destination = airport_code_for_display(
|
||||
str(
|
||||
row.get("destinationAirportCode")
|
||||
or row.get("destination")
|
||||
or facts.get("destinationCode")
|
||||
or ""
|
||||
),
|
||||
str(facts.get("目的地") or facts.get("目的港") or ""),
|
||||
)
|
||||
airline_text = (airline or "").strip().upper()
|
||||
if not airline_text:
|
||||
airline_text = str(row.get("airlineCode") or row.get("airline") or "").strip().upper()
|
||||
flight_date = option_flight_date(row) or _ticket_flight_date(ticket)
|
||||
return origin, destination, airline_text, flight_date
|
||||
|
||||
|
||||
def is_airline(flow: Any, sender_id: str) -> bool:
|
||||
"""员工管理岗位=航线·空运且启用。"""
|
||||
uid = (sender_id or "").strip()
|
||||
@@ -1001,6 +1037,9 @@ def apply_air_lock(
|
||||
flight_no=flight_no,
|
||||
instruction=instruction,
|
||||
)
|
||||
origin, destination, airline_label, flight_date = _lock_receipt_fields(
|
||||
ticket, quote, picked, air_code
|
||||
)
|
||||
missing = _missing_lock_labels(picked, air_code, flight)
|
||||
if missing and _quote_ready_for_lock(quote):
|
||||
_remember_partial_lock(
|
||||
@@ -1119,12 +1158,14 @@ def apply_air_lock(
|
||||
conversation_target=chat_id,
|
||||
extra={
|
||||
"option_no": picked,
|
||||
"airline": air_code,
|
||||
"airline": airline_label or air_code,
|
||||
"flight_no": flight,
|
||||
"instruction": instruction,
|
||||
"sender_id": sender_id,
|
||||
"operator_name": name,
|
||||
"flight_date": _ticket_flight_date(ticket),
|
||||
"flight_date": flight_date,
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
},
|
||||
),
|
||||
ledger=flow.ledger,
|
||||
@@ -1155,6 +1196,10 @@ def apply_air_lock(
|
||||
option_no=picked,
|
||||
exec_no=exec_no,
|
||||
quote_no=quote_no,
|
||||
origin=origin,
|
||||
destination=destination,
|
||||
airline=airline_label,
|
||||
flight_date=flight_date,
|
||||
),
|
||||
)
|
||||
from agent.policy.system_exception import clear_system_exception, ticket_is_paused
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
空运 TMS 报价正文:私聊价格明细,以及协同群有价时的货物信息和 TMS 段。
|
||||
|
||||
本文件职责:只排销售和群里看得见的文字。费用名、单价、单位、备注、合计、有效期、航司、计费重、货型都取 TMS 原文。
|
||||
起运地、品名、件数等仍用工单上销售已填的内容。
|
||||
禁止:改按钮、改卡片、改海运陆运、没查到价时另写一套、本地编备注或货型。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
# 货型没有单独合同字段。TMS 若带回下列键,原样展示;都没有就「-」。
|
||||
_CARGO_TYPE_KEYS = (
|
||||
"cargoType",
|
||||
"goodsType",
|
||||
"cargoRatio",
|
||||
"weightVolumeRatio",
|
||||
"volumeWeightRatio",
|
||||
"heavyBubbleRatio",
|
||||
"bubbleRatio",
|
||||
"densityRatio",
|
||||
"cargoShape",
|
||||
)
|
||||
|
||||
_REMARK_KEYS = (
|
||||
"remark",
|
||||
"remarks",
|
||||
"note",
|
||||
"specialRemarks",
|
||||
"special_remarks",
|
||||
"quoteRemark",
|
||||
"description",
|
||||
)
|
||||
|
||||
|
||||
def air_private_quote_text(
|
||||
*,
|
||||
work_order_no: str,
|
||||
facts: dict[str, str] | None,
|
||||
quote: dict[str, Any] | None,
|
||||
) -> str:
|
||||
"""
|
||||
私聊查到一条空运 TMS 报价后的完整文字。
|
||||
|
||||
先货物信息,再「价格方案如下」。协同说明和按钮不在这里,仍由后面的卡片发。
|
||||
副作用:无。线程:调用方线程。
|
||||
"""
|
||||
lines = air_cargo_lines(work_order_no=work_order_no, facts=facts, quote=quote)
|
||||
lines.append("")
|
||||
lines.append("价格方案如下:")
|
||||
lines.extend(air_price_lines(quote, facts))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def air_cargo_lines(
|
||||
*,
|
||||
work_order_no: str,
|
||||
facts: dict[str, str] | None,
|
||||
quote: dict[str, Any] | None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
货物信息。工单字段与现在确认工单同一套单位;航司、计费重、货型只认 TMS。
|
||||
|
||||
报价日期没填写成「-」,不写「非必填」。增值服务有内容才多一行。
|
||||
"""
|
||||
from agent.policy.inquiry_copy import _with_unit
|
||||
|
||||
src = dict(facts or {})
|
||||
origin = (src.get("起运港") or src.get("起运地") or "-").strip() or "-"
|
||||
dest = (src.get("目的港") or src.get("目的地") or "-").strip() or "-"
|
||||
cargo = (src.get("品名") or "-").strip() or "-"
|
||||
pack = (src.get("包装方式") or src.get("包装类型") or "-").strip() or "-"
|
||||
quote_date = (src.get("报价日期") or "").strip()
|
||||
if quote_date in {"", "-", "—", "-"}:
|
||||
quote_date = "-"
|
||||
lines = [
|
||||
f"工单号:{(work_order_no or '').strip()}",
|
||||
"运输方式:空运",
|
||||
f"起运地:{origin}",
|
||||
f"目的地:{dest}",
|
||||
f"航司:{_airline_display(quote, src)}",
|
||||
f"品名:{cargo}",
|
||||
f"件数:{_with_unit(str(src.get('件数') or ''), '件')}",
|
||||
f"重量(KG):{_with_unit(str(src.get('毛重') or ''), 'KGS')}",
|
||||
f"体积(CBM):{_with_unit(str(src.get('体积') or ''), 'CBM')}",
|
||||
f"包装类型:{pack}",
|
||||
]
|
||||
added = (src.get("added_services") or src.get("增值服务") or "").strip()
|
||||
if added and added not in {"-", "—", "-"}:
|
||||
lines.append(f"增值服务:{added}")
|
||||
lines.append(f"计费重:{_chargeable_display(quote, src)}")
|
||||
lines.append(f"货型:{_cargo_type_display(quote, src)}")
|
||||
lines.append(f"报价日期:{quote_date}")
|
||||
return lines
|
||||
|
||||
|
||||
def air_price_lines(
|
||||
quote: dict[str, Any] | None,
|
||||
facts: dict[str, str] | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
TMS 价格段:费用行、备注、费用合计、报价有效期。
|
||||
|
||||
有单价且有计价单位:单价CNY11/KG。按票:CNY300/票。只有金额:CNY 100.0。
|
||||
备注每次都有,TMS 没带回就「-」。不写时效。
|
||||
"""
|
||||
from agent.policy.inquiry_copy import money_text, tms_valid_until_display
|
||||
|
||||
src = dict(quote or {})
|
||||
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
|
||||
lines = [_format_fee(row, header_ccy) for row in _fee_rows(src, facts)]
|
||||
lines = [line for line in lines if line]
|
||||
lines.append(f"备注:{_remark_display(src, facts)}")
|
||||
total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy)
|
||||
lines.append(f"费用合计:{total}")
|
||||
lines.append(f"报价有效期:{tms_valid_until_display(src)}")
|
||||
return lines
|
||||
|
||||
|
||||
def _airline_display(quote: dict[str, Any] | None, facts: dict[str, str]) -> str:
|
||||
"""航司优先代码(TH)。只有中文名时用中文名。没有就「-」。"""
|
||||
option = _option(quote, facts)
|
||||
blobs = [option, dict(quote or {})]
|
||||
for blob in blobs:
|
||||
for key in ("airlineCode", "carrierCode"):
|
||||
text = str(blob.get(key) or "").strip()
|
||||
if text:
|
||||
return text
|
||||
for blob in blobs:
|
||||
raw = str(blob.get("airline") or blob.get("carrier") or "").strip()
|
||||
if raw and _looks_like_code(raw):
|
||||
return raw
|
||||
for blob in blobs:
|
||||
name = str(blob.get("airlineName") or "").strip()
|
||||
if name:
|
||||
return name
|
||||
raw = str(blob.get("airline") or blob.get("carrier") or "").strip()
|
||||
if raw:
|
||||
return raw
|
||||
return "-"
|
||||
|
||||
|
||||
def _looks_like_code(raw: str) -> bool:
|
||||
"""两到四位字母数字才当航司代码,避免把中文名收成代码。"""
|
||||
token = raw.replace(" ", "")
|
||||
return token.isascii() and token.isalnum() and 2 <= len(token) <= 4
|
||||
|
||||
|
||||
def _chargeable_display(quote: dict[str, Any] | None, facts: dict[str, str]) -> str:
|
||||
"""计费重。纯数字补 KG;TMS 已带单位则原样。没有就「-」。"""
|
||||
option = _option(quote, facts)
|
||||
for blob in (option, dict(quote or {})):
|
||||
raw = blob.get("chargeableWeightKg")
|
||||
if raw in (None, "", "-"):
|
||||
raw = blob.get("chargeable_weight_kg") or blob.get("chargeableWeight")
|
||||
text = str(raw or "").strip()
|
||||
if not text or text in {"-", "—", "-"}:
|
||||
continue
|
||||
upper = text.upper()
|
||||
if any(upper.endswith(unit) for unit in ("KG", "KGS", "公斤", "千克")):
|
||||
return text
|
||||
return f"{_compact_number(text)}KG"
|
||||
return "-"
|
||||
|
||||
|
||||
def _cargo_type_display(quote: dict[str, Any] | None, facts: dict[str, str]) -> str:
|
||||
"""货型只展示 TMS 带回的原文,例如 1:212。没有就「-」。"""
|
||||
option = _option(quote, facts)
|
||||
for blob in (option, dict(quote or {})):
|
||||
for key in _CARGO_TYPE_KEYS:
|
||||
text = str(blob.get(key) or "").strip()
|
||||
if text and text not in {"-", "—", "-"}:
|
||||
return text
|
||||
return "-"
|
||||
|
||||
|
||||
def _remark_display(quote: dict[str, Any] | None, facts: dict[str, str] | None) -> str:
|
||||
"""报价备注。整单没有时,把费用行上不重复的备注拼起来。仍没有就「-」。"""
|
||||
src = dict(quote or {})
|
||||
option = _option(src, facts or {})
|
||||
for blob in (src, option):
|
||||
for key in _REMARK_KEYS:
|
||||
text = str(blob.get(key) or "").strip()
|
||||
if text and text not in {"-", "—", "-"}:
|
||||
return text
|
||||
notes: list[str] = []
|
||||
for row in _fee_rows(src, facts):
|
||||
note = str(row.get("remark") or row.get("note") or row.get("description") or "").strip()
|
||||
if note and note not in notes and note not in {"-", "—", "-"}:
|
||||
notes.append(note)
|
||||
return ";".join(notes) if notes else "-"
|
||||
|
||||
|
||||
def _option(quote: dict[str, Any] | None, facts: dict[str, str] | None) -> dict[str, Any]:
|
||||
"""群摘要和私聊共用:已选定的那条,或仅一条,或起运目的对得上的那条。"""
|
||||
from agent.schema.air_route_options import resolve_display_air_option
|
||||
|
||||
found = resolve_display_air_option(quote, facts)
|
||||
return dict(found or {})
|
||||
|
||||
|
||||
def _fee_rows(quote: dict[str, Any] | None, facts: dict[str, str] | None) -> list[dict[str, Any]]:
|
||||
"""
|
||||
该条线路的原始费用。
|
||||
|
||||
先读舱位上的空运段、起运港、目的地。多条线路对不上时不借用整单费用。
|
||||
已经有其它分项时,去掉「空运费=总价」这一行兜底。
|
||||
"""
|
||||
from agent.schema.air_route_options import (
|
||||
drop_air_freight_total_alias,
|
||||
parse_air_options,
|
||||
resolve_display_air_option,
|
||||
)
|
||||
|
||||
src = dict(quote or {})
|
||||
option = resolve_display_air_option(src, facts)
|
||||
rows: list[dict[str, Any]] = []
|
||||
if option:
|
||||
rows.extend(_named_rows(option.get("feeItems") or option.get("fee_items")))
|
||||
rows.extend(
|
||||
_named_rows(
|
||||
option.get("departureCharges")
|
||||
or option.get("departure_charges")
|
||||
or option.get("charges")
|
||||
)
|
||||
)
|
||||
rows.extend(
|
||||
_named_rows(
|
||||
option.get("destinationCharges")
|
||||
or option.get("destination_charges")
|
||||
or option.get("arrivalCharges")
|
||||
)
|
||||
)
|
||||
rows = _dedupe(rows)
|
||||
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:
|
||||
rows = _named_rows(src.get("fee_lines") or src.get("fee_rows"))
|
||||
if not rows:
|
||||
return []
|
||||
projected = drop_air_freight_total_alias({"total": src.get("total"), "fee_lines": rows})
|
||||
kept = projected.get("fee_lines")
|
||||
return [row for row in kept if isinstance(row, dict)] if isinstance(kept, list) else rows
|
||||
|
||||
|
||||
def _named_rows(raw: Any) -> list[dict[str, Any]]:
|
||||
"""费用数组收成字典。字符串 JSON 也认。没有名称的跳过。"""
|
||||
parsed = _parse_list(raw)
|
||||
out: list[dict[str, Any]] = []
|
||||
for item in parsed:
|
||||
row = dict(item)
|
||||
name = str(
|
||||
row.get("name") or row.get("chargeName") or row.get("feeName") or row.get("label") or ""
|
||||
).strip()
|
||||
if not name:
|
||||
continue
|
||||
row["name"] = name
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def _parse_list(raw: Any) -> list[dict[str, Any]]:
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [dict(item) for item in raw if isinstance(item, dict)]
|
||||
|
||||
|
||||
def _dedupe(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""同名费用保留先出现的桶,和主账投影一致。"""
|
||||
seen: set[str] = set()
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
key = str(row.get("name") or "").strip().lower()
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def _format_fee(row: dict[str, Any], header_ccy: str) -> str:
|
||||
"""一条费用。没有可展示的金额就不出现。"""
|
||||
from agent.policy.inquiry_copy import money_text
|
||||
|
||||
name = str(row.get("name") or "").strip()
|
||||
if not name:
|
||||
return ""
|
||||
ccy = _currency(str(row.get("currency") or row.get("currencyCode") or header_ccy or ""))
|
||||
unit = _bill_unit(str(row.get("unit") or row.get("unitName") or row.get("chargeUnit") or ""))
|
||||
unit_price = _first_text(row.get("unitPrice"), row.get("unit_price"))
|
||||
amount = _first_text(row.get("amount"), row.get("value"))
|
||||
if "%" in amount or name.endswith("费率") or name in {"税率", "燃油附加费率"}:
|
||||
shown = amount if "%" in amount else f"{amount}%"
|
||||
return f"{name}:{shown}"
|
||||
if unit and unit_price and unit != "票":
|
||||
price = _compact_number(unit_price)
|
||||
prefix = f"单价{ccy}{price}" if ccy else f"单价{price}"
|
||||
return f"{name}:{prefix}/{unit}"
|
||||
if unit == "票":
|
||||
num = unit_price or amount
|
||||
if num:
|
||||
body = f"{ccy}{_compact_number(num)}" if ccy else _compact_number(num)
|
||||
return f"{name}:{body}/票"
|
||||
shown = amount or unit_price
|
||||
if not shown:
|
||||
return ""
|
||||
return f"{name}:{money_text(shown, ccy)}"
|
||||
|
||||
|
||||
def _bill_unit(raw: str) -> str:
|
||||
"""计价单位收成 KG / 票 / CBM。空串表示 TMS 没给单位。"""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
token = text.upper().replace("/", "").replace("每", "").replace(" ", "")
|
||||
if token in {"票", "SHIPMENT", "BL"} or text in {"票", "/票", "每票"}:
|
||||
return "票"
|
||||
if token in {"KG", "KGS", "公斤", "千克"}:
|
||||
return "KG"
|
||||
if token in {"CBM", "M3", "立方米", "立方"}:
|
||||
return "CBM"
|
||||
return text.lstrip("/")
|
||||
|
||||
|
||||
def _currency(raw: str) -> str:
|
||||
code = (raw or "").strip().upper()
|
||||
if code == "RMB":
|
||||
return "CNY"
|
||||
return code
|
||||
|
||||
|
||||
def _first_text(*values: Any) -> str:
|
||||
for value in values:
|
||||
text = str(value if value is not None else "").strip()
|
||||
if text and text not in {"-", "—", "-", "None"}:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def _compact_number(raw: str) -> str:
|
||||
"""单价和按票金额去掉多余的尾零:11.00 写成 11。解析不了就原样。"""
|
||||
text = (raw or "").strip().replace(",", "")
|
||||
for prefix in ("CNY", "USD", "EUR", "HKD", "RMB"):
|
||||
if text.upper().startswith(prefix):
|
||||
text = text[len(prefix):].strip()
|
||||
break
|
||||
try:
|
||||
number = float(text)
|
||||
except ValueError:
|
||||
return text
|
||||
if number == int(number):
|
||||
return str(int(number))
|
||||
return f"{number:.4f}".rstrip("0").rstrip(".")
|
||||
@@ -68,6 +68,8 @@ class FlowSession:
|
||||
virtual_work_order_no: str = ""
|
||||
# 核对卡还没有工单号时,图片/附件先记在这里,确定建单后再挂到主账。
|
||||
pending_files: tuple[dict[str, str], ...] = ()
|
||||
# 建单前出现过、还没被品名改口清掉的违禁原话。空表示当前没有。
|
||||
sensitive_hold: str = ""
|
||||
|
||||
|
||||
def is_waiting_mode_session(sess) -> bool:
|
||||
@@ -152,6 +154,7 @@ def _session_from_payload(raw: dict[str, Any]) -> FlowSession:
|
||||
for item in (raw.get("pending_files") or [])
|
||||
if isinstance(item, dict) and str(item.get("object_key") or "").strip()
|
||||
),
|
||||
sensitive_hold=str(raw.get("sensitive_hold") or ""),
|
||||
)
|
||||
|
||||
|
||||
@@ -306,6 +309,65 @@ class AirTextInquiryFlow:
|
||||
"invited_product_names": sess.invited_product_names,
|
||||
}
|
||||
|
||||
def _refuse_sensitive(
|
||||
self,
|
||||
*,
|
||||
sender_id: str,
|
||||
text: str,
|
||||
facts: dict[str, Any],
|
||||
reply: ReplyFn,
|
||||
sess: Optional[FlowSession],
|
||||
business_line: str = "",
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
私聊还没建单时,违禁品只回一句并停在原处。
|
||||
|
||||
已有正式工单号的会话直接放过,销售后面补的字按原流程。
|
||||
没拦住时把品名改口写进 facts,并清掉书签上的旧原话。
|
||||
拦住时保存已认定字段,方便改口后接着补问。不建单、不追问缺项。
|
||||
调用在 Worker 的 on_text 里,不占企微回调线程。
|
||||
"""
|
||||
from agent.policy.handoff_human import is_virtual_no
|
||||
from agent.policy.sensitive_cargo import BLOCK_REPLY, judge
|
||||
|
||||
if sess is not None:
|
||||
number = (sess.work_order_no or "").strip()
|
||||
if number and not is_virtual_no(number):
|
||||
return None
|
||||
blocked, new_hold, _edits = judge(
|
||||
text=text,
|
||||
facts=facts,
|
||||
hold=str(getattr(sess, "sensitive_hold", "") or "") if sess else "",
|
||||
)
|
||||
if sess is not None:
|
||||
sess.sensitive_hold = new_hold
|
||||
if not blocked:
|
||||
return None
|
||||
kept = self._handoff_state(sess)
|
||||
pending = tuple(getattr(sess, "pending_files", ()) or ()) if sess else ()
|
||||
line = (business_line or (getattr(sess, "business_line", "") if sess else "") or "").strip()
|
||||
self._save(
|
||||
FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase="sensitive_block",
|
||||
business_line=line,
|
||||
facts={str(key): str(value) for key, value in (facts or {}).items()},
|
||||
immutable_text=(sess.immutable_text if sess and sess.immutable_text else text),
|
||||
sensitive_hold=new_hold,
|
||||
wait_version=(sess.wait_version + 1) if sess else 1,
|
||||
pending_files=pending,
|
||||
clarify_missing_sig=str(kept.get("clarify_missing_sig") or ""),
|
||||
clarify_retry=int(kept.get("clarify_retry") or 0),
|
||||
handoff_offer=str(kept.get("handoff_offer") or ""),
|
||||
handoff_committed=bool(kept.get("handoff_committed")),
|
||||
virtual_work_order_no=str(kept.get("virtual_work_order_no") or ""),
|
||||
)
|
||||
)
|
||||
reply(BLOCK_REPLY)
|
||||
logger.info("sensitive_block sender=%s line=%s", sender_id, line or "-")
|
||||
return "sensitive_block"
|
||||
|
||||
def _block_if_handoff_committed(self, sess: Optional[FlowSession], reply: ReplyFn) -> bool:
|
||||
"""真正转人工之后这一票不再跟。返回 True 表示已经拦住。"""
|
||||
from agent.policy.handoff_human import is_handoff_frozen
|
||||
@@ -870,6 +932,7 @@ class AirTextInquiryFlow:
|
||||
reply,
|
||||
work_order_no=sess.work_order_no,
|
||||
quote=sess.quote,
|
||||
facts=sess.facts,
|
||||
first_or_same=sess.first_or_same,
|
||||
history_work_order_no=sess.history_work_order_no,
|
||||
status=sess.status or "已报价",
|
||||
@@ -962,6 +1025,16 @@ class AirTextInquiryFlow:
|
||||
if sess and sess.phase == "wait_confirm" and looks_like_confirm(text):
|
||||
# 确定查价不再抽字段,避免「确定」被模型改成空 facts。
|
||||
logger.info("air.confirm sender=%s", sender_id)
|
||||
refused = self._refuse_sensitive(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
facts=dict(sess.facts),
|
||||
reply=reply,
|
||||
sess=sess,
|
||||
business_line=sess.business_line,
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
return self._confirm_or_hold(
|
||||
sender_id=sender_id,
|
||||
facts=dict(sess.facts),
|
||||
@@ -996,7 +1069,7 @@ class AirTextInquiryFlow:
|
||||
return llm_phase
|
||||
mode = (snap.get("business_line") or "").upper()
|
||||
facts = dict(snap.get("facts") or {})
|
||||
if sess and sess.phase in {"clarify", "need_mode", "wait_confirm"}:
|
||||
if sess and sess.phase in {"clarify", "need_mode", "wait_confirm", "sensitive_block"}:
|
||||
# 补问轮:旧字段打底;运输方式沿用上一句(飞=空运)
|
||||
# 增值服务按句累加,本句没提时不能把上一句已记下的服务冲掉。
|
||||
from agent.llm.extract_text import detect_transport_mode
|
||||
@@ -1018,6 +1091,18 @@ class AirTextInquiryFlow:
|
||||
|
||||
facts = apply_air_value_added(added_source, facts, previous=prev_added)
|
||||
|
||||
if not mode or mode == "AIR":
|
||||
refused = self._refuse_sensitive(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
facts=facts,
|
||||
reply=reply,
|
||||
sess=sess,
|
||||
business_line=mode or (sess.business_line if sess else ""),
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
|
||||
if not mode:
|
||||
reply(copy.ask_transport())
|
||||
self._save(
|
||||
@@ -1194,6 +1279,16 @@ class AirTextInquiryFlow:
|
||||
from agent.policy.handoff_human import void_virtual_no
|
||||
|
||||
old = self.session_of(sender_id)
|
||||
refused = self._refuse_sensitive(
|
||||
sender_id=sender_id,
|
||||
text="",
|
||||
facts=facts,
|
||||
reply=reply,
|
||||
sess=old,
|
||||
business_line="AIR",
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
if old:
|
||||
void_virtual_no(old)
|
||||
self._save(
|
||||
@@ -1355,6 +1450,7 @@ class AirTextInquiryFlow:
|
||||
reply,
|
||||
work_order_no=no,
|
||||
quote=quote,
|
||||
facts=sess.facts,
|
||||
first_or_same=first,
|
||||
history_work_order_no=history_wo,
|
||||
status="已报价",
|
||||
@@ -1479,6 +1575,7 @@ class AirTextInquiryFlow:
|
||||
reply,
|
||||
work_order_no=sess.work_order_no,
|
||||
quote=quote,
|
||||
facts=facts,
|
||||
first_or_same=sess.first_or_same,
|
||||
history_work_order_no=sess.history_work_order_no,
|
||||
status="已报价",
|
||||
@@ -1491,6 +1588,7 @@ class AirTextInquiryFlow:
|
||||
*,
|
||||
work_order_no: str,
|
||||
quote: dict[str, Any],
|
||||
facts: dict[str, str] | None = None,
|
||||
first_or_same: str = "first",
|
||||
history_work_order_no: str = "",
|
||||
status: str = "已报价",
|
||||
@@ -1506,6 +1604,7 @@ class AirTextInquiryFlow:
|
||||
copy.sea_quote_detail_text(
|
||||
work_order_no=work_order_no,
|
||||
quote=src,
|
||||
facts=facts,
|
||||
first_or_same=first_or_same,
|
||||
history_work_order_no=history_work_order_no,
|
||||
status=status,
|
||||
|
||||
@@ -465,10 +465,10 @@ def ask_land_fields(
|
||||
陆运货物缺项补问。不出核对卡、不出现确定句。
|
||||
|
||||
已识别和待补充都标明必填/非必填。跨境/中港空着的通关口岸也列进待补充。
|
||||
报价日期不进待补充。
|
||||
报价日期不进待补充。货物品名只在前面的选项清单里问,这里不再放进待填框。
|
||||
"""
|
||||
src = dict(facts or {})
|
||||
skip = {"报价日期"}
|
||||
skip = {"报价日期", "品名"}
|
||||
missing = [k for k in missing_keys if k and k not in skip]
|
||||
land_type = (land_subtype or src.get("运输类型") or "").strip()
|
||||
optional = _land_optional_keys(land_type)
|
||||
@@ -1220,26 +1220,29 @@ def sea_quote_detail_text(
|
||||
history_work_order_no: str = "",
|
||||
status: str = "已报价",
|
||||
business_line: str = "SEA",
|
||||
facts: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
完整价格明细文本:来源、工单号、费用行、总价。海运/陆运/空运共用。
|
||||
完整价格明细文本。海运/陆运仍是来源、工单号、费用行、总价。
|
||||
|
||||
空运改成货物信息 + 价格方案。航司、计费重、货型和费用来自 TMS,其余用工单字段。
|
||||
陆运分段时效跟在各段费用后面,用 TMS 原文;报价有效期写在总成本价下面。
|
||||
海运时效、报价有效期仍只在按钮卡副标题。
|
||||
空运:业务确认没有时效,总销售价下面只留报价有效期。
|
||||
不受卡片引用条字数限制。first_or_same 只兼容调用方。
|
||||
"""
|
||||
_ = first_or_same, history_work_order_no, status
|
||||
src = dict(quote or {})
|
||||
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
|
||||
lines = [source, f"{work_order_no}价格明细"]
|
||||
line = (business_line or "").upper()
|
||||
if line == "AIR":
|
||||
from agent.schema.air_route_options import route_codes
|
||||
from agent.policy.air_quote_copy import air_private_quote_text
|
||||
|
||||
route = route_codes(None, {}, src)
|
||||
if route:
|
||||
lines.append(route)
|
||||
return air_private_quote_text(
|
||||
work_order_no=work_order_no,
|
||||
facts=facts,
|
||||
quote=src,
|
||||
)
|
||||
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
|
||||
lines = [source, f"{work_order_no}价格明细"]
|
||||
if line == "LAND":
|
||||
from agent.schema.land_route_options import land_leg_fee_blocks
|
||||
|
||||
@@ -1250,8 +1253,8 @@ def sea_quote_detail_text(
|
||||
if land_line and len(blocks) != 1:
|
||||
lines.append(land_line)
|
||||
lines.append(quote_area_fee_text(src, business_line=business_line or "SEA"))
|
||||
# 空运、陆运的价格明细正文都要带有效期。海运仍放在按钮卡副标题,避免和原口径打架。
|
||||
if line in {"AIR", "LAND"}:
|
||||
# 陆运价格明细正文带有效期。海运仍放在按钮卡副标题。空运已在上面单独成文。
|
||||
if line == "LAND":
|
||||
lines.append(f"报价有效期:{tms_valid_until_display(src)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -3011,12 +3014,12 @@ def air_group_brief(
|
||||
airline_names: list[str] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
空运群激活摘要:与海运进群同一套结构,字段用空运。
|
||||
空运群激活摘要。
|
||||
|
||||
先 @ 销售和航线,再工单号 + 询价字段 + 协同补充(含电/含磁)+ TMS报价。
|
||||
TMS 段跟单聊同一套线路分项,总销售价只一行。
|
||||
已有运输费等分项时,不再叠一行「空运费=总价」;只有空运费这一项时要写出来。
|
||||
不写「当前工单/询价要点/现在等到哪一步」。
|
||||
有 TMS 报价:货物信息(含航司、计费重、货型)+ 协同补充 + 新价格段。
|
||||
没有 TMS 报价:仍是「询价字段」+「TMS 暂无匹配报价」。
|
||||
品名正好是普货且没人补过时,含电含磁仍写「否」。
|
||||
人工报价、销售调价有则接在后面,写法不改。
|
||||
"""
|
||||
no = (work_order_no or "").strip()
|
||||
at_bits: list[str] = []
|
||||
@@ -3027,10 +3030,28 @@ def air_group_brief(
|
||||
token = f"@{(name or '').strip()}"
|
||||
if (name or "").strip() and token not in at_bits:
|
||||
at_bits.append(token)
|
||||
lines = ([(" ".join(at_bits))] if at_bits else []) + [f"工单号:{no}", "询价字段:"]
|
||||
lines.extend(
|
||||
inquiry_field_lines(work_order_no=no, facts=facts, transport_mode="空运")[1:]
|
||||
tms, manual, adjust = _split_brief_quotes(
|
||||
quote=quote,
|
||||
tms_quote=tms_quote,
|
||||
product_quote=product_quote,
|
||||
has_price=has_price,
|
||||
)
|
||||
if tms:
|
||||
from agent.schema.air_route_options import project_group_air_quote
|
||||
|
||||
tms = project_group_air_quote(tms, facts)
|
||||
has_tms_price = bool(tms and (tms.get("total") or quote_fee_lines(tms)))
|
||||
if has_tms_price:
|
||||
# 有 TMS 报价才换成货物信息。没报价仍走下面的「询价字段」。
|
||||
from agent.policy.air_quote_copy import air_cargo_lines
|
||||
|
||||
lines = ([(" ".join(at_bits))] if at_bits else [])
|
||||
lines.extend(air_cargo_lines(work_order_no=no, facts=facts, quote=tms))
|
||||
else:
|
||||
lines = ([(" ".join(at_bits))] if at_bits else []) + [f"工单号:{no}", "询价字段:"]
|
||||
lines.extend(
|
||||
inquiry_field_lines(work_order_no=no, facts=facts, transport_mode="空运")[1:]
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("协同补充字段:")
|
||||
collab = dict(collab_facts or {})
|
||||
@@ -3046,20 +3067,12 @@ def air_group_brief(
|
||||
if val in {"", "-", "—", "-"}:
|
||||
val = "-"
|
||||
lines.append(f"{key}:{val}")
|
||||
tms, manual, adjust = _split_brief_quotes(
|
||||
quote=quote,
|
||||
tms_quote=tms_quote,
|
||||
product_quote=product_quote,
|
||||
has_price=has_price,
|
||||
)
|
||||
if tms:
|
||||
from agent.schema.air_route_options import project_group_air_quote
|
||||
|
||||
tms = project_group_air_quote(tms, facts)
|
||||
lines.append("")
|
||||
lines.append("TMS报价:")
|
||||
if tms and (tms.get("total") or quote_fee_lines(tms)):
|
||||
lines.extend(quote_detail_lines(tms, business_line="AIR"))
|
||||
if has_tms_price:
|
||||
from agent.policy.air_quote_copy import air_price_lines
|
||||
|
||||
lines.extend(air_price_lines(tms, facts))
|
||||
lines.append(group_tms_confirm_hint(business_line="AIR"))
|
||||
else:
|
||||
lines.append("TMS 暂无匹配报价")
|
||||
@@ -3235,16 +3248,37 @@ def air_lock_ok(
|
||||
option_no: str,
|
||||
exec_no: str,
|
||||
quote_no: str,
|
||||
origin: str = "",
|
||||
destination: str = "",
|
||||
airline: str = "",
|
||||
flight_date: str = "",
|
||||
) -> str:
|
||||
"""空运锁舱成功。后台 inquiring.air_lock_ok。"""
|
||||
"""
|
||||
空运锁舱成功。后台 inquiring.air_lock_ok。
|
||||
|
||||
回执在报价编号下补起运地、目的地、航司、航班日期。
|
||||
后台旧模板没有这四行时,这里补上,避免被旧句盖掉。
|
||||
不编造空值;没取到就留空。
|
||||
"""
|
||||
origin_text = (origin or "").strip()
|
||||
destination_text = (destination or "").strip()
|
||||
airline_text = (airline or "").strip()
|
||||
flight_text = (flight_date or "").strip()
|
||||
route = (
|
||||
f"起运地:{origin_text}\n"
|
||||
f"目的地:{destination_text}\n"
|
||||
f"航司:{airline_text}\n"
|
||||
f"航班日期:{flight_text}"
|
||||
)
|
||||
default = (
|
||||
f"工单{work_order_no} 已通知TMS执行空运锁舱,执行成功。\n"
|
||||
"TMS空运锁舱成功。\n"
|
||||
f"操作人:{operator}\n"
|
||||
f"舱位选项:{option_no}\n"
|
||||
f"执行编号:{exec_no}\n"
|
||||
f"报价编号:{quote_no}"
|
||||
f"报价编号:{quote_no}\n"
|
||||
f"{route}"
|
||||
)
|
||||
return _r(
|
||||
text = _r(
|
||||
"inquiring.air_lock_ok",
|
||||
default,
|
||||
ticket_no=work_order_no,
|
||||
@@ -3252,7 +3286,15 @@ def air_lock_ok(
|
||||
option_no=option_no,
|
||||
exec_no=exec_no,
|
||||
quote_no=quote_no,
|
||||
origin=origin_text,
|
||||
destination=destination_text,
|
||||
airline=airline_text,
|
||||
flight_date=flight_text,
|
||||
)
|
||||
body = (text or "").rstrip()
|
||||
if "起运地:" in body and "航班日期:" in body:
|
||||
return body
|
||||
return f"{body}\n{route}" if body else route
|
||||
|
||||
|
||||
# 多段里有空运段时,航线锁舱的失败原因。产品暂不支持,不当 TMS 故障。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
陆运文字询价确定性流程。
|
||||
|
||||
本文件职责:三项选项各自必填(不联动)→ 按类型补货物 → 无工单号核对卡 →(确定后)建单查 TMS。
|
||||
本文件职责:三项选项和货物品名同一张清单各自必填(不联动)→ 按类型补始发站等货物 → 无工单号核对卡 →(确定后)建单查 TMS。
|
||||
查价后:1 条完整线路直接出价格明细;多于 1 条发入口卡,H5 选定后再出该条明细。
|
||||
查价后半段复用海运拉群/报价单/成交,拉人按线路类别,禁止陆运岗全员。
|
||||
禁止:字段齐了就建单;直连 TMS;把中转段当成另一条线路。海运私聊同样是核对后确定再查价。
|
||||
@@ -27,7 +27,10 @@ from agent.policy.air_text_flow import (
|
||||
from agent.policy.sea_text_flow import SeaTextInquiryFlow
|
||||
from agent.schema.field_validate import harvest_oral_measures, validate_required_fields
|
||||
from agent.schema.land_options import (
|
||||
drop_blank_paste_options,
|
||||
drop_land_prompt_echo,
|
||||
harvest_land_options,
|
||||
land_cargo_name,
|
||||
looks_like_confirm,
|
||||
option_prompt,
|
||||
sample_pair_allowed,
|
||||
@@ -37,7 +40,9 @@ from agent.schema.tms_land_query import assemble_land_query
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LAND_FRONT = frozenset({"need_mode", "need_land_options", "clarify", "wait_confirm", "wait_land_route", "idle", ""})
|
||||
_LAND_FRONT = frozenset(
|
||||
{"need_mode", "need_land_options", "clarify", "wait_confirm", "wait_land_route", "sensitive_block", "idle", ""}
|
||||
)
|
||||
|
||||
|
||||
class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
@@ -94,6 +99,16 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
if sess and sess.phase == "wait_confirm" and looks_like_confirm(text):
|
||||
# 确定查价不再抽字段,避免「确定」被模型改成空运/空 facts。
|
||||
logger.info("land.confirm sender=%s", sender_id)
|
||||
refused = self._refuse_sensitive(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
facts=dict(sess.facts),
|
||||
reply=reply,
|
||||
sess=sess,
|
||||
business_line=sess.business_line,
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
return self._confirm_or_hold(
|
||||
sender_id=sender_id,
|
||||
facts=dict(sess.facts),
|
||||
@@ -170,6 +185,20 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
)
|
||||
if b_opt:
|
||||
facts = harvest_land_options(b_opt, facts)
|
||||
# 复制清单冒号空着的项,菜单文字和模型猜测都不能当成已填。
|
||||
facts = drop_blank_paste_options(facts, source)
|
||||
|
||||
if not mode or mode == "LAND":
|
||||
refused = self._refuse_sensitive(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
facts=facts,
|
||||
reply=reply,
|
||||
sess=sess,
|
||||
business_line=mode or (sess.business_line if sess else ""),
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
|
||||
if not mode:
|
||||
mode = "LAND" if (sess and (sess.business_line or "").upper() == "LAND") else ""
|
||||
@@ -291,6 +320,16 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
"""
|
||||
import uuid
|
||||
|
||||
refused = self._refuse_sensitive(
|
||||
sender_id=sender_id,
|
||||
text="",
|
||||
facts=facts,
|
||||
reply=reply,
|
||||
sess=self.session_of(sender_id),
|
||||
business_line="LAND",
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
tms_ready = assemble_land_query(facts)
|
||||
if not tms_ready.get("ok"):
|
||||
return self._advance_front(
|
||||
@@ -313,7 +352,7 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
created = self._ledger.create_ticket(
|
||||
sender_id=sender_id,
|
||||
business_line="LAND",
|
||||
facts=facts,
|
||||
facts=drop_land_prompt_echo(facts),
|
||||
idempotency_key=f"create:{sender_id}:{uuid.uuid4().hex}",
|
||||
)
|
||||
no = str(created.get("work_order_no") or "").strip()
|
||||
@@ -582,7 +621,8 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
merged.update(opt.get("normalized") or {})
|
||||
if opt.get("errors"):
|
||||
return {"ready": False, "opt": opt, "facts": merged, "stage": "options_error"}
|
||||
if not opt.get("ok"):
|
||||
# 货物品名和三项同一张清单。没填上继续发这张,不挪到始发站补问。
|
||||
if not opt.get("ok") or not land_cargo_name(merged):
|
||||
return {"ready": False, "opt": opt, "facts": merged, "stage": "options_missing"}
|
||||
land_type = (merged.get("运输类型") or "").strip()
|
||||
check = validate_required_fields(
|
||||
@@ -658,6 +698,8 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
return "need_land_options"
|
||||
if state["stage"] == "options_missing":
|
||||
keys = list((state.get("opt") or {}).get("missing") or [])
|
||||
if not land_cargo_name(facts):
|
||||
keys.append("品名")
|
||||
logger.info("land.options_missing missing=%s", keys)
|
||||
new_sess = FlowSession(
|
||||
sender_id=sender_id,
|
||||
|
||||
@@ -20,7 +20,10 @@ from agent.schema.field_validate import (
|
||||
)
|
||||
from agent.llm.extract_text import parse_labeled_facts
|
||||
from agent.schema.land_options import (
|
||||
drop_blank_paste_options,
|
||||
drop_land_prompt_echo,
|
||||
harvest_land_options,
|
||||
land_cargo_name,
|
||||
option_prompt,
|
||||
scrub_land_placeholder_ports,
|
||||
validate_land_options,
|
||||
@@ -220,15 +223,26 @@ def _segment_required_check(seg: Segment) -> tuple[dict[str, Any], dict[str, str
|
||||
)
|
||||
)
|
||||
option_missing = [str(x) for x in (opt.get("missing") or []) if str(x).strip()]
|
||||
cargo_missing = [str(x) for x in (check.get("missing") or []) if str(x).strip()]
|
||||
missing = option_missing + [k for k in cargo_missing if k not in option_missing]
|
||||
cargo_missing = [
|
||||
str(x)
|
||||
for x in (check.get("missing") or [])
|
||||
if str(x).strip() and str(x) not in option_missing and str(x) != "品名"
|
||||
]
|
||||
# 货物品名没填时仍算这张选项清单没完成,不进始发站待填。
|
||||
cargo_name = land_cargo_name(facts)
|
||||
missing = list(option_missing) + cargo_missing
|
||||
if not cargo_name:
|
||||
missing.append("品名")
|
||||
merged = dict(check.get("facts") or facts)
|
||||
merged.update({k: facts[k] for k in ("运输类型", "线路类别", "运输分类") if facts.get(k)})
|
||||
for key, val in facts.items():
|
||||
if str(key).startswith("_land_show_") and str(val).strip():
|
||||
merged[str(key)] = str(val).strip()
|
||||
check["facts"] = merged
|
||||
check["missing"] = missing
|
||||
check["ok"] = not missing
|
||||
check["need_land_subtype"] = False
|
||||
check["need_land_options"] = bool(option_missing)
|
||||
check["need_land_options"] = bool(option_missing) or not cargo_name
|
||||
return check, merged
|
||||
check = validate_required_fields(facts=dict(seg.facts), business_line=seg.mode)
|
||||
facts = dict(seg.facts)
|
||||
@@ -342,7 +356,10 @@ def _overlay_segments(
|
||||
continue
|
||||
blob = (neu.source or "").strip()
|
||||
if target.mode == "LAND":
|
||||
overlay = harvest_land_options(blob, dict(neu.facts))
|
||||
overlay = drop_blank_paste_options(
|
||||
harvest_land_options(blob, dict(neu.facts)),
|
||||
blob,
|
||||
)
|
||||
elif target.mode == "SEA":
|
||||
overlay = harvest_sea_class(blob, dict(neu.facts))
|
||||
else:
|
||||
@@ -412,16 +429,23 @@ def group_brief(
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def segments_to_payload(segments: list[Segment]) -> list[dict[str, Any]]:
|
||||
"""书签和工单里保存的段。只保留字符串,避免书签序列化掉类型。"""
|
||||
return [
|
||||
{
|
||||
"index": seg.index,
|
||||
"mode": seg.mode,
|
||||
"facts": {str(k): str(v) for k, v in (seg.facts or {}).items() if str(v).strip()},
|
||||
}
|
||||
for seg in segments
|
||||
]
|
||||
def segments_to_payload(
|
||||
segments: list[Segment],
|
||||
*,
|
||||
keep_prompt_echo: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
书签和工单里保存的段。只保留字符串,避免书签序列化掉类型。
|
||||
|
||||
建单时 keep_prompt_echo=False,回显用的内部键不进工单。
|
||||
"""
|
||||
rows: list[dict[str, Any]] = []
|
||||
for seg in segments:
|
||||
facts = {str(k): str(v) for k, v in (seg.facts or {}).items() if str(v).strip()}
|
||||
if not keep_prompt_echo:
|
||||
facts = drop_land_prompt_echo(facts)
|
||||
rows.append({"index": seg.index, "mode": seg.mode, "facts": facts})
|
||||
return rows
|
||||
|
||||
|
||||
def segments_from_payload(raw: Any) -> list[Segment]:
|
||||
@@ -462,16 +486,13 @@ def _segment_clarify(seg: Segment, check: dict[str, Any]) -> str:
|
||||
if check.get("ok") and not check.get("need_land_subtype"):
|
||||
return ""
|
||||
missing = [str(x) for x in (check.get("missing") or []) if str(x).strip()]
|
||||
if check.get("need_land_subtype"):
|
||||
return (
|
||||
copy.CLARIFY_PASTE_HINT
|
||||
+ "\n1.运输类型(必填):\n(如:国内运输拼车)"
|
||||
)
|
||||
if seg.mode == "LAND" and (
|
||||
check.get("need_land_subtype") or check.get("need_land_options")
|
||||
):
|
||||
# 和单聊同一张清单:类型、线路、分类、货物品名。已填的留在复制框上面。
|
||||
return option_prompt(current=seg.facts)
|
||||
if seg.mode == "LAND":
|
||||
if check.get("need_land_options"):
|
||||
# 和单聊一样先列出线路类别、运输分类,选完再问询价字段。
|
||||
return option_prompt(current=seg.facts)
|
||||
cargo_missing = [k for k in missing if k not in _LAND_OPTION_KEYS]
|
||||
cargo_missing = [k for k in missing if k not in _LAND_OPTION_KEYS and k != "品名"]
|
||||
raw = copy.ask_land_fields(
|
||||
facts=seg.facts,
|
||||
missing_keys=cargo_missing,
|
||||
@@ -594,7 +615,7 @@ def _facts_from_body(body: str, mode: str) -> dict[str, str]:
|
||||
if val and not str(facts.get(key) or "").strip():
|
||||
facts[key] = val
|
||||
if mode == "LAND":
|
||||
facts = harvest_land_options(body, facts)
|
||||
facts = drop_blank_paste_options(harvest_land_options(body, facts), body)
|
||||
if mode == "SEA":
|
||||
facts = harvest_sea_class(body, facts)
|
||||
return facts
|
||||
@@ -624,7 +645,7 @@ def _split_by_segment_mark(text: str) -> dict[int, dict[str, str]]:
|
||||
end = marks[i + 1].start() if i + 1 < len(marks) else len(raw)
|
||||
body = raw[mark.end() : end]
|
||||
facts = parse_labeled_facts(body)
|
||||
facts = harvest_land_options(body, facts)
|
||||
facts = drop_blank_paste_options(harvest_land_options(body, facts), body)
|
||||
facts = harvest_sea_class(body, facts)
|
||||
facts = harvest_oral_measures(body, facts)
|
||||
if facts:
|
||||
|
||||
@@ -43,7 +43,7 @@ from agent.schema.land_options import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 只有补问/核对还能续同一张多段草稿。已出号的拉群停点不能拦住新询价。
|
||||
_FRONT = frozenset({"clarify", "wait_confirm"})
|
||||
_FRONT = frozenset({"clarify", "wait_confirm", "sensitive_block"})
|
||||
|
||||
|
||||
class MultiTextInquiryFlow:
|
||||
@@ -170,6 +170,52 @@ class MultiTextInquiryFlow:
|
||||
reply("多段询价请先把各段需求发齐并回复确定。需要人来接管时,请在工单创建后再说。")
|
||||
return "multi_handoff_later"
|
||||
|
||||
def _gate_sensitive(
|
||||
self,
|
||||
*,
|
||||
sender_id: str,
|
||||
text: str,
|
||||
reply: ReplyFn,
|
||||
segments: list[Segment],
|
||||
sess: Optional[FlowSession],
|
||||
immutable: str,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
多段私聊建单前的违禁品拦截。
|
||||
|
||||
已有工单号的旧会话不把违禁原话带进新的一段。没命中则把品名改口写回各段。
|
||||
命中只回一句,不补问、不建单。
|
||||
"""
|
||||
from agent.policy.sensitive_cargo import BLOCK_REPLY, judge
|
||||
|
||||
facts: dict[str, str] = {}
|
||||
for seg in segments:
|
||||
for key, value in (seg.facts or {}).items():
|
||||
if str(value or "").strip():
|
||||
facts[str(key)] = str(value)
|
||||
use_old = sess is not None and not (sess.work_order_no or "").strip()
|
||||
blocked, new_hold, edits = judge(
|
||||
text=text,
|
||||
facts=facts,
|
||||
hold=str(sess.sensitive_hold or "") if use_old else "",
|
||||
)
|
||||
for seg in segments:
|
||||
for key, value in edits.items():
|
||||
seg.facts[key] = value
|
||||
if not blocked:
|
||||
if use_old and sess is not None:
|
||||
sess.sensitive_hold = new_hold
|
||||
if edits:
|
||||
self._put_segments(sess, segments)
|
||||
self._save(sess)
|
||||
return None
|
||||
draft = self._draft(sender_id, segments, phase="sensitive_block", immutable=immutable or text)
|
||||
draft.sensitive_hold = new_hold
|
||||
self._save(draft)
|
||||
reply(BLOCK_REPLY)
|
||||
logger.info("multi.sensitive_block sender=%s", sender_id)
|
||||
return "sensitive_block"
|
||||
|
||||
def on_text(
|
||||
self,
|
||||
*,
|
||||
@@ -196,6 +242,19 @@ class MultiTextInquiryFlow:
|
||||
sess = self.session_of(sid)
|
||||
if sess and self.match_button(raw, sess):
|
||||
return self._pull_group(sess, reply)
|
||||
if sess and (sess.phase or "") == "sensitive_block" and not (sess.work_order_no or "").strip():
|
||||
refused = self._gate_sensitive(
|
||||
sender_id=sid,
|
||||
text=raw,
|
||||
reply=reply,
|
||||
segments=self._segments(sess),
|
||||
sess=sess,
|
||||
immutable=sess.immutable_text or raw,
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
sess.phase = "clarify"
|
||||
self._save(sess)
|
||||
verdict = classify_multi_text(raw)
|
||||
if verdict == "same_mode":
|
||||
reply(copy.MULTI_SAME_MODE)
|
||||
@@ -276,7 +335,28 @@ class MultiTextInquiryFlow:
|
||||
附件口播经常没有「起运港:」这种标签,要按段走和单聊一样的抽取,
|
||||
不能只认标签行,否则会整段待补充。
|
||||
"""
|
||||
current = self.session_of(sender_id)
|
||||
refused = self._gate_sensitive(
|
||||
sender_id=sender_id,
|
||||
text=immutable,
|
||||
reply=reply,
|
||||
segments=list(segments),
|
||||
sess=current if current and not (current.work_order_no or "").strip() else None,
|
||||
immutable=immutable,
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
filled = self._enrich_segments(segments, allow_b=allow_b, chat_fn=chat_fn)
|
||||
refused = self._gate_sensitive(
|
||||
sender_id=sender_id,
|
||||
text="",
|
||||
reply=reply,
|
||||
segments=filled,
|
||||
sess=self.session_of(sender_id),
|
||||
immutable=immutable,
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
ready, updated, gaps = refresh_segments(filled)
|
||||
if not ready:
|
||||
self._reply_gaps(updated, gaps, reply)
|
||||
@@ -358,6 +438,16 @@ class MultiTextInquiryFlow:
|
||||
|
||||
口语补句走单聊同一套抽取,不在这里猜「广州-深圳」。
|
||||
"""
|
||||
refused = self._gate_sensitive(
|
||||
sender_id=sess.sender_id,
|
||||
text=text,
|
||||
reply=reply,
|
||||
segments=self._segments(sess),
|
||||
sess=sess,
|
||||
immutable=sess.immutable_text or text,
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
from agent.llm.extract_text import extract_inquiry_snapshot
|
||||
from agent.policy.system_exception import handle_extract_tech_fail
|
||||
|
||||
@@ -407,6 +497,16 @@ class MultiTextInquiryFlow:
|
||||
def _create_ticket(self, sess: FlowSession, reply: ReplyFn) -> str:
|
||||
"""回复确定后建一张 MULTI 工单。含空运只给手动拉群提示,不含空运出拉群卡。"""
|
||||
segments = self._segments(sess)
|
||||
refused = self._gate_sensitive(
|
||||
sender_id=sess.sender_id,
|
||||
text="",
|
||||
reply=reply,
|
||||
segments=segments,
|
||||
sess=sess,
|
||||
immutable=sess.immutable_text,
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
ready, updated, gaps = refresh_segments(segments)
|
||||
if not ready:
|
||||
self._reply_gaps(updated, gaps, reply)
|
||||
@@ -414,7 +514,7 @@ class MultiTextInquiryFlow:
|
||||
self._put_segments(sess, updated)
|
||||
self._save(sess)
|
||||
return "clarify"
|
||||
payload = segments_to_payload(updated)
|
||||
payload = segments_to_payload(updated, keep_prompt_echo=False)
|
||||
created = self._ledger.create_ticket(
|
||||
sender_id=sess.sender_id,
|
||||
business_line="MULTI",
|
||||
|
||||
@@ -113,6 +113,16 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
if sess and sess.phase == "wait_confirm" and looks_like_confirm(text):
|
||||
# 确定查价不再抽字段,避免「确定」被模型改成空 facts。
|
||||
logger.info("sea.confirm sender=%s", sender_id)
|
||||
refused = self._refuse_sensitive(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
facts=dict(sess.facts),
|
||||
reply=reply,
|
||||
sess=sess,
|
||||
business_line=sess.business_line,
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
return self._confirm_or_hold(
|
||||
sender_id=sender_id,
|
||||
facts=dict(sess.facts),
|
||||
@@ -145,7 +155,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
return llm_phase
|
||||
mode = (snap.get("business_line") or "").upper()
|
||||
facts = dict(snap.get("facts") or {})
|
||||
if sess and sess.phase in {"clarify", "need_mode", "wait_confirm"}:
|
||||
if sess and sess.phase in {"clarify", "need_mode", "wait_confirm", "sensitive_block"}:
|
||||
from agent.llm.extract_text import detect_transport_mode
|
||||
from agent.schema.field_validate import harvest_oral_measures
|
||||
|
||||
@@ -156,6 +166,18 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
if not mode:
|
||||
mode = detect_transport_mode(f"{sess.immutable_text or ''}\n{text}")
|
||||
|
||||
if not mode or mode == "SEA":
|
||||
refused = self._refuse_sensitive(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
facts=facts,
|
||||
reply=reply,
|
||||
sess=sess,
|
||||
business_line=mode or (sess.business_line if sess else ""),
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
|
||||
if not mode:
|
||||
reply(copy.ask_transport())
|
||||
self._save(
|
||||
@@ -374,6 +396,16 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
from agent.policy.handoff_human import void_virtual_no
|
||||
|
||||
old = self.session_of(sender_id)
|
||||
refused = self._refuse_sensitive(
|
||||
sender_id=sender_id,
|
||||
text="",
|
||||
facts=facts,
|
||||
reply=reply,
|
||||
sess=old,
|
||||
business_line="SEA",
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
if old:
|
||||
void_virtual_no(old)
|
||||
pending = tuple(getattr(old, "pending_files", ()) or ()) if old else ()
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""
|
||||
私聊建单前的违禁品拦截。
|
||||
|
||||
本文件职责:用固定词表判断当前这句话、尚未改口的原话,以及品名、起运地、备注
|
||||
是否涉及真家伙(枪支、弹药、爆炸物、毒品)。命中则不能建单。
|
||||
群聊、已经建成的工单不在这里判断,由调用方避开。
|
||||
调用:Worker 里的私聊文字流程,在出补问或建单之前。只做字符串匹配,不调模型。
|
||||
禁止:把水枪、玩具枪、仿真枪、气枪、烟花、礼炮、鞭炮算进去;禁止拦化学品、电池、油品。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# 销售私聊里只回这一句。不要再追问缺项,也不要带工单号。
|
||||
BLOCK_REPLY = "涉及危险品,无法发起报价。"
|
||||
|
||||
# 先圈出放行说法。词表里的「枪支」若落在这些说法内部,不算命中。
|
||||
_ALLOW = (
|
||||
"玩具枪",
|
||||
"水枪",
|
||||
"仿真枪",
|
||||
"气枪",
|
||||
"烟花",
|
||||
"礼炮",
|
||||
"鞭炮",
|
||||
"炮仗",
|
||||
)
|
||||
|
||||
# 真家伙。不用单字「枪」「炮」「弹」,避免误伤水枪和礼炮。
|
||||
_BLOCK = (
|
||||
"汽油弹",
|
||||
"燃烧弹",
|
||||
"原子弹",
|
||||
"手榴弹",
|
||||
"导火索",
|
||||
"甲基苯丙胺",
|
||||
"氯胺酮",
|
||||
"高射炮",
|
||||
"榴弹炮",
|
||||
"冲锋枪",
|
||||
"狙击枪",
|
||||
"霰弹枪",
|
||||
"火药枪",
|
||||
"摇头丸",
|
||||
"海洛因",
|
||||
"可卡因",
|
||||
"炸弹",
|
||||
"炸药",
|
||||
"爆破",
|
||||
"雷管",
|
||||
"手雷",
|
||||
"地雷",
|
||||
"导弹",
|
||||
"核弹",
|
||||
"枪支",
|
||||
"枪械",
|
||||
"手枪",
|
||||
"步枪",
|
||||
"机枪",
|
||||
"猎枪",
|
||||
"子弹",
|
||||
"弹药",
|
||||
"弹头",
|
||||
"大炮",
|
||||
"火炮",
|
||||
"毒品",
|
||||
"冰毒",
|
||||
"大麻",
|
||||
"鸦片",
|
||||
"吗啡",
|
||||
"K粉",
|
||||
)
|
||||
|
||||
# 改口动词。多段原文里的「品名:衣服」不能走这里,否则会抄到每一段。
|
||||
_FIELD_EDIT = re.compile(
|
||||
r"(货物品名|品名|起运地|起运港|始发站|备注)\s*(?:改为|改成|是|为)\s*(\S+)"
|
||||
)
|
||||
# 单票里销售按清单重写「品名:服装」。多段「段1 / 段2」整单不靠这个改字段。
|
||||
_LABEL_EDIT = re.compile(
|
||||
r"(货物品名|品名|起运地|起运港|始发站|备注)\s*[::]\s*(\S+)"
|
||||
)
|
||||
_MULTI_SHEET = re.compile(r"段\s*\d|第\s*[一二三四五六七八九\d]\s*段")
|
||||
# 口语改口:运送衣服、改成衣服、换成衣服、炸弹换衣服。不认「换一下」「换个港口」。
|
||||
_SPOKEN_CARGO = re.compile(
|
||||
r"(?:运送|托运|发运|改成|改为|换成|换为)\s*([^\s,,。;;、请]{1,16})"
|
||||
r"|换(?!成|为|个|一下)([^\s,,。;;、请]{1,16})"
|
||||
)
|
||||
_CARGO_TAIL = re.compile(r"(请报价|报价|一下|吧|了)+$")
|
||||
_CARGO_SKIP = frozenset({"一下", "个", "报价", "价", "询价", "港口", "地方"})
|
||||
|
||||
# 销售侧说法写回内部键。起运地在主账里仍是起运港。
|
||||
_WRITE_KEYS = {
|
||||
"品名": ("品名", "货物品名"),
|
||||
"货物品名": ("品名", "货物品名"),
|
||||
"起运地": ("起运港", "起运地", "始发站"),
|
||||
"起运港": ("起运港", "起运地", "始发站"),
|
||||
"始发站": ("起运港", "起运地", "始发站"),
|
||||
"备注": ("备注",),
|
||||
}
|
||||
|
||||
# 建单前要看的当前字段。没写进这几项、但原话里出现过的,靠 sensitive_hold 记住。
|
||||
_SCAN_KEYS = ("品名", "货物品名", "起运港", "起运地", "始发站", "备注")
|
||||
|
||||
|
||||
def contains_blocked(text: str) -> bool:
|
||||
"""
|
||||
这句话里有没有要拦的词。
|
||||
|
||||
放行说法内部的字不算。例如「玩具枪」放行,「玩具枪支」仍因「枪支」拦下。
|
||||
不做英文、拼音、谐音。纯字符串查找,可在 Worker 线程同步调用。
|
||||
"""
|
||||
raw = text or ""
|
||||
if not raw:
|
||||
return False
|
||||
allow_spans: list[tuple[int, int]] = []
|
||||
for phrase in _ALLOW:
|
||||
start = 0
|
||||
while True:
|
||||
found = raw.find(phrase, start)
|
||||
if found < 0:
|
||||
break
|
||||
allow_spans.append((found, found + len(phrase)))
|
||||
start = found + len(phrase)
|
||||
|
||||
def _inside(begin: int, end: int) -> bool:
|
||||
return any(left <= begin and end <= right for left, right in allow_spans)
|
||||
|
||||
for word in _BLOCK:
|
||||
start = 0
|
||||
while True:
|
||||
found = raw.find(word, start)
|
||||
if found < 0:
|
||||
break
|
||||
if not _inside(found, found + len(word)):
|
||||
return True
|
||||
start = found + len(word)
|
||||
return False
|
||||
|
||||
|
||||
def spoken_cargo(text: str) -> str:
|
||||
"""
|
||||
从这一句里取出销售刚说的品名。
|
||||
|
||||
「运送衣服」「改成衣服」「换成衣服」「炸弹换衣服」都算。
|
||||
取最后一个说法,所以「炸弹换衣服」得到衣服。
|
||||
说出来的还是违禁词,或者只是「换一下」,就当没改口。
|
||||
"""
|
||||
found = ""
|
||||
for match in _SPOKEN_CARGO.finditer(text or ""):
|
||||
raw = match.group(1) or match.group(2) or ""
|
||||
value = _CARGO_TAIL.sub("", raw).strip(",,。;;、 ")
|
||||
if not value or value in _CARGO_SKIP or contains_blocked(value):
|
||||
continue
|
||||
found = value
|
||||
return found
|
||||
|
||||
|
||||
def apply_field_edits(text: str, facts: dict[str, Any]) -> dict[str, str]:
|
||||
"""
|
||||
把当前这句话里的「品名改为…」「起运地改为…」「备注改为…」写进字段。
|
||||
|
||||
只改这一句写明的项。返回实际写下的键值,方便多段把品名写回每一段。
|
||||
会改传入的 facts。值末尾的标点去掉,避免「服装,」留在品名里。
|
||||
"""
|
||||
written: dict[str, str] = {}
|
||||
pairs = list(_FIELD_EDIT.findall(text or ""))
|
||||
if not _MULTI_SHEET.search(text or ""):
|
||||
pairs.extend(_LABEL_EDIT.findall(text or ""))
|
||||
for label, raw_value in pairs:
|
||||
value = str(raw_value or "").strip(",,。;;、 ")
|
||||
if not value:
|
||||
continue
|
||||
for key in _WRITE_KEYS.get(label, (label,)):
|
||||
facts[key] = value
|
||||
written[key] = value
|
||||
cargo = spoken_cargo(text)
|
||||
if cargo:
|
||||
for key in ("品名", "货物品名"):
|
||||
facts[key] = cargo
|
||||
written[key] = cargo
|
||||
return written
|
||||
|
||||
|
||||
def is_cargo_rename(text: str) -> bool:
|
||||
"""当前句有没有把品名重新说成一个新值。没说品名的补重量不算改口。"""
|
||||
if spoken_cargo(text):
|
||||
return True
|
||||
body = text or ""
|
||||
labels = [label for label, _value in _FIELD_EDIT.findall(body)]
|
||||
if not _MULTI_SHEET.search(body):
|
||||
labels.extend(label for label, _value in _LABEL_EDIT.findall(body))
|
||||
return any(label in {"品名", "货物品名"} for label in labels)
|
||||
|
||||
|
||||
def judge(*, text: str, facts: dict[str, Any], hold: str) -> tuple[bool, str]:
|
||||
"""
|
||||
决定这一轮私聊能不能继续往建单走。
|
||||
|
||||
返回 (是否拦截, 新的未改口原话, 本句写下的字段)。
|
||||
当前句仍只是在说违禁词:拦住,并把这句记下来。
|
||||
当前句已经把品名说成普通货(运送衣服、改成衣服、换成衣服):前面的违禁原话作废。
|
||||
同一句里先提到旧词再改口,按改口后的品名放行。
|
||||
只补重量、件数:旧原话继续有效。
|
||||
品名改干净之后,起运地或备注里还留着违禁词,仍然拦截。
|
||||
会就地更新 facts 里本句写明的品名、起运地、备注。
|
||||
第三个返回值是本句写下的键,多段用来写回每一段,避免把别的字段盖掉。
|
||||
"""
|
||||
edits = apply_field_edits(text, facts)
|
||||
replaced = bool(spoken_cargo(text)) or is_cargo_rename(text)
|
||||
message_hit = contains_blocked(text)
|
||||
if replaced and not contains_blocked(str(facts.get("品名") or "")):
|
||||
# 已经说出新的普通货品名。旧词留在这句里只是在改口,不再记住。
|
||||
new_hold = ""
|
||||
message_hit = False
|
||||
elif message_hit:
|
||||
new_hold = (text or "").strip() or (hold or "")
|
||||
else:
|
||||
new_hold = hold or ""
|
||||
field_text = "\n".join(str((facts or {}).get(key) or "") for key in _SCAN_KEYS)
|
||||
blocked = message_hit or contains_blocked(new_hold) or contains_blocked(field_text)
|
||||
return blocked, new_hold, edits
|
||||
@@ -954,7 +954,19 @@ def _resume_cabin_step(
|
||||
facts={"__air_lock_id": exec_no, "__air_lock_option": option_no, "__pending_air_lock": ""},
|
||||
sender_id=sender,
|
||||
)
|
||||
reply(copy.air_lock_ok(work_order_no=no, operator="同事", option_no=option_no, exec_no=exec_no, quote_no=""))
|
||||
reply(
|
||||
copy.air_lock_ok(
|
||||
work_order_no=no,
|
||||
operator="同事",
|
||||
option_no=option_no,
|
||||
exec_no=exec_no,
|
||||
quote_no="",
|
||||
origin=str(extra.get("origin") or ""),
|
||||
destination=str(extra.get("destination") or ""),
|
||||
airline=str(extra.get("airline") or ""),
|
||||
flight_date=str(extra.get("flight_date") or ""),
|
||||
)
|
||||
)
|
||||
else:
|
||||
reply(copy.air_release_ok(work_order_no=no, operator="同事", option_no="", exec_no=str((out or {}).get("releaseId") or lock_id), quote_no=""))
|
||||
return {"ok": True, "step": cabin_step}
|
||||
|
||||
@@ -136,6 +136,18 @@ def _iata_code(raw: str, fallback: str = "") -> str:
|
||||
return (mapped or "").upper()
|
||||
|
||||
|
||||
def airport_code_for_display(raw: str, fallback: str = "") -> str:
|
||||
"""
|
||||
锁舱成功回执上的起运地/目的地。
|
||||
|
||||
能收成三字码就用三字码(NKG、KUL)。对不上就留原文,不编城市。
|
||||
"""
|
||||
code = _iata_code(raw, fallback)
|
||||
if code:
|
||||
return code
|
||||
return (raw or fallback or "").strip()
|
||||
|
||||
|
||||
def route_codes(
|
||||
option: dict[str, Any] | None,
|
||||
facts: dict[str, str] | None = None,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""
|
||||
陆运选项规则:运输类型、线路类别、运输分类。
|
||||
陆运选项规则:运输类型、线路类别、运输分类,以及和它们同一张清单里的货物品名。
|
||||
|
||||
本文件职责:从销售原话或复制填空清单收取标准项、校验是否在名单内、生成补问清单、判断「确定查价」意图。
|
||||
三项各自必填,组合不拦截查价。拉群必须对上填写样例。运输分类句里的「中港/东南亚」不得冒充类型或线路。
|
||||
本文件职责:从销售原话或复制填空清单收取标准项、校验三项是否在名单内、生成补问清单、判断「确定查价」意图。
|
||||
三项各自必填,组合不拦截查价。货物品名六个运输类型都要,和三项同一张复制清单里填;没填完不进始发站补问。
|
||||
已填项回显销售刚写的数字或文字,并附上完整选项,方便对着编号填线路类别。
|
||||
拉群必须对上填写样例。运输分类句里的「中港/东南亚」不得冒充类型或线路。
|
||||
禁止:改六态;发明枚举;把选项当货物字段;再要单独的中港装载方式。
|
||||
线程:纯函数,无共享可变状态,可在 Worker 槽调用。
|
||||
"""
|
||||
@@ -243,20 +245,26 @@ def _decode_option_token(key: str, raw: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_land_option_labels(text: str) -> dict[str, str]:
|
||||
"""复制清单里「字段(必填):值」只取冒号同行,空着的不当已填。"""
|
||||
def _parse_land_option_labels(text: str) -> tuple[dict[str, str], dict[str, str]]:
|
||||
"""
|
||||
复制清单里「字段(必填):值」只取冒号同行,空着的不当已填。
|
||||
|
||||
返回标准项,以及销售冒号后原文,供下一张清单原样回显。
|
||||
"""
|
||||
found: dict[str, str] = {}
|
||||
shown: dict[str, str] = {}
|
||||
inferred_type = ""
|
||||
for match in _PASTE_LABEL.finditer(text or ""):
|
||||
key = match.group(1)
|
||||
token = (match.group(2) or "").strip()
|
||||
if not token:
|
||||
if not token or _CARGO_PLACEHOLDER.match(token):
|
||||
continue
|
||||
if key == "线路类别":
|
||||
hit = ""
|
||||
for code, typ, route in ROUTE_CODES:
|
||||
if token == code or token.startswith(code):
|
||||
found["线路类别"] = route
|
||||
shown["线路类别"] = token
|
||||
inferred_type = typ
|
||||
hit = code
|
||||
break
|
||||
@@ -265,9 +273,12 @@ def _parse_land_option_labels(text: str) -> dict[str, str]:
|
||||
decoded = _decode_option_token(key, token)
|
||||
if decoded:
|
||||
found[key] = decoded
|
||||
shown[key] = token
|
||||
if inferred_type and not found.get("运输类型"):
|
||||
# 编号带出的类型不是销售本行写下的字。已有回显时不要改成标准名称。
|
||||
found["运输类型"] = inferred_type
|
||||
return found
|
||||
shown.setdefault("运输类型", "")
|
||||
return found, shown
|
||||
|
||||
|
||||
def _strip_land_option_prompt(text: str) -> str:
|
||||
@@ -304,7 +315,9 @@ def harvest_land_options(text: str, current: dict[str, str] | None = None) -> di
|
||||
未填的补问清单本身不能当成已选。
|
||||
先收运输分类并把命中句从原文挖掉,再收类型/线路,避免分类里的东南亚、中港冒充选项。
|
||||
已有非空项默认保留;本句明确写出另一标准项时才覆盖。
|
||||
已有值对不上标准枚举则丢掉。不改货物字段。返回新 dict,不修改 current。
|
||||
已有值对不上标准枚举则丢掉。不改货物字段。
|
||||
回显键只在本句真正改了该项时更新:复制清单留冒号后原文,口语留标准名称。
|
||||
返回新 dict,不修改 current。
|
||||
"""
|
||||
merged = {
|
||||
k: str(v).strip()
|
||||
@@ -312,15 +325,26 @@ def harvest_land_options(text: str, current: dict[str, str] | None = None) -> di
|
||||
if str(v).strip()
|
||||
}
|
||||
_keep_canonical_options(merged)
|
||||
labeled = _parse_land_option_labels(text or "")
|
||||
labeled, shown = _parse_land_option_labels(text or "")
|
||||
for key in ("运输类型", "线路类别", "运输分类"):
|
||||
if labeled.get(key):
|
||||
merged[key] = labeled[key]
|
||||
if not labeled.get(key):
|
||||
continue
|
||||
previous = str(merged.get(key) or "").strip()
|
||||
merged[key] = labeled[key]
|
||||
token = str(shown.get(key) or "").strip()
|
||||
if token:
|
||||
_remember_show(merged, key, token, labeled[key])
|
||||
elif previous != labeled[key] or not str(merged.get(land_show_key(key)) or "").strip():
|
||||
_remember_show(merged, key, labeled[key], labeled[key])
|
||||
|
||||
blob = _strip_land_option_prompt(text or "")
|
||||
compact = blob.replace(" ", "")
|
||||
if compact in _ROUTE_CODE_MAP:
|
||||
_apply_route_code(merged, compact, fill_type=not bool(merged.get("运输类型")))
|
||||
had_type = bool(merged.get("运输类型"))
|
||||
_apply_route_code(merged, compact, fill_type=not had_type)
|
||||
_remember_show(merged, "线路类别", compact, str(merged.get("线路类别") or ""))
|
||||
if not had_type and merged.get("运输类型"):
|
||||
_remember_show(merged, "运输类型", merged["运输类型"], merged["运输类型"])
|
||||
merged.pop("中港装载方式", None)
|
||||
return merged
|
||||
if compact.isdigit() and len(compact) == 1:
|
||||
@@ -329,8 +353,10 @@ def harvest_land_options(text: str, current: dict[str, str] | None = None) -> di
|
||||
missing_class = not merged.get("运输分类")
|
||||
if missing_type and not missing_class and 1 <= idx <= len(TRANSPORT_TYPES):
|
||||
merged["运输类型"] = TRANSPORT_TYPES[idx - 1]
|
||||
_remember_show(merged, "运输类型", compact, merged["运输类型"])
|
||||
elif missing_class and not missing_type and 1 <= idx <= len(LAND_CLASSES):
|
||||
merged["运输分类"] = LAND_CLASSES[idx - 1]
|
||||
_remember_show(merged, "运输分类", compact, merged["运输分类"])
|
||||
merged.pop("中港装载方式", None)
|
||||
return merged
|
||||
|
||||
@@ -338,16 +364,105 @@ def harvest_land_options(text: str, current: dict[str, str] | None = None) -> di
|
||||
remainder = blob.replace(found_class, " ") if found_class else blob
|
||||
found_type = canonicalize_transport_type(remainder)
|
||||
found_route = canonicalize_route(remainder)
|
||||
if found_type:
|
||||
merged["运输类型"] = found_type
|
||||
if found_route:
|
||||
merged["线路类别"] = found_route
|
||||
if found_class:
|
||||
merged["运输分类"] = found_class
|
||||
_assign_spoken_option(merged, "运输类型", found_type)
|
||||
_assign_spoken_option(merged, "线路类别", found_route)
|
||||
_assign_spoken_option(merged, "运输分类", found_class)
|
||||
merged.pop("中港装载方式", None)
|
||||
return merged
|
||||
|
||||
|
||||
def _assign_spoken_option(merged: dict[str, str], key: str, canonical: str) -> None:
|
||||
"""口语命中才覆盖。标准名没变时保留销售上次写的数字回显。"""
|
||||
if not canonical:
|
||||
return
|
||||
previous = str(merged.get(key) or "").strip()
|
||||
merged[key] = canonical
|
||||
if previous != canonical or not str(merged.get(land_show_key(key)) or "").strip():
|
||||
_remember_show(merged, key, canonical, canonical)
|
||||
|
||||
|
||||
# 回显用:销售写「5」就留 5,写「中港整车」就留这句。只进会话,建单前丢掉。
|
||||
LAND_SHOW_PREFIX = "_land_show_"
|
||||
_CARGO_PLACEHOLDER = re.compile(r"^[((]\s*如\s*[::]")
|
||||
|
||||
|
||||
def land_show_key(field: str) -> str:
|
||||
"""选项回显键。不进 TMS,也不出现在核对卡。"""
|
||||
return LAND_SHOW_PREFIX + field
|
||||
|
||||
|
||||
def drop_land_prompt_echo(facts: dict[str, Any] | None) -> dict[str, str]:
|
||||
"""建单前去掉回显键。返回新 dict,不改入参。"""
|
||||
out: dict[str, str] = {}
|
||||
for key, raw in dict(facts or {}).items():
|
||||
name = str(key)
|
||||
if name.startswith(LAND_SHOW_PREFIX):
|
||||
continue
|
||||
val = str(raw).strip()
|
||||
if val:
|
||||
out[name] = val
|
||||
return out
|
||||
|
||||
|
||||
def blank_paste_option_fields(text: str) -> set[str]:
|
||||
"""
|
||||
复制清单里出现了选项标签、但冒号后没写值的那些项。
|
||||
|
||||
编号菜单和模型不能把「1.普件…」抄成已填。不是复制清单时返回空,口语「急件」仍可收。
|
||||
"""
|
||||
raw = text or ""
|
||||
if "请复制以下内容" not in raw and "Step1" not in raw:
|
||||
return set()
|
||||
seen: set[str] = set()
|
||||
filled: set[str] = set()
|
||||
for match in _PASTE_LABEL.finditer(raw):
|
||||
key = match.group(1)
|
||||
token = (match.group(2) or "").strip()
|
||||
seen.add(key)
|
||||
if token and not _CARGO_PLACEHOLDER.match(token):
|
||||
filled.add(key)
|
||||
return seen - filled
|
||||
|
||||
|
||||
def drop_blank_paste_options(facts: dict[str, str] | None, text: str) -> dict[str, str]:
|
||||
"""
|
||||
复制清单里空着的运输类型、线路类别、运输分类,从本句结果里拿掉。
|
||||
|
||||
模型若按菜单猜了一项,也不留。返回新 dict,不改入参。
|
||||
"""
|
||||
out = {
|
||||
str(k): str(v).strip()
|
||||
for k, v in dict(facts or {}).items()
|
||||
if str(v).strip()
|
||||
}
|
||||
for key in blank_paste_option_fields(text):
|
||||
out.pop(key, None)
|
||||
out.pop(land_show_key(key), None)
|
||||
return out
|
||||
|
||||
|
||||
def land_cargo_name(facts: dict[str, Any] | None) -> str:
|
||||
"""
|
||||
陆运货物品名。六个运输类型都要。
|
||||
|
||||
示例行「(如:衣服)」原样贴回不算已填。没有则空串。
|
||||
"""
|
||||
src = facts or {}
|
||||
name = str(src.get("品名") or src.get("货物品名") or "").strip()
|
||||
if not name or _CARGO_PLACEHOLDER.match(name):
|
||||
return ""
|
||||
return name
|
||||
|
||||
|
||||
def _remember_show(merged: dict[str, str], key: str, shown: str, canonical: str) -> None:
|
||||
"""记下销售刚写的数字或文字。超长句子改回标准名称,避免把整段需求贴进回显。"""
|
||||
token = (shown or "").strip()
|
||||
if not token or "\n" in token or len(token) > 80:
|
||||
token = canonical
|
||||
if token:
|
||||
merged[land_show_key(key)] = token
|
||||
|
||||
|
||||
def _keep_canonical_options(merged: dict[str, str]) -> None:
|
||||
"""只留能对上枚举的选项;模型胡写的「陆运」不能冒充运输类型。旧中港两项作废。"""
|
||||
t = canonicalize_transport_type(merged.get("运输类型") or "")
|
||||
@@ -355,16 +470,19 @@ def _keep_canonical_options(merged: dict[str, str]) -> None:
|
||||
merged["运输类型"] = t
|
||||
else:
|
||||
merged.pop("运输类型", None)
|
||||
merged.pop(land_show_key("运输类型"), None)
|
||||
r = canonicalize_route(merged.get("线路类别") or "")
|
||||
if r:
|
||||
merged["线路类别"] = r
|
||||
else:
|
||||
merged.pop("线路类别", None)
|
||||
merged.pop(land_show_key("线路类别"), None)
|
||||
c = canonicalize_land_class(merged.get("运输分类") or "")
|
||||
if c:
|
||||
merged["运输分类"] = c
|
||||
else:
|
||||
merged.pop("运输分类", None)
|
||||
merged.pop(land_show_key("运输分类"), None)
|
||||
merged.pop("中港装载方式", None)
|
||||
|
||||
|
||||
@@ -412,25 +530,39 @@ _OPTION_PASTE_HEAD = (
|
||||
)
|
||||
|
||||
|
||||
def _type_menu_lines() -> list[str]:
|
||||
return [f"{i}.{name}" for i, name in enumerate(TRANSPORT_TYPES, start=1)]
|
||||
|
||||
|
||||
def _route_menu_lines() -> list[str]:
|
||||
return [f"{code}{route}" for code, _typ, route in ROUTE_CODES]
|
||||
|
||||
|
||||
def _class_menu_lines() -> list[str]:
|
||||
return [f"{i}.{name}" for i, name in enumerate(LAND_CLASSES, start=1)]
|
||||
|
||||
|
||||
def _type_prompt_lines() -> list[str]:
|
||||
lines = ["运输类型(必填):"]
|
||||
for i, name in enumerate(TRANSPORT_TYPES, start=1):
|
||||
lines.append(f"{i}.{name}")
|
||||
return lines
|
||||
return ["运输类型(必填):", *_type_menu_lines()]
|
||||
|
||||
|
||||
def _route_prompt_lines() -> list[str]:
|
||||
lines = ["线路类别(必填):"]
|
||||
for code, _typ, route in ROUTE_CODES:
|
||||
lines.append(f"{code}{route}")
|
||||
return lines
|
||||
return ["线路类别(必填):", *_route_menu_lines()]
|
||||
|
||||
|
||||
def _class_prompt_lines() -> list[str]:
|
||||
lines = ["运输分类(必填):"]
|
||||
for i, name in enumerate(LAND_CLASSES, start=1):
|
||||
lines.append(f"{i}.{name}")
|
||||
return lines
|
||||
return ["运输分类(必填):", *_class_menu_lines()]
|
||||
|
||||
|
||||
def _cargo_prompt_lines() -> list[str]:
|
||||
"""货物品名没有编号。示例单独一行,原样贴回不算已填。"""
|
||||
return ["货物品名(必填):", "(如:衣服)"]
|
||||
|
||||
|
||||
def _shown_option(current: dict[str, str], key: str, canonical: str) -> str:
|
||||
"""回显销售上次写的数字或文字;没有则用标准名称。"""
|
||||
raw = str(current.get(land_show_key(key)) or "").strip()
|
||||
return raw or canonical
|
||||
|
||||
|
||||
# 编号清单已覆盖类型+线路,补问不再发填写样例图。验收脚本认这个开关。
|
||||
@@ -473,27 +605,51 @@ def option_prompt(*, current: dict[str, str]) -> str:
|
||||
"""
|
||||
按已识别项生成复制填空清单。
|
||||
|
||||
缺哪项列哪项。销售把数字或文字填在字段冒号后面。
|
||||
运输类型、线路类别、运输分类、货物品名同一张清单。缺的留在复制框里。
|
||||
已经填过的排在 Step1 上面:保留销售写的数字或文字,并附上完整选项。
|
||||
货物品名没有选项,填了只回显一行。四个都齐了返回空串。
|
||||
填写样例图不再另发。不修改 current。
|
||||
"""
|
||||
src = {k: str(v).strip() for k, v in dict(current or {}).items() if str(v).strip()}
|
||||
t = canonicalize_transport_type(src.get("运输类型") or "")
|
||||
r = canonicalize_route(src.get("线路类别") or "")
|
||||
c = canonicalize_land_class(src.get("运输分类") or "")
|
||||
need_type = t not in TRANSPORT_TYPES
|
||||
need_route = r not in ROUTES
|
||||
need_class = c not in LAND_CLASSES
|
||||
if not (need_type or need_route or need_class):
|
||||
cargo = land_cargo_name(src)
|
||||
filled: list[list[str]] = []
|
||||
missing: list[list[str]] = []
|
||||
if t in TRANSPORT_TYPES:
|
||||
filled.append(
|
||||
[f"运输类型(必填):{_shown_option(src, '运输类型', t)}", *_type_menu_lines()]
|
||||
)
|
||||
else:
|
||||
missing.append(_type_prompt_lines())
|
||||
if r in ROUTES:
|
||||
filled.append(
|
||||
[f"线路类别(必填):{_shown_option(src, '线路类别', r)}", *_route_menu_lines()]
|
||||
)
|
||||
else:
|
||||
missing.append(_route_prompt_lines())
|
||||
if c in LAND_CLASSES:
|
||||
filled.append(
|
||||
[f"运输分类(必填):{_shown_option(src, '运输分类', c)}", *_class_menu_lines()]
|
||||
)
|
||||
else:
|
||||
missing.append(_class_prompt_lines())
|
||||
if cargo:
|
||||
filled.append([f"货物品名(必填):{cargo}"])
|
||||
else:
|
||||
missing.append(_cargo_prompt_lines())
|
||||
if not missing:
|
||||
return ""
|
||||
chunks: list[list[str]] = []
|
||||
if need_type:
|
||||
chunks.append(_type_prompt_lines())
|
||||
if need_route:
|
||||
chunks.append(_route_prompt_lines())
|
||||
if need_class:
|
||||
chunks.append(_class_prompt_lines())
|
||||
body: list[str] = list(_OPTION_PASTE_HEAD)
|
||||
for i, block in enumerate(chunks):
|
||||
body: list[str] = []
|
||||
for i, block in enumerate(filled):
|
||||
if i:
|
||||
body.append("")
|
||||
body.extend(block)
|
||||
if filled:
|
||||
body.append("")
|
||||
body.extend(_OPTION_PASTE_HEAD)
|
||||
for i, block in enumerate(missing):
|
||||
if i:
|
||||
body.append("")
|
||||
body.extend(block)
|
||||
|
||||
@@ -341,6 +341,59 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
self.assertIn("报价有效期:2026-09-30", tms_block)
|
||||
self.assertIn("请航线人员回复“确认报价”确定采用TMS报价。", text)
|
||||
self.assertIn("亦可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。", text)
|
||||
self.assertNotIn("询价字段:", text)
|
||||
self.assertIn("备注:-", text)
|
||||
|
||||
def test_air_group_brief_with_tms_uses_cargo_and_price_shape(self) -> None:
|
||||
"""有 TMS 报价时,群摘要换成货物信息和三种费用写法。普货含电含磁仍是「否」。"""
|
||||
tms = {
|
||||
"source": "TMS",
|
||||
"total": "87150.0",
|
||||
"currency": "CNY",
|
||||
"valid_until": "2026-10-31",
|
||||
"remark": "其他杂费如有实报实销",
|
||||
"airlineCode": "TH",
|
||||
"chargeableWeightKg": "8300",
|
||||
"cargoType": "1:212",
|
||||
"fee_lines": [
|
||||
{"name": "空运费", "currency": "CNY", "unit": "KG", "unitPrice": 11, "amount": "91300"},
|
||||
{"name": "文件费", "currency": "CNY", "unit": "票", "amount": "300"},
|
||||
],
|
||||
}
|
||||
text = copy.air_group_brief(
|
||||
work_order_no="WO202610090040",
|
||||
facts={
|
||||
"起运地": "NKG",
|
||||
"目的地": "KUL",
|
||||
"品名": "普货",
|
||||
"件数": "800箱",
|
||||
"毛重": "8300KG",
|
||||
"体积": "39CBM",
|
||||
"包装类型": "散货",
|
||||
"报价日期": "2026-10-09",
|
||||
},
|
||||
quote=tms,
|
||||
collab_facts={},
|
||||
has_price=True,
|
||||
tms_quote=tms,
|
||||
sales_name="吴季林",
|
||||
airline_names=["高芸"],
|
||||
)
|
||||
self.assertTrue(text.startswith("@吴季林 @高芸"))
|
||||
self.assertNotIn("询价字段:", text)
|
||||
self.assertIn("航司:TH", text)
|
||||
self.assertIn("计费重:8300KG", text)
|
||||
self.assertIn("货型:1:212", text)
|
||||
self.assertIn("是否含电:否", text)
|
||||
self.assertIn("是否含磁:否", text)
|
||||
tms_block = text.split("TMS报价:", 1)[1]
|
||||
self.assertIn("空运费:单价CNY11/KG", tms_block)
|
||||
self.assertIn("文件费:CNY300/票", tms_block)
|
||||
self.assertIn("备注:其他杂费如有实报实销", tms_block)
|
||||
self.assertIn("费用合计:CNY 87150.0", tms_block)
|
||||
self.assertIn("报价有效期:2026-10-31", tms_block)
|
||||
self.assertIn("请航线人员回复“确认报价”确定采用TMS报价。", text)
|
||||
self.assertLess(tms_block.find("报价有效期:"), tms_block.find("请航线人员"))
|
||||
self.assertIn("附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统", text)
|
||||
self.assertNotIn("请产品人员回复", text)
|
||||
|
||||
@@ -1111,7 +1164,7 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
injected_intent="air_pick_option",
|
||||
)
|
||||
self.assertEqual(phase, "lock_ok")
|
||||
self.assertTrue(any("执行成功" in t for t in self._texts()))
|
||||
self.assertTrue(any("TMS空运锁舱成功" in t for t in self._texts()))
|
||||
|
||||
def test_tms_quote_with_selected_cabin_locks_without_asking_params(self) -> None:
|
||||
"""TMS 已有 selected_air_option:直接锁,不追问舱位/航司/航班(WO014)。"""
|
||||
@@ -1162,6 +1215,40 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
self.assertEqual(calls[0].get("flight_no"), "JG2719")
|
||||
self.assertFalse(any("锁舱参数不完整" in t for t in self._texts()))
|
||||
|
||||
def test_lock_ok_reply_includes_route(self) -> None:
|
||||
"""锁舱成功回执带出本次舱位的起运地、目的地、航司、航班日期。"""
|
||||
wo = self._create_air(
|
||||
quote={
|
||||
"total": "1800",
|
||||
"source": "TMS",
|
||||
"source_label": "航线确认TMS",
|
||||
"quoteId": "AIR-2099695579169554433-2099695579182137346",
|
||||
"selected_air_option": {
|
||||
"optionNo": "AIR-OPT01",
|
||||
"airlineCode": "TH",
|
||||
"flightNo": "TH123",
|
||||
"originAirportCode": "NKG",
|
||||
"destinationAirportCode": "KUL",
|
||||
"flightDate": "2026-10-15",
|
||||
},
|
||||
},
|
||||
status="已成交",
|
||||
)
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_route", mid="route1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.assertEqual(phase, "lock_ok")
|
||||
text = self._texts()[-1]
|
||||
self.assertIn("TMS空运锁舱成功。", text)
|
||||
self.assertIn("舱位选项:AIR-OPT01", text)
|
||||
self.assertIn("起运地:NKG", text)
|
||||
self.assertIn("目的地:KUL", text)
|
||||
self.assertIn("航司:TH", text)
|
||||
self.assertIn("航班日期:2026-10-15", text)
|
||||
|
||||
def test_lock_missing_params_asks_and_does_not_call_tms(self) -> None:
|
||||
"""无舱位明细时缺三项才请补,不锁舱、不报 TMS 失败、不通知 IT。"""
|
||||
wo = self._create_air(
|
||||
@@ -1318,7 +1405,7 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.assertEqual(phase, "lock_ok")
|
||||
self.assertTrue(any("执行成功" in t for t in self._texts()))
|
||||
self.assertTrue(any("TMS空运锁舱成功" in t for t in self._texts()))
|
||||
self.assertFalse(any("请确认成交状态" in t for t in self._texts()))
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}取消舱位", chat="wr_air_1", mid="l5"),
|
||||
|
||||
@@ -382,17 +382,89 @@ class AirRouteOptionsTests(unittest.TestCase):
|
||||
text,
|
||||
"\n".join(
|
||||
[
|
||||
"TMS标准报价",
|
||||
"WO202609220013价格明细",
|
||||
"NKG-KUL",
|
||||
"工单号:WO202609220013",
|
||||
"运输方式:空运",
|
||||
"起运地:-",
|
||||
"目的地:-",
|
||||
"航司:-",
|
||||
"品名:-",
|
||||
"件数:-",
|
||||
"重量(KG):-",
|
||||
"体积(CBM):-",
|
||||
"包装类型:-",
|
||||
"计费重:-",
|
||||
"货型:-",
|
||||
"报价日期:-",
|
||||
"",
|
||||
"价格方案如下:",
|
||||
"增点费:CNY 100.0",
|
||||
"拆板费:USD 100.0",
|
||||
"备注:-",
|
||||
"费用合计:CNY 5001.0",
|
||||
"报价有效期:2026-09-30",
|
||||
]
|
||||
),
|
||||
)
|
||||
self.assertNotIn("时效", text)
|
||||
self.assertNotIn("TMS标准报价", text)
|
||||
self.assertNotIn("NKG-KUL", text)
|
||||
|
||||
def test_air_detail_text_uses_ticket_facts_and_tms_price_shape(self) -> None:
|
||||
"""货物信息用工单,价格方案按单价/按票/只有金额三种写法。"""
|
||||
text = copy.sea_quote_detail_text(
|
||||
work_order_no="WO202610090040",
|
||||
facts={
|
||||
"起运地": "NKG",
|
||||
"目的地": "KUL",
|
||||
"品名": "普货",
|
||||
"件数": "800箱",
|
||||
"毛重": "8300KG",
|
||||
"体积": "39CBM",
|
||||
"包装类型": "散货",
|
||||
"报价日期": "2026-10-09",
|
||||
},
|
||||
quote={
|
||||
"currency": "CNY",
|
||||
"total": "87150.0",
|
||||
"valid_until": "2026-10-31",
|
||||
"remark": "其他杂费如有实报实销,不含续页;续页50/票",
|
||||
"airlineCode": "TH",
|
||||
"chargeableWeightKg": "8300",
|
||||
"cargoType": "1:212",
|
||||
"fee_lines": [
|
||||
{
|
||||
"name": "空运费",
|
||||
"currency": "CNY",
|
||||
"unit": "KG",
|
||||
"unitPrice": 11,
|
||||
"amount": "91300",
|
||||
},
|
||||
{"name": "文件费", "currency": "CNY", "unit": "票", "amount": "300"},
|
||||
{"name": "报关费", "currency": "CNY", "unit": "/票", "unitPrice": "250"},
|
||||
{"name": "增点费", "currency": "CNY", "amount": "100.0"},
|
||||
],
|
||||
},
|
||||
business_line="AIR",
|
||||
)
|
||||
self.assertIn("工单号:WO202610090040", text)
|
||||
self.assertIn("起运地:NKG", text)
|
||||
self.assertIn("目的地:KUL", text)
|
||||
self.assertIn("航司:TH", text)
|
||||
self.assertIn("件数:800箱", text)
|
||||
self.assertIn("重量(KG):8300KG", text)
|
||||
self.assertIn("体积(CBM):39CBM", text)
|
||||
self.assertIn("计费重:8300KG", text)
|
||||
self.assertIn("货型:1:212", text)
|
||||
self.assertIn("报价日期:2026-10-09", text)
|
||||
self.assertIn("价格方案如下:", text)
|
||||
self.assertIn("空运费:单价CNY11/KG", text)
|
||||
self.assertIn("文件费:CNY300/票", text)
|
||||
self.assertIn("报关费:CNY250/票", text)
|
||||
self.assertIn("增点费:CNY 100.0", text)
|
||||
self.assertIn("备注:其他杂费如有实报实销,不含续页;续页50/票", text)
|
||||
self.assertIn("费用合计:CNY 87150.0", text)
|
||||
self.assertIn("报价有效期:2026-10-31", text)
|
||||
self.assertNotIn("询价字段", text)
|
||||
|
||||
def test_h5_page_lists_route_and_fees(self) -> None:
|
||||
from agent.channel.h5.air_route_page import render_air_route_page
|
||||
|
||||
@@ -934,10 +934,12 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
quote=long_quote,
|
||||
business_line="AIR",
|
||||
)
|
||||
self.assertIn("TMS标准报价", text)
|
||||
self.assertIn("WO202609220005价格明细", text)
|
||||
self.assertIn("价格方案如下:", text)
|
||||
self.assertNotIn("TMS标准报价", text)
|
||||
self.assertNotIn("价格明细", text)
|
||||
self.assertIn("操作费:CNY 1220.00", text)
|
||||
self.assertIn("空运费:USD 1250.00", text)
|
||||
self.assertIn("备注:-", text)
|
||||
self.assertIn("费用合计:CNY 142998.53", text)
|
||||
self.assertNotIn("时效", text)
|
||||
self.assertIn("报价有效期:2026-09-30", text)
|
||||
@@ -987,7 +989,8 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
quote=quote,
|
||||
business_line="AIR",
|
||||
)
|
||||
self.assertIn("ZUH-CAN-CRK", detail)
|
||||
self.assertNotIn("ZUH-CAN-CRK", detail)
|
||||
self.assertIn("价格方案如下:", detail)
|
||||
self.assertIn("运输费:CNY 11.0", detail)
|
||||
self.assertIn("费用合计:CNY 183370", detail)
|
||||
rows = {
|
||||
|
||||
@@ -124,6 +124,9 @@ class LandCopyTest(unittest.TestCase):
|
||||
self.assertIn(copy.CLARIFY_PASTE_HINT, text)
|
||||
self.assertIn("1.体积(CBM)(必填):\n(如:100CBM)", text)
|
||||
self.assertIn("2.通关口岸(非必填):\n(如:友谊关)", text)
|
||||
self.assertIn("货物品名(必填):衣服", text.split(copy.CLARIFY_PASTE_HINT, 1)[0])
|
||||
pending = text.split(copy.CLARIFY_PASTE_HINT, 1)[1]
|
||||
self.assertNotIn("货物品名", pending)
|
||||
self.assertNotIn("体积(必填):(如:", text)
|
||||
self.assertNotIn(copy.LAND_CONFIRM_TAIL, text)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ if ROOT not in sys.path:
|
||||
|
||||
from agent.schema.land_options import (
|
||||
harvest_land_options,
|
||||
land_cargo_name,
|
||||
looks_like_confirm,
|
||||
option_prompt,
|
||||
option_prompt_needs_sample,
|
||||
@@ -118,6 +119,12 @@ class LandOptionsTest(unittest.TestCase):
|
||||
self.assertIn("运输分类(必填):\n1.普件(常用线路/常规车型)", text)
|
||||
self.assertIn("2.普件(东南亚、中港常用线路/常规车型)", text)
|
||||
self.assertIn("5.急件", text)
|
||||
self.assertIn("货物品名(必填):\n(如:衣服)", text)
|
||||
self.assertTrue(text.startswith("Step1: 请复制以下内容,粘贴到输入框;"))
|
||||
self.assertLess(
|
||||
text.index("5.急件"),
|
||||
text.index("货物品名(必填)"),
|
||||
)
|
||||
self.assertNotIn("请一次选择:运输类型", text)
|
||||
self.assertNotIn("可选择回复", text)
|
||||
self.assertNotIn("填写样例见图", text)
|
||||
@@ -134,16 +141,26 @@ class LandOptionsTest(unittest.TestCase):
|
||||
self.assertNotIn("请补充陆运类型:国内运输拼车", text)
|
||||
|
||||
def test_sample_table_when_route_missing(self):
|
||||
text = option_prompt(current={"运输类型": "国内运输拼车"})
|
||||
self.assertIn("Step1: 请复制以下内容,粘贴到输入框;", text)
|
||||
self.assertIn("---请复制以下内容---\n线路类别(必填):", text)
|
||||
self.assertIn("线路类别(必填):\n1.1国内长途/零担", text)
|
||||
self.assertIn("运输分类(必填):\n1.普件", text)
|
||||
text = option_prompt(
|
||||
current={
|
||||
"运输类型": "国内运输拼车",
|
||||
"_land_show_运输类型": "1",
|
||||
"品名": "衣服",
|
||||
}
|
||||
)
|
||||
head, _, tail = text.partition("---请复制以下内容---")
|
||||
self.assertIn("运输类型(必填):1\n1.国内运输拼车", head)
|
||||
self.assertIn("6.中港零担/集拼", head)
|
||||
self.assertIn("货物品名(必填):衣服", head)
|
||||
self.assertIn("Step1: 请复制以下内容,粘贴到输入框;", head)
|
||||
self.assertIn("线路类别(必填):\n1.1国内长途/零担", tail)
|
||||
self.assertIn("运输分类(必填):\n1.普件", tail)
|
||||
self.assertNotIn("运输类型(必填)", tail)
|
||||
self.assertNotIn("货物品名", tail)
|
||||
self.assertFalse(option_prompt_needs_sample(current={"运输类型": "国内运输拼车"}))
|
||||
self.assertFalse(option_prompt_needs_sample(current={}))
|
||||
self.assertNotIn("国内运输拼车 + 国内长途/零担", text)
|
||||
self.assertNotIn("请一次选择:运输类型", text)
|
||||
self.assertNotIn("运输类型(必填)", text)
|
||||
|
||||
def test_sample_pairs_go_to_image_not_text(self):
|
||||
text = option_prompt(current={})
|
||||
@@ -218,6 +235,46 @@ class LandOptionsTest(unittest.TestCase):
|
||||
self.assertFalse(got.get("线路类别"))
|
||||
self.assertFalse(got.get("运输分类"))
|
||||
|
||||
def test_paste_number_is_echoed_and_kept(self):
|
||||
"""冒号后写 5,下一张清单回显 5,不改成标准名称。"""
|
||||
got = harvest_land_options("运输类型(必填):5")
|
||||
self.assertEqual(got.get("运输类型"), "中港整车")
|
||||
self.assertEqual(got.get("_land_show_运输类型"), "5")
|
||||
nxt = harvest_land_options("线路类别(必填):5.1", current=got)
|
||||
self.assertEqual(nxt.get("_land_show_运输类型"), "5")
|
||||
self.assertEqual(nxt.get("_land_show_线路类别"), "5.1")
|
||||
self.assertEqual(nxt.get("线路类别"), "中港/中亚/中欧")
|
||||
text = option_prompt(current={**nxt, "品名": ""})
|
||||
head, _, tail = text.partition("---请复制以下内容---")
|
||||
self.assertIn("运输类型(必填):5\n1.国内运输拼车", head)
|
||||
self.assertIn("线路类别(必填):5.1\n1.1国内长途/零担", head)
|
||||
self.assertIn("货物品名(必填):\n(如:衣服)", tail)
|
||||
self.assertNotIn("运输类型(必填)", tail)
|
||||
self.assertNotIn("线路类别(必填)", tail)
|
||||
|
||||
def test_prose_type_echoes_words(self):
|
||||
got = harvest_land_options("中港整车,衣服,广州到香港")
|
||||
self.assertEqual(got.get("运输类型"), "中港整车")
|
||||
self.assertEqual(got.get("_land_show_运输类型"), "中港整车")
|
||||
|
||||
def test_placeholder_cargo_name_is_still_empty(self):
|
||||
self.assertEqual(land_cargo_name({"品名": "衣服"}), "衣服")
|
||||
self.assertEqual(land_cargo_name({"货物品名": "零件"}), "零件")
|
||||
self.assertEqual(land_cargo_name({"品名": "(如:衣服)"}), "")
|
||||
text = option_prompt(
|
||||
current={
|
||||
"运输类型": "中港整车",
|
||||
"线路类别": "中港/中亚/中欧",
|
||||
"运输分类": "急件",
|
||||
"品名": "(如:衣服)",
|
||||
}
|
||||
)
|
||||
head, _, tail = text.partition("---请复制以下内容---")
|
||||
self.assertIn("运输类型(必填):中港整车", head)
|
||||
self.assertNotIn("货物品名(必填):", head)
|
||||
self.assertIn("货物品名(必填):\n(如:衣服)", tail)
|
||||
self.assertNotIn("运输类型(必填)", tail)
|
||||
|
||||
def test_confirm_intent(self):
|
||||
self.assertTrue(looks_like_confirm("确认"))
|
||||
self.assertTrue(looks_like_confirm("确定"))
|
||||
|
||||
@@ -28,6 +28,7 @@ from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.air_text_flow import FlowSession
|
||||
from agent.policy.land_text_flow import LandTextInquiryFlow
|
||||
from agent.schema.land_options import option_prompt
|
||||
|
||||
|
||||
class CountingLedger(MemoryLedger):
|
||||
@@ -75,6 +76,9 @@ class LandTextInquiryFrontTests(unittest.TestCase):
|
||||
self.assertIn("中港整车", text)
|
||||
self.assertIn("Step1: 请复制以下内容,粘贴到输入框;", text)
|
||||
self.assertIn("---请复制以下内容---", text)
|
||||
head, _, tail = text.partition("---请复制以下内容---")
|
||||
self.assertIn("货物品名(必填):衣服", head)
|
||||
self.assertNotIn("货物品名", tail)
|
||||
self.assertNotIn("请补充陆运类型:", text)
|
||||
self.assertFalse(any(x.get("msgtype") == "image" for x in self.extras))
|
||||
|
||||
@@ -232,6 +236,80 @@ class LandTextInquiryFrontTests(unittest.TestCase):
|
||||
self.assertEqual(phase, "wait_confirm")
|
||||
self.assertEqual(self.flow.session_of("u1").facts.get("通关口岸"), "皇岗")
|
||||
|
||||
def test_missing_cargo_name_stays_on_option_sheet(self) -> None:
|
||||
"""三项齐了但没品名,仍停在第一张复制清单,不进始发站补问。"""
|
||||
phase = self.flow.on_text(
|
||||
sender_id="u1",
|
||||
text="国内运输拼车 国内长途/零担 急件",
|
||||
reply=self._reply,
|
||||
injected_facts={
|
||||
"起运港": "广州",
|
||||
"目的港": "深圳",
|
||||
"体积": "100CBM",
|
||||
"毛重": "50kg",
|
||||
},
|
||||
injected_mode="LAND",
|
||||
)
|
||||
self.assertEqual(phase, "need_land_options")
|
||||
text = self._joined()
|
||||
head, _, tail = text.partition("---请复制以下内容---")
|
||||
self.assertIn("运输类型(必填):国内运输拼车", head)
|
||||
self.assertIn("货物品名(必填):\n(如:衣服)", tail)
|
||||
self.assertNotIn("始发站", text)
|
||||
self.assertNotIn(copy.LAND_CONFIRM_TAIL, text)
|
||||
|
||||
def test_typed_number_is_shown_on_next_sheet(self) -> None:
|
||||
self.flow.on_text(
|
||||
sender_id="u1",
|
||||
text="陆运",
|
||||
reply=self._reply,
|
||||
injected_facts={"起运港": "广州", "目的港": "深圳"},
|
||||
injected_mode="LAND",
|
||||
)
|
||||
self.replies.clear()
|
||||
phase = self.flow.on_text(
|
||||
sender_id="u1",
|
||||
text="运输类型(必填):5",
|
||||
reply=self._reply,
|
||||
injected_mode="LAND",
|
||||
)
|
||||
self.assertEqual(phase, "need_land_options")
|
||||
text = self._joined()
|
||||
head, _, tail = text.partition("---请复制以下内容---")
|
||||
self.assertIn("运输类型(必填):5\n1.国内运输拼车", head)
|
||||
self.assertNotIn("运输类型(必填)", tail)
|
||||
self.assertIn("货物品名(必填):", tail)
|
||||
self.assertEqual(self.flow.session_of("u1").facts.get("运输类型"), "中港整车")
|
||||
|
||||
def test_blank_class_on_paste_is_not_taken_from_menu(self) -> None:
|
||||
"""复制清单里运输分类冒号是空的,不能把菜单第一项或模型抄来的分类当成已填。"""
|
||||
sheet = option_prompt(current={})
|
||||
sheet = sheet.replace("运输类型(必填):\n", "运输类型(必填):1\n", 1)
|
||||
sheet = sheet.replace("货物品名(必填):\n", "货物品名(必填):茶叶\n", 1)
|
||||
phase = self.flow.on_text(
|
||||
sender_id="u1",
|
||||
text=sheet,
|
||||
reply=self._reply,
|
||||
injected_facts={
|
||||
"运输类型": "国内运输拼车",
|
||||
"运输分类": "普件(常用线路/常规车型)",
|
||||
"品名": "茶叶",
|
||||
},
|
||||
injected_mode="LAND",
|
||||
)
|
||||
self.assertEqual(phase, "need_land_options")
|
||||
facts = self.flow.session_of("u1").facts
|
||||
self.assertEqual(facts.get("运输类型"), "国内运输拼车")
|
||||
self.assertEqual(facts.get("品名"), "茶叶")
|
||||
self.assertFalse(facts.get("运输分类"))
|
||||
self.assertFalse(facts.get("线路类别"))
|
||||
head, _, tail = self._joined().partition("---请复制以下内容---")
|
||||
self.assertIn("运输类型(必填):1", head)
|
||||
self.assertIn("货物品名(必填):茶叶", head)
|
||||
self.assertNotIn("运输分类(必填):普件", head)
|
||||
self.assertIn("运输分类(必填):", tail)
|
||||
self.assertIn("线路类别(必填):", tail)
|
||||
|
||||
def test_missing_volume_no_confirm(self) -> None:
|
||||
phase = self.flow.on_text(
|
||||
sender_id="u1",
|
||||
|
||||
@@ -545,7 +545,8 @@ class FlowTests(unittest.TestCase):
|
||||
self.assertEqual(phase, "clarify")
|
||||
card = self.last_prompt()
|
||||
self.assertNotIn("起运港:(如:上海)", card)
|
||||
self.assertIn("始发站", card)
|
||||
self.assertIn("货物品名(必填)", card)
|
||||
self.assertNotIn("始发站", card)
|
||||
sess = self.flow.session_of("sales")
|
||||
segs = self.flow._segments(sess)
|
||||
sea = next(seg for seg in segs if seg.mode == "SEA")
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
私聊建单前违禁品:词表、改口、不建单。
|
||||
|
||||
不连企微、TMS、主账。群聊不走这条拦截。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
os.environ.setdefault("YTD_ENV", "test")
|
||||
os.environ.setdefault("REDIS_BACKEND", "memory")
|
||||
os.environ.setdefault("CHECKPOINT_BACKEND", "memory")
|
||||
os.environ.setdefault("MESSAGE_STORE_BACKEND", "memory")
|
||||
os.environ.setdefault("LLM_DATA_USAGE_CONFIRMED", "false")
|
||||
os.environ.setdefault("LLM_ALLOW_NETWORK", "false")
|
||||
os.environ.setdefault("LEDGER_BACKEND", "memory")
|
||||
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy.sensitive_cargo import BLOCK_REPLY, contains_blocked, judge
|
||||
from agent.policy.sea_text_flow import SeaTextInquiryFlow, reset_sea_text_flow_for_test
|
||||
|
||||
COMPLETE_SEA = {
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
"品名": "普货",
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
"贸易条款": "FOB",
|
||||
"运输分类": "Port to Port",
|
||||
}
|
||||
|
||||
|
||||
class WordListTests(unittest.TestCase):
|
||||
def test_real_weapons_and_drugs_block(self) -> None:
|
||||
for word in ("炸弹", "枪支", "手枪", "子弹", "海洛因", "火炮"):
|
||||
self.assertTrue(contains_blocked(f"上海到洛杉矶,品名{word}"), word)
|
||||
|
||||
def test_toys_and_fireworks_pass(self) -> None:
|
||||
for word in ("玩具枪", "水枪", "仿真枪", "气枪", "烟花", "礼炮", "鞭炮"):
|
||||
self.assertFalse(contains_blocked(f"品名{word}"), word)
|
||||
|
||||
def test_toy_prefix_does_not_hide_real_gun(self) -> None:
|
||||
self.assertTrue(contains_blocked("玩具枪支"))
|
||||
|
||||
def test_ordinary_dangerous_goods_pass(self) -> None:
|
||||
self.assertFalse(contains_blocked("这票是危险品,含电,锂电池"))
|
||||
|
||||
|
||||
class JudgeTests(unittest.TestCase):
|
||||
def test_stray_sentence_sticks_until_cargo_rename(self) -> None:
|
||||
facts = {"品名": "服装", "起运港": "上海"}
|
||||
blocked, hold, _edits = judge(text="客户提到有炸弹", facts=dict(facts), hold="")
|
||||
self.assertTrue(blocked)
|
||||
self.assertIn("炸弹", hold)
|
||||
still, hold2, _edits = judge(text="重量100KGS", facts=dict(facts), hold=hold)
|
||||
self.assertTrue(still)
|
||||
self.assertEqual(hold2, hold)
|
||||
released, hold3, edits = judge(text="品名改为服装", facts=dict(facts), hold=hold)
|
||||
self.assertFalse(released)
|
||||
self.assertEqual(hold3, "")
|
||||
self.assertEqual(edits.get("品名"), "服装")
|
||||
|
||||
def test_rename_sentence_that_still_says_the_word_stays_blocked(self) -> None:
|
||||
blocked, hold, _edits = judge(text="不是炸弹,品名服装", facts={"品名": "服装"}, hold="")
|
||||
self.assertTrue(blocked)
|
||||
self.assertIn("炸弹", hold)
|
||||
|
||||
def test_origin_and_remark_remain_after_cargo_rename(self) -> None:
|
||||
facts = {"品名": "炸弹", "起运港": "上海", "备注": "内有枪支"}
|
||||
blocked, hold, _edits = judge(text="品名改为服装", facts=facts, hold="品名炸弹")
|
||||
self.assertTrue(blocked)
|
||||
self.assertEqual(hold, "")
|
||||
self.assertEqual(facts["品名"], "服装")
|
||||
self.assertIn("枪支", facts["备注"])
|
||||
|
||||
def test_remark_can_be_rewritten(self) -> None:
|
||||
facts = {"品名": "服装", "备注": "枪支"}
|
||||
blocked, hold, _edits = judge(text="备注改为无", facts=facts, hold="")
|
||||
self.assertFalse(blocked)
|
||||
self.assertEqual(facts["备注"], "无")
|
||||
self.assertEqual(hold, "")
|
||||
|
||||
|
||||
class SeaPrivateFlowTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
reset_sea_text_flow_for_test()
|
||||
self.ledger = MemoryLedger()
|
||||
self.flow = SeaTextInquiryFlow(self.ledger)
|
||||
self.replies: list[str] = []
|
||||
|
||||
def _reply(self, text: str, extra=None) -> None:
|
||||
_ = extra
|
||||
self.replies.append(text)
|
||||
|
||||
def test_private_text_blocks_before_ticket(self) -> None:
|
||||
facts = dict(COMPLETE_SEA)
|
||||
facts["品名"] = "炸弹"
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s-bomb",
|
||||
text="海运 上海到洛杉矶 品名炸弹",
|
||||
reply=self._reply,
|
||||
injected_facts=facts,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "sensitive_block")
|
||||
self.assertEqual(self.replies, [BLOCK_REPLY])
|
||||
self.assertEqual(self.ledger._tickets, {})
|
||||
sess = self.flow.session_of("s-bomb")
|
||||
self.assertFalse((sess.work_order_no or "").strip())
|
||||
|
||||
def test_file_facts_block_even_if_caption_is_plain(self) -> None:
|
||||
facts = dict(COMPLETE_SEA)
|
||||
facts["品名"] = "枪支"
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s-file",
|
||||
text="海运报价单",
|
||||
reply=self._reply,
|
||||
injected_facts=facts,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "sensitive_block")
|
||||
self.assertEqual(self.ledger._tickets, {})
|
||||
|
||||
def test_rename_then_confirm_creates_ticket(self) -> None:
|
||||
facts = dict(COMPLETE_SEA)
|
||||
facts["品名"] = "炸弹"
|
||||
self.flow.on_text(
|
||||
sender_id="s-fix",
|
||||
text="海运 品名炸弹 上海到洛杉矶",
|
||||
reply=self._reply,
|
||||
injected_facts=facts,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.replies.clear()
|
||||
fixed = dict(COMPLETE_SEA)
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s-fix",
|
||||
text="品名改为服装",
|
||||
reply=self._reply,
|
||||
injected_facts=fixed,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertNotEqual(phase, "sensitive_block")
|
||||
self.assertNotIn(BLOCK_REPLY, self.replies)
|
||||
self.replies.clear()
|
||||
created = self.flow.on_text(sender_id="s-fix", text="确定", reply=self._reply)
|
||||
self.assertNotEqual(created, "sensitive_block")
|
||||
self.assertTrue(self.ledger._tickets)
|
||||
joined = "\n".join(self.replies)
|
||||
self.assertNotIn(BLOCK_REPLY, joined)
|
||||
|
||||
def test_oral_restatement_clears_previous_bomb(self) -> None:
|
||||
"""销售不说「品名改为」,直接重说运送衣服,或说改成衣服。"""
|
||||
hold = "10月30日,从广州去深圳,运送炸弹,请报价"
|
||||
facts = {"品名": "炸弹", "起运港": "广州"}
|
||||
for text in (
|
||||
"10月30日,从广州去深圳,运送衣服,请报价",
|
||||
"炸弹错了,给我改成衣服",
|
||||
"炸弹换衣服",
|
||||
):
|
||||
blocked, new_hold, edits = judge(text=text, facts=dict(facts), hold=hold)
|
||||
self.assertFalse(blocked, text)
|
||||
self.assertEqual(new_hold, "", text)
|
||||
self.assertEqual(edits.get("品名"), "衣服", text)
|
||||
|
||||
def test_weight_alone_does_not_clear(self) -> None:
|
||||
facts = dict(COMPLETE_SEA)
|
||||
facts["品名"] = "弹药"
|
||||
self.flow.on_text(
|
||||
sender_id="s-hold",
|
||||
text="海运 品名弹药",
|
||||
reply=self._reply,
|
||||
injected_facts=facts,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.replies.clear()
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s-hold",
|
||||
text="重量 20吨",
|
||||
reply=self._reply,
|
||||
injected_facts=facts,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "sensitive_block")
|
||||
self.assertEqual(self.replies, [BLOCK_REPLY])
|
||||
self.assertEqual(self.ledger._tickets, {})
|
||||
|
||||
def test_toy_gun_can_continue(self) -> None:
|
||||
facts = dict(COMPLETE_SEA)
|
||||
facts["品名"] = "玩具枪"
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s-toy",
|
||||
text="海运 品名玩具枪 上海到洛杉矶",
|
||||
reply=self._reply,
|
||||
injected_facts=facts,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "wait_confirm")
|
||||
self.assertNotIn(BLOCK_REPLY, self.replies)
|
||||
+12
@@ -359,6 +359,18 @@ public class InquiryTmsQueryService {
|
||||
if (StringUtils.isNotBlank(chargeable)) {
|
||||
quote.put("chargeableWeightKg", chargeable);
|
||||
}
|
||||
// 销售侧报价正文要原样带出 TMS 备注和货型。没有就不写,禁止本地编。
|
||||
String remark = firstText(data.get("remark"), data.get("specialRemarks"), data.get("note"));
|
||||
if (StringUtils.isNotBlank(remark)) {
|
||||
quote.put("remark", remark);
|
||||
}
|
||||
String cargoType = firstText(
|
||||
data.get("cargoType"), data.get("goodsType"), data.get("cargoRatio"),
|
||||
data.get("weightVolumeRatio"), data.get("volumeWeightRatio"),
|
||||
data.get("heavyBubbleRatio"), data.get("bubbleRatio"));
|
||||
if (StringUtils.isNotBlank(cargoType)) {
|
||||
quote.put("cargoType", cargoType);
|
||||
}
|
||||
return quote;
|
||||
}
|
||||
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
-- 空运锁舱成功回执补起运地、目的地、航司、航班日期。
|
||||
-- 仅改 inquiry_robot 的机器人回复语;幂等。已发出的历史消息不改。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
UPDATE `inq_bot_reply`
|
||||
SET `reply_text` = 'TMS空运锁舱成功。
|
||||
操作人:{{operator}}
|
||||
舱位选项:{{option_no}}
|
||||
执行编号:{{exec_no}}
|
||||
报价编号:{{quote_no}}
|
||||
起运地:{{origin}}
|
||||
目的地:{{destination}}
|
||||
航司:{{airline}}
|
||||
航班日期:{{flight_date}}',
|
||||
`update_by` = 'system',
|
||||
`update_time` = NOW()
|
||||
WHERE `reply_code` = 'inquiring.air_lock_ok'
|
||||
OR `id` = 'brv10000000000000000000000000027';
|
||||
+6
-2
@@ -41,11 +41,15 @@ VALUES
|
||||
('brv10000000000000000000000000024','inquiring','询价中','inquiring.air_lock_need_wo','空运锁舱/释放未带工单号','锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。',1,24,'admin',NOW()),
|
||||
('brv10000000000000000000000000025','inquiring','询价中','inquiring.air_lock_need_quote','没有报价不能锁舱','工单{{ticket_no}}还没有报价,不能锁舱。',1,25,'admin',NOW()),
|
||||
('brv10000000000000000000000000026','inquiring','询价中','inquiring.air_airline_only_cabin','只有航线能锁舱或释放','只有岗位为「航线·空运」的人能锁舱或释放舱位。',1,26,'admin',NOW()),
|
||||
('brv10000000000000000000000000027','inquiring','询价中','inquiring.air_lock_ok','空运锁舱成功','工单{{ticket_no}} 已通知TMS执行空运锁舱,执行成功。
|
||||
('brv10000000000000000000000000027','inquiring','询价中','inquiring.air_lock_ok','空运锁舱成功','TMS空运锁舱成功。
|
||||
操作人:{{operator}}
|
||||
舱位选项:{{option_no}}
|
||||
执行编号:{{exec_no}}
|
||||
报价编号:{{quote_no}}',1,27,'admin',NOW()),
|
||||
报价编号:{{quote_no}}
|
||||
起运地:{{origin}}
|
||||
目的地:{{destination}}
|
||||
航司:{{airline}}
|
||||
航班日期:{{flight_date}}',1,27,'admin',NOW()),
|
||||
('brv10000000000000000000000000028','inquiring','询价中','inquiring.air_lock_fail','空运锁舱失败','工单{{ticket_no}} 空运锁舱失败。
|
||||
操作人:{{operator}}
|
||||
锁舱失败的原因:{{reason}}
|
||||
|
||||
@@ -212,7 +212,7 @@ export const mockReplyCategories: ReplyCategory[] = [
|
||||
id: "brv10000000000000000000000000027",
|
||||
replyCode: "inquiring.air_lock_ok",
|
||||
trigger: "空运锁舱成功",
|
||||
text: "工单{{ticket_no}} 已通知TMS执行空运锁舱,执行成功。\n操作人:{{operator}}\n舱位选项:{{option_no}}\n执行编号:{{exec_no}}\n报价编号:{{quote_no}}",
|
||||
text: "TMS空运锁舱成功。\n操作人:{{operator}}\n舱位选项:{{option_no}}\n执行编号:{{exec_no}}\n报价编号:{{quote_no}}\n起运地:{{origin}}\n目的地:{{destination}}\n航司:{{airline}}\n航班日期:{{flight_date}}",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -105,6 +105,10 @@ NormalizedInbound
|
||||
|
||||
实现与 `inquiry-required-fields-v1.json` 等价的条件激活(JSON 放在新仓库)。SEA 箱型箱量保持单原始字段 + B 模型投影 `containerItems`。协同点击用不可变询价原话重跑 B,去重补问。
|
||||
|
||||
## 违禁品(建单前)
|
||||
|
||||
企微私聊里,海运、空运、陆运、多段都一样:工单还没建成时,当前这句话、还没改口的原话,以及已经认定的品名、起运地、备注(含表格、图片、文件认出来的字)里出现枪支、弹药、爆炸物、毒品,只回「涉及危险品,无法发起报价。」,不建工单,后台看不到这票,这一轮也不追问缺项。同一条私聊里直接把品名改成普通货后,前面没落进字段的那句不再算;这句里仍写着违禁词,或起运地、备注里还留着,继续拦。只补重量清不掉。水枪、玩具枪、仿真枪、气枪、烟花、礼炮、鞭炮可以报价。化学品、电池、油品以及「是否为危险品:是」仍按原询价走。群聊不拦。工单已经建成之后不拦。英文、拼音、谐音这一轮不拦。
|
||||
|
||||
## 出站
|
||||
|
||||
- 主账:登录或 Token,所有写操作要幂等键(message_id / request_id / quoteVersion)。
|
||||
|
||||
Reference in New Issue
Block a user