diff --git a/inquiry-agent/agent/channel/h5/__init__.py b/inquiry-agent/agent/channel/h5/__init__.py index cd06649..f05a50e 100644 --- a/inquiry-agent/agent/channel/h5/__init__.py +++ b/inquiry-agent/agent/channel/h5/__init__.py @@ -118,7 +118,7 @@ async def _accept_upload_files( return HTMLResponse(expired_upload_page(), status_code=410) if phase in {"reject_type", "need_file"}: return HTMLResponse(render_upload_message("无法上传", hint), status_code=400) - return HTMLResponse(render_upload_message("上传成功", copy.ATTACH_H5_OK)) + return HTMLResponse(render_upload_message("上传成功", copy.attach_h5_ok())) @router.get("/upload/{token}", response_class=HTMLResponse) diff --git a/inquiry-agent/agent/channel/h5/upload_page.py b/inquiry-agent/agent/channel/h5/upload_page.py index 0f328b2..e5a48da 100644 --- a/inquiry-agent/agent/channel/h5/upload_page.py +++ b/inquiry-agent/agent/channel/h5/upload_page.py @@ -319,4 +319,4 @@ def render_upload_message(title: str, body: str, *, expired: bool = False) -> st def expired_upload_page() -> str: - return render_upload_message("链接已过期", copy.ATTACH_H5_EXPIRED, expired=True) + return render_upload_message("链接已过期", copy.attach_h5_expired(), expired=True) diff --git a/inquiry-agent/agent/handlers/attachment_inquiry.py b/inquiry-agent/agent/handlers/attachment_inquiry.py index 6b4f56e..f24b075 100644 --- a/inquiry-agent/agent/handlers/attachment_inquiry.py +++ b/inquiry-agent/agent/handlers/attachment_inquiry.py @@ -207,7 +207,7 @@ def join_attachment_image( if not wave.ack_sent: msg_store.enqueue_outbound( touser=message.sender_id, - content=copy.IMAGE_RECOGNIZING, + content=copy.image_recognizing(), dedupe_key=f"attach-ack:{wave.wave_id}", ) book.mark_ack(message.sender_id, "image") @@ -258,7 +258,7 @@ def accept_h5_uploads( book = get_attach_wave_book() wave = book.active(sid, now=stamp) if wave is None: - return "expired", copy.ATTACH_H5_EXPIRED + return "expired", copy.attach_h5_expired() incoming = [(str(n or "").strip(), raw or b"") for n, raw in files if str(n or "").strip()] if not incoming: return "need_file", copy.ATTACH_H5_NEED_FILE @@ -268,12 +268,12 @@ def accept_h5_uploads( start_index = len(wave.materials) wave = book.add_files(sid, [n for n, _r in incoming], now=stamp) if wave is None: - return "expired", copy.ATTACH_H5_EXPIRED + return "expired", copy.attach_h5_expired() msg_store = store or get_message_store() if not wave.ack_sent: msg_store.enqueue_outbound( touser=sid, - content=copy.ATTACH_RECOGNIZING, + content=copy.attach_recognizing(), dedupe_key=f"attach-ack:{wave.wave_id}", ) book.mark_ack(sid, "file") @@ -304,8 +304,8 @@ def accept_h5_uploads( wave = book.active(sid, now=stamp) if process_now and wave is not None and book.pending(wave) <= 0: finish_attach_wave(sid, store=msg_store) - return "done", copy.ATTACH_H5_OK - return "recognizing", copy.ATTACH_H5_OK + return "done", copy.attach_h5_ok() + return "recognizing", copy.attach_h5_ok() def finish_queued_attachment(payload: dict[str, Any]) -> dict[str, Any]: @@ -403,7 +403,7 @@ def finish_attach_wave( oral = book.compose_oral(sender_id) reason = "" if not oral.strip(): - reason = copy.ATTACH_TECH_FAIL if book.has_errors(sender_id) else copy.ATTACH_UNREADABLE + reason = copy.attach_tech_fail() if book.has_errors(sender_id) else copy.ATTACH_UNREADABLE msg_store = store or get_message_store() prefix_used = {"v": False} @@ -441,7 +441,7 @@ def finish_attach_wave( if reason and not prefix_used["v"]: msg_store.enqueue_outbound( touser=sender_id, - content=copy.image_fail_then_clarify(reason, copy.ASK_TRANSPORT), + content=copy.image_fail_then_clarify(reason, copy.ask_transport()), dedupe_key=f"attach-empty:{(wave.wave_id if wave else inbound.message_id)}", ) phase = "need_mode" diff --git a/inquiry-agent/agent/handlers/image_inquiry.py b/inquiry-agent/agent/handlers/image_inquiry.py index f3fe231..7067f41 100644 --- a/inquiry-agent/agent/handlers/image_inquiry.py +++ b/inquiry-agent/agent/handlers/image_inquiry.py @@ -79,7 +79,7 @@ def handle_image_inquiry( drop_all_private_bookmarks(message.sender_id) msg_store.enqueue_outbound( touser=message.sender_id, - content=copy.IMAGE_RECOGNIZING, + content=copy.image_recognizing(), dedupe_key=f"img-ack:{wave.wave_id}", ) book.mark_ack(message.sender_id) @@ -341,7 +341,7 @@ def finish_wave( oral = book.compose_oral(sender_id) reason = "" if not oral.strip(): - reason = copy.IMAGE_TECH_FAIL if (wave and wave.errors) else copy.IMAGE_UNREADABLE + reason = copy.image_tech_fail() if (wave and wave.errors) else copy.IMAGE_UNREADABLE msg_store = store or get_message_store() prefix_used = {"v": False} @@ -373,7 +373,7 @@ def finish_wave( if reason and not prefix_used["v"]: msg_store.enqueue_outbound( touser=sender_id, - content=copy.image_fail_then_clarify(reason, copy.ASK_TRANSPORT), + content=copy.image_fail_then_clarify(reason, copy.ask_transport()), dedupe_key=f"img-empty:{inbound.message_id}", ) phase = "need_mode" diff --git a/inquiry-agent/agent/handlers/text_inquiry.py b/inquiry-agent/agent/handlers/text_inquiry.py index 429f92b..a14dccc 100644 --- a/inquiry-agent/agent/handlers/text_inquiry.py +++ b/inquiry-agent/agent/handlers/text_inquiry.py @@ -478,18 +478,7 @@ def handle_text_other(message: InboundMessage, *, store=None) -> str: logger.info("text_other 陆运确定/重查,改走询价流程 sender=%s", message.sender_id) return handle_text_inquiry(message, store=msg_store) - from agent.schema.land_options import looks_like_confirm - - if looks_like_confirm(message.content or ""): - body = copy.LAND_CONFIRM_EXPIRED - msg_store.enqueue_outbound( - touser=message.sender_id, - content=body, - dedupe_key=f"land-confirm-expired:{message.message_id}", - ) - return "land_confirm_expired" - - body = copy.FIRST_CHAT + body = copy.first_chat() msg_store.enqueue_outbound( touser=message.sender_id, content=body, diff --git a/inquiry-agent/agent/ledger/api_shell.py b/inquiry-agent/agent/ledger/api_shell.py index 8ffdfc7..d00eb5f 100644 --- a/inquiry-agent/agent/ledger/api_shell.py +++ b/inquiry-agent/agent/ledger/api_shell.py @@ -103,6 +103,15 @@ class LedgerApiShell: params={"enabledOnly": str(bool(enabled_only)).lower()}, ) + def list_bot_replies(self) -> LedgerResponse: + """ + 已启用机器人回复语。 + + 正式路径:GET /inquiry/agent/botReply/listEnabled + 只读;停用项不在列表里。 + """ + return self.client.request_json("GET", "/inquiry/agent/botReply/listEnabled") + def wake_agent_ack( self, *, diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index d98f907..cd0388b 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -484,7 +484,7 @@ def _send_air_activate_sheet( return err = str(painted.get("error") or "") if err == "NO_TEMPLATE": - send_group_text(client, chat_id, painted.get("message") or copy.QUOTE_TEMPLATE_MISSING) + send_group_text(client, chat_id, painted.get("message") or copy.quote_template_missing()) return if err in {"no_renderer_or_chat", "no_file_client"}: send_group_text(client, chat_id, copy.group_sheet_send_fail()) diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index 286ca08..e491cb5 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -418,21 +418,21 @@ class AirTextInquiryFlow: hit.business_line = str(view.get("business_line") or "").strip().upper() if hit is None: if not callable(getter): - reply(f"找不到工单 {no},请核对工单号后再试。") + reply(copy.continue_ticket_not_found(no)) return "ticket_missing" if not view.get("found"): - reply(f"找不到工单 {no},请核对工单号后再试。") + reply(copy.continue_ticket_not_found(no)) return "ticket_missing" line = str(view.get("business_line") or "").strip().upper() owner = self._handoff_owner_for_line(line) if owner is None: - reply(copy.ASK_TRANSPORT) + reply(copy.ask_transport()) return "need_mode" hit = owner._session_from_ledger(sender_id=sender_id, view=view) line = (hit.business_line or "").strip().upper() owner = self._handoff_owner_for_line(line) if owner is None: - reply(copy.ASK_TRANSPORT) + reply(copy.ask_transport()) return "need_mode" logger.info( "handoff.named wo=%s line=%s phase=%s chat=%s", @@ -474,7 +474,7 @@ class AirTextInquiryFlow: mode = (sess.business_line if sess else "") or detect_transport_mode(text) or "" mode = (mode or "").upper() if not mode: - reply(copy.ASK_TRANSPORT) + reply(copy.ask_transport()) self._save( FlowSession( sender_id=sender_id, @@ -538,7 +538,7 @@ class AirTextInquiryFlow: sess.business_line = "SEA" line = (sess.business_line or "").upper() if has_collab_group(sess): - reply(copy.HANDOFF_ALREADY_COLLAB) + reply(copy.already_collab()) return "handoff_already" why = copy.HANDOFF_CLARIFY_REASON if reason == "clarify" else copy.HANDOFF_INTENT_REASON if line == "AIR": @@ -711,7 +711,7 @@ class AirTextInquiryFlow: """ no = (work_order_no or "").strip().upper() if not no: - reply("请告诉我要继续的工单号,例如 WO202609140034。") + reply(copy.continue_need_wo()) return "need_work_order" with self._lock: sess = self._by_ticket.get(no) @@ -730,11 +730,11 @@ class AirTextInquiryFlow: return self._replay_wait(keep, reply, source="session") getter = getattr(self._ledger, "get_for_agent", None) if not callable(getter): - reply(f"找不到工单 {no},请核对工单号后再试。") + reply(copy.continue_ticket_not_found(no)) return "ticket_missing" view = getter(work_order_no=no, sender_id=sender_id) or {} if not view.get("found"): - reply(f"找不到工单 {no},请核对工单号后再试。") + reply(copy.continue_ticket_not_found(no)) return "ticket_missing" if view.get("owned") is False: reply("这张工单不属于当前账号,不能继续。") @@ -879,7 +879,7 @@ class AirTextInquiryFlow: if sess.phase == "wait_file": self._refresh_wait_file(sess) if sess.phase == "wait_file": - reply("报价单正在生成,请稍候。") + reply(copy.quote_generating()) return "wait_file" if sess.phase == "wait_deal": # 成交卡已经绑在报价文件那条出站上。只续办、没说成交结果时不再另发一张卡。 @@ -964,9 +964,8 @@ class AirTextInquiryFlow: reply=reply, sess=sess, ) - if (not sess or sess.phase != "wait_confirm") and looks_like_confirm(text) and not sess: - reply(copy.AIR_CONFIRM_EXPIRED) - return "confirm_expired" + # 核对卡不再设时效。没有待确定会话时不当成「已过期」, + # 让本句按新询价走,避免正文里的「确认」误伤。 sess = self._detach_ticketed_for_new_inquiry(sess) snap = extract_inquiry_snapshot( @@ -1006,7 +1005,7 @@ class AirTextInquiryFlow: ) if not mode: - reply(copy.ASK_TRANSPORT) + reply(copy.ask_transport()) self._save( FlowSession( sender_id=sender_id, @@ -1055,7 +1054,7 @@ class AirTextInquiryFlow: chat_fn=chat_fn, inbound_raw=inbound_raw, ) - reply(copy.ASK_TRANSPORT) + reply(copy.ask_transport()) return "need_mode" if is_waiting_mode_session(sess): diff --git a/inquiry-agent/agent/policy/bot_reply.py b/inquiry-agent/agent/policy/bot_reply.py new file mode 100644 index 0000000..8023b60 --- /dev/null +++ b/inquiry-agent/agent/policy/bot_reply.py @@ -0,0 +1,156 @@ +""" +后台机器人回复语:按 reply_code 取正文。 + +本文件职责:短缓存主账已启用列表,把 {{ticket_no}} 等变量填进模板。 +禁止:当六态;禁止回调线程同步等完整 LLM;失败必须回退 inquiry_copy 默认句。 +线程:进程内锁刷新;不同工单可读同一份缓存。 +""" + +from __future__ import annotations + +import logging +import os +import re +import threading +import time +from typing import Any, Optional + +logger = logging.getLogger(__name__) + +# 后台改文案后最多这么久出现在销售侧;不当 checkpoint。 +_TTL_SECONDS = 60.0 +_PLACEHOLDER = re.compile(r"\{\{\s*([a-zA-Z0-9_]+)\s*\}\}") + +_lock = threading.Lock() +_loaded_at = 0.0 +_by_code: dict[str, str] = {} +# 单测注入;None 表示走主账/空缓存。 +_override: Optional[dict[str, str]] = None + + +def override_catalog(mapping: Optional[dict[str, str]]) -> None: + """ + 单测注入调用编码 → 正文。传 None 清掉注入。 + + 副作用:清空加载时间,下次 render 重新取。不打主账。 + """ + global _override, _loaded_at, _by_code + with _lock: + _override = dict(mapping) if mapping is not None else None + _loaded_at = 0.0 + if mapping is None: + _by_code = {} + + +def fill(template: str, values: dict[str, Any] | None = None) -> str: + """把 {{ticket_no}} 换成实值;未提供的占位原样保留。""" + src = dict(values or {}) + + def _repl(match: re.Match[str]) -> str: + key = match.group(1) + if key not in src: + return match.group(0) + raw = src.get(key) + if raw is None: + return "" + return str(raw) + + return _PLACEHOLDER.sub(_repl, template or "") + + +def render(code: str, default: str, **kwargs: Any) -> str: + """ + 取已启用回复语并填变量。 + + 停用、库空、主账失败、编码对不上:返回 default(已是拼好的默认句)。 + 副作用:缓存过期时 GET 主账;超时/失败吞掉,不抛给销售。 + """ + key = (code or "").strip() + fallback = default if default is not None else "" + if not key: + return fallback + tpl = _lookup(key) + if not (tpl or "").strip(): + return fallback + return fill(tpl, kwargs) + + +def _lookup(code: str) -> str: + """读缓存;过期再拉主账。override 优先。""" + global _loaded_at, _by_code + with _lock: + if _override is not None: + return str(_override.get(code) or "") + now = time.monotonic() + if _loaded_at > 0 and (now - _loaded_at) < _TTL_SECONDS: + return str(_by_code.get(code) or "") + fresh = _fetch() + with _lock: + if _override is not None: + return str(_override.get(code) or "") + if fresh is not None: + _by_code = fresh + _loaded_at = time.monotonic() + elif not _by_code: + _loaded_at = time.monotonic() + return str(_by_code.get(code) or "") + + +def _fetch() -> Optional[dict[str, str]]: + """ + GET /inquiry/agent/botReply/listEnabled。 + + 未配主账地址视为空,返回 {{}} 以免每次空打。失败返回 None 以保留旧缓存。 + """ + try: + from agent.config import get_settings + + cfg = get_settings() + backend = str( + os.environ.get("LEDGER_BACKEND") or getattr(cfg, "ledger_backend", "") or "" + ).strip().lower() + if backend in {"memory", "mem", "stub"}: + return {} + if not str(getattr(cfg, "biz_api_base_url", "") or "").strip(): + return {} + from agent.ledger import LedgerClient + from agent.ledger.api_shell import LedgerApiShell + + client = LedgerClient.from_settings() + client.timeout_seconds = min(float(client.timeout_seconds or 10), 3.0) + resp = LedgerApiShell(client=client).list_bot_replies() + if not resp.ok: + logger.warning("bot_reply listEnabled fail status=%s err=%s", resp.status_code, resp.error) + return None + rows = _unwrap_rows(resp.data) + out: dict[str, str] = {} + for row in rows: + if not isinstance(row, dict): + continue + code = str(row.get("replyCode") or row.get("reply_code") or "").strip() + text = str(row.get("replyText") or row.get("reply_text") or "") + if code and text.strip(): + out[code] = text + return out + except Exception: + logger.warning("bot_reply listEnabled 异常,回退默认句", exc_info=True) + return None + + +def _unwrap_rows(data: Any) -> list[Any]: + """解开 Jeecg Result 列表。""" + if isinstance(data, list): + return data + if not isinstance(data, dict): + return [] + inner = data.get("result") + if isinstance(inner, list): + return inner + if isinstance(inner, dict): + rec = inner.get("records") + if isinstance(rec, list): + return rec + rec = data.get("records") or data.get("data") + if isinstance(rec, list): + return rec + return [] diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 4b27d66..471731d 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -2,7 +2,8 @@ 空运文字询价出站话术(销售可见)。 本文件职责:集中文案;按钮字面量必须与 handler 精确匹配。 -禁止:在本文件调主账/TMS;禁止把密钥写进话术。 +销售可见固定句经 bot_reply 按调用编码读后台;停用/失败回退本文件默认句。 +禁止:在本文件直接 request 主账;禁止把密钥写进话术。 """ from __future__ import annotations @@ -27,6 +28,17 @@ from agent.schema.field_validate import ( from agent.schema.land_options import option_prompt as _land_option_prompt from agent.schema.quote_adjust import total_price_label +def _r(code: str, default: str, **kwargs: Any) -> str: + """ + 按后台调用编码取回复语。 + + 停用、库空、主账失败返回 default。本函数不打主账,只调 bot_reply 短缓存。 + """ + from agent.policy.bot_reply import render + + return render(code, default, **kwargs) + + # 空运补问:必填 7 项 + 非必填。报价日期默认当天,不进待补充。 _AIR_EXAMPLES = { "起运港": "南京", @@ -50,9 +62,11 @@ _AIR_OPTIONAL_CLARIFY = tuple( (key, label, _AIR_EXAMPLES.get(key, "")) for key, label in AIR_OPTIONAL_FIELDS ) AIR_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。" +# 核对卡已取消时效,不再对销售发送此句;常量仅保留避免旧引用报错。 AIR_CONFIRM_EXPIRED = "刚才的空运核对已过期,请把空运需求再发一次,出核对卡后再回复确定。" # 海运私聊与空运同一句。核对卡没有工单号,回复确定后才建单查价。 SEA_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。" +# 核对卡已取消时效,不再对销售发送此句。 SEA_CONFIRM_EXPIRED = "刚才的海运核对已过期,请把海运需求再发一次,出核对卡后再回复确定。" # 海运补问:与合同 10 项一致。报价日期默认当天,不进待补充。 @@ -125,10 +139,79 @@ ASK_TRANSPORT = ( "2.海运\n" "3.空运" ) + + +def ask_transport() -> str: + """未识别运输方式。后台 inquiring.ask_transport。""" + return _r("inquiring.ask_transport", ASK_TRANSPORT) + + +def first_chat() -> str: + """销售首次单聊。后台 inquiring.first_chat。""" + return _r("inquiring.first_chat", FIRST_CHAT) + + +def image_recognizing() -> str: + """正在识别图片。后台 inquiring.image_recognizing。""" + return _r("inquiring.image_recognizing", IMAGE_RECOGNIZING) + + +def image_tech_fail() -> str: + """图片技术失败。后台 inquiring.image_tech_fail。""" + return _r("inquiring.image_tech_fail", IMAGE_TECH_FAIL) + + +def attach_recognizing() -> str: + """正在识别附件。后台 inquiring.attach_recognizing。""" + return _r("inquiring.attach_recognizing", ATTACH_RECOGNIZING) + + +def attach_tech_fail() -> str: + """附件技术失败。后台 inquiring.attach_tech_fail。""" + return _r("inquiring.attach_tech_fail", ATTACH_TECH_FAIL) + + +def attach_h5_expired() -> str: + """H5 上传过期。后台 inquiring.attach_h5_expired。""" + return _r("inquiring.attach_h5_expired", ATTACH_H5_EXPIRED) + + +def attach_h5_ok() -> str: + """H5 上传成功。后台 inquiring.attach_h5_ok。""" + return _r("inquiring.attach_h5_ok", ATTACH_H5_OK) + + +def continue_need_wo() -> str: + """续办但未提供工单号。后台 inquiring.continue_need_wo。""" + return _r("inquiring.continue_need_wo", "请告诉我要继续的工单号,例如 WO202609140034。") + + +def continue_ticket_not_found(work_order_no: str) -> str: + """续办找不到工单。后台 inquiring.continue_not_found(工单号后有空格)。""" + no = (work_order_no or "").strip() + return _r( + "inquiring.continue_not_found", + f"找不到工单 {no},请核对工单号后再试。", + ticket_no=no, + ) + + +def quote_generating() -> str: + """报价单生成中。后台 inquiring.quote_generating。""" + return _r("inquiring.quote_generating", "报价单正在生成,请稍候。") + + # 待补充清单:请销售整段复制,只在冒号后填值;示例行不算已填。 CLARIFY_PASTE_HINT = "请复制粘贴以下内容,通过回复数字或文字方式,告知我以下字段值:" _PENDING_UNITS = {} HANDOFF_ALREADY_COLLAB = "该工单已拉产品进群协同。" + + +def already_collab() -> str: + """该工单已拉过产品进群。后台 transfer.already_collab。""" + return _r("transfer.already_collab", HANDOFF_ALREADY_COLLAB) + + HANDOFF_CLARIFY_REASON = "参数提取不全(连续 3 次反问仍不完整)" HANDOFF_INTENT_REASON = "已识别您希望转人工处理" CLARIFY_LIMIT_HINT = "连续 3 次反问仍不完整将触发转人工" @@ -214,6 +297,7 @@ def land_sample_image_fallback() -> str: FIRST_CHAT = "你好,我是询价机器人。请直接描述客户需求,例如「上海到洛杉矶空运,3 件 180kg,下周三前到」,我会解析需求并查询 TMS 标准报价。" ROUTE_MODEL_FAILED = "刚才没听清,请再说一次:是新询价,还是要接着办哪张工单?" LAND_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。" +# 核对卡已取消时效。仅工单号丢失时内部兜底,不对销售当「过期」主路径。 LAND_CONFIRM_EXPIRED = "刚才的陆运核对已过期,请把陆运需求再发一次,出核对卡后再回复确定。" LAND_TMS_RETRY = "工单 {wo} 上次查价没成功。回复「确定」按原需求再查一次,不用重新填。" LAND_TMS_RETRYING = "正在重新查询TMS报价。工单:{wo}" @@ -274,8 +358,11 @@ def land_confirm_card(facts: dict[str, str] | None) -> str: def land_group_no_staff() -> str: - """没有对上线路的陆运产品,不建空群。""" - return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的陆运产品岗后再试。" + """没有对上线路的陆运产品,不建空群。后台 inquiring.land_no_staff。""" + return _r( + "inquiring.land_no_staff", + "没有可拉的产品人员。请先在后台「员工管理」维护本线路的陆运产品岗后再试。", + ) def land_group_pair_rejected() -> str: @@ -1354,8 +1441,13 @@ def skip_collab_card_update(*, response_code: str, replace_name: str = "") -> di def tms_miss_quote_text(*, work_order_no: str) -> str: - """文字兜底里的拉群句,与现网图一致。""" - return f"请手动拉相关人员和智能体到原有群,并在群聊发送工单编号{work_order_no}" + """请手动拉人进群并发送工单号。后台 inquiring.pull_group_manual。""" + no = (work_order_no or "").strip() + return _r( + "inquiring.pull_group_manual", + f"请手动拉相关人员和智能体到原有群,并在群聊发送工单编号{no}", + ticket_no=no, + ) def tms_miss_hint() -> str: @@ -1438,7 +1530,11 @@ def tms_need_more(*, work_order_no: str, hint: str = "") -> str: def skip_collab_ack() -> str: - return "好的,已跳过内部协同。正在按 TMS 标准报价生成报价单草稿…" + """销售跳过内部协同。后台 inquiring.skip_collab_ack。""" + return _r( + "inquiring.skip_collab_ack", + "好的,已跳过内部协同。正在按 TMS 标准报价生成报价单草稿…", + ) def sea_collab_ask() -> str: @@ -1584,13 +1680,13 @@ def sea_tms_miss_wecom_payload(*, work_order_no: str, action_url: str = "") -> d MULTI_CONFIRM_TAIL = "请回复「确定」创建工单,内容有误时,请重新发内容。" MULTI_SAME_MODE = "同一运输方式出现了两段,请拆开再发。" +# 核对卡已取消时效,不再对销售发送此句。 MULTI_CONFIRM_EXPIRED = "刚才的多段核对已过期,请把多段需求再发一次,出核对后再回复确定。" def multi_air_manual_hint(*, work_order_no: str) -> str: - """含空运的多段:不自动拉群,请销售把人和智能体拉进原有群并发送工单号。""" - no = (work_order_no or "").strip() - return f"请手动拉相关人员和智能体到原有群,并在群聊发送工单编号{no}" + """含空运的多段:不自动拉群。与 inquiring.pull_group_manual 同一条后台。""" + return tms_miss_quote_text(work_order_no=work_order_no) def multi_pull_text(*, work_order_no: str) -> str: @@ -1629,11 +1725,16 @@ def multi_pull_wecom_payload(*, work_order_no: str) -> dict[str, Any]: def handoff_air_hint(*, work_order_no: str) -> str: - """空运转人工:只发文字,不建群。""" + """空运转人工:只发文字,不建群。后台 transfer.air_hint。""" no = (work_order_no or "").strip() if no: - return f"触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号 {no}。" - return "触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号。" + default = f"触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号 {no}。" + return _r("transfer.air_hint", default, ticket_no=no) + return _r( + "transfer.air_hint", + "触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号。", + ticket_no="", + ) def handoff_offer_text(*, reason: str, transport_mode: str) -> str: @@ -1729,27 +1830,45 @@ def _role_label(name: str, role: str) -> str: def sea_group_created(*, work_order_no: str, sales_name: str, product_names: list[str]) -> str: - """拉群成功后的私聊。已邀请写成「销售某某、产品某某」,岗位不能省略。""" + """拉群成功后的私聊。后台 inquiring.group_created。已邀请写成「销售某某、产品某某」。""" no = (work_order_no or "").strip() - sales = _role_label(sales_name, "销售") - products = "、".join( - _role_label(n, "产品") for n in (product_names or []) if (n or "").strip() - ) or "产品" - return ( + sales_raw = (sales_name or "").strip() + sales = _role_label(sales_raw, "销售") + name_bits = [n.strip() for n in (product_names or []) if (n or "").strip()] + products = "、".join(_role_label(n, "产品") for n in name_bits) or "产品" + default = ( f"协同群已创建,群名与工单号一致:{no}。" f"已邀请:{sales}、{products}。请在群聊中继续确认报价。" ) + sales_var = sales_raw[2:] if sales_raw.startswith("销售") else sales_raw + prod_var = "、".join( + (n[2:] if n.startswith("产品") else n) for n in name_bits + ) or "产品" + return _r( + "inquiring.group_created", + default, + ticket_no=no, + sales_name=sales_var, + product_names=prod_var, + ) def sea_group_exists(*, work_order_no: str) -> str: - """同一工单已有群,不再新建。""" + """同一工单已有群,不再新建。后台 inquiring.group_exists。""" no = (work_order_no or "").strip() - return f"协同群已在,群名与工单号一致:{no}。请到该群继续确认报价。" + return _r( + "inquiring.group_exists", + f"协同群已在,群名与工单号一致:{no}。请到该群继续确认报价。", + ticket_no=no, + ) def sea_group_no_staff() -> str: - """没有可拉的产品人员,不建空群。""" - return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的海运产品岗后再试。" + """没有可拉的产品人员,不建空群。后台 inquiring.sea_no_staff。""" + return _r( + "inquiring.sea_no_staff", + "没有可拉的产品人员。请先在后台「员工管理」维护本线路的海运产品岗后再试。", + ) def group_sheet_send_fail() -> str: @@ -1758,11 +1877,10 @@ def group_sheet_send_fail() -> str: def sea_group_create_fail(*, reason: str = "") -> str: - """建群失败,按钮保持可点。""" + """建群失败,按钮保持可点。后台 inquiring.group_create_fail(无原因短句)。""" extra = (reason or "").strip() - if extra: - return f"建群失败:{extra}。请稍后重试。" - return "建群失败,请稍后重试。" + default = f"建群失败:{extra}。请稍后重试。" if extra else "建群失败,请稍后重试。" + return _r("inquiring.group_create_fail", default, reason=extra) def quote_is_product(quote: dict[str, Any] | None) -> bool: @@ -2212,7 +2330,11 @@ def scheme_wecom_payload( def reject_ack() -> str: - return "请问如何调整报价单,以满足您的需求?请说明要调哪一项费用,例如「提货费加 10%」。" + """销售选择不采用。后台 quoted.reject_ack。""" + return _r( + "quoted.reject_ack", + "请问如何调整报价单,以满足您的需求?请说明要调哪一项费用,例如「提货费加 10%」。", + ) def adjust_confirm(applied: list[dict[str, Any]]) -> str: @@ -2278,23 +2400,32 @@ def adjust_group_body(quote: dict[str, Any] | None, business_line: str = "") -> def adopt_excel_ack() -> str: - return "已确认采用该方案,正在生成 Excel 最终报价单…" + """采用并生成 Excel。后台 quoted.adopt_excel。""" + return _r("quoted.adopt_excel", "已确认采用该方案,正在生成 Excel 最终报价单…") def adopt_pdf_ack() -> str: - return "已确认采用该方案,正在生成PDF最终报价单…" + """采用并生成 PDF。后台 quoted.adopt_pdf。""" + return _r("quoted.adopt_pdf", "已确认采用该方案,正在生成PDF最终报价单…") QUOTE_TEMPLATE_MISSING = "生成报价单失败,请联系IT运维维护后台报价模板。" def quote_template_missing() -> str: - """关键词与默认模板都未维护时的销售提示(产品口径,勿改字)。""" - return QUOTE_TEMPLATE_MISSING + """关键词与默认模板都未维护。后台 quoted.template_missing。""" + return _r("quoted.template_missing", QUOTE_TEMPLATE_MISSING) def file_ready(*, kind: str, work_order_no: str, filename: str) -> str: - return f"已生成{kind}最终报价单:{filename}(工单 {work_order_no})" + """最终报价单生成完成。后台 quoted.file_ready。""" + return _r( + "quoted.file_ready", + f"已生成{kind}最终报价单:{filename}(工单 {work_order_no})", + kind=kind, + filename=filename, + ticket_no=work_order_no, + ) # 未成交原因说明跟在未成交后,不跟在协商中后。验收脚本认这个开关。 @@ -2429,7 +2560,11 @@ def group_only_excel_pdf() -> str: def group_only_product_can_quote() -> str: - return "只有产品能报价格,请产品在群里发价格或 Excel/PDF" + """非产品人员在群里报价。后台 inquiring.group_only_product。""" + return _r( + "inquiring.group_only_product", + "只有产品能报价格,请产品在群里发价格或 Excel/PDF", + ) _GROUP_QUOTE_TAIL = ( @@ -2464,14 +2599,24 @@ def group_no_tms_quote_hint(*, business_line: str = "") -> str: def quote_attachment_saved_app(work_order_no: str, count: int = 1) -> str: - """群里告诉大家:模板或产品附件已进后台。""" + """ + 群里告诉大家:模板或产品附件已进后台。 + + 后台 inquiring.group_file_saved。默认句带工单号和份数;后台改成短句则原样用。 + """ no = (work_order_no or "").strip() or "WO" n = count if count > 0 else 1 - return f"工单{no}的{n}个附件已保存到后台工单" + return _r( + "inquiring.group_file_saved", + f"工单{no}的{n}个附件已保存到后台工单", + ticket_no=no, + count=n, + ) -def group_confirm_need_tms() -> str: - return "本单还没有可沿用的 TMS 报价,请直接回复协同报价或上传报价附件。" +def group_file_saved(*, work_order_no: str = "", count: int = 1) -> str: + """群附件已保存。与 quote_attachment_saved_app 同一条后台编码。""" + return quote_attachment_saved_app(work_order_no, count) def group_ask_tms_or_product() -> str: @@ -2490,8 +2635,8 @@ def group_need_price_before_choice() -> str: return "请产品先发价格,再说在TMS基础上改或不用TMS报价" -def group_file_saved() -> str: - return "附件已保存后台" +def group_confirm_need_tms() -> str: + return "本单还没有可沿用的 TMS 报价,请直接回复协同报价或上传报价附件。" def group_quote_not_recognized() -> str: @@ -2561,18 +2706,26 @@ def deal_ack( next_followup_at: str = "", lost_reason: str = "", ) -> str: - """成交三态回执。协商中写明顺延三个工作日和下次再问的月日 9:00。""" + """成交三态回执。后台 deal.recorded_won / lost.ask_reason / negotiating.recorded。""" + no = (work_order_no or "").strip() if outcome == "已成交": - return f"已记录为「已成交」。工单 {work_order_no} 已关闭智能体跟进。" + return _r( + "deal.recorded_won", + f"已记录为「已成交」。工单 {no} 已关闭智能体跟进。", + ticket_no=no, + ) if outcome == "未成交": reason = (lost_reason or "").strip() if reason: return f"已记录为「未成交」。{lost_reason_ack(reason)}" - return "已记录为「未成交」。请补充未成交原因。" - when = _followup_month_day(next_followup_at) - if when: - return f"已记录为「协商中」。已顺延三个工作日,{when}的9:00再次发起成交状态询问。" - return "已记录为「协商中」。已顺延三个工作日,第四个工作日早上9:00再次发起成交状态询问。" + return _r("lost.ask_reason", "已记录为「未成交」。请补充未成交原因。", ticket_no=no) + when = _followup_month_day(next_followup_at) or "第四个工作日" + return _r( + "negotiating.recorded", + f"已记录为「协商中」。已顺延三个工作日,{when}的9:00再次发起成交状态询问。", + followup_when=when, + ticket_no=no, + ) def _followup_month_day(raw: str) -> str: @@ -2592,11 +2745,13 @@ def _followup_month_day(raw: str) -> str: def lost_reason_ack(reason: str = "") -> str: - """收下未成交原因后回显原句,不再追问。""" + """收下未成交原因后回显原句。后台 lost.reason_ack。""" text = (reason or "").strip() if text.endswith(("。", ".", "!", "!", "?", "?")): - return f"已记下未成交原因:{text}" - return f"已记下未成交原因:{text}。" if text else "已记下未成交原因。" + default = f"已记下未成交原因:{text}" + else: + default = f"已记下未成交原因:{text}。" if text else "已记下未成交原因。" + return _r("lost.reason_ack", default, lost_reason=text) def deal_notify_card( @@ -2679,29 +2834,49 @@ def air_need_work_order() -> str: def air_lock_need_work_order() -> str: - """锁舱/释放必须自带工单号,不猜当前单。""" - return "锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。" + """锁舱/释放必须自带工单号。后台 inquiring.air_lock_need_wo。""" + return _r( + "inquiring.air_lock_need_wo", + "锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。", + ) def air_ticket_not_found(work_order_no: str) -> str: + """空运群找不到工单。后台 inquiring.air_ticket_not_found。""" no = (work_order_no or "").strip() - return f"找不到工单{no},请核对工单号后再试。" + return _r("inquiring.air_ticket_not_found", f"找不到工单{no},请核对工单号后再试。", ticket_no=no) def air_ticket_not_air(work_order_no: str) -> str: + """工单不是空运单。后台 inquiring.air_ticket_not_air。""" no = (work_order_no or "").strip() - return f"工单{no}不是空运单,不能在空运群里跟。" + return _r( + "inquiring.air_ticket_not_air", + f"工单{no}不是空运单,不能在空运群里跟。", + ticket_no=no, + ) def air_ticket_closed(work_order_no: str, status: str) -> str: + """空运工单已是终态仍要激活。后台 closed.air_cannot_activate。""" no = (work_order_no or "").strip() st = (status or "").strip() or "已关闭" - return f"工单{no}已是「{st}」,不能再激活。" + return _r( + "closed.air_cannot_activate", + f"工单{no}已是「{st}」,不能再激活。", + ticket_no=no, + status=st, + ) def air_ticket_occupied(work_order_no: str) -> str: + """空运工单已在另一个群。后台 inquiring.air_ticket_occupied。""" no = (work_order_no or "").strip() - return f"工单{no}已经在另一个群跟了,请到那个群继续。" + return _r( + "inquiring.air_ticket_occupied", + f"工单{no}已经在另一个群跟了,请到那个群继续。", + ticket_no=no, + ) def air_group_brief( @@ -2820,7 +2995,11 @@ def air_airline_only_quote() -> str: def air_airline_only_cabin() -> str: - return "只有岗位为「航线·空运」的人能锁舱或释放舱位。" + """只有航线能锁舱或释放。后台 inquiring.air_airline_only_cabin。""" + return _r( + "inquiring.air_airline_only_cabin", + "只有岗位为「航线·空运」的人能锁舱或释放舱位。", + ) def air_sales_only_deal() -> str: @@ -2844,9 +3023,13 @@ def air_no_tms_quote_to_lock(work_order_no: str) -> str: def air_lock_need_quote(work_order_no: str) -> str: - """没有任何报价时的锁舱拒绝。不提 TMS,避免把人工报价说成没报价。""" + """没有任何报价时的锁舱拒绝。后台 inquiring.air_lock_need_quote。""" no = (work_order_no or "").strip() - return f"工单{no}还没有报价,不能锁舱。" + return _r( + "inquiring.air_lock_need_quote", + f"工单{no}还没有报价,不能锁舱。", + ticket_no=no, + ) def air_ask_lock_option(options: list) -> str: @@ -2869,23 +3052,41 @@ def air_lock_ok( exec_no: str, quote_no: str, ) -> str: - return ( + """空运锁舱成功。后台 inquiring.air_lock_ok。""" + default = ( f"工单{work_order_no} 已通知TMS执行空运锁舱,执行成功。\n" f"操作人:{operator}\n" f"舱位选项:{option_no}\n" f"执行编号:{exec_no}\n" f"报价编号:{quote_no}" ) + return _r( + "inquiring.air_lock_ok", + default, + ticket_no=work_order_no, + operator=operator, + option_no=option_no, + exec_no=exec_no, + quote_no=quote_no, + ) def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str: + """空运锁舱失败。后台 inquiring.air_lock_fail。""" why = (reason or "").strip() or "TMS未返回原因" - return ( + default = ( f"工单{work_order_no} 空运锁舱失败。\n" f"操作人:{operator}\n" f"锁舱失败的原因:{why}\n" "已通知IT运维排查,请稍等。" ) + return _r( + "inquiring.air_lock_fail", + default, + ticket_no=work_order_no, + operator=operator, + reason=why, + ) def air_cabin_fail_reason(*, tms_reason: str = "", flight_date: str = "", today: str = "") -> str: @@ -2907,33 +3108,61 @@ def air_release_ok( exec_no: str, quote_no: str, ) -> str: - return ( + """空运释放成功。后台 inquiring.air_release_ok。""" + default = ( f"工单{work_order_no} 已通知TMS执行空运释放舱位成功\n" f"操作人:{operator}\n" f"舱位选项:{option_no}\n" f"执行编号:{exec_no}\n" f"报价编号:{quote_no}" ) + return _r( + "inquiring.air_release_ok", + default, + ticket_no=work_order_no, + operator=operator, + option_no=option_no, + exec_no=exec_no, + quote_no=quote_no, + ) def air_release_fail(*, work_order_no: str, operator: str, reason: str) -> str: + """空运释放失败。后台 inquiring.air_release_fail。""" why = (reason or "").strip() or "TMS未返回原因" - return ( + default = ( f"工单{work_order_no} 空运释放舱位失败。\n" f"操作人:{operator}\n" f"释放舱位失败的原因:{why}\n" "已通知IT运维排查,请稍等。" ) + return _r( + "inquiring.air_release_fail", + default, + ticket_no=work_order_no, + operator=operator, + reason=why, + ) def air_release_deal_blocked(work_order_no: str) -> str: + """已成交不能释放舱位。后台 inquiring.air_release_deal_blocked。""" no = (work_order_no or "").strip() - return f"释放舱位失败,工单{no}已成交。" + return _r( + "inquiring.air_release_deal_blocked", + f"释放舱位失败,工单{no}已成交。", + ticket_no=no, + ) def air_release_need_lock(work_order_no: str) -> str: + """没有有效锁舱不能释放。后台 inquiring.air_release_need_lock。""" no = (work_order_no or "").strip() - return f"工单{no}还没有有效锁舱,不能释放。" + return _r( + "inquiring.air_release_need_lock", + f"工单{no}还没有有效锁舱,不能释放。", + ticket_no=no, + ) # —— 系统异常(UC024):销售/群兜底与恢复;告警卡只给有权限的人 —— @@ -2966,29 +3195,34 @@ IMPACT_GROUP = "当前建群/加人暂停,已建群不新建第二个群" def system_exception_fallback(exception_type: str, *, names: list[str] | None = None) -> str: - """判定系统异常后,给卡住的会话的兜底话术。""" + """判定系统异常后,给卡住的会话的兜底话术。后台 sys.llm_fail / sys.tms_fail / sys.group_add_fail。""" kind = (exception_type or "").strip() if kind == TYPE_LLM: - return SYS_EXC_LLM_FALLBACK + return _r("sys.llm_fail", SYS_EXC_LLM_FALLBACK) if kind == TYPE_GROUP: people = [str(x).strip() for x in (names or []) if str(x).strip()] - if not people: - return "无法添加同事,请联系管理员检查企业微信配置。" joined = "、".join(people) - return f"无法添加 {joined} 同事,请联系管理员检查企业微信配置。" - return SYS_EXC_TMS_FALLBACK + if joined: + default = f"无法添加 {joined} 同事,请联系管理员检查企业微信配置。" + else: + default = "无法添加同事,请联系管理员检查企业微信配置。" + text = _r("sys.group_add_fail", default, staff_name=joined) + if not joined: + text = re.sub(r"无法添加\s+同事", "无法添加同事", text) + return text + return _r("sys.tms_fail", SYS_EXC_TMS_FALLBACK) def system_exception_recovery(step: str) -> str: """后台点重试成功后,先发这一句,再补没做完的事。""" key = (step or "").strip() if key == STEP_LLM or key == STEP_LLM_NO_TICKET: - return SYS_EXC_RECOVERY_LLM + return _r("sys.llm_recovery", SYS_EXC_RECOVERY_LLM) if key in {STEP_TMS_CABIN, STEP_TMS_LOCK, STEP_TMS_RELEASE}: - return SYS_EXC_RECOVERY_CABIN + return _r("sys.cabin_recovery", SYS_EXC_RECOVERY_CABIN) if key == STEP_GROUP: - return SYS_EXC_RECOVERY_GROUP - return SYS_EXC_RECOVERY_TMS + return _r("sys.group_recovery", SYS_EXC_RECOVERY_GROUP) + return _r("sys.tms_recovery", SYS_EXC_RECOVERY_TMS) def system_exception_impact(step: str) -> str: diff --git a/inquiry-agent/agent/policy/land_text_flow.py b/inquiry-agent/agent/policy/land_text_flow.py index 527b3de..fd37821 100644 --- a/inquiry-agent/agent/policy/land_text_flow.py +++ b/inquiry-agent/agent/policy/land_text_flow.py @@ -174,7 +174,7 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): if not mode: mode = "LAND" if (sess and (sess.business_line or "").upper() == "LAND") else "" if not mode: - reply(copy.ASK_TRANSPORT) + reply(copy.ask_transport()) self._save( FlowSession( sender_id=sender_id, @@ -364,8 +364,9 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): no = (sess.work_order_no or "").strip() facts = dict(sess.facts or {}) if not no or not facts: - reply(copy.LAND_CONFIRM_EXPIRED) - return "land_confirm_expired" + # 核对卡已取消时效,禁止再回「陆运核对已过期」。 + logger.warning("land.retry 缺工单或字段,不提示过期") + return "land_retry_missing" reply(copy.LAND_TMS_RETRYING.format(wo=no)) from agent.policy.system_exception import query_tms_resilient diff --git a/inquiry-agent/agent/policy/multi_text_flow.py b/inquiry-agent/agent/policy/multi_text_flow.py index fc9f008..1586d73 100644 --- a/inquiry-agent/agent/policy/multi_text_flow.py +++ b/inquiry-agent/agent/policy/multi_text_flow.py @@ -256,9 +256,7 @@ class MultiTextInquiryFlow: allow_b=allow_b, chat_fn=chat_fn, ) - if sess and sess.phase == "wait_confirm": - reply(copy.MULTI_CONFIRM_EXPIRED) - return "multi_confirm_expired" + # 核对卡不再设时效。走到这里说明不是「确定」也不是补字段。 reply(copy.MULTI_SAME_MODE if verdict == "same_mode" else "请按段发多段需求,例如段1海运、段2空运。") return "multi_need_text" diff --git a/inquiry-agent/agent/policy/sea_text_flow.py b/inquiry-agent/agent/policy/sea_text_flow.py index 619c3a2..ea21992 100644 --- a/inquiry-agent/agent/policy/sea_text_flow.py +++ b/inquiry-agent/agent/policy/sea_text_flow.py @@ -120,9 +120,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): reply=reply, sess=sess, ) - if not sess and looks_like_confirm(text): - reply(copy.SEA_CONFIRM_EXPIRED) - return "confirm_expired" + # 核对卡不再设时效。没有待确定会话时不当成「已过期」。 # 上一单已出号:新一轮从本句重收,禁止把旧贸易条款/分类带进下一张工单。 sess = self._detach_ticketed_for_new_inquiry(sess) @@ -159,7 +157,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): mode = detect_transport_mode(f"{sess.immutable_text or ''}\n{text}") if not mode: - reply(copy.ASK_TRANSPORT) + reply(copy.ask_transport()) self._save( FlowSession( sender_id=sender_id, @@ -888,7 +886,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): ) if sess.collab_ended or (sess.collab_chat_id or "").strip(): - reply(copy.HANDOFF_ALREADY_COLLAB) + reply(copy.already_collab()) return "handoff_already" display_no = ensure_virtual_no(sess) if not (sess.work_order_no or "").strip() else sess.work_order_no staff = self._match_handoff_staff(sess) diff --git a/inquiry-agent/agent/schema/land_options.py b/inquiry-agent/agent/schema/land_options.py index 72e9ca6..275226e 100644 --- a/inquiry-agent/agent/schema/land_options.py +++ b/inquiry-agent/agent/schema/land_options.py @@ -504,12 +504,20 @@ def looks_like_confirm(text: str) -> bool: """ 判断是否在表示确定查价。 - 「确定」「确认」「查价」「可以查了」及相近说法都算。 - 同一句像在改始发/目的/体积/车型/「改成」则不当确定,避免改内容被当成查价。 + 「确定」「确认」「查价」「可以查了」及相近短句才算。 + 长询价里「需确认/请确认」不算点核对卡,避免误回「核对已过期」。 + 同一句像在改始发/目的/体积/车型/「改成」则不当确定。 + 纯函数,无副作用。 """ raw = (text or "").strip() if not raw: return False if any(x in raw for x in _CONFIRM_EXCLUDE): return False - return any(p in raw for p in _CONFIRM_PHRASES) + # 超过约 20 字一定是需求正文,不是「确定」短答。 + if len(raw) > 20: + return False + compact = re.sub(r"[\s。!?!?,,、]+", "", raw) + if not compact: + return False + return any(p in compact for p in _CONFIRM_PHRASES) diff --git a/inquiry-agent/tests/test_bot_reply.py b/inquiry-agent/tests/test_bot_reply.py new file mode 100644 index 0000000..519978f --- /dev/null +++ b/inquiry-agent/tests/test_bot_reply.py @@ -0,0 +1,97 @@ +"""后台回复语:按调用编码填句,失败回退默认。""" + +from __future__ import annotations + +import os +import sys +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +os.environ.setdefault("LEDGER_BACKEND", "memory") + +from agent.policy import inquiry_copy as copy +from agent.policy.bot_reply import fill, override_catalog, render + + +class BotReplyRenderTests(unittest.TestCase): + def tearDown(self) -> None: + override_catalog(None) + + def test_fill_placeholder(self) -> None: + self.assertEqual(fill("工单{{ticket_no}}。", {"ticket_no": "WO1"}), "工单WO1。") + self.assertEqual(fill("工单{{ ticket_no }}。", {"ticket_no": "WO1"}), "工单WO1。") + self.assertIn("{{lost_reason}}", fill("因{{lost_reason}}", {})) + + def test_default_when_empty(self) -> None: + override_catalog({}) + self.assertEqual(copy.first_chat(), copy.FIRST_CHAT) + self.assertEqual(copy.ask_transport(), copy.ASK_TRANSPORT) + self.assertEqual(copy.deal_ack("未成交", "WO1"), "已记录为「未成交」。请补充未成交原因。") + + def test_override_first_chat(self) -> None: + override_catalog({"inquiring.first_chat": "你好,测试问候。"}) + self.assertEqual(copy.first_chat(), "你好,测试问候。") + self.assertEqual(copy.FIRST_CHAT, "你好,我是询价机器人。请直接描述客户需求,例如「上海到洛杉矶空运,3 件 180kg,下周三前到」,我会解析需求并查询 TMS 标准报价。") + + def test_group_created_fills_names(self) -> None: + override_catalog( + { + "inquiring.group_created": ( + "协同群已创建,群名与工单号一致:{{ticket_no}}。" + "已邀请:销售{{sales_name}}、产品{{product_names}}。请在群聊中继续确认报价。" + ) + } + ) + text = copy.sea_group_created( + work_order_no="WO202610060001", + sales_name="张三", + product_names=["李四"], + ) + self.assertIn("WO202610060001", text) + self.assertIn("销售张三", text) + self.assertIn("产品李四", text) + self.assertNotIn("销售销售", text) + + def test_disabled_falls_back(self) -> None: + override_catalog({"quoted.adopt_excel": ""}) + self.assertEqual(copy.adopt_excel_ack(), "已确认采用该方案,正在生成 Excel 最终报价单…") + + def test_group_file_saved_uses_admin(self) -> None: + override_catalog({"inquiring.group_file_saved": "附件已保存后台"}) + self.assertEqual( + copy.quote_attachment_saved_app("WO202610060001", 2), + "附件已保存后台", + ) + override_catalog({}) + self.assertEqual( + copy.quote_attachment_saved_app("WO202610060001", 2), + "工单WO202610060001的2个附件已保存到后台工单", + ) + + def test_negotiate_without_date_still_reads_admin(self) -> None: + override_catalog( + { + "negotiating.recorded": ( + "已记录为「协商中」。已顺延三个工作日,{{followup_when}}的9:00再次发起成交状态询问。" + ) + } + ) + text = copy.deal_ack("协商中", "WO1", next_followup_at="") + self.assertIn("第四个工作日", text) + self.assertIn("9:00", text) + + def test_group_add_fail_empty_names_reads_admin(self) -> None: + override_catalog( + {"sys.group_add_fail": "无法添加 {{staff_name}} 同事,请联系管理员检查企业微信配置。"} + ) + text = copy.system_exception_fallback(copy.TYPE_GROUP, names=[]) + self.assertEqual(text, "无法添加同事,请联系管理员检查企业微信配置。") + named = copy.system_exception_fallback(copy.TYPE_GROUP, names=["张三"]) + self.assertIn("张三", named) + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-agent/tests/test_land_options.py b/inquiry-agent/tests/test_land_options.py index a0e3a10..7d8dd9b 100644 --- a/inquiry-agent/tests/test_land_options.py +++ b/inquiry-agent/tests/test_land_options.py @@ -220,9 +220,16 @@ class LandOptionsTest(unittest.TestCase): def test_confirm_intent(self): self.assertTrue(looks_like_confirm("确认")) + self.assertTrue(looks_like_confirm("确定")) self.assertTrue(looks_like_confirm("可以查了")) self.assertTrue(looks_like_confirm("查价")) self.assertFalse(looks_like_confirm("广州到深圳衣服")) + # 询价正文里的「确认」不是点核对卡。 + self.assertFalse( + looks_like_confirm( + "服务器、不带电,预计7月初货好,因货值较高 派送需确认安全运输方案,比如押运。" + ) + ) if __name__ == "__main__": diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java index f27843a..971bc50 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java @@ -44,12 +44,28 @@ public class InquiryAgentController { @Autowired private org.jeecg.modules.inquiry.tms.InquiryTmsCabinService cabinService; + @Autowired + private org.jeecg.modules.inquiry.service.IInquiryBotReplyService botReplyService; + @Value("${inquiry.agent.callback-token:${AGENT_CALLBACK_TOKEN:}}") private String agentToken; @Value("${inquiry.agent.biz-token:${BIZ_API_TOKEN:}}") private String bizToken; + /** + * 智能体只读回复语。停用或不在列表里的编码由智能体回退默认句。 + * 鉴权同其它 /inquiry/agent/** ,禁止后台菜单权限码。 + */ + @ApiOperation("智能体-已启用回复语") + @GetMapping("/botReply/listEnabled") + public Result>> botReplyListEnabled(HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + return Result.OK(botReplyService.listEnabledForAgent()); + } + @ApiOperation("智能体-回写模板 AI 映射结果") @PutMapping("/quoteTemplate/parseResult") public Result quoteTemplateParseResult(@RequestBody Map body, diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryBotReplyController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryBotReplyController.java index a2dd9ff..253118f 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryBotReplyController.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryBotReplyController.java @@ -21,7 +21,7 @@ import java.util.List; /** * 机器人回复语 API。 * 支持按分类拉取全部条目(后台九分类页)与分页查询。 - * 调用编码 replyCode 给智能体日后按码取文案;本期只维护、不向智能体下发。 + * 调用编码 replyCode 供智能体 GET /inquiry/agent/botReply/listEnabled 按码取文案。 */ @Slf4j @Api(tags = "询价-机器人回复语") diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java index dbdef8f..538bdfa 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java @@ -123,7 +123,7 @@ public class InquiryTicket implements Serializable { private List saleLines; /** - * 利润点逐行。不入库。没调过价为空。最后一行是整单利润点。 + * 利润点逐行。不入库。没调过价每项都是 0%。最后一行是整单利润点。 */ @TableField(exist = false) @ApiModelProperty("利润点逐行(展示)") diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryBotReplyService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryBotReplyService.java index 8abaec7..98edda4 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryBotReplyService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryBotReplyService.java @@ -22,4 +22,10 @@ public interface IInquiryBotReplyService extends IService { * 编辑正文/启停。已有调用编码不可被请求覆盖。 */ void updateReply(InquiryBotReply body); + + /** + * 智能体只读:已启用且已有调用编码的正文。 + * 停用项不返回,智能体回退默认句。 + */ + java.util.List> listEnabledForAgent(); } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryBotReplyServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryBotReplyServiceImpl.java index 9a89d78..a780b2a 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryBotReplyServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryBotReplyServiceImpl.java @@ -9,7 +9,13 @@ import org.jeecg.modules.inquiry.service.IInquiryBotReplyService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; + +import java.util.ArrayList; import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import java.util.regex.Pattern; /** @@ -90,6 +96,35 @@ public class InquiryBotReplyServiceImpl extends ServiceImpl> listEnabledForAgent() { + QueryWrapper qw = new QueryWrapper<>(); + qw.eq("enabled", 1); + qw.isNotNull("reply_code"); + qw.ne("reply_code", ""); + qw.orderByAsc("sort_no").orderByAsc("id"); + Page page = new Page<>(1, 2000); + page.setSearchCount(false); + page.setMaxLimit(2000L); + List rows = page(page, qw).getRecords(); + List> out = new ArrayList<>(); + if (rows == null) { + return out; + } + for (InquiryBotReply row : rows) { + if (row == null || StringUtils.isBlank(row.getReplyCode())) { + continue; + } + Map item = new LinkedHashMap<>(); + item.put("replyCode", row.getReplyCode()); + item.put("replyText", row.getReplyText()); + item.put("triggerScene", row.getTriggerScene()); + item.put("categoryCode", row.getCategoryCode()); + out.add(item); + } + return out; + } + private void assertCodeUnique(String replyCode, String excludeId) { if (StringUtils.isBlank(replyCode)) { return; diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java index bed9e9a..d1a456e 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java @@ -686,7 +686,7 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService { return; } String username = StringUtils.trimToEmpty(login.getUsername()); - String wecom = StringUtils.trimToEmpty(login.getWorkNo()); + String wecom = dashboardWecomId(login); qw.and(w -> { boolean any = false; if (StringUtils.isNotBlank(wecom)) { @@ -734,4 +734,20 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService { return false; } } + + /** + * 看板「只看自己」用的企微工号。 + * 不用直接调 getWorkNo:测服打包时基座 LoginUser 可能还没有这个方法。 + */ + private static String dashboardWecomId(LoginUser login) { + if (login == null) { + return ""; + } + try { + Object v = login.getClass().getMethod("getWorkNo").invoke(login); + return v == null ? "" : StringUtils.trimToEmpty(String.valueOf(v)); + } catch (ReflectiveOperationException ignored) { + return ""; + } + } } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryQuoteLineSupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryQuoteLineSupport.java index edc0aa9..8c06074 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryQuoteLineSupport.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryQuoteLineSupport.java @@ -19,7 +19,9 @@ import java.util.List; *

* 职责:从已保存的报价明细铺开展示。不改调价算法,不把各行金额加总冒充费用合计。 * 海运/陆运:成本是调价前生效价(手工同名盖 TMS,多出来的补上);销售是最新调价。 - * 空运:成本整列空;销售是当前生效价;利润点只在调过价之后,对调价前销售价。 + * 空运:成本整列空;销售是当前生效价。 + * 未调价:各项利润点与费用合计都是 0%,禁止拿金额去填利润点列。 + * 已调价:利润点对调价前同名行。 * 副作用:改 ticket 的三列逐行;手工盖价算出新的官方总价时,同时改成本价和利润点字符串。不写库。 * 调用:{@link InquiryTicketPriceSupport#apply} 末尾。列表 enrich 与保存报价后的 update 都走那里。 */ @@ -126,7 +128,7 @@ public final class InquiryQuoteLineSupport { profitTotal = StringUtils.defaultString(InquiryTicketPriceSupport.profitText(saleTotal, baseTotal)); ticket.setProfitRate(profitTotal); } else if (!adjusted) { - ticket.setProfitRate(""); + ticket.setProfitRate("0%"); } if (air) { @@ -134,13 +136,13 @@ public final class InquiryQuoteLineSupport { ticket.setSaleLines(moneyLines(saleFees, saleTotal)); ticket.setProfitLines(adjusted && StringUtils.isNotBlank(profitTotal) ? profitAgainst(baselineFees, saleFees, profitTotal) - : Collections.emptyList()); + : zeroPercentLines(saleFees)); return; } if (!adjusted) { ticket.setCostLines(moneyLines(costFees, costTotal)); ticket.setSaleLines(Collections.emptyList()); - ticket.setProfitLines(Collections.emptyList()); + ticket.setProfitLines(zeroPercentLines(costFees)); return; } List rows = align(costFees, saleFees); @@ -216,6 +218,21 @@ public final class InquiryQuoteLineSupport { return formatMoney(base.add(delta), totalCcy); } + /** + * 未调价:每一项费用和费用合计都写 0%。 + * 列表/详情都走这里,避免利润点列空白或误把金额填进去。 + */ + private static List zeroPercentLines(List fees) { + List lines = new ArrayList(); + if (fees != null) { + for (FeeRow fee : fees) { + lines.add(line(fee.name, "0%")); + } + } + lines.add(line(TOTAL_NAME, "0%")); + return lines; + } + private static List moneyLines(List fees, String totalText) { List lines = new ArrayList(); if (fees != null) { diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketPriceSupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketPriceSupport.java index 2cfb41e..e920c57 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketPriceSupport.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketPriceSupport.java @@ -14,7 +14,8 @@ import java.util.regex.Pattern; * 后台成本价 / 销售价 / 利润点:按业务线从报价记录重算。 *

* 海运/陆运:TMS、产品报价是成本;销售调价后才有销售价,利润点=(销售-成本)/成本。 - * 空运:成本价始终空;TMS/航线/调价都是销售侧;利润点只在销售调过价后算。 + * 空运:成本价始终空;TMS/航线/调价都是销售侧。 + * 未调价时利润点固定 0%(含各项),调过价才按差额算。 * 副作用:只改传入 ticket 的展示字段(三个总价字符串和三列逐行),不写库。调用方决定是否 updateById。 */ public final class InquiryTicketPriceSupport { @@ -63,7 +64,7 @@ public final class InquiryTicketPriceSupport { ticket.setProfitRate(profitText(adjustPrice, baseline)); } else { ticket.setSalePrice(StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice); - ticket.setProfitRate(""); + ticket.setProfitRate("0%"); } } else { String cost = StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice; @@ -73,7 +74,7 @@ public final class InquiryTicketPriceSupport { ticket.setProfitRate(profitText(adjustPrice, cost)); } else { ticket.setSalePrice(""); - ticket.setProfitRate(""); + ticket.setProfitRate("0%"); } } // 三个总价定完后,按同一批报价拆成逐行。有手工明细盖 TMS 时,这里会改写成本价和利润点。 diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java index c130445..bd5b1e7 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java @@ -32,20 +32,21 @@ import java.util.*; public final class QuoteTemplateExcelSupport { // #region agent log - /** 调试会话 2d01eb:空运无 TMS 时「费用名称」被写成起运港。 */ + /** 调试:出单底色。会话 446444=陆运整车顶栏;保留旧路径兼容。 */ private static void agentDbg(String hypothesisId, String location, String message, String dataJson) { try { - String line = "{\"sessionId\":\"2d01eb\",\"runId\":\"post-fix\",\"hypothesisId\":\"" + String line = "{\"sessionId\":\"446444\",\"runId\":\"post-fix\",\"hypothesisId\":\"" + hypothesisId + "\",\"location\":\"" + location + "\",\"message\":\"" + String.valueOf(message).replace("\\", "\\\\").replace("\"", "'") + "\",\"data\":" + (dataJson == null || dataJson.isEmpty() ? "{}" : dataJson) + ",\"timestamp\":" + System.currentTimeMillis() + "}\n"; byte[] bytes = line.getBytes(StandardCharsets.UTF_8); - // 本地调试 + 测服热更后均可落盘(路径不存在则跳过) + // 主账进程账号写不了开发机目录;优先落到 inquiry-new/logs。 String[] paths = { - "d:/own/友通达询价智能体(新)/debug-2d01eb.log", - "C:/Users/ytd_extdev_0903/incoming/debug-2d01eb.log", - System.getProperty("java.io.tmpdir") + "/debug-2d01eb.log" + "E:/wwwroot/inquiry-new/logs/debug-446444.log", + "C:/Users/ytd_extdev_0903/incoming/debug-446444.log", + System.getProperty("java.io.tmpdir") + "/debug-446444.log", + "d:/own/友通达询价智能体(新)/debug-446444.log" }; for (String p : paths) { try { @@ -62,6 +63,59 @@ public final class QuoteTemplateExcelSupport { // 调试日志失败不影响出单 } } + + /** 采样顶栏/运价表头填充色,验证陆运整车蓝底是否被改写。 */ + private static void debugLandBannerColors(Workbook wb, String phase, String sourceHint) { + try { + if (wb == null || wb.getNumberOfSheets() <= 0) { + return; + } + Sheet sheet = wb.getSheetAt(0); + if (sheet == null) { + return; + } + String name = String.valueOf(sheet.getSheetName()); + boolean landish = name.contains("整车") || name.contains("拼车") || name.contains("陆运") + || String.valueOf(sourceHint).contains("整车") || String.valueOf(sourceHint).contains("拼车"); + if (!landish && sheet.getLastRowNum() < 2) { + return; + } + boolean[] keep = markLandHeaderRows(sheet); + StringBuilder sb = new StringBuilder(); + sb.append("{\"phase\":\"").append(phase).append("\",\"sheet\":\"") + .append(name.replace("\"", "'")).append("\",\"src\":\"") + .append(String.valueOf(sourceHint).replace("\\", "/").replace("\"", "'")) + .append("\",\"keep0to3\":["); + for (int r = 0; r <= 3 && r < keep.length; r++) { + if (r > 0) { + sb.append(','); + } + sb.append(keep[r]); + } + sb.append("],\"cells\":["); + int[][] pts = {{0, 1}, {0, 2}, {1, 1}, {1, 2}, {7, 1}, {7, 8}, {8, 3}}; + DataFormatter fmt = new DataFormatter(); + boolean first = true; + for (int[] pt : pts) { + Row row = sheet.getRow(pt[0]); + Cell cell = row == null ? null : row.getCell(pt[1]); + String text = cell == null ? "" : StringUtils.trimToEmpty(fmt.formatCellValue(cell)); + byte[] rgb = readFillRgb(cell); + String hex = rgb == null ? "" : String.format("%02X%02X%02X", rgb[0] & 0xff, rgb[1] & 0xff, rgb[2] & 0xff); + if (!first) { + sb.append(','); + } + first = false; + sb.append("{\"r\":").append(pt[0]).append(",\"c\":").append(pt[1]) + .append(",\"t\":\"").append(text.replace("\"", "'").replace("\\", "/")) + .append("\",\"fill\":\"").append(hex).append("\"}"); + } + sb.append("]}"); + agentDbg("H1", "land.banner." + phase, "land banner fill sample", sb.toString()); + } catch (Exception ignored) { + // 采样失败不影响出单 + } + } // #endregion private static final Set YELLOW_RGB = new HashSet<>(Arrays.asList( @@ -124,6 +178,7 @@ public final class QuoteTemplateExcelSupport { FILL_BY_LABEL.put("体积(CBM)", "volume_cbm"); FILL_BY_LABEL.put("始发地", "origin"); FILL_BY_LABEL.put("始发站", "origin"); + FILL_BY_LABEL.put("起始地", "origin"); FILL_BY_LABEL.put("目的地", "destination"); FILL_BY_LABEL.put("货物品名", "commodity"); // 陆运跨境集拼表头是「货物类型」,与货物品名同一事实码;禁止用「货物品名」前缀误匹配后写进蓝底表头格 @@ -134,6 +189,9 @@ public final class QuoteTemplateExcelSupport { FILL_BY_LABEL.put("运输分类", "运输分类"); FILL_BY_LABEL.put("中港装载方式", "中港装载方式"); FILL_BY_LABEL.put("币别", "currency"); + FILL_BY_LABEL.put("币种", "currency"); + FILL_BY_LABEL.put("燃油附加费率", "fuel_surcharge_rate"); + FILL_BY_LABEL.put("税率", "tax_rate"); FILL_BY_LABEL.put("业务员", "sales_name"); FILL_BY_LABEL.put("客户名称", "customer_name"); FILL_BY_LABEL.put("联系人", "contact_name"); @@ -614,7 +672,13 @@ public final class QuoteTemplateExcelSupport { } JSONObject fieldMeta = mapping.getJSONObject("fieldMeta"); JSONObject fields = mapping.getJSONObject("fields"); + // #region agent log + debugLandBannerColors(wb, "after_open", String.valueOf(sourceXlsx)); + // #endregion LandTemplateLook landLook = snapshotLandTemplateLook(wb); + // #region agent log + debugLandBannerColors(wb, "after_snapshot", String.valueOf(sourceXlsx)); + // #endregion if (fields != null) { // 陆运拼车等模板常把「报价日期」与「有效期」误绑同一格(如 H5「报价日期:」)。 // 同格时只写出报价日期,避免 TMS valid_until 盖掉日期标签含义。 @@ -639,6 +703,11 @@ public final class QuoteTemplateExcelSupport { if ("valid_until".equals(code) && datesShareCell) { continue; } + // 跨境整车国内段/境外段:标量 origin/destination 禁止两段各写一遍完整线路。 + if (("origin".equals(code) || "destination".equals(code)) + && landQuoteHasDomesticAndOverseasSections(wb, mapping)) { + continue; + } boolean afterColon = false; if (fieldMeta != null && fieldMeta.getJSONObject(code) != null) { afterColon = Boolean.TRUE.equals(fieldMeta.getJSONObject(code).getBoolean("sameCellAfterColon")); @@ -678,6 +747,7 @@ public final class QuoteTemplateExcelSupport { continue; } cellRef = redirectLandHeaderBandMapping(wb, cellRef); + cellRef = redirectLandRouteHeaderMapping(wb, cellRef); writeCell(wb, cellRef, String.valueOf(val)); } } @@ -720,13 +790,84 @@ public final class QuoteTemplateExcelSupport { // 无费用明细时:清掉误写入费用名称列的起运港/目的港(WO202609280006) clearPortSpillInFeeNameColumn(wb, facts); // 跨境集拼:列头必须和空白模板一样(蓝底白字)。POI 写回后币种/燃油会变成大红字。 + // #region agent log + debugLandBannerColors(wb, "before_restore", String.valueOf(sourceXlsx)); + // #endregion restoreLandTemplateLook(wb, landLook); + // #region agent log + debugLandBannerColors(wb, "after_restore", String.valueOf(sourceXlsx)); + // #endregion + // 列头盖回去时,数据行若被误当成表头,目的地会再次变成币种那套红字。 + restyleLandCityValues(wb); + // 拼车「元/CBM」整车车型格:跟「始发站」同一浅蓝,禁止盖成顶栏那种深蓝。 + alignLandNestedHeaderFill(wb); + // #region agent log + debugLandBannerColors(wb, "after_align", String.valueOf(sourceXlsx)); + // #endregion // 转 PDF 前把纸张改成 A4 居中,避免顶部裁字、整页偏左。 applyQuotePrintLayout(wb); + // #region agent log + debugLandBannerColors(wb, "after_print", String.valueOf(sourceXlsx)); + // #endregion Files.createDirectories(targetXlsx.getParent()); try (OutputStream out = Files.newOutputStream(targetXlsx)) { wb.write(out); } + // 仅陆运:POI 写盘会把 theme+tint 浅蓝塌成实色深蓝;按空白模板盖回主题蓝底。 + if (!seaTemplate) { + String srcFg = ""; + try { + srcFg = readPrimaryLandThemeFgColor(sourceXlsx); + } catch (Exception ignoredFg) { + srcFg = ""; + } + int themeFixed = restoreLandThemeFills(sourceXlsx, targetXlsx); + String dstTheme = ""; + int dstCollapsed = -1; + int dstMixed = -1; + String fill2Sample = ""; + try { + dstTheme = readPrimaryLandThemeFgColor(targetXlsx); + try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(targetXlsx.toFile())) { + java.util.zip.ZipEntry st = zf.getEntry("xl/styles.xml"); + if (st != null) { + try (InputStream stylesIn = zf.getInputStream(st)) { + String xml = new String(readStreamBytes(stylesIn), StandardCharsets.UTF_8); + dstCollapsed = countLandBlueFillHits(xml); + dstMixed = countMixedThemeRgbFg(xml); + java.util.regex.Matcher fm = java.util.regex.Pattern.compile( + ".*?", java.util.regex.Pattern.DOTALL).matcher(xml); + int fi = 0; + while (fm.find()) { + if (fi == 2) { + java.util.regex.Matcher fg = java.util.regex.Pattern.compile( + "]*/?>").matcher(fm.group()); + if (fg.find()) { + fill2Sample = fg.group().replace("\"", "'"); + } + break; + } + fi++; + } + } + } + } + } catch (Exception ignoredDst) { + // 仅调试 + } + // #region agent log + agentDbg("H7", "land.theme.after_write", "restored land theme fills; strip mixed rgb", + "{\"fixed\":" + themeFixed + + ",\"srcFg\":\"" + String.valueOf(srcFg).replace("\"", "'") + + "\",\"dstTheme\":\"" + String.valueOf(dstTheme).replace("\"", "'") + + "\",\"dstCollapsed\":" + dstCollapsed + + ",\"dstMixed\":" + dstMixed + + ",\"fill2\":\"" + fill2Sample + + "\",\"src\":\"" + String.valueOf(sourceXlsx).replace("\\", "/").replace("\"", "'") + + "\",\"dst\":\"" + String.valueOf(targetXlsx).replace("\\", "/").replace("\"", "'") + + "\"}"); + // #endregion + } } // 只对海运模板做控件包/骨架盖回。空运到港等模板禁止走 WO011,否则标题变成「国际海运询价单」。 if (!seaTemplate) { @@ -2680,14 +2821,18 @@ public final class QuoteTemplateExcelSupport { } // 中港等模板:dataStartRow 常误指「4.2米/3T」车型表头行,下移到真正数据行 rowIdx0 = resolveRouteDataStartRow(sheet, rowIdx0, region.getIntValue("headerRow")); + JSONArray sectionRoutes = routesForLandRouteSection(routes, sheet, region, i, regions.size()); + if (sectionRoutes == null || sectionRoutes.isEmpty()) { + continue; + } int stopRow = findStopRow(sheet, region.getJSONArray("stopAtText"), rowIdx0); int styleCopy = region.getIntValue("styleCopyRows"); if (styleCopy <= 0) { styleCopy = 1; } - ensureRowsBeforeStop(sheet, rowIdx0, routes.size(), stopRow, styleCopy); - for (int r = 0; r < routes.size(); r++) { - JSONObject rowObj = routes.getJSONObject(r); + ensureRowsBeforeStop(sheet, rowIdx0, sectionRoutes.size(), stopRow, styleCopy); + for (int r = 0; r < sectionRoutes.size(); r++) { + JSONObject rowObj = sectionRoutes.getJSONObject(r); int ri = rowIdx0 + r; writeFeeCol(sheet, ri, columns.getString("seq"), String.valueOf(r + 1), yellow, sheet.getSheetName()); writeFeeCol(sheet, ri, columns.getString("origin"), @@ -2696,16 +2841,30 @@ public final class QuoteTemplateExcelSupport { writeFeeCol(sheet, ri, columns.getString("destination"), firstNonBlank(rowObj.getString("destination"), rowObj.getString("destinationCode")), yellow, sheet.getSheetName()); - writeFeeCol(sheet, ri, columns.getString("currency"), rowObj.getString("currency"), yellow, sheet.getSheetName()); + writeFeeCol(sheet, ri, columns.getString("currency"), + firstNonBlank(rowObj.getString("currency"), + facts == null ? null : facts.getString("currency")), + yellow, sheet.getSheetName()); writeFeeCol(sheet, ri, columns.getString("transit_time"), - firstNonBlank(rowObj.getString("transit_time"), rowObj.getString("transitDays")), + firstNonBlank(rowObj.getString("transit_time"), rowObj.getString("transitDays"), + facts == null ? null : facts.getString("transit_time"), + facts == null ? null : facts.getString("transit_days")), yellow, sheet.getSheetName()); writeFeeCol(sheet, ri, columns.getString("remark"), rowObj.getString("remark"), yellow, sheet.getSheetName()); if (columns.containsKey("tax_rate")) { writeFeeCol(sheet, ri, columns.getString("tax_rate"), - firstNonBlank(rowObj.getString("tax_rate"), rowObj.getString("taxRate")), + firstNonBlank(rowObj.getString("tax_rate"), rowObj.getString("taxRate"), + facts == null ? null : facts.getString("tax_rate")), yellow, sheet.getSheetName()); } + if (columns.containsKey("fuel_surcharge_rate")) { + writeFeeCol(sheet, ri, columns.getString("fuel_surcharge_rate"), + firstNonBlank(rowObj.getString("fuel_surcharge_rate"), + rowObj.getString("fuelSurchargeRate"), + facts == null ? null : facts.getString("fuel_surcharge_rate")), + yellow, sheet.getSheetName()); + } + writeLandMatrixSideCols(sheet, ri, region, rowObj, facts, yellow); // 小计/金额列(中港 K 列等) if (columns.containsKey("amount")) { writeFeeCol(sheet, ri, columns.getString("amount"), @@ -2730,6 +2889,334 @@ public final class QuoteTemplateExcelSupport { } } + /** + * 运价表右侧时效、币种、燃油、税率、含税合计:按列头写到数据行。 + * 燃油/税率/含税合计只写行上或 TMS 已有的数,不编造。 + */ + static void writeLandMatrixSideCols(Sheet sheet, int dataRow, JSONObject region, + JSONObject rowObj, JSONObject facts, Set yellow) { + if (sheet == null || rowObj == null || region == null) { + return; + } + int header0 = region.getIntValue("headerRow") - 1; + if (header0 < 0) { + header0 = dataRow - 1; + } + DataFormatter fmt = new DataFormatter(); + int from = Math.max(0, header0 - 1); + int to = header0 + 1; + for (int r = from; r <= to; r++) { + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + short last = row.getLastCellNum(); + for (int c = 0; c < last; c++) { + String text = StringUtils.trimToEmpty(fmt.formatCellValue(row.getCell(c))).replaceAll("\\s+", ""); + if (text.isEmpty()) { + continue; + } + String val = landMatrixSideValue(text, rowObj, facts); + if (StringUtils.isBlank(val) || "-".equals(val)) { + continue; + } + String col = org.apache.poi.ss.util.CellReference.convertNumToColString(c); + writeFeeCol(sheet, dataRow, col, val, yellow, sheet.getSheetName()); + } + } + } + + static String landMatrixSideValue(String header, JSONObject rowObj, JSONObject facts) { + if (header.contains("时效")) { + return firstNonBlank(rowObj.getString("transit_time"), rowObj.getString("transitDays"), + facts == null ? null : facts.getString("transit_time"), + facts == null ? null : facts.getString("transit_days")); + } + if ("币种".equals(header) || "币别".equals(header)) { + return firstNonBlank(rowObj.getString("currency"), + facts == null ? null : facts.getString("currency")); + } + if (header.contains("燃油附加")) { + return firstNonBlank(rowObj.getString("fuel_surcharge_rate"), rowObj.getString("fuelSurchargeRate"), + facts == null ? null : facts.getString("fuel_surcharge_rate")); + } + if ("税率".equals(header)) { + return firstNonBlank(rowObj.getString("tax_rate"), rowObj.getString("taxRate"), + facts == null ? null : facts.getString("tax_rate")); + } + if (header.contains("含税合计") || header.contains("杂费合计")) { + return firstNonBlank(rowObj.getString("amount"), rowObj.getString("salePrice"), + rowObj.getString("costPrice")); + } + return ""; + } + + /** + * 跨境整车同时有「国内段报价」和「境外段报价」时,线路要拆开写。 + */ + static boolean landQuoteHasDomesticAndOverseasSections(Workbook wb, JSONObject mapping) { + if (wb == null) { + return false; + } + JSONArray regions = mapping == null ? null : mapping.getJSONArray("routeRegions"); + boolean sawDomestic = false; + boolean sawOverseas = false; + if (regions != null) { + for (int i = 0; i < regions.size(); i++) { + JSONObject region = regions.getJSONObject(i); + String kind = landRouteSectionKind(null, region); + if ("domestic".equals(kind)) { + sawDomestic = true; + } + if ("overseas".equals(kind)) { + sawOverseas = true; + } + } + } + if (sawDomestic && sawOverseas) { + return true; + } + for (int s = 0; s < wb.getNumberOfSheets(); s++) { + Sheet sheet = wb.getSheetAt(s); + if (sheetHasDomesticAndOverseasSections(sheet)) { + return true; + } + } + return false; + } + + static boolean sheetHasDomesticAndOverseasSections(Sheet sheet) { + if (sheet == null) { + return false; + } + DataFormatter fmt = new DataFormatter(); + boolean domestic = false; + boolean overseas = false; + int last = Math.min(sheet.getLastRowNum(), 80); + for (int r = 0; r <= last; r++) { + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + short lastCol = row.getLastCellNum(); + for (int c = 0; c < lastCol; c++) { + String text = StringUtils.trimToEmpty(fmt.formatCellValue(row.getCell(c))).replaceAll("\\s+", ""); + if (text.contains("国内段")) { + domestic = true; + } + if (text.contains("境外段") || text.contains("国外段")) { + overseas = true; + } + } + } + return domestic && overseas; + } + + /** + * 国内段 / 境外段。认 mapping 标题,再认表上「一、国内段报价」这类分区标题。 + */ + static String landRouteSectionKind(Sheet sheet, JSONObject region) { + String title = ""; + if (region != null) { + title = firstNonBlank(region.getString("title"), region.getString("id"), region.getString("name")); + } + String compact = StringUtils.trimToEmpty(title).replaceAll("\\s+", ""); + if (compact.contains("境外") || compact.contains("国外")) { + return "overseas"; + } + if (compact.contains("国内")) { + return "domestic"; + } + if (sheet == null || region == null) { + return ""; + } + int header0 = region.getIntValue("headerRow") - 1; + if (header0 < 0) { + header0 = region.getIntValue("dataStartRow") - 1; + } + DataFormatter fmt = new DataFormatter(); + for (int r = header0; r >= Math.max(0, header0 - 5); r--) { + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + short lastCol = row.getLastCellNum(); + for (int c = 0; c < lastCol; c++) { + String text = StringUtils.trimToEmpty(fmt.formatCellValue(row.getCell(c))).replaceAll("\\s+", ""); + if (text.contains("境外段") || text.contains("国外段")) { + return "overseas"; + } + if (text.contains("国内段")) { + return "domestic"; + } + } + } + return ""; + } + + static String landRouteSectionKindNearRow(Sheet sheet, int rowIdx) { + if (sheet == null || rowIdx < 0) { + return ""; + } + DataFormatter fmt = new DataFormatter(); + for (int r = rowIdx; r >= Math.max(0, rowIdx - 6); r--) { + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + short lastCol = row.getLastCellNum(); + for (int c = 0; c < lastCol; c++) { + String text = StringUtils.trimToEmpty(fmt.formatCellValue(row.getCell(c))).replaceAll("\\s+", ""); + if (text.contains("境外段") || text.contains("国外段")) { + return "overseas"; + } + if (text.contains("国内段")) { + return "domestic"; + } + } + } + return ""; + } + + /** + * 国内段写境内城市,境外段写境外城市。两段 TMS 腿按腿序号对上。 + * 只有一对郑州→吉隆坡时:国内段只写郑州,境外段只写吉隆坡,不发明口岸。 + */ + static JSONArray routesForLandRouteSection(JSONArray routes, Sheet sheet, JSONObject region, + int regionIndex, int regionCount) { + if (routes == null || routes.isEmpty()) { + return routes; + } + String kind = landRouteSectionKind(sheet, region); + if (StringUtils.isBlank(kind) && regionCount >= 2 && sheetHasDomesticAndOverseasSections(sheet)) { + kind = regionIndex == 0 ? "domestic" : "overseas"; + } + if (StringUtils.isBlank(kind)) { + return routes; + } + JSONArray byLeg = pickLandRouteLegsForSection(routes, kind); + if (byLeg != null && !byLeg.isEmpty()) { + return byLeg; + } + JSONArray sliced = new JSONArray(); + JSONObject one = sliceCrossBorderOd(routes.getJSONObject(0), kind); + if (one != null && (StringUtils.isNotBlank(one.getString("origin")) + || StringUtils.isNotBlank(one.getString("destination")))) { + sliced.add(one); + } + return sliced; + } + + static JSONArray pickLandRouteLegsForSection(JSONArray routes, String kind) { + if (routes == null || routes.size() < 2) { + return null; + } + String seq = StringUtils.trimToEmpty(routes.getJSONObject(0).getString("segmentSequence")); + for (int i = 1; i < routes.size(); i++) { + if (!seq.equals(StringUtils.trimToEmpty(routes.getJSONObject(i).getString("segmentSequence")))) { + return null; + } + } + JSONArray ordered = new JSONArray(); + ordered.addAll(routes); + ordered.sort((a, b) -> { + int la = parseLegSeq((JSONObject) a); + int lb = parseLegSeq((JSONObject) b); + return Integer.compare(la, lb); + }); + JSONArray out = new JSONArray(); + if ("domestic".equals(kind)) { + out.add(ordered.getJSONObject(0)); + } else { + out.add(ordered.getJSONObject(ordered.size() - 1)); + } + return out; + } + + static int parseLegSeq(JSONObject row) { + if (row == null) { + return 0; + } + String raw = firstNonBlank(row.getString("routeLegSequence"), row.getString("legSequence")); + if (StringUtils.isBlank(raw)) { + return 0; + } + try { + return Integer.parseInt(raw.trim()); + } catch (NumberFormatException ex) { + return 0; + } + } + + static JSONObject sliceCrossBorderOd(JSONObject row, String kind) { + if (row == null) { + return null; + } + String origin = firstNonBlank(row.getString("origin"), row.getString("originCode")); + String dest = firstNonBlank(row.getString("destination"), row.getString("destinationCode")); + JSONObject out = new JSONObject(true); + if ("domestic".equals(kind)) { + if (looksDomesticPlace(origin)) { + out.put("origin", origin); + } + if (looksDomesticPlace(dest)) { + out.put("destination", dest); + } + } else if ("overseas".equals(kind)) { + if (looksOverseasPlace(origin)) { + out.put("origin", origin); + } + if (looksOverseasPlace(dest)) { + out.put("destination", dest); + } + } + String ccy = firstNonBlank(row.getString("currency")); + if (StringUtils.isNotBlank(ccy)) { + out.put("currency", ccy); + } + String transit = firstNonBlank(row.getString("transit_time"), row.getString("transitDays")); + if (StringUtils.isNotBlank(transit)) { + out.put("transit_time", transit); + out.put("transitDays", transit); + } + String fuel = firstNonBlank(row.getString("fuel_surcharge_rate"), row.getString("fuelSurchargeRate")); + if (StringUtils.isNotBlank(fuel)) { + out.put("fuel_surcharge_rate", fuel); + } + String tax = firstNonBlank(row.getString("tax_rate"), row.getString("taxRate")); + if (StringUtils.isNotBlank(tax)) { + out.put("tax_rate", tax); + } + return out; + } + + static boolean looksOverseasPlace(String text) { + if (StringUtils.isBlank(text)) { + return false; + } + String t = text.replaceAll("\\s+", ""); + String[] marks = { + "吉隆坡", "胡志明", "河内", "海防", "岘港", "谅山", "同登", "老街", "芒街", + "曼谷", "清迈", "金边", "西哈努克", "新加坡", "雅加达", "万象", "仰光", + "马来", "越南", "泰国", "柬埔寨", "老挝", "缅甸", "印尼", "菲律宾", + "Hồ", "ChiMinh", "Kuala", "Lumpur", "Hanoi", "Bangkok", "Saigon", "Phnom" + }; + String compact = t.replace(" ", ""); + for (String m : marks) { + if (compact.contains(m.replace(" ", ""))) { + return true; + } + } + return false; + } + + static boolean looksDomesticPlace(String text) { + if (StringUtils.isBlank(text) || looksOverseasPlace(text)) { + return false; + } + return true; + } + /** * 线路数据起点:若 mapping 的 dataStartRow 落在「4.2米/3T」或「始发地/元/KG」类表头,下移到空白数据行。 * 跨境集拼:蓝底「始发地 | 目的地 | 2-500KG…」是表头,禁止当第一行数据写(会把蓝底盖成白底并把「始发地」改成城市名)。 @@ -2759,7 +3246,8 @@ public final class QuoteTemplateExcelSupport { } /** - * 陆运线路矩阵表头:同行有「始发地+目的地」,或「元/KG」分档,或燃油/报关费列名。 + * 陆运线路矩阵表头:同行有「始发地/起始地+目的地」,或「元/KG」分档,或燃油/报关费列名。 + * 跨境整车列名是「起始地」,不能只认「始发地」,否则城市会盖掉列名。 */ static boolean rowLooksLikeLandRouteMatrixHeader(Sheet sheet, int rowIdx) { if (sheet == null || rowIdx < 0) { @@ -2781,33 +3269,87 @@ public final class QuoteTemplateExcelSupport { if (text.isEmpty()) { continue; } - if ("始发地".equals(text) || "始发站".equals(text)) { + if (isLandOriginHeaderText(text)) { origin = true; } if ("目的地".equals(text)) { dest = true; } - if (text.contains("元/KG") || text.contains("元/kg") || text.contains("最低收费") + if (text.contains("元/KG") || text.contains("元/kg") || text.contains("元/CBM") + || text.contains("元/cbm") || text.contains("最低收费") || text.contains("燃油附加") || text.contains("报关费") || text.contains("提货费") - || text.contains("口岸") || "税率".equals(text) || "币种".equals(text)) { + || text.contains("口岸") || "税率".equals(text) || "币种".equals(text) + || "送货费".equals(text) || "运输费".equals(text)) { feeHits++; } } return (origin && dest) || feeHits >= 2; } + /** + * 陆运出发地列名:集拼用「始发地」,整车用「起始地」,拼车有时用「始发站」。 + */ + static boolean isLandOriginHeaderText(String text) { + if (StringUtils.isBlank(text)) { + return false; + } + String t = text.replaceAll("\\s+", "").trim(); + return "始发地".equals(t) || "始发站".equals(t) || "起始地".equals(t); + } + + /** + * 写入点若落在「起始地/目的地」列名或车型表头行,下移到第一个数据格。 + * 列名与数据若在同一合并区,必须跨过合并区下沿,不能 resolveMerge 拉回列名再放弃写入。 + */ + static int[] shiftLandRouteWriteDown(Sheet sheet, int rowIdx, int col) { + if (sheet == null || rowIdx < 0 || col < 0) { + return new int[]{rowIdx, col}; + } + for (int i = 0; i < 5; i++) { + int[] merge = resolveMergeOrigin(sheet, rowIdx, col); + Row mrow = sheet.getRow(merge[0]); + Cell mcell = mrow == null ? null : mrow.getCell(merge[1]); + String exist = readCellText(mcell); + boolean originOrDest = isLandOriginHeaderText(exist) || "目的地".equals(StringUtils.trimToEmpty(exist).replaceAll("\\s+", "")); + boolean matrixRow = rowLooksLikeLandRouteMatrixHeader(sheet, rowIdx) + || rowLooksLikeVehicleHeader(sheet, rowIdx) + || rowLooksLikeDeliveryPriceHeader(sheet, rowIdx) + || rowLooksLikeLandRouteMatrixHeader(sheet, merge[0]) + || rowLooksLikeVehicleHeader(sheet, merge[0]); + // 拼车是上下标签栏(始发站、目的地、元/CBM),不能整列往下挪,否则城市会盖掉单价标签。 + if (!matrixRow && !originOrDest) { + return new int[]{rowIdx, col}; + } + if (!matrixRow) { + return new int[]{rowIdx, col}; + } + int last = Math.max(rowIdx, merge[0]); + for (int m = 0; m < sheet.getNumMergedRegions(); m++) { + org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(m); + if (range.isInRange(rowIdx, col) || range.isInRange(merge[0], merge[1])) { + last = Math.max(last, range.getLastRow()); + } + } + rowIdx = last + 1; + } + return new int[]{rowIdx, col}; + } + /** 线路矩阵单个表头格:始发地、目的地、分档单价、费种名。 */ static boolean isLandRouteMatrixHeaderCell(String text) { if (StringUtils.isBlank(text)) { return false; } String t = text.replaceAll("\\s+", "").trim(); - if ("始发地".equals(t) || "始发站".equals(t) || "目的地".equals(t) || "最低收费".equals(t) - || "币种".equals(t) || "税率".equals(t) || "时效".equals(t)) { + if (isLandOriginHeaderText(t) || "目的地".equals(t) || "最低收费".equals(t) + || "币种".equals(t) || "税率".equals(t) || "时效".equals(t) || "路线".equals(t) + || "送货费".equals(t) || "运输费".equals(t) || "备注".equals(t) || t.contains("车型")) { return true; } - return t.contains("元/KG") || t.contains("元/kg") || t.contains("燃油附加") || t.contains("报关费") - || t.contains("提货费") || t.contains("口岸核放"); + return t.contains("元/KG") || t.contains("元/kg") || t.contains("元/CBM") || t.contains("元/cbm") + || t.contains("燃油附加") || t.contains("报关费") + || t.contains("提货费") || t.contains("口岸核放") + || t.contains("米/") || t.contains("尺"); } /** @@ -2849,7 +3391,7 @@ public final class QuoteTemplateExcelSupport { if (isDeliveryPriceHeaderCell(text)) { deliveryLabel = true; } - if ("始发地".equals(text) || "始发站".equals(text) || "目的地".equals(text) + if (isLandOriginHeaderText(text) || "目的地".equals(text) || "币种".equals(text) || "最低收费".equals(text) || text.contains("元/KG") || text.contains("元/kg") || text.contains("报关费") || text.contains("提货费")) { @@ -3335,10 +3877,16 @@ public final class QuoteTemplateExcelSupport { return; } int col = CellReference.convertColStringToIndex(colLetter.trim()); - // 两行合并的列头(燃油附加费率、时效)值在左上角。写到合并区下沿会把列头样式撑乱。 + // 列头行或列名格:改写到下方数据行,不能把「起始地」盖掉后又不写城市。 + int[] shifted = shiftLandRouteWriteDown(sheet, rowIdx, col); + rowIdx = shifted[0]; + col = shifted[1]; int[] origin = resolveMergeOrigin(sheet, rowIdx, col); - rowIdx = origin[0]; - col = origin[1]; + if (!isLandRouteMatrixHeaderCell(readCellText( + sheet.getRow(origin[0]) == null ? null : sheet.getRow(origin[0]).getCell(origin[1])))) { + rowIdx = origin[0]; + col = origin[1]; + } String ref = sheetName + "!" + new CellReference(rowIdx, col).formatAsString(); // #region agent log if (StringUtils.isNotBlank(value) && (value.contains("广州") || "B".equalsIgnoreCase(colLetter.trim()))) { @@ -3445,7 +3993,7 @@ public final class QuoteTemplateExcelSupport { } return header.contains("燃油") || header.contains("覆盖") || header.contains("城市") || header.contains("单价") || header.contains("时效") || header.contains("税率") - || header.contains("始发") || header.contains("目的") || header.contains("备注") + || header.contains("始发") || header.contains("起始") || header.contains("目的") || header.contains("备注") || header.contains("报关") || header.contains("提货") || header.contains("口岸"); } @@ -3453,7 +4001,7 @@ public final class QuoteTemplateExcelSupport { * 运价表「始发地 / 目的地」列:列头文案,或向上几行合并格就是这两个字。 */ static boolean isCityRouteColumn(Sheet sheet, int rowIdx, int col, String header) { - if (header != null && (header.contains("始发") || header.contains("目的"))) { + if (header != null && (header.contains("始发") || header.contains("起始") || header.contains("目的"))) { return true; } if (sheet == null || rowIdx <= 0 || col < 0) { @@ -3467,7 +4015,7 @@ public final class QuoteTemplateExcelSupport { continue; } String text = StringUtils.trimToEmpty(fmt.formatCellValue(row.getCell(origin[1]))).replaceAll("\\s+", ""); - if ("始发地".equals(text) || "始发站".equals(text) || "目的地".equals(text)) { + if (isLandOriginHeaderText(text) || "目的地".equals(text)) { return true; } } @@ -3475,8 +4023,9 @@ public final class QuoteTemplateExcelSupport { } /** - * 城市写在白格里:黑字、垂直居中。 + * 城市写在白格里:黑字、垂直居中,字号跟同行运价格一样。 * 不沿用币种列的红字,也不顶对齐,避免字画进上面的蓝表头。 + * 禁止缩小字体:长地名一缩,目的地会比始发地、公斤价看起来小一号。 * 不改填充和边框,蓝表头本身不动。 */ private static void restyleCityDataCell(Cell cell) { @@ -3484,27 +4033,57 @@ public final class QuoteTemplateExcelSupport { return; } Workbook wb = cell.getSheet().getWorkbook(); - Font old = wb.getFontAt(cell.getCellStyle().getFontIndex()); + Font sample = sampleLandDataFont(cell); CellStyle copy = wb.createCellStyle(); copy.cloneStyleFrom(cell.getCellStyle()); Font black = wb.createFont(); - if (old != null) { - if (StringUtils.isNotBlank(old.getFontName())) { - black.setFontName(old.getFontName()); + if (sample != null) { + if (StringUtils.isNotBlank(sample.getFontName())) { + black.setFontName(sample.getFontName()); } - if (old.getFontHeight() > 0) { - black.setFontHeight(old.getFontHeight()); + if (sample.getFontHeight() > 0) { + black.setFontHeight(sample.getFontHeight()); } - black.setBold(false); } + black.setBold(false); black.setColor(IndexedColors.BLACK.getIndex()); copy.setFont(black); copy.setWrapText(false); - copy.setShrinkToFit(true); + copy.setShrinkToFit(false); copy.setVerticalAlignment(VerticalAlignment.CENTER); cell.setCellStyle(copy); } + /** + * 同行里已经是黑字的格子(公斤价、币种)提供字号和字体名。 + * 城市格原先套着表头红字,直接抄会把 9 号说明字带到目的地。 + */ + private static Font sampleLandDataFont(Cell city) { + if (city == null || city.getRow() == null) { + return null; + } + Workbook wb = city.getSheet().getWorkbook(); + Row row = city.getRow(); + short last = row.getLastCellNum(); + for (int c = 0; c < last; c++) { + if (c == city.getColumnIndex()) { + continue; + } + Cell other = row.getCell(c); + if (other == null || other.getCellStyle() == null) { + continue; + } + if (fontIsRed(other)) { + continue; + } + Font font = wb.getFontAt(other.getCellStyle().getFontIndex()); + if (font != null && font.getFontHeight() > 0) { + return font; + } + } + return wb.getFontAt(city.getCellStyle().getFontIndex()); + } + /** * 列头底色/字色用 RGB 记下来。cloneStyleFrom 会丢掉主题色,运价表会变成「业务员」那种深蓝, * 配送费「燃油附加费率」蓝底也会没。 @@ -3719,9 +4298,6 @@ public final class QuoteTemplateExcelSupport { for (int r = 0; r <= last; r++) { if (rowLooksLikeLandRouteMatrixHeader(sheet, r)) { keep[r] = true; - if (r + 1 < keep.length) { - keep[r + 1] = true; - } } if (rowLooksLikeDeliveryPriceHeader(sheet, r)) { keep[r] = true; @@ -3730,6 +4306,493 @@ public final class QuoteTemplateExcelSupport { return keep; } + /** + * POI 写回 xlsx 会把空白模板的「theme+tint」浅蓝塌成实色(如 7891B0)或偏深主题色。 + * 出单后按源模板 styles.xml 里的主题蓝底,改写出单文件的 fills。 + * 黄底、无填充不动。返回改写条数。 + */ + static int restoreLandThemeFills(Path sourceXlsx, Path targetXlsx) { + if (sourceXlsx == null || targetXlsx == null + || !Files.isRegularFile(sourceXlsx) || !Files.isRegularFile(targetXlsx)) { + return 0; + } + try { + String srcFg = readPrimaryLandThemeFgColor(sourceXlsx); + if (StringUtils.isBlank(srcFg)) { + return 0; + } + byte[] stylesBytes; + try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(targetXlsx.toFile())) { + java.util.zip.ZipEntry st = zf.getEntry("xl/styles.xml"); + if (st == null) { + return 0; + } + try (InputStream in = zf.getInputStream(st)) { + stylesBytes = readStreamBytes(in); + } + } + String xml = new String(stylesBytes, StandardCharsets.UTF_8); + String patched = patchLandBlueFills(xml, srcFg); + if (patched.equals(xml)) { + return 0; + } + // WO006/007:坏 styles(如 //>)会让 WPS「无法打开文件」;校验失败则保留 POI 原件。 + if (!isWellFormedStylesXml(patched)) { + log.warn("restoreLandThemeFills abort: patched styles.xml not well-formed"); + return 0; + } + int fixed = countLandBlueFillHits(xml) - countLandBlueFillHits(patched); + if (fixed < 0) { + fixed = countLandBlueFillHits(xml); + } + // 混合 theme+tint+rgb 必须清掉,否则顶栏仍中蓝 + fixed = Math.max(fixed, countMixedThemeRgbFg(xml) - countMixedThemeRgbFg(patched)); + Path tmp = Files.createTempFile(targetXlsx.getParent(), "land-theme-", ".xlsx"); + try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(targetXlsx.toFile()); + OutputStream fos = Files.newOutputStream(tmp); + java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(fos)) { + java.util.Enumeration en = zf.entries(); + while (en.hasMoreElements()) { + java.util.zip.ZipEntry entry = en.nextElement(); + String name = entry.getName(); + zos.putNextEntry(new java.util.zip.ZipEntry(name)); + if ("xl/styles.xml".equals(name)) { + zos.write(patched.getBytes(StandardCharsets.UTF_8)); + } else { + try (InputStream in = zf.getInputStream(entry)) { + zos.write(readStreamBytes(in)); + } + } + zos.closeEntry(); + } + } + Files.move(tmp, targetXlsx, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + return Math.max(fixed, 1); + } catch (Exception ex) { + log.warn("restoreLandThemeFills failed: {}", ex.getMessage()); + return 0; + } + } + + /** 空白陆运模板里的主题浅蓝 fgColor 属性,如 theme=\"4\" tint=\"0.79...\"。 */ + static String readPrimaryLandThemeFgColor(Path sourceXlsx) throws Exception { + try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(sourceXlsx.toFile())) { + java.util.zip.ZipEntry st = zf.getEntry("xl/styles.xml"); + if (st == null) { + return ""; + } + String xml; + try (InputStream in = zf.getInputStream(st)) { + xml = new String(readStreamBytes(in), StandardCharsets.UTF_8); + } + java.util.regex.Matcher m = java.util.regex.Pattern.compile( + "]*theme=\"(\\d+)\"[^>]*tint=\"([^\"]+)\"[^>]*/?>", + java.util.regex.Pattern.CASE_INSENSITIVE).matcher(xml); + String best = ""; + double bestTint = -1; + while (m.find()) { + String theme = m.group(1); + String tint = m.group(2); + double t; + try { + t = Double.parseDouble(tint); + } catch (Exception e) { + continue; + } + // 浅蓝通常 tint 很高(≈0.8);取最浅的主题填充。 + if (t > bestTint) { + bestTint = t; + best = "theme=\"" + theme + "\" tint=\"" + tint + "\""; + } + } + return best; + } + } + + /** + * 把出单 styles 里塌掉的蓝底改成与空白模板观感一致的浅蓝实色。 + *

+ * 证据链: + * WO004 POI 在 theme+tint 上挂 rgb=5B9BD5 → 客户端忽略 tint,顶栏中蓝; + * WO009 虽已去掉多余 rgb、fill 与模板同为 theme+tint,但 WPS/LibreOffice 仍常忽略 tint, + * 按 accent1 原色画成中蓝。故把「高 tint 的 theme4 浅蓝」落成实色 FFDEEAF6(与 POI 读模板一致)。 + * 不碰黄底;不碰无 tint 的 theme4(深色强调)。 + */ + static String patchLandBlueFills(String stylesXml, String themeFgAttrs) { + if (StringUtils.isBlank(stylesXml)) { + return stylesXml; + } + String lightRgb = resolveLandLightBlueRgb(themeFgAttrs); + String lightFg = ""; + String out = stripRgbFromThemeTintFgColors(stylesXml); + // 兜底:剥 rgb 时若误留双斜杠,改回合法自闭合标签 + out = out.replaceAll("(]*?)//>", "$1/>"); + // 高 tint(≈0.8)的 theme4 = 顶栏/表头浅蓝 → 写成实色,避免客户端忽略 tint + out = out.replaceAll( + "]*theme=\"4\"[^>]*tint=\"0\\.[789][^\"]*\"[^>]*/?>", + lightFg); + out = out.replaceAll( + "]*tint=\"0\\.[789][^\"]*\"[^>]*theme=\"4\"[^>]*/?>", + lightFg); + // POI 塌缩实色 / 误挂主题原色 → 同样盖成浅蓝 + out = out.replaceAll( + "]*rgb=\"(?:FF)?7891B0\"[^>]*/?>", + lightFg); + out = out.replaceAll( + "]*rgb=\"(?:FF)?5B9BD5\"[^>]*/?>", + lightFg); + out = out.replaceAll( + "]*rgb=\"(?:FF)?1F497D\"[^>]*/?>", + lightFg); + out = out.replaceAll( + "]*theme=\"3\"[^>]*tint=\"0\\.39[^\"]*\"[^>]*/?>", + lightFg); + out = out.replaceAll( + "]*tint=\"0\\.39[^\"]*\"[^>]*theme=\"3\"[^>]*/?>", + lightFg); + return out; + } + + /** + * 按模板 tint 把 accent1(#5B9BD5) 算成浅蓝实色;算不出时用 POI 读模板常见值 DEEAF6。 + */ + static String resolveLandLightBlueRgb(String themeFgAttrs) { + double tint = 0.8; + if (StringUtils.isNotBlank(themeFgAttrs)) { + java.util.regex.Matcher m = java.util.regex.Pattern.compile( + "tint=\"([^\"]+)\"").matcher(themeFgAttrs); + if (m.find()) { + try { + tint = Double.parseDouble(m.group(1)); + } catch (Exception ignored) { + tint = 0.8; + } + } + } + // Office 正 tint:向白色插值。5B9BD5 + 0.8 ≈ DEEBF6;POI 整车采样常见 DEEAF6。 + int r = 0x5B + (int) Math.round((255 - 0x5B) * tint); + int g = 0x9B + (int) Math.round((255 - 0x9B) * tint); + int b = 0xD5 + (int) Math.round((255 - 0xD5) * tint); + r = Math.max(0, Math.min(255, r)); + g = Math.max(0, Math.min(255, g)); + b = Math.max(0, Math.min(255, b)); + return String.format("FF%02X%02X%02X", r, g, b); + } + + /** + * 去掉「既有 theme+tint 又有 rgb」的 fgColor 上的 rgb。 + * 模板原件只有 theme+tint;POI 写回后挂上主题原色 rgb,界面就不再按 tint 变浅。 + * 注意:正则捕获组会吃掉自闭合的「/」,拼回时必须去掉残留斜杠,否则会写成 //> 坏标签。 + */ + static String stripRgbFromThemeTintFgColors(String stylesXml) { + if (StringUtils.isBlank(stylesXml)) { + return stylesXml; + } + java.util.regex.Pattern p = java.util.regex.Pattern.compile( + "]*)/?>", + java.util.regex.Pattern.CASE_INSENSITIVE); + java.util.regex.Matcher m = p.matcher(stylesXml); + StringBuffer sb = new StringBuffer(); + while (m.find()) { + String attrs = m.group(1); + boolean hasTheme = attrs.contains("theme="); + boolean hasTint = attrs.contains("tint="); + boolean hasRgb = attrs.toLowerCase().contains("rgb="); + if (hasTheme && hasTint && hasRgb) { + String cleaned = attrs.replaceAll("(?i)\\s*rgb=\"[^\"]+\"", "").replaceAll("/+\\s*$", "").trim(); + m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement("")); + } else { + m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(m.group(0))); + } + } + m.appendTail(sb); + return sb.toString(); + } + + static int countLandBlueFillHits(String stylesXml) { + if (stylesXml == null) { + return 0; + } + int n = 0; + for (String p : new String[] {"7891B0", "DEEAF6", "1F497D", "5B9BD5", "theme=\"3\""}) { + int from = 0; + while (true) { + int i = stylesXml.indexOf(p, from); + if (i < 0) { + break; + } + n++; + from = i + p.length(); + } + } + return n; + } + + /** 统计带 theme+tint+rgb 的混合 fgColor(Excel 会忽略 tint)。 */ + static int countMixedThemeRgbFg(String stylesXml) { + if (stylesXml == null) { + return 0; + } + java.util.regex.Matcher m = java.util.regex.Pattern.compile( + "]*theme=)(?=[^>]*tint=)(?=[^>]*rgb=)[^>]*/?>", + java.util.regex.Pattern.CASE_INSENSITIVE).matcher(stylesXml); + int n = 0; + while (m.find()) { + n++; + } + return n; + } + + /** styles.xml 必须可解析;含 //> 等坏标签时直接拒绝写回。 */ + static boolean isWellFormedStylesXml(String stylesXml) { + if (StringUtils.isBlank(stylesXml) || stylesXml.contains("//>")) { + return false; + } + try { + javax.xml.parsers.DocumentBuilderFactory factory = javax.xml.parsers.DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + javax.xml.parsers.DocumentBuilder builder = factory.newDocumentBuilder(); + builder.parse(new java.io.ByteArrayInputStream(stylesXml.getBytes(StandardCharsets.UTF_8))); + return true; + } catch (Exception ex) { + return false; + } + } + + /** + * 国内拼车单价标签、国内整车车型格:底色跟同一张表「始发站/序号」走。 + * 自己快照主题色会读成顶栏深蓝,出单后和旁边的浅蓝对不上。 + * 项目二黄底、数据行数字不能刷蓝。 + */ + static void alignLandNestedHeaderFill(Workbook wb) { + if (wb == null) { + return; + } + for (int s = 0; s < wb.getNumberOfSheets(); s++) { + Sheet sheet = wb.getSheetAt(s); + if (sheet == null) { + continue; + } + int last = Math.min(sheet.getLastRowNum(), 40); + for (int r = 0; r <= last; r++) { + if (!rowLooksLikeLandUnitSubHeader(sheet, r) && !rowLooksLikeVehicleHeader(sheet, r)) { + continue; + } + byte[] sample = sampleLandMatrixHeaderFill(sheet, r); + if (sample == null || looksYellowRgb(sample)) { + continue; + } + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + short lastCol = row.getLastCellNum(); + for (int c = 0; c < lastCol; c++) { + Cell cell = row.getCell(c); + if (cell == null) { + continue; + } + if (looksYellowRgb(readFillRgb(cell))) { + continue; + } + applyFillRgb(cell, sample); + } + } + } + } + + /** + * 向上找运价表头「始发站/始发地/序号」的浅蓝底。 + */ + static byte[] sampleLandMatrixHeaderFill(Sheet sheet, int fromRow) { + if (sheet == null) { + return null; + } + DataFormatter fmt = new DataFormatter(); + for (int r = fromRow; r >= Math.max(0, fromRow - 4); r--) { + if (!rowLooksLikeLandRouteMatrixHeader(sheet, r) && r != fromRow) { + continue; + } + if (!rowLooksLikeLandRouteMatrixHeader(sheet, r)) { + continue; + } + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + byte[] fallback = null; + for (Cell cell : row) { + if (cell == null) { + continue; + } + String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)).replaceAll("\\s+", ""); + byte[] rgb = readFillRgb(cell); + if (rgb == null || looksYellowRgb(rgb)) { + continue; + } + if (isLandOriginHeaderText(text) || "序号".equals(text) || "目的地".equals(text) + || "提货费".equals(text) || "送货费".equals(text)) { + return rgb; + } + if (fallback == null) { + fallback = rgb; + } + } + if (fallback != null) { + return fallback; + } + } + return null; + } + + static boolean looksYellowRgb(byte[] rgb) { + if (rgb == null || rgb.length < 3) { + return false; + } + int r = rgb[0] & 0xff; + int g = rgb[1] & 0xff; + int b = rgb[2] & 0xff; + return r >= 200 && g >= 180 && b <= 140; + } + + static void applyFillRgb(Cell cell, byte[] fillRgb) { + if (cell == null || fillRgb == null || fillRgb.length < 3) { + return; + } + Workbook wb = cell.getSheet().getWorkbook(); + CellStyle copy = wb.createCellStyle(); + if (cell.getCellStyle() != null) { + copy.cloneStyleFrom(cell.getCellStyle()); + } + if (copy instanceof org.apache.poi.xssf.usermodel.XSSFCellStyle) { + org.apache.poi.xssf.usermodel.XSSFCellStyle xs = (org.apache.poi.xssf.usermodel.XSSFCellStyle) copy; + xs.setFillForegroundColor(new XSSFColor(fillRgb, null)); + xs.setFillPattern(FillPatternType.SOLID_FOREGROUND); + } else { + copy.setFillPattern(FillPatternType.SOLID_FOREGROUND); + } + cell.setCellStyle(copy); + } + + /** + * 国内拼车单价标签行:元/CBM、元/KG。不是始发站那一行。 + */ + static boolean rowLooksLikeLandUnitSubHeader(Sheet sheet, int rowIdx) { + if (sheet == null || rowIdx < 0) { + return false; + } + Row row = sheet.getRow(rowIdx); + if (row == null) { + return false; + } + DataFormatter fmt = new DataFormatter(); + int units = 0; + boolean originDest = false; + for (Cell cell : row) { + if (cell == null) { + continue; + } + String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)).replaceAll("\\s+", ""); + if (text.isEmpty()) { + continue; + } + if (isLandOriginHeaderText(text) || "目的地".equals(text)) { + originDest = true; + } + String u = text.toUpperCase(Locale.ROOT); + if (u.contains("元/CBM") || u.contains("元/KG") || u.contains("元/TON") + || u.contains("/CBM") || u.contains("/KG")) { + units++; + } + } + return units >= 1 && !originDest; + } + + /** 「项目一:零担报价 / 整车报价」「项目二:其它费用」。 */ + static boolean rowLooksLikeLandSectionTitle(Sheet sheet, int rowIdx) { + if (sheet == null || rowIdx < 0) { + return false; + } + Row row = sheet.getRow(rowIdx); + if (row == null) { + return false; + } + DataFormatter fmt = new DataFormatter(); + for (Cell cell : row) { + if (cell == null) { + continue; + } + String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)).replaceAll("\\s+", ""); + if (text.contains("项目一") || text.contains("项目二") || text.contains("零担报价") + || text.contains("整车报价")) { + return true; + } + } + return false; + } + + static boolean rowHasSolidFill(Sheet sheet, int rowIdx) { + if (sheet == null || rowIdx < 0) { + return false; + } + Row row = sheet.getRow(rowIdx); + if (row == null) { + return false; + } + for (Cell cell : row) { + if (cell == null || cell.getCellStyle() == null) { + continue; + } + if (cell.getCellStyle().getFillPattern() == FillPatternType.SOLID_FOREGROUND + && readFillRgb(cell) != null) { + return true; + } + } + return false; + } + + /** + * 运价表始发地/目的地只写黑字。列头样式盖回后若把数据格刷成红字,这里再改回来。 + */ + static void restyleLandCityValues(Workbook wb) { + if (wb == null) { + return; + } + DataFormatter fmt = new DataFormatter(); + for (int s = 0; s < wb.getNumberOfSheets(); s++) { + Sheet sheet = wb.getSheetAt(s); + if (sheet == null) { + continue; + } + int last = Math.min(sheet.getLastRowNum(), 40); + for (int r = 0; r <= last; r++) { + if (rowLooksLikeLandRouteMatrixHeader(sheet, r) || rowLooksLikeDeliveryPriceHeader(sheet, r) + || rowLooksLikeVehicleHeader(sheet, r) || rowLooksLikeLandUnitSubHeader(sheet, r)) { + continue; + } + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + short lastCol = row.getLastCellNum(); + for (int c = 0; c < lastCol; c++) { + Cell cell = row.getCell(c); + if (cell == null) { + continue; + } + String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)); + if (text.isEmpty() || isLandRouteMatrixHeaderCell(text) || isLabelLike(text)) { + continue; + } + if (isCityRouteColumn(sheet, r, c, columnHeaderText(sheet, r, c))) { + restyleCityDataCell(cell); + } + } + } + } + } + /** * 跨境集拼模板里印好的说明和配送时效,不是待填数据。 * 报关费、提货费、口岸、重抛比、当日达被空备注或运价盖掉后,销售对不上原模板。 @@ -4934,6 +5997,7 @@ public final class QuoteTemplateExcelSupport { return; } int[] dest = resolveMergeOrigin(sheet, ref.row, ref.col); + dest = shiftLandRouteWriteDown(sheet, dest[0], dest[1]); Row row = sheet.getRow(dest[0]); if (row == null) { row = sheet.createRow(dest[0]); @@ -4950,7 +6014,9 @@ public final class QuoteTemplateExcelSupport { || isLandInquiryHeaderBandLabel(existing) || isLandRouteMatrixHeaderCell(existing) || isDeliveryPriceHeaderCell(existing) || rowLooksLikeLandRouteMatrixHeader(sheet, dest[0]) - || rowLooksLikeDeliveryPriceHeader(sheet, dest[0])) { + || rowLooksLikeDeliveryPriceHeader(sheet, dest[0]) + || rowLooksLikeVehicleHeader(sheet, dest[0]) + || rowLooksLikeLandUnitSubHeader(sheet, dest[0])) { return; } int colon = Math.max(existing.indexOf(':'), existing.indexOf(':')); @@ -5077,6 +6143,15 @@ public final class QuoteTemplateExcelSupport { // 否则 N+3 会写到时效列右边的空白格,列头样式也被带乱。 if (rowLooksLikeLandRouteMatrixHeader(sheet, rowIdx) || rowLooksLikeDeliveryPriceHeader(sheet, rowIdx)) { + String compact = text.replace(":", "").replace(":", "").replaceAll("\\s+", "").trim(); + if (isLandOriginHeaderText(compact) || "目的地".equals(compact) + || "时效".equals(compact) || "时效(天)".equals(compact) + || "币种".equals(compact) || "币别".equals(compact) + || compact.contains("燃油附加") || "税率".equals(compact) + || compact.contains("含税合计") || compact.contains("杂费合计")) { + int[] at = shiftLandRouteWriteDown(sheet, rowIdx, labelCell.getColumnIndex()); + return sheetName + "!" + new CellReference(at[0], at[1]).formatAsString(); + } return null; } int startCol = labelCell.getColumnIndex() + 1; @@ -5270,6 +6345,28 @@ public final class QuoteTemplateExcelSupport { return StringUtils.isNotBlank(redirected) ? redirected : cellRef; } + /** + * 映射若指到「起始地/目的地」列名,改写到正下方数据格。 + */ + static String redirectLandRouteHeaderMapping(Workbook wb, String cellRef) { + ParsedRef pref = ParsedRef.parse(cellRef); + if (pref == null || wb == null) { + return cellRef; + } + Sheet sh = wb.getSheet(pref.sheet); + if (sh == null && wb.getNumberOfSheets() > 0) { + sh = wb.getSheetAt(0); + } + if (sh == null) { + return cellRef; + } + int[] at = shiftLandRouteWriteDown(sh, pref.row, pref.col); + if (at[0] == pref.row && at[1] == pref.col) { + return cellRef; + } + return sh.getSheetName() + "!" + new CellReference(at[0], at[1]).formatAsString(); + } + /** * 映射点不够时,按标签把询价七项 / 合计写到标签右侧空格。 * 币别若落在费用表头行则跳过,避免 CNY 盖掉 20GP。 @@ -5343,6 +6440,17 @@ public final class QuoteTemplateExcelSupport { if (StringUtils.isBlank(val) || "-".equals(val)) { continue; } + // 运价矩阵的起始地/目的地按国内段、境外段拆开写,禁止同一对城市填进两张表。 + if (("origin".equals(code) || "destination".equals(code)) + && rowLooksLikeLandRouteMatrixHeader(sheet, r)) { + String kind = landRouteSectionKindNearRow(sheet, r); + if ("domestic".equals(kind) && "destination".equals(code) && looksOverseasPlace(val)) { + continue; + } + if ("overseas".equals(kind) && "origin".equals(code) && looksDomesticPlace(val)) { + continue; + } + } String fillRef = resolveFillTarget(sheet, cell, sheetName, text, fmt); if (StringUtils.isBlank(fillRef)) { continue; diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java index 3021806..f8a7cfb 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java @@ -689,7 +689,7 @@ public class InquiryTmsQueryService { /** * 把本次 TMS 报价写回工单主表,供后台列表/详情展示。 - * 海运/陆运进成本价,空运进销售价;利润点必须等销售调价后才算。 + * 海运/陆运进成本价,空运进销售价;未调价利润点为 0%,调过价才按差额算。 */ static void applyQuoteToTicket(InquiryTicket ticket, Map quote) { if (ticket == null || quote == null) { diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryQuoteLineSupportTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryQuoteLineSupportTest.java index 88bbbc1..2a09235 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryQuoteLineSupportTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryQuoteLineSupportTest.java @@ -28,13 +28,16 @@ public class InquiryQuoteLineSupportTest { + "]}"))); assertEquals("CNY 1200.00", ticket.getCostPrice()); assertEquals("", ticket.getSalePrice()); - assertEquals("", ticket.getProfitRate()); + assertEquals("0%", ticket.getProfitRate()); assertLines(ticket.getCostLines(), "运输费", "CNY 1000", "卸货费", "CNY 200", "费用合计", "CNY 1200.00"); assertTrue(ticket.getSaleLines() == null || ticket.getSaleLines().isEmpty()); - assertTrue(ticket.getProfitLines() == null || ticket.getProfitLines().isEmpty()); + assertLines(ticket.getProfitLines(), + "运输费", "0%", + "卸货费", "0%", + "费用合计", "0%"); } @Test @@ -113,7 +116,12 @@ public class InquiryQuoteLineSupportTest { assertTrue(before.getCostLines() == null || before.getCostLines().isEmpty()); assertEquals("", before.getCostPrice()); assertEquals("CNY 2000.00", before.getSalePrice()); - assertTrue(before.getProfitLines() == null || before.getProfitLines().isEmpty()); + assertEquals("0%", before.getProfitRate()); + assertLines(before.getProfitLines(), + "空运费", "0%", + "增点费", "0%", + "拆板费", "0%", + "费用合计", "0%"); assertLines(before.getSaleLines(), "空运费", "CNY 1800", "增点费", "CNY 100", @@ -189,6 +197,11 @@ public class InquiryQuoteLineSupportTest { "费用合计", "CNY 1320.00"); assertEquals("CNY 1320.00", ticket.getCostPrice()); assertTrue(ticket.getSaleLines() == null || ticket.getSaleLines().isEmpty()); + assertLines(ticket.getProfitLines(), + "运输费", "0%", + "卸货费", "0%", + "配送费", "0%", + "费用合计", "0%"); } private static void assertLines(List lines, String... nameText) { diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryTicketPriceSupportTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryTicketPriceSupportTest.java index 84a70f2..83c418a 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryTicketPriceSupportTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryTicketPriceSupportTest.java @@ -21,7 +21,7 @@ public class InquiryTicketPriceSupportTest { InquiryTicketPriceSupport.apply(ticket, Collections.singletonList(quote("TMS", "TMS标准报价", "CNY 121.00"))); assertEquals("CNY 121.00", ticket.getCostPrice()); assertEquals("", ticket.getSalePrice()); - assertEquals("", ticket.getProfitRate()); + assertEquals("0%", ticket.getProfitRate()); } @Test @@ -53,7 +53,7 @@ public class InquiryTicketPriceSupportTest { InquiryTicketPriceSupport.apply(ticket, Collections.singletonList(quote("TMS", "TMS标准报价", "USD 1280"))); assertEquals("", ticket.getCostPrice()); assertEquals("USD 1280", ticket.getSalePrice()); - assertEquals("", ticket.getProfitRate()); + assertEquals("0%", ticket.getProfitRate()); } @Test diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java index 858318e..465cb7c 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java @@ -1543,6 +1543,9 @@ public class QuoteTemplateFillTest { matrix.getCell(15).setCellStyle(matrixBlue); sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 8, 2, 2)); sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 8, 9, 9)); + org.apache.poi.ss.usermodel.Row sub = row(sheet, 8); + sub.createCell(3).setCellValue("最低收费"); + sub.createCell(4).setCellValue("元/KG"); org.apache.poi.ss.usermodel.Font red = wb.createFont(); red.setColor(org.apache.poi.ss.usermodel.IndexedColors.RED.getIndex()); org.apache.poi.ss.usermodel.CellStyle redOnSky = wb.createCellStyle(); @@ -1553,6 +1556,14 @@ public class QuoteTemplateFillTest { currencyHead.setCellStyle(redOnSky); matrix.getCell(10).setCellStyle(redOnSky); org.apache.poi.ss.usermodel.Row data = row(sheet, 9); + org.apache.poi.ss.usermodel.Font blackData = wb.createFont(); + blackData.setFontName("宋体"); + blackData.setFontHeightInPoints((short) 10); + blackData.setColor(org.apache.poi.ss.usermodel.IndexedColors.BLACK.getIndex()); + org.apache.poi.ss.usermodel.CellStyle priceStyle = wb.createCellStyle(); + priceStyle.setFont(blackData); + data.createCell(4).setCellValue(12); + data.getCell(4).setCellStyle(priceStyle); org.apache.poi.ss.usermodel.Cell dest = data.createCell(2); dest.setCellStyle(redOnSky); org.apache.poi.ss.usermodel.Row delivery = row(sheet, 18); @@ -1610,6 +1621,9 @@ public class QuoteTemplateFillTest { assertEquals(org.apache.poi.ss.usermodel.VerticalAlignment.CENTER, sheet.getRow(9).getCell(2).getCellStyle().getVerticalAlignment()); assertFalse(QuoteTemplateExcelSupport.fontIsRed(sheet.getRow(9).getCell(2))); + assertFalse(sheet.getRow(9).getCell(2).getCellStyle().getShrinkToFit()); + assertEquals(QuoteTemplateExcelSupport.fontHeight(sheet.getRow(9).getCell(4)), + QuoteTemplateExcelSupport.fontHeight(sheet.getRow(9).getCell(2))); assertEquals("始发地", read(sheet, 7, 1)); assertEquals("币种", read(sheet, 7, 9)); byte[] originFill = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(7).getCell(1)); @@ -1678,6 +1692,133 @@ public class QuoteTemplateFillTest { } } + /** + * 跨境整车:列头是「起始地」,城市必须写在下一行白格,不能盖掉列名。 + */ + @Test + public void landCrossBorderFtlKeepsOriginHeader() throws Exception { + Path src = Files.createTempFile("quote-land-cb-ftl-src", ".xlsx"); + Path out = Files.createTempFile("quote-land-cb-ftl-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("跨境整车"); + org.apache.poi.ss.usermodel.Row top = row(sheet, 6); + top.createCell(1).setCellValue("路线"); + top.createCell(3).setCellValue("车型"); + org.apache.poi.ss.usermodel.Row head = row(sheet, 7); + head.createCell(1).setCellValue("起始地"); + head.createCell(2).setCellValue("目的地"); + head.createCell(3).setCellValue("3T"); + head.createCell(4).setCellValue("5T"); + head.createCell(5).setCellValue("8T"); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject mapping = new JSONObject(true); + mapping.put("sheet", "跨境整车"); + JSONObject fields = new JSONObject(true); + fields.put("origin", "跨境整车!B8"); + fields.put("destination", "跨境整车!C8"); + mapping.put("fields", fields); + JSONObject facts = new JSONObject(true); + facts.put("origin", "郑州"); + facts.put("destination", "河内"); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = filled.getSheetAt(0); + assertEquals("起始地", read(sheet, 7, 1)); + assertEquals("目的地", read(sheet, 7, 2)); + assertEquals("郑州", read(sheet, 8, 1)); + assertEquals("河内", read(sheet, 8, 2)); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + + /** + * 跨境整车国内段只写境内起点,境外段只写境外终点,不能两段都写成郑州→吉隆坡。 + */ + @Test + public void landCrossBorderFtlSplitsDomesticAndOverseasOd() throws Exception { + Path src = Files.createTempFile("quote-land-cb-split-src", ".xlsx"); + Path out = Files.createTempFile("quote-land-cb-split-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("跨境整车"); + row(sheet, 5).createCell(0).setCellValue("一、国内段报价"); + org.apache.poi.ss.usermodel.Row head1 = row(sheet, 7); + head1.createCell(1).setCellValue("起始地"); + head1.createCell(2).setCellValue("目的地"); + head1.createCell(3).setCellValue("3T"); + head1.createCell(4).setCellValue("5T"); + head1.createCell(15).setCellValue("时效"); + head1.createCell(16).setCellValue("币种"); + head1.createCell(17).setCellValue("燃油附加费率"); + head1.createCell(18).setCellValue("含税合计"); + row(sheet, 24).createCell(0).setCellValue("二、境外段报价"); + org.apache.poi.ss.usermodel.Row head2 = row(sheet, 26); + head2.createCell(1).setCellValue("起始地"); + head2.createCell(2).setCellValue("目的地"); + head2.createCell(3).setCellValue("1.25T"); + head2.createCell(4).setCellValue("2.5T"); + head2.createCell(15).setCellValue("时效"); + head2.createCell(16).setCellValue("币种"); + head2.createCell(17).setCellValue("燃油附加费率"); + head2.createCell(18).setCellValue("含税合计"); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject mapping = new JSONObject(true); + mapping.put("sheet", "跨境整车"); + JSONArray regions = new JSONArray(); + JSONObject inland = new JSONObject(true); + inland.put("title", "国内段报价"); + inland.put("headerRow", 8); + inland.put("dataStartRow", 8); + JSONObject cols1 = new JSONObject(true); + cols1.put("origin", "B"); + cols1.put("destination", "C"); + inland.put("columns", cols1); + regions.add(inland); + JSONObject overseas = new JSONObject(true); + overseas.put("title", "境外段报价"); + overseas.put("headerRow", 27); + overseas.put("dataStartRow", 27); + JSONObject cols2 = new JSONObject(true); + cols2.put("origin", "B"); + cols2.put("destination", "C"); + overseas.put("columns", cols2); + regions.add(overseas); + mapping.put("routeRegions", regions); + JSONObject facts = new JSONObject(true); + facts.put("origin", "郑州"); + facts.put("destination", "吉隆坡"); + facts.put("currency", "CNY"); + facts.put("transit_days", "N+3"); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = filled.getSheetAt(0); + assertEquals("起始地", read(sheet, 7, 1)); + assertEquals("郑州", read(sheet, 8, 1)); + assertEquals("", read(sheet, 8, 2)); + assertEquals("起始地", read(sheet, 26, 1)); + assertEquals("", read(sheet, 27, 1)); + assertEquals("吉隆坡", read(sheet, 27, 2)); + assertEquals("N+3", read(sheet, 8, 15)); + assertEquals("CNY", read(sheet, 8, 16)); + assertEquals("", read(sheet, 8, 17)); + assertEquals("", read(sheet, 8, 18)); + assertEquals("N+3", read(sheet, 27, 15)); + assertEquals("CNY", read(sheet, 27, 16)); + assertEquals("", read(sheet, 27, 17)); + assertEquals("", read(sheet, 27, 18)); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + /** * 国内整车:4.2米/3T 这类车型费用也是标签右侧,不是海运费目表。 */ @@ -1967,6 +2108,113 @@ public class QuoteTemplateFillTest { } } + /** + * 国内拼车:元/CBM、元/KG 必须跟「始发站」同一浅蓝,不能留成顶栏深蓝。 + */ + @Test + public void landDomesticLtlUnitHeaderRowIsKept() throws Exception { + Path src = Files.createTempFile("quote-land-ltl-src", ".xlsx"); + Path out = Files.createTempFile("quote-land-ltl-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("国内拼车"); + org.apache.poi.ss.usermodel.CellStyle sky = wb.createCellStyle(); + sky.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.SKY_BLUE.getIndex()); + sky.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND); + org.apache.poi.ss.usermodel.CellStyle navy = wb.createCellStyle(); + navy.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.ROYAL_BLUE.getIndex()); + navy.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND); + org.apache.poi.ss.usermodel.Row head = row(sheet, 7); + head.createCell(1).setCellValue("始发站"); + head.getCell(1).setCellStyle(sky); + head.createCell(2).setCellValue("目的地"); + head.getCell(2).setCellStyle(sky); + head.createCell(5).setCellValue("运输费"); + head.getCell(5).setCellStyle(sky); + org.apache.poi.ss.usermodel.Row units = row(sheet, 8); + units.createCell(5).setCellValue("元/CBM"); + units.getCell(5).setCellStyle(navy); + units.createCell(6).setCellValue("元/KG"); + units.getCell(6).setCellStyle(navy); + row(sheet, 9); + boolean[] keep = QuoteTemplateExcelSupport.markLandHeaderRows(sheet); + assertTrue(keep[7]); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject facts = new JSONObject(true); + facts.put("origin", "上海"); + facts.put("destination", "江苏南京市"); + facts.put("price_per_cbm", "100"); + QuoteTemplateExcelSupport.fillWorkbook(src, out, new JSONObject(), facts); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = filled.getSheetAt(0); + assertEquals("元/CBM", read(sheet, 8, 5)); + assertEquals("元/KG", read(sheet, 8, 6)); + byte[] unitRgb = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(8).getCell(5)); + byte[] headRgb = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(7).getCell(1)); + assertTrue(unitRgb != null && unitRgb.length >= 3); + assertTrue(headRgb != null && headRgb.length >= 3); + assertArrayEquals(headRgb, unitRgb); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + + /** + * 国内整车:车型格「4.2米/3T」是浅蓝表头,出单后不能变成业务员那种深蓝。 + */ + @Test + public void landDomesticFtlVehicleHeaderFillSurvives() throws Exception { + Path src = Files.createTempFile("quote-land-ftl-src", ".xlsx"); + Path out = Files.createTempFile("quote-land-ftl-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("国内整车"); + org.apache.poi.ss.usermodel.CellStyle sky = wb.createCellStyle(); + sky.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.SKY_BLUE.getIndex()); + sky.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND); + org.apache.poi.ss.usermodel.CellStyle navy = wb.createCellStyle(); + navy.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.ROYAL_BLUE.getIndex()); + navy.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND); + org.apache.poi.ss.usermodel.Row head = row(sheet, 7); + head.createCell(1).setCellValue("始发站"); + head.getCell(1).setCellStyle(sky); + head.createCell(2).setCellValue("目的地"); + head.getCell(2).setCellStyle(sky); + head.createCell(3).setCellValue("车型/费用"); + head.getCell(3).setCellStyle(sky); + head.createCell(8).setCellValue("时效"); + head.getCell(8).setCellStyle(sky); + org.apache.poi.ss.usermodel.Row veh = row(sheet, 8); + veh.createCell(3).setCellValue("4.2米/3T"); + veh.getCell(3).setCellStyle(navy); + veh.createCell(4).setCellValue("7.6米/8T"); + veh.getCell(4).setCellStyle(navy); + row(sheet, 9); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject facts = new JSONObject(true); + facts.put("origin", "北京"); + facts.put("destination", "上海"); + QuoteTemplateExcelSupport.fillWorkbook(src, out, new JSONObject(), facts); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = filled.getSheetAt(0); + assertEquals("4.2米/3T", read(sheet, 8, 3)); + assertEquals("7.6米/8T", read(sheet, 8, 4)); + byte[] vehRgb = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(8).getCell(3)); + byte[] headRgb = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(7).getCell(1)); + assertTrue(vehRgb != null && vehRgb.length >= 3); + assertTrue(headRgb != null && headRgb.length >= 3); + assertArrayEquals(headRgb, vehRgb); + } finally { + Files.deleteIfExists(src); + 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; diff --git a/inquiry-backend/src/views/inquiry/account/index.vue b/inquiry-backend/src/views/inquiry/account/index.vue index 93ef2e2..63afe51 100644 --- a/inquiry-backend/src/views/inquiry/account/index.vue +++ b/inquiry-backend/src/views/inquiry/account/index.vue @@ -1,4 +1,4 @@ -