Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc8529f569 |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
-1
@@ -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());
|
||||
|
||||
+31
-4
@@ -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<>();
|
||||
|
||||
+9
-1
@@ -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);
|
||||
|
||||
+23
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
+725
-75
File diff suppressed because it is too large
Load Diff
+39
-8
@@ -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 "";
|
||||
|
||||
+68
-21
@@ -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);
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+273
-9
@@ -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);
|
||||
|
||||
+11
@@ -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(
|
||||
|
||||
+7
-2
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user