Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97f0219dfd | ||
|
|
3a4c440354 | ||
|
|
4f8c373121 | ||
|
|
428548c390 | ||
|
|
739ba401aa | ||
|
|
185abbf45e |
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
陆运选线路 H5 页。
|
||||
|
||||
本文件职责:按完整线路列出各段成本与合计,提交所选下标。
|
||||
本文件职责:按完整线路列出各段车型、成本与合计,提交所选下标。
|
||||
样式对齐空运选线路页。禁止 URL userid;禁止本页查 TMS。
|
||||
"""
|
||||
|
||||
@@ -64,19 +64,40 @@ def render_land_route_ok() -> str:
|
||||
)
|
||||
|
||||
|
||||
def _segment_vehicle(leg: dict[str, Any] | None) -> str:
|
||||
"""每段车型用 TMS 的 vehicleType 原文。没有台数,空值显示 -。"""
|
||||
src = dict(leg or {})
|
||||
raw = str(
|
||||
src.get("vehicleType") or src.get("vehicle_type") or src.get("truckType") or ""
|
||||
).strip()
|
||||
if raw.lower() in {"none", "null"}:
|
||||
raw = ""
|
||||
return raw or "-"
|
||||
|
||||
|
||||
def _route_card(index: int, route: dict[str, Any], quote: dict[str, Any] | None = None) -> str:
|
||||
"""一张线路:航线、各段路段+TMS 费目、合计。"""
|
||||
"""一张线路:航线、各段路段、车型、TMS 费目、合计。"""
|
||||
line = html.escape(route_line(route))
|
||||
blocks = land_leg_fee_blocks_for_route(quote, route)
|
||||
shown_legs = [
|
||||
leg
|
||||
for leg in list(route.get("legs") or [])
|
||||
if str(leg.get("origin") or "").strip() or str(leg.get("destination") or "").strip()
|
||||
]
|
||||
etas = {i: d for i, (_, d) in enumerate(option_eta_bits(route), 1)}
|
||||
fee_bits: list[str] = []
|
||||
items = ""
|
||||
if blocks:
|
||||
for i, (title, fees, eta) in enumerate(blocks, 1):
|
||||
vehicle = _segment_vehicle(shown_legs[i - 1] if i - 1 < len(shown_legs) else None)
|
||||
items += (
|
||||
f"<li class='leg'><span class='n'>{html.escape(title)}</span>"
|
||||
"<span></span></li>"
|
||||
)
|
||||
items += (
|
||||
f"<li><span class='n'>车型 {html.escape(vehicle)}</span>"
|
||||
"<span></span></li>"
|
||||
)
|
||||
for fee in fees:
|
||||
amount = _money(fee.get("amount"), str(fee.get("currency") or "").strip())
|
||||
name = str(fee.get("name") or "").strip()
|
||||
|
||||
@@ -53,6 +53,8 @@ from agent.routing.air_group_intent import (
|
||||
INTENT_PICK_OPTION,
|
||||
INTENT_QUOTE,
|
||||
INTENT_RELEASE,
|
||||
INTENT_RELEASE_ASK,
|
||||
cabin_confirm_answer,
|
||||
classify_air_group_text,
|
||||
)
|
||||
|
||||
@@ -167,6 +169,9 @@ def handle_air_group(
|
||||
return "duplicate_inbound"
|
||||
mark_air_text(chat_id=chat_id, sender_id=message.sender_id, text=text)
|
||||
wo = copy.extract_work_order_no(text)
|
||||
answered = _answer_pending_cabin_cancel(engine, message, text)
|
||||
if answered:
|
||||
return answered
|
||||
# 句中有多段工单号,或本群已经激活过多段单:跟这张单,不必每句再带工单号。
|
||||
followed = _follow_activated_multi(
|
||||
engine,
|
||||
@@ -200,7 +205,7 @@ def handle_air_group(
|
||||
intent,
|
||||
wo or "-",
|
||||
)
|
||||
if intent in {INTENT_LOCK, INTENT_RELEASE}:
|
||||
if intent in {INTENT_LOCK, INTENT_RELEASE, INTENT_RELEASE_ASK}:
|
||||
return _handle_cabin(
|
||||
engine,
|
||||
message,
|
||||
@@ -309,6 +314,92 @@ def _should_speak_activate(intent: str) -> bool:
|
||||
return intent in {INTENT_ACTIVATE, INTENT_OTHER}
|
||||
|
||||
|
||||
def _answer_pending_cabin_cancel(engine, message: InboundMessage, text: str) -> str:
|
||||
"""
|
||||
上一句已经问过是否取消舱位。
|
||||
|
||||
航线回是/需要/确认/取消舱位才执行。回否/不用/不需要就停。
|
||||
销售回这些词不算。说了别的业务就清掉询问,按那句话继续办。
|
||||
没有待确认时返回空串。
|
||||
"""
|
||||
from agent.policy.air_group_ops import is_airline
|
||||
from agent.redis_coord.pending_cabin_cancel import (
|
||||
clear_pending_cabin_cancel,
|
||||
load_pending_cabin_cancel,
|
||||
)
|
||||
|
||||
chat_id = (message.chat_id or "").strip()
|
||||
pending_wo = load_pending_cabin_cancel(chat_id)
|
||||
if not pending_wo:
|
||||
return ""
|
||||
kind = cabin_confirm_answer(text)
|
||||
if not kind:
|
||||
clear_pending_cabin_cancel(chat_id)
|
||||
logger.info("cabin.ask dropped chat=%s wo=%s", chat_id, pending_wo)
|
||||
return ""
|
||||
if not is_airline(engine, message.sender_id):
|
||||
send_group_text(group_client_of(engine), chat_id, copy.air_airline_only_cabin())
|
||||
return "not_airline"
|
||||
clear_pending_cabin_cancel(chat_id)
|
||||
if kind == "no":
|
||||
send_group_text(group_client_of(engine), chat_id, copy.air_release_ask_dropped())
|
||||
return "release_ask_dropped"
|
||||
ticket, phase = resolve_cabin_ticket(
|
||||
flow=engine,
|
||||
chat_id=chat_id,
|
||||
work_order_no=pending_wo,
|
||||
allow_terminal=True,
|
||||
)
|
||||
if ticket is None:
|
||||
return phase
|
||||
return apply_air_release(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
sender_id=message.sender_id,
|
||||
chat_id=chat_id,
|
||||
instruction=text,
|
||||
)
|
||||
|
||||
|
||||
def _ask_cabin_cancel(
|
||||
engine,
|
||||
message: InboundMessage,
|
||||
*,
|
||||
work_order_no: str,
|
||||
) -> str:
|
||||
"""航线说了释放舱位:只提问,不打 TMS。"""
|
||||
from agent.policy.air_group_ops import is_airline
|
||||
from agent.redis_coord.pending_cabin_cancel import save_pending_cabin_cancel
|
||||
|
||||
chat_id = message.chat_id
|
||||
if not is_airline(engine, message.sender_id):
|
||||
send_group_text(group_client_of(engine), chat_id, copy.air_airline_only_cabin())
|
||||
return "not_airline"
|
||||
no = (work_order_no or "").strip()
|
||||
if not no:
|
||||
bound = current_ticket(engine, chat_id)
|
||||
if bound is not None and is_air_ticket(bound):
|
||||
from agent.policy.sea_group_ops import ticket_no
|
||||
|
||||
no = ticket_no(bound)
|
||||
if not no:
|
||||
send_group_text(group_client_of(engine), chat_id, copy.air_lock_need_work_order())
|
||||
return "cabin_need_work_order"
|
||||
ticket, phase = resolve_cabin_ticket(
|
||||
flow=engine,
|
||||
chat_id=chat_id,
|
||||
work_order_no=no,
|
||||
allow_terminal=True,
|
||||
)
|
||||
if ticket is None:
|
||||
return phase
|
||||
from agent.policy.sea_group_ops import ticket_no
|
||||
|
||||
save_pending_cabin_cancel(chat_id, ticket_no(ticket))
|
||||
send_group_text(group_client_of(engine), chat_id, copy.air_release_ask())
|
||||
return "release_ask"
|
||||
|
||||
|
||||
def _handle_cabin(
|
||||
engine,
|
||||
message: InboundMessage,
|
||||
@@ -319,6 +410,12 @@ def _handle_cabin(
|
||||
) -> str:
|
||||
_ = injected_quote
|
||||
chat_id = message.chat_id
|
||||
if intent == INTENT_RELEASE_ASK:
|
||||
return _ask_cabin_cancel(
|
||||
engine,
|
||||
message,
|
||||
work_order_no=work_order_no,
|
||||
)
|
||||
if not work_order_no:
|
||||
# 本群已经在锁这张单时,补舱位不用再报工单号。
|
||||
bound = current_ticket(engine, chat_id)
|
||||
|
||||
@@ -11,14 +11,17 @@ import logging
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.schema.field_validate import QUOTE_DATE_SYNONYMS
|
||||
try:
|
||||
from agent.schema.field_validate import QUOTE_DATE_SYNONYMS
|
||||
except ImportError: # 测服旧 release 未导出同义词时仍可抽产品价
|
||||
QUOTE_DATE_SYNONYMS = ()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 报价日期同义词。长的在前,避免短词截断。货好时间、提货地址不在这里。
|
||||
_QUOTE_DATE_LABEL_RE = "|".join(
|
||||
re.escape(name)
|
||||
for name in sorted(("报价日期",) + QUOTE_DATE_SYNONYMS, key=len, reverse=True)
|
||||
for name in sorted(("报价日期",) + tuple(QUOTE_DATE_SYNONYMS or ()), key=len, reverse=True)
|
||||
)
|
||||
|
||||
_MODE_WORDS = (
|
||||
@@ -371,6 +374,68 @@ def _tighten_sheet_labels(text: str) -> str:
|
||||
return body
|
||||
|
||||
|
||||
# 「货交上海」是提货地址,不是起运地。地址/地、到/于只是口语粘连,不进地址正文。
|
||||
_RE_CARGO_HANDOVER = re.compile(
|
||||
r"货交\s*(?:地址|地)?\s*[::]?\s*(?:到|于|至|往)?\s*([^\n,,。;;]{1,60})"
|
||||
)
|
||||
# 同一行跟在地址后面的询价口头禅或下一栏字段,不是地址的一部分。
|
||||
_RE_HANDOVER_TAIL = re.compile(
|
||||
r"(?:\s+|,|,)?(?:看看价格|看一下价格|看价格|询价|报价|多少钱|多少|"
|
||||
r"目的地|目的港|起运地|起运港|品名|件数|毛重|体积|包装|空运|海运|陆运).*$"
|
||||
)
|
||||
# 另有起运说法时保留起运地。先抠掉「货交…」,避免「货交到上海」被当成「到上海」。
|
||||
_RE_EXPLICIT_ORIGIN = re.compile(
|
||||
r"起运地|起运港|"
|
||||
r"[A-Za-z]{3}\s*[-–—//]\s*[A-Za-z]{3}|"
|
||||
r"[A-Za-z]{3}\s*(?:到|至)\s*[A-Za-z]{3}|"
|
||||
r"从\s*\S{1,16}\s*(?:飞|到|至)|"
|
||||
r"[\u4e00-\u9fff]{2,12}\s*飞|"
|
||||
r"[\u4e00-\u9fff]{2,12}\s*到\s*[\u4e00-\u9fffA-Za-z]{2,12}"
|
||||
)
|
||||
|
||||
|
||||
def _handover_place(raw: str) -> str:
|
||||
"""货交后面的地名。去掉口头禅和首尾标点,空的不当地址。"""
|
||||
place = _RE_HANDOVER_TAIL.sub("", (raw or "").strip())
|
||||
return place.strip(" ::,,。;;")
|
||||
|
||||
|
||||
def _has_explicit_origin(text: str) -> bool:
|
||||
"""原话里另写了起运地、三字码航线或「从哪飞/到」。货交那一段先拿掉。"""
|
||||
stripped = _RE_CARGO_HANDOVER.sub(" ", text or "")
|
||||
return _RE_EXPLICIT_ORIGIN.search(stripped) is not None
|
||||
|
||||
|
||||
def apply_cargo_handover_synonym(
|
||||
text: str,
|
||||
facts: dict[str, str] | None,
|
||||
*,
|
||||
business_line: str = "",
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
货交 = 提货地址。例:货交上海 → 提货地址上海。
|
||||
|
||||
只认原话里的「货交」,不把裸城市猜成港口。
|
||||
空运且没有另写起运地时,清掉被误收成起运地的同一处地名,否则 TMS 会按错误起运查价。
|
||||
海运/陆运只补提货地址,不动起运港。副作用:无。
|
||||
"""
|
||||
merged = dict(facts or {})
|
||||
places: list[str] = []
|
||||
for hit in _RE_CARGO_HANDOVER.finditer(text or ""):
|
||||
place = _handover_place(hit.group(1))
|
||||
if place and place not in places:
|
||||
places.append(place)
|
||||
if not places:
|
||||
return merged
|
||||
merged["提货地址"] = "、".join(places)
|
||||
is_air = (business_line or "").strip().upper() == "AIR" or "空运" in (text or "")
|
||||
if is_air and not _has_explicit_origin(text):
|
||||
merged.pop("起运港", None)
|
||||
merged.pop("起运地", None)
|
||||
merged.pop("originCode", None)
|
||||
return merged
|
||||
|
||||
|
||||
def _drop_fee_place_values(facts: dict[str, str]) -> dict[str, str]:
|
||||
"""起运地/目的地收成「操作费、文件费」时丢掉。那是费用栏,不是港口。"""
|
||||
out = dict(facts or {})
|
||||
@@ -407,6 +472,7 @@ def extract_inquiry_snapshot(
|
||||
facts = harvest_oral_measures(text, normalize_facts(dict(injected_facts)))
|
||||
facts = _merge_spoken_collab(text, facts)
|
||||
facts = _merge_optional_inquiry_labels(text, facts)
|
||||
facts = apply_cargo_handover_synonym(text, facts, business_line=mode)
|
||||
for key in ("货好时间", "提货地址", "收货地址", "货源地", "贸易条款"):
|
||||
if str(facts.get(key) or "").strip():
|
||||
facts[key] = clip_inquiry_value(str(facts[key]))
|
||||
@@ -453,6 +519,7 @@ def extract_inquiry_snapshot(
|
||||
facts = harvest_oral_measures(text, normalize_facts(facts))
|
||||
facts = _merge_spoken_collab(text, facts)
|
||||
facts = _merge_optional_inquiry_labels(text, facts)
|
||||
facts = apply_cargo_handover_synonym(text, facts, business_line=mode)
|
||||
for key in ("货好时间", "提货地址", "收货地址", "货源地", "贸易条款"):
|
||||
if str(facts.get(key) or "").strip():
|
||||
facts[key] = clip_inquiry_value(str(facts[key]))
|
||||
@@ -765,9 +832,11 @@ def extract_air_collab_fields(
|
||||
return {k: got[k] for k in keys if str(got.get(k) or "").strip()}
|
||||
|
||||
|
||||
# 金额旁币种词:含港币;禁止只认 USD/CNY 把港币单漏成美元。
|
||||
_CCY_TOKEN = r"USD|CNY|RMB|HKD|EUR|JPY|GBP|美金|美元|人民币|港币|港元|欧元|日元|英镑"
|
||||
_RE_MONEY = re.compile(
|
||||
r"(?:USD|CNY|RMB|美金|美元|人民币)?\s*(\d+(?:\.\d+)?)\s*(?:USD|CNY|RMB|美金|美元|人民币|元)?"
|
||||
r"|(?:USD|CNY|RMB|美金|美元)\s*(\d+(?:\.\d+)?)",
|
||||
rf"(?:{_CCY_TOKEN}|元)?\s*(\d+(?:\.\d+)?)\s*(?:{_CCY_TOKEN}|元)?"
|
||||
rf"|(?:{_CCY_TOKEN})\s*(\d+(?:\.\d+)?)",
|
||||
re.I,
|
||||
)
|
||||
# 陆运集拼:「2-500KG 单价CNY990」是分档单价,不是「燃油附加费=2」。
|
||||
@@ -775,14 +844,14 @@ _RE_WEIGHT_BAND_PRICE = re.compile(
|
||||
r"(?P<min>\d+)\s*[-~~—–到至]\s*(?P<max>\d+)\s*K\s*G"
|
||||
r"(?:\s*以上)?"
|
||||
r"\s*(?:单价)?"
|
||||
r"\s*(?:(?P<ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
|
||||
rf"\s*(?:(?P<ccy>{_CCY_TOKEN})\s*)?"
|
||||
r"(?P<price>\d+(?:\.\d+)?)",
|
||||
re.I,
|
||||
)
|
||||
_RE_WEIGHT_BAND_OPEN = re.compile(
|
||||
r"(?P<min>\d+)\s*K\s*G\s*以上"
|
||||
r"\s*(?:单价)?"
|
||||
r"\s*(?:(?P<ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
|
||||
rf"\s*(?:(?P<ccy>{_CCY_TOKEN})\s*)?"
|
||||
r"(?P<price>\d+(?:\.\d+)?)",
|
||||
re.I,
|
||||
)
|
||||
@@ -798,9 +867,9 @@ _RE_FEE_LINE = re.compile(
|
||||
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费|[A-Za-z]{2,10})"
|
||||
r"\s*(?:改为|改成|调整为|调整成)?"
|
||||
r"\s*[::=]?"
|
||||
r"\s*(?:(?P<ccy1>USD|CNY|RMB|美金|美元|人民币)\s+)?"
|
||||
rf"\s*(?:(?P<ccy1>{_CCY_TOKEN})\s+)?"
|
||||
r"(?P<amount>\d+(?:\.\d+)?)"
|
||||
r"\s*(?P<ccy2>USD|CNY|RMB|美金|美元|人民币|元)?",
|
||||
rf"\s*(?P<ccy2>{_CCY_TOKEN}|元)?",
|
||||
re.I,
|
||||
)
|
||||
# 「文件发改为:500」漏了「费」、中间还有冒号,不能丢掉。
|
||||
@@ -809,19 +878,21 @@ _RE_FEE_CHANGE = re.compile(
|
||||
r"\s*(?:改为|改成|调整为|调整成)"
|
||||
r"\s*[::=]?"
|
||||
r"\s*(?P<amount>\d+(?:\.\d+)?)"
|
||||
r"\s*(?P<ccy>USD|CNY|RMB|美金|美元|人民币|元)?",
|
||||
rf"\s*(?P<ccy>{_CCY_TOKEN}|元)?",
|
||||
re.I,
|
||||
)
|
||||
_SKIP_FEE_NAMES = frozenset({"usd", "cny", "rmb", "美金", "美元", "人民币", "元"})
|
||||
_SKIP_FEE_NAMES = frozenset(
|
||||
{"usd", "cny", "rmb", "hkd", "eur", "jpy", "gbp", "美金", "美元", "人民币", "港币", "港元", "元"}
|
||||
)
|
||||
# 航线常写「空运费 单价CNY99,数量100,金额CNY199」。单价和金额不是同一个数,必须认金额。
|
||||
_RE_FEE_PRICED = re.compile(
|
||||
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)"
|
||||
r"\s*单价\s*"
|
||||
r"(?:(?P<unit_ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
|
||||
rf"(?:(?P<unit_ccy>{_CCY_TOKEN})\s*)?"
|
||||
r"(?P<unit>\d+(?:\.\d+)?)"
|
||||
r"(?:\s*[,,、]\s*数量\s*(?P<qty>\d+(?:\.\d+)?))?"
|
||||
r"(?:\s*[,,、]\s*金额\s*"
|
||||
r"(?:(?P<amt_ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
|
||||
rf"(?:(?P<amt_ccy>{_CCY_TOKEN})\s*)?"
|
||||
r"(?P<amount>\d+(?:\.\d+)?))?",
|
||||
re.I,
|
||||
)
|
||||
@@ -834,21 +905,40 @@ def _ascii_amount(raw: str) -> str:
|
||||
|
||||
|
||||
def _currency_of(text: str, hint: str = "") -> str:
|
||||
"""
|
||||
从正文/提示抽三字码。正文没写币种时只回传 hint,禁止默认编造 USD。
|
||||
|
||||
港币必须先于「元」判断,避免「港元」被误判成 CNY。
|
||||
WO202610090107:TMS 是 HKD,产品只写「运输费调整为1100」时必须继承 HKD。
|
||||
"""
|
||||
blob = f"{hint} {text}".upper()
|
||||
if any(x in blob for x in ("CNY", "RMB", "人民币", "元")):
|
||||
raw = f"{hint} {text}"
|
||||
if any(x in blob for x in ("HKD", "港币", "港元", "HK$")) or "港币" in raw or "港元" in raw:
|
||||
return "HKD"
|
||||
if any(x in blob for x in ("CNY", "RMB", "人民币")) or (
|
||||
"元" in raw and "港元" not in raw and "港币" not in raw
|
||||
):
|
||||
return "CNY"
|
||||
if any(x in blob for x in ("USD", "美金", "美元")):
|
||||
return "USD"
|
||||
return hint or "USD"
|
||||
if any(x in blob for x in ("EUR", "欧元")):
|
||||
return "EUR"
|
||||
if any(x in blob for x in ("JPY", "日元")):
|
||||
return "JPY"
|
||||
if any(x in blob for x in ("GBP", "英镑")):
|
||||
return "GBP"
|
||||
return str(hint or "").strip().upper()
|
||||
|
||||
|
||||
def _parse_fee_rows(text: str) -> list[dict[str, str]]:
|
||||
def _parse_fee_rows(text: str, baseline_currency: str = "") -> list[dict[str, str]]:
|
||||
"""
|
||||
按行抽费用:码头操作费100CNY、报关费改为300CNY。
|
||||
|
||||
只认带「费」的中文名或短英文费用码,避免把工单号当金额。
|
||||
句里无币种时继承 baseline_currency(通常是当前 TMS/主账币种),禁止默认 USD。
|
||||
"""
|
||||
raw = _ascii_amount(text or "")
|
||||
base_ccy = str(baseline_currency or "").strip().upper()
|
||||
rows: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
@@ -861,10 +951,11 @@ def _parse_fee_rows(text: str) -> list[dict[str, str]]:
|
||||
if name in seen:
|
||||
return
|
||||
seen.add(name)
|
||||
line_ccy = _currency_of(raw, ccy or base_ccy) or base_ccy
|
||||
row = {
|
||||
"name": name,
|
||||
"amount": amount,
|
||||
"currency": _currency_of(raw, ccy),
|
||||
"currency": line_ccy,
|
||||
}
|
||||
for key, val in extra.items():
|
||||
if val:
|
||||
@@ -897,7 +988,33 @@ def _parse_fee_rows(text: str) -> list[dict[str, str]]:
|
||||
return rows
|
||||
|
||||
|
||||
def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
||||
def _default_product_fee_name(business_line: str = "", transport_mode: str = "") -> str:
|
||||
"""
|
||||
产品文字未点费用名时的默认费目。
|
||||
|
||||
必须跟本单运输方式走:陆运→陆运费、空运→空运费、其余→海运费。
|
||||
禁止陆运单无费名时硬编码「海运费」(WO202610090118「价格为1RMB/KG」)。
|
||||
"""
|
||||
line = (business_line or "").strip().upper()
|
||||
mode = (transport_mode or "").strip()
|
||||
if line == "LAND" or mode in {"陆运", "LAND", "land"}:
|
||||
return "陆运费"
|
||||
if line == "AIR" or mode in {"空运", "AIR", "air"}:
|
||||
return "空运费"
|
||||
if line == "SEA" or mode in {"海运", "SEA", "sea"}:
|
||||
return "海运费"
|
||||
# 未传业务线时保持旧默认,避免海运调用方漏参后费名变空。
|
||||
return "海运费"
|
||||
|
||||
|
||||
def extract_product_quote(
|
||||
text: str,
|
||||
*,
|
||||
injected: Optional[dict[str, Any]] = None,
|
||||
business_line: str = "",
|
||||
transport_mode: str = "",
|
||||
baseline_currency: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
产品文字报价:先按费用行抽,再退回合计/币别。注入优先。
|
||||
|
||||
@@ -906,7 +1023,10 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
||||
「空运费 单价…金额…」必须保留空运费,金额用「金额」后面的数,不用单价。
|
||||
陆运集拼:「2-500KG 单价CNY990,燃油附加费率1%,税率6%」走分档单价+费率,
|
||||
禁止把公斤档下限「2」当成燃油附加费金额(WO021)。
|
||||
无费用名时按 business_line/transport_mode 默认陆运费/空运费/海运费,禁止陆运硬套海运费。
|
||||
正文未写币种时继承 baseline_currency(当前 TMS/主账币种),禁止默认 USD(WO107)。
|
||||
"""
|
||||
base_ccy = str(baseline_currency or "").strip().upper()
|
||||
if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")):
|
||||
out = dict(injected)
|
||||
out.setdefault("source_label", "产品报价")
|
||||
@@ -918,14 +1038,14 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
||||
compact = stripped.replace(" ", "").replace(" ", "")
|
||||
if re.fullmatch(r"WO\d{12}", compact, re.I):
|
||||
return {}
|
||||
band = _parse_weight_band_quote(raw)
|
||||
band = _parse_weight_band_quote(raw, baseline_currency=base_ccy)
|
||||
if band:
|
||||
return band
|
||||
# 费率句若仍落到普通费用行,会把「燃油附加费」配上句首公斤数。先剥掉再抽。
|
||||
scrubbed = _strip_rate_and_band_noise(raw)
|
||||
rows = _parse_fee_rows(scrubbed) if scrubbed.strip() else []
|
||||
rows = _parse_fee_rows(scrubbed, baseline_currency=base_ccy) if scrubbed.strip() else []
|
||||
if rows:
|
||||
ccy = rows[0].get("currency") or _currency_of(raw)
|
||||
ccy = rows[0].get("currency") or _currency_of(raw, base_ccy) or base_ccy
|
||||
money_rows = [x for x in rows if "%" not in str(x.get("amount") or "")]
|
||||
total = (money_rows[0] if money_rows else rows[0])["amount"]
|
||||
if money_rows and len({x.get("currency") for x in money_rows}) == 1:
|
||||
@@ -950,16 +1070,20 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
||||
amount = _ascii_amount(hit.group(1) or hit.group(2) or "")
|
||||
if not amount:
|
||||
return {}
|
||||
ccy = _currency_of(raw)
|
||||
ccy = _currency_of(raw, base_ccy) or base_ccy
|
||||
default_fee = _default_product_fee_name(business_line, transport_mode)
|
||||
# 句里已经点了费用名(空运费)时,不能退回默认海运费。
|
||||
named = re.search(
|
||||
r"([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)",
|
||||
scrubbed or raw,
|
||||
)
|
||||
fee_name = (named.group(1) if named else "") or "海运费"
|
||||
if fee_name in {"费用", "燃油附加费"}:
|
||||
# 「燃油附加费率」里的「燃油附加费」不是费用行名;无其它费名时用运费。
|
||||
fee_name = "海运费" if fee_name == "费用" else "陆运费"
|
||||
fee_name = (named.group(1) if named else "") or default_fee
|
||||
if fee_name == "费用":
|
||||
# 句里只有「费用」没有运输方式前缀时,跟本单默认运费。
|
||||
fee_name = default_fee
|
||||
elif fee_name == "燃油附加费":
|
||||
# 「燃油附加费率」误抽成费名时:陆运/空运用本单默认;海运旧行为落陆运费,避免当海运费金额。
|
||||
fee_name = default_fee if default_fee != "海运费" else "陆运费"
|
||||
out = {
|
||||
"total": amount,
|
||||
"currency": ccy,
|
||||
@@ -972,6 +1096,7 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
||||
return out
|
||||
|
||||
|
||||
|
||||
def _format_percent_rate(raw: str) -> str:
|
||||
"""费率展示统一带 %;已有 % 保留。"""
|
||||
text = (raw or "").strip()
|
||||
@@ -1006,13 +1131,15 @@ def _strip_rate_and_band_noise(text: str) -> str:
|
||||
return cleaned
|
||||
|
||||
|
||||
def _parse_weight_band_quote(text: str) -> dict[str, Any]:
|
||||
def _parse_weight_band_quote(text: str, baseline_currency: str = "") -> dict[str, Any]:
|
||||
"""
|
||||
陆运集拼分档单价:2-500KG 单价CNY990 + 可选燃油附加费率/税率。
|
||||
|
||||
产出 weightPrices,供主账出单按「2-500KG」表头对档;费率进顶层字段填 Excel。
|
||||
无币种时优先继承 baseline,再退回 CNY(集拼常见人民币报价)。
|
||||
"""
|
||||
raw = _ascii_amount(text or "")
|
||||
base_ccy = str(baseline_currency or "").strip().upper()
|
||||
tiers: list[dict[str, Any]] = []
|
||||
rows: list[dict[str, str]] = []
|
||||
ccy = ""
|
||||
@@ -1022,7 +1149,7 @@ def _parse_weight_band_quote(text: str) -> dict[str, Any]:
|
||||
price = _ascii_amount(hit.group("price") or "")
|
||||
if not price:
|
||||
continue
|
||||
line_ccy = _currency_of(raw, hit.group("ccy") or "")
|
||||
line_ccy = _currency_of(raw, hit.group("ccy") or base_ccy) or base_ccy
|
||||
ccy = ccy or line_ccy
|
||||
label = f"{lo}-{hi}KG"
|
||||
tiers.append({"min": lo, "max": hi, "price": price})
|
||||
@@ -1039,7 +1166,7 @@ def _parse_weight_band_quote(text: str) -> dict[str, Any]:
|
||||
price = _ascii_amount(hit.group("price") or "")
|
||||
if not price:
|
||||
continue
|
||||
line_ccy = _currency_of(raw, hit.group("ccy") or "")
|
||||
line_ccy = _currency_of(raw, hit.group("ccy") or base_ccy) or base_ccy
|
||||
ccy = ccy or line_ccy
|
||||
label = f"{lo}KG以上"
|
||||
tiers.append({"min": lo, "max": None, "price": price})
|
||||
@@ -1068,7 +1195,7 @@ def _parse_weight_band_quote(text: str) -> dict[str, Any]:
|
||||
# 分档是单价,不能把多档相加冒充费用合计。
|
||||
out: dict[str, Any] = {
|
||||
"total": tiers[0]["price"],
|
||||
"currency": ccy or "CNY",
|
||||
"currency": ccy or base_ccy or "CNY",
|
||||
"source_label": "产品报价",
|
||||
"raw_text": (text or "").strip(),
|
||||
"fee_rows": rows,
|
||||
|
||||
@@ -35,6 +35,7 @@ _FIELD_KEYS = (
|
||||
"车型数量",
|
||||
"通关口岸",
|
||||
"贸易条款",
|
||||
"提货地址",
|
||||
)
|
||||
|
||||
|
||||
@@ -134,6 +135,8 @@ def build_extract_messages(text: str) -> list[dict[str, str]]:
|
||||
"陆运「通关口岸:皇岗」「通关口岸皇岗」「过皇岗」有说就写入通关口岸,没说留空,禁止编造口岸。"
|
||||
"原话单独出现 DAP、DDP、CIF、CIP、FOB、EXW 时写入贸易条款;没出现留空,禁止编造。"
|
||||
"「从南京飞吉隆坡」起运港=南京、目的港=吉隆坡;「北京飞吉隆坡」起运港=北京。"
|
||||
"「货交上海」「货交:上海」「货交到上海」是提货地址=上海,不是起运港。"
|
||||
"没有另写起运地、三字码航线或「从哪飞/到」时,起运港必须空串。"
|
||||
"原话是机场三字码或航线就原样记,禁止翻译成城市名:"
|
||||
"SZX-CGK / SZX/CGK / SZX到CGK → 起运港=SZX、目的港=CGK,不要写成深圳、雅加达。"
|
||||
"换城市必须覆盖旧值,不要沿用上一句的港口。"
|
||||
|
||||
@@ -629,12 +629,19 @@ def apply_air_quote(
|
||||
fields = extract_air_collab_fields(text)
|
||||
if fields:
|
||||
flow.ledger.patch_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
|
||||
quote = injected_quote or extract_product_quote(text) or {}
|
||||
view = ticket_view(ticket)
|
||||
current = dict(view.get("quote") or {})
|
||||
from agent.policy.sea_group_ops import _quote_currency_hint
|
||||
|
||||
quote = injected_quote or extract_product_quote(
|
||||
text,
|
||||
business_line="AIR",
|
||||
transport_mode="空运",
|
||||
baseline_currency=_quote_currency_hint(current),
|
||||
) or {}
|
||||
if not quote:
|
||||
send_group_text(client, chat_id, copy.air_quote_not_recognized())
|
||||
return "quote_empty"
|
||||
view = ticket_view(ticket)
|
||||
current = dict(view.get("quote") or {})
|
||||
if _has_tms_quote(current) and not _is_product_quote(current):
|
||||
_park_air_pending(flow, no, quote, sender_id)
|
||||
send_group_text(client, chat_id, copy.group_ask_tms_or_product())
|
||||
@@ -1068,7 +1075,7 @@ def apply_air_release(
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
copy.air_release_fail(work_order_no=no, operator=name, reason="主账未开放释放"),
|
||||
copy.air_release_fail(work_order_no=no, operator=name, reason="主账未开放取消舱位"),
|
||||
)
|
||||
return "release_unavailable"
|
||||
from agent.policy.system_exception import (
|
||||
@@ -1104,7 +1111,7 @@ def apply_air_release(
|
||||
event=SystemExceptionEvent(
|
||||
exception_type=TYPE_TMS,
|
||||
step=STEP_TMS_RELEASE,
|
||||
reason="空运释放舱位接口调用失败",
|
||||
reason="空运取消舱位接口调用失败",
|
||||
service="空运舱位",
|
||||
error_code=why or "RELEASE_FAIL",
|
||||
work_order_no=no,
|
||||
|
||||
@@ -39,6 +39,24 @@ def _r(code: str, default: str, **kwargs: Any) -> str:
|
||||
return render(code, default, **kwargs)
|
||||
|
||||
|
||||
def _cabin_copy(code: str, default: str, **kwargs: Any) -> str:
|
||||
"""
|
||||
舱位话术。库里旧句仍写「释放」时,新发出的改用当前默认句。
|
||||
|
||||
先把填进去的原因、工单号抠掉再看骨架,避免 TMS 原文里的「释放」把新句子打回去。
|
||||
已经发出的历史消息不在这里改。
|
||||
"""
|
||||
text = _r(code, default, **kwargs)
|
||||
skeleton = text
|
||||
for value in kwargs.values():
|
||||
token = str(value or "")
|
||||
if token:
|
||||
skeleton = skeleton.replace(token, "")
|
||||
if "释放" in skeleton:
|
||||
return default
|
||||
return text
|
||||
|
||||
|
||||
# 空运补问:必填 7 项 + 非必填。报价日期默认当天,不进待补充。
|
||||
_AIR_EXAMPLES = {
|
||||
"起运港": "南京",
|
||||
@@ -314,7 +332,7 @@ _LAND_EXAMPLES = {
|
||||
"体积": "100CBM",
|
||||
"毛重": "50kg",
|
||||
"车型/数量": "9米6/1",
|
||||
"通关口岸": "皇岗",
|
||||
"通关口岸": "友谊关",
|
||||
"运输类型": "国内运输拼车",
|
||||
"线路类别": "国内长途/零担",
|
||||
"运输分类": "急件",
|
||||
@@ -2606,6 +2624,20 @@ def group_tms_confirm_hint(*, business_line: str = "") -> str:
|
||||
return f"请{who}回复“确认报价”确定采用TMS报价。\n{_GROUP_QUOTE_TAIL}"
|
||||
|
||||
|
||||
def multi_group_field_hint(*, includes_air: bool) -> str:
|
||||
"""
|
||||
多段群里每次带出各段询价字段时,贴在最后一行字段下面。
|
||||
|
||||
任一段是空运,喊航线或产品;没有空运只喊产品。成交跟进不走这里。
|
||||
注里说的是附件报价单只存档,不改报价写入规则。
|
||||
"""
|
||||
who = "航线或产品人员" if includes_air else "产品人员"
|
||||
return (
|
||||
f"{who}可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。\n"
|
||||
"(注:附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统)"
|
||||
)
|
||||
|
||||
|
||||
def group_no_tms_quote_hint(*, business_line: str = "") -> str:
|
||||
"""
|
||||
TMS 无价时写在「暂无匹配」下方:谁可以用文字或 Excel 另报。
|
||||
@@ -2879,9 +2911,9 @@ def air_need_work_order() -> str:
|
||||
|
||||
def air_lock_need_work_order() -> str:
|
||||
"""锁舱/释放必须自带工单号。后台 inquiring.air_lock_need_wo。"""
|
||||
return _r(
|
||||
return _cabin_copy(
|
||||
"inquiring.air_lock_need_wo",
|
||||
"锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。",
|
||||
"锁舱或取消舱位请带上工单号,例如 WO202609160001取消舱位。",
|
||||
)
|
||||
|
||||
|
||||
@@ -3041,9 +3073,9 @@ def air_airline_only_quote() -> str:
|
||||
|
||||
def air_airline_only_cabin() -> str:
|
||||
"""只有航线能锁舱或释放。后台 inquiring.air_airline_only_cabin。"""
|
||||
return _r(
|
||||
return _cabin_copy(
|
||||
"inquiring.air_airline_only_cabin",
|
||||
"只有岗位为「航线·空运」的人能锁舱或释放舱位。",
|
||||
"只有岗位为「航线·空运」的人能锁舱或取消舱位。",
|
||||
)
|
||||
|
||||
|
||||
@@ -3264,13 +3296,13 @@ def air_release_ok(
|
||||
) -> str:
|
||||
"""空运释放成功。后台 inquiring.air_release_ok。"""
|
||||
default = (
|
||||
f"工单{work_order_no} 已通知TMS执行空运释放舱位成功\n"
|
||||
f"工单{work_order_no} 已通知TMS执行空运取消舱位成功\n"
|
||||
f"操作人:{operator}\n"
|
||||
f"舱位选项:{option_no}\n"
|
||||
f"执行编号:{exec_no}\n"
|
||||
f"报价编号:{quote_no}"
|
||||
)
|
||||
return _r(
|
||||
return _cabin_copy(
|
||||
"inquiring.air_release_ok",
|
||||
default,
|
||||
ticket_no=work_order_no,
|
||||
@@ -3285,12 +3317,12 @@ def air_release_fail(*, work_order_no: str, operator: str, reason: str) -> str:
|
||||
"""空运释放失败。后台 inquiring.air_release_fail。"""
|
||||
why = (reason or "").strip() or "TMS未返回原因"
|
||||
default = (
|
||||
f"工单{work_order_no} 空运释放舱位失败。\n"
|
||||
f"工单{work_order_no} 空运取消舱位失败。\n"
|
||||
f"操作人:{operator}\n"
|
||||
f"释放舱位失败的原因:{why}\n"
|
||||
f"取消舱位失败的原因:{why}\n"
|
||||
"已通知IT运维排查,请稍等。"
|
||||
)
|
||||
return _r(
|
||||
return _cabin_copy(
|
||||
"inquiring.air_release_fail",
|
||||
default,
|
||||
ticket_no=work_order_no,
|
||||
@@ -3302,9 +3334,9 @@ def air_release_fail(*, work_order_no: str, operator: str, reason: str) -> str:
|
||||
def air_release_deal_blocked(work_order_no: str) -> str:
|
||||
"""已成交不能释放舱位。后台 inquiring.air_release_deal_blocked。"""
|
||||
no = (work_order_no or "").strip()
|
||||
return _r(
|
||||
return _cabin_copy(
|
||||
"inquiring.air_release_deal_blocked",
|
||||
f"释放舱位失败,工单{no}已成交。",
|
||||
f"取消舱位失败,工单{no}已成交。",
|
||||
ticket_no=no,
|
||||
)
|
||||
|
||||
@@ -3312,9 +3344,9 @@ def air_release_deal_blocked(work_order_no: str) -> str:
|
||||
def air_release_need_lock(work_order_no: str) -> str:
|
||||
"""没有有效锁舱不能释放。后台 inquiring.air_release_need_lock。"""
|
||||
no = (work_order_no or "").strip()
|
||||
return _r(
|
||||
return _cabin_copy(
|
||||
"inquiring.air_release_need_lock",
|
||||
f"工单{no}还没有有效锁舱,不能释放。",
|
||||
f"工单{no}还没有有效锁舱,不能取消舱位。",
|
||||
ticket_no=no,
|
||||
)
|
||||
|
||||
@@ -3342,9 +3374,19 @@ SYS_EXC_RECOVERY_GROUP = "建群已恢复正常,感谢您的耐心等待。"
|
||||
IMPACT_LLM_NO_TICKET = "尚未建单,不落后台工单"
|
||||
IMPACT_LLM = "当前识别暂停,已有字段与工单状态保持不变"
|
||||
IMPACT_TMS_QUOTE = "当前询价流程暂停,识别字段与原业务节点保持不变"
|
||||
IMPACT_TMS_CABIN = "当前锁舱/释放暂停,工单状态保持不变"
|
||||
IMPACT_TMS_CABIN = "当前锁舱/取消舱位暂停,工单状态保持不变"
|
||||
IMPACT_TMS_LOCK = "锁舱失败,工单状态保持不变"
|
||||
IMPACT_TMS_RELEASE = "释放舱位失败,工单状态保持不变"
|
||||
IMPACT_TMS_RELEASE = "取消舱位失败,工单状态保持不变"
|
||||
|
||||
|
||||
def air_release_ask() -> str:
|
||||
"""航线说了和舱位有关的「释放」时先问,不直接取消。"""
|
||||
return "是否需要取消舱位?"
|
||||
|
||||
|
||||
def air_release_ask_dropped() -> str:
|
||||
"""航线否认这次取消。不打 TMS。"""
|
||||
return "已取消这次操作"
|
||||
IMPACT_GROUP = "当前建群/加人暂停,已建群不新建第二个群"
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from agent.llm.extract_text import extract_collab_fields, extract_product_quote
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.multi_segments import Segment, group_brief, includes_air, segments_from_payload
|
||||
from agent.policy.sea_group_ops import (
|
||||
_quote_currency_hint,
|
||||
apply_group_deal,
|
||||
group_client_of,
|
||||
is_sales,
|
||||
@@ -152,7 +153,22 @@ def handle_multi_inbound(
|
||||
ticket = _reload(flow, no) or ticket
|
||||
segments = load_segments(ticket)
|
||||
target = next((seg for seg in segments if seg.mode == mode), target)
|
||||
quote = extract_product_quote(text or "", injected=injected_quote)
|
||||
# 段报价币种优先;否则用工单顶层 quote(产品未写币种时继承 TMS)
|
||||
baseline = ""
|
||||
if target is not None:
|
||||
baseline = _quote_currency_hint((segment_quotes(ticket) or {}).get(target.mode) or {})
|
||||
if not baseline:
|
||||
baseline = _quote_currency_hint(dict(ticket_view(ticket).get("quote") or {}))
|
||||
quote = extract_product_quote(
|
||||
text or "",
|
||||
injected=injected_quote,
|
||||
business_line=str(mode or "").upper(),
|
||||
transport_mode=(
|
||||
"陆运" if str(mode or "").upper() == "LAND"
|
||||
else ("空运" if str(mode or "").upper() == "AIR" else "海运")
|
||||
),
|
||||
baseline_currency=baseline,
|
||||
)
|
||||
if not quote:
|
||||
if fields:
|
||||
return "multi_collab_saved"
|
||||
@@ -289,6 +305,15 @@ def _cabin(flow: Any, ticket: object, chat_id: str, sender_id: str, text: str, i
|
||||
operator_name,
|
||||
)
|
||||
|
||||
if intent == "ask":
|
||||
if not is_airline(flow, sender_id):
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
|
||||
return "not_airline"
|
||||
from agent.redis_coord.pending_cabin_cancel import save_pending_cabin_cancel
|
||||
|
||||
save_pending_cabin_cancel(chat_id, ticket_no(ticket))
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_release_ask())
|
||||
return "release_ask"
|
||||
if intent == "release":
|
||||
return apply_air_release(
|
||||
flow=flow,
|
||||
@@ -314,10 +339,15 @@ def _cabin(flow: Any, ticket: object, chat_id: str, sender_id: str, text: str, i
|
||||
|
||||
|
||||
def _cabin_intent(text: str) -> str:
|
||||
raw = text or ""
|
||||
if "释放" in raw and "舱" in raw:
|
||||
"""取消舱位马上办;和舱位有关的「释放」只提问。"""
|
||||
from agent.routing.air_group_intent import cabin_release_kind
|
||||
|
||||
kind = cabin_release_kind(text or "")
|
||||
if kind == "direct":
|
||||
return "release"
|
||||
if "锁舱" in raw:
|
||||
if kind == "ask":
|
||||
return "ask"
|
||||
if "锁舱" in (text or ""):
|
||||
return "lock"
|
||||
return ""
|
||||
|
||||
|
||||
@@ -385,6 +385,7 @@ def group_brief(
|
||||
|
||||
结构对齐私聊核对:@销售和各段产品,下一行直接接段1询价字段。
|
||||
不写 TMS,不写协同段。工单号已在私聊提示过,群里不再重复一行。
|
||||
每次拼这段字段都在底部加另报提示;成交跟进是另一条消息,不加。
|
||||
"""
|
||||
_ = work_order_no
|
||||
names: list[str] = []
|
||||
@@ -406,6 +407,8 @@ def group_brief(
|
||||
body = _segment_inquiry_block(seg).strip()
|
||||
if body:
|
||||
lines.extend(body.splitlines())
|
||||
# 紧挨最后一行字段,中间不空行。含空运与不含空运用不同开头。
|
||||
lines.append(copy.multi_group_field_hint(includes_air=includes_air(segments)))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
@@ -929,19 +929,28 @@ def apply_product_quote(
|
||||
merged = dict(sess.collab_facts)
|
||||
merged.update(fields)
|
||||
sess.collab_facts = merged
|
||||
quote = extract_product_quote(text, injected=injected_quote)
|
||||
line = str(view.get("business_line") or view.get("businessLine") or "").strip().upper()
|
||||
base = _current_quote(view, sess)
|
||||
quote = extract_product_quote(
|
||||
text,
|
||||
injected=injected_quote,
|
||||
business_line=line,
|
||||
transport_mode="陆运" if line == "LAND" else ("海运" if line == "SEA" else ""),
|
||||
baseline_currency=_quote_currency_hint(base),
|
||||
)
|
||||
if not quote:
|
||||
logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200])
|
||||
send_group_text(client, chat_id, copy.group_quote_not_recognized())
|
||||
return "quote_not_recognized"
|
||||
quote = _attach_land_weight_band_segment(quote, view)
|
||||
base = _current_quote(view, sess)
|
||||
if _has_tms_quote(base):
|
||||
_park_pending_quote(no, quote, sess, flow)
|
||||
send_group_text(client, chat_id, copy.group_ask_tms_or_product())
|
||||
return "wait_tms_choice"
|
||||
# 分档单价是完整人工价(含费率),禁止和旧错误行(如燃油附加费=2)相加。
|
||||
# 旧错默认「海运费」的陆运产品价:重报时按本单默认运费对齐后再盖,避免海运费+陆运费并排。
|
||||
if _quote_fee_rows(base) and not (quote.get("weightPrices") or []):
|
||||
base = _align_wrong_default_freight(base, quote, business_line=line)
|
||||
quote = merge_quote_fees(base, quote)
|
||||
_save_product_quote(flow, no, quote, sess)
|
||||
ticket2 = _reload(flow, chat_id, no) or ticket
|
||||
@@ -1397,6 +1406,53 @@ def _current_quote(view: dict[str, Any], sess: Any) -> dict[str, Any]:
|
||||
return base
|
||||
|
||||
|
||||
def _quote_currency_hint(quote: dict[str, Any] | None) -> str:
|
||||
"""
|
||||
当前报价币种提示:顶层 currency → 金额前缀 → 费用行/线路段。
|
||||
|
||||
产品改价未写币种时继承此值,禁止默认 USD(WO202610090107 中港 HKD)。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
for key in ("currency", "currencyCode"):
|
||||
code = str(src.get(key) or "").strip().upper()
|
||||
if code:
|
||||
return code
|
||||
for key in ("cost", "total", "freight", "land_freight"):
|
||||
raw = str(src.get(key) or "").strip().upper()
|
||||
for prefix in ("HKD", "CNY", "USD", "EUR", "JPY", "GBP", "RMB"):
|
||||
if raw.startswith(prefix):
|
||||
return "CNY" if prefix == "RMB" else prefix
|
||||
for row in _quote_fee_rows(src):
|
||||
code = str(row.get("currency") or row.get("currencyCode") or "").strip().upper()
|
||||
if code:
|
||||
return code
|
||||
for key in ("landSegments", "route_rows", "selected_land_route"):
|
||||
raw = src.get(key)
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
import json
|
||||
|
||||
raw = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(raw, dict):
|
||||
legs = raw.get("legs") if isinstance(raw.get("legs"), list) else [raw]
|
||||
for leg in legs:
|
||||
if not isinstance(leg, dict):
|
||||
continue
|
||||
code = str(leg.get("currency") or "").strip().upper()
|
||||
if code:
|
||||
return code
|
||||
elif isinstance(raw, list):
|
||||
for leg in raw:
|
||||
if not isinstance(leg, dict):
|
||||
continue
|
||||
code = str(leg.get("currency") or "").strip().upper()
|
||||
if code:
|
||||
return code
|
||||
return ""
|
||||
|
||||
|
||||
def _has_tms_quote(quote: dict[str, Any] | None) -> bool:
|
||||
"""当前价来自 TMS 才要问;产品价、销售调价不再替用户选。"""
|
||||
src = dict(quote or {})
|
||||
@@ -1627,6 +1683,45 @@ def _merged_total(
|
||||
return str(src.get("total") or ""), header_ccy
|
||||
|
||||
|
||||
def _align_wrong_default_freight(
|
||||
base: dict[str, Any] | None,
|
||||
overlay: dict[str, Any] | None,
|
||||
*,
|
||||
business_line: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
陆运/空运旧产品价若误落成别的运输方式默认费名,重报前改成与新报价同名,便于按行盖价。
|
||||
|
||||
只动「海运费/陆运费/空运费/运输费」这类主运费默认名,不碰操作费等真实分项。
|
||||
"""
|
||||
src = dict(base or {})
|
||||
line = (business_line or "").strip().upper()
|
||||
want = {"LAND": "陆运费", "AIR": "空运费", "SEA": "海运费"}.get(line, "")
|
||||
if not want:
|
||||
return src
|
||||
new_names = {
|
||||
str(x.get("name") or "").strip()
|
||||
for x in (overlay or {}).get("fee_rows") or (overlay or {}).get("fee_lines") or []
|
||||
if isinstance(x, dict)
|
||||
}
|
||||
if want not in new_names:
|
||||
return src
|
||||
mains = {"海运费", "陆运费", "空运费", "运输费"}
|
||||
rows = _quote_fee_rows(src)
|
||||
changed = False
|
||||
for row in rows:
|
||||
name = str(row.get("name") or "").strip()
|
||||
if name in mains and name != want:
|
||||
row["name"] = want
|
||||
changed = True
|
||||
if not changed:
|
||||
return src
|
||||
out = dict(src)
|
||||
out["fee_rows"] = rows
|
||||
out["fee_lines"] = rows
|
||||
return out
|
||||
|
||||
|
||||
def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""
|
||||
产品按行改价盖到原 TMS 明细上。没点名的费用原样保留。
|
||||
|
||||
@@ -888,7 +888,7 @@ def _resume_cabin_step(
|
||||
is_ok=lambda x: bool((x or {}).get("ok")),
|
||||
)
|
||||
service = "空运舱位"
|
||||
reason = "空运释放舱位接口调用失败"
|
||||
reason = "空运取消舱位接口调用失败"
|
||||
else:
|
||||
# 与首次群锁舱对齐:重试必须带 operatorName / airline,否则 MANUAL 出站缺姓名。
|
||||
locker = getattr(ledger, "lock_cabin", None)
|
||||
|
||||
@@ -57,6 +57,8 @@ QUOTE_FILE_READY = "quote_file_ready"
|
||||
PENDING_PRODUCT_QUOTE = "pending_product_quote"
|
||||
# 陆运核对卡已出、等确定查价:短协调,不当六态、不当工单号
|
||||
PENDING_LAND_CONFIRM = "pending_land_confirm"
|
||||
# 空运群问过「是否需要取消舱位」:短协调,不当六态
|
||||
PENDING_CABIN_CANCEL = "pending_cabin_cancel"
|
||||
# 空运/海运/陆运当前书签:HTTP 与 Worker 共用,不当六态
|
||||
FLOW_BOOKMARK = "flow_bookmark"
|
||||
# 私聊图片波次:HTTP 与 Worker 必须共用,不当六态
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
空运群取消舱位待确认。
|
||||
|
||||
航线说出和舱位有关的「释放」后先问一句,确认前不打 TMS。
|
||||
短协调,不当六态、不当 checkpoint。按群记下工单号,双进程仍能读到。
|
||||
两小时没人接话就过期,避免过后回一个「是」误取消。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from agent.redis_coord.keys import PENDING_CABIN_CANCEL
|
||||
from agent.redis_coord.runtime import get_redis_runtime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 问句只在这一阵有效。过期后「是」不再取消舱位。
|
||||
_TTL_SECONDS = 2 * 60 * 60
|
||||
|
||||
|
||||
def save_pending_cabin_cancel(chat_id: str, work_order_no: str) -> None:
|
||||
"""记下本群正在等航线确认取消哪张单。"""
|
||||
room = (chat_id or "").strip()
|
||||
no = (work_order_no or "").strip()
|
||||
if not room or not no:
|
||||
return
|
||||
payload = {"work_order_no": no}
|
||||
try:
|
||||
client = get_redis_runtime().client
|
||||
client.raw.set(
|
||||
client.key(PENDING_CABIN_CANCEL, room),
|
||||
json.dumps(payload, ensure_ascii=False),
|
||||
ex=_TTL_SECONDS,
|
||||
)
|
||||
logger.info("cabin.ask saved chat=%s wo=%s", room, no)
|
||||
except Exception:
|
||||
logger.exception("写取消舱位待确认失败 chat=%s", room)
|
||||
|
||||
|
||||
def load_pending_cabin_cancel(chat_id: str) -> str:
|
||||
"""本群正在等确认的工单号。没有则空串。"""
|
||||
room = (chat_id or "").strip()
|
||||
if not room:
|
||||
return ""
|
||||
try:
|
||||
client = get_redis_runtime().client
|
||||
raw = client.raw.get(client.key(PENDING_CABIN_CANCEL, room))
|
||||
if not raw:
|
||||
return ""
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode("utf-8")
|
||||
data = json.loads(raw)
|
||||
if not isinstance(data, dict):
|
||||
return ""
|
||||
return str(data.get("work_order_no") or "").strip()
|
||||
except Exception:
|
||||
logger.exception("读取消舱位待确认失败 chat=%s", room)
|
||||
return ""
|
||||
|
||||
|
||||
def clear_pending_cabin_cancel(chat_id: str) -> None:
|
||||
"""确认、否认或改口办别的事时清掉。"""
|
||||
room = (chat_id or "").strip()
|
||||
if not room:
|
||||
return
|
||||
try:
|
||||
client = get_redis_runtime().client
|
||||
client.raw.delete(client.key(PENDING_CABIN_CANCEL, room))
|
||||
except Exception:
|
||||
logger.exception("清取消舱位待确认失败 chat=%s", room)
|
||||
@@ -35,12 +35,15 @@ logger = logging.getLogger(__name__)
|
||||
INTENT_ACTIVATE = "air_activate"
|
||||
INTENT_LOCK = "air_lock"
|
||||
INTENT_RELEASE = "air_release"
|
||||
# 说出和舱位有关的「释放」:先问是否取消,不直接执行。
|
||||
INTENT_RELEASE_ASK = "air_release_ask"
|
||||
INTENT_PICK_OPTION = "air_pick_option"
|
||||
|
||||
_VALID = {
|
||||
INTENT_ACTIVATE,
|
||||
INTENT_LOCK,
|
||||
INTENT_RELEASE,
|
||||
INTENT_RELEASE_ASK,
|
||||
INTENT_PICK_OPTION,
|
||||
INTENT_FIELDS,
|
||||
INTENT_QUOTE,
|
||||
@@ -56,7 +59,10 @@ _VALID = {
|
||||
|
||||
# 相近说法回退,不是唯一口令。
|
||||
_LOCK_HINTS = ("锁舱", "锁仓", "锁定舱位", "锁住舱位", "帮我锁舱")
|
||||
_RELEASE_HINTS = ("释放舱位", "放舱", "解锁舱", "取消锁舱", "释放舱")
|
||||
# 这四句马上取消舱位。「释放舱位」不在这里。
|
||||
_RELEASE_DIRECT = ("取消舱位", "放舱", "解锁舱", "取消锁舱")
|
||||
_CONFIRM_YES = ("是", "需要", "确认")
|
||||
_CONFIRM_NO = ("不需要", "不用", "否")
|
||||
|
||||
|
||||
def is_missed_air_bot_at(text: str, *, room_already_air: bool = False) -> bool:
|
||||
@@ -154,10 +160,53 @@ def classify_air_group_text(
|
||||
return INTENT_OTHER
|
||||
|
||||
|
||||
def _compact_cabin_text(text: str) -> str:
|
||||
"""去掉空白,方便认「取消舱位」「释放舱位」。"""
|
||||
return (text or "").replace(" ", "").replace(" ", "")
|
||||
|
||||
|
||||
def cabin_release_kind(text: str) -> str:
|
||||
"""
|
||||
舱位取消怎么认。
|
||||
|
||||
direct:马上执行。ask:先问「是否需要取消舱位」。
|
||||
跟舱位有关的「释放」只问不办。没说舱位的「释放」不当取消。
|
||||
"""
|
||||
blob = _compact_cabin_text(text)
|
||||
# 「释放舱位」里含着「放舱」,必须先认释放,避免被放舱直接取消。
|
||||
if "释放" in blob and "舱" in blob:
|
||||
return "ask"
|
||||
if any(hint in blob for hint in _RELEASE_DIRECT):
|
||||
return "direct"
|
||||
return ""
|
||||
|
||||
|
||||
def cabin_confirm_answer(text: str) -> str:
|
||||
"""
|
||||
问过之后这一句是确认、否认,还是别的业务。
|
||||
|
||||
只认整句(可带工单号和 @)。「不需要」不能被看成「需要」。
|
||||
返回 yes / no / 空串。空串表示询问作废,调用方按原句继续办。
|
||||
"""
|
||||
raw = text or ""
|
||||
no = copy.extract_work_order_no(raw)
|
||||
rest = raw.replace(no, "").replace(no.lower(), "")
|
||||
rest = rest.replace("@询价小助手", "").replace("询价小助手", "")
|
||||
rest = "".join(ch for ch in rest if ch.isalnum() or "\u4e00" <= ch <= "\u9fff")
|
||||
if rest in _CONFIRM_NO:
|
||||
return "no"
|
||||
if rest in _CONFIRM_YES or rest == "取消舱位":
|
||||
return "yes"
|
||||
return ""
|
||||
|
||||
|
||||
def _hint_lock_or_release(text: str) -> str:
|
||||
blob = (text or "").replace(" ", "").replace(" ", "")
|
||||
if any(x in blob for x in _RELEASE_HINTS):
|
||||
kind = cabin_release_kind(text)
|
||||
if kind == "direct":
|
||||
return INTENT_RELEASE
|
||||
if kind == "ask":
|
||||
return INTENT_RELEASE_ASK
|
||||
blob = _compact_cabin_text(text)
|
||||
if any(x in blob for x in _LOCK_HINTS):
|
||||
return INTENT_LOCK
|
||||
return ""
|
||||
@@ -191,7 +240,8 @@ def _classify_with_model(text: str, settings) -> str:
|
||||
client = LlmHttpClient.from_settings(settings)
|
||||
system = (
|
||||
"你是空运协同群意图分类。只回复一个英文词,不要解释。"
|
||||
"air_lock=航线要锁舱;air_release=航线要释放舱位;"
|
||||
"air_lock=航线要锁舱;air_release=航线要取消舱位(取消舱位、放舱、解锁舱、取消锁舱);"
|
||||
"说出释放舱位、释放舱、把舱位释放了时不要回 air_release;"
|
||||
"air_activate=只是指定/切换工单号;"
|
||||
"air_pick_option=在补充或选择舱位编号、航司、航班号,写法不限;"
|
||||
"collab_fields=在补是否含电/是否含磁;"
|
||||
|
||||
@@ -20,6 +20,7 @@ from agent.policy import inquiry_copy as copy
|
||||
from agent.routing.air_group_intent import (
|
||||
INTENT_LOCK,
|
||||
INTENT_RELEASE,
|
||||
INTENT_RELEASE_ASK,
|
||||
classify_air_group_text,
|
||||
)
|
||||
from agent.routing.dispatch import dispatch_inbound
|
||||
@@ -201,7 +202,12 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
|
||||
def test_intent_lock_is_not_hardcoded(self) -> None:
|
||||
self.assertEqual(classify_air_group_text("帮我锁舱 WO202609160001", allow_network=False), INTENT_LOCK)
|
||||
self.assertEqual(classify_air_group_text("WO202609160001释放舱位", allow_network=False), INTENT_RELEASE)
|
||||
self.assertEqual(classify_air_group_text("WO202609160001取消舱位", allow_network=False), INTENT_RELEASE)
|
||||
self.assertEqual(classify_air_group_text("放舱", allow_network=False), INTENT_RELEASE)
|
||||
self.assertEqual(classify_air_group_text("解锁舱", allow_network=False), INTENT_RELEASE)
|
||||
self.assertEqual(classify_air_group_text("取消锁舱", allow_network=False), INTENT_RELEASE)
|
||||
self.assertEqual(classify_air_group_text("WO202609160001释放舱位", allow_network=False), INTENT_RELEASE_ASK)
|
||||
self.assertEqual(classify_air_group_text("把舱位释放了", allow_network=False), INTENT_RELEASE_ASK)
|
||||
|
||||
def test_airline_fee_wording_is_quote_not_adjust(self) -> None:
|
||||
"""航线写「空运费改为900」是手工报价,不能先判成调价。比例和改总价仍是调价。"""
|
||||
@@ -1278,9 +1284,84 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(phase, "release_ok")
|
||||
added = "\n".join(self._texts()[before:])
|
||||
self.assertIn("释放", added)
|
||||
self.assertIn("取消舱位", added)
|
||||
self.assertNotIn("请确认成交状态", added)
|
||||
|
||||
def test_release_wording_asks_before_cancel(self) -> None:
|
||||
"""说出释放舱位只提问。销售回「是」不算,航线确认后才取消。"""
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_ask", mid="ask0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.groups.sent.clear()
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}释放舱位", chat="wr_air_ask", mid="ask1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "release_ask")
|
||||
self.assertIn("是否需要取消舱位?", "\n".join(self._texts()))
|
||||
self.assertNotIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts()))
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="sales1", text="是", chat="wr_air_ask", mid="ask2"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "not_airline")
|
||||
self.assertNotIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts()))
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text="是", chat="wr_air_ask", mid="ask3"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "release_ok")
|
||||
self.assertIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts()))
|
||||
|
||||
def test_release_wording_can_be_declined(self) -> None:
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_ask_no", mid="no0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.groups.sent.clear()
|
||||
handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}把舱位释放了", chat="wr_air_ask_no", mid="no1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text="不用", chat="wr_air_ask_no", mid="no2"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "release_ask_dropped")
|
||||
self.assertIn("已取消这次操作", "\n".join(self._texts()))
|
||||
self.assertNotIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts()))
|
||||
|
||||
def test_cancel_cabin_words_release_immediately(self) -> None:
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_now", mid="now0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.groups.sent.clear()
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}取消舱位", chat="wr_air_now", mid="now1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "release_ok")
|
||||
joined = "\n".join(self._texts())
|
||||
self.assertIn("已通知TMS执行空运取消舱位成功", joined)
|
||||
self.assertNotIn("是否需要取消舱位?", joined)
|
||||
|
||||
def test_sales_cannot_lock(self) -> None:
|
||||
wo = self._create_air()
|
||||
phase = handle_air_group(
|
||||
@@ -1825,9 +1906,9 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(phase, "system_exception")
|
||||
joined = "\n".join(self._texts())
|
||||
self.assertIn(f"工单{wo} 空运释放舱位失败。", joined)
|
||||
self.assertIn(f"工单{wo} 空运取消舱位失败。", joined)
|
||||
self.assertIn("操作人:HangXian", joined)
|
||||
self.assertIn("释放舱位失败的原因:舱位不存在", joined)
|
||||
self.assertIn("取消舱位失败的原因:舱位不存在", joined)
|
||||
self.assertIn("已通知IT运维排查,请稍等。", joined)
|
||||
self.assertNotIn("TMS接口异常,IT运维排查中", joined)
|
||||
|
||||
|
||||
@@ -624,6 +624,79 @@ AIR_QUOTE_SHEET = """
|
||||
"""
|
||||
|
||||
|
||||
class CargoHandoverSynonymTests(unittest.TestCase):
|
||||
"""货交是提货地址,不能当成起运地。"""
|
||||
|
||||
def test_huojiao_shanghai_is_pickup_not_origin(self) -> None:
|
||||
text = (
|
||||
"KUL 空运 普货 散货\n"
|
||||
"8686KG//9PLT//11.6CBM\n"
|
||||
"110*110*118CM*7\n"
|
||||
"110*110*80CM*1\n"
|
||||
"110*110*53CM*1\n"
|
||||
"货交上海\n"
|
||||
"看看价格 几号"
|
||||
)
|
||||
guessed = parse_extract_payload(
|
||||
{
|
||||
"transport_mode": "AIR",
|
||||
"fields": {
|
||||
"起运港": "上海",
|
||||
"目的港": "KUL",
|
||||
"品名": "普货",
|
||||
"件数": "9",
|
||||
"毛重": "8686KG",
|
||||
"体积": "11.6CBM",
|
||||
"包装方式": "散货",
|
||||
},
|
||||
}
|
||||
)
|
||||
facts = extract_inquiry_snapshot(text, b_result=guessed)["facts"]
|
||||
self.assertEqual(facts.get("提货地址"), "上海")
|
||||
self.assertNotIn("起运港", facts)
|
||||
self.assertEqual(facts.get("目的港"), "KUL")
|
||||
|
||||
def test_huojiao_colon_and_dao_same_as_pickup(self) -> None:
|
||||
for spoken in ("货交:上海", "货交到上海", "货交地:上海"):
|
||||
guessed = parse_extract_payload(
|
||||
{
|
||||
"transport_mode": "AIR",
|
||||
"fields": {"起运港": "上海", "目的港": "KUL", "品名": "普货"},
|
||||
}
|
||||
)
|
||||
facts = extract_inquiry_snapshot(
|
||||
f"空运 {spoken} 目的地KUL", b_result=guessed
|
||||
)["facts"]
|
||||
self.assertEqual(facts.get("提货地址"), "上海", spoken)
|
||||
self.assertNotIn("起运港", facts, spoken)
|
||||
|
||||
def test_explicit_route_keeps_origin(self) -> None:
|
||||
text = "SZX-CGK 空运 货交深圳"
|
||||
guessed = parse_extract_payload(
|
||||
{
|
||||
"transport_mode": "AIR",
|
||||
"fields": {"起运港": "SZX", "目的港": "CGK", "品名": "普货"},
|
||||
}
|
||||
)
|
||||
facts = extract_inquiry_snapshot(text, b_result=guessed)["facts"]
|
||||
self.assertEqual(facts.get("提货地址"), "深圳")
|
||||
self.assertEqual(facts.get("起运港"), "SZX")
|
||||
self.assertEqual(facts.get("目的港"), "CGK")
|
||||
|
||||
def test_fly_from_city_keeps_origin(self) -> None:
|
||||
text = "从南京飞吉隆坡 空运 货交上海"
|
||||
guessed = parse_extract_payload(
|
||||
{
|
||||
"transport_mode": "AIR",
|
||||
"fields": {"起运港": "南京", "目的港": "吉隆坡", "品名": "普货"},
|
||||
}
|
||||
)
|
||||
facts = extract_inquiry_snapshot(text, b_result=guessed)["facts"]
|
||||
self.assertEqual(facts.get("提货地址"), "上海")
|
||||
self.assertEqual(facts.get("起运港"), "南京")
|
||||
self.assertEqual(facts.get("目的港"), "吉隆坡")
|
||||
|
||||
|
||||
class AirQuoteSheetInquiryOnlyTests(unittest.TestCase):
|
||||
def test_quote_sheet_keeps_inquiry_fields_only(self) -> None:
|
||||
"""费用明细、报价条件、落款不能进补问。起运地不能收成操作费。"""
|
||||
|
||||
@@ -123,7 +123,7 @@ class LandCopyTest(unittest.TestCase):
|
||||
self.assertIn("始发站(必填):广州", text)
|
||||
self.assertIn(copy.CLARIFY_PASTE_HINT, text)
|
||||
self.assertIn("1.体积(CBM)(必填):\n(如:100CBM)", text)
|
||||
self.assertIn("2.通关口岸(非必填):\n(如:皇岗)", text)
|
||||
self.assertIn("2.通关口岸(非必填):\n(如:友谊关)", text)
|
||||
self.assertNotIn("体积(必填):(如:", text)
|
||||
self.assertNotIn(copy.LAND_CONFIRM_TAIL, text)
|
||||
|
||||
|
||||
@@ -360,6 +360,84 @@ class LandGroupCollabTests(unittest.TestCase):
|
||||
"只有这张单的销售能调价。航线请继续报价,价格未改。",
|
||||
)
|
||||
|
||||
def test_land_bare_price_defaults_to_land_fee(self) -> None:
|
||||
"""陆运「价格为1RMB/KG」无费用名时默认陆运费,禁止落成海运费(WO118)。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
land = extract_product_quote("价格为1RMB/KG", business_line="LAND")
|
||||
rows = list(land.get("fee_rows") or [])
|
||||
self.assertTrue(rows)
|
||||
self.assertEqual(str(rows[0].get("name") or ""), "陆运费")
|
||||
self.assertEqual(str(land.get("total") or ""), "1")
|
||||
sea = extract_product_quote("价格为1RMB/KG", business_line="SEA")
|
||||
self.assertEqual(str((sea.get("fee_rows") or [{}])[0].get("name") or ""), "海运费")
|
||||
air = extract_product_quote("价格为1RMB/KG", business_line="AIR")
|
||||
self.assertEqual(str((air.get("fee_rows") or [{}])[0].get("name") or ""), "空运费")
|
||||
|
||||
def test_land_adjust_without_currency_inherits_tms_hkd(self) -> None:
|
||||
"""WO202610090107:TMS 港币,产品只写「运输费调整为1100」时继承 HKD,禁止默认 USD。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
from agent.policy.sea_group_ops import _quote_currency_hint, merge_quote_fees
|
||||
|
||||
tms = {
|
||||
"currency": "HKD",
|
||||
"cost": "HKD 1000",
|
||||
"total": "1000",
|
||||
"source": "TMS",
|
||||
"fee_lines": [
|
||||
{
|
||||
"name": "运输费",
|
||||
"amount": "1000",
|
||||
"currency": "HKD",
|
||||
}
|
||||
],
|
||||
"landSegments": [
|
||||
{
|
||||
"origin": "广东省-深圳市-福田区",
|
||||
"destination": "香港特别行政区",
|
||||
"currency": "HKD",
|
||||
"costPrice": "1000",
|
||||
}
|
||||
],
|
||||
}
|
||||
self.assertEqual(_quote_currency_hint(tms), "HKD")
|
||||
# 旧行为:无 baseline 会落到 USD
|
||||
legacy = extract_product_quote("运输费调整为1100", business_line="LAND")
|
||||
self.assertNotEqual(str(legacy.get("currency") or ""), "USD")
|
||||
quote = extract_product_quote(
|
||||
"运输费调整为1100",
|
||||
business_line="LAND",
|
||||
baseline_currency=_quote_currency_hint(tms),
|
||||
)
|
||||
self.assertEqual(str(quote.get("currency") or ""), "HKD")
|
||||
self.assertEqual(str((quote.get("fee_lines") or [{}])[0].get("currency") or ""), "HKD")
|
||||
self.assertEqual(str((quote.get("fee_lines") or [{}])[0].get("amount") or ""), "1100")
|
||||
merged = merge_quote_fees(tms, quote)
|
||||
self.assertEqual(str((merged.get("fee_lines") or [{}])[0].get("currency") or ""), "HKD")
|
||||
self.assertEqual(str(merged.get("currency") or ""), "HKD")
|
||||
|
||||
def test_land_requote_replaces_wrong_sea_fee_name(self) -> None:
|
||||
"""旧陆运产品价误落海运费时,重报陆运费应盖掉而不是并排两行。"""
|
||||
from agent.policy.sea_group_ops import _align_wrong_default_freight, merge_quote_fees
|
||||
|
||||
base = {
|
||||
"fee_rows": [{"name": "海运费", "amount": "1", "currency": "CNY"}],
|
||||
"fee_lines": [{"name": "海运费", "amount": "1", "currency": "CNY"}],
|
||||
"total": "1",
|
||||
"source_label": "产品报价",
|
||||
}
|
||||
overlay = {
|
||||
"fee_rows": [{"name": "陆运费", "amount": "2", "currency": "CNY"}],
|
||||
"fee_lines": [{"name": "陆运费", "amount": "2", "currency": "CNY"}],
|
||||
"total": "2",
|
||||
"source_label": "产品报价",
|
||||
}
|
||||
aligned = _align_wrong_default_freight(base, overlay, business_line="LAND")
|
||||
merged = merge_quote_fees(aligned, overlay)
|
||||
names = [str(x.get("name") or "") for x in (merged.get("fee_rows") or [])]
|
||||
self.assertEqual(names, ["陆运费"])
|
||||
self.assertEqual(str((merged.get("fee_rows") or [{}])[0].get("amount") or ""), "2")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -433,6 +433,41 @@ class LandRouteOptionsTests(unittest.TestCase):
|
||||
self.assertIn("CNY 7500", page)
|
||||
self.assertIn("成本价合计", page)
|
||||
|
||||
def test_h5_shows_vehicle_type_on_each_leg(self) -> None:
|
||||
"""多条线路的 H5 卡片按段展示 TMS 车型,没有就写 -。"""
|
||||
from agent.channel.h5.land_route_page import render_land_route_page
|
||||
|
||||
quote = _three_routes()
|
||||
for leg in quote["landSegments"]:
|
||||
dest = str(leg.get("destination") or "")
|
||||
origin = str(leg.get("origin") or "")
|
||||
if dest == "深圳":
|
||||
leg["vehicleType"] = "9米6"
|
||||
elif origin == "深圳":
|
||||
leg["vehicleType"] = "13米"
|
||||
elif dest == "广西":
|
||||
leg["vehicleType"] = None
|
||||
quote["fee_lines"] = _tms_land_fees(
|
||||
(1, 1, "运输费", 7500),
|
||||
(1, 2, "运输费", 9600),
|
||||
(2, 1, "运输费", 7500),
|
||||
(2, 2, "运输费", 9600),
|
||||
(3, 1, "运输费", 7500),
|
||||
(3, 2, "运输费", 9600),
|
||||
)
|
||||
page = render_land_route_page(
|
||||
{"work_order_no": "WO202609220010", "quote": quote}
|
||||
)
|
||||
od = page.find("上海 → 深圳")
|
||||
vehicle = page.find("车型 9米6", od)
|
||||
fee = page.find("运输费", vehicle)
|
||||
self.assertGreaterEqual(od, 0)
|
||||
self.assertGreater(vehicle, od)
|
||||
self.assertGreater(fee, vehicle)
|
||||
self.assertIn("车型 13米", page)
|
||||
self.assertIn("车型 -", page)
|
||||
self.assertNotIn("9米6/1", page)
|
||||
|
||||
def test_h5_and_detail_use_tms_fees_when_java_sends_json_strings(self) -> None:
|
||||
"""主账 quote 常是 JSON 串,且 fee_lines 可能没带段号。"""
|
||||
from agent.channel.h5.land_route_page import render_land_route_page
|
||||
|
||||
@@ -177,6 +177,7 @@ class QuoteTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(phase, "multi_deal_sent")
|
||||
self.assertIn("120", self.client.texts[-1])
|
||||
self.assertNotIn("纯文字", self.client.texts[-1])
|
||||
self.assertNotIn("100", deal_text(
|
||||
work_order_no=self.ticket.work_order_no,
|
||||
segments=[Segment(index=1, mode="SEA"), Segment(index=2, mode="LAND")],
|
||||
@@ -198,6 +199,8 @@ class QuoteTests(unittest.TestCase):
|
||||
brief = self.client.texts[-1]
|
||||
self.assertIn("段1:海运", brief)
|
||||
self.assertIn("段2:陆运", brief)
|
||||
self.assertIn("产品人员可通过“纯文字”告知新报价", brief)
|
||||
self.assertNotIn("航线或产品人员", brief)
|
||||
self.assertNotIn("TMS报价", brief)
|
||||
self.assertNotIn("其他段报完", brief)
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=no).collab_chat_id, "room-9")
|
||||
@@ -265,6 +268,8 @@ class QuoteTests(unittest.TestCase):
|
||||
self.assertIn("段2:空运", brief)
|
||||
self.assertIn("目的地(必填):CRK", brief)
|
||||
self.assertIn("贸易条款(非必填):DAP", brief)
|
||||
self.assertTrue(brief.endswith("(注:附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统)"))
|
||||
self.assertIn("航线或产品人员可通过“纯文字”告知新报价", brief)
|
||||
self.assertNotIn("其他段报完", "\n".join(self.client.texts))
|
||||
self.assertNotIn("询价字段:", brief)
|
||||
|
||||
|
||||
@@ -167,6 +167,10 @@ class SplitTests(unittest.TestCase):
|
||||
)
|
||||
self.assertIn("@吴季林@林绵助\n段1:", text)
|
||||
self.assertNotIn("@林绵助\n\n段1", text)
|
||||
self.assertTrue(
|
||||
text.endswith(copy.multi_group_field_hint(includes_air=True)),
|
||||
text,
|
||||
)
|
||||
|
||||
def test_missing_fields_use_single_chat_examples(self) -> None:
|
||||
outcome = split_segments("段1:海运\n起运港:广州港\n段2:空运\n目的地:CRK")
|
||||
@@ -694,6 +698,11 @@ class PullTests(unittest.TestCase):
|
||||
segments=flow._segments(flow.session_of("sales")),
|
||||
)
|
||||
self.assertNotIn("TMS", brief_text)
|
||||
self.assertTrue(
|
||||
brief_text.endswith(copy.multi_group_field_hint(includes_air=False)),
|
||||
brief_text,
|
||||
)
|
||||
self.assertNotIn("航线或产品人员", brief_text)
|
||||
|
||||
|
||||
class CardPullTests(unittest.TestCase):
|
||||
|
||||
@@ -65,7 +65,7 @@ class TestSystemExceptionCopy(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
copy.system_exception_impact(copy.STEP_TMS_RELEASE),
|
||||
"释放舱位失败,工单状态保持不变",
|
||||
"取消舱位失败,工单状态保持不变",
|
||||
)
|
||||
|
||||
def test_alert_title_never_has_test_prefix(self) -> None:
|
||||
@@ -118,9 +118,9 @@ class TestSystemExceptionCopy(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
text,
|
||||
"工单WO202609210001 空运释放舱位失败。\n"
|
||||
"工单WO202609210001 空运取消舱位失败。\n"
|
||||
"操作人:吴季林\n"
|
||||
"释放舱位失败的原因:舱位不存在\n"
|
||||
"取消舱位失败的原因:舱位不存在\n"
|
||||
"已通知IT运维排查,请稍等。",
|
||||
)
|
||||
|
||||
@@ -409,7 +409,7 @@ class TestConfirmSystemException(unittest.TestCase):
|
||||
)
|
||||
card = queued[0]["payload"]["template_card"]
|
||||
values = {row["keyname"]: row["value"] for row in card["horizontal_content_list"]}
|
||||
self.assertEqual(values["影响范围"], "释放舱位失败,工单状态保持不变")
|
||||
self.assertEqual(values["影响范围"], "取消舱位失败,工单状态保持不变")
|
||||
|
||||
def test_closed_ticket_not_marked(self) -> None:
|
||||
self.ledger.transition(work_order_no=self.wo, to_status="已关闭")
|
||||
|
||||
+5
-2
@@ -27,7 +27,10 @@ public class InquiryDashboardController {
|
||||
|
||||
@ApiOperation("看板指标")
|
||||
@GetMapping("/metrics")
|
||||
public Result<Map<String, Object>> metrics(@RequestParam(name = "range", defaultValue = "today") String range) {
|
||||
return Result.OK(dashboardService.metrics(range));
|
||||
public Result<Map<String, Object>> metrics(
|
||||
@RequestParam(name = "range", defaultValue = "today") String range,
|
||||
@RequestParam(name = "dateFrom", required = false) String dateFrom,
|
||||
@RequestParam(name = "dateTo", required = false) String dateTo) {
|
||||
return Result.OK(dashboardService.metrics(range, dateFrom, dateTo));
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -9,9 +9,13 @@ public interface IInquiryDashboardService {
|
||||
|
||||
/**
|
||||
* 返回看板指标:metrics + trend + rankings + costDates/costRoutes。
|
||||
* rankings 与工单列表同套发起人解析;costRoutes 为近 7 日线路均价(主账工单投影)。
|
||||
* rankings 与工单列表同套发起人解析;costRoutes 为选定周期内线路均价(主账工单投影)。
|
||||
* <p>
|
||||
* 有 dateFrom/dateTo 时以显式日期为准(与工单列表筛选同源);否则回退 range=today|7d|30d。
|
||||
*
|
||||
* @param range today|7d|30d,默认 today
|
||||
* @param range today|7d|30d,默认 today(无显式日期时)
|
||||
* @param dateFrom 起日 yyyy-MM-dd,可空
|
||||
* @param dateTo 止日 yyyy-MM-dd,可空
|
||||
*/
|
||||
Map<String, Object> metrics(String range);
|
||||
Map<String, Object> metrics(String range, String dateFrom, String dateTo);
|
||||
}
|
||||
|
||||
+47
-27
@@ -78,19 +78,14 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
private InquiryQuoteMapper quoteMapper;
|
||||
|
||||
@Override
|
||||
public Map<String, Object> metrics(String range) {
|
||||
LocalDate today = LocalDate.now(ZONE);
|
||||
LocalDate fromDate;
|
||||
if ("7d".equalsIgnoreCase(range)) {
|
||||
fromDate = today.minusDays(6);
|
||||
} else if ("30d".equalsIgnoreCase(range)) {
|
||||
fromDate = today.minusDays(29);
|
||||
} else {
|
||||
fromDate = today;
|
||||
}
|
||||
public Map<String, Object> metrics(String range, String dateFromStr, String dateToStr) {
|
||||
LocalDate[] bounds = resolveMetricBounds(range, dateFromStr, dateToStr);
|
||||
LocalDate fromDate = bounds[0];
|
||||
LocalDate toDate = bounds[1];
|
||||
Date from = Date.from(fromDate.atStartOfDay(ZONE).toInstant());
|
||||
Date to = Date.from(LocalDateTime.of(today, LocalTime.MAX).atZone(ZONE).toInstant());
|
||||
Date to = Date.from(LocalDateTime.of(toDate, LocalTime.MAX).atZone(ZONE).toInstant());
|
||||
|
||||
// 指标 / 排名 / 趋势 / 成本:同一批工单、同一日期窗,与工单列表 dateFrom~dateTo 对齐
|
||||
LambdaQueryWrapper<InquiryTicket> qw = new LambdaQueryWrapper<InquiryTicket>()
|
||||
.ge(InquiryTicket::getCreateTime, from)
|
||||
.le(InquiryTicket::getCreateTime, to);
|
||||
@@ -127,28 +122,24 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
metricCards.add(card("avgResp", "内部报价平均响应",
|
||||
"AVG(首次有效回复时间 − 分发时间)", avgResp, true));
|
||||
|
||||
// 近 7 日趋势 + 成本:一次拉周内工单,避免 7 次 count
|
||||
Date weekFrom = Date.from(today.minusDays(6).atStartOfDay(ZONE).toInstant());
|
||||
LambdaQueryWrapper<InquiryTicket> weekQw = new LambdaQueryWrapper<InquiryTicket>()
|
||||
.ge(InquiryTicket::getCreateTime, weekFrom)
|
||||
.le(InquiryTicket::getCreateTime, to);
|
||||
InquiryDataScopeSupport.applySelfOnly(weekQw);
|
||||
List<InquiryTicket> weekTickets = ticketMapper.selectList(weekQw);
|
||||
|
||||
List<String> trendDates = new ArrayList<>(7);
|
||||
List<Map<String, Object>> trend = new ArrayList<>(7);
|
||||
// 趋势 / 成本:按选定起止日逐日铺点(单日则一点;长周期最多 62 天防图爆炸)
|
||||
List<String> trendDates = new ArrayList<>();
|
||||
List<Map<String, Object>> trend = new ArrayList<>();
|
||||
Map<LocalDate, Integer> dayIndex = new HashMap<>();
|
||||
for (int i = 6; i >= 0; i--) {
|
||||
LocalDate d = today.minusDays(i);
|
||||
dayIndex.put(d, trendDates.size());
|
||||
String label = d.format(DAY_FMT);
|
||||
LocalDate cursor = fromDate;
|
||||
int dayGuard = 0;
|
||||
while (!cursor.isAfter(toDate) && dayGuard < 62) {
|
||||
dayIndex.put(cursor, trendDates.size());
|
||||
String label = cursor.format(DAY_FMT);
|
||||
trendDates.add(label);
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("date", label);
|
||||
p.put("value", 0L);
|
||||
trend.add(p);
|
||||
cursor = cursor.plusDays(1);
|
||||
dayGuard++;
|
||||
}
|
||||
for (InquiryTicket t : weekTickets) {
|
||||
for (InquiryTicket t : tickets) {
|
||||
LocalDate d = toLocalDate(t.getCreateTime());
|
||||
Integer idx = d == null ? null : dayIndex.get(d);
|
||||
if (idx == null) {
|
||||
@@ -174,10 +165,12 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
}
|
||||
|
||||
List<Map<String, Object>> rankings = buildRankings(tickets, staffByWecom, staffByName);
|
||||
List<Map<String, Object>> costRoutes = buildCostRoutes(weekTickets, dayIndex, trendDates);
|
||||
List<Map<String, Object>> costRoutes = buildCostRoutes(tickets, dayIndex, trendDates);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("range", range == null ? "today" : range);
|
||||
result.put("dateFrom", fromDate.toString());
|
||||
result.put("dateTo", toDate.toString());
|
||||
result.put("metrics", metricCards);
|
||||
result.put("trend", trend);
|
||||
result.put("rankings", rankings);
|
||||
@@ -186,6 +179,33 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析看板统计起止日。
|
||||
* 优先用显式 dateFrom/dateTo(与工单列表同源);否则按 range=today|7d|30d 相对「今天」。
|
||||
*/
|
||||
private static LocalDate[] resolveMetricBounds(String range, String dateFromStr, String dateToStr) {
|
||||
if (StringUtils.isNotBlank(dateFromStr) && StringUtils.isNotBlank(dateToStr)) {
|
||||
LocalDate fromDate = LocalDate.parse(dateFromStr.trim());
|
||||
LocalDate toDate = LocalDate.parse(dateToStr.trim());
|
||||
if (toDate.isBefore(fromDate)) {
|
||||
LocalDate tmp = fromDate;
|
||||
fromDate = toDate;
|
||||
toDate = tmp;
|
||||
}
|
||||
return new LocalDate[]{fromDate, toDate};
|
||||
}
|
||||
LocalDate today = LocalDate.now(ZONE);
|
||||
LocalDate fromDate;
|
||||
if ("7d".equalsIgnoreCase(range)) {
|
||||
fromDate = today.minusDays(6);
|
||||
} else if ("30d".equalsIgnoreCase(range)) {
|
||||
fromDate = today.minusDays(29);
|
||||
} else {
|
||||
fromDate = today;
|
||||
}
|
||||
return new LocalDate[]{fromDate, today};
|
||||
}
|
||||
|
||||
/**
|
||||
* 人员排名:与工单列表 enrichDisplayFields 同口径。
|
||||
* 聚合键优先 salesWecomId(同人合并),展示名走 resolveSalesDisplayName。
|
||||
|
||||
+49
-10
@@ -235,6 +235,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
|
||||
int nextVer = nextVersionNo(template.getId());
|
||||
String sha = DigestUtils.sha256Hex(fileBytes);
|
||||
// 本地 by-sha 镜像:MinIO 对象损坏时仍能按版本 sha 回退出完好模板
|
||||
objectStore.writeByShaMirror(fileBytes, sha);
|
||||
|
||||
InquiryQuoteTemplateVersion version = new InquiryQuoteTemplateVersion();
|
||||
version.setTemplateId(template.getId());
|
||||
@@ -564,7 +566,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
if (!"excel".equals(ver.getFormat())) {
|
||||
throw new IllegalStateException("仅 Excel 支持样例回填预览");
|
||||
}
|
||||
Path source = resolveObjectPath(ver.getObjectKey());
|
||||
Path source = resolveObjectPath(ver.getObjectKey(), ver.getSha256());
|
||||
JSONObject contract = QuoteTemplateExcelSupport.loadContract(resolveContractPath());
|
||||
JSONObject mapping;
|
||||
try {
|
||||
@@ -599,7 +601,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
if (!"excel".equals(ver.getFormat())) {
|
||||
throw new IllegalStateException("仅 Excel 支持表格预览");
|
||||
}
|
||||
Path source = resolveObjectPath(ver.getObjectKey());
|
||||
Path source = resolveObjectPath(ver.getObjectKey(), ver.getSha256());
|
||||
JSONObject mapping;
|
||||
try {
|
||||
mapping = StringUtils.isNotBlank(ver.getMappingJson())
|
||||
@@ -681,7 +683,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
Files.createDirectories(previewDir);
|
||||
Path cachedPng = previewDir.resolve(ver.getId() + "-snapshot-v5.png");
|
||||
Path cachedPdf = previewDir.resolve(ver.getId() + "-snapshot-v5.pdf");
|
||||
Path source = resolveObjectPath(ver.getObjectKey());
|
||||
Path source = resolveObjectPath(ver.getObjectKey(), ver.getSha256());
|
||||
QuoteTemplateSnapshotRenderer.PreviewBytes rendered = snapshotRenderer.render(source, ver.getId());
|
||||
Path target = rendered.isImage() ? cachedPng : cachedPdf;
|
||||
Files.write(target, rendered.getBytes());
|
||||
@@ -696,6 +698,13 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
return objectStore.resolve(objectKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 带版本 sha256 的解析:远程缓存/对象与指纹不一致时删缓存并回退 by-sha 镜像。
|
||||
*/
|
||||
public Path resolveObjectPath(String objectKey, String expectedSha256) {
|
||||
return objectStore.resolve(objectKey, expectedSha256);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> listEnabledForAgent(boolean enabledOnly) {
|
||||
QueryWrapper<InquiryQuoteTemplate> qw = new QueryWrapper<>();
|
||||
@@ -750,13 +759,16 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
}
|
||||
String objectKey = String.valueOf(picked.getOrDefault("objectKey", ""));
|
||||
String mappingJson = String.valueOf(picked.getOrDefault("mappingJson", ""));
|
||||
String expectedSha = String.valueOf(picked.getOrDefault("sha256", ""));
|
||||
if (StringUtils.isBlank(objectKey) || "null".equalsIgnoreCase(objectKey)) {
|
||||
log.warn("报价出单模板无文件 wo={} template={}", workOrderNo, picked.get("templateId"));
|
||||
return fail;
|
||||
}
|
||||
Path source;
|
||||
try {
|
||||
source = resolveObjectPath(objectKey);
|
||||
// 传入版本 sha:防止 MinIO/缓存被掏空后仍出「费目名全空」的坏表
|
||||
source = resolveObjectPath(objectKey,
|
||||
("null".equalsIgnoreCase(expectedSha) ? null : expectedSha));
|
||||
} catch (Exception e) {
|
||||
log.warn("报价出单找不到模板文件 wo={} err={}", workOrderNo, e.getMessage());
|
||||
return fail;
|
||||
@@ -1043,6 +1055,18 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
out.put("chargeable_weight_kg", chargeable);
|
||||
out.put("chargeableWeightKg", chargeable);
|
||||
}
|
||||
// 重量单位:仅供「已有费用行」时写入报价明细「单位」;无报价不出费用行,也不填单位。
|
||||
String weightUnitEarly = first(src, "重量单位", "weight_unit", "weightUnit", "毛重单位");
|
||||
if (StringUtils.isBlank(weightUnitEarly)) {
|
||||
String rawW = first(src, "毛重", "重量", "重量(KG)", "weight_kg");
|
||||
if (StringUtils.isNotBlank(rawW) && rawW.matches("(?i).*\\b(KG|KGS|公斤)\\b.*")) {
|
||||
weightUnitEarly = "KG";
|
||||
}
|
||||
}
|
||||
if (looksAirInquiry(src, q, businessLine)) {
|
||||
out.put("weight_unit", QuoteTemplateExcelSupport.normalizeWeightUnit(weightUnitEarly));
|
||||
out.put("重量单位", out.getString("weight_unit"));
|
||||
}
|
||||
for (Map.Entry<String, String> e : QuoteTemplateExcelSupport.LAND_TMS_KEY_TO_CODE.entrySet()) {
|
||||
String val = first(q, e.getKey(), e.getValue());
|
||||
if (StringUtils.isNotBlank(val) && !"-".equals(val) && StringUtils.isBlank(out.getString(e.getValue()))) {
|
||||
@@ -1080,8 +1104,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
}
|
||||
}
|
||||
}
|
||||
// 空运:已落库的旧明细若只有费率,出单时按计费重补 单位/单价/数量/行金额
|
||||
if (!rows.isEmpty() && StringUtils.isNotBlank(chargeable)) {
|
||||
// 空运:补单位/数量/单价(手工常只有金额,WO020 空运费与增点费)
|
||||
if (!rows.isEmpty()) {
|
||||
List<Map<String, String>> lineMaps = new ArrayList<>();
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
JSONObject one = rows.getJSONObject(i);
|
||||
@@ -1097,8 +1121,15 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
}
|
||||
lineMaps.add(m);
|
||||
}
|
||||
// 出单路径自带重算(单位/单价/数量/金额)
|
||||
QuoteTemplateExcelSupport.enrichAirWeightFeeRows(lineMaps, chargeable);
|
||||
String weightUnit = first(src, "重量单位", "weight_unit", "weightUnit", "毛重单位");
|
||||
if (StringUtils.isBlank(weightUnit)) {
|
||||
// 从「262 KG」「重量:262KG」里剥单位
|
||||
String rawW = first(src, "毛重", "重量", "重量(KG)", "weight_kg");
|
||||
if (StringUtils.isNotBlank(rawW) && rawW.matches("(?i).*\\b(KG|KGS|公斤)\\b.*")) {
|
||||
weightUnit = "KG";
|
||||
}
|
||||
}
|
||||
QuoteTemplateExcelSupport.enrichAirWeightFeeRows(lineMaps, chargeable, weightUnit);
|
||||
JSONArray enriched = new JSONArray();
|
||||
for (Map<String, String> m : lineMaps) {
|
||||
// Fastjson 只要 Map<String,Object>,不能直接塞 Map<String,String>。
|
||||
@@ -1145,6 +1176,14 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
if (!routeRows.isEmpty()) {
|
||||
QuoteTemplateExcelSupport.copyMissingWeightPrices(landSegs, rawLand);
|
||||
}
|
||||
// 销售已选线路透传:跨境整车国内/境外段按 legs 写完整起讫(多备选混在 route_rows 时不能靠同序列过滤)
|
||||
Object selectedLand = q.get("selected_land_route");
|
||||
if (selectedLand == null) {
|
||||
selectedLand = q.get("selectedLandRoute");
|
||||
}
|
||||
if (selectedLand != null) {
|
||||
out.put("selected_land_route", selectedLand);
|
||||
}
|
||||
if (!landSegs.isEmpty()) {
|
||||
out.put("landSegments", landSegs);
|
||||
out.put("route_rows", landSegs);
|
||||
@@ -1573,7 +1612,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
throw new IllegalArgumentException("模板不存在");
|
||||
}
|
||||
try {
|
||||
Path local = resolveObjectPath(ver.getObjectKey());
|
||||
Path local = resolveObjectPath(ver.getObjectKey(), ver.getSha256());
|
||||
byte[] fileBytes = Files.readAllBytes(local);
|
||||
ver.setParseStatus("pending");
|
||||
ver.setParseMessage("解析中,请稍候");
|
||||
@@ -1642,7 +1681,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
org.apache.poi.ss.usermodel.Workbook wb = null;
|
||||
try {
|
||||
if (StringUtils.isNotBlank(ver.getObjectKey())) {
|
||||
Path local = resolveObjectPath(ver.getObjectKey());
|
||||
Path local = resolveObjectPath(ver.getObjectKey(), ver.getSha256());
|
||||
try (InputStream in = Files.newInputStream(local)) {
|
||||
wb = org.apache.poi.ss.usermodel.WorkbookFactory.create(in);
|
||||
}
|
||||
|
||||
+783
-47
File diff suppressed because it is too large
Load Diff
+128
-9
@@ -22,7 +22,8 @@ import java.util.Locale;
|
||||
* <p>
|
||||
* Jeecg MinIO/OSS 上传返回完整 http(s) URL,Windows 上 {@code Paths.get("http://...")}
|
||||
* 会在冒号处抛 Illegal char <:>。本类先认远程 URL,拉到本地缓存再给 POI / 下载。
|
||||
* 副作用:写 {@code upload/inquiry/quote-templates/_remote-cache/}。
|
||||
* 副作用:写 {@code upload/inquiry/quote-templates/_remote-cache/},以及
|
||||
* {@code upload/inquiry/quote-templates/by-sha/{sha256}.xlsx} 本地镜像(上传时写入,远程损坏时可回退)。
|
||||
* 线程:跟随调用方 HTTP 线程;不持有全局锁。
|
||||
*/
|
||||
@Slf4j
|
||||
@@ -70,18 +71,75 @@ public class QuoteTemplateObjectStore {
|
||||
* @return 已存在的本地文件,或从对象存储落地后的缓存文件
|
||||
*/
|
||||
public Path resolve(String objectKey) {
|
||||
return resolve(objectKey, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模板文件,并在提供 expectedSha256 时校验内容指纹。
|
||||
* <p>
|
||||
* 为何:MinIO 对象偶发被掏空/覆盖后,旧逻辑只要缓存「非空」就直接用,导致空运报价费目名全空。
|
||||
* 有 sha 时:缓存/远程不匹配则删缓存重拉;仍不匹配则回退 {@code by-sha/{sha}.xlsx}。
|
||||
*
|
||||
* @param objectKey 主账 version.object_key(本地相对路径或 http(s) URL)
|
||||
* @param expectedSha256 版本表记录的 sha256(可空;空则只做路径解析,与历史行为一致)
|
||||
* @return 可读本地文件
|
||||
*/
|
||||
public Path resolve(String objectKey, String expectedSha256) {
|
||||
if (StringUtils.isBlank(objectKey)) {
|
||||
throw new IllegalArgumentException("objectKey 空");
|
||||
}
|
||||
String key = objectKey.trim();
|
||||
String want = normalizeSha(expectedSha256);
|
||||
|
||||
if (want != null) {
|
||||
Path bySha = tryByShaMirror(want);
|
||||
if (bySha != null) {
|
||||
return bySha;
|
||||
}
|
||||
}
|
||||
|
||||
Path resolved;
|
||||
if (isRemoteUrl(key)) {
|
||||
return fetchRemoteToCache(key);
|
||||
resolved = fetchRemoteToCache(key, want);
|
||||
} else {
|
||||
resolved = tryLocalFile(key);
|
||||
if (resolved == null) {
|
||||
throw new IllegalStateException("找不到模板文件: " + key);
|
||||
}
|
||||
if (want != null && !shaMatches(resolved, want)) {
|
||||
Path fallback = tryByShaMirror(want);
|
||||
if (fallback != null) {
|
||||
log.warn("本地模板 sha 不匹配,改用 by-sha 镜像 key={}", key);
|
||||
return fallback;
|
||||
}
|
||||
throw new IllegalStateException("模板文件 sha256 不匹配: " + key);
|
||||
}
|
||||
}
|
||||
Path local = tryLocalFile(key);
|
||||
if (local != null) {
|
||||
return local;
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传成功后写一份按内容指纹命名的本地镜像,供远程损坏时回退。
|
||||
* <p>
|
||||
* 副作用:写 {@code upload/inquiry/quote-templates/by-sha/{sha256}.xlsx}。
|
||||
* 失败只记日志,不阻断上传主流程。
|
||||
*/
|
||||
public void writeByShaMirror(byte[] fileBytes, String sha256Hex) {
|
||||
String sha = normalizeSha(sha256Hex);
|
||||
if (fileBytes == null || fileBytes.length == 0 || sha == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Path dir = Paths.get(StringUtils.defaultIfBlank(uploadPath, System.getProperty("java.io.tmpdir")),
|
||||
BIZ_PATH, "by-sha");
|
||||
Files.createDirectories(dir);
|
||||
Path target = dir.resolve(sha + ".xlsx");
|
||||
Files.write(target, fileBytes);
|
||||
log.info("模板 by-sha 镜像已写入 sha={} bytes={}", sha.substring(0, 12), fileBytes.length);
|
||||
} catch (Exception e) {
|
||||
log.warn("模板 by-sha 镜像写入失败 sha={} err={}", sha, e.getMessage());
|
||||
}
|
||||
throw new IllegalStateException("找不到模板文件: " + key);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,9 +196,11 @@ public class QuoteTemplateObjectStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 MinIO/OSS 拉到本地缓存。同一 URL 只落一次(文件名带时间戳,一般不会覆盖)。
|
||||
* 从 MinIO/OSS 拉到本地缓存。同一 URL 缓存文件名 = sha256(url)。
|
||||
* <p>
|
||||
* 有 expectedSha 时:已有缓存若指纹不对则删除重拉;重拉后仍不对则回退 by-sha 镜像。
|
||||
*/
|
||||
private Path fetchRemoteToCache(String url) {
|
||||
private Path fetchRemoteToCache(String url, String expectedSha) {
|
||||
Path cacheDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, System.getProperty("java.io.tmpdir")),
|
||||
BIZ_PATH, "_remote-cache");
|
||||
try {
|
||||
@@ -152,7 +212,11 @@ public class QuoteTemplateObjectStore {
|
||||
Path cacheFile = cacheDir.resolve(DigestUtils.sha256Hex(url) + ext);
|
||||
try {
|
||||
if (Files.isRegularFile(cacheFile) && Files.size(cacheFile) > 0) {
|
||||
return cacheFile;
|
||||
if (expectedSha == null || shaMatches(cacheFile, expectedSha)) {
|
||||
return cacheFile;
|
||||
}
|
||||
log.warn("模板远程缓存 sha 不匹配,删除后重拉 file={}", cacheFile.getFileName());
|
||||
Files.deleteIfExists(cacheFile);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 读缓存失败则重新拉取
|
||||
@@ -161,11 +225,66 @@ public class QuoteTemplateObjectStore {
|
||||
Files.copy(in, cacheFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (Exception e) {
|
||||
log.error("对象存储落地失败 url={}", url, e);
|
||||
if (expectedSha != null) {
|
||||
Path fallback = tryByShaMirror(expectedSha);
|
||||
if (fallback != null) {
|
||||
log.warn("对象存储读取失败,改用 by-sha 镜像 url={}", url);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("对象存储读取失败: " + e.getMessage(), e);
|
||||
}
|
||||
if (expectedSha != null && !shaMatches(cacheFile, expectedSha)) {
|
||||
log.error("对象存储模板 sha256 与版本表不一致 urlHash={}",
|
||||
DigestUtils.sha256Hex(url).substring(0, 12));
|
||||
try {
|
||||
Files.deleteIfExists(cacheFile);
|
||||
} catch (Exception ignored) {
|
||||
// ignore
|
||||
}
|
||||
Path fallback = tryByShaMirror(expectedSha);
|
||||
if (fallback != null) {
|
||||
log.warn("远程模板损坏,改用 by-sha 镜像");
|
||||
return fallback;
|
||||
}
|
||||
throw new IllegalStateException("对象存储模板内容与版本 sha256 不一致");
|
||||
}
|
||||
return cacheFile;
|
||||
}
|
||||
|
||||
private Path tryByShaMirror(String sha) {
|
||||
if (StringUtils.isBlank(sha)) {
|
||||
return null;
|
||||
}
|
||||
Path p = Paths.get(StringUtils.defaultIfBlank(uploadPath, System.getProperty("java.io.tmpdir")),
|
||||
BIZ_PATH, "by-sha", sha + ".xlsx");
|
||||
try {
|
||||
if (Files.isRegularFile(p) && Files.size(p) > 0 && shaMatches(p, sha)) {
|
||||
return p;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String normalizeSha(String sha) {
|
||||
if (StringUtils.isBlank(sha)) {
|
||||
return null;
|
||||
}
|
||||
String s = sha.trim().toLowerCase(Locale.ROOT);
|
||||
return s.matches("[0-9a-f]{64}") ? s : null;
|
||||
}
|
||||
|
||||
private static boolean shaMatches(Path file, String expectedSha) {
|
||||
try {
|
||||
String actual = DigestUtils.sha256Hex(Files.readAllBytes(file)).toLowerCase(Locale.ROOT);
|
||||
return expectedSha.equals(actual);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private InputStream openRemoteStream(String url) {
|
||||
String[] parts = splitStorageUrl(url, MinioUtil.getMinioUrl());
|
||||
if (parts != null) {
|
||||
|
||||
+7
-2
@@ -11,6 +11,7 @@ import org.jeecg.modules.inquiry.entity.InquiryQuote;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryQuoteMapper;
|
||||
import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService;
|
||||
import org.jeecg.modules.inquiry.template.QuoteTemplateExcelSupport;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -294,7 +295,7 @@ public class InquiryTmsCabinService {
|
||||
return fail("release_need_lock", "本单还没有成功锁舱记录");
|
||||
}
|
||||
if (!properties.lockConfigured()) {
|
||||
return fail("lock_disabled", "主账未开放释放");
|
||||
return fail("lock_disabled", "主账未开放取消舱位");
|
||||
}
|
||||
Map<String, Object> req = new LinkedHashMap<>();
|
||||
req.put("lockId", lockId);
|
||||
@@ -363,7 +364,7 @@ public class InquiryTmsCabinService {
|
||||
return out;
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("tms.release fail wo={} err={}", ticket.getWorkOrderNo(), ex.getMessage());
|
||||
return fail("release_fail", firstNonBlank(ex.getMessage(), "释放失败"));
|
||||
return fail("release_fail", firstNonBlank(ex.getMessage(), "取消舱位失败"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1050,6 +1051,10 @@ public class InquiryTmsCabinService {
|
||||
item.put("currency", currency);
|
||||
}
|
||||
String unit = text(row, "unit", "单位");
|
||||
// 手工报价常只有金额无单位;空运运输费锁舱必须带 unit(WO007:单位未传 TMS)
|
||||
if (QuoteTemplateExcelSupport.isAirWeightFeeName(name)) {
|
||||
unit = QuoteTemplateExcelSupport.normalizeWeightUnit(unit);
|
||||
}
|
||||
if (StringUtils.isNotBlank(unit)) {
|
||||
item.put("unit", unit);
|
||||
}
|
||||
|
||||
+96
-8
@@ -20,9 +20,12 @@ import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TimeZone;
|
||||
|
||||
/**
|
||||
@@ -803,17 +806,13 @@ public class InquiryTmsQueryService {
|
||||
continue;
|
||||
}
|
||||
Map<?, ?> opt = (Map<?, ?>) optObj;
|
||||
Object charges = first(opt.get("departureCharges"), opt.get("departure_charges"),
|
||||
opt.get("charges"), opt.get("feeItems"));
|
||||
if (!(charges instanceof List)) {
|
||||
continue;
|
||||
}
|
||||
// 合并 departureCharges + feeItems,避免只取附加费丢掉空运费
|
||||
List<Map<?, ?>> charges = mergeAirOptionChargeMaps(opt);
|
||||
String optCcy = firstText(opt.get("currency"), headerCurrency);
|
||||
for (Object one : (List<?>) charges) {
|
||||
if (!(one instanceof Map)) {
|
||||
for (Map<?, ?> ch : charges) {
|
||||
if (ch == null) {
|
||||
continue;
|
||||
}
|
||||
Map<?, ?> ch = (Map<?, ?>) one;
|
||||
String name = firstText(ch.get("chargeName"), ch.get("feeName"), ch.get("name"));
|
||||
String unitPrice = firstText(ch.get("unitPrice"), ch.get("unit_price"), ch.get("salePrice"), ch.get("amount"));
|
||||
if (StringUtils.isBlank(name) || StringUtils.isBlank(unitPrice)) {
|
||||
@@ -830,6 +829,8 @@ public class InquiryTmsQueryService {
|
||||
// 先把单价写入 amount;运输费会在 enrich 里改成 单价×计费重
|
||||
addFeeLine(out, name, unitPrice, lineCcy, extras);
|
||||
}
|
||||
// departureCharges 只有附加费时,用舱位 salePrice 补空运费(与出单 parse 对齐)
|
||||
injectAirFreightFeeLineIfMissing(out, opt, headerCurrency);
|
||||
// 通常只用首个舱位选项的费用明细
|
||||
if (!out.isEmpty()) {
|
||||
break;
|
||||
@@ -838,6 +839,93 @@ public class InquiryTmsQueryService {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 合并舱位 departureCharges / feeItems,同名去重。 */
|
||||
@SuppressWarnings("unchecked")
|
||||
static List<Map<?, ?>> mergeAirOptionChargeMaps(Map<?, ?> opt) {
|
||||
List<Map<?, ?>> 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"}) {
|
||||
Object raw = opt.get(key);
|
||||
if (!(raw instanceof List)) {
|
||||
continue;
|
||||
}
|
||||
for (Object one : (List<?>) raw) {
|
||||
if (!(one instanceof Map)) {
|
||||
continue;
|
||||
}
|
||||
Map<?, ?> ch = (Map<?, ?>) one;
|
||||
String name = firstText(ch.get("chargeName"), ch.get("feeName"), ch.get("name"));
|
||||
if (StringUtils.isBlank(name)) {
|
||||
continue;
|
||||
}
|
||||
String dedupe = name.trim().toLowerCase(Locale.ROOT);
|
||||
if (!seen.add(dedupe)) {
|
||||
continue;
|
||||
}
|
||||
out.add(ch);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查价投影 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"))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
String unitPrice = firstText(opt.get("unitPrice"), opt.get("unit_price"), opt.get("pricePerKg"), opt.get("freightRate"));
|
||||
String salePrice = firstText(opt.get("salePrice"), opt.get("sale_price"), opt.get("totalSalePrice"),
|
||||
opt.get("airFreight"), opt.get("freight"));
|
||||
String cw = firstText(opt.get("chargeableWeightKg"), opt.get("chargeable_weight_kg"));
|
||||
String amount = "";
|
||||
String price = unitPrice;
|
||||
if (StringUtils.isNotBlank(unitPrice) && StringUtils.isNotBlank(cw)) {
|
||||
amount = multiplyDecimal(unitPrice, cw);
|
||||
} else if (StringUtils.isNotBlank(salePrice) && !"-".equals(salePrice)) {
|
||||
amount = salePrice;
|
||||
if (StringUtils.isBlank(price) && StringUtils.isNotBlank(cw)) {
|
||||
try {
|
||||
java.math.BigDecimal a = new java.math.BigDecimal(salePrice.trim().replace(",", ""));
|
||||
java.math.BigDecimal b = new java.math.BigDecimal(cw.trim().replace(",", ""));
|
||||
if (b.compareTo(java.math.BigDecimal.ZERO) != 0) {
|
||||
price = a.divide(b, 4, java.math.RoundingMode.HALF_UP).stripTrailingZeros().toPlainString();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
price = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (StringUtils.isBlank(amount) || "-".equals(amount)) {
|
||||
return;
|
||||
}
|
||||
String ccy = firstText(opt.get("currency"), headerCurrency);
|
||||
Map<String, String> extras = new LinkedHashMap<>();
|
||||
putExtra(extras, "project", "空运段");
|
||||
putExtra(extras, "unit_price", StringUtils.isNotBlank(price) ? price : amount);
|
||||
if (StringUtils.isNotBlank(cw)) {
|
||||
putExtra(extras, "quantity", cw);
|
||||
putExtra(extras, "unit", "KG");
|
||||
}
|
||||
// 先 append 再挪到最前:空运段在附加费之前
|
||||
int before = lines.size();
|
||||
addFeeLine(lines, "空运费", amount, ccy, extras);
|
||||
if (lines.size() > before) {
|
||||
Map<String, String> injected = lines.remove(lines.size() - 1);
|
||||
lines.add(0, injected);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 空运运输费:单价 × 计费重 = 行金额。
|
||||
* <p>
|
||||
|
||||
+342
-11
@@ -2,6 +2,7 @@ package org.jeecg.modules.inquiry.template;
|
||||
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import org.apache.poi.ss.usermodel.DataFormatter;
|
||||
import org.apache.poi.ss.usermodel.FillPatternType;
|
||||
import org.apache.poi.ss.usermodel.PrintSetup;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
@@ -38,6 +39,31 @@ public class QuoteTemplateFillTest {
|
||||
assertEquals("14669.60", rows.getJSONObject(0).getString("amount"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void manualAirFeesOnlyAmountFillsUnitPriceQtyAndUnit() {
|
||||
// WO020:手工只有金额 → 空运费补 KG/1/单价;增点费补 票/1/单价
|
||||
java.util.List<Map<String, String>> lines = new java.util.ArrayList<>();
|
||||
Map<String, String> air = new HashMap<>();
|
||||
air.put("name", "空运费");
|
||||
air.put("amount", "150");
|
||||
air.put("currency", "CNY");
|
||||
lines.add(air);
|
||||
Map<String, String> add = new HashMap<>();
|
||||
add.put("name", "增点费");
|
||||
add.put("amount", "200");
|
||||
add.put("currency", "CNY");
|
||||
add.put("project", "空运段"); // 误归空运段也要纠正
|
||||
lines.add(add);
|
||||
QuoteTemplateExcelSupport.enrichAirWeightFeeRows(lines, "", "KG");
|
||||
assertEquals("KG", lines.get(0).get("unit"));
|
||||
assertEquals("1", lines.get(0).get("quantity"));
|
||||
assertEquals("150", lines.get(0).get("unit_price"));
|
||||
assertEquals("票", lines.get(1).get("unit"));
|
||||
assertEquals("1", lines.get(1).get("quantity"));
|
||||
assertEquals("200", lines.get(1).get("unit_price"));
|
||||
assertEquals("起运港", lines.get(1).get("project"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseFeeLinesDoubleEncoded() {
|
||||
JSONArray rows = QuoteTemplateExcelSupport.parseFeeRows("[{\"name\":\"空运费\",\"amount\":\"14669.60\"}]");
|
||||
@@ -242,8 +268,8 @@ public class QuoteTemplateFillTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 回归:无 TMS 费用行时,费用表「项目=起运港」不得把 pol 写进右侧「费用名称」。
|
||||
* 对应 WO202609230026 空运协同出单:空运段旁费用名称被写成「广州」。
|
||||
* 回归:无 TMS 费用行时,不得把 pol 写进费用名称;
|
||||
* 并保留模板项目骨架「空运段/起运港」(图2),禁止掏空项目列。
|
||||
*/
|
||||
@Test
|
||||
public void airNoTmsMustNotWritePolIntoFeeNameColumn() throws Exception {
|
||||
@@ -267,13 +293,17 @@ public class QuoteTemplateFillTest {
|
||||
head.createCell(7).setCellValue("备注");
|
||||
org.apache.poi.ss.usermodel.Row airSeg = sheet.createRow(16);
|
||||
airSeg.createCell(0).setCellValue("空运段");
|
||||
airSeg.createCell(1).setCellValue("");
|
||||
airSeg.createCell(1).setCellValue("空运费");
|
||||
org.apache.poi.ss.usermodel.Row polSeg = sheet.createRow(17);
|
||||
polSeg.createCell(0).setCellValue("起运港");
|
||||
polSeg.createCell(1).setCellValue("");
|
||||
org.apache.poi.ss.usermodel.Row otherSeg = sheet.createRow(18);
|
||||
polSeg.createCell(1).setCellValue("操作费");
|
||||
org.apache.poi.ss.usermodel.Row destSeg = sheet.createRow(18);
|
||||
destSeg.createCell(0).setCellValue("目的港");
|
||||
destSeg.createCell(1).setCellValue("目的港派送费");
|
||||
org.apache.poi.ss.usermodel.Row otherSeg = sheet.createRow(19);
|
||||
otherSeg.createCell(0).setCellValue("其他");
|
||||
otherSeg.createCell(1).setCellValue("");
|
||||
otherSeg.createCell(1).setCellValue("*其他杂费");
|
||||
otherSeg.createCell(7).setCellValue("发生时收取");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
@@ -300,13 +330,22 @@ public class QuoteTemplateFillTest {
|
||||
b16 = fmt.formatCellValue(sheet.getRow(16).getCell(1));
|
||||
b17 = fmt.formatCellValue(sheet.getRow(17).getCell(1));
|
||||
b18 = fmt.formatCellValue(sheet.getRow(18).getCell(1));
|
||||
String b19 = fmt.formatCellValue(sheet.getRow(19).getCell(1));
|
||||
String h19 = fmt.formatCellValue(sheet.getRow(19).getCell(7));
|
||||
String cargoPol = fmt.formatCellValue(sheet.getRow(5).getCell(1));
|
||||
assertTrue(cargoPol.contains("广州"), "货物区起运港应写入广州,实际=" + cargoPol);
|
||||
assertFalse(b16.contains("广州"), "空运段旁费用名称不应是起运港,实际=" + b16);
|
||||
assertFalse(b17.contains("广州"), "起运港项目旁费用名称不应是起运港,实际=" + b17);
|
||||
assertFalse(b18.contains("广州"), "其他旁费用名称不应是起运港,实际=" + b18);
|
||||
assertEquals("起运港", fmt.formatCellValue(sheet.getRow(17).getCell(0)));
|
||||
assertFalse(b16.contains("广州"), "费用名称不应是起运港,实际=" + b16);
|
||||
assertFalse(b17.contains("广州"), "费用名称不应是起运港,实际=" + b17);
|
||||
assertFalse(b18.contains("广州"), "费用名称不应是起运港,实际=" + b18);
|
||||
// 无 fee_rows:项目骨架保留;样例费目名/备注清掉(有费用的才显示行)
|
||||
assertEquals("空运段", fmt.formatCellValue(sheet.getRow(16).getCell(0)));
|
||||
assertEquals("起运港", fmt.formatCellValue(sheet.getRow(17).getCell(0)));
|
||||
assertEquals("目的港", fmt.formatCellValue(sheet.getRow(18).getCell(0)));
|
||||
assertEquals("", b16.trim(), "无费用时空运费样例名应清空,实际=" + b16);
|
||||
assertEquals("", b17.trim(), "无费用时操作费样例名应清空,实际=" + b17);
|
||||
assertEquals("", b18.trim(), "无费用时目的港派送费样例名应清空,实际=" + b18);
|
||||
assertEquals("", b19.trim(), "无费用时其他杂费样例名应清空,实际=" + b19);
|
||||
assertEquals("", h19.trim(), "无费用时样例备注应清空,实际=" + h19);
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
@@ -857,6 +896,104 @@ public class QuoteTemplateFillTest {
|
||||
assertEquals("16670", QuoteTemplateExcelSupport.feeQuantityOrDefault("16670", "运输费"));
|
||||
}
|
||||
|
||||
/**
|
||||
* WO014:departureCharges 只有增点/拆板时,必须注入空运费,且项目列归类正确。
|
||||
*/
|
||||
@Test
|
||||
public void parseAirDepartureChargesInjectsFreightAndProjects() {
|
||||
String airOptions = "["
|
||||
+ "{\"optionNo\":\"AIR-OPT01\",\"salePrice\":4750.95,\"currency\":\"CNY\","
|
||||
+ "\"chargeableWeightKg\":500.1,"
|
||||
+ "\"departureCharges\":["
|
||||
+ "{\"chargeName\":\"增点费\",\"unitPrice\":100.0,\"unit\":\"点\",\"currency\":\"CNY\"},"
|
||||
+ "{\"chargeName\":\"拆板费\",\"unitPrice\":100.0,\"unit\":\"板数\",\"currency\":\"USD\"}"
|
||||
+ "]}]";
|
||||
JSONArray rows = QuoteTemplateExcelSupport.parseAirDepartureChargeRows(
|
||||
airOptions, null, "500.1", "CNY");
|
||||
assertEquals(3, rows.size());
|
||||
assertEquals("空运费", rows.getJSONObject(0).getString("name"));
|
||||
assertEquals("空运段", rows.getJSONObject(0).getString("project"));
|
||||
assertEquals("4750.95", rows.getJSONObject(0).getString("amount"));
|
||||
assertEquals("增点费", rows.getJSONObject(1).getString("name"));
|
||||
assertEquals("起运港", rows.getJSONObject(1).getString("project"));
|
||||
assertEquals("拆板费", rows.getJSONObject(2).getString("name"));
|
||||
assertEquals("起运港", rows.getJSONObject(2).getString("project"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射未标 project 列时,表头「项目」仍要写入,增点费不能留在模板样例「空运段」。
|
||||
*/
|
||||
@Test
|
||||
public void airFeeProjectColumnFilledFromHeaderWhenMappingOmitsIt() throws Exception {
|
||||
Path src = Files.createTempFile("quote-air-proj-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-air-proj-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("样例2");
|
||||
r2.createCell(6).setCellValue(1.0);
|
||||
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("name", "B");
|
||||
cols.put("unit", "C");
|
||||
cols.put("currency", "D");
|
||||
cols.put("unit_price", "E");
|
||||
cols.put("quantity", "F");
|
||||
cols.put("amount", "G");
|
||||
// 故意不写 project,验证 mergeFeeColumnsFromHeader 补 A 列
|
||||
region.put("columns", cols);
|
||||
mapping.put("feeRegions", new JSONArray(java.util.Collections.singletonList(region)));
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("total_amount", "5001");
|
||||
facts.put("currency", "CNY");
|
||||
facts.put("fee_rows", QuoteTemplateExcelSupport.parseAirDepartureChargeRows(
|
||||
"[{\"salePrice\":4750.95,\"currency\":\"CNY\",\"chargeableWeightKg\":500.1,"
|
||||
+ "\"departureCharges\":["
|
||||
+ "{\"chargeName\":\"增点费\",\"unitPrice\":100.0,\"unit\":\"点\",\"currency\":\"CNY\"},"
|
||||
+ "{\"chargeName\":\"拆板费\",\"unitPrice\":100.0,\"unit\":\"板数\",\"currency\":\"USD\"}"
|
||||
+ "]}]",
|
||||
null, "500.1", "CNY"));
|
||||
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)));
|
||||
assertEquals("空运费", fmt.formatCellValue(sheet.getRow(16).getCell(1)));
|
||||
assertEquals("起运港", fmt.formatCellValue(sheet.getRow(17).getCell(0)));
|
||||
assertEquals("增点费", fmt.formatCellValue(sheet.getRow(17).getCell(1)));
|
||||
assertEquals("起运港", fmt.formatCellValue(sheet.getRow(18).getCell(0)));
|
||||
assertEquals("拆板费", fmt.formatCellValue(sheet.getRow(18).getCell(1)));
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 出单后必须 A4 水平居中,抬头行高够放下中英文公司名。
|
||||
* 否则转 PDF 会顶部裁字、整页偏左。
|
||||
@@ -1343,6 +1480,81 @@ public class QuoteTemplateFillTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WO202610080043:TMS weightPrices 只有 min/max、整票 costPrice=18200(计价单位:次)。
|
||||
* 询价毛重 2KG 落入 2-500KG 档时写入 18200;其它公斤列保持空;有真实档内单价时不走此回退。
|
||||
*/
|
||||
@Test
|
||||
public void landGroupageEmptyWeightBandsWritesFlatFeeIntoCargoBandOnly() throws Exception {
|
||||
Path src = Files.createTempFile("quote-land-groupage-flat", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-land-groupage-flat-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("跨境集拼");
|
||||
org.apache.poi.ss.usermodel.Row matrix = row(sheet, 7);
|
||||
matrix.createCell(1).setCellValue("始发地");
|
||||
matrix.createCell(2).setCellValue("目的地");
|
||||
matrix.createCell(3).setCellValue("最低收费");
|
||||
matrix.createCell(4).setCellValue("2-500KG");
|
||||
matrix.createCell(5).setCellValue("501-1000KG");
|
||||
matrix.createCell(6).setCellValue("1001-3000KG");
|
||||
matrix.createCell(7).setCellValue("3001-5000KG");
|
||||
matrix.createCell(8).setCellValue("5001KG以上");
|
||||
row(sheet, 9);
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject route = new JSONObject(true);
|
||||
route.put("origin", "凉山-春疆货场");
|
||||
route.put("destination", "湖北省-武汉市");
|
||||
route.put("currency", "CNY");
|
||||
route.put("costPrice", "18200.00");
|
||||
route.put("amount", "18200.00");
|
||||
route.put("weightPrices", JSONArray.parseArray(
|
||||
"[{\"min\":2,\"max\":500},{\"min\":501,\"max\":1000},{\"min\":1001,\"max\":3000},"
|
||||
+ "{\"min\":3001,\"max\":5000},{\"min\":5001}]"));
|
||||
JSONArray routeRows = new JSONArray();
|
||||
routeRows.add(route);
|
||||
JSONObject mapping = new JSONObject(true);
|
||||
mapping.put("sheet", "跨境集拼");
|
||||
JSONArray regions = new JSONArray();
|
||||
JSONObject region = new JSONObject(true);
|
||||
region.put("headerRow", 8);
|
||||
region.put("dataStartRow", 10);
|
||||
JSONObject columns = new JSONObject(true);
|
||||
columns.put("origin", "B");
|
||||
columns.put("destination", "C");
|
||||
JSONObject vehicleRates = new JSONObject(true);
|
||||
vehicleRates.put("最低收费", "D");
|
||||
vehicleRates.put("2-500KG", "E");
|
||||
vehicleRates.put("501-1000KG", "F");
|
||||
vehicleRates.put("1001-3000KG", "G");
|
||||
vehicleRates.put("3001-5000KG", "H");
|
||||
vehicleRates.put("5001KG以上", "I");
|
||||
columns.put("vehicleRates", vehicleRates);
|
||||
region.put("columns", columns);
|
||||
regions.add(region);
|
||||
mapping.put("routeRegions", regions);
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("毛重", "2");
|
||||
facts.put("route_rows", routeRows);
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertEquals("凉山-春疆货场", read(sheet, 9, 1));
|
||||
assertEquals("湖北省-武汉市", read(sheet, 9, 2));
|
||||
assertEquals("18200", normalizeMoney(read(sheet, 9, 3)));
|
||||
assertEquals("18200", normalizeMoney(read(sheet, 9, 4)));
|
||||
assertEquals("", read(sheet, 9, 5));
|
||||
assertEquals("", read(sheet, 9, 6));
|
||||
assertEquals("", read(sheet, 9, 7));
|
||||
assertEquals("", read(sheet, 9, 8));
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销售没填客户名称时,客户名称格保持空白,不能写成业务员。
|
||||
* 询价条带的时效写在表头下一行;公斤分档价按 weightPrices 落入各列。
|
||||
@@ -1741,6 +1953,7 @@ public class QuoteTemplateFillTest {
|
||||
|
||||
/**
|
||||
* 跨境整车分段切片后仍须带上车型与运价;尺寸副表头行不能被 4000 盖掉,运价写在下一行。
|
||||
* WO006:单腿 vehicleType=3T 时,国内写 3T 列,国外段换算成 3.5T 再写,不能留空。
|
||||
*/
|
||||
@Test
|
||||
public void landCrossBorderFtlKeepsVehiclePriceAfterOdSlice() throws Exception {
|
||||
@@ -1763,9 +1976,11 @@ public class QuoteTemplateFillTest {
|
||||
head2.createCell(2).setCellValue("目的地");
|
||||
head2.createCell(3).setCellValue("1.25T");
|
||||
head2.createCell(4).setCellValue("2.5T");
|
||||
head2.createCell(5).setCellValue("3.5T");
|
||||
org.apache.poi.ss.usermodel.Row dim2 = row(sheet, 27);
|
||||
dim2.createCell(3).setCellValue("2.8x1.5x1.6M");
|
||||
dim2.createCell(4).setCellValue("3.50x1.6x1.6M");
|
||||
dim2.createCell(5).setCellValue("4.2x1.8x1.8M");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
@@ -1796,6 +2011,7 @@ public class QuoteTemplateFillTest {
|
||||
JSONObject rates2 = new JSONObject(true);
|
||||
rates2.put("1.25T", "D");
|
||||
rates2.put("2.5T", "E");
|
||||
rates2.put("3.5T", "F");
|
||||
cols2.put("vehicleRates", rates2);
|
||||
overseas.put("columns", cols2);
|
||||
regions.add(overseas);
|
||||
@@ -1813,12 +2029,14 @@ public class QuoteTemplateFillTest {
|
||||
assertEquals("4.1*2.1*2.3M", read(sheet, 8, 3));
|
||||
assertEquals("广东省-深圳市", read(sheet, 9, 1));
|
||||
assertEquals("", read(sheet, 9, 2));
|
||||
assertEquals("4000.00", read(sheet, 9, 3));
|
||||
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, 2));
|
||||
assertEquals("", read(sheet, 28, 3));
|
||||
assertEquals("", read(sheet, 28, 4));
|
||||
assertEquals("4000", normalizeMoney(read(sheet, 28, 5)));
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
@@ -1943,6 +2161,107 @@ public class QuoteTemplateFillTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WO202610120001:route_rows 混有多条备选(不同 segmentSequence),国内/境外段应写完整两段 OD,
|
||||
* 不能因「整表必须同序列」退回切片导致口岸列空。
|
||||
*/
|
||||
@Test
|
||||
public void landCrossBorderFtlFillsFullOdFromMultiAltRouteRows() throws Exception {
|
||||
Path src = Files.createTempFile("quote-land-cb-wo001-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-land-cb-wo001-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");
|
||||
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");
|
||||
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", 8);
|
||||
JSONObject cols1 = new JSONObject(true);
|
||||
cols1.put("origin", "B");
|
||||
cols1.put("destination", "C");
|
||||
cols1.put("3T", "D");
|
||||
inland.put("columns", cols1);
|
||||
regions.add(inland);
|
||||
JSONObject overseas = new JSONObject(true);
|
||||
overseas.put("title", "境外段报价");
|
||||
overseas.put("headerRow", 27);
|
||||
overseas.put("dataStartRow", 27);
|
||||
JSONObject cols2 = new JSONObject(true);
|
||||
cols2.put("origin", "B");
|
||||
cols2.put("destination", "C");
|
||||
cols2.put("3.5T", "D");
|
||||
overseas.put("columns", cols2);
|
||||
regions.add(overseas);
|
||||
mapping.put("routeRegions", regions);
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("origin", "上海");
|
||||
facts.put("destination", "胡志明");
|
||||
JSONArray rows = new JSONArray();
|
||||
JSONObject a1 = new JSONObject(true);
|
||||
a1.put("segmentSequence", "1");
|
||||
a1.put("routeLegSequence", "1");
|
||||
a1.put("origin", "上海");
|
||||
a1.put("destination", "谅山-春疆货场");
|
||||
a1.put("vehicleType", "3T");
|
||||
a1.put("costPrice", "7500");
|
||||
a1.put("currency", "CNY");
|
||||
rows.add(a1);
|
||||
JSONObject a2 = new JSONObject(true);
|
||||
a2.put("segmentSequence", "1");
|
||||
a2.put("routeLegSequence", "2");
|
||||
a2.put("origin", "谅山-春疆货场");
|
||||
a2.put("destination", "胡志明");
|
||||
a2.put("vehicleType", "3.5T");
|
||||
a2.put("costPrice", "9600");
|
||||
a2.put("currency", "CNY");
|
||||
rows.add(a2);
|
||||
JSONObject b1 = new JSONObject(true);
|
||||
b1.put("segmentSequence", "2");
|
||||
b1.put("routeLegSequence", "1");
|
||||
b1.put("origin", "上海");
|
||||
b1.put("destination", "凭祥友谊关口岸");
|
||||
b1.put("vehicleType", "3T");
|
||||
b1.put("costPrice", "7000");
|
||||
rows.add(b1);
|
||||
JSONObject b2 = new JSONObject(true);
|
||||
b2.put("segmentSequence", "2");
|
||||
b2.put("routeLegSequence", "2");
|
||||
b2.put("origin", "凭祥友谊关口岸");
|
||||
b2.put("destination", "胡志明");
|
||||
b2.put("vehicleType", "3T");
|
||||
b2.put("costPrice", "9000");
|
||||
rows.add(b2);
|
||||
facts.put("route_rows", rows);
|
||||
facts.put("landSegments", rows);
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertEquals("上海", read(sheet, 8, 1));
|
||||
assertEquals("谅山-春疆货场", read(sheet, 8, 2));
|
||||
assertEquals("谅山-春疆货场", read(sheet, 27, 1));
|
||||
assertEquals("胡志明", read(sheet, 27, 2));
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WO202610080006:跨境整车上传版表头是「时效 | 币种(错行) | 燃油 | 税率 | 含税合计」。
|
||||
* 起始地不能被费用名「运输费」盖掉;CNY 只能进币种列;燃油附加费率要写到燃油列。
|
||||
@@ -2483,6 +2802,18 @@ public class QuoteTemplateFillTest {
|
||||
return cell.getStringCellValue();
|
||||
}
|
||||
|
||||
/** 金额格可能是数字 18200.0 或文本 18200.00,统一成去尾零纯数字再比。 */
|
||||
private static String normalizeMoney(String raw) {
|
||||
if (raw == null || raw.isBlank() || "-".equals(raw.trim())) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return new java.math.BigDecimal(raw.trim()).stripTrailingZeros().toPlainString();
|
||||
} catch (RuntimeException ex) {
|
||||
return raw.trim();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运运价表:燃油附加费率、税率必须写到表头同列的数据行。
|
||||
* rate=0.09→9%;rateFlag 是是否含税,不写进税率格。
|
||||
|
||||
+7
@@ -42,4 +42,11 @@ public class QuoteTemplateObjectStoreTest {
|
||||
public void splitRejectsBlank() {
|
||||
assertNull(QuoteTemplateObjectStore.splitStorageUrl(" ", "http://127.0.0.1:9000/"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void remoteUrlStillDetectedAfterShaOverload() {
|
||||
// 回归:增加 resolve(objectKey, sha) 后,URL 识别不能回退成 Paths.get
|
||||
assertTrue(QuoteTemplateObjectStore.isRemoteUrl(
|
||||
"http://127.0.0.1:9000/inquiry-robot/inquiry/quote-templates/空运到门报价单模板_1790069791709.xlsx"));
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -193,6 +193,30 @@ public class InquiryTmsCabinServiceTest {
|
||||
java.util.List<Map<String, Object>> fees =
|
||||
(java.util.List<Map<String, Object>>) req.get("feeItems");
|
||||
assertEquals("空运费", fees.get(0).get("feeName"));
|
||||
// 空运费缺单位时锁舱补 KG(WO007)
|
||||
assertEquals("KG", fees.get(0).get("unit"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void manualFeeLinesWithoutUnitGetsKgOnLock() {
|
||||
Map<String, Object> quote = new HashMap<>();
|
||||
quote.put("source", "PRODUCT");
|
||||
Map<String, Object> line = new HashMap<>();
|
||||
line.put("name", "空运费");
|
||||
line.put("unit_price", "10.00");
|
||||
line.put("quantity", "5063");
|
||||
line.put("amount", "50630");
|
||||
line.put("currency", "CNY");
|
||||
quote.put("fee_lines", java.util.Arrays.asList(line));
|
||||
Map<String, Object> req = new HashMap<>();
|
||||
InquiryTmsCabinService.fillLockQuoteExtras(req, quote, new HashMap<>());
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.List<Map<String, Object>> fees =
|
||||
(java.util.List<Map<String, Object>>) req.get("feeItems");
|
||||
assertNotNull(fees);
|
||||
assertEquals(1, fees.size());
|
||||
assertEquals("空运费", fees.get(0).get("feeName"));
|
||||
assertEquals("KG", fees.get(0).get("unit"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 询价后台列表分页条:总数 + 每页条数 + 页码。
|
||||
* 询价后台列表分页条:整组靠右,总数紧挨在「每页」左边,页码在后。
|
||||
* 样式对齐 admin-fidelity.css 的 .pagination;翻页只发事件,不请求接口。
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
@@ -87,13 +87,13 @@ function onSizeChange(e: Event) {
|
||||
|
||||
<template>
|
||||
<div class="pagination admin-pagination">
|
||||
<span class="admin-pagination__total">
|
||||
共 <b>{{ total }}</b> {{ unit }}
|
||||
<span v-if="total > 0" class="admin-pagination__range">
|
||||
(第 {{ pageNo }} / {{ totalPages }} 页)
|
||||
</span>
|
||||
</span>
|
||||
<div class="admin-pagination__right">
|
||||
<span class="admin-pagination__total">
|
||||
共 <b>{{ total }}</b> {{ unit }}
|
||||
<span v-if="total > 0" class="admin-pagination__range">
|
||||
(第 {{ pageNo }} / {{ totalPages }} 页)
|
||||
</span>
|
||||
</span>
|
||||
<label class="admin-pagination__size">
|
||||
每页
|
||||
<select :value="pageSize" :disabled="disabled" @change="onSizeChange">
|
||||
@@ -127,6 +127,11 @@ function onSizeChange(e: Event) {
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.admin-pagination__total {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-pagination__size {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* 分析看板 API。
|
||||
* 显式传 dateFrom/dateTo,与工单列表日期筛选同源;range 仅作无日期时的回退。
|
||||
*/
|
||||
import { defHttp } from '/@/utils/http/axios';
|
||||
|
||||
@@ -7,5 +8,20 @@ enum Api {
|
||||
metrics = '/inquiry/dashboard/metrics',
|
||||
}
|
||||
|
||||
export const getDashboardMetrics = (range = 'today') =>
|
||||
defHttp.get({ url: Api.metrics, params: { range } });
|
||||
export type DashboardMetricsParams = {
|
||||
range?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
};
|
||||
|
||||
export const getDashboardMetrics = (params: DashboardMetricsParams | string = 'today') => {
|
||||
const query =
|
||||
typeof params === 'string'
|
||||
? { range: params }
|
||||
: {
|
||||
range: params.range || 'today',
|
||||
dateFrom: params.dateFrom,
|
||||
dateTo: params.dateTo,
|
||||
};
|
||||
return defHttp.get({ url: Api.metrics, params: query });
|
||||
};
|
||||
|
||||
@@ -56,11 +56,15 @@ const activePoint = ref<string | null>(null);
|
||||
const INQUIRY_PLOT_TOP = 36;
|
||||
const INQUIRY_PLOT_BOTTOM = 188;
|
||||
|
||||
/**
|
||||
* 粗粒度 range 仅作后端无 dateFrom/dateTo 时的回退;正式请求一律带显式日期。
|
||||
* 注意:不可再把「单日」映射成 today——today 是日历今天,不是所选那一天。
|
||||
*/
|
||||
function resolveRange(): string {
|
||||
const from = new Date(dateFrom.value);
|
||||
const to = new Date(dateTo.value);
|
||||
const days = Math.max(0, Math.round((to.getTime() - from.getTime()) / 86400000));
|
||||
if (days <= 0) return "today";
|
||||
if (days <= 0) return "1d";
|
||||
if (days <= 6) return "7d";
|
||||
return "30d";
|
||||
}
|
||||
@@ -88,7 +92,12 @@ function onDateRangeChange() {
|
||||
async function loadMetrics() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await getDashboardMetrics(resolveRange());
|
||||
const req = {
|
||||
range: resolveRange(),
|
||||
dateFrom: dateFrom.value,
|
||||
dateTo: dateTo.value,
|
||||
};
|
||||
const res: any = await getDashboardMetrics(req);
|
||||
const data = res?.result || res || {};
|
||||
todayMetrics.value = (data.metrics || []) as MetricCard[];
|
||||
inquiryTrend.value = ((data.trend || []) as any[]).map((p) => ({
|
||||
|
||||
@@ -190,8 +190,8 @@ export const mockReplyCategories: ReplyCategory[] = [
|
||||
{
|
||||
id: "brv10000000000000000000000000024",
|
||||
replyCode: "inquiring.air_lock_need_wo",
|
||||
trigger: "空运锁舱/释放未带工单号",
|
||||
text: "锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。",
|
||||
trigger: "空运锁舱/取消舱位未带工单号",
|
||||
text: "锁舱或取消舱位请带上工单号,例如 WO202609160001取消舱位。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
@@ -205,7 +205,7 @@ export const mockReplyCategories: ReplyCategory[] = [
|
||||
id: "brv10000000000000000000000000026",
|
||||
replyCode: "inquiring.air_airline_only_cabin",
|
||||
trigger: "只有航线能锁舱或释放",
|
||||
text: "只有岗位为「航线·空运」的人能锁舱或释放舱位。",
|
||||
text: "只有岗位为「航线·空运」的人能锁舱或取消舱位。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
@@ -225,29 +225,29 @@ export const mockReplyCategories: ReplyCategory[] = [
|
||||
{
|
||||
id: "brv10000000000000000000000000029",
|
||||
replyCode: "inquiring.air_release_ok",
|
||||
trigger: "空运释放成功",
|
||||
text: "工单{{ticket_no}} 已通知TMS执行空运释放舱位成功\n操作人:{{operator}}\n舱位选项:{{option_no}}\n执行编号:{{exec_no}}\n报价编号:{{quote_no}}",
|
||||
trigger: "空运取消舱位成功",
|
||||
text: "工单{{ticket_no}} 已通知TMS执行空运取消舱位成功\n操作人:{{operator}}\n舱位选项:{{option_no}}\n执行编号:{{exec_no}}\n报价编号:{{quote_no}}",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000030",
|
||||
replyCode: "inquiring.air_release_fail",
|
||||
trigger: "空运释放失败",
|
||||
text: "工单{{ticket_no}} 空运释放舱位失败。\n操作人:{{operator}}\n释放舱位失败的原因:{{reason}}\n已通知IT运维排查,请稍等。",
|
||||
trigger: "空运取消舱位失败",
|
||||
text: "工单{{ticket_no}} 空运取消舱位失败。\n操作人:{{operator}}\n取消舱位失败的原因:{{reason}}\n已通知IT运维排查,请稍等。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000031",
|
||||
replyCode: "inquiring.air_release_deal_blocked",
|
||||
trigger: "已成交不能释放舱位",
|
||||
text: "释放舱位失败,工单{{ticket_no}}已成交。",
|
||||
trigger: "已成交不能取消舱位",
|
||||
text: "取消舱位失败,工单{{ticket_no}}已成交。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000032",
|
||||
replyCode: "inquiring.air_release_need_lock",
|
||||
trigger: "没有有效锁舱不能释放",
|
||||
text: "工单{{ticket_no}}还没有有效锁舱,不能释放。",
|
||||
trigger: "没有有效锁舱不能取消舱位",
|
||||
text: "工单{{ticket_no}}还没有有效锁舱,不能取消舱位。",
|
||||
enabled: true,
|
||||
}
|
||||
],
|
||||
|
||||
@@ -918,6 +918,10 @@ body {
|
||||
color: #155eef !important;
|
||||
}
|
||||
|
||||
.pagination.admin-pagination {
|
||||
justify-content: flex-end !important;
|
||||
}
|
||||
|
||||
.pagination .pages {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -830,7 +830,7 @@ async function downloadPreviewOrOriginal() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<div class="pagination admin-pagination">
|
||||
<span>
|
||||
共 <b style="color: #0f172a">{{ filtered.length }}</b> 个模板
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user