Compare commits

..
Author SHA1 Message Date
jillion886andCursor 9ffba70741 陆运补问把货物品名放进选项清单,并按销售原文回显已填项。
空着的运输分类不再被菜单第一项认成已填。空运报价改为货物信息加价格方案,锁舱成功回执补起运地、目的地、航司和航班日期。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 17:39:54 +08:00
linmianxunandCursor cc8529f569 空运成交后可在本群继续锁舱;调价同步舱位费用桶;出单按空运段骨架落费并保留陆运车型尺寸;导出文件名乱码修复。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 15:34:24 +08:00
42 changed files with 3069 additions and 405 deletions
@@ -447,6 +447,7 @@ def _handle_cabin(
chat_id=chat_id,
work_order_no=work_order_no,
allow_terminal=intent == INTENT_RELEASE,
for_lock=intent == INTENT_LOCK,
)
if ticket is None:
return phase
+14 -8
View File
@@ -440,12 +440,17 @@ class HttpLedger:
idem=f"collab-facts:{work_order_no}:{sorted(facts)}",
)
def activate_air_group(self, *, chat_id: str, work_order_no: str) -> dict[str, Any]:
"""空运群激活/切换当前工单。一单一群。"""
def activate_air_group(
self, *, chat_id: str, work_order_no: str, allow_deal: bool = False
) -> dict[str, Any]:
"""空运群激活/切换当前工单。一单一群。allow_deal 只用于已成交后锁舱补参数。"""
body: dict[str, Any] = {"chatId": chat_id, "workOrderNo": work_order_no}
if allow_deal:
body["allowDeal"] = True
return self._post(
"/inquiry/agent/ticket/activateAirGroup",
{"chatId": chat_id, "workOrderNo": work_order_no},
idem=f"air-activate:{chat_id}:{work_order_no}",
body,
idem=f"air-activate:{chat_id}:{work_order_no}:{int(bool(allow_deal))}",
)
def lock_cabin(
@@ -479,9 +484,9 @@ class HttpLedger:
_line = _json.dumps(
{
"sessionId": "e38e4c",
"sessionId": "3ba460",
"runId": "pre-fix",
"hypothesisId": "H-E",
"hypothesisId": "C",
"location": "http_ledger.py:lock_cabin",
"message": "ledger_lock_called",
"data": {
@@ -496,8 +501,9 @@ class HttpLedger:
ensure_ascii=False,
) + "\n"
for _p in (
_Path(r"d:\own\友通达询价智能体(新)\debug-e38e4c.log"),
_Path(r"C:/Users/ytd_extdev_0903/incoming/debug-e38e4c.log"),
_Path(r"d:\own\友通达询价智能体(新)\debug-3ba460.log"),
_Path(r"C:/Users/ytd_extdev_0903/incoming/debug-3ba460.log"),
_Path(r"E:/wwwroot/agent/current/debug-3ba460.log"),
):
try:
_p.parent.mkdir(parents=True, exist_ok=True)
+36 -6
View File
@@ -805,11 +805,13 @@ class MemoryLedger:
return ticket
return None
def activate_air_group(self, *, chat_id: str, work_order_no: str) -> dict[str, Any]:
def activate_air_group(
self, *, chat_id: str, work_order_no: str, allow_deal: bool = False
) -> dict[str, Any]:
"""
空运群当前工单:一单一群,未结案才能激活。
不改六态。
allow_deal 只给已成交后的锁舱补参数:把本群记到这张单上,不改六态。
"""
no = (work_order_no or "").strip()
room = (chat_id or "").strip()
@@ -819,7 +821,9 @@ class MemoryLedger:
return {"ok": False, "error": "ticket_not_found"}
if (ticket.business_line or "").upper() != "AIR":
return {"ok": False, "error": "not_air", "status": ticket.status}
if ticket.status in {"已成交", "未成交", "已关闭"}:
if ticket.status in {"已成交", "未成交", "已关闭"} and not (
allow_deal and ticket.status == "已成交"
):
return {"ok": False, "error": "terminal", "status": ticket.status}
if ticket.collab_chat_id and ticket.collab_chat_id != room:
return {"ok": False, "error": "occupied", "other_chat_id": ticket.collab_chat_id}
@@ -844,17 +848,43 @@ class MemoryLedger:
airline: str = "",
flight_no: str = "",
) -> dict[str, Any]:
"""单测可注入 _cabin_lock_result;默认成功。
"""单测可注入 _cabin_lock_result;默认成功。只有已成交才锁。
纯人工且视图无 TMS 舱位时,缺 option/航司/航班 → lock_incomplete,
对齐主账「现拉仍缺才回」的口径(内存账不会真查 TMS)。
"""
_ = sender_id, operator_name, instruction_text, airline, flight_no
no = (work_order_no or "").strip()
ticket = self.get_ticket(work_order_no=no)
# 与主账一致:只有已成交才锁。单测注入的失败结果也不能绕过这道门。
if ticket is not None and (ticket.status or "") != "已成交":
st = ticket.status or ""
if st == "协商中":
return {
"ok": False,
"error": "lock_negotiating",
"failReason": "工单还在协商中,成交后才能锁舱",
}
if st == "未成交":
return {
"ok": False,
"error": "lock_lost",
"failReason": "工单已是「未成交」,不能锁舱",
}
if st in {"已关闭", "转人工"}:
return {
"ok": False,
"error": "ticket_closed",
"failReason": f"工单已是「{st}」,不能锁舱",
}
return {
"ok": False,
"error": "lock_need_deal",
"failReason": "请销售先确认工单「已成交」",
}
forced = getattr(self, "_cabin_lock_result", None)
if isinstance(forced, dict):
return dict(forced)
no = (work_order_no or "").strip()
ticket = self.get_ticket(work_order_no=no)
quote = dict(ticket.quote or {}) if ticket else {}
if not _memory_quote_can_lock(quote):
return {
+59 -5
View File
@@ -903,10 +903,12 @@ _SKIP_FEE_NAMES = frozenset(
)
# 航线常写「空运费 单价CNY99,数量100,金额CNY199」。单价和金额不是同一个数,必须认金额。
# WO202610130003:协同句常见「空运费, KG, 单价CNY99.00, 数量100, 金额CNY9900」——费名与单价之间夹单位;
# WO202610140008:航线写「增值费_板,单价CNY10…」——下划线单位;漏认会落到金额误抽,出单默认单位「票」。
# 数量/金额前也可能只有空格无逗号。漏匹配会落到 _RE_FEE_LINE,把单价 99 当成行金额、数量被补成 1。
_FEE_UOM = r"KG|KGS|公斤|千克|TON|CBM|柜|票|板数|板|点|[A-Za-z]{1,8}"
_RE_FEE_PRICED = re.compile(
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)"
r"(?:\s*[,,、]\s*(?:KG|KGS|公斤|千克|TON|CBM|柜|票|板|点|[A-Za-z]{1,8}))?"
rf"(?:(?:\s*[,,、/]\s*|\s+|_)(?P<uom>{_FEE_UOM}))?"
r"\s*[,,、]?\s*单价\s*"
rf"(?:(?P<unit_ccy>{_CCY_TOKEN})\s*)?"
r"(?P<unit>\d+(?:\.\d+)?)"
@@ -916,6 +918,11 @@ _RE_FEE_PRICED = re.compile(
r"(?P<amount>\d+(?:\.\d+)?))?",
re.I,
)
# 句首分段:起运港、增值费_板… → project=起运港(出单落到对应项目区)
_RE_FEE_PROJECT_PREFIX = re.compile(
r"^\s*(?P<project>空运段|空运|起运港|起运地|目的地|目的港|其它|其他)\s*[,,、::]\s*",
re.I,
)
_FULLWIDTH_NUM = str.maketrans("0123456789.", "0123456789.")
@@ -950,14 +957,56 @@ def _currency_of(text: str, hint: str = "") -> str:
return str(hint or "").strip().upper()
def _normalize_fee_project(raw: str) -> str:
"""空运报价单项目列:空运段/起运港/目的地/其它。"""
t = (raw or "").strip()
if t in {"空运", "空运段"}:
return "空运段"
if t in {"起运港", "起运地"}:
return "起运港"
if t in {"目的地", "目的港"}:
return "目的地"
if t in {"其它", "其他"}:
return "其它"
return ""
def _strip_group_bot_mentions(text: str) -> str:
"""
去掉群里 @询价小助手 前缀,避免挡住句首「起运港、」分段标签。
企微协同句常以 @ 开头;不剥的话 project 抽不到,出单只能靠附加费默认「起运港」,
且二次盖价时更容易丢掉分段。
副作用:无。
"""
cleaned = (text or "").replace("@询价小助手", "").replace("询价小助手", "")
return cleaned.strip()
def _split_fee_project_prefix(text: str) -> tuple[str, str]:
"""
拆句首分段标签。例:起运港、增值费_板,单价… → (起运港, 增值费_板,单价…)
副作用:无。
"""
raw = _strip_group_bot_mentions(text or "")
hit = _RE_FEE_PROJECT_PREFIX.match(raw)
if not hit:
return "", raw
project = _normalize_fee_project(hit.group("project") or "")
rest = raw[hit.end() :]
return project, rest
def _parse_fee_rows(text: str, baseline_currency: str = "") -> list[dict[str, str]]:
"""
按行抽费用:码头操作费100CNY、报关费改为300CNY。
只认带「费」的中文名或短英文费用码,避免把工单号当金额。
句里无币种时继承 baseline_currency(通常是当前 TMS/主账币种),禁止默认 USD。
认「费名, KG, 单价…」与「费名_板,单价…」里的计量单位,写入 unit,禁止出单再默认「票」。
"""
raw = _ascii_amount(text or "")
project, body = _split_fee_project_prefix(text or "")
raw = _ascii_amount(body)
base_ccy = str(baseline_currency or "").strip().upper()
rows: list[dict[str, str]] = []
seen: set[str] = set()
@@ -977,6 +1026,8 @@ def _parse_fee_rows(text: str, baseline_currency: str = "") -> list[dict[str, st
"amount": amount,
"currency": line_ccy,
}
if project:
row["project"] = project
for key, val in extra.items():
if val:
row[key] = val
@@ -986,10 +1037,12 @@ def _parse_fee_rows(text: str, baseline_currency: str = "") -> list[dict[str, st
# 有「金额」用金额;只有单价时才用单价。禁止把单价误当成这一行的总金额。
amount = (hit.group("amount") or "").strip() or (hit.group("unit") or "").strip()
ccy = hit.group("amt_ccy") or hit.group("unit_ccy") or ""
uom = (hit.groupdict().get("uom") or "").strip()
_add(
(hit.group("name") or "").strip(),
_ascii_amount(amount),
ccy,
unit=uom,
unit_price=_ascii_amount(hit.group("unit") or ""),
quantity=_ascii_amount(hit.group("qty") or ""),
)
@@ -1054,15 +1107,16 @@ def extract_product_quote(
raw = (text or "").strip()
if not raw:
return {}
stripped = raw.replace("@询价小助手", "").replace("询价小助手", "")
stripped = _strip_group_bot_mentions(raw)
compact = stripped.replace(" ", "").replace(" ", "")
if re.fullmatch(r"WO\d{12}", compact, re.I):
return {}
band = _parse_weight_band_quote(raw, baseline_currency=base_ccy)
band = _parse_weight_band_quote(stripped or raw, baseline_currency=base_ccy)
if band:
return band
# 费率句若仍落到普通费用行,会把「燃油附加费」配上句首公斤数。先剥掉再抽。
scrubbed = _strip_rate_and_band_noise(raw)
# 必须用剥掉 @询价小助手 后的正文,否则句首「起运港、」分段标签匹配不到。
scrubbed = _strip_rate_and_band_noise(stripped or raw)
rows = _parse_fee_rows(scrubbed, baseline_currency=base_ccy) if scrubbed.strip() else []
if rows:
ccy = rows[0].get("currency") or _currency_of(raw, base_ccy) or base_ccy
+154 -19
View File
@@ -1,5 +1,5 @@
"""
空运协同群业务:激活当前工单、补含电含磁、航线报价/附件、锁舱/释放、成交跟进。
空运协同群业务:激活当前工单、补含电含磁、航线报价/附件、成交后再锁舱、成交后仍可取消舱位。
本文件职责:群里副作用(主账 + BOT 回话)。海运应用群仍走 sea_group_ops。
禁止:自动建群/拉人/解散;拿当前工单去猜锁舱工单号。
@@ -39,6 +39,7 @@ from agent.policy.sea_group_ops import (
logger = logging.getLogger(__name__)
TERMINAL = {"已成交", "未成交", "已关闭", "转人工"}
OPEN_ACTIVATE = {"询价中", "已报价", "协商中"}
AIRLINE_ROLE = "air"
@@ -68,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()
@@ -535,12 +572,13 @@ def resolve_cabin_ticket(
chat_id: str,
work_order_no: str,
allow_terminal: bool,
for_lock: bool = False,
) -> tuple[Optional[object], str]:
"""
锁舱/释放按句中工单号取单,不猜当前单。
未结案:激活并切到本群。已在别的群:occupied,不锁不切。
已成交/未成交:不能再激活,但释放仍要读到这张单(成交拦住、未成交放舱)。
已成交可以锁、也可以取消已锁舱位,不再激活。未成交不能锁,但已有锁仍可取消。
"""
book = flow.ledger
no = (work_order_no or "").strip()
@@ -561,13 +599,18 @@ def resolve_cabin_ticket(
send_group_text(client, chat_id, copy.air_ticket_occupied(no))
return None, "occupied"
if is_terminal(ticket):
st = ticket_status(ticket)
# 锁舱只在已成交之后办。未成交要能读到单,才能回「不能锁」,而不是「不能再激活」。
if for_lock and st in {"已成交", "未成交"}:
# 已成交不再走普通激活,但要把本群记下,下一句补舱位不用再带工单号。
if st == "已成交":
_bind_deal_cabin_room(flow, chat_id=chat_id, work_order_no=no)
return ticket, "deal_loaded" if st == "已成交" else "lost_loaded"
if allow_terminal:
return ticket, "terminal_loaded"
if ticket_status(ticket) == "转人工":
if st == "转人工":
return None, "handoff_ignore"
send_group_text(
client, chat_id, copy.air_ticket_closed(no, ticket_status(ticket))
)
send_group_text(client, chat_id, copy.air_ticket_closed(no, st))
return None, "ticket_closed"
return activate_ticket(flow=flow, chat_id=chat_id, work_order_no=no, speak=False)
@@ -869,9 +912,91 @@ def apply_air_deal(
followup_hours=result.get("negotiate_hours"),
),
)
# 共用回执仍是「已记录为已成交」。催锁只加在空运群,海运陆运不走这里。
if outcome == "已成交" and phase == "done":
prompt_airline_to_lock(flow=flow, ticket=ticket, chat_id=chat_id, work_order_no=no)
return phase
def _bind_deal_cabin_room(flow: Any, *, chat_id: str, work_order_no: str) -> None:
"""已成交锁舱时把工单留在本群。普通激活仍拒绝已成交。失败只记日志,不挡这一句锁舱。"""
book = getattr(flow, "ledger", None)
activator = getattr(book, "activate_air_group", None) if book is not None else None
if not callable(activator):
return
try:
activator(chat_id=chat_id, work_order_no=work_order_no, allow_deal=True)
except TypeError:
logger.warning("主账激活不接受已成交绑群 wo=%s", work_order_no)
except Exception:
logger.exception("已成交锁舱绑群失败 wo=%s", work_order_no)
def prompt_airline_to_lock(
*,
flow: Any,
ticket: object,
chat_id: str,
work_order_no: str = "",
) -> None:
"""
空运单记成已成交后,在协同群点名航线锁舱。
只 @ 岗位为航线·空运的人,不改成交回执。没有群、或多段/海运/陆运,不发。
群消息在调用方线程发出(群回调或私聊成交后的同一轮)。
"""
view = ticket_view(ticket)
no = (work_order_no or ticket_no(ticket)).strip()
room = (chat_id or str(view.get("collab_chat_id") or view.get("collabChatId") or "")).strip()
line = str(view.get("business_line") or view.get("businessLine") or "AIR").strip().upper()
if not no or not room or line != "AIR":
return
_sales_name, airline_names, mention_ids = resolve_air_mentions(flow, ticket)
sales_id = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
air_ids = [uid for uid in mention_ids if uid and uid != sales_id]
send_group_text(
group_client_of(flow),
room,
copy.air_lock_ask_airline(work_order_no=no, airline_names=airline_names),
mention_userids=air_ids,
)
def _block_lock_until_deal(flow: Any, ticket: object, chat_id: str) -> str:
"""
未成交的空运单不许锁。已成交返回空串,调用方继续锁。
询价中/已报价:点名本单销售。协商中、未成交、已关闭各说各的。
转人工沿用静默。不打 TMS。
"""
st = ticket_status(ticket)
if st == "已成交":
return ""
no = ticket_no(ticket)
client = group_client_of(flow)
if st == "未成交":
send_group_text(client, chat_id, copy.air_lock_blocked_lost(no))
return "lock_lost"
if st == "协商中":
send_group_text(client, chat_id, copy.air_lock_blocked_negotiating(no))
return "lock_negotiating"
if st == "转人工":
return "handoff_ignore"
if st == "已关闭":
send_group_text(client, chat_id, copy.air_ticket_closed(no, st))
return "ticket_closed"
sales_name, _airline_names, _mention_ids = resolve_air_mentions(flow, ticket)
view = ticket_view(ticket)
sales_id = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
send_group_text(
client,
chat_id,
copy.air_lock_need_deal(sales_name=sales_name),
mention_userids=[sales_id] if sales_id else None,
)
return "lock_need_deal"
def apply_air_lock(
*,
flow: Any,
@@ -884,15 +1009,19 @@ def apply_air_lock(
flight_no: str = "",
) -> str:
"""
航线锁舱。
航线锁舱。只有工单已是「已成交」才打主账。
还没回复成交:点名销售先确认。协商中、未成交各用一句,不催销售改口。
舱位编号、航司、航班号没齐时只请补全,不调 TMS 锁舱,不标系统异常。
三项齐了(话里写的优先,报价里已有的可以补上)才打主账锁舱。
上次锁舱失败标过暂停的,航线再锁仍要打 TMS。
上次锁舱失败标过暂停的,航线再锁仍要打 TMS。锁成功不再推成交跟进。
"""
if not is_airline(flow, sender_id):
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
return "not_airline"
blocked = _block_lock_until_deal(flow, ticket, chat_id)
if blocked:
return blocked
no = ticket_no(ticket)
name = operator_name(flow, sender_id)
view_quote = dict(ticket_view(ticket).get("quote") or {})
@@ -908,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(
@@ -950,12 +1082,15 @@ def apply_air_lock(
is_ok=lambda x: bool((x or {}).get("ok")),
)
if not out.get("ok"):
if str(out.get("error") or "") == "no_quote":
err_code = str(out.get("error") or "")
if err_code == "no_quote":
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no))
return "no_quote"
# 主账再拦一遍成交状态:只回业务句,不当 TMS 故障。
if err_code in {"lock_need_deal", "lock_negotiating", "lock_lost", "ticket_closed"}:
return _block_lock_until_deal(flow, ticket, chat_id) or err_code
# 主账在出站前发现三项不齐:只请补,不当 TMS 故障,不通知 IT。
reason_text = str(out.get("failReason") or "")
err_code = str(out.get("error") or "")
if err_code == "lock_params_incomplete" or "锁舱参数不完整" in reason_text:
labels = missing or _labels_from_incomplete_reason(reason_text)
_remember_partial_lock(
@@ -1023,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,
@@ -1059,15 +1196,16 @@ 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
if ticket_is_paused(ticket):
clear_system_exception(ledger=flow.ledger, work_order_no=no)
view = ticket_view(ticket)
view["collab_chat_id"] = chat_id
emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {}))
return "lock_ok"
def _release_should_push_deal(ticket: object) -> bool:
@@ -1091,11 +1229,8 @@ def apply_air_release(
if not is_airline(flow, sender_id):
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
return "not_airline"
# 释放同理:暂停单也允许再打 TMS,回话用释放失败原因而不是接口异常兜底。
# 释放同理:暂停单、已成交单也允许再打 TMS。还没锁过的仍拒绝。
no = ticket_no(ticket)
if ticket_status(ticket) == "已成交":
send_group_text(group_client_of(flow), chat_id, copy.air_release_deal_blocked(no))
return "release_deal_blocked"
facts = dict(ticket_view(ticket).get("collab_facts") or {})
lock_id = str(facts.get(_LOCK_ID) or "").strip()
name = operator_name(flow, sender_id)
@@ -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(".")
@@ -870,6 +870,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 "已报价",
@@ -1355,6 +1356,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 +1481,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 +1494,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 +1510,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,
@@ -2053,6 +2058,16 @@ class AirTextInquiryFlow:
followup_hours=result.get("negotiate_hours"),
)
)
# 私聊点已成交:催锁发到空运协同群。海运陆运、还没建群的不发。
if status == "已成交" and (sess.business_line or "").strip().upper() == "AIR":
from agent.policy.air_group_ops import prompt_airline_to_lock
prompt_airline_to_lock(
flow=self,
ticket=sess,
chat_id=sess.collab_chat_id,
work_order_no=sess.work_order_no,
)
return phase
def _capture_lost_reason(self, sess: FlowSession, text: str, reply: ReplyFn) -> str:
+43 -38
View File
@@ -177,45 +177,50 @@ def finish_deal_outcome(
staff = list(lister(role_code=_role_of(business_line)) or [])
except Exception:
logger.exception("成交匹配负责人失败 wo=%s", no)
leaders = pick_leaders(staff, business_line=business_line, facts=facts)
ready = notify_app_ready() if notify_ready is None else bool(notify_ready)
if not leaders:
summary = "无匹配负责人"
elif not ready:
# 通知卡拼字段失败不能挡住六态「已成交」和群回执(WO001:selected_air_option 非字典)。
try:
leaders = pick_leaders(staff, business_line=business_line, facts=facts)
ready = notify_app_ready() if notify_ready is None else bool(notify_ready)
if not leaders:
summary = "无匹配负责人"
elif not ready:
summary = "通知未发出"
else:
names = [str(x.get("name") or x.get("wecomId") or "").strip() for x in leaders]
summary = "已推:" + "、".join(n for n in names if n)
sender = enqueue or _enqueue_default
payload = copy.deal_notify_wecom_payload(
work_order_no=no,
sales_name=sales_name,
facts=facts or {},
quote=quote or {},
business_line=business_line,
)
text = copy.deal_notify_card(
work_order_no=no,
sales_name=sales_name,
facts=facts or {},
quote=quote or {},
business_line=business_line,
)
for row in leaders:
uid = str(row.get("wecomId") or "").strip()
if not uid:
continue
try:
ok, info = sender(
touser=uid,
content=text,
dedupe_key=f"deal-notify:{no}:{uid}",
payload=dict(payload),
)
if not ok:
logger.warning("成交通知入队失败 wo=%s uid=%s info=%s", no, uid, info)
except Exception:
logger.exception("成交通知入队异常 wo=%s uid=%s", no, uid)
except Exception:
logger.exception("成交通知组装失败,仍继续入账 wo=%s", no)
summary = "通知未发出"
else:
names = [str(x.get("name") or x.get("wecomId") or "").strip() for x in leaders]
summary = "已推:" + "、".join(n for n in names if n)
sender = enqueue or _enqueue_default
payload = copy.deal_notify_wecom_payload(
work_order_no=no,
sales_name=sales_name,
facts=facts or {},
quote=quote or {},
business_line=business_line,
)
text = copy.deal_notify_card(
work_order_no=no,
sales_name=sales_name,
facts=facts or {},
quote=quote or {},
business_line=business_line,
)
for row in leaders:
uid = str(row.get("wecomId") or "").strip()
if not uid:
continue
try:
ok, info = sender(
touser=uid,
content=text,
dedupe_key=f"deal-notify:{no}:{uid}",
payload=dict(payload),
)
if not ok:
logger.warning("成交通知入队失败 wo=%s uid=%s info=%s", no, uid, info)
except Exception:
logger.exception("成交通知入队异常 wo=%s uid=%s", no, uid)
extra["deal_notify_summary"] = summary
extra["remark"] = summary
writer = getattr(ledger, "transition", None)
+134 -37
View File
@@ -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 故障。
@@ -3420,8 +3462,63 @@ def air_release_fail(*, work_order_no: str, operator: str, reason: str) -> str:
)
def air_lock_ask_airline(*, work_order_no: str, airline_names: list[str] | None = None) -> str:
"""
已成交后点名航线去锁舱。只在空运群另发,不改共用的「已记录为已成交」。
后台 inquiring.air_lock_ask_airline。姓名来自岗位「航线·空运」。
"""
no = (work_order_no or "").strip()
names = [str(n or "").strip() for n in (airline_names or []) if str(n or "").strip()]
at = " ".join(f"@{n}" for n in names) if names else "@航线人员"
return _cabin_copy(
"inquiring.air_lock_ask_airline",
f"{no}请锁舱{at}",
ticket_no=no,
airline_names=at,
)
def air_lock_need_deal(*, sales_name: str) -> str:
"""
销售还没回复成交、未成交或协商中,航线就要锁。点名本单销售。
后台 inquiring.air_lock_need_deal。
"""
name = (sales_name or "").strip() or "销售"
return _cabin_copy(
"inquiring.air_lock_need_deal",
f"请销售@{name}先确认工单「已成交」",
sales_name=name,
)
def air_lock_blocked_negotiating(work_order_no: str) -> str:
"""协商中已经回过,不能再当成没回复。后台 inquiring.air_lock_negotiating。"""
no = (work_order_no or "").strip()
return _cabin_copy(
"inquiring.air_lock_negotiating",
f"工单{no}还在协商中,成交后才能锁舱。",
ticket_no=no,
)
def air_lock_blocked_lost(work_order_no: str) -> str:
"""未成交不能再请销售改口确认已成交。后台 inquiring.air_lock_lost。"""
no = (work_order_no or "").strip()
return _cabin_copy(
"inquiring.air_lock_lost",
f"工单{no}已是「未成交」,不能锁舱。",
ticket_no=no,
)
def air_release_deal_blocked(work_order_no: str) -> str:
"""已成交不能释放舱位。后台 inquiring.air_release_deal_blocked。"""
"""
旧句:已成交不能取消舱位。
现口径是成交并锁舱之后仍可取消,群里不再发这句。后台编码保留,避免旧配置对不上。
"""
no = (work_order_no or "").strip()
return _cabin_copy(
"inquiring.air_release_deal_blocked",
+11 -3
View File
@@ -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,
@@ -170,6 +173,8 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
)
if b_opt:
facts = harvest_land_options(b_opt, facts)
# 复制清单冒号空着的项,菜单文字和模型猜测都不能当成已填。
facts = drop_blank_paste_options(facts, source)
if not mode:
mode = "LAND" if (sess and (sess.business_line or "").upper() == "LAND") else ""
@@ -313,7 +318,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 +587,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 +664,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,
+46 -25
View File
@@ -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:
@@ -414,7 +414,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",
@@ -1753,6 +1753,12 @@ def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None
_apply_fee_amount(old, amount)
if item.get("currency"):
old["currency"] = item.get("currency")
# 盖价时一并写入单位/数量/分段:否则旧行默认「票」会盖住新抽到的「板」、
# project=起运港 也会丢,出单又落到错段或错单位(WO008/WO009)。
for key in ("unit", "unit_price", "unitPrice", "quantity", "project", "segment"):
val = item.get(key)
if val not in (None, ""):
old[key] = val
rows[idx] = old
out = dict(base or {})
for key, val in incoming.items():
+18 -1
View File
@@ -913,6 +913,11 @@ def _resume_cabin_step(
service = "空运锁舱"
reason = "空运锁舱接口调用失败"
if not (out or {}).get("ok"):
err = str((out or {}).get("error") or "")
# 还没成交、协商中、未成交:业务拒绝,不标 TMS 异常、不通知 IT。
if err in {"lock_need_deal", "lock_negotiating", "lock_lost", "ticket_closed"}:
reply(str((out or {}).get("failReason") or "还不能锁舱"))
return {"ok": False, "error": err}
why = copy.air_cabin_fail_reason(
tms_reason=str((out or {}).get("failReason") or (out or {}).get("error") or ""),
flight_date=str(extra.get("flight_date") or ""),
@@ -949,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,
@@ -144,11 +156,17 @@ def route_codes(
"""
价格明细/方案卡用的航线三字码:有中转写成 ZUH-CAN-CRK。
对不上就不写假中转。
selected_air_option 可能是字典、JSON 串,或只写了舱位编号;
不能直接 dict(字符串),否则成交卡拼航线会整单炸掉(WO001)。
"""
src = dict(option or {})
src = _parse_option_dict(option)
if not src:
q = dict(quote or {})
src = dict(q.get("selected_air_option") or {})
q = dict(quote or {}) if isinstance(quote, dict) else {}
# 先认 camelCase,和主账快照字段对齐。
src = _parse_option_dict(
q.get("selected_air_option") or q.get("selectedAirOption")
)
if not src:
opts = parse_air_options(q)
src = dict(opts[0]) if opts else {}
+200 -44
View File
@@ -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)
+160 -18
View File
@@ -461,6 +461,11 @@ def effective_quote(tms: dict[str, Any] | None, overlay: dict[str, Any] | None)
_apply_amount(old, str(item.get("amount") or ""))
if item.get("currency"):
old["currency"] = item.get("currency")
# 与 merge_quote_fees 一致:盖价保留新行的单位/数量/分段,禁止旧默认「票」残留。
for key in ("unit", "unit_price", "unitPrice", "quantity", "project", "segment"):
val = item.get(key)
if val not in (None, ""):
old[key] = val
rows[idx] = old
out = dict(base)
out["fee_rows"] = rows
@@ -763,6 +768,52 @@ def _match_fee_index(name: str, fees: list[dict[str, Any]], used: set[int]) -> i
)
def _fee_air_project(fee: dict[str, Any]) -> str:
"""
空运费用落到哪一段:有 project 跟 project;否则空运费进空运段,其余进起运港。
与主账 mergeAirBucketRowsWithFlatManualFees 兜底一致,避免增值费进不了起运港。
"""
project = str(fee.get("project") or fee.get("segment") or "").strip()
if project in {"空运段", "空运"}:
return "空运段"
if project in {"起运港", "起运地"}:
return "起运港"
if project in {"目的地", "目的港"}:
return "目的地"
if project in {"其它", "其他"}:
return "其它"
name = _fee_title(fee)
if "空运" in name and "增值" not in name and "增点" not in name:
return "空运段"
return "起运港"
def _fee_as_air_charge(fee: dict[str, Any], *, quote: dict[str, Any]) -> dict[str, Any]:
"""扁平 fee_lines 转舱位 departureCharges / feeItems 会读的结构。"""
title = _fee_title(fee)
plain = _plain_amount(str(fee.get("amount") or ""))
unit_price = _plain_amount(
str(fee.get("unit_price") or fee.get("unitPrice") or fee.get("amount") or "")
)
out: dict[str, Any] = {
"chargeName": title,
"feeName": title,
"name": title,
"amount": plain or unit_price,
"unitPrice": unit_price or plain,
"currency": fee.get("currency") or quote.get("currency") or "",
"project": _fee_air_project(fee),
}
unit = str(fee.get("unit") or fee.get("unitName") or "").strip()
if unit:
out["unit"] = unit
qty = str(fee.get("quantity") or fee.get("chargeQuantity") or "").strip()
if qty:
out["quantity"] = qty
return out
def _stamp_fee_items(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None:
raw, was_str = _json_struct(quote.get("feeItems"))
items = [dict(x) for x in raw if isinstance(x, dict)] if isinstance(raw, list) else []
@@ -775,8 +826,20 @@ def _stamp_fee_items(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None:
"currency": fee.get("currency") or quote.get("currency") or "",
}
for fee in fees
if _fee_title(fee)
if _fee_title(fee) and _fee_air_project(fee) == "空运段"
]
if not items:
# 无空运段费目时仍按原逻辑铺全量,避免只有起运附加费时整表空掉
items = [
{
"name": _fee_title(fee),
"feeName": _fee_title(fee),
"amount": _plain_amount(str(fee.get("amount") or "")),
"currency": fee.get("currency") or quote.get("currency") or "",
}
for fee in fees
if _fee_title(fee)
]
else:
used: set[int] = set()
for item in items:
@@ -789,6 +852,22 @@ def _stamp_fee_items(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None:
item["name"] = _fee_title(fees[idx])
if not item.get("feeName"):
item["feeName"] = _fee_title(fees[idx])
# 调价新增的空运段费目也要进 feeItems,否则模板只读舱位桶时看不到
for i, fee in enumerate(fees):
if i in used or _fee_air_project(fee) != "空运段":
continue
title = _fee_title(fee)
if not title:
continue
items.append(
{
"name": title,
"feeName": title,
"amount": _plain_amount(str(fee.get("amount") or "")),
"currency": fee.get("currency") or quote.get("currency") or "",
}
)
used.add(i)
if items:
_put_struct(quote, "feeItems", items, was_str)
@@ -870,6 +949,67 @@ def _stamp_buckets(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None:
_store_bucket(quote, key, items)
def _stamp_air_bucket_list(
opt: dict[str, Any],
key_primary: str,
key_alt: str,
fees: list[dict[str, Any]],
used: set[int],
*,
projects: set[str],
quote: dict[str, Any],
) -> None:
"""
更新舱位某一费用桶,并把 fee_lines 里多出来的同段费用追加进去。
为何:调价/产品报价常只改扁平明细;模板读 departureCharges,不追加就会缺「增值费」行。
"""
charge_key = key_primary if key_primary in opt or key_alt not in opt else key_alt
if charge_key not in opt and key_primary not in opt and key_alt not in opt:
# 舱位从未带过该桶:只要有待追加费用就新建,否则不动
if not any(i not in used and _fee_air_project(fees[i]) in projects for i in range(len(fees))):
return
charge_key = key_primary
charges, charges_str = [], False
else:
charges, charges_str = _json_struct(opt.get(charge_key))
if not isinstance(charges, list):
charges = []
copied = [dict(x) if isinstance(x, dict) else x for x in charges]
for charge in copied:
if not isinstance(charge, dict):
continue
idx = _match_fee_index(_fee_title(charge), fees, used)
if idx < 0:
continue
used.add(idx)
plain = _plain_amount(str(fees[idx].get("amount") or ""))
_write_price(charge, plain)
# 单价/数量若调价行有值也盖上,模板按单价×数量重算时才对
fee = fees[idx]
up = _plain_amount(str(fee.get("unit_price") or fee.get("unitPrice") or ""))
if up:
charge["unitPrice"] = up
charge["unit_price"] = up
qty = str(fee.get("quantity") or "").strip()
if qty:
charge["quantity"] = qty
unit = str(fee.get("unit") or "").strip()
if unit:
charge["unit"] = unit
for i, fee in enumerate(fees):
if i in used:
continue
if _fee_air_project(fee) not in projects:
continue
title = _fee_title(fee)
if not title:
continue
copied.append(_fee_as_air_charge(fee, quote=quote))
used.add(i)
opt[charge_key] = json.dumps(copied, ensure_ascii=False) if charges_str else copied
def _stamp_air_copies(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None:
"""空运报价单读舱位 departureCharges 和 salePrice,不读改过的 fee_lines。"""
total_plain = _plain_amount(str(quote.get("total") or ""))
@@ -882,23 +1022,25 @@ def _stamp_air_copies(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None
for opt in options:
if not isinstance(opt, dict):
continue
charge_key = "departureCharges" if "departureCharges" in opt or "departure_charges" not in opt else "departure_charges"
if charge_key not in opt and "departureCharges" not in opt and "departure_charges" not in opt:
charge_key = ""
if charge_key:
charges, charges_str = _json_struct(opt.get(charge_key))
if isinstance(charges, list) and charges:
used: set[int] = set()
copied = [dict(x) if isinstance(x, dict) else x for x in charges]
for charge in copied:
if not isinstance(charge, dict):
continue
idx = _match_fee_index(_fee_title(charge), fees, used)
if idx < 0:
continue
used.add(idx)
_write_price(charge, _plain_amount(str(fees[idx].get("amount") or "")))
opt[charge_key] = json.dumps(copied, ensure_ascii=False) if charges_str else copied
used: set[int] = set()
_stamp_air_bucket_list(
opt,
"departureCharges",
"departure_charges",
fees,
used,
projects={"起运港", "其它"},
quote=quote,
)
_stamp_air_bucket_list(
opt,
"destinationCharges",
"destination_charges",
fees,
used,
projects={"目的地"},
quote=quote,
)
if total_plain:
if opt.get("salePrice") not in (None, ""):
opt["salePrice"] = total_plain
+164 -52
View File
@@ -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)
@@ -1055,6 +1108,8 @@ class AirGroupCollabTests(unittest.TestCase):
)
self.assertEqual(phase, "done")
self.assertEqual(self.ledger.get_ticket(work_order_no=wo).status, "已成交")
self.assertTrue(any(f"{wo}请锁舱@HangXian" in t for t in self._texts()))
self.assertTrue(any("已记录为「已成交」" in t for t in self._texts()))
def test_lock_requires_work_order(self) -> None:
phase = handle_air_group(
@@ -1074,8 +1129,9 @@ class AirGroupCollabTests(unittest.TestCase):
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "no_quote")
self.assertTrue(any("还没有报价,不能锁舱" in t for t in self._texts()))
self.assertEqual(phase, "lock_need_deal")
self.assertTrue(any("请销售@WuJiLin先确认工单「已成交」" in t for t in self._texts()))
self.assertFalse(any("还没有报价,不能锁舱" in t for t in self._texts()))
self.assertFalse(any("AIR-OPT-01" in t for t in self._texts()))
self.assertFalse(any("执行成功" in t for t in self._texts()))
@@ -1087,7 +1143,7 @@ class AirGroupCollabTests(unittest.TestCase):
"source": "manual",
"source_label": "产品报价",
},
status="已报价",
status="已成交",
)
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_manual", mid="lm1"),
@@ -1108,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)。"""
@@ -1136,7 +1192,7 @@ class AirGroupCollabTests(unittest.TestCase):
},
],
},
status="已报价",
status="已成交",
)
calls: list[dict] = []
orig = self.ledger.lock_cabin
@@ -1159,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(
@@ -1167,7 +1257,8 @@ class AirGroupCollabTests(unittest.TestCase):
"source": "TMS",
"source_label": "TMS 标准报价",
"quoteId": "QT-AIR-23",
}
},
status="已成交",
)
calls: list[dict] = []
orig = self.ledger.lock_cabin
@@ -1217,7 +1308,8 @@ class AirGroupCollabTests(unittest.TestCase):
"source": "TMS",
"source_label": "TMS 标准报价",
"quoteId": "QT-AIR-24",
}
},
status="已成交",
)
calls: list[dict] = []
orig = self.ledger.lock_cabin
@@ -1257,7 +1349,8 @@ class AirGroupCollabTests(unittest.TestCase):
"total": "CNY 1800",
"source": "manual",
"source_label": "产品报价",
}
},
status="已成交",
)
calls: list[dict] = []
orig = self.ledger.lock_cabin
@@ -1285,7 +1378,8 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertEqual(calls[0].get("flight_no"), "MU5123")
self.assertFalse(any("锁舱参数不完整" in t for t in self._texts()))
def test_lock_success_and_release_after_deal_blocked(self) -> None:
def test_deal_then_lock_then_release(self) -> None:
"""成交前不能锁。成交后点名航线,锁成功可取消,不再问一次是否成交。"""
wo = self._create_air()
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="l2"),
@@ -1293,36 +1387,64 @@ class AirGroupCollabTests(unittest.TestCase):
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "lock_ok")
self.assertTrue(any("执行成功" in t for t in self._texts()))
handle_air_group(
self.assertEqual(phase, "lock_need_deal")
self.assertTrue(any("请销售@WuJiLin先确认工单「已成交」" in t for t in self._texts()))
phase = handle_air_group(
self._msg(sender="sales1", text="成交", chat="wr_air_1", mid="l3"),
flow=self.flow,
ledger=self.ledger,
injected_intent="deal_won",
)
self.assertEqual(phase, "done")
self.assertTrue(any(f"{wo}请锁舱@HangXian" in t for t in self._texts()))
self.groups.sent.clear()
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}释放舱位", chat="wr_air_1", mid="l4"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_release",
)
self.assertEqual(phase, "release_deal_blocked")
self.assertTrue(any("已成交" in t for t in self._texts()))
def test_lost_can_release(self) -> None:
wo = self._create_air()
handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="r0"),
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="l4"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
handle_air_group(
self._msg(sender="sales1", text="未成交", chat="wr_air_1", mid="r1"),
self.assertEqual(phase, "lock_ok")
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"),
flow=self.flow,
ledger=self.ledger,
injected_intent="deal_lost",
)
self.assertEqual(phase, "release_ok")
self.assertTrue(any("取消舱位成功" in t for t in self._texts()))
def test_negotiating_and_lost_cannot_lock(self) -> None:
wo_talk = self._create_air(status="协商中")
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo_talk}锁舱", chat="wr_air_talk", mid="ng1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "lock_negotiating")
self.assertTrue(any("还在协商中,成交后才能锁舱" in t for t in self._texts()))
self.assertFalse(any("先确认工单「已成交」" in t for t in self._texts()))
wo_lost = self._create_air(status="未成交")
self.groups.sent.clear()
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo_lost}锁舱", chat="wr_air_lost", mid="ls1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "lock_lost")
self.assertTrue(any("已是「未成交」,不能锁舱" in t for t in self._texts()))
self.assertFalse(any("先确认工单「已成交」" in t for t in self._texts()))
def test_lost_can_release(self) -> None:
"""未成交不能新锁,但改规则前已经锁上的仍可取消。"""
wo = self._create_air(status="未成交")
self.ledger.patch_collab_facts(
work_order_no=wo,
facts={"__air_lock_id": "CA-OLD-LOCK"},
sender_id="air1",
)
before = len(self._texts())
phase = handle_air_group(
@@ -1338,7 +1460,7 @@ class AirGroupCollabTests(unittest.TestCase):
def test_release_wording_asks_before_cancel(self) -> None:
"""说出释放舱位只提问。销售回「是」不算,航线确认后才取消。"""
wo = self._create_air()
wo = self._create_air(status="已成交")
handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_ask", mid="ask0"),
flow=self.flow,
@@ -1370,7 +1492,7 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts()))
def test_release_wording_can_be_declined(self) -> None:
wo = self._create_air()
wo = self._create_air(status="已成交")
handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_ask_no", mid="no0"),
flow=self.flow,
@@ -1393,7 +1515,7 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertNotIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts()))
def test_cancel_cabin_words_release_immediately(self) -> None:
wo = self._create_air()
wo = self._create_air(status="已成交")
handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_now", mid="now0"),
flow=self.flow,
@@ -1430,7 +1552,7 @@ class AirGroupCollabTests(unittest.TestCase):
"status": "active",
}
)
wo = self._create_air()
wo = self._create_air(status="已成交")
phase = handle_air_group(
self._msg(sender="sales1", text=f"{wo}锁舱", chat="wr_air_1", mid="s2"),
flow=self.flow,
@@ -1439,7 +1561,8 @@ class AirGroupCollabTests(unittest.TestCase):
)
self.assertEqual(phase, "lock_ok")
def test_multi_option_asks(self) -> None:
def test_multi_tms_option_locks_first_until_named(self) -> None:
"""TMS 有多条舱位且没点名时,直接锁第一条,不再追问三项。"""
wo = self._create_air(
quote={
"total": "USD 800",
@@ -1449,7 +1572,8 @@ class AirGroupCollabTests(unittest.TestCase):
{"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "flightNo": "CZ327"},
{"optionNo": "AIR-OPT-02", "airlineCode": "MU", "flightNo": "MU5123"},
],
}
},
status="已成交",
)
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="m1"),
@@ -1457,16 +1581,9 @@ class AirGroupCollabTests(unittest.TestCase):
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "wait_lock_params")
phase = handle_air_group(
self._msg(sender="air1", text="AIR-OPT-02", chat="wr_air_1", mid="m2"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_pick_option",
)
self.assertEqual(phase, "lock_ok")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.collab_facts.get("__air_lock_option"), "AIR-OPT-02")
self.assertEqual(ticket.collab_facts.get("__air_lock_option"), "AIR-OPT-01")
def _file(self, *, sender: str, filename: str, chat: str, mid: str, text: str = "") -> InboundMessage:
return InboundMessage(
@@ -1787,7 +1904,7 @@ class AirGroupCollabTests(unittest.TestCase):
def test_lock_fail_tells_operator_and_tms_reason(self) -> None:
"""锁舱失败群里要回操作人和 TMS 原因,不能被系统异常兜底盖掉。"""
wo = self._create_air()
wo = self._create_air(status="已成交")
self.ledger._cabin_lock_result = {
"ok": False,
"error": "lock_fail",
@@ -1810,25 +1927,20 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertNotIn("与工单不匹配", joined)
def test_lock_fail_does_not_block_airline_quote(self) -> None:
"""锁舱失败后,航线再报手工价必须收下,不能回 TMS 接口异常。"""
"""成交前锁舱不会打 TMS,也不标系统异常,航线仍可继续报价。"""
wo = self._create_air()
handle_air_group(
self._msg(sender="air1", text=wo, chat="wr_air_lock_quote", mid="lq0"),
flow=self.flow,
ledger=self.ledger,
)
self.ledger._cabin_lock_result = {
"ok": False,
"error": "lock_fail",
"failReason": "该航班日期不存在可锁定的配载记录",
}
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_quote", mid="lq1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "system_exception")
self.assertEqual(phase, "lock_need_deal")
self.groups.sent.clear()
phase = handle_air_group(
self._msg(sender="air1", text="空运费改为900USD", chat="wr_air_lock_quote", mid="lq2"),
@@ -1843,7 +1955,7 @@ class AirGroupCollabTests(unittest.TestCase):
def test_lock_without_quote_still_shows_tms_reason(self) -> None:
"""TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。"""
wo = self._create_air()
wo = self._create_air(status="已成交")
self.ledger._cabin_lock_result = {
"ok": False,
"error": "lock_fail",
@@ -1863,7 +1975,7 @@ class AirGroupCollabTests(unittest.TestCase):
def test_paused_lock_retries_and_shows_tms_reason(self) -> None:
"""上次锁舱失败标了系统异常后,再锁仍要打 TMS,不能只回兜底。"""
wo = self._create_air()
wo = self._create_air(status="已成交")
self.ledger.mark_system_exception(
work_order_no=wo,
system_exception="TMS",
@@ -1937,7 +2049,7 @@ class AirGroupCollabTests(unittest.TestCase):
def test_release_fail_tells_operator_and_tms_reason(self) -> None:
"""释放失败同理:操作人、原因、已通知IT,不要系统异常兜底。"""
wo = self._create_air()
wo = self._create_air(status="已成交")
handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_rl_fail", mid="rf0"),
flow=self.flow,
+101 -3
View File
@@ -65,6 +65,32 @@ class AirRouteOptionsTests(unittest.TestCase):
self.assertTrue(needs_route_pick(quote))
self.assertEqual(len(parse_air_options(quote)), 2)
def test_route_codes_tolerates_string_selected_option(self) -> None:
"""主账若把 selected_air_option 存成 JSON 串或编号串,不能再 dict(字符串) 炸掉成交卡。"""
quote_json = {
"selected_air_option": (
'{"originAirportCode":"NKG","destinationAirportCode":"KUL"}'
),
}
self.assertEqual(
route_codes(None, {"起运港": "南京", "目的港": "吉隆坡"}, quote_json),
"NKG-KUL",
)
quote_code = {
"selected_air_option": "AIR-OPT01",
"airOptions": [
{
"optionNo": "AIR-OPT01",
"originAirportCode": "SZX",
"destinationAirportCode": "CGK",
}
],
}
self.assertEqual(
route_codes(None, {"起运港": "深圳", "目的港": "雅加达"}, quote_code),
"SZX-CGK",
)
def test_select_writes_origin(self) -> None:
option = {"originAirportCode": "SZX", "destinationAirportCode": "KUL"}
facts = apply_selected_option({"目的港": "吉隆坡"}, option)
@@ -356,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
+6 -3
View File
@@ -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 = {
+3
View File
@@ -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)
+63 -6
View File
@@ -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",
+2 -1
View File
@@ -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")
@@ -1392,8 +1392,41 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
self.assertEqual(str(rows["空运费"].get("amount") or ""), "9900.00")
self.assertEqual(str(rows["空运费"].get("unit_price") or ""), "99.00")
self.assertEqual(str(rows["空运费"].get("quantity") or ""), "100")
self.assertEqual(str(rows["空运费"].get("unit") or "").upper(), "KG")
self.assertEqual(str(rows["空运费"].get("project") or ""), "空运段")
self.assertEqual(str(quote.get("total") or ""), "9900.0")
def test_air_origin_fee_underscore_unit_board(self) -> None:
"""WO008:起运港、增值费_板,单价… — 单位是板,不能落成票;项目是起运港。"""
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote(
"起运港、增值费_板,单价CNY10,数量100,金额CNY1000@询价小助手",
business_line="AIR",
)
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
self.assertIn("增值费", rows)
self.assertEqual(str(rows["增值费"].get("unit") or ""), "板")
self.assertEqual(str(rows["增值费"].get("unit_price") or ""), "10")
self.assertEqual(str(rows["增值费"].get("quantity") or ""), "100")
self.assertEqual(str(rows["增值费"].get("amount") or ""), "1000")
self.assertEqual(str(rows["增值费"].get("project") or ""), "起运港")
self.assertEqual(str(quote.get("total") or ""), "1000.0")
def test_air_origin_fee_mention_prefix_keeps_project_and_unit(self) -> None:
"""WO009:句首 @询价小助手 不能挡住起运港分段;逗号单位「板」不能丢。"""
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote(
"@询价小助手 起运港、增值费,板,单价CNY10,数量100,金额CNY1000",
business_line="AIR",
)
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
self.assertIn("增值费", rows)
self.assertEqual(str(rows["增值费"].get("unit") or ""), "板")
self.assertEqual(str(rows["增值费"].get("project") or ""), "起运港")
self.assertEqual(str(rows["增值费"].get("amount") or ""), "1000")
def test_fee_line_text_is_a_product_quote(self) -> None:
"""产品按行改价:码头操作费100CNY / 报关费改为300CNY,必须抽出费用名,不能只当海运费合计。"""
from agent.llm.extract_text import extract_product_quote
+1 -1
View File
@@ -495,7 +495,7 @@ class TestConfirmSystemException(unittest.TestCase):
"quoteId": "QT-AIR-1",
"airOptions": [{"optionNo": "AIR-OPT-01", "airlineCode": "CZ"}],
},
to_status="已报价",
to_status="已成交",
)
self.ledger.mark_system_exception(
work_order_no=air_wo,
@@ -12,6 +12,7 @@ import java.util.Set;
* 空运群「当前工单」能不能激活。纯内存规则,不写库。
* <p>
* 可激活:业务线 AIR,且六态询价中 / 已报价 / 协商中。
* 已成交只在锁舱补参数(allowDeal)时允许记到本群。
* 一张单同时只跟一个群;已经绑了别的群就 occupied。
*/
public final class AirGroupActivateRule {
@@ -27,13 +28,23 @@ public final class AirGroupActivateRule {
* @return 空串表示可以激活;否则是给智能体的错误码
*/
public static String rejectReason(InquiryTicket ticket, String chatId) {
return rejectReason(ticket, chatId, false);
}
/**
* 锁舱补参数时允许把已成交单记在本群,方便下一句不再带工单号。
* 未成交、已关闭、转人工仍然不能激活。普通激活不要传 allowDeal。
*/
public static String rejectReason(InquiryTicket ticket, String chatId, boolean allowDeal) {
if (ticket == null) {
return "ticket_not_found";
}
if (!"AIR".equalsIgnoreCase(StringUtils.trimToEmpty(ticket.getBusinessLine()))) {
return "not_air";
}
if (!OPEN.contains(StringUtils.trimToEmpty(ticket.getStatus()))) {
String status = StringUtils.trimToEmpty(ticket.getStatus());
boolean dealBind = allowDeal && TicketStatuses.DEAL.equals(status);
if (!OPEN.contains(status) && !dealBind) {
return "terminal";
}
String bound = StringUtils.trimToEmpty(ticket.getCollabChatId());
@@ -1043,6 +1043,22 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
out.put("customer_name", customer);
out.put("客户名称", customer);
// 陆运/中港表头「联系人」「电话」映射码是 contact_*;事实里常是中文键,必须投影否则格子只剩标签
String contactName = first(src, "联系人", "contact_name", "联系人姓名", "客户联系人", "contactName");
if (StringUtils.isNotBlank(contactName)) {
out.put("contact_name", contactName);
out.put("联系人", contactName);
}
String contactPhone = first(src, "电话", "联系电话", "contact_phone", "手机", "手机号", "contactPhone");
if (StringUtils.isNotBlank(contactPhone)) {
out.put("contact_phone", contactPhone);
out.put("电话", contactPhone);
}
String cargoValue = first(src, "货值", "货物价值", "cargo_value", "货值(元)", "cargoValue");
if (StringUtils.isNotBlank(cargoValue)) {
out.put("cargo_value", cargoValue);
out.put("货值", cargoValue);
}
boolean hideAirEta = looksAirInquiry(src, q, businessLine);
String eta = first(q, "eta", "transit_days", "transitDays");
if (!hideAirEta && StringUtils.isNotBlank(eta) && !"-".equals(eta)) {
@@ -1077,12 +1093,18 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
if (rows.isEmpty()) {
rows = QuoteTemplateExcelSupport.parseFeeRows(q.get("fee_rows"));
}
// 空运:已落库 quote 常只有一条 fee_lines,完整明细在 airOptions.departureCharges。
// 行数相同也优先 departureCharges(fee_lines 常把单价误当金额,缺单位)。
// 空运:完整明细在 airOptions 分桶 + 顶层 feeItems(空运段)。
// 行数相同也优先分桶解析(fee_lines 常把单价误当金额,缺单位/项目列)。
Object quoteFeeItems = q.get("feeItems");
if (quoteFeeItems == null) {
quoteFeeItems = q.get("fee_items");
}
JSONArray fromAir = QuoteTemplateExcelSupport.parseAirDepartureChargeRows(
q.get("airOptions"), q.get("air_options"), chargeable, currency);
q.get("airOptions"), q.get("air_options"), quoteFeeItems, chargeable, currency);
if (!fromAir.isEmpty()) {
rows = fromAir;
// 分桶优先,但 flat fee_lines 里多出来的人工调价(如「增值费」)必须并进模板,
// 禁止整表替换后把群摘要已展示的费用丢掉(WO202610140006)。
rows = QuoteTemplateExcelSupport.mergeAirBucketRowsWithFlatManualFees(fromAir, rows);
}
if (rows.isEmpty()) {
String freight = first(q, "freight");
@@ -1104,6 +1126,11 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
}
}
// 旧 payload 常丢 unit:从 raw_text 回填后再走默认「票」补全
if (!rows.isEmpty()) {
QuoteTemplateExcelSupport.backfillMissingFeeUnitsFromRawText(
rows, first(q, "raw_text", "rawText", "text"));
}
// 空运:补单位/数量/单价(手工常只有金额,WO020 空运费与增点费)
if (!rows.isEmpty()) {
List<Map<String, String>> lineMaps = new ArrayList<>();
@@ -1307,7 +1307,15 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
String room = text(body, "chatId", "chat_id");
InquiryTicket ticket = findByWorkOrderNo(no);
Map<String, Object> out = new LinkedHashMap<>();
String reject = AirGroupActivateRule.rejectReason(ticket, room);
Object rawAllow = body == null ? null : body.get("allowDeal");
if (rawAllow == null && body != null) {
rawAllow = body.get("allow_deal");
}
// 只有锁舱补参数才带这个标记。普通激活仍拒绝已成交。
boolean allowDeal = Boolean.TRUE.equals(rawAllow)
|| "true".equalsIgnoreCase(String.valueOf(rawAllow))
|| "1".equals(String.valueOf(rawAllow));
String reject = AirGroupActivateRule.rejectReason(ticket, room, allowDeal);
if (StringUtils.isNotBlank(reject)) {
out.put("ok", false);
out.put("error", reject);
@@ -32,11 +32,18 @@ public final class LandQuoteTemplateFields {
add("land_freight", "陆运费", "landFreight");
add("vehicle_42_fee", "4.2米/3T费用", "vehicle42Fee");
add("vehicle_50_fee", "5米/5T费用", "vehicle50Fee");
add("vehicle_68_fee", "6.8/5T费用", "vehicle68Fee");
add("vehicle_65_fee", "6.5米/8T费用", "vehicle65Fee");
add("vehicle_76_fee", "7.6米/8T费用", "vehicle76Fee");
add("vehicle_78_fee", "7.8米/10T费用", "vehicle78Fee");
add("vehicle_82_fee", "8.2米/10T费用", "vehicle82Fee");
add("vehicle_82_12_fee", "8.2米/12T费用", "vehicle8212Fee");
add("vehicle_96_fee", "9.6米/10T费用", "vehicle96Fee");
add("vehicle_59_fee", "5.9米/20尺费用", "vehicle59Fee");
add("vehicle_12_fee", "12米/40尺费用", "vehicle12Fee");
add("vehicle_125_fee", "12.5米/40尺费用", "vehicle125Fee");
add("vehicle_135_fee", "13.5米/45尺费用", "vehicle135Fee");
add("vehicle_15_reefer_fee", "15米/冷柜费用", "vehicle15ReeferFee");
add("vehicle_165_fee", "16.5米/53尺费用", "vehicle165Fee");
add("vehicle_3t_fee", "4.1*2.1*2.3M/3T费用", "vehicle3TFee");
add("vehicle_5t_fee", "6.5*2.4*2.5M/5T费用", "vehicle5TFee");
@@ -51,9 +58,25 @@ public final class LandQuoteTemplateFields {
LABEL_TO_CODE.put("时效", "transit_days");
LABEL_TO_CODE.put("干线费", "land_freight");
LABEL_TO_CODE.put("运费", "land_freight");
// 20261012 国内整车 / 中港:短表头(无「费用」后缀)也要能对上填表码
LABEL_TO_CODE.put("4.2米/3T", "vehicle_42_fee");
LABEL_TO_CODE.put("5米/5T", "vehicle_50_fee");
LABEL_TO_CODE.put("6.8/5T", "vehicle_68_fee");
LABEL_TO_CODE.put("6.8米/5T", "vehicle_68_fee");
LABEL_TO_CODE.put("6.5米/8T", "vehicle_65_fee");
LABEL_TO_CODE.put("7.6米/8T", "vehicle_76_fee");
LABEL_TO_CODE.put("7.8米/10T", "vehicle_78_fee");
LABEL_TO_CODE.put("8.2米/10T", "vehicle_82_fee");
LABEL_TO_CODE.put("8.2米/12T", "vehicle_82_12_fee");
LABEL_TO_CODE.put("9.6米/10T", "vehicle_96_fee");
LABEL_TO_CODE.put("5.9米/20尺", "vehicle_59_fee");
LABEL_TO_CODE.put("12米/40尺", "vehicle_12_fee");
LABEL_TO_CODE.put("12.5米/40尺", "vehicle_125_fee");
LABEL_TO_CODE.put("13.5米/45尺", "vehicle_135_fee");
LABEL_TO_CODE.put("15米/冷柜", "vehicle_15_reefer_fee");
LABEL_TO_CODE.put("16.5米/53尺", "vehicle_165_fee");
LABEL_TO_CODE.put("13.75米平板", "vehicle_1375_fee");
LABEL_TO_CODE.put("17.5米平板", "vehicle_175_fee");
LABEL_TO_CODE.put("元/KG", "price_per_kg");
}
@@ -32,7 +32,7 @@ import java.util.Map;
* 有真实 TMS 编号才带 quoteId。有值才出站,缺航班不拦。
* SYSTEM 出站精简(quoteId + 航班等标量,不带 validUntil/feeItems);
* MANUAL 费用名按 TMS 查价回包原样带回,有效期必须带时区 ISO8601。
* 已成交再释放:本地拦住。测服打开 TMS_V2_LOCK_ENABLED;现网验收期保持关闭。
* 只有已成交才能锁舱。成交并锁上之后仍可释放。测服打开 TMS_V2_LOCK_ENABLED;现网验收期保持关闭。
* 禁止:按 userid 伪造报价;单测打真实 TMS。
*/
@Slf4j
@@ -55,6 +55,27 @@ public class InquiryTmsCabinService {
/** 多段询价锁舱的产品口径。智能体群回复与主账拒绝用同一句。 */
public static final String MULTI_LOCK_UNSUPPORTED = "多段询价,暂时不支持锁舱";
/**
* 锁舱成交门禁。已成交返回空串,表示可以继续拼锁舱正文。
* 协商中、未成交、已关闭/转人工、以及还没回复成交,各自一个错误码。
*/
static String lockDealGate(String status) {
String st = StringUtils.trimToEmpty(status);
if (TicketStatuses.DEAL.equals(st)) {
return "";
}
if (TicketStatuses.NEGOTIATING.equals(st)) {
return "lock_negotiating";
}
if (TicketStatuses.LOST.equals(st)) {
return "lock_lost";
}
if (TicketStatuses.CLOSED.equals(st) || TicketStatuses.HANDOFF.equals(st)) {
return "ticket_closed";
}
return "lock_need_deal";
}
/**
* 还没到 TMS 之前的拒绝原因。空运单返回空,表示可以继续锁。
* 多段整单不是 AIR,但航线会按空运段来锁,单独说明暂不支持。
@@ -86,9 +107,18 @@ public class InquiryTmsCabinService {
String code = MULTI_LOCK_UNSUPPORTED.equals(lockReject) ? "multi_no_lock" : "not_air";
return fail(code, lockReject);
}
if (TicketStatuses.DEAL.equals(ticket.getStatus())
|| TicketStatuses.CLOSED.equals(ticket.getStatus())) {
return fail("ticket_closed", "工单已是「" + ticket.getStatus() + "」,不能锁舱");
String dealGate = lockDealGate(ticket.getStatus());
if ("lock_negotiating".equals(dealGate)) {
return fail(dealGate, "工单还在协商中,成交后才能锁舱");
}
if ("lock_lost".equals(dealGate)) {
return fail(dealGate, "工单已是「未成交」,不能锁舱");
}
if ("ticket_closed".equals(dealGate)) {
return fail(dealGate, "工单已是「" + ticket.getStatus() + "」,不能锁舱");
}
if ("lock_need_deal".equals(dealGate)) {
return fail(dealGate, "请销售先确认工单「已成交」");
}
List<InquiryQuote> rows = quoteRows(no);
if (rows.isEmpty()) {
@@ -279,7 +309,7 @@ public class InquiryTmsCabinService {
}
/**
* 智能体释放入口。已成交不调 TMS。
* 智能体释放入口。已成交仍可取消已锁舱位。没有成功锁舱记录则不调 TMS。
*/
public Map<String, Object> releaseCabin(Map<String, Object> body) {
String no = text(body, "workOrderNo", "work_order_no");
@@ -288,9 +318,6 @@ public class InquiryTmsCabinService {
if (ticket == null) {
return fail("ticket_not_found", "找不到工单");
}
if (TicketStatuses.DEAL.equals(ticket.getStatus())) {
return fail("deal_blocked", "工单已成交");
}
if (StringUtils.isBlank(lockId)) {
return fail("release_need_lock", "本单还没有成功锁舱记录");
}
@@ -1442,6 +1469,10 @@ public class InquiryTmsCabinService {
return out;
}
/**
* 调试会话 3ba460:手工报价锁舱死循环取证。写 NDJSON,不含密钥。
*/
private static String text(Map<String, Object> body, String... keys) {
if (body == null || keys == null) {
return "";
@@ -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;
}
@@ -786,8 +798,9 @@ public class InquiryTmsQueryService {
}
/**
* 空运舱位选项上的 departureCharges → 扁平费用行。
* TMS 字段:chargeName / unitPrice / unit / currency;行金额出单时再按计费重补。
* 空运舱位分桶费用 → 扁平费用行。
* TMS:feeItems=空运段,departureCharges=起运港,destinationCharges=目的地;
* 行金额出单时再按计费重补(仅空运段)。
*/
@SuppressWarnings("unchecked")
static List<Map<String, String>> extractAirDepartureCharges(Map<String, Object> data, String headerCurrency) {
@@ -806,15 +819,21 @@ public class InquiryTmsQueryService {
if (!(raw instanceof List)) {
return out;
}
Object topFeeItems = first(data.get("feeItems"), data.get("fee_items"));
for (Object optObj : (List<?>) raw) {
if (!(optObj instanceof Map)) {
continue;
}
Map<?, ?> opt = (Map<?, ?>) optObj;
// 合并 departureCharges + feeItems,避免只取附加费丢掉空运费
List<Map<?, ?>> charges = mergeAirOptionChargeMaps(opt);
Map<Object, Object> opt = new LinkedHashMap<>((Map<?, ?>) optObj);
// 舱位缺 feeItems 时补整单顶层(TMS 常把空运费放在 data.feeItems)
if (topFeeItems instanceof List
&& !(opt.get("feeItems") instanceof List)
&& !(opt.get("fee_items") instanceof List)) {
opt.put("feeItems", topFeeItems);
}
List<Map<String, Object>> charges = mergeAirOptionChargeMaps(opt);
String optCcy = firstText(opt.get("currency"), headerCurrency);
for (Map<?, ?> ch : charges) {
for (Map<String, Object> ch : charges) {
if (ch == null) {
continue;
}
@@ -823,18 +842,24 @@ public class InquiryTmsQueryService {
if (StringUtils.isBlank(name) || StringUtils.isBlank(unitPrice)) {
continue;
}
String project = firstText(ch.get("project"), "其它");
String amountRaw = firstText(ch.get("amount"), unitPrice);
String lineCcy = firstText(ch.get("currency"), optCcy, headerCurrency);
Map<String, String> extras = new LinkedHashMap<>();
putExtra(extras, "unit", firstText(ch.get("unit"), ch.get("unitName")));
putExtra(extras, "unit_price", unitPrice);
// 固定费无数量时默认 1;运输费 enrich 会改成计费重
putExtra(extras, "project", project);
String qty = firstText(ch.get("quantity"), ch.get("chargeQuantity"), ch.get("charge_quantity"));
putExtra(extras, "quantity", StringUtils.isBlank(qty) ? "1" : qty);
putExtra(extras, "project", isAirWeightFeeName(name) ? "空运段" : "起运港");
// 先把单价写入 amount;运输费会在 enrich 里改成 单价×计费重
addFeeLine(out, name, unitPrice, lineCcy, extras);
boolean airSeg = "空运段".equals(project) && isAirWeightFeeName(name);
if (StringUtils.isNotBlank(qty)) {
putExtra(extras, "quantity", qty);
} else if (!airSeg) {
putExtra(extras, "quantity", "1");
}
// 空运段先写金额原文(或单价);enrich 再按计费重重算
addFeeLine(out, name, amountRaw, lineCcy, extras);
}
// departureCharges 只有附加费时,用舱位 salePrice 补空运费(与出单 parse 对齐)
// 无空运段运费时,用舱位 salePrice 补空运费(与出单 parse 对齐)
injectAirFreightFeeLineIfMissing(out, opt, headerCurrency);
// 通常只用首个舱位选项的费用明细
if (!out.isEmpty()) {
@@ -844,15 +869,31 @@ public class InquiryTmsQueryService {
return out;
}
/** 合并舱位 departureCharges / feeItems,同名去重。 */
/**
* 按 TMS 桶合并:feeItems→空运段,departureCharges→起运港,destinationCharges→目的地。
* 同名去重,先出现的桶优先。
*/
@SuppressWarnings("unchecked")
static List<Map<?, ?>> mergeAirOptionChargeMaps(Map<?, ?> opt) {
List<Map<?, ?>> out = new ArrayList<>();
static List<Map<String, Object>> mergeAirOptionChargeMaps(Map<?, ?> opt) {
List<Map<String, Object>> out = new ArrayList<>();
if (opt == null) {
return out;
}
Set<String> seen = new HashSet<>();
for (Object key : new Object[]{"departureCharges", "departure_charges", "charges", "feeItems", "fee_items"}) {
appendAirBucketMaps(out, seen, opt, "空运段", "feeItems", "fee_items");
appendAirBucketMaps(out, seen, opt, "起运港", "departureCharges", "departure_charges", "charges");
appendAirBucketMaps(out, seen, opt, "目的地",
"destinationCharges", "destination_charges", "arrivalCharges", "destCharges");
return out;
}
@SuppressWarnings("unchecked")
private static void appendAirBucketMaps(List<Map<String, Object>> out, Set<String> seen,
Map<?, ?> opt, String project, String... keys) {
if (out == null || opt == null || keys == null) {
return;
}
for (String key : keys) {
Object raw = opt.get(key);
if (!(raw instanceof List)) {
continue;
@@ -870,22 +911,34 @@ public class InquiryTmsQueryService {
if (!seen.add(dedupe)) {
continue;
}
out.add(ch);
Map<String, Object> copy = new LinkedHashMap<>();
for (Map.Entry<?, ?> e : ch.entrySet()) {
if (e.getKey() != null) {
copy.put(String.valueOf(e.getKey()), e.getValue());
}
}
copy.put("project", project);
out.add(copy);
}
}
return out;
}
/**
* 查价投影 fee_lines:明细无运输/空运费时,用舱位 salePrice/unitPrice 注入。
* 查价投影 fee_lines:明细无空运段运费时,用舱位 salePrice/unitPrice 注入。
* 与 QuoteTemplateExcelSupport.injectAirFreightLineIfMissing 口径一致。
* 起运港「运输费」不算已有空运段。
*/
static void injectAirFreightFeeLineIfMissing(List<Map<String, String>> lines, Map<?, ?> opt, String headerCurrency) {
if (lines == null || opt == null) {
return;
}
for (Map<String, String> line : lines) {
if (line != null && isAirWeightFeeName(line.get("name"))) {
if (line == null) {
continue;
}
String project = StringUtils.trimToEmpty(line.get("project"));
if ((project.isEmpty() || "空运段".equals(project) || "空运".equals(project))
&& isAirWeightFeeName(line.get("name"))) {
return;
}
}
@@ -950,16 +1003,22 @@ public class InquiryTmsQueryService {
if (line == null || !isAirWeightFeeName(line.get("name"))) {
continue;
}
// 已标明非空运段(起运港「运输费」等)禁止乘计费重
String project = StringUtils.trimToEmpty(line.get("project"));
if (StringUtils.isNotBlank(project)
&& !"空运段".equals(project) && !"空运".equals(project)) {
continue;
}
String unitPrice = firstText(line.get("unit_price"), line.get("unitPrice"));
String qty = firstText(line.get("quantity"));
String amount = firstText(line.get("amount"));
String unit = firstText(line.get("unit"));
if (StringUtils.isBlank(qty)) {
line.put("quantity", chargeable);
qty = chargeable;
}
// TMS 常给单位「板数」;按计费重计价时统一写成 KG
line.put("unit", "KG");
line.put("project", "空运段");
// 有数量、无单价:当前 amount 当作单价(TMS 把费率塞进 salePrice/amount)
if (StringUtils.isBlank(unitPrice) && StringUtils.isNotBlank(amount)) {
line.put("unit_price", amount);
@@ -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';
@@ -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}}
@@ -26,6 +26,9 @@ public class AirGroupActivateRuleTest {
public void dealRejected() {
InquiryTicket ticket = ticket("AIR", TicketStatuses.DEAL, "wr_air_1");
assertEquals("terminal", AirGroupActivateRule.rejectReason(ticket, "wr_air_1"));
assertEquals("", AirGroupActivateRule.rejectReason(ticket, "wr_air_1", true));
InquiryTicket lost = ticket("AIR", TicketStatuses.LOST, "");
assertEquals("terminal", AirGroupActivateRule.rejectReason(lost, "wr_air_1", true));
}
@Test
@@ -1,5 +1,6 @@
package org.jeecg.modules.inquiry.template;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import org.apache.poi.ss.usermodel.DataFormatter;
@@ -920,6 +921,180 @@ public class QuoteTemplateFillTest {
assertEquals("起运港", rows.getJSONObject(2).getString("project"));
}
/**
* TMS 分桶:feeItems→空运段,departureCharges→起运港(含名称像「运输费」的),
* destinationCharges→目的地;起运「运输费」不得乘计费重把金额算成单价。
*/
@Test
public void parseAirChargesAssignProjectByTmsBucket() {
System.setProperty("ytd.agent.debug.log",
java.nio.file.Paths.get("").toAbsolutePath().getParent() != null
? java.nio.file.Paths.get("debug-4c500b.log").toAbsolutePath().toString()
: "debug-4c500b.log");
System.setProperty("ytd.agent.debug.runId", "pre-fix-bucket");
String airOptions = "["
+ "{\"optionNo\":\"AIR-OPT01\",\"salePrice\":183370.0,\"currency\":\"CNY\","
+ "\"chargeableWeightKg\":16670.0,"
+ "\"feeItems\":[{\"feeCode\":\"AIR_FREIGHT\",\"feeName\":\"空运费\","
+ "\"unitPrice\":11.0,\"quantity\":16670.0,\"amount\":183370.0,\"unit\":\"KG\",\"currency\":\"CNY\"}],"
+ "\"departureCharges\":["
+ "{\"chargeName\":\"运输费\",\"unitPrice\":11.0,\"unit\":\"板数\",\"currency\":\"CNY\"},"
+ "{\"chargeName\":\"提送货费\",\"unitPrice\":6.0,\"quantity\":1,\"unit\":\"板数\",\"currency\":\"CNY\"},"
+ "{\"chargeName\":\"人工费\",\"unitPrice\":123.0,\"quantity\":1,\"unit\":\"柜\",\"currency\":\"CNY\"}"
+ "],"
+ "\"destinationCharges\":["
+ "{\"chargeName\":\"目的港操作费\",\"unitPrice\":50.0,\"quantity\":1,\"unit\":\"票\",\"currency\":\"CNY\"}"
+ "]}]";
JSONArray rows = QuoteTemplateExcelSupport.parseAirDepartureChargeRows(
airOptions, null, "16670", "CNY");
assertEquals(5, rows.size());
// WO006:flat 有增值费、分桶没有 → 合并后必须进起运港,不能被整表替换丢掉
JSONArray flat = JSON.parseArray(
"[{\"name\":\"运输费\",\"amount\":\"11\"},"
+ "{\"name\":\"提送货费\",\"amount\":\"6\"},"
+ "{\"name\":\"人工费\",\"amount\":\"123\"},"
+ "{\"name\":\"增值费\",\"amount\":\"1000\",\"unit\":\"板\",\"quantity\":\"100\","
+ "\"unit_price\":\"10\",\"project\":\"起运港\"}]");
JSONArray merged = QuoteTemplateExcelSupport.mergeAirBucketRowsWithFlatManualFees(rows, flat);
boolean hasZz = false;
for (int i = 0; i < merged.size(); i++) {
JSONObject one = merged.getJSONObject(i);
if (one != null && "增值费".equals(one.getString("name"))) {
hasZz = true;
assertEquals("起运港", one.getString("project"));
assertEquals("1000", one.getString("amount"));
assertEquals("板", one.getString("unit"));
}
}
assertTrue("人工增值费必须并进分桶结果", hasZz);
assertEquals("空运费", rows.getJSONObject(0).getString("name"));
assertEquals("空运段", rows.getJSONObject(0).getString("project"));
assertTrue(rows.getJSONObject(0).getString("amount").startsWith("183370"));
assertEquals("运输费", rows.getJSONObject(1).getString("name"));
assertEquals("起运港", rows.getJSONObject(1).getString("project"));
assertEquals("1", rows.getJSONObject(1).getString("quantity"));
assertTrue(rows.getJSONObject(1).getString("amount").startsWith("11"));
assertEquals("提送货费", rows.getJSONObject(2).getString("name"));
assertEquals("起运港", rows.getJSONObject(2).getString("project"));
assertEquals("人工费", rows.getJSONObject(3).getString("name"));
assertEquals("起运港", rows.getJSONObject(3).getString("project"));
assertEquals("目的港操作费", rows.getJSONObject(4).getString("name"));
assertEquals("目的地", rows.getJSONObject(4).getString("project"));
}
/**
* 顶层 feeItems + 舱位 departureCharges:空运段来自整单 feeItems,起运港来自舱位。
*/
@Test
public void parseAirChargesUsesTopLevelFeeItemsAsAirSegment() {
String airOptions = "["
+ "{\"optionNo\":\"AIR-OPT01\",\"salePrice\":183370.0,\"currency\":\"CNY\","
+ "\"chargeableWeightKg\":16670.0,"
+ "\"departureCharges\":["
+ "{\"chargeName\":\"运输费\",\"unitPrice\":11.0,\"unit\":\"板数\",\"currency\":\"CNY\"},"
+ "{\"chargeName\":\"提送货费\",\"unitPrice\":6.0,\"unit\":\"板数\",\"currency\":\"CNY\"},"
+ "{\"chargeName\":\"人工费\",\"unitPrice\":123.0,\"unit\":\"柜\",\"currency\":\"CNY\"}"
+ "]}]";
String topFees = "[{\"feeCode\":\"AIR_FREIGHT\",\"feeName\":\"空运费\","
+ "\"unitPrice\":11.0,\"quantity\":16670.0,\"amount\":183370.0,\"unit\":\"KG\",\"currency\":\"CNY\"}]";
JSONArray rows = QuoteTemplateExcelSupport.parseAirDepartureChargeRows(
airOptions, null, topFees, "16670", "CNY");
assertEquals("空运费", rows.getJSONObject(0).getString("name"));
assertEquals("空运段", rows.getJSONObject(0).getString("project"));
assertEquals("运输费", rows.getJSONObject(1).getString("name"));
assertEquals("起运港", rows.getJSONObject(1).getString("project"));
// 起运「运输费」不得再被当成空运段去乘计费重
assertFalse("16670".equals(rows.getJSONObject(1).getString("quantity"))
|| "16670.0".equals(rows.getJSONObject(1).getString("quantity")));
}
/**
* 人工只报起运港费:必须落到模板「起运港」段,禁止占掉「空运段」首行并把骨架改成起运港。
*/
@Test
public void airManualOriginFeeWritesIntoOriginBandNotAirSegment() throws Exception {
System.setProperty("ytd.agent.debug.log",
new java.io.File("debug-4c500b.log").getAbsolutePath());
System.setProperty("ytd.agent.debug.runId", "post-fix-place");
Path src = Files.createTempFile("quote-air-band-src", ".xlsx");
Path out = Files.createTempFile("quote-air-band-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("Sheet2");
org.apache.poi.ss.usermodel.Row head = sheet.createRow(15);
head.createCell(0).setCellValue("项目");
head.createCell(1).setCellValue("费用名称");
head.createCell(2).setCellValue("单位");
head.createCell(3).setCellValue("币别");
head.createCell(4).setCellValue("单价");
head.createCell(5).setCellValue("数量");
head.createCell(6).setCellValue("金额");
org.apache.poi.ss.usermodel.Row r1 = sheet.createRow(16);
r1.createCell(0).setCellValue("空运段");
r1.createCell(1).setCellValue("样例空运费");
r1.createCell(6).setCellValue(1.0);
org.apache.poi.ss.usermodel.Row r2 = sheet.createRow(17);
r2.createCell(0).setCellValue("起运港");
r2.createCell(1).setCellValue("样例增点");
r2.createCell(6).setCellValue(1.0);
org.apache.poi.ss.usermodel.Row r3 = sheet.createRow(18);
r3.createCell(0).setCellValue("起运港");
r3.createCell(1).setCellValue("样例2");
org.apache.poi.ss.usermodel.Row r4 = sheet.createRow(19);
r4.createCell(0).setCellValue("其他");
org.apache.poi.ss.usermodel.Row total = sheet.createRow(22);
total.createCell(0).setCellValue("预估费用");
total.createCell(6).setCellFormula("SUM(G17:G22)");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject();
mapping.put("fields", new JSONObject());
JSONObject region = new JSONObject(true);
region.put("id", "air_fee_detail");
region.put("anchor", "Sheet2!A17");
region.put("stopAtText", java.util.Collections.singletonList("预估费用"));
JSONObject cols = new JSONObject(true);
cols.put("project", "A");
cols.put("name", "B");
cols.put("unit", "C");
cols.put("currency", "D");
cols.put("unit_price", "E");
cols.put("quantity", "F");
cols.put("amount", "G");
region.put("columns", cols);
mapping.put("feeRegions", new JSONArray(java.util.Collections.singletonList(region)));
JSONObject facts = new JSONObject(true);
facts.put("total_amount", "1000");
facts.put("currency", "CNY");
JSONArray feeRows = new JSONArray();
JSONObject one = new JSONObject(true);
one.put("project", "起运港");
one.put("name", "增值费");
one.put("unit", "板");
one.put("unit_price", "10");
one.put("quantity", "100");
one.put("amount", "1000");
one.put("currency", "CNY");
feeRows.add(one);
facts.put("fee_rows", feeRows);
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook got = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = got.getSheet("Sheet2");
DataFormatter fmt = new DataFormatter();
// 空运段骨架保留,样例费目清掉
assertEquals("空运段", fmt.formatCellValue(sheet.getRow(16).getCell(0)));
assertTrue(StringUtils.isBlank(fmt.formatCellValue(sheet.getRow(16).getCell(1))));
// 增值费落在起运港段,不在首行
assertEquals("起运港", fmt.formatCellValue(sheet.getRow(17).getCell(0)));
assertEquals("增值费", fmt.formatCellValue(sheet.getRow(17).getCell(1)));
assertEquals("1000", fmt.formatCellValue(sheet.getRow(17).getCell(6)));
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* 映射未标 project 列时,表头「项目」仍要写入,增点费不能留在模板样例「空运段」。
*/
@@ -2028,11 +2203,12 @@ public class QuoteTemplateFillTest {
Sheet sheet = filled.getSheetAt(0);
assertEquals("4.1*2.1*2.3M", read(sheet, 8, 3));
assertEquals("广东省-深圳市", read(sheet, 9, 1));
assertEquals("", read(sheet, 9, 2));
// 产品口径 A:单腿门到门两段都写完整起讫
assertEquals("谅山-春疆货场", read(sheet, 9, 2));
assertEquals("4000", normalizeMoney(read(sheet, 9, 3)));
assertEquals("", read(sheet, 9, 4));
assertEquals("2.8x1.5x1.6M", read(sheet, 27, 3));
assertEquals("", read(sheet, 28, 1));
assertEquals("广东省-深圳市", read(sheet, 28, 1));
assertEquals("谅山-春疆货场", read(sheet, 28, 2));
assertEquals("", read(sheet, 28, 3));
assertEquals("", read(sheet, 28, 4));
@@ -2079,7 +2255,96 @@ public class QuoteTemplateFillTest {
}
/**
* 跨境整车国内段只写境内起点,境外段只写境外终点,不能两段都写成郑州→吉隆坡。
* WO202610140014 复现:单腿广西→胡志明;模板 3T 有尺寸 vs 无尺寸。
* 用于对照 debug-67d875:切片是否把目的地/起始地留空,以及填表是否清掉 3T 尺寸。
*/
@Test
public void landCrossBorderFtlWo014OdAnd3tDimProbe() throws Exception {
System.setProperty("ytd.agent.debug.runId", "wo014-junit");
Path src = Files.createTempFile("quote-land-wo014-src", ".xlsx");
Path out = Files.createTempFile("quote-land-wo014-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("跨境整车");
row(sheet, 5).createCell(0).setCellValue("一、国内段报价");
org.apache.poi.ss.usermodel.Row head1 = row(sheet, 7);
head1.createCell(1).setCellValue("起始地");
head1.createCell(2).setCellValue("目的地");
head1.createCell(3).setCellValue("3T");
head1.createCell(4).setCellValue("5T");
org.apache.poi.ss.usermodel.Row dim1 = row(sheet, 8);
// 有尺寸:应保留;同时留一列空尺寸模拟「模板缺 3T 尺寸」时对照
dim1.createCell(3).setCellValue("4.1*2.1*2.3M");
dim1.createCell(4).setCellValue("6.5*2.4*2.5M");
row(sheet, 24).createCell(0).setCellValue("二、境外报价");
org.apache.poi.ss.usermodel.Row head2 = row(sheet, 26);
head2.createCell(1).setCellValue("起始地");
head2.createCell(2).setCellValue("目的地");
head2.createCell(3).setCellValue("3.5T");
org.apache.poi.ss.usermodel.Row dim2 = row(sheet, 27);
dim2.createCell(3).setCellValue("4.7x1.8x1.8M");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject(true);
mapping.put("sheet", "跨境整车");
JSONArray regions = new JSONArray();
JSONObject inland = new JSONObject(true);
inland.put("title", "国内段报价");
inland.put("headerRow", 8);
inland.put("dataStartRow", 9);
JSONObject cols1 = new JSONObject(true);
cols1.put("origin", "B");
cols1.put("destination", "C");
JSONObject rates1 = new JSONObject(true);
rates1.put("3T", "D");
rates1.put("5T", "E");
cols1.put("vehicleRates", rates1);
inland.put("columns", cols1);
regions.add(inland);
JSONObject overseas = new JSONObject(true);
overseas.put("title", "境外报价");
overseas.put("headerRow", 27);
overseas.put("dataStartRow", 28);
JSONObject cols2 = new JSONObject(true);
cols2.put("origin", "B");
cols2.put("destination", "C");
JSONObject rates2 = new JSONObject(true);
rates2.put("3.5T", "D");
cols2.put("vehicleRates", rates2);
overseas.put("columns", cols2);
regions.add(overseas);
mapping.put("routeRegions", regions);
JSONObject facts = new JSONObject(true);
facts.put("origin", "广西壮族自治区");
facts.put("destination", "胡志明");
facts.put("currency", "CNY");
facts.put("route_rows",
"[{\"origin\":\"广西壮族自治区\",\"destination\":\"胡志明\","
+ "\"vehicleType\":\"3T\",\"amount\":\"12500.00\",\"currency\":\"CNY\","
+ "\"segmentSequence\":\"1\",\"routeLegSequence\":\"1\"}]");
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
// 有尺寸时应保留(排除「填表清掉尺寸」)
// 3T/5T 尺寸副表头必须保留(后台模板有值,出单不能清掉)
assertEquals("4.1*2.1*2.3M", read(sheet, 8, 3));
assertEquals("6.5*2.4*2.5M", read(sheet, 8, 4));
assertEquals("广西壮族自治区", read(sheet, 9, 1));
// 产品口径 A:国内段/境外段都写完整广西→胡志明
assertEquals("胡志明", read(sheet, 9, 2));
assertEquals("12500", normalizeMoney(read(sheet, 9, 3)));
assertEquals("广西壮族自治区", read(sheet, 28, 1));
assertEquals("胡志明", read(sheet, 28, 2));
assertEquals("4.7x1.8x1.8M", read(sheet, 27, 3));
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* 跨境整车单腿门到门:国内段与境外段都写完整起讫(产品口径 A)。
*/
@Test
public void landCrossBorderFtlSplitsDomesticAndOverseasOd() throws Exception {
@@ -2143,9 +2408,9 @@ public class QuoteTemplateFillTest {
Sheet sheet = filled.getSheetAt(0);
assertEquals("起始地", read(sheet, 7, 1));
assertEquals("郑州", read(sheet, 8, 1));
assertEquals("", read(sheet, 8, 2));
assertEquals("吉隆坡", read(sheet, 8, 2));
assertEquals("起始地", read(sheet, 26, 1));
assertEquals("", read(sheet, 27, 1));
assertEquals("郑州", read(sheet, 27, 1));
assertEquals("吉隆坡", read(sheet, 27, 2));
assertEquals("N+3", read(sheet, 8, 15));
assertEquals("CNY", read(sheet, 8, 16));
@@ -2369,9 +2634,9 @@ public class QuoteTemplateFillTest {
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertEquals("起始地", read(sheet, 6, 1));
// 国内段只写境内起点;春疆货场是境外终点,不能被「运输费」盖掉起点
// 产品口径 A:单腿门到门两段都写完整起讫;运输费不能盖掉起点
assertEquals("深圳", read(sheet, 8, 1));
assertEquals("", read(sheet, 8, 2));
assertEquals("春疆货场", read(sheet, 8, 2));
assertEquals("N+1", read(sheet, 8, 13));
assertEquals("CNY", read(sheet, 8, 14));
assertEquals("8%", read(sheet, 8, 15));
@@ -2380,8 +2645,7 @@ public class QuoteTemplateFillTest {
assertFalse("运输费".equals(read(sheet, 8, 1)));
assertEquals("币种", read(sheet, 6, 14));
assertEquals("燃油附加费率", read(sheet, 5, 15));
// 境外段只写境外终点
assertEquals("", read(sheet, 27, 1));
assertEquals("深圳", read(sheet, 27, 1));
assertEquals("春疆货场", read(sheet, 27, 2));
} finally {
Files.deleteIfExists(src);
@@ -15,6 +15,17 @@ import static org.junit.jupiter.api.Assertions.assertNull;
*/
public class InquiryTmsCabinServiceTest {
@Test
public void lockOnlyAfterDeal() {
assertEquals("", InquiryTmsCabinService.lockDealGate("已成交"));
assertEquals("lock_need_deal", InquiryTmsCabinService.lockDealGate("已报价"));
assertEquals("lock_need_deal", InquiryTmsCabinService.lockDealGate("询价中"));
assertEquals("lock_negotiating", InquiryTmsCabinService.lockDealGate("协商中"));
assertEquals("lock_lost", InquiryTmsCabinService.lockDealGate("未成交"));
assertEquals("ticket_closed", InquiryTmsCabinService.lockDealGate("已关闭"));
assertEquals("ticket_closed", InquiryTmsCabinService.lockDealGate("转人工"));
}
@Test
public void multiInquiryCannotLockCabin() {
assertEquals(
@@ -366,14 +366,19 @@ public class TmsQuoteCurrencyTest {
tms.put("tmsData", data);
Map<String, String> quote = InquiryTmsQueryService.toAgentQuote(tms);
String fees = quote.get("fee_lines");
// 起运港桶保留运输费/提送货/人工;空运段由 salePrice 注入空运费
assertTrue(fees.contains("运输费"));
assertTrue(fees.contains("提送货费"));
assertTrue(fees.contains("人工费"));
assertTrue(fees.contains("\"amount\":\"183370\""));
assertTrue(fees.contains("空运费"));
assertTrue(fees.contains("\"project\":\"空运段\""));
assertTrue(fees.contains("\"project\":\"起运港\""));
assertTrue(fees.contains("\"amount\":\"183370\"") || fees.contains("\"amount\":\"183370.0\""));
assertTrue(fees.contains("\"amount\":\"6.0\"") || fees.contains("\"amount\":\"6\""));
assertTrue(fees.contains("\"amount\":\"123.0\"") || fees.contains("\"amount\":\"123\""));
assertTrue(fees.contains("\"unit\":\"KG\""));
assertTrue(fees.contains("\"quantity\":\"16670.0000\""));
assertTrue(fees.contains("\"quantity\":\"16670\"") || fees.contains("\"quantity\":\"16670.0\"")
|| fees.contains("\"quantity\":\"16670.0000\""));
}
@Test
@@ -24,9 +24,10 @@ export {
/**
* 按当前筛选导出账号 Excel(Jeecg /sys/user/exportXls,与列表 QueryGenerator 同源)。
* 文件名兜底用中文:Jeecg AutoPoi 的 Content-Disposition 常把「用户列表」编成乱码。
*/
export const exportAccounts = (params: Record<string, any>) =>
downloadAuthGet(getExportUrl, params, '账号列表.xlsx');
downloadAuthGet(getExportUrl, params, '账号列表.xls');
export type AccountRow = {
id: string;
@@ -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,
},
{
@@ -1,17 +1,87 @@
/**
* 带鉴权下载二进制文件(Excel 等)。
* 为何:window.open 带 token 遇 JSON 错误页难提示;统一走 fetch + blob。
* 文件名:优先 RFC5987 filename*=UTF-8'';Jeecg AutoPoi 常把 UTF-8 字节塞进 filename=,
* 浏览器会显示成「ç…」乱码,需 latin1→utf8 还原,还原失败则用 fallback。
*/
import { useGlobSetting } from '/@/hooks/setting';
import { getToken } from '/@/utils/auth';
export type AuthDownloadParams = Record<string, string | number | undefined | null>;
/**
* 从 Content-Disposition 解析下载文件名。
* @param cd Content-Disposition 头
* @param fallbackName 解析失败或仍乱码时使用
*/
export function parseDownloadFilename(cd: string, fallbackName: string): string {
const header = String(cd || '');
if (!header) return fallbackName;
const star = /filename\*=(?:UTF-8''|utf-8'')([^;]+)/i.exec(header);
if (star?.[1]) {
try {
return decodeURIComponent(star[1].trim().replace(/^"+|"+$/g, ''));
} catch {
/* 继续试 filename= */
}
}
const plain = /filename=\"?([^\";]+)\"?/i.exec(header);
if (!plain?.[1]) return fallbackName;
let name = plain[1].trim();
try {
name = decodeURIComponent(name);
} catch {
/* 非百分号编码则原样 */
}
const recovered = recoverUtf8Mojibake(name);
if (recovered) {
name = recovered;
}
// 仍无汉字且 fallback 有中文 → 用业务侧给定名(账号导出「账号列表.xls」)
if (looksLikeMojibake(name) && /[\u4e00-\u9fff]/.test(fallbackName)) {
return fallbackName;
}
return name || fallbackName;
}
/**
* UTF-8 字节被当成 Latin-1/ISO-8859-1 字符时的还原。
* 「用户列表」→「稅」这类乱码可恢复。
*/
function recoverUtf8Mojibake(s: string): string | null {
try {
const bytes = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) {
const code = s.charCodeAt(i);
if (code > 255) return null;
bytes[i] = code;
}
const decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
if (/[\u4e00-\u9fff]/.test(decoded)) {
return decoded;
}
} catch {
/* 不是可恢复的 UTF-8 乱码 */
}
return null;
}
/** 像乱码:无汉字,但有一串 Latin-1 高位字符 */
function looksLikeMojibake(s: string): boolean {
if (/[\u4e00-\u9fff]/.test(s)) return false;
return /[\u00c0-\u00ff]{2,}/.test(s);
}
/**
* GET 下载附件并触发浏览器保存。
* @param path 相对 API 路径,如 /inquiry/workOrder/view/export
* @param params query 参数(空值跳过)
* @param fallbackName 无 Content-Disposition 时的文件名
* @param fallbackName 无可靠 Content-Disposition 时的文件名
*/
export async function downloadAuthGet(
path: string,
@@ -46,12 +116,32 @@ export async function downloadAuthGet(
throw new Error(msg);
}
const blob = await res.blob();
let name = fallbackName;
const cd = res.headers.get('content-disposition') || '';
const m = /filename\*=UTF-8''([^;]+)|filename=\"?([^\";]+)\"?/i.exec(cd);
if (m) {
name = decodeURIComponent((m[1] || m[2] || name).trim());
}
const name = parseDownloadFilename(cd, fallbackName);
// #region agent log
fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '489366' },
body: JSON.stringify({
sessionId: '489366',
runId: 'post-fix',
hypothesisId: 'FN1',
location: 'inquiryAuthDownload.ts:downloadAuthGet',
message: '导出文件名解析',
data: {
path,
cd,
fallbackName,
resolvedName: name,
hasStar: /filename\*=/i.test(cd),
hasCjk: /[\u4e00-\u9fff]/.test(name),
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
const href = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = href;