Compare commits

..
29 changed files with 1941 additions and 276 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
+107 -17
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"
@@ -535,12 +536,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 +563,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 +876,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 +973,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 {})
@@ -950,12 +1043,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(
@@ -1065,9 +1161,6 @@ def apply_air_lock(
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 +1184,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)
@@ -2053,6 +2053,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)
+56 -1
View File
@@ -3420,8 +3420,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",
@@ -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():
@@ -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 ""),
@@ -144,11 +144,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 {}
+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
+76 -51
View File
@@ -1055,6 +1055,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 +1076,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 +1090,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"),
@@ -1136,7 +1139,7 @@ class AirGroupCollabTests(unittest.TestCase):
},
],
},
status="已报价",
status="已成交",
)
calls: list[dict] = []
orig = self.ledger.lock_cabin
@@ -1167,7 +1170,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 +1221,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 +1262,8 @@ class AirGroupCollabTests(unittest.TestCase):
"total": "CNY 1800",
"source": "manual",
"source_label": "产品报价",
}
},
status="已成交",
)
calls: list[dict] = []
orig = self.ledger.lock_cabin
@@ -1285,7 +1291,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 +1300,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("执行成功" 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 +1373,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 +1405,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 +1428,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 +1465,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 +1474,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 +1485,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 +1494,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 +1817,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 +1840,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 +1868,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 +1888,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 +1962,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,
@@ -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)
@@ -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 "";
@@ -786,8 +786,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 +807,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 +830,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 +857,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 +899,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 +991,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);
@@ -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;
@@ -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;