Compare commits

...
63 changed files with 5367 additions and 215 deletions
@@ -269,12 +269,15 @@ def _handle_cabin(
chat_id=chat_id,
instruction=message.content or "",
)
# 「WO…锁舱,锁AIR-OPT01 TH」要带上句中的舱位和航司,不能只靠报价里碰巧只有一条。
return apply_air_lock(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
chat_id=chat_id,
instruction=message.content or "",
option_no=_option_from_text(message.content or ""),
airline=_airline_from_text(message.content or ""),
)
@@ -308,6 +311,7 @@ def _handle_bound(
chat_id=chat_id,
instruction=message.content or "",
option_no=option,
airline=_airline_from_text(message.content or ""),
)
if intent in {INTENT_DEAL, INTENT_LOST, INTENT_NEGOTIATE}:
outcome = {
@@ -371,14 +375,25 @@ def _handle_bound(
def _option_from_text(text: str) -> str:
blob = (text or "").replace(" ", "").upper()
if "AIR-OPT" in blob or "AIROPT" in blob:
start = blob.find("AIR")
token = []
for ch in blob[start:]:
if ch.isalnum() or ch == "-":
token.append(ch)
else:
break
return "".join(token)
"""从「锁AIR-OPT01 TH」抽出舱位编号;不要把后面的航司码拼进 optionNo。"""
import re
m = re.search(r"AIR-?OPT-?\d+", (text or "").upper())
if not m:
return ""
raw = m.group(0)
# 统一成 TMS 常见写法 AIR-OPT01(中间可保留已有的第二根横杠)
if raw.startswith("AIROPT"):
return "AIR-OPT" + raw[6:]
return raw
def _airline_from_text(text: str) -> str:
"""从「AIR-OPT01 TH」抽出两位航司码;没有就不猜。"""
import re
blob = (text or "").upper()
m = re.search(r"AIR-?OPT-?\d+\s*([A-Z]{2})(?![A-Z0-9])", blob)
if m:
return m.group(1)
return ""
@@ -91,6 +91,7 @@ def handle_continue_thread(
sender_id=message.sender_id,
work_order_no=wo,
reply=reply,
text=message.content or "",
)
logger.info(
"continue_thread phase=%s sender=%s wo=%s",
+108 -2
View File
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
def _front_bookmark(sess) -> bool:
"""补问/核对手稿优先于已出票旧书签。空的 need_mode 不算(那是丢会话后的误伤)。已转人工的票也不再当当前询价。"""
"""补问/核对手稿优先于已出票旧书签。还没定运输方式的草稿不算当前询价,避免抢走另一条已在补问的单。已转人工的票也不再当当前询价。"""
if sess is None:
return False
from agent.policy.handoff_human import is_handoff_frozen
@@ -36,17 +36,92 @@ def _front_bookmark(sess) -> bool:
return phase in {"clarify", "need_mode", "need_land_options", "wait_confirm"}
def _is_waiting_mode(sess) -> bool:
"""运输方式还没定、业务线空着。定方式前的几句询价字段都堆在这份草稿上。"""
if sess is None:
return False
from agent.policy.handoff_human import is_handoff_frozen
if is_handoff_frozen(sess):
return False
if (sess.phase or "").strip() != "need_mode":
return False
return not (sess.business_line or "").strip()
def _carry_waiting_inquiry_to_owner(*, owner: str, sender_id: str) -> None:
"""
销售点明空运/海运/陆运时,把定方式之前积累的询价字段整份交给该方式。
可能是连续几句,不只上一句。已在补问或核对的单不盖。不带旧工单号。
"""
kind = (owner or "").upper()
sid = (sender_id or "").strip()
if not sid or kind not in {"SEA", "AIR", "LAND"}:
return
flows = {
"AIR": get_air_text_flow(),
"SEA": get_sea_text_flow(),
"LAND": get_land_text_flow(),
}
owner_flow = flows[kind]
existing = owner_flow.session_of(sid)
if _front_bookmark(existing):
return
drafts = []
for flow in flows.values():
sess = flow.session_of(sid)
if _is_waiting_mode(sess):
drafts.append(sess)
if not drafts:
return
# 字段多的打底,其它草稿只补空缺,避免后写的空值盖掉已认出的港口。
drafts.sort(key=lambda item: len(item.facts or {}), reverse=True)
facts: dict[str, str] = {}
texts: list[str] = []
for sess in drafts:
for key, value in (sess.facts or {}).items():
text = str(value).strip()
if text and not str(facts.get(key) or "").strip():
facts[str(key)] = text
raw = (sess.immutable_text or "").strip()
if raw and raw not in texts:
texts.append(raw)
if not facts and not texts:
return
from agent.policy.air_text_flow import FlowSession
owner_flow._save(
FlowSession(
sender_id=sid,
thread_id=sid,
phase="need_mode",
business_line="",
facts=facts,
immutable_text="\n".join(texts),
)
)
logger.info(
"text_inquiry 未定方式的询价字段交给 %s keys=%s sender=%s",
kind,
",".join(sorted(facts)),
sid,
)
def _drop_other_mode_drafts(*, owner: str, sender_id: str) -> None:
"""
本句已点名空运/海运/陆运:丢掉其它方式未出号的补问/选项草稿。
本句已点名空运/海运/陆运:先带走「还没定方式」时积下的询价字段,再丢掉其它方式的补问/选项草稿。
测服复现:旧陆运「选项未齐」书签还在,海运补「贸易条款 / 运输分类」会被陆运抢走,
回「参数提取不全」并发送填写样例图。已出号会话留在工单号索引,不当当前草稿。
定方式前的 need_mode 要整份移交,不能直接删。
"""
kind = (owner or "").upper()
sid = (sender_id or "").strip()
if not sid or kind not in {"SEA", "AIR", "LAND", "MULTI"}:
return
_carry_waiting_inquiry_to_owner(owner=kind, sender_id=sid)
land = get_land_text_flow()
air = get_air_text_flow()
sea = get_sea_text_flow()
@@ -74,6 +149,24 @@ def _drop_other_mode_drafts(*, owner: str, sender_id: str) -> None:
clearer(sid)
def _flow_waiting_lost_reason(sender_id: str, sea, air, land):
"""谁在等未成交原因就交给谁。刚记下未成交的那条优先。"""
from agent.policy.handoff_human import is_handoff_frozen
waiting = []
for flow in (sea, air, land):
sess = flow.session_of(sender_id)
if sess is None or is_handoff_frozen(sess):
continue
if (sess.phase or "").strip() != "wait_lost_reason":
continue
waiting.append((int(getattr(sess, "wait_version", 0) or 0), flow))
if not waiting:
return None
waiting.sort(key=lambda item: item[0], reverse=True)
return waiting[0][1]
def should_resume_other_as_inquiry(message: InboundMessage) -> bool:
"""
A 把补问短答标成闲聊时,仍续填当前询价。
@@ -135,6 +228,10 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = "", prefe
return land
sea = get_sea_text_flow()
air = get_air_text_flow()
# 正在等未成交原因:下一句就是原因。调价、旧报价单、新的运输方式都不能把这句带走。
lost_flow = _flow_waiting_lost_reason(sender_id, sea, air, land)
if lost_flow is not None:
return lost_flow
spoken = (injected_mode or detect_transport_mode(text) or "").upper()
multi_kind = classify_multi_text(text) if not injected_mode else "single"
if multi_kind in {"multi", "same_mode"}:
@@ -183,6 +280,15 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = "", prefe
for sess, flow in ((sea_sess, sea), (air_sess, air), (land_sess, land)):
if _front_bookmark(sess):
return flow
# 还在问运输方式:后面几句询价字段继续叠在同一份草稿上,不要另开一条只留最后一句。
parked = [
(sess, flow)
for sess, flow in ((sea_sess, sea), (air_sess, air), (land_sess, land))
if _is_waiting_mode(sess)
]
if parked:
parked.sort(key=lambda item: len(item[0].facts or {}), reverse=True)
return parked[0][1]
if prefer_quoted:
quoted = quoted_adjust_flow(sender_id)
if quoted is not None:
+11 -7
View File
@@ -402,16 +402,20 @@ class HttpLedger:
sender_id: str = "",
operator_name: str = "",
instruction_text: str = "",
airline: str = "",
) -> dict[str, Any]:
body: dict[str, Any] = {
"workOrderNo": work_order_no,
"optionNo": option_no,
"operatorUserId": sender_id,
"operatorName": operator_name,
"instructionText": instruction_text,
}
if (airline or "").strip():
body["airline"] = airline.strip().upper()
return self._post(
"/inquiry/agent/tms/lockCabin",
{
"workOrderNo": work_order_no,
"optionNo": option_no,
"operatorUserId": sender_id,
"operatorName": operator_name,
"instructionText": instruction_text,
},
body,
idem=f"air-lock:{work_order_no}:{option_no}",
)
+2 -1
View File
@@ -823,9 +823,10 @@ class MemoryLedger:
sender_id: str = "",
operator_name: str = "",
instruction_text: str = "",
airline: str = "",
) -> dict[str, Any]:
"""单测可注入 _cabin_lock_result;默认成功。"""
_ = sender_id, operator_name, instruction_text
_ = sender_id, operator_name, instruction_text, airline
forced = getattr(self, "_cabin_lock_result", None)
if isinstance(forced, dict):
return dict(forced)
+6 -1
View File
@@ -275,10 +275,15 @@ def _merge_spoken_collab(text: str, facts: dict[str, str]) -> dict[str, str]:
不覆盖已有询价键。不拿去补问、不挡查价。
"""
from agent.schema.field_validate import harvest_oral_collab, normalize_facts
from agent.schema.field_validate import (
harvest_oral_collab,
harvest_spoken_trade_term,
normalize_facts,
)
merged = dict(facts or {})
extra = dict(harvest_oral_collab(text))
extra.update(harvest_spoken_trade_term(text))
extra.update(normalize_facts(harvest_collab_labels(text)))
for key, val in extra.items():
if val and not str(merged.get(key) or "").strip():
@@ -34,6 +34,7 @@ _FIELD_KEYS = (
"车型/数量",
"车型数量",
"通关口岸",
"贸易条款",
)
@@ -131,6 +132,7 @@ def build_extract_messages(text: str) -> list[dict[str, str]]:
"询价日期、走货日期、询价时间、走货时间、发货时间、发货日期、出货日期、出货时间、提货时间、提货日期一律写入报价日期。"
"货好时间、提货地址不要写成报价日期。"
"陆运「通关口岸:皇岗」「通关口岸皇岗」「过皇岗」有说就写入通关口岸,没说留空,禁止编造口岸。"
"原话单独出现 DAP、DDP、CIF、CIP、FOB、EXW 时写入贸易条款;没出现留空,禁止编造。"
"「从南京飞吉隆坡」起运港=南京、目的港=吉隆坡;「北京飞吉隆坡」起运港=北京。"
"原话是机场三字码或航线就原样记,禁止翻译成城市名:"
"SZX-CGK / SZX/CGK / SZX到CGK → 起运港=SZX、目的港=CGK,不要写成深圳、雅加达。"
+151 -6
View File
@@ -226,6 +226,8 @@ def send_air_brief_and_sheet(
空运群再发一版摘要 + 匹配到的空运模板。对齐海运 send_brief_and_sheet。
必须先发出工单信息,模板等摘要发出后再发,禁止后台线程抢先发文件。
填表报价必须带上 TMS 总销售价:群票上的 latest quote 常只有增点费/拆板费明细,
若直接拿去出单,预估费用会落成明细加总(如 200)而不是 salePrice(如 7918.25)。
"""
ticket = hydrate_ticket(flow, ticket)
view = ticket_view(ticket)
@@ -247,6 +249,8 @@ def send_air_brief_and_sheet(
if not manual and copy.quote_is_product(override or view_q):
manual = override or view_q
quote = dict(override or view_q or tms or manual)
# 出单专用:把 TMS salePrice 盖回薄报价,避免预估费用=明细 SUM。
sheet_quote = _sheet_quote_with_tms_sale_total(quote, tms=tms, facts=facts)
sales_name, airline_names, mention_ids = resolve_air_mentions(flow, view)
send_group_text(
group_client_of(flow),
@@ -264,10 +268,133 @@ def send_air_brief_and_sheet(
),
mention_userids=mention_ids,
)
_send_air_activate_sheet(flow=flow, ticket=view, chat_id=room, quote=quote)
_send_air_activate_sheet(flow=flow, ticket=view, chat_id=room, quote=sheet_quote)
return "brief_and_sheet"
def _air_usable_sale_total(quote: Optional[dict[str, Any]]) -> str:
"""
空运出单可用的总销售价:顶层 total / salePrice,或选定/首条线路 salePrice。
「-」与空串不算。
"""
src = dict(quote or {})
for key in ("total", "total_amount", "salePrice", "totalSalePrice"):
val = str(src.get(key) or "").strip()
if val and val != "-":
return val
selected = src.get("selected_air_option") or src.get("selectedAirOption")
if isinstance(selected, dict):
val = str(selected.get("salePrice") or selected.get("sale_price") or "").strip()
if val and val != "-":
return val
raw_opts = src.get("airOptions") or src.get("air_options") or []
if isinstance(raw_opts, list) and raw_opts and isinstance(raw_opts[0], dict):
val = str(raw_opts[0].get("salePrice") or raw_opts[0].get("sale_price") or "").strip()
if val and val != "-":
return val
return ""
def _money_plain_number(text: str) -> Optional[float]:
"""从「CNY 7918.25」类文本抽出数字;解不开返回 None。"""
raw = str(text or "").strip()
if not raw or raw == "-":
return None
digits = "".join(ch if (ch.isdigit() or ch in ".-") else " " for ch in raw).split()
if not digits:
return None
try:
return float(digits[-1].replace(",", ""))
except ValueError:
return None
def _total_matches_fee_row_sum(quote: dict[str, Any], total_text: str) -> bool:
"""
顶层 total 是否只是明细金额相加(如增点费100+拆板费100=200)。
是则说明缺了 TMS 总销售价,不能当真预估费用。
"""
total_num = _money_plain_number(total_text)
if total_num is None:
return False
rows = quote.get("fee_rows") or quote.get("fee_lines") or quote.get("feeItems") or []
if isinstance(rows, str) and rows.strip():
try:
rows = json.loads(rows)
except json.JSONDecodeError:
rows = []
if not isinstance(rows, list) or not rows:
return False
summed = 0.0
hit = 0
for item in rows:
if not isinstance(item, dict):
continue
amt = _money_plain_number(
str(
item.get("amount")
or item.get("value")
or item.get("salePrice")
or item.get("price")
or ""
)
)
if amt is None:
continue
summed += amt
hit += 1
if hit <= 0:
return False
return abs(total_num - summed) < 0.01
def _sheet_quote_with_tms_sale_total(
quote: dict[str, Any],
*,
tms: Optional[dict[str, Any]] = None,
facts: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""
群填表用的报价包:保留产品/航线改过的明细,但预估费用必须能落到 TMS 总销售价。
协同群 latest quote 常只有 departureCharges 投影出的附加费行,total 空或被加成 200;
销售 1V1 会话里带了 salePrice,所以同一工单两边会不一致。这里出单前对齐。
"""
from agent.schema.air_route_options import project_group_air_quote
out = dict(quote or {})
source = dict(tms or {}) or dict(out)
if not source:
return out
projected = project_group_air_quote(source, dict(facts or {}))
tms_total = _air_usable_sale_total(projected)
cur_total = _air_usable_sale_total(out)
# 补齐线路对象 / 计费重,便于主账从 airOptions.salePrice 再兜底一次。
for key in (
"airOptions",
"air_options",
"selected_air_option",
"selectedAirOption",
"chargeableWeightKg",
"chargeable_weight_kg",
"currency",
"valid_until",
):
cur = out.get(key)
empty = cur in (None, "", "-", [], {})
proj_val = projected.get(key)
if empty and proj_val not in (None, "", "-", [], {}):
out[key] = proj_val
if not tms_total:
return out
need_tms_total = (not cur_total) or cur_total == "-" or _total_matches_fee_row_sum(out, cur_total)
if need_tms_total:
out["total"] = str(projected.get("total") or tms_total).strip()
if projected.get("currency") and not str(out.get("currency") or "").strip():
out["currency"] = projected.get("currency")
return out
def resolve_air_mentions(flow: Any, ticket: object) -> tuple[str, list[str], list[str]]:
"""
空运群摘要必须 @ 到本单销售和航线·空运,对上海运 @ 销售/@ 产品。
@@ -581,16 +708,21 @@ def apply_air_confirm_tms(*, flow: Any, ticket: object, sender_id: str, chat_id:
quote["source_label"] = "航线确认TMS"
no = ticket_no(ticket)
_clear_air_pending(flow, no, sender_id)
saved = flow.ledger.upsert_product_quote(work_order_no=no, quote=quote, to_status="已报价") or {}
# 确认 TMS 出单:同样用总销售价,避免只落附加费明细时预估费用=200。
sheet_quote = _sheet_quote_with_tms_sale_total(
quote, tms=quote, facts=dict(view.get("facts") or {})
)
saved = flow.ledger.upsert_product_quote(work_order_no=no, quote=sheet_quote, to_status="已报价") or {}
if not saved.get("ok"):
send_group_text(group_client_of(flow), chat_id, "主账写入报价失败。")
return "ledger_fail"
view["collab_chat_id"] = chat_id
view["quote"] = sheet_quote
_persist_quote_sheet(
flow=flow,
ticket=view,
sess=None,
quote=quote,
quote=sheet_quote,
sender_id=sender_id,
business_line="AIR",
)
@@ -658,6 +790,7 @@ def apply_air_lock(
chat_id: str,
instruction: str,
option_no: str = "",
airline: str = "",
) -> str:
if not is_airline(flow, sender_id):
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
@@ -705,6 +838,7 @@ def apply_air_lock(
sender_id=sender_id,
operator_name=name,
instruction_text=instruction,
airline=(airline or "").strip(),
)
or {},
is_ok=lambda x: bool((x or {}).get("ok")),
@@ -778,6 +912,16 @@ def apply_air_lock(
return "lock_ok"
def _release_should_push_deal(ticket: object) -> bool:
"""
放舱成功后要不要再推成交跟进。
未成交、已关闭、已成交、转人工:成交结果已经定过,只回释放结果。
还在等销售确认时仍要推一张。
"""
return ticket_status(ticket) not in TERMINAL
def apply_air_release(
*,
flow: Any,
@@ -875,9 +1019,10 @@ def apply_air_release(
if ticket_is_paused(ticket):
clear_system_exception(ledger=flow.ledger, work_order_no=no)
view = ticket_view(ticket)
view["collab_chat_id"] = chat_id
emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {}))
if _release_should_push_deal(ticket):
view = ticket_view(ticket)
view["collab_chat_id"] = chat_id
emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {}))
return "release_ok"
+83 -11
View File
@@ -66,6 +66,38 @@ class FlowSession:
virtual_work_order_no: str = ""
def is_waiting_mode_session(sess) -> bool:
"""还在问运输方式、业务线空着。这时抽字段要看前面几句,不只看点名的这一句。"""
if sess is None:
return False
if (getattr(sess, "phase", None) or "").strip() != "need_mode":
return False
return not (getattr(sess, "business_line", None) or "").strip()
def waiting_mode_source(sess, text: str) -> str:
"""未定方式时返回前面全部原话加本句;已经在补问则只返回本句,避免旧句盖掉销售刚改的值。"""
latest = text or ""
if not is_waiting_mode_session(sess):
return latest
return stack_user_text(getattr(sess, "immutable_text", "") or "", latest)
def stack_user_text(previous: str, latest: str) -> str:
"""
运输方式还没定时,把销售每一句原话接在一起。
不是只留上一句。同一句重复进来不追加。补问阶段不要用这个,避免把已定方式的旧句再拼回去改判。
"""
prev = (previous or "").strip()
cur = (latest or "").strip()
if not cur:
return prev
if not prev or cur == prev or prev.endswith("\n" + cur):
return cur if not prev else prev
return prev + "\n" + cur
def _session_payload(sess: FlowSession) -> dict[str, Any]:
"""书签转 JSON。tuple 改 list,方便 HTTP/Worker 共用。"""
data = asdict(sess)
@@ -512,6 +544,7 @@ class AirTextInquiryFlow:
sender_id: str,
work_order_no: str,
reply: ReplyFn,
text: str = "",
) -> str:
"""
按工单号回到当时停点并重出对应卡片。
@@ -530,6 +563,9 @@ class AirTextInquiryFlow:
keep = None
if keep:
self._align_resume_phase(keep)
closed = self._close_spoken_deal(keep, text, reply)
if closed:
return closed
return self._replay_wait(keep, reply, source="session")
getter = getattr(self._ledger, "get_for_agent", None)
if not callable(getter):
@@ -545,6 +581,9 @@ class AirTextInquiryFlow:
sess = self._session_from_ledger(sender_id=sender_id, view=view)
self._align_resume_phase(sess)
self._save(sess)
closed = self._close_spoken_deal(sess, text, reply)
if closed:
return closed
return self._replay_wait(sess, reply, source="ledger")
def _session_from_ledger(self, *, sender_id: str, view: dict[str, Any]) -> FlowSession:
@@ -659,8 +698,8 @@ class AirTextInquiryFlow:
reply("报价单正在生成,请稍候。")
return "wait_file"
if sess.phase == "wait_deal":
# 成交卡已经绑在报价文件那条出站上。这里再发一张独立卡,
# 会在文件还在上传时被另一条出站线程抢走,销售又先看到成交跟进。
# 成交卡已经绑在报价文件那条出站上。只续办、没说成交结果时不再另发一张卡。
# 原话写了已成交/未成交/协商中时,由 _close_spoken_deal 先入账,不会走到这里。
return "wait_deal"
if sess.phase == "wait_lost_reason":
reply(copy.deal_ack("未成交", sess.work_order_no))
@@ -774,7 +813,8 @@ class AirTextInquiryFlow:
merged = dict(sess.facts)
merged.update(facts)
facts = harvest_oral_measures(text, merged)
source = waiting_mode_source(sess, text)
facts = harvest_oral_measures(source, merged)
mode = mode or (sess.business_line or "").upper()
if not mode:
mode = detect_transport_mode(
@@ -790,7 +830,10 @@ class AirTextInquiryFlow:
phase="need_mode",
business_line="",
facts=facts,
immutable_text=sess.immutable_text if sess and sess.immutable_text else text,
immutable_text=stack_user_text(
sess.immutable_text if sess else "",
text,
),
)
)
return "need_mode"
@@ -831,6 +874,11 @@ class AirTextInquiryFlow:
reply(copy.ASK_TRANSPORT)
return "need_mode"
if is_waiting_mode_session(sess):
from agent.schema.field_validate import shape_facts_for_chosen_mode
# 刚选定空运:上文按空运字段收口,海运箱型不留在空运单上。
facts = shape_facts_for_chosen_mode(facts, "AIR")
check = validate_required_fields(facts=facts, business_line="AIR")
facts = dict(check["facts"])
tms_ready = assemble_air_query(facts)
@@ -1631,22 +1679,46 @@ class AirTextInquiryFlow:
card_meta: Optional[dict[str, Any]] = None,
) -> str:
"""
成交跟进阶段口播未成交;同一句可带原因。
成交跟进阶段口播已成交、未成交或协商中。未成交同一句可带原因。
点卡仍走 match_button。返回阶段名;不是未成交口播则空串。
点卡仍走 match_button。原话写了别的工单号则不关当前单。不是口播则空串。
"""
from agent.policy.deal_outcome import looks_like_lost_outcome
from agent.policy.deal_outcome import spoken_deal_status
if sess is None or not (text or "").strip():
return ""
allowed = sess.allowed or ()
if copy.BTN_LOST not in allowed and sess.phase not in {"wait_deal", "wait_file"}:
status = spoken_deal_status(text)
if not status:
return ""
if not looks_like_lost_outcome(text):
allowed = sess.allowed or ()
if sess.phase not in {"wait_deal", "wait_file"} and copy.BTN_LOST not in allowed and copy.BTN_DEAL not in allowed:
return ""
if sess.phase not in {"wait_deal", "wait_file"} and status != "未成交":
return ""
named = copy.extract_work_order_no(text)
if named and (sess.work_order_no or "").strip() and named.upper() != sess.work_order_no.strip().upper():
return ""
if sess.phase == "wait_file":
self._promote_wait_deal(sess)
return self._close(sess, reply, "未成交", card_meta=card_meta, source_text=text)
return self._close(sess, reply, status, card_meta=card_meta, source_text=text)
def _close_spoken_deal(self, sess: FlowSession, text: str, reply: ReplyFn) -> str:
"""
续办原话已经写了成交结果:直接入账,不要静默停在成交跟进。
只在报价文件已出、正在等成交时生效。没写结果仍按原样重放,避免多发一张卡。
"""
from agent.policy.deal_outcome import spoken_deal_status
status = spoken_deal_status(text)
if not status or sess.phase not in {"wait_deal", "wait_file"}:
return ""
named = copy.extract_work_order_no(text)
if named and named.upper() != (sess.work_order_no or "").strip().upper():
return ""
if sess.phase == "wait_file":
self._promote_wait_deal(sess)
return self._close(sess, reply, status, source_text=text)
def _close(
self,
@@ -79,6 +79,25 @@ def looks_like_lost_outcome(text: str) -> bool:
return any(token in blob for token in _LOST_MARKERS)
def spoken_deal_status(text: str) -> str:
"""
私聊口播成交结果。先认未成交,再认已成交、协商中。
「WO202609240006已成交」整句就是要关单,不是再打开成交卡。
问句(已成交吗)不算。对不上返回空串。
"""
blob = (text or "").replace(" ", "")
if not blob or blob[-1] in _QUESTION_TAILS:
return ""
if looks_like_lost_outcome(blob):
return "未成交"
if "已成交" in blob:
return "已成交"
if "协商中" in blob:
return "协商中"
return ""
def notify_app_ready() -> bool:
"""询价消息服务已配 AgentId+Secret 才推。空则只成交不推。"""
settings = get_settings()
+9 -7
View File
@@ -60,7 +60,8 @@ _SEA_EXAMPLES = {
"货量": "20吨",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
"贸易条款": "FOB",
# 销售看到的举例是门到门、港到港,不是 FOB。举例行不参与已填判断。
"贸易条款": "门到门、港到港",
"运输分类": "Port to Port",
"报价日期": "2026-09-15",
}
@@ -2239,13 +2240,14 @@ def adjust_only_sales() -> str:
def adjust_group_body(quote: dict[str, Any] | None, business_line: str = "") -> str:
"""群里调后全量明细,利润点不写给销售。"""
"""
群里调后全量明细,利润点不写给销售。
海运必须带三段标题与小计(始发港/海运/目的港),禁止只摊平 fee_lines 把小计弄丢。
"""
src = dict(quote or {})
lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)]
header = str(src.get("currency") or "")
total = money_text(str(src.get("total") or "-").strip() or "-", header)
lines.append(f"{_total_label(src, business_line)}:{total}")
return "调价后价格:\n" + "\n".join(lines)
text = quote_area_fee_text(src, business_line=business_line)
return "调价后价格:\n" + text
def adopt_excel_ack() -> str:
+20 -4
View File
@@ -17,7 +17,13 @@ from typing import Any, Optional
from agent.ledger.memory_ledger import MemoryLedger
from agent.llm.extract_text import extract_inquiry_snapshot
from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import FlowSession, ReplyFn
from agent.policy.air_text_flow import (
FlowSession,
ReplyFn,
is_waiting_mode_session,
stack_user_text,
waiting_mode_source,
)
from agent.policy.sea_text_flow import SeaTextInquiryFlow
from agent.schema.field_validate import harvest_oral_measures, validate_required_fields
from agent.schema.land_options import (
@@ -149,9 +155,11 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
cargo = {k: v for k, v in facts.items() if k not in option_keys}
merged = dict(base)
merged.update(cargo)
facts = harvest_oral_measures(text, merged)
# 还在问运输方式时,重量体积和陆运选项要对照前面几句,不只看「1」。
source = waiting_mode_source(sess, text)
facts = harvest_oral_measures(source, merged)
# 选项只从原话+能对上枚举的模型值收;禁止模型「陆运」盖掉已选的国内运输拼车。
facts = harvest_land_options(text, facts)
facts = harvest_land_options(source, facts)
b_opt = " ".join(
str(x).strip()
for x in (
@@ -174,7 +182,10 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
phase="need_mode",
business_line="",
facts=facts,
immutable_text=sess.immutable_text if sess and sess.immutable_text else text,
immutable_text=stack_user_text(
sess.immutable_text if sess else "",
text,
),
)
)
return "need_mode"
@@ -211,6 +222,11 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
inbound_raw=inbound_raw,
)
if is_waiting_mode_session(sess):
from agent.schema.field_validate import shape_facts_for_chosen_mode
# 刚选定陆运:不留空运件数、海运箱型和贸易条款。重量体积仍按陆运货物字段留。
facts = shape_facts_for_chosen_mode(facts, "LAND")
return self._advance_front(
sender_id=sender_id,
facts=facts,
+130 -6
View File
@@ -3,11 +3,16 @@
主账填表只认英文码,货好时间 / 海关编码 / 含油电磁常空着。
本文件只改 Excel 字节,不打主账、不占回调线程。
注意:openpyxl 保存会弄坏海运顶部 Form Control(drawing/VML/sheet1 锚点)。
因此只用来「读」标签位置;真正写入走 zip 内定点改格,不动控件骨架。
无待写字段、或目标格已是目标值时原样返回。
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import Any, Mapping
@@ -30,6 +35,18 @@ _COLLAB_LABEL_MARKERS: tuple[tuple[str, tuple[str, ...]], ...] = (
("包装方式", ("包装方式", "包装类型")),
)
# 旧路径兜底:若仍误走 openpyxl 保存,至少盖回这些部件
_PRESERVE_ZIP_PARTS = (
"xl/drawings/drawing1.xml",
"xl/drawings/vmlDrawing1.vml",
"xl/styles.xml",
"xl/ctrlProps/ctrlProp1.xml",
"xl/ctrlProps/ctrlProp2.xml",
"xl/worksheets/sheet1.xml",
"xl/worksheets/_rels/sheet1.xml.rels",
"[Content_Types].xml",
)
def _fact_for_markers(facts: Mapping[str, Any], keys: tuple[str, ...]) -> str:
for key in keys:
@@ -39,6 +56,102 @@ def _fact_for_markers(facts: Mapping[str, Any], keys: tuple[str, ...]) -> str:
return ""
def _restore_sea_draw_parts(original: bytes, rewritten: bytes) -> bytes:
"""把原件里的 drawing/VML/ctrl/styles/sheet1 控件骨架盖回 openpyxl 重写后的 xlsx。"""
import io
import zipfile
try:
with zipfile.ZipFile(io.BytesIO(original)) as zin:
keep = {
name: zin.read(name)
for name in _PRESERVE_ZIP_PARTS
if name in zin.namelist()
}
if not keep:
return rewritten
out = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(rewritten)) as zin, zipfile.ZipFile(
out, "w", compression=zipfile.ZIP_DEFLATED
) as zout:
for info in zin.infolist():
data = keep.get(info.filename) or zin.read(info.filename)
zout.writestr(info, data)
for name, data in keep.items():
if name not in zin.namelist():
zout.writestr(name, data)
return out.getvalue()
except Exception:
logger.exception("overlay 盖回海运 drawing 失败,退回 openpyxl 结果")
return rewritten
def _col_letter(col: int) -> str:
"""1-based 列号 → Excel 列字母。"""
n = int(col)
letters: list[str] = []
while n > 0:
n, rem = divmod(n - 1, 26)
letters.append(chr(65 + rem))
return "".join(reversed(letters)) or "A"
def _xml_escape(text: str) -> str:
return (
str(text)
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
def _patch_cells_keep_structure(data: bytes, cells: list[tuple[int, int, str]]) -> bytes:
"""
只改 sheet1 里指定格的字符串,保留 AlternateContent / 控件关系。
禁止整表经 openpyxl 重存:否则拉群协同 Excel 顶部 Port/其它单选会空白,
而「直接生成」不经本函数所以看起来正常(WO031 vs WO033)。
"""
import io
import zipfile
if not cells:
return data
with zipfile.ZipFile(io.BytesIO(data)) as zin:
if "xl/worksheets/sheet1.xml" not in zin.namelist():
return data
sheet = zin.read("xl/worksheets/sheet1.xml").decode("utf-8", "replace")
others = {name: zin.read(name) for name in zin.namelist() if name != "xl/worksheets/sheet1.xml"}
for row, col, val in cells:
ref = f"{_col_letter(col)}{int(row)}"
cell_xml = (
f'<c r="{ref}" t="inlineStr"><is><t>{_xml_escape(val)}</t></is></c>'
)
pat = re.compile(rf'<c\b[^>]*\br="{re.escape(ref)}"[^>]*(?:/>|>[\s\S]*?</c>)')
if pat.search(sheet):
sheet = pat.sub(cell_xml, sheet, count=1)
continue
row_pat = re.compile(rf'(<row\b[^>]*\br="{int(row)}"[^>]*>)([\s\S]*?)(</row>)')
m = row_pat.search(sheet)
if m:
sheet = sheet[: m.start(3)] + cell_xml + sheet[m.start(3) :]
continue
sheet = sheet.replace(
"</sheetData>",
f'<row r="{int(row)}">{cell_xml}</row></sheetData>',
1,
)
out = io.BytesIO()
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zout:
for name, blob in others.items():
zout.writestr(name, blob)
zout.writestr("xl/worksheets/sheet1.xml", sheet.encode("utf-8"))
return out.getvalue()
def overlay_labeled_facts(
*,
facts: Mapping[str, Any],
@@ -48,7 +161,8 @@ def overlay_labeled_facts(
"""
按模板标签再写协同格。失败则原样交回。
副作用:可能写临时 xlsx。
副作用:可能写临时 xlsx。无待写字段、或格子已是目标值时不改字节,
避免毁掉海运顶部运输分类单选。
"""
import base64
import tempfile
@@ -91,20 +205,30 @@ def overlay_labeled_facts(
if not val:
continue
dest = _label_value_cell(ws, cell)
# 主账已填过同值(如贸易条款):再写会经 openpyxl 毁掉单选
if str(dest.value or "").strip() == val:
used_rows.add(cell.row)
break
pending.append((dest.row, dest.column, val))
used_rows.add(cell.row)
break
for r, c, val in pending:
ws.cell(row=r, column=c, value=val)
written += 1
wb.save(tmp)
wb.close()
if not pending:
# 无增量可写:原样返回,禁止重存
logger.info("overlay 协同字段 written=0 keep_original")
return {
"filepath": filepath or str(tmp),
"file_b64": file_b64 or base64.b64encode(raw).decode("ascii"),
}
patched = _patch_cells_keep_structure(raw, pending)
tmp.write_bytes(patched)
written = len(pending)
except Exception:
logger.exception("overlay 协同字段失败")
return {"filepath": filepath, "file_b64": file_b64}
out_b64 = base64.b64encode(tmp.read_bytes()).decode("ascii")
logger.info("overlay 协同字段 written=%s", written)
logger.info("overlay 协同字段 written=%s keep_structure=1", written)
return {"filepath": str(tmp), "file_b64": out_b64}
+91 -7
View File
@@ -401,17 +401,21 @@ def hydrate_ticket(flow: Any, ticket: object) -> object:
)
)
quote = dict(view.get("quote") or {})
has_quote = bool(quote.get("total") or _quote_fee_rows(quote))
# total="-" 在 Python 里也是 truthy,不能当成已有总价;只有附加费明细也不算完整。
has_sale_total = _quote_has_usable_sale_total(quote)
has_fee_rows = bool(_quote_fee_rows(quote))
has_quote = has_sale_total or has_fee_rows
has_status = bool(ticket_status(ticket))
has_tms = bool(dict(view.get("tms_quote") or view.get("tmsQuote") or {}))
# 调价后 latest quote 仍有价,但不能因此跳过补 TMS 原价。
if has_facts and has_quote and has_status and has_tms:
return ticket
# 仅有 fee_rows、没有可用总价时仍要往下补全,否则空运出单预估费用会落成明细 SUM。
if has_facts and has_sale_total and has_status and has_tms:
return _soft_fill_quote_sale_fields(ticket)
no = ticket_no(ticket)
sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
book = getattr(flow, "ledger", None)
if book is None or not no:
return ticket
return _soft_fill_quote_sale_fields(ticket)
full: Any = None
getter = getattr(book, "get_for_agent", None)
if callable(getter) and sales:
@@ -423,13 +427,33 @@ def hydrate_ticket(flow: Any, ticket: object) -> object:
if callable(getter):
full = getter(work_order_no=no)
if full is None:
return ticket
return _soft_fill_quote_sale_fields(ticket)
merged = dict(view)
full_view = ticket_view(full)
if (not has_facts) and full_view.get("facts"):
merged["facts"] = dict(full_view.get("facts") or {})
if (not has_quote) and full_view.get("quote"):
merged["quote"] = dict(full_view.get("quote") or {})
if (not has_sale_total) and full_view.get("quote"):
# 薄报价保留已有明细,补上完整快照里的总价/线路。
rich = dict(full_view.get("quote") or {})
cur = dict(quote)
for key in (
"total",
"salePrice",
"totalSalePrice",
"airOptions",
"air_options",
"selected_air_option",
"selectedAirOption",
"chargeableWeightKg",
"chargeable_weight_kg",
"currency",
"valid_until",
):
if cur.get(key) in (None, "", "-", [], {}) and rich.get(key) not in (None, "", "-", [], {}):
cur[key] = rich[key]
if not has_fee_rows and (rich.get("fee_lines") or rich.get("fee_rows")):
cur["fee_lines"] = rich.get("fee_lines") or rich.get("fee_rows")
merged["quote"] = cur
if full_view.get("tms_quote") or full_view.get("tmsQuote"):
merged["tms_quote"] = dict(full_view.get("tms_quote") or full_view.get("tmsQuote") or {})
if (not has_status) and full_view.get("status"):
@@ -449,6 +473,66 @@ def hydrate_ticket(flow: Any, ticket: object) -> object:
):
if full_view.get(key) and not merged.get(key):
merged[key] = full_view.get(key)
return _soft_fill_quote_sale_fields(merged)
def _quote_has_usable_sale_total(quote: dict[str, Any] | None) -> bool:
"""
是否已有可用于出单预估费用的总价。
total="-" 不算;仅有 fee_rows 也不算。
"""
src = dict(quote or {})
total = str(src.get("total") or src.get("total_amount") or src.get("salePrice") or "").strip()
if total and total != "-":
return True
selected = src.get("selected_air_option") or src.get("selectedAirOption")
if isinstance(selected, dict):
sale = str(selected.get("salePrice") or selected.get("sale_price") or "").strip()
if sale and sale != "-":
return True
opts = src.get("airOptions") or src.get("air_options") or []
if isinstance(opts, list) and opts and isinstance(opts[0], dict):
sale = str(opts[0].get("salePrice") or opts[0].get("sale_price") or "").strip()
if sale and sale != "-":
return True
return False
def _soft_fill_quote_sale_fields(ticket: object) -> object:
"""
从 tms_quote 补齐当前 quote 缺失的总销售价 / 线路对象。
群 findByCollabChat 常只有薄 latest quote,出单前必须能落到 salePrice。
"""
view = ticket_view(ticket)
cur = dict(view.get("quote") or {})
rich = dict(view.get("tms_quote") or view.get("tmsQuote") or {})
if not rich:
return ticket if isinstance(ticket, dict) else view
changed = False
for key in (
"total",
"salePrice",
"totalSalePrice",
"airOptions",
"air_options",
"selected_air_option",
"selectedAirOption",
"chargeableWeightKg",
"chargeable_weight_kg",
"currency",
"valid_until",
):
if cur.get(key) in (None, "", "-", [], {}) and rich.get(key) not in (None, "", "-", [], {}):
cur[key] = rich[key]
changed = True
if not changed:
return ticket if isinstance(ticket, dict) else view
if isinstance(ticket, dict):
out = dict(ticket)
out["quote"] = cur
return out
merged = dict(view)
merged["quote"] = cur
return merged
+27 -6
View File
@@ -23,6 +23,9 @@ from agent.policy.air_text_flow import (
FlowSession,
ReplyFn,
build_ledger,
is_waiting_mode_session,
stack_user_text,
waiting_mode_source,
)
from agent.schema.field_validate import validate_required_fields
from agent.schema.tms_sea_query import assemble_sea_query
@@ -135,7 +138,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
merged = dict(sess.facts)
merged.update(facts)
facts = harvest_oral_measures(text, merged)
facts = harvest_oral_measures(waiting_mode_source(sess, text), merged)
mode = mode or (sess.business_line or "").upper()
if not mode:
mode = detect_transport_mode(f"{sess.immutable_text or ''}\n{text}")
@@ -149,7 +152,10 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
phase="need_mode",
business_line="",
facts=facts,
immutable_text=sess.immutable_text if sess and sess.immutable_text else text,
immutable_text=stack_user_text(
sess.immutable_text if sess else "",
text,
),
)
)
return "need_mode"
@@ -187,13 +193,28 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
inbound_raw=inbound_raw,
)
from agent.schema.sea_options import drop_invented_door_trade_terms, harvest_sea_class
from agent.schema.field_validate import harvest_spoken_trade_term, shape_facts_for_chosen_mode
from agent.schema.sea_options import (
apply_spoken_sea_trade_reply,
harvest_sea_class,
short_sea_trade_reply,
)
facts = harvest_sea_class(text, facts)
# 旧陆运/补问草稿可能把「门到门」塞进贸易条款;原话没写条款则丢掉。
facts = drop_invented_door_trade_terms(text, facts)
# 刚选定海运时,贸易条款、运输分类要对照定方式前的全部原话,不只看「海运」两个字。
source = waiting_mode_source(sess, text)
# 整句只回「港到港」是贸易条款举例,不要改写成运输分类。
class_source = "" if short_sea_trade_reply(source) == "港到港" else source
facts = harvest_sea_class(class_source, facts)
# 贸易条款标签行原样优先;整句回复门到门/港到港要收下;没写这两个词时模型冒充的门到门丢掉。
facts = apply_spoken_sea_trade_reply(source, facts)
spoken = harvest_spoken_trade_term(source)
if spoken.get("贸易条款") and not str(facts.get("贸易条款") or "").strip():
facts["贸易条款"] = spoken["贸易条款"]
check = validate_required_fields(facts=facts, business_line="SEA")
facts = dict(check["facts"])
if is_waiting_mode_session(sess):
# 件数里的数量已进货物数量;空运的件数、毛重、体积、包装不再留在海运单上。
facts = shape_facts_for_chosen_mode(facts, "SEA")
tms_ready = assemble_sea_query(facts)
facts = dict(tms_ready["facts"])
if not check["ok"] or not tms_ready["ok"]:
@@ -516,6 +516,36 @@ def harvest_oral_collab(text: str) -> dict[str, str]:
return out
# 原话里单独出现才收下。举例行「(如:DDP、DAP、CIF、CIP)」整行跳过,避免示例被当成已填。
_TRADE_TERM_CODES = ("DDP", "DAP", "DDU", "CIF", "CIP", "CPT", "CFR", "FOB", "FCA", "EXW")
_TRADE_TERM_RE = re.compile(
r"(?i)(?<![A-Za-z])(" + "|".join(_TRADE_TERM_CODES) + r")(?![A-Za-z])"
)
_TRADE_TERM_EXAMPLE_LINE = re.compile(r"^\s*[((]如\s*[::]")
def harvest_spoken_trade_term(text: str) -> dict[str, str]:
"""
原话里单独写出的 DAP、DDP、CIF 等记成贸易条款。
只认这一组术语本身,不从「门到门」猜。同一句出现两个不同术语时不选。
补问举例行整行不看。已有「贸易条款」标签值的,由调用方决定不覆盖。
"""
kept: list[str] = []
for line in (text or "").splitlines() or [""]:
if _TRADE_TERM_EXAMPLE_LINE.match((line or "").strip()):
continue
kept.append(line)
found: list[str] = []
for match in _TRADE_TERM_RE.finditer("\n".join(kept)):
code = match.group(1).upper()
if code not in found:
found.append(code)
if len(found) != 1:
return {}
return {"贸易条款": found[0]}
# 箱型原文:40HQ*1、1x40HQ、40尺高柜。货物数量不能收这种串。
_CONTAINER_SPEC = re.compile(
r"(?i)(20|40|45)\s*['\"′尺]?\s*(HQ|GP|NOR|RH|HC|高柜|平柜|柜)"
@@ -549,6 +579,39 @@ def project_sea_cargo_qty(facts: dict[str, str] | None) -> dict[str, str]:
return merged
# 选定方式后,不属于该方式首次询价的键拿掉。海运件数先投影成货量再删。
_SEA_DROP_AFTER_PROJECT = ("件数", "毛重", "体积", "包装方式", "包装类型")
_LAND_DROP_FOREIGN = ("件数", "货量", "货物数量", "整柜或拼柜", "箱型箱量", "贸易条款")
_AIR_DROP_FOREIGN = ("货量", "货物数量", "整柜或拼柜", "箱型箱量")
def shape_facts_for_chosen_mode(facts: dict[str, str] | None, business_line: str) -> dict[str, str]:
"""
运输方式刚定下来时,按该方式的询价字段收口。
海运留下货物数量、整柜或拼柜、箱型箱量、贸易条款、运输分类;件数里的数量先写入货量。
陆运留下运输类型、线路类别、运输分类和重量体积或车型,不留海运箱型、空运件数。
空运留下件数、毛重、体积、包装方式,不留海运箱型。
不猜新值,只删不属于本方式的键。返回新 dict。
"""
line = (business_line or "").strip().upper()
merged = {str(k): str(v).strip() for k, v in dict(facts or {}).items() if str(v).strip()}
if line == "SEA":
merged = project_sea_cargo_qty(merged)
for key in _SEA_DROP_AFTER_PROJECT:
merged.pop(key, None)
return merged
if line == "LAND":
for key in _LAND_DROP_FOREIGN:
merged.pop(key, None)
return merged
if line == "AIR":
for key in _AIR_DROP_FOREIGN:
merged.pop(key, None)
return merged
return merged
def apply_quote_date_default(facts: dict[str, str]) -> dict[str, str]:
"""销售未提供报价日期时填当天;已有则不覆盖。"""
merged = dict(facts)
+88 -14
View File
@@ -20,7 +20,7 @@ _CLOSED = frozenset({"已成交", "未成交", "已关闭", "转人工"})
_QUOTED = frozenset({"已报价", "协商中"})
_PERCENT_UP = re.compile(
r"(上调|上涨|上升|上浮|提高|加|涨|升)\s*(\d+(?:\.\d+)?)\s*%"
r"(上调|上涨|上升|上浮|提高|提升|加|涨|升)\s*(\d+(?:\.\d+)?)\s*%"
)
_PERCENT_DOWN = re.compile(r"(下调|下降|降低|减|降)\s*(\d+(?:\.\d+)?)\s*%")
_TARGET = re.compile(
@@ -531,6 +531,7 @@ def extract_adjust(
if not raw:
return AdjustExtract(is_adjust=False)
names = [str(x).strip() for x in (fee_names or []) if str(x).strip()]
names_sorted = sorted(names, key=len, reverse=True)
use_model = allow_model
if use_model is None:
try:
@@ -558,11 +559,28 @@ def extract_adjust(
op = _item_from_chunk(raw, "")
if op:
modeled.items = [op]
# 模型常只抽出「费用合计」:本地再扫点名小计/费目,避免「始发港费用小计提升10%」被丢掉
local_items = _local_fee_items(raw, names_sorted)
if local_items:
seen = {
str(x.fee_name or "").strip()
for x in (modeled.items or [])
if str(x.fee_name or "").strip()
}
merged = list(modeled.items or [])
for it in local_items:
key = str(it.fee_name or "").strip()
if key and key not in seen:
merged.append(it)
seen.add(key)
modeled = AdjustExtract(
is_adjust=True,
items=merged,
wants_total=bool(modeled.wants_total),
)
return modeled
except Exception:
logger.exception("调价模型抽取失败,回退本地")
names_sorted = sorted(names, key=len, reverse=True)
blob = raw.replace(" ", "")
total_hit = any(w in raw for w in _TOTAL_WORDS)
has_op = bool(_PERCENT_UP.search(raw) or _PERCENT_DOWN.search(raw) or _TARGET.search(raw))
fee_in_text = [n for n in names_sorted if n and n in raw]
@@ -571,6 +589,17 @@ def extract_adjust(
return AdjustExtract(is_adjust=True, wants_total=True, items=[op] if op else [])
if _QUOTE_WORD in raw and has_op and not fee_in_text and not total_hit:
return AdjustExtract(is_adjust=True, wants_total=False)
items = _local_fee_items(raw, names_sorted)
if not items and has_op:
# 说了费用口语但和名单不完全一致:整句再扫一次
item = _item_from_chunk(raw, "")
if item:
items.append(item)
return AdjustExtract(is_adjust=bool(items or has_op), items=items)
def _local_fee_items(raw: str, names_sorted: list[str]) -> list[AdjustItem]:
"""按分句把点名费目/小计抽成调价项。提升/上调等口令都认。"""
items: list[AdjustItem] = []
for part in _SPLIT.split(raw) or [raw]:
chunk = (part or "").strip()
@@ -585,16 +614,15 @@ def extract_adjust(
found = _FEE_TOKEN.findall(chunk)
spoken = found[-1] if found else ""
if not spoken:
continue
# 「费用合计提升10%」:名单里有费用合计时上面已命中;否则用合计词
if any(w in chunk for w in _TOTAL_WORDS):
spoken = "费用合计"
else:
continue
item = _item_from_chunk(chunk, spoken)
if item:
items.append(item)
if not items and has_op:
# 说了费用口语但和名单不完全一致:整句再扫一次
item = _item_from_chunk(raw, "")
if item:
items.append(item)
return AdjustExtract(is_adjust=bool(items or has_op), items=items)
return items
def _item_from_chunk(chunk: str, fee_name: str) -> Optional[AdjustItem]:
@@ -763,6 +791,42 @@ def _apply_total_only(
)
def _fold_total_into_items(
extract: AdjustExtract,
*,
business_line: str = "",
quote: dict[str, Any] | None = None,
) -> AdjustExtract:
"""
合计与费目小计写在同一条指令里时:取消 wants_total 独占,把合计并进 items。
为何:wants_total 只改 total,海运「始发港费用小计」不会动,Excel 汇总仍是旧数。
"""
if not extract.wants_total:
return extract
named = [x for x in (extract.items or []) if str(x.fee_name or "").strip()]
if not named:
return extract
label = total_price_label(business_line, quote=quote)
has_total = any(fee_name_same(str(x.fee_name or ""), label) for x in named)
items = list(named)
if not has_total:
seed = named[0]
for raw in extract.items or []:
if not str(raw.fee_name or "").strip():
seed = raw
break
items.append(
AdjustItem(
fee_name=label,
kind=seed.kind,
direction=seed.direction,
value=seed.value,
)
)
return AdjustExtract(is_adjust=True, wants_total=False, items=items)
def apply_adjust(
quote: dict[str, Any] | None,
extract: AdjustExtract,
@@ -782,6 +846,8 @@ def apply_adjust(
rows = adjustable_rows(src)
if not extract.is_adjust:
return ApplyResult(ok=False, code="not_adjust", quote=src)
# 同时说了「始发港费用小计」和「费用合计」时:不能只走合计通道,否则小计不改、Excel 汇总也不动。
extract = _fold_total_into_items(extract, business_line=business_line, quote=src)
header = str(src.get("currency") or "").strip()
total_ccy = row_currency({"currency": header, "amount": src.get("total")}, header)
currencies = {row_currency(r, header) for r in rows if row_currency(r, header)}
@@ -798,6 +864,7 @@ def apply_adjust(
currency_hint = bool(multi)
total_amt, _ = parse_money(src.get("total"))
new_total = total_amt
total_touched = False
single_same_ccy = _single_line_same_currency(
[row for row in rows if not row.get("_subtotal_key") and not row.get("_is_total")],
total_ccy or header,
@@ -845,8 +912,10 @@ def apply_adjust(
bucket = str(row.get("_bucket") or "")
if row.get("_is_total"):
subtotal_only["__total__"] = new_amt
total_touched = True
continue
if row.get("_subtotal_key"):
# 只改小计数字写回 fee_total_*;明细是否另调看有没有点名费目
subtotal_only[bucket] = new_amt - old_amt
continue
if bucket:
@@ -861,10 +930,15 @@ def apply_adjust(
if (not multi) and new_total is not None:
if "__total__" in subtotal_only:
# 合计是整单改数;小计差额不再叠进合计,避免「合计×1.1 + 小计差额」重复
new_total = subtotal_only.pop("__total__")
for bucket, delta in subtotal_only.items():
if bucket not in touched_buckets:
new_total = new_total + delta
else:
for bucket, delta in subtotal_only.items():
if bucket not in touched_buckets:
new_total = new_total + delta
elif total_touched and "__total__" in subtotal_only:
# 多币种:点名「费用合计」仍改合计数字,不做汇率换算
new_total = subtotal_only["__total__"]
out = dict(src)
visible = []
@@ -876,7 +950,7 @@ def apply_adjust(
out["fee_rows"] = visible
out["fee_lines"] = visible
_write_adjusted_buckets(out, rows)
if applied and (not multi) and new_total is not None and new_total != total_amt:
if applied and new_total is not None and new_total != total_amt and ((not multi) or total_touched):
out["total"] = format_money(new_total, sample=str(src.get("total") or ""), currency=total_ccy)
if not applied:
code = "ambiguous" if ambiguous else "none_matched"
+98 -5
View File
@@ -1,7 +1,7 @@
"""
海运运输分类:只认 Port to Port、其他。
本文件职责:从原话收成标准词、生成补问清单。
本文件职责:从原话收成标准词、生成补问清单;贸易条款标签行原样优先。
禁止:发明第三项;把分类带去 TMS;改六态。
线程:纯函数,可在 Worker 槽调用。
"""
@@ -15,6 +15,15 @@ SEA_CLASSES = ("Port to Port", "其他")
# 补问清单下一行「(如:Port to Port、其他)」只是举例,不能当成销售已选。
_CLARIFY_EXAMPLE_LINE = re.compile(r"^\s*[((]如\s*[::]")
# 标签行优先:避免「贸易条款:Port to Port」整句扫描时把运输分类也收成 Port to Port。
_LABELED_SEA_CLASS = re.compile(
r"(?im)^\s*运输分类(?:(必填)|\(必填\))?\s*[::]\s*(.+?)\s*$"
)
_LABELED_TRADE_TERMS = re.compile(
r"(?im)^\s*贸易条款(?:(必填)|\(必填\)|(非必填)|\(非必填\))?\s*[::]\s*(.+?)\s*$"
)
_TRADE_TERMS_LINE = re.compile(r"(?im)^\s*贸易条款")
# 长词优先,避免 P2P 误伤其它词;门到门不在名单里。
_ALIASES = (
("port to port", "Port to Port"),
@@ -58,16 +67,39 @@ def _without_clarify_examples(text: str) -> str:
)
def _without_trade_terms_lines(text: str) -> str:
"""整句扫运输分类时丢掉「贸易条款:…」行,避免条款里的 Port to Port 冒充分类。"""
return "\n".join(
line for line in (text or "").splitlines() if not _TRADE_TERMS_LINE.match(line.strip())
)
def _labeled_value(text: str, pattern: re.Pattern[str]) -> str:
"""取原文最后一次标签行冒号后的值;没有则空串。"""
last = ""
for hit in pattern.finditer(_without_clarify_examples(text or "")):
last = (hit.group(1) or "").strip()
return last
def harvest_sea_class(text: str, current: dict[str, str] | None = None) -> dict[str, str]:
"""
当前句能认则覆盖;认不出则保留已有标准值。
参数:本轮原文、已有 facts。返回合并后的 facts 副本。
举例行「(如:Port to Port、其他)」先丢掉,避免示例里的 Port to Port 盖掉冒号后的「其它」。
优先「运输分类:」标签行(WO202609280012:条款写 Port to Port、分类写其它)。
整句扫描时排除贸易条款行,避免条款里的 Port to Port 盖掉「其它」。
「其它」与「其他」都收成「其他」。非法旧值会丢掉,避免「门到门」一直占着必填位。
"""
merged = dict(current or {})
found = canonicalize_sea_class(_without_clarify_examples(text or ""))
cleaned = _without_clarify_examples(text or "")
labeled = _labeled_value(cleaned, _LABELED_SEA_CLASS)
if labeled:
found_labeled = canonicalize_sea_class(labeled)
if found_labeled:
merged["运输分类"] = found_labeled
return merged
# 整句扫描:不含贸易条款行
found = canonicalize_sea_class(_without_trade_terms_lines(cleaned))
if found:
merged["运输分类"] = found
return merged
@@ -98,6 +130,47 @@ _DOOR_TRADE_TERMS = frozenset(
)
def harvest_sea_trade_terms(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
"""
海运贸易条款:标签行冒号后原样优先。
WO202609280012:销售写「贸易条款:Port to Port」,模型却收成「门到门」。
有标签行则强制用标签值;门到门是否丢掉仍交给 drop_invented_door_trade_terms。
"""
merged = dict(facts or {})
labeled = _labeled_value(text or "", _LABELED_TRADE_TERMS)
if labeled:
merged["贸易条款"] = labeled
return merged
# 补问举例让销售直接回这两个词。整句只有其中一个才记成贸易条款,长句里的港到港仍给运输分类。
_SHORT_SEA_TRADE_REPLY = re.compile(
r"^\s*(门到门|港到港|door\s*to\s*door|door-to-door|d2d)\s*[.。!!??]?\s*$",
re.IGNORECASE,
)
def short_sea_trade_reply(text: str) -> str:
"""
销售整句只回门到门或港到港时,收成贸易条款。
举例行不算。长句里没写这两个词时,不能从这里编出一个条款。
返回「门到门」或「港到港」;对不上返回空串。
"""
cleaned = _without_clarify_examples(text or "").strip()
hit = _SHORT_SEA_TRADE_REPLY.match(cleaned)
if not hit:
return ""
raw = (hit.group(1) or "").strip()
folded = re.sub(r"[\s-]+", "", raw).lower()
if raw == "港到港":
return "港到港"
if raw == "门到门" or folded in {"doortodoor", "d2d"}:
return "门到门"
return ""
def drop_invented_door_trade_terms(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
"""
丢掉冒充贸易条款的「门到门」。
@@ -105,11 +178,17 @@ def drop_invented_door_trade_terms(text: str, facts: dict[str, str] | None = Non
测服 WO202609200013:原话没写贸易条款,确认卡却出现「贸易条款:门到门」。
门到门只是运输分类说法,不是 FOB/CIF 这类条款;原话没有「贸易条款」四字时不准收下。
销售明确写了「贸易条款:门到门」则保留原文,不改六态。
若标签行写的是 Port to Port / 港到港等,禁止再用模型的「门到门」盖掉。
"""
merged = dict(facts or {})
merged = harvest_sea_trade_terms(text, facts)
trade = str(merged.get("贸易条款") or "").strip()
if not trade:
return merged
labeled = _labeled_value(text or "", _LABELED_TRADE_TERMS)
if labeled:
# 标签已写死条款:以标签为准(含 Port to Port)
merged["贸易条款"] = labeled.strip()
return merged
if "贸易条款" in (text or ""):
return merged
if trade.lower().replace(" ", "") in {x.replace(" ", "") for x in _DOOR_TRADE_TERMS} or trade in _DOOR_TRADE_TERMS:
@@ -117,6 +196,20 @@ def drop_invented_door_trade_terms(text: str, facts: dict[str, str] | None = Non
return merged
def apply_spoken_sea_trade_reply(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
"""
先丢掉模型冒充的门到门,再收下销售整句回复的门到门或港到港。
测服 2026-10-03:补问举例是门到门、港到港,销售回「门到门」仍被当成没填,并提示参数提取不全。
原话没出现这两个词时,旧草稿里的门到门继续丢掉。返回新 dict。
"""
merged = drop_invented_door_trade_terms(text, facts)
spoken = short_sea_trade_reply(text)
if spoken:
merged["贸易条款"] = spoken
return merged
def sea_class_prompt() -> str:
"""缺运输分类时只列两项,不再追问「其他是什么」。"""
return "运输分类(必填):请回复 Port to Port 或 其他。"
+31 -1
View File
@@ -78,6 +78,10 @@ _IATA_PAIR_BLOCK = frozenset(
}
)
_NUMBER = re.compile(r"(\d+(?:\.\d+)?)")
# 销售写尺寸当体积:1.1*1.2*3 / 1.1x1.2x3 / 1.1×1.2×3
_DIM_PRODUCT = re.compile(
r"(\d+(?:\.\d+)?)\s*[*xX×]\s*(\d+(?:\.\d+)?)(?:\s*[*xX×]\s*(\d+(?:\.\d+)?))?"
)
def _first_number(raw: str) -> str:
@@ -85,6 +89,32 @@ def _first_number(raw: str) -> str:
return hit.group(1) if hit else ""
def volume_cbm_number(raw: str) -> str:
"""
体积出站用数字:尺寸式先相乘;否则取第一个数。
例:1.1*1.2*3 → 3.96;2CBM → 2。空串表示没有可用体积。
"""
text = (raw or "").strip()
if not text:
return ""
dims = _DIM_PRODUCT.search(text)
if dims:
try:
a = float(dims.group(1))
b = float(dims.group(2))
c = float(dims.group(3) or "1")
product = a * b * c
if product <= 0:
return ""
# 去掉多余尾零,保持可读小数
text_num = f"{product:.10f}".rstrip("0").rstrip(".")
return text_num or ""
except ValueError:
return ""
return _first_number(text)
def resolve_airport_code(raw: str) -> str:
"""销售原文是地名或三字码时,给出 TMS originCode/destinationCode。"""
text = (raw or "").strip()
@@ -161,7 +191,7 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
cargo = str(src.get("品名") or "").strip()
pieces = _first_number(str(src.get("件数") or ""))
weight = _first_number(str(src.get("毛重") or ""))
volume = _first_number(str(src.get("体积") or ""))
volume = volume_cbm_number(str(src.get("体积") or ""))
pack_raw = str(src.get("包装方式") or src.get("包装类型") or "").strip()
pack_code = resolve_package_type(pack_raw)
quote_date = str(src.get("报价日期") or "").strip()
+19 -1
View File
@@ -21,10 +21,28 @@ from agent.schema.field_validate import (
air_general_cargo_collab_defaults,
validate_required_fields,
)
from agent.schema.tms_air_query import assemble_air_query
from agent.schema.tms_air_query import assemble_air_query, volume_cbm_number
class AirFieldContractTests(unittest.TestCase):
def test_volume_dimension_product(self) -> None:
self.assertEqual(volume_cbm_number("1.1*1.2*3"), "3.96")
self.assertEqual(volume_cbm_number("1.1x1.2x3"), "3.96")
self.assertEqual(volume_cbm_number("2CBM"), "2")
out = assemble_air_query(
{
"目的港": "吉隆坡",
"品名": "普货",
"件数": "3件",
"毛重": "5",
"体积": "1.1*1.2*3",
"包装方式": "托盘",
"报价日期": "2026-10-08",
}
)
self.assertTrue(out["ok"], out)
self.assertEqual(out["facts"]["体积"], "1.1*1.2*3")
def test_origin_optional_still_ok(self) -> None:
result = validate_required_fields(
facts={
@@ -38,6 +38,64 @@ class FakeBotGroup:
return {"ok": True}
class AirSheetQuoteTotalTests(unittest.TestCase):
"""群填表报价包必须带 TMS 总销售价,不能把附加费之和当预估费用。"""
def test_sheet_quote_replaces_fee_sum_total_with_tms_sale_price(self) -> None:
from agent.policy.air_group_ops import _sheet_quote_with_tms_sale_total
thin = {
"total": "200",
"currency": "CNY",
"fee_lines": [
{"name": "增点费", "amount": "100", "currency": "CNY"},
{"name": "拆板费", "amount": "100", "currency": "USD"},
],
}
tms = {
"source": "TMS",
"total": "7918.25",
"currency": "CNY",
"chargeableWeightKg": "833.5",
"airOptions": [
{
"optionNo": "AIR-1",
"salePrice": 7918.25,
"currency": "CNY",
"departureCharges": [
{"chargeName": "增点费", "unitPrice": 100, "currency": "CNY"},
{"chargeName": "拆板费", "unitPrice": 100, "currency": "USD"},
],
}
],
}
out = _sheet_quote_with_tms_sale_total(thin, tms=tms, facts={"起运港": "南京", "目的港": "吉隆坡"})
self.assertIn("7918.25", str(out.get("total") or ""))
self.assertNotEqual(str(out.get("total") or "").strip(), "200")
self.assertTrue(out.get("airOptions") or out.get("selected_air_option"))
def test_sheet_quote_keeps_product_total_when_not_fee_sum(self) -> None:
from agent.policy.air_group_ops import _sheet_quote_with_tms_sale_total
product = {
"total": "9000",
"currency": "CNY",
"source_label": "航线报价",
"fee_lines": [
{"name": "增点费", "amount": "100", "currency": "CNY"},
{"name": "拆板费", "amount": "100", "currency": "USD"},
],
}
tms = {
"source": "TMS",
"total": "7918.25",
"currency": "CNY",
"airOptions": [{"optionNo": "AIR-1", "salePrice": 7918.25, "currency": "CNY"}],
}
out = _sheet_quote_with_tms_sale_total(product, tms=tms, facts={})
self.assertEqual(str(out.get("total") or "").strip(), "9000")
class AirGroupCollabTests(unittest.TestCase):
def setUp(self) -> None:
from agent.redis_coord.air_inbound import reset_air_inbound_for_tests
@@ -905,6 +963,7 @@ class AirGroupCollabTests(unittest.TestCase):
ledger=self.ledger,
injected_intent="deal_lost",
)
before = len(self._texts())
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}释放舱位", chat="wr_air_1", mid="r2"),
flow=self.flow,
@@ -912,6 +971,9 @@ class AirGroupCollabTests(unittest.TestCase):
injected_intent="air_release",
)
self.assertEqual(phase, "release_ok")
added = "\n".join(self._texts()[before:])
self.assertIn("释放", added)
self.assertNotIn("请确认成交状态", added)
def test_sales_cannot_lock(self) -> None:
wo = self._create_air()
@@ -230,6 +230,41 @@ class ContinueTicketTests(unittest.TestCase):
self.assertEqual(phase, "wait_deal")
self.assertFalse(any("成交" in x or copy.BTN_DEAL in x for x in replies))
def test_resume_spoken_deal_closes(self) -> None:
"""报价文件已出后,原话写工单号加已成交,要入账并回复,不能没反应。"""
ledger = MemoryLedger()
created = ledger.create_ticket(
sender_id="u6b", business_line="AIR", facts=COMPLETE_AIR
)
wo = created["work_order_no"]
ledger.upsert_quote(
work_order_no=wo,
quote={"total": "USD 1", "eta": "-", "valid_until": "-"},
to_status="已报价",
)
ledger.render_quote(
work_order_no=wo,
business_line="AIR",
facts=COMPLETE_AIR,
quote={"total": "USD 1"},
)
flow = AirTextInquiryFlow(ledger)
replies: list[str] = []
def reply(text: str, extra=None) -> None:
replies.append(text)
_ = extra
phase = flow.resume_historical(
sender_id="u6b",
work_order_no=wo,
reply=reply,
text=f"{wo}已成交",
)
self.assertEqual(phase, "done")
self.assertTrue(any("已记录为「已成交」" in x for x in replies))
self.assertIn(wo, "\n".join(replies))
def test_resume_missing_wo_asks(self) -> None:
flow = AirTextInquiryFlow(MemoryLedger())
replies: list[str] = []
+12
View File
@@ -70,6 +70,18 @@ class OralExtractTests(unittest.TestCase):
self.assertEqual(snap["business_line"], "AIR")
self.assertFalse(snap["facts"].get("起运港"))
def test_bare_dap_is_trade_term(self) -> None:
"""句尾单独的 DAP 记成贸易条款,不依赖模型有没有这个字段。"""
snap = extract_inquiry_snapshot("查一下空运,出货日期9月30日广州到香港的普货价格,DAP")
self.assertEqual(snap["business_line"], "AIR")
self.assertEqual(snap["facts"].get("贸易条款"), "DAP")
def test_trade_term_example_line_is_not_filled(self) -> None:
snap = extract_inquiry_snapshot("贸易条款(非必填):\n(如:DDP、DAP、CIF、CIP)")
self.assertFalse(snap["facts"].get("贸易条款"))
labeled = extract_inquiry_snapshot("贸易条款:CIF\n(如:DDP、DAP、CIF、CIP)")
self.assertEqual(labeled["facts"].get("贸易条款"), "CIF")
def test_labeled_customs_port(self) -> None:
self.assertEqual(parse_labeled_facts("通关口岸:皇岗").get("通关口岸"), "皇岗")
snap = extract_inquiry_snapshot("通关口岸(非必填):皇岗")
+142
View File
@@ -414,6 +414,148 @@ class LandDispatchTests(unittest.TestCase):
self.assertNotIn("你好,我是询价机器人", last)
self.assertNotIn("请直接描述客户需求", last)
def test_lost_reason_not_taken_as_new_inquiry(self) -> None:
"""未成交后下一句是原因。旧报价单被判成调价时,不能改问运输方式。"""
from agent.policy.air_text_flow import FlowSession
sea = get_sea_text_flow()
created = sea._ledger.create_ticket(
sender_id="lost-1",
business_line="SEA",
facts={"起运港": "广州港", "目的港": "巴生港", "品名": "机器设备"},
)
wo = str(created.get("work_order_no") or "")
sea._ledger.transition(work_order_no=wo, to_status="未成交")
sea._save(
FlowSession(
sender_id="lost-1",
thread_id="lost-1",
phase="wait_lost_reason",
business_line="SEA",
work_order_no=wo,
status="未成交",
wait_version=2,
)
)
get_land_text_flow()._save(
FlowSession(
sender_id="lost-1",
thread_id="lost-1",
phase="wait_deal",
business_line="LAND",
work_order_no="WO202609240045",
status="已报价",
quote={"total": "CNY 1"},
)
)
phase = handle_text_inquiry(
InboundMessage(sender_id="lost-1", message_id="m-lost", content="客户不需要了"),
prefer_quoted=True,
store=self.store,
)
self.assertEqual(phase, "done")
body = self._outbox_text()
self.assertIn("已记下未成交原因:客户不需要了", body)
self.assertNotIn("告知我询价的运输方式", body)
def test_several_turns_before_mode_follow_the_chosen_line(self) -> None:
"""定运输方式前连续几句询价字段都要留下,选定后再整份带上。不只认上一句。"""
from agent.policy.air_text_flow import FlowSession, get_air_text_flow
turns = (
(
"a",
"10月1日,深圳-吉隆坡,散货、普货报价",
{
"起运港": "深圳",
"目的港": "吉隆坡",
"品名": "普货",
"包装方式": "散货",
"报价日期": "2026-10-01",
},
),
(
"b",
"10件,毛重100公斤,体积2方",
{"件数": "10", "毛重": "100", "体积": "2"},
),
)
for label, reply, mode in (
("空运", "3", "AIR"),
("海运", "海运", "SEA"),
("陆运", "1", "LAND"),
):
reset_sea_text_flow_for_test()
reset_land_text_flow_for_test()
reset_air_text_flow_for_test()
sid = "premode-" + mode
# 上一单还在海运,新的几句里没有运输方式,不能把这些字段丢掉。
get_sea_text_flow()._save(
FlowSession(
sender_id=sid,
thread_id=sid,
phase="tms_miss",
business_line="SEA",
work_order_no="WO-OLD",
facts={"目的港": "旧港口"},
)
)
for mid, content, facts in turns:
phase = handle_text_inquiry(
InboundMessage(sender_id=sid, message_id=sid + mid, content=content),
injected_facts=facts,
injected_mode="",
store=self.store,
)
self.assertEqual(phase, "need_mode", label)
waiting = get_sea_text_flow().session_of(sid)
self.assertEqual(waiting.facts.get("目的港"), "吉隆坡", label)
self.assertIn("10", waiting.facts.get("件数") or "", label)
self.assertIn("深圳-吉隆坡", waiting.immutable_text)
self.assertIn("10件", waiting.immutable_text)
before = len(self.store._outbox)
phase = handle_text_inquiry(
InboundMessage(sender_id=sid, message_id=sid + "mode", content=reply),
store=self.store,
)
self.assertNotEqual(phase, "need_mode", label)
added = "\n".join(i.content for i in list(self.store._outbox.values())[before:])
owner = {
"AIR": get_air_text_flow(),
"SEA": get_sea_text_flow(),
"LAND": get_land_text_flow(),
}[mode]
sess = owner.session_of(sid)
self.assertIsNotNone(sess, label)
self.assertEqual((sess.business_line or "").upper(), mode, label)
self.assertEqual(sess.facts.get("起运港"), "深圳", label)
self.assertEqual(sess.facts.get("目的港"), "吉隆坡", label)
self.assertEqual(sess.facts.get("品名"), "普货", label)
self.assertEqual(sess.facts.get("报价日期"), "2026-10-01", label)
self.assertNotEqual(sess.facts.get("目的港"), "旧港口", label)
if mode == "AIR":
self.assertIn("10", sess.facts.get("件数") or "", label)
self.assertNotIn("整柜或拼柜", sess.facts, label)
self.assertNotIn("箱型箱量", sess.facts, label)
elif mode == "SEA":
self.assertIn("10", sess.facts.get("货量") or "", label)
self.assertNotIn("件数", sess.facts, label)
self.assertNotIn("毛重", sess.facts, label)
self.assertNotIn("包装方式", sess.facts, label)
self.assertIn("货物数量", added)
self.assertIn("整柜或拼柜", added)
self.assertIn("箱型箱量", added)
self.assertIn("贸易条款", added)
self.assertNotIn("件数(必填)", added)
else:
self.assertIn("100", sess.facts.get("毛重") or "", label)
self.assertIn("2", sess.facts.get("体积") or "", label)
self.assertNotIn("件数", sess.facts, label)
self.assertNotIn("箱型箱量", sess.facts, label)
self.assertNotIn("贸易条款", sess.facts, label)
self.assertIn("运输类型", added)
self.assertNotIn("件数(必填)", added)
if __name__ == "__main__":
unittest.main()
+42
View File
@@ -17,6 +17,7 @@ from agent.policy import inquiry_copy as copy
from agent.schema.quote_adjust import (
AdjustExtract,
AdjustItem,
adjustable_rows,
apply_adjust,
effective_quote,
extract_adjust,
@@ -196,6 +197,47 @@ class QuoteAdjustTests(unittest.TestCase):
self.assertEqual(names["箱单费"], "210.00")
self.assertEqual(got.quote["fee_total_polCostItems"], "1344.00")
def test_sea_pol_subtotal_and_total_up_ten_percent(self) -> None:
"""协同话术:始发港费用小计 + 费用合计 各提升 10%。"""
src = {
"currency": "CNY",
"total": "14300",
"fee_lines": [
{"name": "操作费", "amount": "1220", "currency": "CNY"},
{"name": "海运费", "amount": "USD 1250", "currency": "USD"},
],
"polCostItems": [
{"name": "操作费", "amount": "1220", "currency": "CNY"},
{"name": "箱单费", "amount": "50", "currency": "CNY"},
],
"oceanCostItems": [
{"name": "海运费", "amount": "1250", "currency": "USD"},
],
"podCostItems": [
{"name": "操作费", "amount": "240", "currency": "USD"},
],
"fee_total_polCostItems": "2335",
"fee_total_oceanCostItems": "8778.75",
"fee_total_podCostItems": "2985",
}
names = [str(r.get("name") or "") for r in adjustable_rows(src)]
text = "将始发港费用小计提升10%\n费用合计提升10%"
ext = extract_adjust(text, fee_names=names, allow_model=False)
self.assertTrue(ext.is_adjust)
fee_names = [x.fee_name for x in ext.items]
self.assertIn("始发港费用小计", fee_names)
self.assertIn("费用合计", fee_names)
# 即使模型误标 wants_total,有点名费目时也不能只改合计
ext.wants_total = True
got = apply_adjust(src, ext, business_line="SEA")
self.assertTrue(got.ok)
self.assertEqual(got.quote["fee_total_polCostItems"], "2568.50")
self.assertEqual(got.quote["total"], "15730.00")
body = copy.adjust_group_body(got.quote, business_line="SEA")
self.assertIn("始发港费用小计", body)
self.assertIn("海运费小计", body)
self.assertIn("目的港费用小计", body)
def test_same_fee_name_keeps_its_own_amount(self) -> None:
src = {
"currency": "USD",
+28 -5
View File
@@ -60,11 +60,34 @@ class SeaOptionsTest(unittest.TestCase):
kept = drop_invented_door_trade_terms("贸易条款:门到门", {"贸易条款": "门到门"})
self.assertEqual(kept["贸易条款"], "门到门")
def test_prompt_lists_two_options_only(self):
text = sea_class_prompt()
self.assertIn("Port to Port", text)
self.assertIn("其他", text)
self.assertNotIn("门到门", text)
def test_short_reply_door_or_port_is_trade_term(self):
from agent.schema.sea_options import apply_spoken_sea_trade_reply
door = apply_spoken_sea_trade_reply("门到门", {})
self.assertEqual(door.get("贸易条款"), "门到门")
port = apply_spoken_sea_trade_reply("港到港", {"运输分类": "Port to Port"})
self.assertEqual(port.get("贸易条款"), "港到港")
self.assertEqual(port.get("运输分类"), "Port to Port")
example = apply_spoken_sea_trade_reply("(如:门到门、港到港)", {})
self.assertNotIn("贸易条款", example)
def test_wo012_trade_port_class_other(self):
"""条款写 Port to Port、分类写其它:二者各归各栏,禁止互盖。"""
text = (
"运输方式:海运\n起运港:纽约港\n目的港:巴生西港\n品名:机器设备\n"
"货物数量:15件\n整柜或拼柜:整柜\n箱型箱量:40HQ*1\n"
"贸易条款:Port to Port\n运输分类:其它\n报价日期:2026-09-29"
)
got = harvest_sea_class(text, {"贸易条款": "门到门", "运输分类": "Port to Port"})
self.assertEqual(got.get("运输分类"), "其他")
got = drop_invented_door_trade_terms(text, {"贸易条款": "门到门", "运输分类": "其他"})
self.assertEqual(got.get("贸易条款"), "Port to Port")
self.assertEqual(got.get("运输分类"), "其他")
def test_trade_line_port_does_not_set_class_when_class_labeled_other(self):
text = "贸易条款:Port to Port\n运输分类:其它"
self.assertEqual(harvest_sea_class(text, {}).get("运输分类"), "其他")
if __name__ == "__main__":
@@ -206,6 +206,13 @@ class SeaTextInquiryTests(unittest.TestCase):
self.assertIn("运输分类", check["missing"])
self.assertIn("贸易条款", check["missing_display"])
self.assertIn("运输分类", check["missing_display"])
text = copy.ask_clarify(
facts=check["facts"],
missing_keys=check["missing"],
transport_mode="海运",
)
self.assertIn("贸易条款(必填):\n(如:门到门、港到港)", text)
self.assertNotIn("如:FOB", text)
def test_sea_rejects_door_to_door_class(self) -> None:
facts = {
@@ -324,6 +331,29 @@ class SeaTextInquiryTests(unittest.TestCase):
self.assertIn("贸易条款", joined)
self.assertNotEqual(self.flow.session_of("s-door").facts.get("贸易条款"), "门到门")
def test_reply_door_to_door_fills_trade_terms(self) -> None:
"""补问后只回门到门,要记成贸易条款,不能回参数提取不全。"""
first = dict(COMPLETE_SEA)
first.pop("贸易条款")
phase = self.flow.on_text(
sender_id="s-door-reply",
text="先缺条款",
reply=self._reply,
injected_facts=first,
injected_mode="SEA",
)
self.assertEqual(phase, "clarify")
self.replies.clear()
phase = self.flow.on_text(
sender_id="s-door-reply",
text="门到门",
reply=self._reply,
)
self.assertEqual(phase, "wait_collab")
joined = "\n".join(self.replies)
self.assertIn("贸易条款:门到门", joined)
self.assertNotIn("参数提取不全", joined)
def test_harvest_class_from_reply_then_quotes(self) -> None:
first = dict(COMPLETE_SEA)
first.pop("运输分类")
+6
View File
@@ -34,6 +34,12 @@
<artifactId>poi-ooxml</artifactId>
<version>4.1.2</version>
</dependency>
<!-- 模板预览:PDF→PNG 等比例渲染(替代 LibreOffice png 滤镜的非等比拉伸) -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>2.0.29</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
@@ -643,11 +643,15 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
});
}
/**
* 查找已缓存预览图。
* 只用 v2 文件名:旧版 LibreOffice 直转 PNG 可能非等比拉伸,废弃 *-snapshot.png。
*/
private Path findCachedSnapshot(String versionId) throws Exception {
Path previewDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH, "preview");
Files.createDirectories(previewDir);
Path cachedPng = previewDir.resolve(versionId + "-snapshot.png");
Path cachedPdf = previewDir.resolve(versionId + "-snapshot.pdf");
Path cachedPng = previewDir.resolve(versionId + "-snapshot-v4.png");
Path cachedPdf = previewDir.resolve(versionId + "-snapshot-v4.pdf");
if (Files.isRegularFile(cachedPng) && Files.size(cachedPng) > 0) {
return cachedPng;
}
@@ -660,8 +664,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
private Path generateAndCacheSnapshot(InquiryQuoteTemplateVersion ver) throws Exception {
Path previewDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH, "preview");
Files.createDirectories(previewDir);
Path cachedPng = previewDir.resolve(ver.getId() + "-snapshot.png");
Path cachedPdf = previewDir.resolve(ver.getId() + "-snapshot.pdf");
Path cachedPng = previewDir.resolve(ver.getId() + "-snapshot-v4.png");
Path cachedPdf = previewDir.resolve(ver.getId() + "-snapshot-v4.pdf");
Path source = resolveObjectPath(ver.getObjectKey());
QuoteTemplateSnapshotRenderer.PreviewBytes rendered = snapshotRenderer.render(source, ver.getId());
Path target = rendered.isImage() ? cachedPng : cachedPdf;
@@ -871,8 +875,31 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
out.put("quote_date", new java.text.SimpleDateFormat("yyyy-MM-dd").format(new Date()));
}
out.put("valid_until", first(q, "valid_until"));
out.put("total_amount", first(q, "total", "total_amount"));
// 预估费用必须用 TMS 总销售价。会话 quote 偶发只有 selected_air_option.salePrice、
// 顶层 total 空时,若仍走模板 SUM,会把增点费+拆板费加成 200(WO040/WO026)。
// 群出单还可能把明细之和误写成 total=200,此时仍以线路 salePrice 为准。
String fromOpt = airOptionSalePrice(q);
String totalAmount = first(q, "total", "total_amount", "salePrice", "totalSalePrice");
if (StringUtils.isBlank(totalAmount) || "-".equals(totalAmount)) {
totalAmount = fromOpt;
} else if (StringUtils.isNotBlank(fromOpt)
&& looksAirInquiry(src, q, businessLine)
&& totalLooksLikeFeeRowSum(q, totalAmount)
&& !sameMoneyAmount(totalAmount, fromOpt)) {
totalAmount = fromOpt;
}
if (StringUtils.isBlank(totalAmount) || "-".equals(totalAmount)) {
// freight 与总价同值时(无分项空运费)也可用
String freight = first(q, "freight");
if (StringUtils.isNotBlank(freight) && !"-".equals(freight)) {
totalAmount = freight;
}
}
out.put("total_amount", totalAmount);
String currency = first(q, "currency");
if (StringUtils.isBlank(currency)) {
currency = airOptionCurrency(q);
}
if (StringUtils.isNotBlank(currency)) {
out.put("currency", currency);
}
@@ -977,6 +1004,11 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
out.put("fee_rows_" + bucket, part);
out.put(bucket, part);
}
// 三段小计:卡片用 fee_total_*,出单写进「始发港/海运/目的港费用汇总」行
String sub = first(q, "fee_total_" + bucket);
if (StringUtils.isNotBlank(sub) && !"-".equals(sub)) {
out.put("fee_total_" + bucket, sub);
}
}
// 陆运线路:透传 landSegments / route_rows,供 routeRegions 填项目一
JSONArray landSegs = QuoteTemplateExcelSupport.parseFeeRows(q.get("route_rows"));
@@ -1071,6 +1103,178 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
return "";
}
/**
* 空运选定线路 / 首条 airOptions 的 salePrice。
* 会话 quote 有时只投影了线路对象,顶层 total 空,出单预估费用必须能落到这个数。
*/
private static String airOptionSalePrice(Map<String, Object> quote) {
if (quote == null || quote.isEmpty()) {
return "";
}
Object selected = quote.get("selected_air_option");
if (selected == null) {
selected = quote.get("selectedAirOption");
}
String fromSelected = salePriceFromOptionObj(selected);
if (StringUtils.isNotBlank(fromSelected)) {
return fromSelected;
}
Object raw = quote.get("airOptions");
if (raw == null) {
raw = quote.get("air_options");
}
JSONArray opts = QuoteTemplateExcelSupport.parseFeeRows(raw);
if (opts.isEmpty()) {
return "";
}
return salePriceFromOptionObj(opts.getJSONObject(0));
}
/** 空运线路币种:选定 > 首条 airOptions。 */
private static String airOptionCurrency(Map<String, Object> quote) {
if (quote == null || quote.isEmpty()) {
return "";
}
Object selected = quote.get("selected_air_option");
if (selected == null) {
selected = quote.get("selectedAirOption");
}
String ccy = currencyFromOptionObj(selected);
if (StringUtils.isNotBlank(ccy)) {
return ccy;
}
Object raw = quote.get("airOptions");
if (raw == null) {
raw = quote.get("air_options");
}
JSONArray opts = QuoteTemplateExcelSupport.parseFeeRows(raw);
if (opts.isEmpty()) {
return "";
}
return currencyFromOptionObj(opts.getJSONObject(0));
}
private static String salePriceFromOptionObj(Object raw) {
JSONObject opt = asJsonObject(raw);
if (opt == null) {
return "";
}
return firstNonBlankLocal(opt.getString("salePrice"), opt.getString("sale_price"),
opt.getString("totalSalePrice"), opt.getString("total"));
}
/**
* 顶层 total 是否只是 fee_lines 金额相加(如 100+100=200)。
* 空运群薄报价常把附加费之和误当总价,真总价在 airOptions.salePrice。
*/
private static boolean totalLooksLikeFeeRowSum(Map<String, Object> quote, String totalText) {
Double totalNum = parseMoneyPlain(totalText);
if (totalNum == null) {
return false;
}
JSONArray rows = QuoteTemplateExcelSupport.parseFeeRows(quote.get("fee_lines"));
if (rows.isEmpty()) {
rows = QuoteTemplateExcelSupport.parseFeeRows(quote.get("fee_rows"));
}
if (rows.isEmpty()) {
rows = QuoteTemplateExcelSupport.parseFeeRows(quote.get("feeItems"));
}
if (rows.isEmpty()) {
return false;
}
double sum = 0.0;
int hit = 0;
for (int i = 0; i < rows.size(); i++) {
JSONObject one = rows.getJSONObject(i);
if (one == null) {
continue;
}
Double amt = parseMoneyPlain(firstNonBlankLocal(
one.getString("amount"), one.getString("value"),
one.getString("salePrice"), one.getString("price")));
if (amt == null) {
continue;
}
sum += amt;
hit++;
}
if (hit <= 0) {
return false;
}
return Math.abs(totalNum - sum) < 0.01;
}
/** 去掉币种前缀后比较金额是否相同。 */
private static boolean sameMoneyAmount(String left, String right) {
Double a = parseMoneyPlain(left);
Double b = parseMoneyPlain(right);
if (a == null || b == null) {
return StringUtils.equals(StringUtils.trimToEmpty(left), StringUtils.trimToEmpty(right));
}
return Math.abs(a - b) < 0.01;
}
/** 从「CNY 7918.25」抽出数字;解不开返回 null。 */
private static Double parseMoneyPlain(String text) {
String raw = StringUtils.trimToEmpty(text);
if (raw.isEmpty() || "-".equals(raw)) {
return null;
}
java.util.regex.Matcher m = java.util.regex.Pattern
.compile("(-?\\d+(?:,\\d{3})*(?:\\.\\d+)?)")
.matcher(raw);
String last = null;
while (m.find()) {
last = m.group(1);
}
if (last == null) {
return null;
}
try {
return Double.parseDouble(last.replace(",", ""));
} catch (NumberFormatException e) {
return null;
}
}
private static String currencyFromOptionObj(Object raw) {
JSONObject opt = asJsonObject(raw);
if (opt == null) {
return "";
}
return StringUtils.trimToEmpty(opt.getString("currency"));
}
private static JSONObject asJsonObject(Object raw) {
if (raw == null) {
return null;
}
if (raw instanceof JSONObject) {
return (JSONObject) raw;
}
if (raw instanceof Map) {
return JSON.parseObject(JSON.toJSONString(raw));
}
if (raw instanceof String) {
String text = StringUtils.trimToEmpty((String) raw);
if (text.isEmpty() || "null".equalsIgnoreCase(text)) {
return null;
}
try {
Object parsed = JSON.parse(text);
if (parsed instanceof JSONObject) {
return (JSONObject) parsed;
}
if (parsed instanceof Map) {
return JSON.parseObject(JSON.toJSONString(parsed));
}
} catch (Exception ignored) {
return null;
}
}
return null;
}
/** 协同项没写,或只写了横杠。横杠是空占位,不能当成已经填过。 */
private static boolean collabBlank(String raw) {
String val = StringUtils.trimToEmpty(raw);
@@ -503,7 +503,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
ticket.setGoodsName(facts.get("品名"));
ticket.setPieces(parseInt(facts.get("件数")));
ticket.setWeightKg(parseDecimal(firstFact(facts, "重量", "毛重")));
ticket.setVolumeCbm(parseDecimal(facts.get("体积")));
// 体积常写尺寸 1.1*1.2*3,必须相乘;旧解析剥 * 会变成 1.11.23 直接 NumberFormat 炸建单。
ticket.setVolumeCbm(parseVolumeCbm(facts.get("体积")));
ticket.setQuoteDate(parseDate(facts.get("报价日期")));
ticket.setCargoQty(StringUtils.trimToNull(firstFact(facts, "货量", "货物数量")));
ticket.setFclLcl(StringUtils.trimToNull(facts.get("整柜或拼柜")));
@@ -578,7 +579,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
Map<String, String> facts = req.getFacts() == null ? new HashMap<>() : req.getFacts();
Integer pieces = parseInt(facts.get("件数"));
BigDecimal weight = parseDecimal(firstFact(facts, "重量", "毛重"));
BigDecimal volume = parseDecimal(facts.get("体积"));
BigDecimal volume = parseVolumeCbm(facts.get("体积"));
Date quoteDate = parseDate(facts.get("报价日期"));
return list(new LambdaQueryWrapper<InquiryTicket>()
.eq(InquiryTicket::getBusinessLine, req.getBusinessLine().trim().toUpperCase())
@@ -677,15 +678,24 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
return Integer.valueOf(digits);
}
/**
* 重量等:只取第一个数字(100KGS → 100)。坏串返回 null,禁止抛到建单接口。
*/
private static BigDecimal parseDecimal(String raw) {
if (StringUtils.isBlank(raw)) {
return null;
}
String cleaned = raw.replaceAll("[^0-9.]", "");
if (cleaned.isEmpty()) {
return org.jeecg.modules.inquiry.tms.TmsV2RequestMapper.asPositiveDecimal(raw);
}
/**
* 体积:尺寸式相乘得 CBM;普通「2CBM」取数字。失败返回 null,不炸建单。
*/
private static BigDecimal parseVolumeCbm(String raw) {
if (StringUtils.isBlank(raw)) {
return null;
}
return new BigDecimal(cleaned);
return org.jeecg.modules.inquiry.tms.TmsV2RequestMapper.asVolumeCbm(raw);
}
private static Date parseDate(String raw) {
@@ -2,12 +2,23 @@ package org.jeecg.modules.inquiry.template;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.poi.ss.usermodel.PrintSetup;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.charset.StandardCharsets;
@@ -20,8 +31,10 @@ import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* 管理端报价模板「原表截图」:本机 LibreOffice 把 xlsx 转 PNG(失败则 PDF)。
* 管理端报价模板「原表截图」:本机 LibreOffice 把 xlsx 转 PDF,再用 PDFBox 等比例渲 PNG。
* <p>
* 为何不用 LibreOffice 直接转 PNG:其 pdf→png 滤镜在部分版本会把页内容非等比拉伸,
* 后台预览出现「字被拉高/压扁」。PDFBox 按页 MediaBox 均匀换算 DPI,保证 1:1 像素比例。
* 为何放在主账:后台预览不应依赖智能体进程;与出单 PDF 错开靠文件锁互斥。
* 禁止:接到企微回调 / 智能体出单热路径;禁止本类起第二台 soffice 并行。
* 副作用:写临时目录与预览缓存;短阻塞当前 HTTP 线程(仅管理端预览)。
@@ -30,6 +43,9 @@ import java.util.stream.Stream;
@Component
public class QuoteTemplateSnapshotRenderer {
/** 预览 PNG 渲染 DPI;只影响清晰度,不改变宽高比。 */
private static final float PREVIEW_DPI = 144f;
@Value("${inquiry.quote-template.soffice-path:${LIBREOFFICE_PATH:C:/Program Files/LibreOffice/program/soffice.exe}}")
private String sofficePath;
@@ -73,15 +89,17 @@ public class QuoteTemplateSnapshotRenderer {
try {
Path localXlsx = workDir.resolve("source.xlsx");
Files.copy(xlsxPath, localXlsx, StandardCopyOption.REPLACE_EXISTING);
// 预览专用打印版式:按表内容宽高选横/竖,避免宽表硬塞竖版 A4 显得挤扁
preparePreviewPrintLayout(localXlsx);
Path pdf = convert(soffice, localXlsx, workDir, "pdf:calc_pdf_Export", ".pdf");
if (pdf == null || !Files.isRegularFile(pdf)) {
throw new IllegalStateException("LibreOffice 未生成 PDF");
}
Path png = convert(soffice, pdf, workDir, "png", ".png");
if (png != null && Files.isRegularFile(png) && Files.size(png) > 0) {
return new PreviewBytes(Files.readAllBytes(png), "image/png");
byte[] pngBytes = pdfToPngEqualRatio(pdf);
if (pngBytes != null && pngBytes.length > 0) {
return new PreviewBytes(pngBytes, "image/png");
}
log.warn("PNG 未生成,退回 PDF 预览 versionId={}", versionId);
log.warn("PDFBox 未生成 PNG,退回 PDF 预览 versionId={}", versionId);
return new PreviewBytes(Files.readAllBytes(pdf), "application/pdf");
} finally {
deleteQuietly(workDir);
@@ -94,6 +112,194 @@ public class QuoteTemplateSnapshotRenderer {
}
}
/**
* 预览前改写打印属性:按已用区域宽高比选横向/纵向 A4,并按页适配。
* 只动打印,不改单元格业务内容;写回同一 xlsx 供 soffice 使用。
*/
private void preparePreviewPrintLayout(Path xlsx) throws IOException {
try (InputStream in = Files.newInputStream(xlsx);
Workbook wb = WorkbookFactory.create(in)) {
for (int s = 0; s < wb.getNumberOfSheets(); s++) {
Sheet sheet = wb.getSheetAt(s);
if (sheet == null) {
continue;
}
boolean landscape = preferLandscape(sheet);
sheet.setMargin(Sheet.LeftMargin, 0.4);
sheet.setMargin(Sheet.RightMargin, 0.4);
sheet.setMargin(Sheet.TopMargin, 0.5);
sheet.setMargin(Sheet.BottomMargin, 0.5);
sheet.setHorizontallyCenter(true);
sheet.setVerticallyCenter(false);
sheet.setAutobreaks(true);
sheet.setFitToPage(true);
PrintSetup ps = sheet.getPrintSetup();
ps.setPaperSize(PrintSetup.A4_PAPERSIZE);
ps.setLandscape(landscape);
ps.setFitWidth((short) 1);
// 只按页宽适配:不把矮表纵向撑满整页,减少页底空白(最终仍靠 PNG 裁白边)
ps.setFitHeight((short) 0);
}
try (OutputStream out = Files.newOutputStream(xlsx, StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE)) {
wb.write(out);
}
}
}
/**
* 粗估工作表内容宽高:列宽合计 vs 行高合计,宽更大则预览用横向。
* 单位换算只需相对比较,不要求绝对毫米精度。
*/
private static boolean preferLandscape(Sheet sheet) {
int firstRow = Math.max(sheet.getFirstRowNum(), 0);
int lastRow = sheet.getLastRowNum();
if (lastRow < firstRow) {
return false;
}
int maxCol = 0;
for (int r = firstRow; r <= lastRow && r <= firstRow + 120; r++) {
Row row = sheet.getRow(r);
if (row == null) {
continue;
}
short last = row.getLastCellNum();
if (last > maxCol) {
maxCol = last;
}
}
if (maxCol <= 0) {
return false;
}
double widthPx = 0;
for (int c = 0; c < maxCol; c++) {
// POI 列宽:1/256 字符宽;约 7px/字符(96dpi 经验值)
widthPx += (sheet.getColumnWidth(c) / 256.0) * 7.0;
}
double heightPx = 0;
int rowLimit = Math.min(lastRow, firstRow + 120);
for (int r = firstRow; r <= rowLimit; r++) {
Row row = sheet.getRow(r);
float points = row != null ? row.getHeightInPoints() : sheet.getDefaultRowHeightInPoints();
heightPx += points * (96.0 / 72.0);
}
return widthPx > heightPx * 1.08;
}
/**
* PDF 首页等比例渲 PNG:PDFBox 按页尺寸 × DPI 均匀换算,禁止非等比拉伸。
* 渲完后裁掉近白页边,避免 A4 页底大块空白占预览高度。
*/
private byte[] pdfToPngEqualRatio(Path pdf) throws IOException {
try (PDDocument doc = PDDocument.load(pdf.toFile())) {
if (doc.getNumberOfPages() < 1) {
return null;
}
PDFRenderer renderer = new PDFRenderer(doc);
BufferedImage image = renderer.renderImageWithDPI(0, PREVIEW_DPI, ImageType.RGB);
image = trimNearWhiteMargins(image, 8);
ByteArrayOutputStream buf = new ByteArrayOutputStream();
if (!ImageIO.write(image, "png", buf)) {
throw new IOException("ImageIO 无法写出 PNG");
}
return buf.toByteArray();
}
}
/**
* 裁掉四周近白色边距,四周各留 {@code pad} 像素,避免预览弹窗底下留一大块空白。
* <p>
* 行/列允许极少量杂点(浅网格线);写出前必须拷贝到新 BufferedImage,
* 禁止直接 ImageIO.write(getSubimage())——部分 JDK 会把整页母图写出去,裁切等于没做。
*/
static BufferedImage trimNearWhiteMargins(BufferedImage src, int pad) {
if (src == null) {
return null;
}
int w = src.getWidth();
int h = src.getHeight();
if (w <= 0 || h <= 0) {
return src;
}
int top = 0;
int bottom = h - 1;
int left = 0;
int right = w - 1;
while (top < h && isMostlyWhiteRow(src, top)) {
top++;
}
while (bottom > top && isMostlyWhiteRow(src, bottom)) {
bottom--;
}
while (left < w && isMostlyWhiteCol(src, left, top, bottom)) {
left++;
}
while (right > left && isMostlyWhiteCol(src, right, top, bottom)) {
right--;
}
// 几乎全白:不裁,避免空图
if (top >= bottom || left >= right || (bottom - top + 1) < h / 20) {
return src;
}
int padSafe = Math.max(0, pad);
int x = Math.max(0, left - padSafe);
int y = Math.max(0, top - padSafe);
int x2 = Math.min(w - 1, right + padSafe);
int y2 = Math.min(h - 1, bottom + padSafe);
int cw = x2 - x + 1;
int ch = y2 - y + 1;
if (cw >= w && ch >= h) {
return src;
}
BufferedImage copy = new BufferedImage(cw, ch, BufferedImage.TYPE_INT_RGB);
java.awt.Graphics2D g = copy.createGraphics();
try {
g.drawImage(src, 0, 0, cw, ch, x, y, x + cw, y + ch, null);
} finally {
g.dispose();
}
return copy;
}
/** 一行里非近白像素不超过约 0.5% 则视为空白行(容忍浅网格)。 */
private static boolean isMostlyWhiteRow(BufferedImage img, int y) {
int w = img.getWidth();
int allow = Math.max(2, w / 200);
int dirty = 0;
for (int x = 0; x < w; x++) {
if (!isNearWhitePixel(img.getRGB(x, y))) {
dirty++;
if (dirty > allow) {
return false;
}
}
}
return true;
}
private static boolean isMostlyWhiteCol(BufferedImage img, int x, int y0, int y1) {
int h = y1 - y0 + 1;
int allow = Math.max(2, h / 200);
int dirty = 0;
for (int y = y0; y <= y1; y++) {
if (!isNearWhitePixel(img.getRGB(x, y))) {
dirty++;
if (dirty > allow) {
return false;
}
}
}
return true;
}
private static boolean isNearWhitePixel(int rgb) {
int r = (rgb >> 16) & 0xff;
int g = (rgb >> 8) & 0xff;
int b = rgb & 0xff;
// 略放宽:打印灰底/抗锯齿边也不挡裁切
return r >= 240 && g >= 240 && b >= 240;
}
/**
* 调用 soffice 无界面转换;已在文件锁内,禁止再开并行实例。
*/
@@ -28,7 +28,9 @@ import java.util.Map;
* 锁舱按 /air/cabin/lock:用工单起运地/目的地/报价日期/重量/体积拼正文。
* 没有报价不能锁舱,禁止编造 QT-{工单号}、AIR-OPT-01。
* 只看最新一版:库里 source=TMS(或航线确认 TMS)出站写成 source=SYSTEM;人工报价出站 source=MANUAL。
* 有真实 TMS 编号才带 quoteId。有值才出站,缺航班和费用不拦。
* 有真实 TMS 编号才带 quoteId。有值才出站,缺航班不拦。
* SYSTEM 出站精简(quoteId + 航班等标量,不带 validUntil/feeItems);
* MANUAL 费用名按 TMS 查价回包原样带回,有效期必须带时区 ISO8601。
* 已成交再释放:本地拦住。测服打开 TMS_V2_LOCK_ENABLED;现网验收期保持关闭。
* 禁止:按 userid 伪造报价;单测打真实 TMS。
*/
@@ -71,37 +73,64 @@ public class InquiryTmsCabinService {
return fail("no_quote", "这张单还没有报价,不能锁舱");
}
Map<String, Object> quote = payloadOf(rows.get(0));
boolean manual = isManualQuote(quote);
if (!manual && StringUtils.isBlank(resolveQuoteId(quote, no))) {
// 「AIR-OPT01 TH」被误落成航线报价不算真正人工价 → 仍走仅 TMS(SYSTEM)。
boolean latestManual = isManualQuote(quote) && !isFakeManualLockQuote(quote);
if (!latestManual && StringUtils.isBlank(resolveQuoteId(quote, no))) {
quote = refreshLockQuote(ticket, quote);
}
String quoteId = manual ? realTmsQuoteId(rows, no) : resolveQuoteId(quote, no);
if (!manual && StringUtils.isBlank(quoteId)) {
quoteId = realTmsQuoteId(rows, no);
String tmsQuoteId = realTmsQuoteId(rows, no);
if (!latestManual && StringUtils.isBlank(tmsQuoteId)) {
tmsQuoteId = resolveQuoteId(quote, no);
}
if (!manual && StringUtils.isBlank(quoteId)) {
boolean hasTms = StringUtils.isNotBlank(tmsQuoteId);
// 三种锁舱口径(产品):
// 1) 仅 TMS → SYSTEM + quoteId
// 2) TMS + 人工 → MANUAL + quoteId(引用原 TMS 编号)
// 3) 仅人工 → MANUAL,不传 quoteId
String lockSource;
String quoteId;
if (hasTms && !latestManual) {
lockSource = "SYSTEM";
quoteId = tmsQuoteId;
} else if (hasTms) {
lockSource = "MANUAL";
quoteId = tmsQuoteId;
} else if (latestManual) {
lockSource = "MANUAL";
quoteId = "";
} else {
return fail("no_quote", "这张单还没有报价,不能锁舱");
}
Map<String, Object> option = resolveOption(quote, optionNo);
if (option == null) {
option = new LinkedHashMap<>();
}
if (manual && StringUtils.isBlank(text(option, "optionNo", "option_no"))) {
// 手工价往往只有金额。舱位、航班仍用本单那条 TMS 报价,不另编编号。
Map<String, Object> tmsOption = tmsOptionForLock(rows, no, optionNo);
if (!tmsOption.isEmpty()) {
option = tmsOption;
}
// 手工价常只有金额/错误抽到的 fee_lines(如把 AIR-OPT01 抽成 OPT)。
// 只要本单有真实 TMS 舱位,就用它补航班、费用、有效期,不能只因 stub 已有 optionNo 就跳过。
String wantOption = firstNonBlank(text(option, "optionNo", "option_no"), optionNo);
Map<String, Object> tmsOption = tmsOptionForLock(rows, no, wantOption);
if (!tmsOption.isEmpty()) {
option = mergeLockOption(option, tmsOption);
}
String resolvedOption = text(option, "optionNo", "option_no");
Map<String, Object> tmsQuote = tmsQuotePayload(rows, no);
if (tmsQuote != null && !tmsQuote.isEmpty()) {
quote = enrichLockQuoteFromTms(quote, tmsQuote);
}
// 航线句中已点名的 optionNo 不能因报价数组对不上就被丢掉。
String resolvedOption = firstNonBlank(text(option, "optionNo", "option_no"), optionNo);
if (!properties.lockConfigured()) {
return fail("lock_disabled", "主账未开放锁舱");
}
Map<String, Object> req = new LinkedHashMap<>();
req.put("workOrderNo", no);
req.put("quoteId", quoteId);
if (StringUtils.isNotBlank(quoteId)) {
req.put("quoteId", quoteId);
}
req.put("optionNo", resolvedOption);
req.put("flightDate", firstNonBlank(text(option, "flightDate"), quoteDate(ticket)));
req.put("flightDate", firstNonBlank(
text(option, "flightDate"),
flightDateFromDeparture(text(option, "departureTime")),
quoteDate(ticket)));
req.put("origin", firstNonBlank(text(option, "originAirportCode"),
fact(ticket, "originCode"),
text(option, "origin"), ticket.getOrigin(),
@@ -110,7 +139,11 @@ public class InquiryTmsCabinService {
fact(ticket, "destinationCode"),
text(option, "destination"), ticket.getDestination(),
fact(ticket, "目的地", "目的港")));
req.put("airline", firstNonBlank(text(option, "airlineCode", "airline"),
// 航司:句中两位码(如「AIR-OPT01 TH」)> 舱位/报价字段 > 航班号前两位。
req.put("airline", firstNonBlank(
text(body, "airline", "airlineCode"),
airlineFromInstruction(text(body, "instructionText", "instruction_text")),
text(option, "airlineCode", "airline"),
text(option, "airlineName"),
text(quote, "airlineCode", "airline", "airlineName"),
fact(ticket, "航司", "航司代码"),
@@ -119,19 +152,39 @@ public class InquiryTmsCabinService {
req.put("volumeCbm", ticket.getVolumeCbm());
req.put("pieceQty", ticket.getPieces());
req.put("chargeableWeightKg", firstNonBlank(
text(quote, "chargeableWeightKg", "chargeable_weight_kg"),
text(option, "chargeableWeightKg", "chargeable_weight_kg")));
text(option, "chargeableWeightKg", "chargeable_weight_kg"),
text(quote, "chargeableWeightKg", "chargeable_weight_kg")));
req.put("operatorUserId", text(body, "operatorUserId", "sender_id"));
req.put("operatorName", text(body, "operatorName", "operator_name"));
req.put("instructionText", text(body, "instructionText", "instruction_text"));
if (StringUtils.isNotBlank(text(body, "requestedUntil"))) {
req.put("requestedUntil", text(body, "requestedUntil"));
}
if (manual) {
fillManualLock(req, quote, option);
if ("MANUAL".equals(lockSource)) {
// 有 TMS 时保留 quoteId;纯人工才去掉。
fillManualLock(req, quote, option, body, StringUtils.isNotBlank(quoteId));
} else {
// 库里仍是 source=TMS。TMS 锁舱要求纯系统报价带 source=SYSTEM,只写在出站正文。
// 仅 TMS:source=SYSTEM + quoteId。禁止再塞 validUntil/feeItems(会 1001)。
req.put("source", "SYSTEM");
req.remove("validUntil");
req.remove("requestedUntil");
req.remove("departureCharges");
req.remove("feeItems");
req.remove("currency");
req.remove("totalSalePrice");
String transfer = firstNonBlank(text(option, "transferAirport", "transferAirportCode"),
text(quote, "transferAirport", "transferAirportCode"));
if (StringUtils.isNotBlank(transfer)) {
req.put("transferAirport", transfer);
}
String flightNo = firstNonBlank(
text(option, "flightNo", "flight_no"),
text(quote, "flightNo", "flight_no"),
flightNoFromSelectedAirOption(quote),
flightNoFromAirOptions(quote, resolvedOption));
if (StringUtils.isNotBlank(flightNo)) {
req.put("flightNo", flightNo);
}
if (StringUtils.isNotBlank(text(body, "requestedUntil"))) {
req.put("requestedUntil", text(body, "requestedUntil"));
}
}
return invokeLock(ticket, req, quoteId, resolvedOption);
}
@@ -342,15 +395,22 @@ public class InquiryTmsCabinService {
}
/**
* 人工报价出站:source 只写在 TMS 正文,不回写报价表。
* 费用数组用报价里已有的;没有就不放。不用总价编一条空运费。
* 人工报价出站:对齐 TMS MANUAL 合同。
* <p>
* 必带:source=MANUAL、optionNo、validUntil(ISO)、origin/destination、
* flightDate、airline、flightNo、重量体积件数、计费重、币种、总价、
* departureCharges、feeItems;可选 requestedUntil(缺则用 validUntil)。
* {@code withQuoteId=true}:本单还有 TMS 报价时必须带原 quoteId;
* {@code withQuoteId=false}:纯人工、无 TMS 时不传 quoteId。
*/
static void fillManualLock(Map<String, Object> req, Map<String, Object> quote, Map<String, Object> option) {
static void fillManualLock(Map<String, Object> req, Map<String, Object> quote,
Map<String, Object> option, Map<String, Object> body,
boolean withQuoteId) {
req.put("source", "MANUAL");
String until = firstNonBlank(text(quote, "validUntil", "valid_until"), text(option, "validUntil"));
if (StringUtils.isNotBlank(until) && !"-".equals(until)) {
req.put("validUntil", until);
if (!withQuoteId) {
req.remove("quoteId");
}
fillLockQuoteExtras(req, quote, option);
String transfer = firstNonBlank(text(option, "transferAirport", "transferAirportCode"),
text(quote, "transferAirport", "transferAirportCode"));
if (StringUtils.isNotBlank(transfer)) {
@@ -360,14 +420,46 @@ public class InquiryTmsCabinService {
if (StringUtils.isNotBlank(flightNo)) {
req.put("flightNo", flightNo);
}
String currency = firstNonBlank(text(quote, "currency", "currencyCode"), text(option, "currency"));
String currency = firstNonBlank(text(option, "currency"),
text(quote, "currency", "currencyCode"));
if (StringUtils.isNotBlank(currency)) {
req.put("currency", currency);
}
String total = firstNonBlank(text(quote, "totalSalePrice", "salePrice", "total"), text(option, "salePrice"));
// 舱位售价优先;避免手工误抽的 total=1 盖住 TMS 真价。
String total = firstNonBlank(text(option, "salePrice", "totalSalePrice"),
text(quote, "totalSalePrice", "salePrice", "total"));
if (StringUtils.isNotBlank(total) && !"-".equals(total)) {
req.put("totalSalePrice", total);
}
// requestedUntil:调用方有就用;否则用 validUntil(合同样例两者都有)。
String requested = firstNonBlank(text(body, "requestedUntil"),
text(req, "validUntil"));
String isoReq = normalizeValidUntilIso(requested);
if (StringUtils.isNotBlank(isoReq)) {
req.put("requestedUntil", isoReq);
}
}
static String flightDateFromDeparture(String departureTime) {
if (StringUtils.isBlank(departureTime) || departureTime.length() < 10) {
return "";
}
return departureTime.substring(0, 10);
}
/**
* 锁舱共用:有效期转 ISO8601,费用名按 TMS 查价回包原样带回。
* <p>
* 查价落库常是 {@code valid_until=2026-09-30}(仅日期);锁舱要带时区的 ISO,
* 否则 TMS 回 1001「参数格式错误」。费用优先 {@code airOptions[].departureCharges} /
* {@code feeItems} 原文,禁止用本地改写过的展示名替换。
*/
static void fillLockQuoteExtras(Map<String, Object> req, Map<String, Object> quote, Map<String, Object> option) {
String until = firstNonBlank(text(quote, "validUntil", "valid_until"), text(option, "validUntil"));
String isoUntil = normalizeValidUntilIso(until);
if (StringUtils.isNotBlank(isoUntil)) {
req.put("validUntil", isoUntil);
}
List<Map<String, Object>> departure = feeRows(quote, option, "departureCharges", "departure_charges");
List<Map<String, Object>> fees = feeRows(quote, option, "feeItems", "fee_items");
splitFeeRows(quote, departure, fees);
@@ -379,6 +471,57 @@ public class InquiryTmsCabinService {
}
}
/**
* 锁舱 validUntil / requestedUntil:规范成带时区的 ISO8601。
* <ul>
* <li>{@code 2026-09-30} → {@code 2026-09-30T23:59:59+08:00}(有效期到当日结束)</li>
* <li>已有 {@code T} 但无时区 → 补 {@code +08:00}</li>
* <li>已是完整 ISO → 原样</li>
* </ul>
*/
static String normalizeValidUntilIso(String raw) {
if (StringUtils.isBlank(raw) || "-".equals(raw.trim())) {
return "";
}
String text = raw.trim();
// 已是完整带时区的 ISO
if (text.matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}([.,]\\d+)?(Z|[+-]\\d{2}:?\\d{2})")) {
return text;
}
// 有 T 无时区
if (text.matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}(:\\d{2})?([.,]\\d+)?")) {
if (text.length() == 16) {
// yyyy-MM-ddTHH:mm
return text + ":00+08:00";
}
return text + "+08:00";
}
// 仅日期:yyyy-MM-dd 或 yyyy/MM/dd
java.util.regex.Matcher m = java.util.regex.Pattern
.compile("^(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})$")
.matcher(text);
if (m.matches()) {
int month = Integer.parseInt(m.group(2));
int day = Integer.parseInt(m.group(3));
return String.format("%s-%02d-%02dT23:59:59+08:00", m.group(1), month, day);
}
// 空格分隔的本地时间:yyyy-MM-dd HH:mm[:ss]
java.util.regex.Matcher local = java.util.regex.Pattern
.compile("^(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})[ T](\\d{1,2}):(\\d{2})(?::(\\d{2}))?$")
.matcher(text);
if (local.matches()) {
String sec = local.group(6) == null ? "00" : local.group(6);
return String.format("%s-%02d-%02dT%02d:%02d:%s+08:00",
local.group(1),
Integer.parseInt(local.group(2)),
Integer.parseInt(local.group(3)),
Integer.parseInt(local.group(4)),
Integer.parseInt(local.group(5)),
sec.length() == 1 ? "0" + sec : sec);
}
return "";
}
/**
* 最新一版是不是人工报价。航线只确认沿用 TMS 不算。
* 认 payload 里的 source / source_label,因为落库时确认 TMS 也会被写成 PRODUCT。
@@ -400,10 +543,26 @@ public class InquiryTmsCabinService {
}
/**
* 本单更早报价里的真实 TMS 编号。自编 QT-工单号不算。
* 误把锁舱口令落成「航线报价」:raw_text 像 AIR-OPT / 锁舱,或费用名只有 OPT 碎片。
* 这种不算「有人工报价」,锁舱应按仅 TMS(SYSTEM)。
*/
static boolean isFakeManualLockQuote(Map<String, Object> quote) {
if (quote == null || quote.isEmpty()) {
return false;
}
if (looksLikeOptionTokenFees(quote)) {
return true;
}
String raw = text(quote, "raw_text", "rawText", "instructionText").toUpperCase();
if (raw.contains("AIR-OPT") || raw.contains("AIROPT") || raw.contains("锁舱")) {
return true;
}
return false;
}
/**
* 手工价没有舱位时,用本单 TMS 报价上的那一条。多条且没指定就不猜。
* optionNo 大小写、横杠差异按忽略大小写匹配。
*/
static Map<String, Object> tmsOptionForLock(List<InquiryQuote> rows, String workOrderNo, String optionNo) {
if (rows == null) {
@@ -423,7 +582,7 @@ public class InquiryTmsCabinService {
}
if (StringUtils.isNotBlank(optionNo)) {
for (Map<String, Object> item : options) {
if (optionNo.equals(text(item, "optionNo", "option_no"))) {
if (optionNo.equalsIgnoreCase(text(item, "optionNo", "option_no"))) {
return item;
}
}
@@ -437,6 +596,101 @@ public class InquiryTmsCabinService {
return new LinkedHashMap<>();
}
/**
* 本单最新一版非手工、带真实 quoteId 的 TMS 报价正文。
*/
static Map<String, Object> tmsQuotePayload(List<InquiryQuote> rows, String workOrderNo) {
if (rows == null) {
return new LinkedHashMap<>();
}
for (InquiryQuote row : rows) {
Map<String, Object> payload = payloadOf(row);
if (isManualQuote(payload)) {
continue;
}
if (StringUtils.isNotBlank(resolveQuoteId(payload, workOrderNo))) {
return payload;
}
}
return new LinkedHashMap<>();
}
/**
* 锁舱舱位:TMS 明细打底,航线点名的 optionNo 盖上去。
*/
static Map<String, Object> mergeLockOption(Map<String, Object> stub, Map<String, Object> tmsOption) {
Map<String, Object> out = new LinkedHashMap<>();
if (tmsOption != null) {
out.putAll(tmsOption);
}
String no = text(stub, "optionNo", "option_no");
if (StringUtils.isNotBlank(no)) {
out.put("optionNo", no);
}
return out;
}
/**
* 手工价缺有效期/计费重时,从本单 TMS 报价补;不覆盖手工已有金额。
* 费用明细优先走舱位上的 departureCharges(merge 后),这里只补标量。
*/
static Map<String, Object> enrichLockQuoteFromTms(Map<String, Object> quote, Map<String, Object> tmsQuote) {
Map<String, Object> out = new LinkedHashMap<>();
if (quote != null) {
out.putAll(quote);
}
if (tmsQuote == null || tmsQuote.isEmpty()) {
return out;
}
if (StringUtils.isBlank(text(out, "validUntil", "valid_until"))
|| "-".equals(text(out, "validUntil", "valid_until"))) {
String until = firstNonBlank(text(tmsQuote, "validUntil", "valid_until"));
if (StringUtils.isNotBlank(until) && !"-".equals(until)) {
out.put("validUntil", until);
out.put("valid_until", until);
}
}
if (StringUtils.isBlank(text(out, "chargeableWeightKg", "chargeable_weight_kg"))) {
String cw = firstNonBlank(text(tmsQuote, "chargeableWeightKg", "chargeable_weight_kg"));
if (StringUtils.isNotBlank(cw)) {
out.put("chargeableWeightKg", cw);
}
}
// 手工 fee_lines 若像「OPT」这种从舱位编号误抽的,清掉,避免盖住 TMS departureCharges。
if (looksLikeOptionTokenFees(out)) {
out.remove("fee_lines");
out.remove("fee_rows");
out.remove("feeItems");
}
// 补 TMS 顶层 feeItems(空运费等),手工价常常没有。
if (out.get("feeItems") == null && out.get("fee_items") == null
&& (tmsQuote.get("feeItems") != null || tmsQuote.get("fee_items") != null)) {
Object fees = tmsQuote.get("feeItems");
if (fees == null) {
fees = tmsQuote.get("fee_items");
}
out.put("feeItems", fees);
}
return out;
}
/**
* fee_lines 只有「OPT / AIR-OPT…」这类舱位编号碎片时,不是真费用。
*/
static boolean looksLikeOptionTokenFees(Map<String, Object> quote) {
List<Map<String, Object>> rows = feeRows(quote, null, "fee_lines", "fee_rows", "feeItems");
if (rows.isEmpty()) {
return false;
}
for (Map<String, Object> row : rows) {
String name = feeName(row).toUpperCase().replace("-", "");
if (!(name.equals("OPT") || name.startsWith("AIROPT") || name.matches("OPT\\d+"))) {
return false;
}
}
return true;
}
static String realTmsQuoteId(List<InquiryQuote> rows, String workOrderNo) {
if (rows == null) {
return "";
@@ -546,8 +800,10 @@ public class InquiryTmsCabinService {
}
/**
* 费用行出站改成 TMS 字段:feeName/chargeName、unitPrice、数量和金额用数字。
* 库里的 name、unit_price 不原样送出,避免 TMS 报参数格式错误。
* 费用行出站:对齐 MANUAL 合同字段。
* departureCharges:chargeName/unitPrice/quantity/amount/unit/currency;
* feeItems:feeCode/feeName/…/segmentSequence/remark。
* 起运港费缺 quantity 时默认 1(合同样例)。
*/
static List<Map<String, Object>> toLockFees(List<Map<String, Object>> rows, boolean departure) {
List<Map<String, Object>> out = new ArrayList<>();
@@ -559,7 +815,9 @@ public class InquiryTmsCabinService {
continue;
}
Map<String, Object> item = new LinkedHashMap<>();
String name = feeName(row);
String name = departure
? firstNonBlank(text(row, "chargeName"), text(row, "feeName"), text(row, "name", "label"))
: firstNonBlank(text(row, "feeName"), text(row, "chargeName"), text(row, "name", "label"));
if (StringUtils.isNotBlank(name)) {
item.put(departure ? "chargeName" : "feeName", name);
}
@@ -568,8 +826,19 @@ public class InquiryTmsCabinService {
item.put("feeCode", code);
}
putFeeNumber(item, "unitPrice", text(row, "unitPrice", "unit_price"));
putFeeNumber(item, "quantity", text(row, "quantity"));
String qtyRaw = text(row, "quantity");
if (StringUtils.isBlank(qtyRaw) && departure) {
qtyRaw = "1";
}
putFeeNumber(item, "quantity", qtyRaw);
putFeeNumber(item, "amount", text(row, "amount"));
// 有单价无金额时:金额 = 单价 × 数量
if (!item.containsKey("amount") && item.get("unitPrice") instanceof java.math.BigDecimal
&& item.get("quantity") instanceof java.math.BigDecimal) {
item.put("amount", plainFeeDecimal(
((java.math.BigDecimal) item.get("unitPrice"))
.multiply((java.math.BigDecimal) item.get("quantity"))));
}
String currency = text(row, "currency");
if (StringUtils.isNotBlank(currency)) {
item.put("currency", currency);
@@ -578,6 +847,34 @@ public class InquiryTmsCabinService {
if (StringUtils.isNotBlank(unit)) {
item.put("unit", unit);
}
if (!departure) {
// 合同样例:feeCode 在 feeName 前;上面已先写 feeName,这里保持有值即可。
String seg = text(row, "segmentSequence", "segment_sequence");
if (StringUtils.isNotBlank(seg)) {
try {
item.put("segmentSequence", Integer.parseInt(seg.trim()));
} catch (NumberFormatException ignore) {
// 非整数就不送
}
}
String remark = text(row, "remark", "备注");
if (StringUtils.isNotBlank(remark)) {
item.put("remark", remark);
}
}
// 合同样例 feeItems:feeCode 在 feeName 前
if (!departure && item.containsKey("feeCode") && item.containsKey("feeName")) {
Map<String, Object> ordered = new LinkedHashMap<>();
ordered.put("feeCode", item.get("feeCode"));
ordered.put("feeName", item.get("feeName"));
for (Map.Entry<String, Object> e : item.entrySet()) {
if ("feeCode".equals(e.getKey()) || "feeName".equals(e.getKey())) {
continue;
}
ordered.put(e.getKey(), e.getValue());
}
item = ordered;
}
if (!item.isEmpty()) {
out.add(item);
}
@@ -585,6 +882,18 @@ public class InquiryTmsCabinService {
return out;
}
/**
* 费用数字:去掉尾零,但避免 100.000 → 1E+2(TMS 当参数格式错误)。
* 与重量字段不同:费用合同样例是 11 / 1333.6,整数千不要写成 11.0。
*/
static java.math.BigDecimal plainFeeDecimal(java.math.BigDecimal value) {
java.math.BigDecimal stripped = value.stripTrailingZeros();
if (stripped.scale() < 0) {
return stripped.setScale(0);
}
return stripped;
}
private static void putFeeNumber(Map<String, Object> item, String key, String raw) {
if (StringUtils.isBlank(raw) || "-".equals(raw)) {
return;
@@ -595,7 +904,7 @@ public class InquiryTmsCabinService {
text = text.substring(space + 1).trim();
}
try {
item.put(key, new java.math.BigDecimal(text));
item.put(key, plainFeeDecimal(new java.math.BigDecimal(text)));
} catch (NumberFormatException ignore) {
// 不是数字就不送,避免把「99.00元」这类原文塞进 TMS 数字栏。
}
@@ -630,8 +939,69 @@ public class InquiryTmsCabinService {
return "";
}
/**
* 从报价 airOptions 里按 optionNo 取航班号;对不上则取唯一有值的那条。
* SYSTEM 锁舱缺 flightNo 时补用,避免只带了航司码却没航班号。
*/
static String flightNoFromAirOptions(Map<String, Object> quote, String optionNo) {
List<Map<String, Object>> options = airOptions(quote);
if (options.isEmpty()) {
return "";
}
String want = StringUtils.trimToEmpty(optionNo);
String lone = "";
int withFlight = 0;
for (Map<String, Object> row : options) {
String no = firstNonBlank(text(row, "optionNo", "option_no"));
String fn = firstNonBlank(text(row, "flightNo", "flight_no"));
if (StringUtils.isBlank(fn)) {
continue;
}
if (StringUtils.isNotBlank(want) && want.equalsIgnoreCase(no)) {
return fn;
}
withFlight++;
lone = fn;
}
if (withFlight == 1) {
return lone;
}
return "";
}
/**
* 从 selected_air_option(常为 JSON 字符串)取航班号。
*/
@SuppressWarnings("unchecked")
static String flightNoFromSelectedAirOption(Map<String, Object> quote) {
if (quote == null) {
return "";
}
Object raw = quote.get("selected_air_option");
if (raw == null) {
raw = quote.get("selectedAirOption");
}
if (raw instanceof String) {
String s = String.valueOf(raw).trim();
if (s.startsWith("{")) {
try {
raw = JSON.parseObject(s);
} catch (Exception ignore) {
return "";
}
} else {
return "";
}
}
if (raw instanceof Map) {
return firstNonBlank(text((Map<String, Object>) raw, "flightNo", "flight_no"));
}
return "";
}
/**
* 有舱位按名称挑。没有舱位时,航线已指定的编号原样带上,不编 AIR-OPT-01。
* 报价数组对不上时仍保留航线说的 optionNo,避免出站空编号。
*/
static Map<String, Object> resolveOption(Map<String, Object> quote, String optionNo) {
List<Map<String, Object>> options = airOptions(quote);
@@ -643,6 +1013,11 @@ public class InquiryTmsCabinService {
if (options.size() == 1 && StringUtils.isBlank(optionNo)) {
return options.get(0);
}
if (StringUtils.isNotBlank(optionNo)) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("optionNo", optionNo.trim());
return row;
}
return new LinkedHashMap<>();
}
if (StringUtils.isBlank(optionNo)) {
@@ -653,6 +1028,22 @@ public class InquiryTmsCabinService {
return row;
}
/**
* 从锁舱口令抽出两位航司码,例如「锁AIR-OPT01 TH」→ TH。
*/
static String airlineFromInstruction(String instruction) {
if (StringUtils.isBlank(instruction)) {
return "";
}
java.util.regex.Matcher m = java.util.regex.Pattern
.compile("(?i)AIR-?OPT-?\\d+\\s*([A-Za-z]{2})(?![A-Za-z0-9])")
.matcher(instruction);
if (m.find()) {
return m.group(1).toUpperCase();
}
return "";
}
@SuppressWarnings("unchecked")
static Map<String, Object> pickOption(Map<String, Object> quote, String optionNo) {
List<Map<String, Object>> options = airOptions(quote);
@@ -109,7 +109,7 @@ public class InquiryTmsQueryService {
segment.put("destination", "");
segment.put("quantity", TmsV2RequestMapper.asInteger(facts.get("件数")));
segment.put("weightKg", TmsV2RequestMapper.asPositiveDecimal(facts.get("毛重")));
segment.put("volumeCbm", TmsV2RequestMapper.asPositiveDecimal(facts.get("体积")));
segment.put("volumeCbm", TmsV2RequestMapper.asVolumeCbm(facts.get("体积")));
fillAirportCode(segment, "originCode", text(first(facts.get("originCode"), origin)));
fillAirportCode(segment, "destinationCode", text(first(facts.get("destinationCode"), destination)));
// 包装优先销售原文,避免旧工单 packageType=2 盖住「托盘」。
@@ -13,9 +13,11 @@ import java.util.UUID;
* <p>
* 锁舱唯一键:workOrderNo + quoteId + optionNo。flightDate 用工单报价日期,接口没有单独 quoteDate。
* 操作人角色固定 AIRLINE。数字字段保持 Number,禁止带 KGS/CBM。
* 只有 TMS 报价:沿用原字段,不带 source、不带费用数组。
* 人工报价:调用方放入 source=MANUAL 和已有的费用;空字符串、空数组不写出。
* 不在这里编造 quoteId / optionNo。
* 系统报价:source=SYSTEM + quoteId。
* 人工+TMS:source=MANUAL + 原 quoteId。
* 纯人工:source=MANUAL,不传 quoteId;validUntil/requestedUntil 用 ISO8601;
* 必带 transferAirport/flightNo/departureCharges/feeItems(有值才写)。
* 不在这里编造 optionNo。
*/
public final class TmsCabinRequestMapper {
@@ -23,34 +25,39 @@ public final class TmsCabinRequestMapper {
}
/**
* 锁舱正文。字段顺序固定,签名按这份 JSON 原文计算。
* 锁舱正文。字段顺序按 TMS MANUAL/SYSTEM 样例排,签名按这份 JSON 原文计算。
*/
public static Map<String, Object> lock(Map<String, Object> internal) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("requestId", firstText(internal.get("requestId"), UUID.randomUUID().toString()));
put(body, "workOrderNo", internal.get("workOrderNo"));
put(body, "quoteId", internal.get("quoteId"));
put(body, "source", internal.get("source"));
put(body, "workOrderNo", internal.get("workOrderNo"));
put(body, "optionNo", internal.get("optionNo"));
Object until = internal.get("requestedUntil");
if (until != null && StringUtils.isNotBlank(String.valueOf(until))) {
body.put("requestedUntil", String.valueOf(until).trim());
Object requestedUntil = internal.get("requestedUntil");
if (requestedUntil != null && StringUtils.isNotBlank(String.valueOf(requestedUntil))) {
String iso = InquiryTmsCabinService.normalizeValidUntilIso(String.valueOf(requestedUntil).trim());
body.put("requestedUntil", StringUtils.isNotBlank(iso) ? iso : String.valueOf(requestedUntil).trim());
}
Object validUntil = internal.get("validUntil");
if (validUntil != null && StringUtils.isNotBlank(String.valueOf(validUntil))) {
String iso = InquiryTmsCabinService.normalizeValidUntilIso(String.valueOf(validUntil).trim());
put(body, "validUntil", StringUtils.isNotBlank(iso) ? iso : validUntil);
}
put(body, "origin", first(internal, "origin", "originCode"));
put(body, "transferAirport", internal.get("transferAirport"));
put(body, "destination", first(internal, "destination", "destinationCode"));
put(body, "flightDate", TmsV2RequestMapper.normalizeQuoteDate(internal.get("flightDate")));
put(body, "airline", first(internal, "airline", "airlineCode"));
put(body, "flightNo", internal.get("flightNo"));
putNumber(body, "weightKg", TmsV2RequestMapper.asPositiveDecimal(internal.get("weightKg")));
putNumber(body, "volumeCbm", TmsV2RequestMapper.asPositiveDecimal(internal.get("volumeCbm")));
putNumber(body, "volumeCbm", TmsV2RequestMapper.asVolumeCbm(internal.get("volumeCbm")));
putNumber(body, "pieceQty", TmsV2RequestMapper.asInteger(internal.get("pieceQty")));
putNumber(body, "chargeableWeightKg", TmsV2RequestMapper.asPositiveDecimal(internal.get("chargeableWeightKg")));
put(body, "operatorUserId", internal.get("operatorUserId"));
put(body, "operatorName", internal.get("operatorName"));
body.put("operatorRole", "AIRLINE");
put(body, "instructionText", internal.get("instructionText"));
put(body, "source", internal.get("source"));
put(body, "validUntil", internal.get("validUntil"));
put(body, "transferAirport", internal.get("transferAirport"));
put(body, "flightNo", internal.get("flightNo"));
put(body, "currency", internal.get("currency"));
putNumber(body, "totalSalePrice", asDecimal(internal.get("totalSalePrice")));
putList(body, "departureCharges", internal.get("departureCharges"));
@@ -52,7 +52,7 @@ public final class TmsLandSegmentMapper {
put(seg, "destination", text(source.get("目的港")));
put(seg, "cargoName", text(source.get("品名")));
if (LCL.contains(type)) {
put(seg, "volumeCbm", TmsV2RequestMapper.asPositiveDecimal(source.get("体积")));
put(seg, "volumeCbm", TmsV2RequestMapper.asVolumeCbm(source.get("体积")));
put(seg, "weightKg", TmsV2RequestMapper.asPositiveDecimal(source.get("毛重")));
} else if ("中港整车".equals(type)) {
// 中港整车:询价不要车型/数量,出站也不带
@@ -24,6 +24,13 @@ public final class TmsV2RequestMapper {
/** 从「100KGS」「2.5CBM」「10件」里取出第一个数字。 */
private static final Pattern FIRST_NUMBER = Pattern.compile("(\\d+(?:\\.\\d+)?)");
/**
* 销售常写尺寸当体积:1.1*1.2*3 / 1.1x1.2x3 / 1.1×1.2×3(米)。
* 必须先相乘得 CBM;禁止剥掉 * 后变成 1.11.23 再 new BigDecimal(建单会炸)。
*/
private static final Pattern DIM_PRODUCT = Pattern.compile(
"(\\d+(?:\\.\\d+)?)\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?)(?:\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?))?");
/** 文档 2.1:后缀 Date 的字段格式 yyyy-MM-dd;也容忍斜杠或后面跟时间。 */
private static final Pattern ISO_DATE = Pattern.compile("(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})");
@@ -88,7 +95,7 @@ public final class TmsV2RequestMapper {
// 5.2:quantity=int,weightKg/volumeCbm=decimal>0,禁止带单位字符串
put(target, "quantity", asInteger(first(source, "quantity", "pieces")));
put(target, "weightKg", asPositiveDecimal(first(source, "weightKg")));
put(target, "volumeCbm", asPositiveDecimal(first(source, "volumeCbm")));
put(target, "volumeCbm", asVolumeCbm(first(source, "volumeCbm")));
put(target, "packageType", first(source, "packageType", "packagingType"));
// 空运选填:有值才带。贸易条款不进出站,只给后台模板关键词匹配。
applyAirOptionalFields(source, target);
@@ -199,7 +206,8 @@ public final class TmsV2RequestMapper {
}
/**
* 毛重/体积:JSON 小数且 &gt;0。销售原文「100KGS」「2CBM」只取数字。
* 毛重等:JSON 小数且 &gt;0。销售原文「100KGS」「2CBM」只取第一个数字。
* 体积请用 {@link #asVolumeCbm},避免把尺寸式写成错误小数。
*/
public static BigDecimal asPositiveDecimal(Object raw) {
if (raw instanceof Number) {
@@ -213,6 +221,39 @@ public final class TmsV2RequestMapper {
return number;
}
/**
* 体积 CBM:尺寸式(长*宽*高)先相乘;否则与 {@link #asPositiveDecimal} 相同。
* 例:1.1*1.2*3 → 3.96;2CBM → 2。
*/
public static BigDecimal asVolumeCbm(Object raw) {
if (raw instanceof Number) {
return asPositiveDecimal(raw);
}
if (raw == null) {
return null;
}
String text = String.valueOf(raw).trim();
if (text.isEmpty()) {
return null;
}
Matcher dims = DIM_PRODUCT.matcher(text);
if (dims.find()) {
try {
BigDecimal a = new BigDecimal(dims.group(1));
BigDecimal b = new BigDecimal(dims.group(2));
BigDecimal c = dims.group(3) == null ? BigDecimal.ONE : new BigDecimal(dims.group(3));
BigDecimal product = a.multiply(b).multiply(c);
if (product.compareTo(BigDecimal.ZERO) <= 0) {
return null;
}
return product.stripTrailingZeros();
} catch (NumberFormatException ex) {
return null;
}
}
return asPositiveDecimal(raw);
}
private static BigDecimal firstDecimal(Object raw) {
if (raw == null) {
return null;
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="Radio" firstButton="1" val="0"/>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="Radio" firstButton="1" val="0"/>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="Radio" checked="Checked" val="0"/>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="Radio" checked="Checked" val="0"/>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><xdr:twoCellAnchor><xdr:from><xdr:col>1</xdr:col><xdr:colOff>43180</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>19685</xdr:rowOff></xdr:from><xdr:to><xdr:col>4</xdr:col><xdr:colOff>340995</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>431165</xdr:rowOff></xdr:to><xdr:grpSp><xdr:nvGrpSpPr><xdr:cNvPr id="2" name="组合 1"/><xdr:cNvGrpSpPr/></xdr:nvGrpSpPr><xdr:grpSpPr><a:xfrm><a:off x="3018155" y="503555"/><a:ext cx="2124710" cy="342265"/><a:chOff x="4431" y="1131"/><a:chExt cx="3169" cy="649"/></a:xfrm></xdr:grpSpPr><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice Requires="a14" xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main"><xdr:sp><xdr:nvSpPr><xdr:cNvPr id="2049" name="Option Button 1" hidden="1"><a:extLst><a:ext uri="{63B3BB69-23CF-44E3-9099-C40C66FF867C}"><a14:compatExt spid="_x0000_s2049"/></a:ext></a:extLst></xdr:cNvPr><xdr:cNvSpPr/></xdr:nvSpPr><xdr:spPr><a:xfrm><a:off x="4434" y="1131"/><a:ext cx="3166" cy="360"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr><xdr:txBody><a:bodyPr vertOverflow="clip" wrap="square" lIns="27432" tIns="18288" rIns="0" bIns="18288" anchor="ctr" upright="1"/><a:lstStyle/><a:p><a:pPr algn="l" rtl="0"><a:defRPr sz="1000"/></a:pPr><a:r><a:rPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:rPr><a:t> Port to Port, 1工作天回复</a:t></a:r><a:endParaRPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:endParaRPr></a:p></xdr:txBody></xdr:sp></mc:Choice><mc:Fallback/></mc:AlternateContent><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice Requires="a14" xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main"><xdr:sp><xdr:nvSpPr><xdr:cNvPr id="2050" name="Option Button 2" hidden="1"><a:extLst><a:ext uri="{63B3BB69-23CF-44E3-9099-C40C66FF867C}"><a14:compatExt spid="_x0000_s2050"/></a:ext></a:extLst></xdr:cNvPr><xdr:cNvSpPr/></xdr:nvSpPr><xdr:spPr><a:xfrm><a:off x="4431" y="1421"/><a:ext cx="2987" cy="359"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr><xdr:txBody><a:bodyPr vertOverflow="clip" wrap="square" lIns="27432" tIns="18288" rIns="0" bIns="18288" anchor="ctr" upright="1"/><a:lstStyle/><a:p><a:pPr algn="l" rtl="0"><a:defRPr sz="1000"/></a:pPr><a:r><a:rPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:rPr><a:t> 其它, 3工作天回复</a:t></a:r><a:endParaRPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:endParaRPr></a:p></xdr:txBody></xdr:sp></mc:Choice><mc:Fallback/></mc:AlternateContent></xdr:grpSp><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><xdr:twoCellAnchor><xdr:from><xdr:col>1</xdr:col><xdr:colOff>43180</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>19685</xdr:rowOff></xdr:from><xdr:to><xdr:col>4</xdr:col><xdr:colOff>340995</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>431165</xdr:rowOff></xdr:to><xdr:grpSp><xdr:nvGrpSpPr><xdr:cNvPr id="2" name="组合 1"/><xdr:cNvGrpSpPr/></xdr:nvGrpSpPr><xdr:grpSpPr><a:xfrm><a:off x="3018155" y="503555"/><a:ext cx="2124710" cy="342265"/><a:chOff x="4431" y="1131"/><a:chExt cx="3169" cy="649"/></a:xfrm></xdr:grpSpPr><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main" Requires="a14"><xdr:sp><xdr:nvSpPr><xdr:cNvPr id="2049" name="Option Button 1" hidden="1"><a:extLst><a:ext uri="{63B3BB69-23CF-44E3-9099-C40C66FF867C}"><a14:compatExt spid="_x0000_s2049"/></a:ext></a:extLst></xdr:cNvPr><xdr:cNvSpPr/></xdr:nvSpPr><xdr:spPr><a:xfrm><a:off x="4434" y="1131"/><a:ext cx="3166" cy="360"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr><xdr:txBody><a:bodyPr vertOverflow="clip" wrap="square" lIns="27432" tIns="18288" rIns="0" bIns="18288" anchor="ctr" upright="1"/><a:lstStyle/><a:p><a:pPr algn="l" rtl="0"><a:defRPr sz="1000"/></a:pPr><a:r><a:rPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:rPr><a:t> Port to Port, 1工作天回复</a:t></a:r><a:endParaRPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:endParaRPr></a:p></xdr:txBody></xdr:sp></mc:Choice><mc:Fallback/></mc:AlternateContent><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main" Requires="a14"><xdr:sp><xdr:nvSpPr><xdr:cNvPr id="2050" name="Option Button 2" hidden="1"><a:extLst><a:ext uri="{63B3BB69-23CF-44E3-9099-C40C66FF867C}"><a14:compatExt spid="_x0000_s2050"/></a:ext></a:extLst></xdr:cNvPr><xdr:cNvSpPr/></xdr:nvSpPr><xdr:spPr><a:xfrm><a:off x="4431" y="1421"/><a:ext cx="2987" cy="359"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr><xdr:txBody><a:bodyPr vertOverflow="clip" wrap="square" lIns="27432" tIns="18288" rIns="0" bIns="18288" anchor="ctr" upright="1"/><a:lstStyle/><a:p><a:pPr algn="l" rtl="0"><a:defRPr sz="1000"/></a:pPr><a:r><a:rPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:rPr><a:t> 其它, 3工作天回复</a:t></a:r><a:endParaRPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:endParaRPr></a:p></xdr:txBody></xdr:sp></mc:Choice><mc:Fallback/></mc:AlternateContent></xdr:grpSp><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<xml xmlns:oa="urn:schemas-microsoft-com:office:activation" xmlns:p="urn:schemas-microsoft-com:office:powerpoint" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml"><o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="2"/></o:shapelayout><v:shapetype id="_x0000_t201" coordsize="21600,21600" o:spt="201" path="m,l,21600r21600,l21600,xe"
>
<v:stroke joinstyle="miter"/>
<v:path shadowok="f" o:extrusionok="f" strokeok="f" fillok="f" o:connecttype="rect"/>
<o:lock v:ext="edit" shapetype="t"/>
</v:shapetype>
<v:shape id="_x0000_s2049" o:spt="201" type="#_x0000_t201" style="position:absolute;left:0pt;top:0pt;margin-left:206.9pt;margin-top:39.65pt;height:14.65pt;width:107.95pt;" filled="f" stroked="f" o:insetmode="auto" coordsize="21600,21600"><v:path fillok="t" strokeok="t" shadowok="t"/><v:fill on="f" focussize="0,0"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock v:ext="edit" rotation="t" aspectratio="f"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font face="宋体" size="180" color="#000000"><span style="mso-spacerun:yes"> Port to Port, 1工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,2,4,44,2,26</x:Anchor><x:FirstButton>1</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape><v:shape id="_x0000_s2050" o:spt="201" type="#_x0000_t201" style="position:absolute;left:0pt;top:0pt;margin-left:206.8pt;margin-top:51.45pt;height:14.65pt;width:101.8pt;" filled="f" stroked="f" o:insetmode="auto" coordsize="21600,21600"><v:path fillok="t" strokeok="t" shadowok="t"/><v:fill on="f" focussize="0,0"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock v:ext="edit" rotation="t" aspectratio="f"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font face="宋体" size="180" color="#000000"><span style="mso-spacerun:yes"> 其它, 3工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,22,4,34,3,0</x:Anchor><x:Checked>1</x:Checked><x:FirstButton>0</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape></xml>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<xml xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:x="urn:schemas-microsoft-com:office:excel"><o:shapelayout v:ext="edit"><o:idmap data="2" v:ext="edit"/></o:shapelayout><v:shapetype coordsize="21600,21600" id="_x0000_t201" o:spt="201" path="m,l,21600r21600,l21600,xe">
<v:stroke joinstyle="miter"/>
<v:path fillok="f" o:connecttype="rect" o:extrusionok="f" shadowok="f" strokeok="f"/>
<o:lock shapetype="t" v:ext="edit"/>
</v:shapetype><v:shape coordsize="21600,21600" filled="f" id="_x0000_s2049" o:insetmode="auto" o:spt="201" stroked="f" style="position:absolute;left:0pt;top:0pt;margin-left:237.8pt;margin-top:39.65pt;height:14.9pt;width:167.15pt;" type="#_x0000_t201"><v:path fillok="t" shadowok="t" strokeok="t"/><v:fill focussize="0,0" on="f"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock aspectratio="f" rotation="t" v:ext="edit"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font color="#000000" face="宋体" size="180"><span style="mso-spacerun:yes"> Port to Port, 1工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Checked>1</x:Checked><x:Anchor>1,5,2,2,4,44,2,27</x:Anchor><x:FirstButton>1</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape><v:shape coordsize="21600,21600" filled="f" id="_x0000_s2050" o:insetmode="auto" o:spt="201" stroked="f" style="position:absolute;left:0pt;top:0pt;margin-left:237.65pt;margin-top:51.65pt;height:14.95pt;width:157.65pt;" type="#_x0000_t201"><v:path fillok="t" shadowok="t" strokeok="t"/><v:fill focussize="0,0" on="f"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock aspectratio="f" rotation="t" v:ext="edit"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font color="#000000" face="宋体" size="180"><span style="mso-spacerun:yes"> 其它, 3工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,22,4,28,3,0</x:Anchor><x:FirstButton>0</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape></xml>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<xml xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:x="urn:schemas-microsoft-com:office:excel"><o:shapelayout v:ext="edit"><o:idmap data="2" v:ext="edit"/></o:shapelayout><v:shapetype coordsize="21600,21600" id="_x0000_t201" o:spt="201" path="m,l,21600r21600,l21600,xe">
<v:stroke joinstyle="miter"/>
<v:path fillok="f" o:connecttype="rect" o:extrusionok="f" shadowok="f" strokeok="f"/>
<o:lock shapetype="t" v:ext="edit"/>
</v:shapetype><v:shape coordsize="21600,21600" filled="f" id="_x0000_s2049" o:insetmode="auto" o:spt="201" stroked="f" style="position:absolute;left:0pt;top:0pt;margin-left:237.8pt;margin-top:39.65pt;height:14.9pt;width:167.15pt;" type="#_x0000_t201"><v:path fillok="t" shadowok="t" strokeok="t"/><v:fill focussize="0,0" on="f"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock aspectratio="f" rotation="t" v:ext="edit"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font color="#000000" face="宋体" size="180"><span style="mso-spacerun:yes"> Port to Port, 1工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,2,4,44,2,27</x:Anchor><x:FirstButton>1</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape><v:shape coordsize="21600,21600" filled="f" id="_x0000_s2050" o:insetmode="auto" o:spt="201" stroked="f" style="position:absolute;left:0pt;top:0pt;margin-left:237.65pt;margin-top:51.65pt;height:14.95pt;width:157.65pt;" type="#_x0000_t201"><v:path fillok="t" shadowok="t" strokeok="t"/><v:fill focussize="0,0" on="f"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock aspectratio="f" rotation="t" v:ext="edit"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font color="#000000" face="宋体" size="180"><span style="mso-spacerun:yes"> 其它, 3工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Checked>1</x:Checked><x:Anchor>1,5,2,22,4,28,3,0</x:Anchor><x:FirstButton>0</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape></xml>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<xml xmlns:oa="urn:schemas-microsoft-com:office:activation" xmlns:p="urn:schemas-microsoft-com:office:powerpoint" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml"><o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="2"/></o:shapelayout><v:shapetype id="_x0000_t201" coordsize="21600,21600" o:spt="201" path="m,l,21600r21600,l21600,xe"
>
<v:stroke joinstyle="miter"/>
<v:path shadowok="f" o:extrusionok="f" strokeok="f" fillok="f" o:connecttype="rect"/>
<o:lock v:ext="edit" shapetype="t"/>
</v:shapetype>
<v:shape id="_x0000_s2049" o:spt="201" type="#_x0000_t201" style="position:absolute;left:0pt;top:0pt;margin-left:237.8pt;margin-top:39.65pt;height:14.9pt;width:167.15pt;" filled="f" stroked="f" o:insetmode="auto" coordsize="21600,21600"><v:path fillok="t" strokeok="t" shadowok="t"/><v:fill on="f" focussize="0,0"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock v:ext="edit" rotation="t" aspectratio="f"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font face="宋体" size="180" color="#000000"><span style="mso-spacerun:yes"> Port to Port, 1工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,2,4,44,2,27</x:Anchor><x:FirstButton>1</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape><v:shape id="_x0000_s2050" o:spt="201" type="#_x0000_t201" style="position:absolute;left:0pt;top:0pt;margin-left:237.65pt;margin-top:51.65pt;height:14.95pt;width:157.65pt;" filled="f" stroked="f" o:insetmode="auto" coordsize="21600,21600"><v:path fillok="t" strokeok="t" shadowok="t"/><v:fill on="f" focussize="0,0"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock v:ext="edit" rotation="t" aspectratio="f"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font face="宋体" size="180" color="#000000"><span style="mso-spacerun:yes"> 其它, 3工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,22,4,28,3,0</x:Anchor><x:Checked>1</x:Checked><x:FirstButton>0</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape></xml>
@@ -17,7 +17,9 @@ import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.apache.poi.ss.usermodel.CellType;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -236,6 +238,337 @@ public class QuoteTemplateFillTest {
}
}
/**
* 回归:无 TMS 费用行时,费用表「项目=起运港」不得把 pol 写进右侧「费用名称」。
* 对应 WO202609230026 空运协同出单:空运段旁费用名称被写成「广州」。
*/
@Test
public void airNoTmsMustNotWritePolIntoFeeNameColumn() throws Exception {
Path src = Files.createTempFile("quote-air-notms-src", ".xlsx");
Path out = Files.createTempFile("quote-air-notms-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("Sheet2");
// 货物区:与现网空运映射一致(B6 同格冒号后)
org.apache.poi.ss.usermodel.Row r5 = sheet.createRow(5);
r5.createCell(1).setCellValue("起运港:");
r5.createCell(6).setCellValue("目的港:");
// 费用表明细(headerRow=16 → 0-based 15;anchor A17 → 0-based 16)
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("金额");
head.createCell(7).setCellValue("备注");
org.apache.poi.ss.usermodel.Row airSeg = sheet.createRow(16);
airSeg.createCell(0).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);
otherSeg.createCell(0).setCellValue("其他");
otherSeg.createCell(1).setCellValue("");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = JSONObject.parseObject(
java.nio.file.Files.readString(
java.nio.file.Paths.get("d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/mapping_air_ddp.json")));
JSONObject facts = new JSONObject(true);
facts.put("pol", "广州");
facts.put("pod", "香港");
facts.put("quote_no", "WO202609230026");
// 无 TMS:不带 fee_rows
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
String b16 = sheet.getRow(16).getCell(1) == null ? ""
: String.valueOf(sheet.getRow(16).getCell(1));
String b17 = sheet.getRow(17).getCell(1) == null ? ""
: String.valueOf(sheet.getRow(17).getCell(1));
String b18 = sheet.getRow(18).getCell(1) == null ? ""
: String.valueOf(sheet.getRow(18).getCell(1));
// 用 DataFormatter 读字符串更稳
org.apache.poi.ss.usermodel.DataFormatter fmt = new org.apache.poi.ss.usermodel.DataFormatter();
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 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)));
assertEquals("空运段", fmt.formatCellValue(sheet.getRow(16).getCell(0)));
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* 回归:海运双栏同行时,「整柜或拼柜」不得跨过右侧「送货地址」标签写到 H 列;
* 运输分类单选(drawing 文字)不得被 POI 填表抹掉。
* 对应 WO202609240027:送货地址=整柜、表外残留、运输分类文字丢失。
*/
@Test
public void seaTwoColumnMustNotSpillFclIntoDeliveryAddress() throws Exception {
Path seed = java.nio.file.Paths.get(
"d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_template_seed.xlsx");
org.junit.jupiter.api.Assumptions.assumeTrue(Files.exists(seed), "缺海运 seed 模板");
Path out = Files.createTempFile("quote-sea-spill-out", ".xlsx");
try {
// 读模板 zip 内 drawing 文字作基线
java.util.List<String> before = readDrawingTexts(seed);
org.junit.jupiter.api.Assertions.assertFalse(before.isEmpty(), "模板应有运输分类 drawing 文字");
JSONObject mapping = new JSONObject();
mapping.put("fields", new JSONObject());
JSONObject facts = new JSONObject(true);
facts.put("quote_no", "WO202609240027");
facts.put("fcl_or_lcl", "整柜");
facts.put("pol", "广州港");
facts.put("pod", "巴生港");
facts.put("commodity", "机器设备");
facts.put("quantity", "15件");
facts.put("container_type_qty", "40HQ*1");
facts.put("trade_terms", "门到门");
facts.put("运输分类", "其他");
QuoteTemplateExcelSupport.fillWorkbook(seed, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
org.apache.poi.ss.usermodel.DataFormatter fmt = new org.apache.poi.ss.usermodel.DataFormatter();
String b7 = fmt.formatCellValue(sheet.getRow(6).getCell(1));
String h7 = fmt.formatCellValue(sheet.getRow(6).getCell(7));
String e7 = fmt.formatCellValue(sheet.getRow(6).getCell(4));
assertTrue(e7.contains("送货地址"), "送货地址标签应保留,实际=" + e7);
assertEquals("整柜", b7, "整柜应写在左侧值区 B7");
assertFalse(h7.contains("整柜"), "送货地址值区不得写入整柜,实际=" + h7);
}
// 「其它」:ClientData 保持 WO011 首钮勾选(才能画);文案对调后勾选看起来是「其它」
String p1 = readZipEntryText(out, "xl/ctrlProps/ctrlProp1.xml");
String p2 = readZipEntryText(out, "xl/ctrlProps/ctrlProp2.xml");
assertTrue(p1.contains("checked=\"Checked\"") && p1.contains("firstButton"),
"其它也须保持 WO011 首钮勾选结构,实际=" + p1);
assertFalse(p2.contains("checked=\"Checked\""), "次钮不应 Checked,实际=" + p2);
String vml = readZipEntryText(out, "xl/drawings/vmlDrawing1.vml");
java.util.List<String> bodies = new java.util.ArrayList<>();
java.util.regex.Matcher vm = java.util.regex.Pattern.compile(
"<x:ClientData\\s+ObjectType=\"Radio\">([\\s\\S]*?)</x:ClientData>",
java.util.regex.Pattern.CASE_INSENSITIVE).matcher(vml);
while (vm.find()) {
bodies.add(vm.group(1));
}
assertEquals(2, bodies.size());
assertTrue(bodies.get(0).contains("<x:Checked>"), "ClientData 首钮须 Checked");
assertFalse(bodies.get(1).contains("<x:Checked>"), "ClientData 次钮不得 Checked");
// 文案已对调:第一颗显示「其它」
assertTrue(vml.contains("其它") && vml.indexOf("其它") < vml.indexOf("Port"),
"其它文案应出现在第一颗单选位置");
try (Workbook filled2 = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled2.getSheetAt(0);
org.apache.poi.ss.usermodel.DataFormatter fmt = new org.apache.poi.ss.usermodel.DataFormatter();
String b3 = fmt.formatCellValue(sheet.getRow(2).getCell(1));
assertFalse(b3.contains("●") || b3.contains("○") || b3.contains("工作天"),
"B3 不得写明文,实际=" + b3);
}
} finally {
Files.deleteIfExists(out);
}
}
/**
* 回归 WO007:运输分类 Port to Port 时,ctrlProp 与 VML 都必须勾 Port to Port(WPS 读 VML)。
*/
@Test
public void seaPortToPortMustCheckVmlAndCtrlProp() throws Exception {
Path seed = java.nio.file.Paths.get(
"d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_template_seed.xlsx");
org.junit.jupiter.api.Assumptions.assumeTrue(Files.exists(seed), "缺海运 seed 模板");
Path out = Files.createTempFile("quote-sea-p2p-out", ".xlsx");
try {
JSONObject mapping = new JSONObject();
mapping.put("fields", new JSONObject());
JSONObject facts = new JSONObject(true);
facts.put("quote_no", "WO202609280007");
facts.put("运输分类", "Port to Port");
facts.put("trade_terms", "Port to Port");
facts.put("贸易条款", "Port to Port");
facts.put("pol", "广州港");
facts.put("pod", "巴生港");
QuoteTemplateExcelSupport.fillWorkbook(seed, out, mapping, facts);
String p1 = readZipEntryText(out, "xl/ctrlProps/ctrlProp1.xml");
String p2 = readZipEntryText(out, "xl/ctrlProps/ctrlProp2.xml");
assertTrue(p1.contains("checked=\"Checked\""), "Port to Port 应勾选 ctrlProp1,实际=" + p1);
assertFalse(p2.contains("checked=\"Checked\""), "其它不应勾选,实际=" + p2);
// WO011 标准:强制瘦 drawing≈3966 + 原 Option Button;B3 无合成 ●/○
int outDraw = readZipEntryBytes(out, "xl/drawings/drawing1.xml").length;
assertTrue(outDraw > 3000 && outDraw < 5000,
"应使用 WO011 标准瘦 drawing,out=" + outDraw);
String outDrawXml = readZipEntryText(out, "xl/drawings/drawing1.xml");
assertTrue(outDrawXml.contains("Option Button"),
"drawing 应保留原 Option Button(与 WO011 一致)");
int stylesLen = readZipEntryBytes(out, "xl/styles.xml").length;
assertTrue(stylesLen > 50000,
"应盖回 WO011 styles,避免 POI 改瘦后 WPS 空白,styles=" + stylesLen);
String vml = readZipEntryText(out, "xl/drawings/vmlDrawing1.vml");
// VML 文案用 NBSP(\u00A0)分隔,不能直接 contains "Port to Port"
assertTrue(vml.contains("Port") && vml.contains("其它"),
"VML 应保留原 Form Control 文案");
assertTrue(vml.contains("xmlns:o=") && !vml.contains("xmlns:oa="),
"VML 应为 WO011 标准 xmlns:o");
assertTrue(vml.contains("ObjectType=\"Radio\""), "VML 应有 Radio ClientData");
java.util.regex.Matcher m = java.util.regex.Pattern.compile(
"<x:ClientData\\s+ObjectType=\"Radio\">([\\s\\S]*?)</x:ClientData>",
java.util.regex.Pattern.CASE_INSENSITIVE).matcher(vml);
boolean sawFirst = false;
boolean sawOther = false;
while (m.find()) {
String body = m.group(1);
boolean first = body.contains("<x:FirstButton>1</x:FirstButton>");
boolean checked = body.contains("<x:Checked>");
if (first) {
sawFirst = true;
assertTrue(checked, "Port to Port 的 VML 应 Checked,body=" + body);
} else {
sawOther = true;
assertFalse(checked, "其它的 VML 不应 Checked,body=" + body);
}
}
assertTrue(sawFirst && sawOther, "VML 应有两组 Radio ClientData");
// WO011 标准:B3 无合成 ●/○;原控件勾选即可
org.apache.poi.ss.usermodel.Workbook wb = org.apache.poi.ss.usermodel.WorkbookFactory.create(out.toFile());
try {
org.apache.poi.ss.usermodel.Sheet sh = null;
for (int i = 0; i < wb.getNumberOfSheets(); i++) {
if (wb.getSheetName(i).contains("海运")) {
sh = wb.getSheetAt(i);
break;
}
}
org.junit.jupiter.api.Assertions.assertNotNull(sh, "缺海运 sheet");
org.apache.poi.ss.usermodel.DataFormatter fmt = new org.apache.poi.ss.usermodel.DataFormatter();
String b3 = "";
if (sh.getRow(2) != null && sh.getRow(2).getCell(1) != null) {
b3 = fmt.formatCellValue(sh.getRow(2).getCell(1));
}
assertFalse(b3.contains("●") || b3.contains("○"),
"WO011 标准:B3 不得写合成 ●/○,实际=" + b3);
} finally {
wb.close();
}
} finally {
Files.deleteIfExists(out);
}
}
@Test
public void seaOtherKeepsWo011VmlAndEmptyB3() throws Exception {
Path seed = java.nio.file.Paths.get(
"d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_template_seed.xlsx");
Path wo011 = java.nio.file.Paths.get(
"d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/wo011.xlsx");
org.junit.jupiter.api.Assumptions.assumeTrue(Files.exists(seed), "缺海运 seed 模板");
org.junit.jupiter.api.Assumptions.assumeTrue(Files.exists(wo011), "缺 WO011");
Path out = Files.createTempFile("quote-sea-other-out", ".xlsx");
try {
JSONObject mapping = new JSONObject();
mapping.put("fields", new JSONObject());
JSONObject facts = new JSONObject(true);
facts.put("quote_no", "WO202609280033");
facts.put("运输分类", "其它");
facts.put("pol", "纽约港");
QuoteTemplateExcelSupport.fillWorkbook(seed, out, mapping, facts);
byte[] vmlOut = readZipEntryBytes(out, "xl/drawings/vmlDrawing1.vml");
byte[] vml011 = readZipEntryBytes(wo011, "xl/drawings/vmlDrawing1.vml");
assertArrayEquals(vml011, vmlOut, "其它场景 VML 必须与 WO011 字节级一致(否则 WPS 空白)");
org.apache.poi.ss.usermodel.Workbook wb = org.apache.poi.ss.usermodel.WorkbookFactory.create(out.toFile());
try {
org.apache.poi.ss.usermodel.Sheet sh = null;
for (int i = 0; i < wb.getNumberOfSheets(); i++) {
if (wb.getSheetName(i).contains("海运")) {
sh = wb.getSheetAt(i);
break;
}
}
org.junit.jupiter.api.Assertions.assertNotNull(sh);
org.apache.poi.ss.usermodel.DataFormatter fmt = new org.apache.poi.ss.usermodel.DataFormatter();
String b3 = "";
if (sh.getRow(2) != null && sh.getRow(2).getCell(1) != null) {
b3 = fmt.formatCellValue(sh.getRow(2).getCell(1));
}
// WO033 证据:B3 明文会让 WPS 不画原控件;必须空着,与 WO011/WO030 一致
assertTrue(b3 == null || b3.trim().isEmpty(),
"其它场景 B3 必须空白(控件锚点区),实际=" + b3);
assertFalse(b3.contains("●") || b3.contains("○"), "不得写合成 ●/○");
} finally {
wb.close();
}
} finally {
Files.deleteIfExists(out);
}
}
private static byte[] readZipEntryBytes(Path xlsx, String entry) throws Exception {
try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(xlsx.toFile())) {
java.util.zip.ZipEntry e = zf.getEntry(entry);
org.junit.jupiter.api.Assertions.assertNotNull(e, "缺 zip 条目 " + entry);
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
try (java.io.InputStream in = zf.getInputStream(e)) {
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) >= 0) {
bos.write(buf, 0, n);
}
}
return bos.toByteArray();
}
}
private static String readZipEntryText(Path xlsx, String entry) throws Exception {
try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(xlsx.toFile())) {
java.util.zip.ZipEntry e = zf.getEntry(entry);
org.junit.jupiter.api.Assertions.assertNotNull(e, "缺 zip 条目 " + entry);
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
try (java.io.InputStream in = zf.getInputStream(e)) {
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) >= 0) {
bos.write(buf, 0, n);
}
}
return bos.toString("UTF-8");
}
}
private static java.util.List<String> readDrawingTexts(Path xlsx) throws Exception {
java.util.List<String> out = new java.util.ArrayList<>();
try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(xlsx.toFile())) {
java.util.zip.ZipEntry e = zf.getEntry("xl/drawings/drawing1.xml");
if (e != null) {
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
try (java.io.InputStream in = zf.getInputStream(e)) {
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) >= 0) {
bos.write(buf, 0, n);
}
}
String xml = bos.toString("UTF-8");
java.util.regex.Matcher m = java.util.regex.Pattern.compile("<a:t>([^<]+)</a:t>").matcher(xml);
while (m.find()) {
out.add(m.group(1));
}
}
}
return out;
}
/**
* 空运模板「预估总价」原是 SUM。有 total_amount(TMS 总销售价)时改写成该数,
* 避免刷公式后把固定费加进去、或标签旁再写一份形成两个总价。
@@ -351,6 +684,166 @@ public class QuoteTemplateFillTest {
}
}
/**
* TMS 已返回分段汇总时:海运「始发港费用汇总」格若是 SUM,必须去公式改写 fee_total_*。
*/
@Test
public void seaSectionSubtotalStripsSumWhenTmsTotalPresent() throws Exception {
Path src = Files.createTempFile("quote-sea-sub-src", ".xlsx");
Path out = Files.createTempFile("quote-sea-sub-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("询价单");
org.apache.poi.ss.usermodel.Row head = sheet.createRow(10);
head.createCell(0).setCellValue("项目");
head.createCell(1).setCellValue("币别");
head.createCell(7).setCellValue("金额");
sheet.createRow(11).createCell(0).setCellValue("THC");
sheet.getRow(11).createCell(7).setCellValue(100.0);
sheet.createRow(12).createCell(0).setCellValue("文件费");
sheet.getRow(12).createCell(7).setCellValue(50.0);
org.apache.poi.ss.usermodel.Row sub = sheet.createRow(13);
sub.createCell(0).setCellValue("始发港费用汇总");
sub.createCell(7).setCellFormula("SUM(H12:H13)");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject();
mapping.put("fields", new JSONObject());
JSONObject facts = new JSONObject(true);
facts.put("currency", "CNY");
facts.put("fee_total_polCostItems", "CNY 888.00");
facts.put("total_amount", "9999.00");
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook got = new XSSFWorkbook(Files.newInputStream(out))) {
org.apache.poi.ss.usermodel.Cell amt = got.getSheetAt(0).getRow(13).getCell(7);
assertEquals(CellType.NUMERIC, amt.getCellType());
assertEquals(888.00, amt.getNumericCellValue(), 0.01);
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* WO040:departureCharges 只有增点费/拆板费时,模板 SUM=200;
* 预估费用必须盖成 TMS salePrice(7918.25),且 G 列不能再留 SUM 公式。
*/
@Test
public void estimateTotalUsesSalePriceWhenFeeLinesAreSurchargesOnly() throws Exception {
Path src = Files.createTempFile("quote-est-air-src", ".xlsx");
Path out = Files.createTempFile("quote-est-air-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(100.0);
org.apache.poi.ss.usermodel.Row r2 = sheet.createRow(17);
r2.createCell(0).setCellValue("起运港");
r2.createCell(1).setCellValue("拆板费");
r2.createCell(6).setCellValue(100.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 facts = new JSONObject(true);
facts.put("total_amount", "7918.25");
facts.put("currency", "CNY");
facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows(
"["
+ "{\"name\":\"增点费\",\"amount\":\"100.0\",\"currency\":\"CNY\",\"unit\":\"点\",\"quantity\":\"1\",\"unit_price\":\"100.0\"},"
+ "{\"name\":\"拆板费\",\"amount\":\"100.0\",\"currency\":\"USD\",\"unit\":\"板数\",\"quantity\":\"1\",\"unit_price\":\"100.0\"}"
+ "]"));
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook got = new XSSFWorkbook(Files.newInputStream(out))) {
org.apache.poi.ss.usermodel.Cell sum = got.getSheetAt(0).getRow(22).getCell(6);
assertEquals(CellType.NUMERIC, sum.getCellType());
assertEquals(7918.25, sum.getNumericCellValue(), 0.01);
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* 顶层 total 空、只有 selected_air_option.salePrice 时,填表 total_amount 仍要落到销售价。
*/
@Test
public void buildFillFactsAirTotalFromSelectedOptionSalePrice() {
Map<String, Object> facts = new HashMap<>();
facts.put("运输方式", "空运");
facts.put("品名", "普货");
Map<String, Object> charge = new HashMap<>();
charge.put("chargeName", "增点费");
charge.put("unitPrice", 100.0);
charge.put("currency", "CNY");
charge.put("unit", "点");
Map<String, Object> option = new HashMap<>();
option.put("optionNo", "AIR-OPT01");
option.put("salePrice", 7918.25);
option.put("currency", "CNY");
option.put("departureCharges", java.util.Collections.singletonList(charge));
Map<String, Object> quote = new HashMap<>();
quote.put("selected_air_option", option);
quote.put("airOptions", java.util.Collections.singletonList(option));
quote.put("chargeableWeightKg", "833.5");
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
.buildFillFacts("WO202609290040", facts, quote, "WuJiLin", "AIR");
assertEquals("7918.25", out.getString("total_amount"));
assertEquals("CNY", out.getString("currency"));
assertTrue(out.getJSONArray("fee_rows").size() >= 1);
}
/**
* WO026/WO040:群薄报价把增点费+拆板费之和写成 total=200,
* 但 airOptions.salePrice=7918.25 时,预估费用仍必须用销售价。
*/
@Test
public void buildFillFactsAirPrefersSalePriceWhenTotalIsFeeSum() {
Map<String, Object> facts = new HashMap<>();
facts.put("运输方式", "空运");
facts.put("品名", "普货");
Map<String, Object> option = new HashMap<>();
option.put("optionNo", "AIR-OPT01");
option.put("salePrice", 7918.25);
option.put("currency", "CNY");
Map<String, Object> quote = new HashMap<>();
quote.put("total", "200");
quote.put("currency", "CNY");
quote.put("airOptions", java.util.Collections.singletonList(option));
quote.put("selected_air_option", option);
quote.put("fee_lines", java.util.Arrays.asList(
new HashMap<String, Object>() {{
put("name", "增点费");
put("amount", "100");
put("currency", "CNY");
}},
new HashMap<String, Object>() {{
put("name", "拆板费");
put("amount", "100");
put("currency", "USD");
}}
));
quote.put("chargeableWeightKg", "833.5");
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
.buildFillFacts("WO202609290026", facts, quote, "WuJiLin", "AIR");
assertEquals("7918.25", out.getString("total_amount"));
}
@Test
public void feeQuantityDefaultsToOneWhenBlank() {
assertEquals("1", QuoteTemplateExcelSupport.feeQuantityOrDefault(null, "提送货费"));
@@ -915,6 +1408,49 @@ public class QuoteTemplateFillTest {
}
}
/**
* 回归 WO202609220088:三段汇总写币别+金额;费用总计写在金额列并带币别,不得落在 B 列。
*/
@Test
public void seaSubtotalsAndTotalGoToFeeColumns() throws Exception {
Path seed = java.nio.file.Paths.get(
"d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_template_seed.xlsx");
org.junit.jupiter.api.Assumptions.assumeTrue(Files.exists(seed), "缺海运 seed 模板");
Path out = Files.createTempFile("quote-sea-subtotal-out", ".xlsx");
try {
JSONObject mapping = com.alibaba.fastjson.JSON.parseObject(
java.nio.file.Files.readString(
java.nio.file.Paths.get(
"d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_last_sea_mapping.json")));
JSONObject facts = new JSONObject(true);
facts.put("currency", "CNY");
facts.put("total_amount", "14298.53");
facts.put("fee_total_polCostItems", "2335.00");
facts.put("fee_total_oceanCostItems", "8778.75");
facts.put("fee_total_podCostItems", "3184.78");
QuoteTemplateExcelSupport.fillWorkbook(seed, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
org.apache.poi.ss.usermodel.DataFormatter fmt = new org.apache.poi.ss.usermodel.DataFormatter();
// 汇总行:B=币别 H=金额(0-based: 1 / 7)
assertEquals("CNY", fmt.formatCellValue(sheet.getRow(27).getCell(1)));
assertEquals("2335", fmt.formatCellValue(sheet.getRow(27).getCell(7)).replace(".0", "").replace(".00", ""));
assertTrue(fmt.formatCellValue(sheet.getRow(27).getCell(7)).contains("2335"));
assertTrue(fmt.formatCellValue(sheet.getRow(32).getCell(7)).contains("8778"));
assertEquals("CNY", fmt.formatCellValue(sheet.getRow(32).getCell(1)));
assertTrue(fmt.formatCellValue(sheet.getRow(42).getCell(7)).contains("3184"));
assertEquals("CNY", fmt.formatCellValue(sheet.getRow(42).getCell(1)));
// 费用总计:金额在 H,币别在 B;B 不得只是裸金额
String b44 = fmt.formatCellValue(sheet.getRow(43).getCell(1));
String h44 = fmt.formatCellValue(sheet.getRow(43).getCell(7));
assertEquals("CNY", b44);
assertTrue(h44.contains("14298"), "费用总计金额应在 H 列,实际 B=" + b44 + " H=" + h44);
}
} finally {
Files.deleteIfExists(out);
}
}
private static org.apache.poi.ss.usermodel.Row row(Sheet sheet, int r) {
org.apache.poi.ss.usermodel.Row exist = sheet.getRow(r);
return exist == null ? sheet.createRow(r) : exist;
@@ -140,4 +140,125 @@ public class InquiryTmsCabinServiceTest {
String why = InquiryTmsCabinService.cabinFailReason(2006, new HashMap<>(), "2026-09-20", "2026-09-21");
assertEquals("TMS 业务码=2006", why);
}
@Test
public void validUntilDateOnlyBecomesIsoWithZone() {
assertEquals("2026-09-30T23:59:59+08:00",
InquiryTmsCabinService.normalizeValidUntilIso("2026-09-30"));
assertEquals("2026-09-30T23:59:59+08:00",
InquiryTmsCabinService.normalizeValidUntilIso("2026/09/30"));
}
@Test
public void validUntilAlreadyIsoKept() {
assertEquals("2026-09-30T12:00:00+08:00",
InquiryTmsCabinService.normalizeValidUntilIso("2026-09-30T12:00:00+08:00"));
assertEquals("2026-09-30T12:00:00+08:00",
InquiryTmsCabinService.normalizeValidUntilIso("2026-09-30T12:00:00"));
}
@Test
public void fillLockQuoteExtrasPutsIsoAndTmsFeeNames() {
Map<String, Object> quote = new HashMap<>();
quote.put("valid_until", "2026-09-30");
Map<String, Object> option = new HashMap<>();
Map<String, Object> charge = new HashMap<>();
charge.put("chargeName", "运输费");
charge.put("unitPrice", "11.00000");
charge.put("unit", "板数");
charge.put("currency", "CNY");
option.put("departureCharges", java.util.Arrays.asList(charge));
Map<String, Object> fee = new HashMap<>();
fee.put("feeName", "空运费");
fee.put("unitPrice", 18);
fee.put("currency", "CNY");
quote.put("feeItems", java.util.Arrays.asList(fee));
Map<String, Object> req = new HashMap<>();
InquiryTmsCabinService.fillLockQuoteExtras(req, quote, option);
assertEquals("2026-09-30T23:59:59+08:00", req.get("validUntil"));
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> dep =
(java.util.List<Map<String, Object>>) req.get("departureCharges");
assertEquals("运输费", dep.get(0).get("chargeName"));
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> fees =
(java.util.List<Map<String, Object>>) req.get("feeItems");
assertEquals("空运费", fees.get(0).get("feeName"));
}
@Test
public void airlineFromInstructionReadsCodeAfterOption() {
assertEquals("TH", InquiryTmsCabinService.airlineFromInstruction(
"WO202609290004锁舱,锁AIR-OPT01 TH@询价小助手"));
assertEquals("CZ", InquiryTmsCabinService.airlineFromInstruction("AIR-OPT-02 CZ"));
assertEquals("", InquiryTmsCabinService.airlineFromInstruction("WO202609290004锁舱"));
}
@Test
public void resolveOptionKeepsCallerOptionWhenNotInList() {
Map<String, Object> quote = new HashMap<>();
Map<String, Object> only = new HashMap<>();
only.put("optionNo", "AIR-OPT02");
quote.put("airOptions", java.util.Arrays.asList(only));
assertEquals("AIR-OPT01",
InquiryTmsCabinService.resolveOption(quote, "AIR-OPT01").get("optionNo"));
}
@Test
public void looksLikeOptionTokenFeesDetectsOptFragment() {
Map<String, Object> quote = new HashMap<>();
Map<String, Object> fee = new HashMap<>();
fee.put("name", "OPT");
fee.put("amount", "01");
quote.put("fee_lines", java.util.Arrays.asList(fee));
org.junit.jupiter.api.Assertions.assertTrue(InquiryTmsCabinService.looksLikeOptionTokenFees(quote));
}
@Test
public void enrichLockQuoteClearsOptFeesAndCopiesValidUntil() {
Map<String, Object> manual = new HashMap<>();
manual.put("source", "PRODUCT");
Map<String, Object> fee = new HashMap<>();
fee.put("name", "OPT");
fee.put("amount", "01");
manual.put("fee_lines", java.util.Arrays.asList(fee));
Map<String, Object> tms = new HashMap<>();
tms.put("valid_until", "2026-09-30");
tms.put("chargeableWeightKg", "66.68");
Map<String, Object> out = InquiryTmsCabinService.enrichLockQuoteFromTms(manual, tms);
assertEquals("2026-09-30", out.get("validUntil"));
org.junit.jupiter.api.Assertions.assertNull(out.get("fee_lines"));
assertEquals("66.68", out.get("chargeableWeightKg"));
}
@Test
public void plainFeeDecimalStripsTrailingZerosWithoutSci() {
assertEquals("100", InquiryTmsCabinService.plainFeeDecimal(new java.math.BigDecimal("100.00000")).toPlainString());
assertEquals("10.5", InquiryTmsCabinService.plainFeeDecimal(new java.math.BigDecimal("10.50")).toPlainString());
assertEquals("166.7", InquiryTmsCabinService.plainFeeDecimal(new java.math.BigDecimal("166.7000")).toPlainString());
}
@Test
public void lockModeTmsOnlyIsSystemWithQuoteId() {
// 口径1:仅 TMS → SYSTEM + quoteId(由 lockCabin 组装;此处用 isManual 判定)
Map<String, Object> tms = new HashMap<>();
tms.put("source", "TMS");
tms.put("source_label", "TMS 标准报价");
org.junit.jupiter.api.Assertions.assertFalse(InquiryTmsCabinService.isManualQuote(tms));
}
@Test
public void lockModeManualKeepsQuoteIdWhenFlagTrue() {
Map<String, Object> req = new HashMap<>();
req.put("quoteId", "AIR-1-2");
Map<String, Object> quote = new HashMap<>();
quote.put("valid_until", "2026-09-30");
InquiryTmsCabinService.fillManualLock(req, quote, new HashMap<>(), new HashMap<>(), true);
assertEquals("MANUAL", req.get("source"));
assertEquals("AIR-1-2", req.get("quoteId"));
InquiryTmsCabinService.fillManualLock(req, quote, new HashMap<>(), new HashMap<>(), false);
org.junit.jupiter.api.Assertions.assertNull(req.get("quoteId"));
}
}
@@ -89,4 +89,23 @@ public class TmsCabinRequestMapperTest {
assertEquals("2026-09-16", body.get("flightDate"));
assertEquals("AIRLINE", body.get("operatorRole"));
}
@Test
public void lockNormalizesValidUntilToIso() {
Map<String, Object> internal = new LinkedHashMap<>();
internal.put("workOrderNo", "WO202609290004");
internal.put("source", "SYSTEM");
internal.put("validUntil", "2026-09-30");
Map<String, Object> charge = new LinkedHashMap<>();
charge.put("chargeName", "运输费");
charge.put("unitPrice", new BigDecimal("11"));
internal.put("departureCharges", java.util.Collections.singletonList(charge));
Map<String, Object> body = TmsCabinRequestMapper.lock(internal);
assertEquals("2026-09-30T23:59:59+08:00", body.get("validUntil"));
assertEquals("SYSTEM", body.get("source"));
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> dep =
(java.util.List<Map<String, Object>>) body.get("departureCharges");
assertEquals("运输费", dep.get(0).get("chargeName"));
}
}
@@ -222,4 +222,31 @@ public class TmsV2RequestMapperTest {
assertEquals("CRK", out.get("destinationCode"));
assertEquals("托盘", out.get("packageType"));
}
@Test
public void volumeDimensionProductBecomesCbm() {
assertEquals(new java.math.BigDecimal("3.96"),
TmsV2RequestMapper.asVolumeCbm("1.1*1.2*3"));
assertEquals(new java.math.BigDecimal("3.96"),
TmsV2RequestMapper.asVolumeCbm("1.1x1.2x3"));
assertEquals(new java.math.BigDecimal("3.96"),
TmsV2RequestMapper.asVolumeCbm("1.1×1.2×3"));
assertEquals(new java.math.BigDecimal("2"),
TmsV2RequestMapper.asVolumeCbm("2CBM"));
Map<String, Object> seg = new HashMap<>();
seg.put("originCode", "SZX");
seg.put("destinationCode", "KUL");
seg.put("volumeCbm", "1.1*1.2*3");
seg.put("weightKg", "5");
seg.put("quantity", "3");
seg.put("packageType", "托盘");
Map<String, Object> internal = new HashMap<>();
internal.put("transportMode", "AIR");
internal.put("quoteDate", "2026-10-08");
internal.put("segments", Collections.singletonList(seg));
Map<String, Object> body = TmsV2RequestMapper.quote(internal);
@SuppressWarnings("unchecked")
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
assertEquals(new java.math.BigDecimal("3.96"), segs.get(0).get("volumeCbm"));
}
}
+3 -1
View File
@@ -30,6 +30,8 @@
<jeecgboot.version>3.5.3</jeecgboot.version>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- 默认跳过单测;本地排查时加 -DskipTests=false -->
<skipTests>true</skipTests>
<!-- 微服务 -->
<spring-cloud.version>2021.0.3</spring-cloud.version>
@@ -367,7 +369,7 @@
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<skipTests>true</skipTests>
<skipTests>${skipTests}</skipTests>
</configuration>
</plugin>
<!-- 避免font文件的二进制文件格式压缩破坏 -->
@@ -335,7 +335,6 @@ export const usePermissionStore = defineStore({
try {
this.changePermissionCode();
routeList = (await getMenuList()) as AppRouteRecordRaw[];
routeList = ensureInquiryHandwrittenRoutes(routeList);
// update-begin----author:sunjianlei---date:20220315------for: 判断是否是 vue3 版本的菜单 ---
let hasIndex: boolean = false;
let hasIcon: boolean = false;
@@ -373,7 +372,11 @@ export const usePermissionStore = defineStore({
// update-end----author:sunjianlei---date:20220315------for: 判断是否是 vue3 版本的菜单 ---
} catch (error) {
console.error(error);
// 菜单接口失败时仍补挂询价手写页,避免侧栏点进去全是 404
routeList = Array.isArray(routeList) ? routeList : [];
}
// 无论菜单接口成功与否,都按侧栏清单补齐手写路由
routeList = ensureInquiryHandwrittenRoutes(routeList || []);
// 组件地址前加斜杠处理 author: lsq date:2021-09-08
routeList = addSlashToRouteComponent(routeList);
// 动态引入组件
@@ -1262,6 +1262,43 @@ html {
font-size: 10px !important;
}
/**
* 报价模板预览截图:强制等比例(宽高同比例缩放),覆盖任何全局 img 拉满规则。
* 为何写在 fidelity:Teleport 弹窗挂 body,需非 scoped + !important 兜底。
*/
.qt-snapshot-wrap {
display: flex !important;
justify-content: center !important;
align-items: flex-start !important;
max-height: min(72vh, 760px) !important;
min-height: 0 !important;
overflow: auto !important;
box-sizing: border-box !important;
padding: 6px !important;
}
.qt-snapshot-img,
.modal-overlay .qt-snapshot-img,
.qt-preview-dialog .qt-snapshot-img {
display: block !important;
width: auto !important;
height: auto !important;
max-width: 100% !important;
max-height: min(70vh, 720px) !important;
object-fit: contain !important;
object-position: top center !important;
flex: 0 0 auto !important;
align-self: flex-start !important;
}
.qt-lightbox-img {
width: auto !important;
height: auto !important;
max-width: calc(100vw - 48px) !important;
max-height: calc(100vh - 72px) !important;
object-fit: contain !important;
}
.qt-upload-title,
.qt-file-name {
font-size: 11px !important;
@@ -770,6 +770,50 @@ textarea:focus-visible {
color: #0f172a;
}
/* 报价费用明细:列表紧凑一行;详情左右对齐名称/金额 */
.quote-fee-line {
font-size: 11px;
font-weight: 600;
color: #344054;
line-height: 1.45;
}
.quote-fee-list {
display: flex;
flex-direction: column;
gap: 4px;
margin: 6px 0 4px;
padding: 8px 10px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #eef2f6;
}
.ticket-detail-quote .quote-fee-line {
display: flex;
justify-content: space-between;
gap: 12px;
}
.ticket-detail-quote .quote-fee-name {
color: #475467;
font-weight: 600;
}
.ticket-detail-quote .quote-fee-amount {
color: #0f172a;
font-weight: 700;
white-space: nowrap;
}
.ticket-detail-quote {
margin-bottom: 10px;
}
.ticket-detail-quote:last-child {
margin-bottom: 0;
}
.attach-tags {
display: flex;
flex-wrap: wrap;
@@ -2249,6 +2293,47 @@ textarea:focus-visible {
color: #94a3b8;
}
/* 报价模板原表截图:等比例适配弹窗,禁止拉伸变形 */
.qt-snapshot-wrap {
display: flex;
justify-content: center;
align-items: flex-start;
max-height: min(72vh, 760px);
min-height: 0;
overflow: auto;
border: 1px solid #e2e8f0;
background: #f1f5f9;
border-radius: 8px;
text-align: center;
box-sizing: border-box;
padding: 6px;
}
.qt-snapshot-img {
display: block;
width: auto;
height: auto;
max-width: 100%;
max-height: min(70vh, 720px);
object-fit: contain;
object-position: top center;
flex: 0 0 auto;
align-self: flex-start;
background: #fff;
cursor: zoom-in;
}
.qt-lightbox-img {
display: block;
width: auto;
height: auto;
max-width: calc(100vw - 48px);
max-height: calc(100vh - 72px);
object-fit: contain;
background: #fff;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
}
/* 工单详情:对齐原型 admin-inquiry-tickets 分节网格 */
.ticket-detail-no {
display: inline-flex;
@@ -70,6 +70,8 @@ const previewState = reactive({
isImage: true,
mappedCount: 0,
fileName: "",
/** 浏览器侧已裁过白边,防止 @load 重复裁切 */
trimmed: false,
});
/** 列表「解析状态」点击弹窗:已解析 / 未解析 */
@@ -125,16 +127,103 @@ const formSub = computed(() =>
: "上传 Excel 后 AI 异步解析映射;失败原因会显示在列表",
);
const formOkText = computed(() => (editing.value ? "保存" : "上传并解析"));
const previewFootHint = "按上传原表打印版式截图,比例 1:1 不变形;点击图片可放大。";
const previewFootHint = "按原表打印版式等比例截图(不拉高/压扁);点击图片可放大。";
const previewLightbox = ref(false);
const filtered = computed(() => rows.value);
function revokePreviewUrl() {
if (previewState.objectUrl) {
if (previewState.objectUrl && previewState.objectUrl.startsWith("blob:")) {
URL.revokeObjectURL(previewState.objectUrl);
previewState.objectUrl = "";
}
previewState.objectUrl = "";
previewState.trimmed = false;
}
/**
* 浏览器侧再裁一层近白边:主账若仍带 A4 页底空白,预览也能贴紧内容。
* 副作用:替换 objectUrl(旧 blob 释放);仅图片预览调用一次。
*/
function onSnapshotImgLoad(ev: Event) {
if (previewState.trimmed || !previewState.isImage) return;
const img = ev.target as HTMLImageElement | null;
if (!img?.naturalWidth || !img.naturalHeight) return;
try {
const next = cropNearWhiteObjectUrl(img);
if (!next) {
previewState.trimmed = true;
return;
}
const old = previewState.objectUrl;
previewState.trimmed = true;
previewState.objectUrl = next;
if (old && old.startsWith("blob:")) {
URL.revokeObjectURL(old);
}
} catch {
previewState.trimmed = true;
}
}
/**
* Canvas 裁近白边 → data URL。几乎无内容可裁时返回 null。
*/
function cropNearWhiteObjectUrl(img: HTMLImageElement): string | null {
const w = img.naturalWidth;
const h = img.naturalHeight;
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.drawImage(img, 0, 0);
const data = ctx.getImageData(0, 0, w, h).data;
const isWhite = (i: number) => data[i] >= 240 && data[i + 1] >= 240 && data[i + 2] >= 240;
const rowMostlyWhite = (y: number) => {
const allow = Math.max(2, Math.floor(w / 200));
let dirty = 0;
const row = y * w * 4;
for (let x = 0; x < w; x++) {
if (!isWhite(row + x * 4)) {
dirty++;
if (dirty > allow) return false;
}
}
return true;
};
const colMostlyWhite = (x: number, y0: number, y1: number) => {
const allow = Math.max(2, Math.floor((y1 - y0 + 1) / 200));
let dirty = 0;
for (let y = y0; y <= y1; y++) {
if (!isWhite((y * w + x) * 4)) {
dirty++;
if (dirty > allow) return false;
}
}
return true;
};
let top = 0;
let bottom = h - 1;
let left = 0;
let right = w - 1;
while (top < h && rowMostlyWhite(top)) top++;
while (bottom > top && rowMostlyWhite(bottom)) bottom--;
while (left < w && colMostlyWhite(left, top, bottom)) left++;
while (right > left && colMostlyWhite(right, top, bottom)) right--;
if (top >= bottom || left >= right || bottom - top + 1 < h / 20) return null;
const pad = 8;
const x = Math.max(0, left - pad);
const y = Math.max(0, top - pad);
const cw = Math.min(w - 1, right + pad) - x + 1;
const ch = Math.min(h - 1, bottom + pad) - y + 1;
if (cw >= w && ch >= h) return null;
const out = document.createElement("canvas");
out.width = cw;
out.height = ch;
const octx = out.getContext("2d");
if (!octx) return null;
octx.drawImage(canvas, x, y, cw, ch, 0, 0, cw, ch);
return out.toDataURL("image/png");
}
/**
@@ -149,6 +238,7 @@ async function openPreview(row: TemplateRow) {
previewState.loading = true;
previewState.error = "";
previewState.isImage = true;
previewState.trimmed = false;
previewState.mappedCount = row.mappingPointCount || 0;
previewState.fileName = row.fileName || "";
if (!row.versionId) {
@@ -919,6 +1009,7 @@ async function downloadPreviewOrOriginal() {
:src="previewState.objectUrl"
alt="原表截图预览"
title="点击放大"
@load="onSnapshotImgLoad"
@click="openLightbox"
/>
<iframe
@@ -1248,19 +1339,35 @@ async function downloadPreviewOrOriginal() {
.qt-paper-loading--err {
color: #b42318;
}
/**
* 原表截图:等比例缩放;容器贴合图片,不人为撑出大块空白。
* 弹窗 Teleport 到 body,关键规则在 admin-fidelity 也有 !important 兜底。
*/
.qt-snapshot-wrap {
max-height: min(70vh, 720px);
display: flex;
justify-content: center;
align-items: flex-start;
max-height: min(72vh, 760px);
overflow: auto;
border: 1px solid #e2e8f0;
background: #f1f5f9;
border-radius: 8px;
text-align: center;
box-sizing: border-box;
padding: 6px;
/* 不设 min-height,避免图矮时底下留灰白空带 */
min-height: 0;
}
.qt-snapshot-img {
display: inline-block;
width: auto;
height: auto;
max-width: none;
display: block;
width: auto !important;
height: auto !important;
max-width: 100% !important;
max-height: min(70vh, 720px) !important;
object-fit: contain !important;
object-position: top center;
flex: 0 0 auto;
align-self: flex-start;
vertical-align: top;
background: #fff;
cursor: zoom-in;
@@ -17,7 +17,16 @@ import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
type TicketQuote = { source: string; price: string; time?: string; highlight?: boolean };
/** 单条费用明细:名称 + 带币种金额,对齐企微价格明细。 */
type TicketQuoteFee = { name: string; amount: string };
type TicketQuote = {
source: string;
price: string;
time?: string;
highlight?: boolean;
/** TMS/手工报价费用明细行;无明细时为空数组 */
fees: TicketQuoteFee[];
};
type TicketAttach = { name: string; uploadedAt: string; uploader: string };
type TicketTimeline = { status: string; remark: string; time: string };
@@ -108,6 +117,110 @@ function quoteSourceLabel(q: any) {
return label && label !== "报价" ? "手工报价" : "报价";
}
/** JSON 字符串或对象都解开;解不开当没有。 */
function asObject(raw: any): Record<string, any> | null {
if (raw == null) return null;
if (typeof raw === "object" && !Array.isArray(raw)) return raw as Record<string, any>;
if (typeof raw === "string" && raw.trim()) {
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, any>;
}
} catch {
return null;
}
}
return null;
}
/** 费用数组:List / JSON 字符串 / 二次编码都认。 */
function asFeeArray(raw: any): any[] {
if (raw == null) return [];
if (Array.isArray(raw)) return raw;
if (typeof raw === "string" && raw.trim()) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed;
if (typeof parsed === "string") {
const again = JSON.parse(parsed);
return Array.isArray(again) ? again : [];
}
} catch {
return [];
}
}
return [];
}
/**
* 把一条费用行收成「名称 + 金额」。
* TMS 字段:name/chargeName、amount/unitPrice、currency。
*/
function feeRowToDisplay(item: any): TicketQuoteFee | null {
if (!item || typeof item !== "object") return null;
const name = String(
item.name || item.chargeName || item.feeName || item.label || ""
).trim();
if (!name) return null;
let amount = String(item.amount ?? item.totalPrice ?? item.value ?? "").trim();
const unitPrice = String(item.unitPrice ?? item.unit_price ?? "").trim();
if (!amount || amount === "-") {
amount = unitPrice;
}
if (!amount || amount === "-") return null;
const ccy = String(item.currency || "").trim().toUpperCase();
const amountUpper = amount.toUpperCase();
if (ccy && !amountUpper.startsWith(ccy) && !/^[A-Z]{3}\s/.test(amountUpper)) {
amount = `${ccy} ${amount}`;
}
return { name, amount };
}
/**
* 从报价 payload 抽费用明细,供后台「价格与利润」展示。
* 优先 fee_lines / fee_rows;空运再看 departureCharges;海运再看三段桶。
* 副作用:无。不写库。
*/
function parseQuoteFees(q: any): TicketQuoteFee[] {
const payload = asObject(q?.payloadJson) || asObject(q?.payload) || {};
const out: TicketQuoteFee[] = [];
const pushRows = (rows: any[]) => {
for (const row of rows) {
const one = feeRowToDisplay(row);
if (one) out.push(one);
}
};
pushRows(asFeeArray(payload.fee_lines ?? payload.fee_rows ?? q?.fee_lines ?? q?.fee_rows));
if (!out.length) {
const selected =
asObject(payload.selected_air_option) || asObject(payload.selectedAirOption);
if (selected) {
pushRows(
asFeeArray(
selected.departureCharges || selected.departure_charges || selected.feeItems
)
);
}
}
if (!out.length) {
const opts = asFeeArray(payload.airOptions || payload.air_options);
const first = opts.find((x) => x && typeof x === "object") || null;
if (first) {
pushRows(
asFeeArray(first.departureCharges || first.departure_charges || first.feeItems)
);
}
}
if (!out.length) {
for (const bucket of ["polCostItems", "oceanCostItems", "podCostItems"] as const) {
const key = `fee_rows_${bucket}`;
pushRows(asFeeArray(payload[key] || payload[bucket]));
}
}
return out;
}
/**
* 主账 inq_ticket_status_log.action 是内部码,禁止当备注展示。
* transition 与「已报价」等 toStatus 重复,无用户备注时不再跟一段英文。
@@ -260,6 +373,7 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
price: q.priceText || "-",
time: fmtTime(q.createTime),
highlight: q.highlighted === 1 || q.highlighted === true,
fees: parseQuoteFees(q),
})),
attachments: attachRows.map((a) => a.name),
attachRows,
@@ -556,6 +670,13 @@ function refresh() {
style="margin-bottom: 8px"
>
<div class="quote-source">{{ q.source }}</div>
<div
v-for="(fee, fi) in q.fees"
:key="q.source + '-fee-' + fi"
class="quote-fee-line"
>
{{ fee.name }}:{{ fee.amount }}
</div>
<div class="quote-price" :style="q.highlight ? { color: '#1d4ed8' } : undefined">
{{ q.price }}
</div>
@@ -688,9 +809,30 @@ function refresh() {
<div class="ticket-detail-item"><label>利润点</label><span>{{ current.profit }}</span></div>
</div>
<div v-if="current.quotes.length" class="ticket-detail-demand">
<div v-for="q in current.quotes" :key="q.source + q.price + (q.time || '')" class="quote-row" style="margin-bottom: 6px">
<div class="quote-source">{{ q.source }}<span v-if="q.time && q.time !== '-'"> · {{ q.time }}</span></div>
<div class="quote-price" :style="q.highlight ? { color: '#1d4ed8' } : undefined">{{ q.price }}</div>
<div
v-for="q in current.quotes"
:key="q.source + q.price + (q.time || '')"
class="quote-row ticket-detail-quote"
>
<div class="quote-source">
{{ q.source }}<span v-if="q.time && q.time !== '-'"> · {{ q.time }}</span>
</div>
<div v-if="q.fees.length" class="quote-fee-list">
<div
v-for="(fee, fi) in q.fees"
:key="q.source + '-detail-fee-' + fi"
class="quote-fee-line"
>
<span class="quote-fee-name">{{ fee.name }}</span>
<span class="quote-fee-amount">{{ fee.amount }}</span>
</div>
</div>
<div
class="quote-price"
:style="q.highlight ? { color: '#1d4ed8' } : undefined"
>
合计 {{ q.price }}
</div>
</div>
</div>
</section>