diff --git a/inquiry-agent/agent/channel/h5/air_route_page.py b/inquiry-agent/agent/channel/h5/air_route_page.py index c35fe83..5e60ccc 100644 --- a/inquiry-agent/agent/channel/h5/air_route_page.py +++ b/inquiry-agent/agent/channel/h5/air_route_page.py @@ -230,7 +230,7 @@ def render_air_route_ok() -> str: """选定成功:短暂展示后关企微 H5。""" return _wrap( "已选定", - "

已选定

正在返回企微,请查看价格明细。

", + "

已选定

正在关闭页面,请在对话里查看价格明细。

", auto_close=True, ) diff --git a/inquiry-agent/agent/channel/h5/land_route_page.py b/inquiry-agent/agent/channel/h5/land_route_page.py index d0ba0cd..6d336cb 100644 --- a/inquiry-agent/agent/channel/h5/land_route_page.py +++ b/inquiry-agent/agent/channel/h5/land_route_page.py @@ -59,7 +59,7 @@ def render_land_route_ok() -> str: """选定成功:短暂展示后关企微 H5。""" return _wrap( "已选定", - "

已选定

正在返回企微,请查看价格明细。

", + "

已选定

正在关闭页面,请在对话里查看价格明细。

", auto_close=True, ) diff --git a/inquiry-agent/agent/channel/h5/wecom_close.py b/inquiry-agent/agent/channel/h5/wecom_close.py index 73faaf5..c67cb59 100644 --- a/inquiry-agent/agent/channel/h5/wecom_close.py +++ b/inquiry-agent/agent/channel/h5/wecom_close.py @@ -2,8 +2,9 @@ 企微内嵌 H5 成功后自动关页。 本文件职责:给附件上传成功、选线路成功页一段共用脚本。 -手机端可走 WeixinJSBridge;企微 Windows 客户端没有这套桥,必须像复制工单号页一样 -先加载 wecom-jssdk、用现成 /inquiry-form/jsapi-signature 做 ww.register,再 closeWindow。 +手机端只调一次 WeixinJSBridge.closeWindow,关掉当前 H5 后留在询价对话里。 +再调 ww/wx/window.close 会把询价小助手整个退出,回到企业微信首页。 +电脑企微没有这套桥,加载 wecom-jssdk、用 /inquiry-form/jsapi-signature 注册后再关一次。 失败、过期页不要挂这段。 副作用:只产出 HTML 片段;关窗签名走浏览器短请求,不占智能体请求线程做慢活。 """ @@ -46,30 +47,37 @@ def wecom_auto_close_script() -> str: }}; }} - function nativeClose() {{ - try {{ - if (typeof WeixinJSBridge !== "undefined") {{ - WeixinJSBridge.call("closeWindow"); - }} - }} catch (e) {{}} - try {{ - if (window.ww && typeof ww.closeWindow === "function") {{ - ww.closeWindow(); - }} - }} catch (e) {{}} - try {{ - if (window.wx && typeof wx.closeWindow === "function") {{ - wx.closeWindow(); - }} - }} catch (e) {{}} - try {{ window.close(); }} catch (e) {{}} + var closed = false; + + function closeOnce(fn) {{ + if (closed) return; + closed = true; + try {{ fn(); }} catch (e) {{}} + }} + + // 手机企微有 WeixinJSBridge:只关当前 H5。再调第二套关闭会把询价小助手整个退出,回到企业微信首页。 + function closeMobile() {{ + closeOnce(function () {{ WeixinJSBridge.call("closeWindow"); }}); + }} + + // 电脑企微没有 WeixinJSBridge,注册 JS-SDK 后只关一次。 + function closeDesktop() {{ + closeOnce(function () {{ + if (window.ww && typeof ww.closeWindow === "function") ww.closeWindow(); + else if (window.wx && typeof wx.closeWindow === "function") wx.closeWindow(); + }}); }} function boot() {{ if (typeof WeixinJSBridge !== "undefined") {{ - setTimeout(nativeClose, {delay}); + setTimeout(closeMobile, {delay}); + return; }} - document.addEventListener("WeixinJSBridgeReady", nativeClose, false); + var sawBridge = false; + document.addEventListener("WeixinJSBridgeReady", function () {{ + sawBridge = true; + setTimeout(closeMobile, {delay}); + }}, false); var ready = Promise.resolve(); if (window.ww && ww.register && corpId) {{ ready = ww.register({{ @@ -81,9 +89,17 @@ def wecom_auto_close_script() -> str: }}).catch(function () {{ return null; }}); }} ready.then(function () {{ - setTimeout(nativeClose, {delay}); + setTimeout(function () {{ + if (sawBridge || typeof WeixinJSBridge !== "undefined") {{ + if (!closed) closeMobile(); + return; + }} + closeDesktop(); + }}, {delay}); }}).catch(function () {{ - setTimeout(nativeClose, {delay}); + setTimeout(function () {{ + if (!closed && typeof WeixinJSBridge === "undefined") closeDesktop(); + }}, {delay}); }}); }} diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py index ac7a745..92984c8 100644 --- a/inquiry-agent/agent/ledger/http_ledger.py +++ b/inquiry-agent/agent/ledger/http_ledger.py @@ -245,6 +245,10 @@ class HttpLedger: "error": out.get("error") or "NO_TEMPLATE", "message": out.get("message") or FAIL_MESSAGE, } + from agent.quote_templates.filename import filename_from_render + + # 主账旧版本仍回 WO_quote.xlsx 时,这里按模板名改成销售看到的名字。 + out["fileName"] = filename_from_render(work_order_no, out) out.setdefault("needsPdf", str(format).lower() == "pdf") return out diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py index ace1b1f..0e415e1 100644 --- a/inquiry-agent/agent/ledger/memory_ledger.py +++ b/inquiry-agent/agent/ledger/memory_ledger.py @@ -425,13 +425,16 @@ class MemoryLedger: out = {"ok": False, "error": "NO_TEMPLATE", "message": FAIL_MESSAGE, "facts": dict(facts or {})} self.last_render = out return out - ext = "pdf" if str(format).lower() == "pdf" else "xlsx" + from agent.quote_templates.filename import quote_sheet_filename + + suffix = ".pdf" if str(format).lower() == "pdf" else ".xlsx" out = { "ok": True, "matchType": picked.match_type, "templateId": picked.template_id, "templateName": picked.name, - "fileName": f"{work_order_no}_quote.{ext}", + "fileName": quote_sheet_filename(work_order_no, picked.name, suffix=suffix) + or f"{work_order_no}_quote{suffix}", "fileBase64": "UEsDBAoAAAAAA", "needsPdf": False, "facts": dict(facts or {}), diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py index 35cd85f..342a55c 100644 --- a/inquiry-agent/agent/llm/extract_text.py +++ b/inquiry-agent/agent/llm/extract_text.py @@ -57,6 +57,7 @@ _OPTIONAL_INQUIRY_LABELS = ( "提货地址(非必填)", "收货地址(非必填)", "贸易条款(非必填)", + "起运地(非必填)", "起运港(非必填)", "货源地", "货好时间", @@ -129,6 +130,7 @@ _SHEET_CLEAN_KEYS = frozenset( "整柜或拼柜", "箱型箱量", "贸易条款", + "货好时间", "运输分类", "报价日期", *QUOTE_DATE_SYNONYMS, @@ -136,7 +138,52 @@ _SHEET_CLEAN_KEYS = frozenset( "包装类型", } ) +# 报价单从这里往下是费用和条款,不是询价栏。切掉后再抽字段。 +_QUOTE_BODY_MARKS = ( + "报价明细", + "报价条件", + "预估费用", + "空舱费", + "托运方", + "承运方", +) +# 表头常被拉成「体 积」「计 费 重」。只收这些词中间的空格,不跨行。 +_TIGHTEN_LABELS = tuple( + sorted( + { + *_QUOTE_BODY_MARKS, + "计费重", + "时效要求", + "派送地址", + "提货地址", + "收货地址", + "货好时间", + "贸易条款", + "报价日期", + "起运地", + "起运港", + "目的地", + "目的港", + "包装类型", + "包装方式", + "体积", + "重量", + "品名", + "件数", + "货源地", + "运输方式", + }, + key=len, + reverse=True, + ) +) +_FEE_NAME = re.compile(r"^\*?[\u4e00-\u9fffA-Za-z0-9]{1,12}费$") _SHEET_STOP = ( + "报价明细", + "报价条件", + "预估费用", + "计费重", + "时效要求", "要求运输时效", "Requested Transit", "费用汇总", @@ -291,6 +338,52 @@ def _merge_spoken_collab(text: str, facts: dict[str, str]) -> dict[str, str]: return merged +def prepare_sheet_oral(text: str) -> str: + """ + 报价单只留询价栏。 + + 费用明细、报价条件、落款整段丢掉。表头里被空格拉开的「体 积」收回成「体积」, + 否则上一栏会把后面的报价条件整段吞进去。计费重不是毛重,单独拿掉。 + 副作用:无。 + """ + body = _tighten_sheet_labels(text or "") + cut = len(body) + for mark in _QUOTE_BODY_MARKS: + pos = body.find(mark) + if 0 <= pos < cut: + cut = pos + body = body[:cut] + body = re.sub(r"计费重\s*[::]?\s*[0-9.]+\s*(?:KG|KGS|kg|公斤)?", " ", body) + return body.strip() + + +def _tighten_sheet_labels(text: str) -> str: + """已知表头中间只允许空格。不跨行,避免把两行并成一个词。""" + body = text or "" + gap = r"[ \t\u3000]{0,6}" + for label in _TIGHTEN_LABELS: + if len(label) < 2: + continue + pat = re.compile( + r"(? dict[str, str]: + """起运地/目的地收成「操作费、文件费」时丢掉。那是费用栏,不是港口。""" + out = dict(facts or {}) + for key in ("起运港", "起运地", "目的港", "目的地"): + val = str(out.get(key) or "").strip() + if not val: + continue + bits = [p.strip(" *::\t") for p in re.split(r"[\n/、,,]+", val) if p.strip()] + if bits and all(_FEE_NAME.match(p) for p in bits): + out.pop(key, None) + return out + + def extract_inquiry_snapshot( text: str, *, @@ -317,7 +410,7 @@ def extract_inquiry_snapshot( for key in ("货好时间", "提货地址", "收货地址", "货源地", "贸易条款"): if str(facts.get(key) or "").strip(): facts[key] = clip_inquiry_value(str(facts[key])) - facts = _clip_sheet_inquiry_facts(facts) + facts = _drop_fee_place_values(_clip_sheet_inquiry_facts(facts)) subtype = str(facts.get("运输类型") or "").strip() return { "business_line": mode, @@ -325,6 +418,8 @@ def extract_inquiry_snapshot( "facts": facts, "source": "injected", } + # 附件常是整张报价单。先丢掉费用明细和条款,再抽询价栏。 + text = prepare_sheet_oral(text) mode = detect_transport_mode(text) facts = parse_labeled_facts(text) source = "labeled_or_mode" @@ -361,7 +456,7 @@ def extract_inquiry_snapshot( for key in ("货好时间", "提货地址", "收货地址", "货源地", "贸易条款"): if str(facts.get(key) or "").strip(): facts[key] = clip_inquiry_value(str(facts[key])) - facts = _clip_sheet_inquiry_facts(facts) + facts = _drop_fee_place_values(_clip_sheet_inquiry_facts(facts)) land_subtype = str((b_payload or {}).get("land_subtype") or facts.get("运输类型") or "").strip() if land_subtype and not str(facts.get("运输类型") or "").strip(): facts["运输类型"] = land_subtype @@ -453,6 +548,10 @@ def clip_sheet_field_value(raw: str) -> str: parts = [p for p in parts if p and not _SHEET_HEADER_NOISE.search(p)] if not parts: return "" + # 格子里只剩下一个字段名(体积、时效要求)时,这一栏是空的,不能把字段名当成值。 + token = parts[0].strip().strip("::") + if token in _SHEET_CLEAN_KEYS or token in _TIGHTEN_LABELS: + return "" return parts[0].strip() diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index 87113c8..0247839 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -337,13 +337,134 @@ class AirTextInquiryFlow: self.drop_current_bookmark(sender_id) return None + def _handoff_flows(self) -> list[Any]: + """陆运、海运、空运各查自己的工单索引。当前流程放前面,避免单测实例对不上全局。""" + from agent.policy.land_text_flow import get_land_text_flow + from agent.policy.sea_text_flow import get_sea_text_flow + + flows: list[Any] = [self] + for flow in (get_land_text_flow(), get_sea_text_flow(), get_air_text_flow()): + if all(flow is not item for item in flows): + flows.append(flow) + return flows + + def _handoff_owner_for_line(self, line: str): + """ + 转人工入口跟工单运输方式走,不跟当前私聊里的另一张单走。 + + 空运、海运、陆运各用自己的流程收书签。对不上就返回空,不能默认成空运。 + """ + from agent.policy.land_text_flow import LandTextInquiryFlow, get_land_text_flow + from agent.policy.sea_text_flow import SeaTextInquiryFlow, get_sea_text_flow + + code = (line or "").strip().upper() + if code == "LAND": + return self if isinstance(self, LandTextInquiryFlow) else get_land_text_flow() + if code == "SEA": + return ( + self + if isinstance(self, SeaTextInquiryFlow) + and not isinstance(self, LandTextInquiryFlow) + else get_sea_text_flow() + ) + if code == "AIR": + return self if type(self) is AirTextInquiryFlow else get_air_text_flow() + return None + + def _offer_handoff_for_named_ticket( + self, + *, + sender_id: str, + work_order_no: str, + text: str, + reply: ReplyFn, + ) -> str: + """ + 按原话里的工单号转人工。 + + 有没有协同群只决定拦不拦。话术只看这张单是空运、海运还是陆运。 + 销售私聊里另一张已建群的单,不能拿来拒绝,也不能拿来决定话术。 + 副作用:命中后把这张单收到对应运输方式的转人工入口;读主账。不建群。 + """ + from agent.policy.handoff_human import is_closed_for_handoff + + no = (work_order_no or "").strip().upper() + hit = None + hit_flow = None + for flow in self._handoff_flows(): + found = flow.session_by_ticket(sender_id, no) + if found is None: + continue + hit = found + hit_flow = flow + break + getter = getattr((hit_flow or self)._ledger, "get_for_agent", None) + view: dict[str, Any] = {} + if callable(getter): + try: + view = getter(work_order_no=no, sender_id=sender_id) or {} + except Exception: + logger.exception("转人工读主账失败 wo=%s", no) + view = {} + if view.get("found") and view.get("owned") is False: + reply("这张工单不属于当前账号,不能转人工。") + return "ticket_forbidden" + if hit is not None and view.get("found"): + # 主账上的运输方式和群号盖过进程里的旧书签。 + hit.collab_chat_id = str(view.get("collab_chat_id") or "").strip() + if str(view.get("status") or "").strip(): + hit.status = str(view.get("status") or "").strip() + if str(view.get("business_line") or "").strip(): + hit.business_line = str(view.get("business_line") or "").strip().upper() + if hit is None: + if not callable(getter): + reply(f"找不到工单 {no},请核对工单号后再试。") + return "ticket_missing" + if not view.get("found"): + reply(f"找不到工单 {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) + 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) + return "need_mode" + logger.info( + "handoff.named wo=%s line=%s phase=%s chat=%s", + no, + line or "-", + hit.phase or "-", + "yes" if (hit.collab_chat_id or "").strip() else "no", + ) + if is_closed_for_handoff(hit.status): + reply("该工单已结束,不能再转人工。") + return "handoff_closed" + if owner._block_if_handoff_committed(hit, reply): + return "handoff_done" + return owner._offer_handoff_now( + sender_id=sender_id, reason="intent", reply=reply, text=text, sess=hit + ) + def offer_handoff(self, *, sender_id: str, text: str, reply: ReplyFn) -> str: """ 路由判定销售明确要求人来接管。运输方式不明则继续补问运输方式。 + + 原话里写了工单号时,只处理那一张。 + 有没有协同群只决定拦不拦;空运、海运、陆运各走各的转人工。 """ from agent.llm.extract_text import detect_transport_mode from agent.policy.handoff_human import is_closed_for_handoff + named = copy.extract_work_order_no(text) + if named: + return self._offer_handoff_for_named_ticket( + sender_id=sender_id, work_order_no=named, text=text, reply=reply + ) sess = self.session_of(sender_id) if sess and is_closed_for_handoff(sess.status): reply("该工单已结束,不能再转人工。") @@ -1572,7 +1693,15 @@ class AirTextInquiryFlow: if not result.get("ok"): reply(str(result.get("message") or copy.quote_template_missing())) return "wait_adopt" - filename = str(result.get("fileName") or f"{sess.work_order_no}_quote.{fmt}") + from agent.quote_templates.filename import filename_from_render + + # 私聊直接生成与群里同一套名字:工单号 + 模板名 + 后缀。PDF 用 .pdf,表格用 .xlsx。 + xlsx_name = filename_from_render(sess.work_order_no, result, suffix=".xlsx") + filename = ( + filename_from_render(sess.work_order_no, result, suffix=".pdf") + if kind == "PDF" + else xlsx_name + ) from agent.policy.event_exception import clear_wait clear_wait(self._ledger, work_order_no=sess.work_order_no) @@ -1582,20 +1711,18 @@ class AirTextInquiryFlow: result, work_order_no=sess.work_order_no, sender_id=sess.sender_id, - filename=filename if filename.lower().endswith(".pdf") else f"{sess.work_order_no}_quote.pdf", + filename=filename, quote=dict(sess.quote or {}), facts=dict(sess.facts or {}), ) if not queued: - fallback = str(result.get("fileName") or f"{sess.work_order_no}_quote.xlsx") - if not fallback.lower().endswith(".xlsx"): - fallback = f"{sess.work_order_no}_quote.xlsx" reply("PDF 转换排队失败,已按后台模板发送 Excel 报价单。") + file_extra = self._quote_file_payload(result, filename=xlsx_name) return self._emit_file_then_deal( sess, reply, kind="Excel", - filename=fallback, + filename=xlsx_name, file_extra=file_extra, ) # 已入队:等 Worker 出文件后再出成交卡,这里不提前发卡 diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 92fa222..4b27d66 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -454,9 +454,7 @@ def _air_field_line(key: str, value: str, *, required: bool, example: str = "") def AIR_DISPLAY_LABEL(key: str) -> str: - """核对/补问对外名。起运港按产品口径写起运港。""" - if key == "起运港": - return "起运港" + """核对/补问对外名。空运起运港对外写起运地,内部键不变。""" return display_name(key, "AIR") @@ -464,10 +462,11 @@ def air_confirm_card(facts: dict[str, str] | None) -> str: """ 确定查价前的核对卡。没有工单号。 - 必填 7 项 + 6 个非必填都列出;空着的非必填留空行。 + 起运地(非必填)紧挨在目的地(必填)上面。其余非必填仍在报价日期后面。 """ src = dict(facts or {}) lines = ["运输方式(必填):空运"] + lines.append(_air_field_line("起运港", _air_known_value(src, "起运港"), required=False)) for key, _label in AIR_REQUIRED_FIELDS: if key == "报价日期": val = _air_known_value(src, key) or (src.get("报价日期") or "").strip() @@ -475,6 +474,8 @@ def air_confirm_card(facts: dict[str, str] | None) -> str: continue lines.append(_air_field_line(key, _air_known_value(src, key), required=True)) for key, _label in AIR_OPTIONAL_FIELDS: + if key == "起运港": + continue lines.append(_air_field_line(key, _air_known_value(src, key), required=False)) lines.append("") lines.append(AIR_CONFIRM_TAIL) @@ -523,14 +524,15 @@ def ask_clarify( lines = ["请确认并补充以下信息:"] if transport_mode: lines.append(f"运输方式:{transport_mode}") + if not sea: + origin = known(src, "起运港") + if origin: + lines.append(f"起运地:{origin}") for key, label, _example in rows: val = known(src, key) if val: lines.append(f"{label}:{val}") if not sea: - origin = known(src, "起运港") - if origin: - lines.append(f"起运地:{origin}") for key, label, _example in _AIR_OPTIONAL_CLARIFY: if key == "起运港": continue @@ -545,6 +547,10 @@ def ask_clarify( any_missing = True seq += 1 lines.append(_pending_field_block(seq, "运输方式", "空运")) + if not sea and not known(src, "起运港"): + any_missing = True + seq += 1 + lines.append(_pending_field_block(seq, "起运地", _AIR_EXAMPLES["起运港"], optional=True)) for key, label, example in rows: if key in missing_set: any_missing = True @@ -564,13 +570,12 @@ def ask_clarify( optional_pending = [ (key, label, example) for key, label, example in _AIR_OPTIONAL_CLARIFY - if not known(src, key) + if key != "起运港" and not known(src, key) ] for key, label, example in optional_pending: any_missing = True seq += 1 - shown = "起运港" if key == "起运港" else label - lines.append(_pending_field_block(seq, shown, example, optional=True)) + lines.append(_pending_field_block(seq, label, example, optional=True)) if not any_missing: return "" return "\n".join(lines) diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index 80ff673..fd62f19 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -778,7 +778,10 @@ def _render_and_send_sheet( if not result.get("ok"): logger.warning("群填表失败 wo=%s line=%s err=%s", work_order_no, line, result.get("error")) return result - filename = str(result.get("fileName") or f"{work_order_no}_quote.xlsx") + from agent.quote_templates.filename import filename_from_render + + # 群里文件名:工单号 + 后台模板名 + .xlsx。调价后再发也是这个名字。 + filename = filename_from_render(work_order_no, result) sender = getattr(client, "send_group_file", None) if not callable(sender): logger.warning("群发文件无客户端 wo=%s chat=%s", work_order_no, chat_id) @@ -1039,6 +1042,8 @@ def _persist_quote_sheet( if sess is not None and getattr(sess, "collab_facts", None): collab.update(dict(sess.collab_facts or {})) renderer = getattr(flow.ledger, "render_quote", None) + from agent.quote_templates.filename import filename_from_render + filename = f"{no}_quote.xlsx" object_key = filename line = str( @@ -1062,8 +1067,8 @@ def _persist_quote_sheet( ), ) or {} if result.get("ok"): - filename = str(result.get("fileName") or filename) - object_key = str(result.get("objectKey") or result.get("fileName") or filename) + filename = filename_from_render(no, result) + object_key = str(result.get("objectKey") or filename) _archive_file(flow, no, filename, {"media_id": object_key}, sender_id) diff --git a/inquiry-agent/agent/policy/sea_text_flow.py b/inquiry-agent/agent/policy/sea_text_flow.py index 94e9004..5bd626e 100644 --- a/inquiry-agent/agent/policy/sea_text_flow.py +++ b/inquiry-agent/agent/policy/sea_text_flow.py @@ -598,7 +598,14 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): ) def _replay_wait(self, sess: FlowSession, reply: ReplyFn, *, source: str) -> str: - if sess.business_line == "SEA" and sess.phase == "wait_collab": + """ + 海运、陆运停在拉群问句时,继续工单必须把同一张卡拉回来。 + + 空运父类的无价卡没有「拉产品进群」按钮。陆运以前被当成空运重放, + 销售继续工单就看不到这张卡。 + """ + line = (sess.business_line or "").upper() + if line in {"SEA", "LAND"} and sess.phase == "wait_collab": self._emit_sea_hit( reply, work_order_no=sess.work_order_no, @@ -606,10 +613,10 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): first_or_same=sess.first_or_same, history_work_order_no=sess.history_work_order_no, status=sess.status or "已报价", - business_line=sess.business_line or "SEA", + business_line=line, ) return "wait_collab" - if sess.business_line == "SEA" and sess.phase == "tms_miss": + if line in {"SEA", "LAND"} and sess.phase == "tms_miss": self._emit_tms_miss(reply, sess.work_order_no) return "tms_miss" return super()._replay_wait(sess, reply, source=source) diff --git a/inquiry-agent/agent/quote_templates/filename.py b/inquiry-agent/agent/quote_templates/filename.py new file mode 100644 index 0000000..7db1ac7 --- /dev/null +++ b/inquiry-agent/agent/quote_templates/filename.py @@ -0,0 +1,83 @@ +""" +系统生成报价单的对外文件名。 + +协同群发出、私聊直接生成、调价后再发,都用「工单号 + 后台模板名 + 后缀」。 +表格是 .xlsx,转成 PDF 再发出去是 .pdf。 +航线、产品自己上传的附件不走这里,原文件名存后台。 +""" + +from __future__ import annotations + +from typing import Any + +# 模板名若自带这些后缀,发出去时去掉。中文名本身不动。 +_SUFFIXES = (".xlsx", ".xls", ".xlsm", ".pdf") +# Windows / 企微文件名不允许的字符。其余(含中文)原样保留。 +_ILLEGAL = set('\\/:*?"<>|') + + +def quote_sheet_filename( + work_order_no: str, + template_name: str, + *, + suffix: str = ".xlsx", +) -> str: + """ + 工单号紧挨后台模板名,再加文件后缀。没有模板名则返回空。 + + 模板名里如果已经写了 .xlsx / .pdf,先去掉再按这次要发的格式重加,避免两个后缀。 + 副作用:无。纯字符串,可在请求线程调用。 + """ + no = str(work_order_no or "").strip() + name = _plain_name(template_name) + if not no or not name: + return "" + return f"{no}{name}{_suffix(suffix)}" + + +def filename_from_render( + work_order_no: str, + result: dict[str, Any] | None, + *, + suffix: str = ".xlsx", +) -> str: + """ + 出单结果里的对外文件名。有模板名就用工单号拼接,并带上这次的后缀。 + + 主账即使仍回 WO_quote.xlsx,只要带了 templateName,销售侧仍是「工单号+模板名.xlsx」。 + PDF 出站传入 suffix=".pdf"。 + """ + src = result or {} + ext = _suffix(suffix) + shown = quote_sheet_filename( + work_order_no, str(src.get("templateName") or ""), suffix=ext + ) + if shown: + return shown + raw = _plain_name(str(src.get("fileName") or "")) + if raw: + return f"{raw}{ext}" + no = str(work_order_no or "").strip() + return f"{no}_quote{ext}" if no else f"quote{ext}" + + +def _suffix(suffix: str) -> str: + """只接受已知表格/PDF 后缀,其余一律按 Excel。""" + text = str(suffix or "").strip().lower() + if not text.startswith("."): + text = f".{text}" if text else "" + if text in _SUFFIXES: + return text + return ".xlsx" + + +def _plain_name(name: str) -> str: + """去掉已知后缀和非法字符。空则返回空。""" + text = str(name or "").strip() + lower = text.lower() + for ext in _SUFFIXES: + if lower.endswith(ext): + text = text[: -len(ext)].rstrip(" .") + break + cleaned = "".join(ch for ch in text if ch not in _ILLEGAL and ord(ch) >= 32) + return cleaned.strip() diff --git a/inquiry-agent/agent/schema/field_validate.py b/inquiry-agent/agent/schema/field_validate.py index cfd6731..170ba48 100644 --- a/inquiry-agent/agent/schema/field_validate.py +++ b/inquiry-agent/agent/schema/field_validate.py @@ -31,7 +31,7 @@ AIR_REQUIRED_FIELDS = ( ) # 空运询价非必填:有值收下;贸易条款只记工单,不挡查价。 AIR_OPTIONAL_FIELDS = ( - ("起运港", "起运港"), + ("起运港", "起运地"), ("货源地", "货源地"), ("货好时间", "货好时间"), ("提货地址", "提货地址"), @@ -40,7 +40,7 @@ AIR_OPTIONAL_FIELDS = ( ) AIR_DISPLAY = {key: label for key, label in AIR_REQUIRED_FIELDS} AIR_DISPLAY.update({key: label for key, label in AIR_OPTIONAL_FIELDS}) -# 补问回显仍用「起运地」,与历史卡片一致。 +# 内部键仍是起运港(主账/TMS)。销售侧一律写起运地。 AIR_DISPLAY["起运港"] = "起运地" # 海运询价必填 10 项(销售侧含运输方式):内部键「货量」对外叫「货物数量」。 @@ -235,6 +235,7 @@ _ALIASES = { "收货地址(非必填)": "收货地址", "贸易条款(非必填)": "贸易条款", "起运港(非必填)": "起运港", + "起运地(非必填)": "起运港", } for _quote_alias in QUOTE_DATE_SYNONYMS: _ALIASES[_quote_alias] = "报价日期" @@ -327,7 +328,30 @@ def _quote_date_labeled_value(raw: str) -> str: best_val = hit.group(1).strip().strip(",,。;;、") if not best_val: return "" - return _harvest_quote_date(best_val) or best_val + return _harvest_quote_date(best_val) or _excel_serial_date(best_val) or best_val + + +def _excel_serial_date(token: str) -> str: + """ + Excel 把日期存成序列号时(如 46307),收成 yyyy-MM-dd。 + + 只认整段就是 5 位数字。件数、重量不走这里。 + """ + from datetime import datetime, timedelta + + text = (token or "").strip() + if text.endswith(".0"): + text = text[:-2] + if not re.fullmatch(r"\d{5}", text): + return "" + serial = int(text) + # 约 1982–2064。再外的 5 位数不当日期。 + if serial < 30000 or serial > 60000: + return "" + try: + return (datetime(1899, 12, 30) + timedelta(days=serial)).date().isoformat() + except OverflowError: + return "" def _harvest_quote_date(raw: str) -> str: diff --git a/inquiry-agent/tests/test_air_field_contract.py b/inquiry-agent/tests/test_air_field_contract.py index f63a042..258e46e 100644 --- a/inquiry-agent/tests/test_air_field_contract.py +++ b/inquiry-agent/tests/test_air_field_contract.py @@ -113,12 +113,25 @@ class AirFieldContractTests(unittest.TestCase): } ) self.assertNotIn("工单号", text) + self.assertIn("起运地(非必填):", text) + self.assertNotIn("起运港(非必填)", text) + self.assertLess(text.index("起运地(非必填):"), text.index("目的地(必填):克拉克")) self.assertIn("目的地(必填):克拉克", text) - self.assertIn("起运港(非必填):", text) self.assertIn("货源地(非必填):", text) self.assertIn("贸易条款(非必填):", text) self.assertIn(copy.AIR_CONFIRM_TAIL, text) + def test_clarify_puts_origin_above_destination(self) -> None: + text = copy.ask_clarify( + facts={}, + missing_keys=["目的港", "品名"], + transport_mode="空运", + ) + self.assertIn("1.起运地(非必填):\n(如:南京)", text) + self.assertIn("2.目的地(必填):\n(如:吉隆坡)", text) + self.assertLess(text.index("起运地(非必填)"), text.index("目的地(必填)")) + self.assertNotIn("起运港(非必填)", text) + def test_empty_origin_confirm_then_quote(self) -> None: flow = AirTextInquiryFlow(MemoryLedger()) replies: list[str] = [] diff --git a/inquiry-agent/tests/test_air_route_options.py b/inquiry-agent/tests/test_air_route_options.py index f3013be..856a97e 100644 --- a/inquiry-agent/tests/test_air_route_options.py +++ b/inquiry-agent/tests/test_air_route_options.py @@ -331,6 +331,7 @@ class AirRouteOptionsTests(unittest.TestCase): self.assertIn("closeWindow", ok) self.assertIn("wecom-jssdk", ok) self.assertIn("jsapi-signature", ok) + self.assertNotIn("window.close()", ok) self.assertNotIn("closeWindow", dead) diff --git a/inquiry-agent/tests/test_air_text_inquiry.py b/inquiry-agent/tests/test_air_text_inquiry.py index 684999d..7503290 100644 --- a/inquiry-agent/tests/test_air_text_inquiry.py +++ b/inquiry-agent/tests/test_air_text_inquiry.py @@ -93,6 +93,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertIn("请确认并补充以下信息:", text) self.assertIn("运输方式:空运", text) self.assertIn("起运地:珠海", text) + self.assertLess(text.index("起运地:珠海"), text.index("目的地:克拉克")) self.assertIn("目的地:克拉克", text) self.assertIn("品名:茶叶", text) self.assertIn("件数:10件", text) diff --git a/inquiry-agent/tests/test_extract_oral.py b/inquiry-agent/tests/test_extract_oral.py index afb982e..3eeaf2b 100644 --- a/inquiry-agent/tests/test_extract_oral.py +++ b/inquiry-agent/tests/test_extract_oral.py @@ -572,5 +572,58 @@ class SeaSheetHeaderClipTests(unittest.TestCase): self.assertEqual(oral["facts"].get("报价日期"), "2026-09-15") +# 测服 2026-10-05:空运报价单整表被抽进补问,费用和条款进了起运地、货好时间。 +AIR_QUOTE_SHEET = """ +运输方式:空运 +目的地:CRK +品名:化妆品 +件数:11 +重量:1KG +体 积: 100 CBM +报价日期:46307 +货好时间: +体 积: 100 CBM 时效要求: +计 费 重: 10 KG +派送地址: +二、报价明细 +项目 费用名称 单位 币别 单价 数量 金额 备注 +空运段 空运费 +起运港 操作费 +文件费 如需多个分单另计 +*代理报关费 发生时收取 +*提货费 发生时收取 +其他 *其他杂费 发生时收取 +预估费用 0 +三、报价条件 +1、报价适用范围:一般货物(非危险品) +贸易条款:DAP +托运方:某某有限公司 承运方:深圳市友通达国际货运代理有限公司 +""" + + +class AirQuoteSheetInquiryOnlyTests(unittest.TestCase): + def test_quote_sheet_keeps_inquiry_fields_only(self) -> None: + """费用明细、报价条件、落款不能进补问。起运地不能收成操作费。""" + snap = extract_inquiry_snapshot(AIR_QUOTE_SHEET, allow_b=False) + facts = snap["facts"] + self.assertEqual(snap["business_line"], "AIR") + self.assertEqual(facts.get("目的港"), "CRK") + self.assertEqual(facts.get("品名"), "化妆品") + self.assertIn("11", str(facts.get("件数") or "")) + self.assertIn("1", str(facts.get("毛重") or "")) + self.assertNotIn("10", str(facts.get("毛重") or "")) + self.assertIn("100", str(facts.get("体积") or "")) + self.assertEqual(facts.get("报价日期"), "2026-10-12") + self.assertNotIn("起运港", facts) + self.assertNotIn("货好时间", facts) + self.assertNotIn("贸易条款", facts) + blob = "\n".join(facts.values()) + self.assertNotIn("操作费", blob) + self.assertNotIn("报价明细", blob) + self.assertNotIn("报价条件", blob) + self.assertNotIn("空舱", blob) + self.assertNotIn("友通达", blob) + + if __name__ == "__main__": unittest.main() diff --git a/inquiry-agent/tests/test_handoff_human.py b/inquiry-agent/tests/test_handoff_human.py index 06a3f5e..29ffc0e 100644 --- a/inquiry-agent/tests/test_handoff_human.py +++ b/inquiry-agent/tests/test_handoff_human.py @@ -345,5 +345,231 @@ class HandoffFlowOfferTests(unittest.TestCase): self.assertIn(copy.BTN_PULL_COLLAB, bodies) +class NamedTicketHandoffTests(unittest.TestCase): + """点名工单号转人工时,只看那一张单,不看私聊里另一张已建群的单。""" + + def _flow(self): + from agent.policy.land_text_flow import LandTextInquiryFlow + from agent.ledger.memory_ledger import MemoryLedger + + return LandTextInquiryFlow(MemoryLedger()) + + def test_deal_followup_not_blocked_by_other_group(self) -> None: + flow = self._flow() + flow._save( + FlowSession( + sender_id="s1", + thread_id="s1", + phase="wait_deal", + business_line="LAND", + work_order_no="WO202610040002", + status="已报价", + quote={"total": "CNY 800", "quoteFileIssued": True}, + ) + ) + flow._save( + FlowSession( + sender_id="s1", + thread_id="s1", + phase="collab_group", + business_line="LAND", + work_order_no="WO202610040004", + collab_chat_id="wr_other", + status="询价中", + ) + ) + replies: list[str] = [] + + def reply(text: str, extra=None) -> str: + replies.append(text) + _ = extra + return "" + + phase = flow.offer_handoff( + sender_id="s1", text="WO202610040002转人工", reply=reply + ) + self.assertEqual(phase, "handoff_offer") + self.assertIn("运输方式:陆运", replies[-1]) + self.assertIn("是否拉产品人员进群协同", replies[-1]) + self.assertNotIn("原有空运群", replies[-1]) + self.assertNotEqual(replies[-1], copy.HANDOFF_ALREADY_COLLAB) + named = flow.session_by_ticket("s1", "WO202610040002") + self.assertEqual(named.phase, "handoff_offer") + self.assertFalse((named.collab_chat_id or "").strip()) + + def test_named_ticket_with_group_still_blocked(self) -> None: + flow = self._flow() + flow._save( + FlowSession( + sender_id="s1", + thread_id="s1", + phase="collab_group", + business_line="LAND", + work_order_no="WO202610040002", + collab_chat_id="wr_real", + status="询价中", + ) + ) + replies: list[str] = [] + + def reply(text: str, extra=None) -> str: + replies.append(text) + _ = extra + return "" + + phase = flow.offer_handoff( + sender_id="s1", text="WO202610040002转人工", reply=reply + ) + self.assertEqual(phase, "handoff_already") + self.assertEqual(replies[-1], copy.HANDOFF_ALREADY_COLLAB) + + def test_ledger_deal_ticket_without_group_offers_card(self) -> None: + from agent.ledger.memory_ledger import MemoryTicket + + flow = self._flow() + no = "WO202610040012" + flow.ledger._tickets[no] = MemoryTicket( + work_order_no=no, + sales_wecom_id="s1", + business_line="LAND", + facts={"运输方式": "陆运"}, + fingerprint="land-deal", + status="已报价", + quote={"total": "CNY 800", "quoteFileIssued": True}, + quote_file_ready=True, + ) + flow._save( + FlowSession( + sender_id="s1", + thread_id="s1", + phase="collab_group", + business_line="LAND", + work_order_no="WO202610040004", + collab_chat_id="wr_other", + status="询价中", + ) + ) + replies: list[str] = [] + + def reply(text: str, extra=None) -> str: + replies.append(text) + _ = extra + return "" + + phase = flow.offer_handoff(sender_id="s1", text=f"{no}转人工", reply=reply) + self.assertEqual(phase, "handoff_offer") + self.assertIn("是否拉产品人员进群协同", "\n".join(replies)) + self.assertNotIn(copy.HANDOFF_ALREADY_COLLAB, replies) + + def test_air_deal_ticket_ignores_other_land_group(self) -> None: + """空运成交跟进没有协同群时,不能被另一张陆运已拉群的单挡成「已拉产品进群」。""" + from agent.ledger.memory_ledger import MemoryTicket + + flow = self._flow() + no = "WO202610040002" + flow.ledger._tickets[no] = MemoryTicket( + work_order_no=no, + sales_wecom_id="s1", + business_line="AIR", + facts={"运输方式": "空运", "目的港": "CRK"}, + fingerprint="air-deal", + status="已报价", + quote={"total": "CNY 800", "quoteFileIssued": True}, + quote_file_ready=True, + ) + flow._save( + FlowSession( + sender_id="s1", + thread_id="s1", + phase="collab_group", + business_line="LAND", + work_order_no="WO202610040004", + collab_chat_id="wr_other", + status="询价中", + ) + ) + replies: list[str] = [] + + def reply(text: str, extra=None) -> str: + replies.append(text) + _ = extra + return "" + + phase = flow.offer_handoff(sender_id="s1", text=f"{no}转人工", reply=reply) + joined = "\n".join(replies) + self.assertEqual(phase, "handoff_offer") + self.assertIn("原有空运群", joined) + self.assertNotIn("是否拉产品人员进群协同", joined) + self.assertNotIn(copy.HANDOFF_ALREADY_COLLAB, joined) + + def test_ledger_mode_wins_over_stale_bookmark(self) -> None: + """主账是空运时,进程里标成陆运的旧书签也不能改成陆运拉群卡。""" + from agent.ledger.memory_ledger import MemoryTicket + + flow = self._flow() + no = "WO202610040002" + flow.ledger._tickets[no] = MemoryTicket( + work_order_no=no, + sales_wecom_id="s1", + business_line="AIR", + facts={}, + fingerprint="air-wins", + status="已报价", + quote={"total": "CNY 800", "quoteFileIssued": True}, + quote_file_ready=True, + ) + flow._save( + FlowSession( + sender_id="s1", + thread_id="s1", + phase="wait_deal", + business_line="LAND", + work_order_no=no, + status="已报价", + ) + ) + replies: list[str] = [] + + def reply(text: str, extra=None) -> str: + replies.append(text) + _ = extra + return "" + + phase = flow.offer_handoff(sender_id="s1", text=f"{no}转人工", reply=reply) + joined = "\n".join(replies) + self.assertEqual(phase, "handoff_offer") + self.assertIn("原有空运群", joined) + self.assertNotIn("运输方式:陆运", joined) + + def test_sea_deal_ticket_uses_sea_card(self) -> None: + from agent.ledger.memory_ledger import MemoryTicket + + flow = self._flow() + no = "WO202610040013" + flow.ledger._tickets[no] = MemoryTicket( + work_order_no=no, + sales_wecom_id="s1", + business_line="SEA", + facts={"运输方式": "海运"}, + fingerprint="sea-deal", + status="已报价", + quote={"total": "USD 100", "quoteFileIssued": True}, + quote_file_ready=True, + ) + replies: list[str] = [] + + def reply(text: str, extra=None) -> str: + replies.append(text) + _ = extra + return "" + + phase = flow.offer_handoff(sender_id="s1", text=f"{no}转人工", reply=reply) + joined = "\n".join(replies) + self.assertEqual(phase, "handoff_offer") + self.assertIn("运输方式:海运", joined) + self.assertIn("是否拉产品人员进群协同", joined) + self.assertNotIn("原有空运群", joined) + + if __name__ == "__main__": unittest.main() diff --git a/inquiry-agent/tests/test_land_text_inquiry.py b/inquiry-agent/tests/test_land_text_inquiry.py index 8bed4a1..28ecc1d 100644 --- a/inquiry-agent/tests/test_land_text_inquiry.py +++ b/inquiry-agent/tests/test_land_text_inquiry.py @@ -711,5 +711,69 @@ class LandSkipAfterRestartTests(unittest.TestCase): self.assertIn(f"{wo}报价方案", joined) +class LandContinuePullCardTests(unittest.TestCase): + def test_tms_miss_resume_shows_pull_card(self) -> None: + """陆运没查到价时继续工单,要再出「是否拉产品进群」卡,不能出空运无价卡。""" + flow = LandTextInquiryFlow(MemoryLedger()) + flow._save( + FlowSession( + sender_id="land-miss", + thread_id="land-miss", + phase="tms_miss", + business_line="LAND", + work_order_no="WO202610040004", + status="询价中", + allowed=(copy.BTN_PULL_COLLAB,), + ) + ) + replies: list[str] = [] + extras: list[dict] = [] + + def reply(text: str, extra=None) -> str: + replies.append(text) + extras.append(dict(extra) if extra else {}) + return "" + + phase = flow.resume_historical( + sender_id="land-miss", work_order_no="WO202610040004", reply=reply + ) + self.assertEqual(phase, "tms_miss") + joined = "\n".join(replies) + self.assertIn("是否需要拉内部产品人员进群", joined) + self.assertIn(copy.BTN_PULL_COLLAB, joined) + buttons = (extras[-1].get("template_card") or {}).get("button_list") or [] + self.assertTrue(any(copy.BTN_PULL_COLLAB == b.get("text") for b in buttons)) + + def test_priced_resume_shows_pull_card(self) -> None: + """陆运已有价、还在问要不要拉群时,继续工单要出带拉群按钮的价格卡。""" + flow = LandTextInquiryFlow(MemoryLedger()) + flow._save( + FlowSession( + sender_id="land-hit", + thread_id="land-hit", + phase="wait_collab", + business_line="LAND", + work_order_no="WO202610040004", + status="已报价", + quote={"total": "CNY 800", "source_label": "TMS 标准报价"}, + allowed=(copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB), + ) + ) + replies: list[str] = [] + + def reply(text: str, extra=None) -> str: + replies.append(text) + _ = extra + return "" + + phase = flow.resume_historical( + sender_id="land-hit", work_order_no="WO202610040004", reply=reply + ) + self.assertEqual(phase, "wait_collab") + joined = "\n".join(replies) + self.assertIn("是否拉产品人员进群协同", joined) + self.assertIn(copy.BTN_PULL_COLLAB, joined) + + if __name__ == "__main__": unittest.main() diff --git a/inquiry-agent/tests/test_quote_sheet_filename.py b/inquiry-agent/tests/test_quote_sheet_filename.py new file mode 100644 index 0000000..10897b9 --- /dev/null +++ b/inquiry-agent/tests/test_quote_sheet_filename.py @@ -0,0 +1,50 @@ +"""系统报价单对外文件名:工单号接后台模板名,并带文件后缀。""" + +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) + +from agent.quote_templates.filename import filename_from_render, quote_sheet_filename + + +class QuoteSheetFilenameTests(unittest.TestCase): + def test_joins_work_order_and_backend_template_name(self) -> None: + self.assertEqual( + quote_sheet_filename("WO202609100001", "陆运国内运输拼车报价单"), + "WO202609100001陆运国内运输拼车报价单.xlsx", + ) + + def test_drops_suffix_on_template_name(self) -> None: + self.assertEqual( + quote_sheet_filename("WO202609100001", "海运报价单模板.xlsx"), + "WO202609100001海运报价单模板.xlsx", + ) + + def test_pdf_uses_pdf_suffix(self) -> None: + self.assertEqual( + quote_sheet_filename("WO202609100001", "默认空运", suffix=".pdf"), + "WO202609100001默认空运.pdf", + ) + + def test_render_prefers_template_name_over_old_file_name(self) -> None: + shown = filename_from_render( + "WO202609100001", + { + "templateName": "国内运输拼车报价单", + "fileName": "WO202609100001_quote.xlsx", + }, + ) + self.assertEqual(shown, "WO202609100001国内运输拼车报价单.xlsx") + + def test_blank_template_keeps_xlsx_suffix(self) -> None: + shown = filename_from_render( + "WO202609100001", + {"fileName": "WO202609100001_quote.xlsx"}, + ) + self.assertEqual(shown, "WO202609100001_quote.xlsx") diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py index e56366a..b1a6a4f 100644 --- a/inquiry-agent/tests/test_sea_text_inquiry.py +++ b/inquiry-agent/tests/test_sea_text_inquiry.py @@ -2404,7 +2404,8 @@ class SeaGroupOpsTests(unittest.TestCase): ticket = self.ledger.get_ticket(work_order_no=wo) self.assertEqual(ticket.status, "已报价") self.assertTrue(ticket.attachments) - self.assertIn("quote", str(ticket.attachments[0].get("file_name") or "").lower()) + saved_name = str(ticket.attachments[0].get("file_name") or "") + self.assertEqual(saved_name, f"{wo}海运默认.xlsx") texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"] self.assertIn(f"工单{wo}的1个附件已保存到后台工单", texts) self.assertTrue(any("成交" in t for t in texts)) diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryPriceLine.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryPriceLine.java new file mode 100644 index 0000000..79d7b63 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryPriceLine.java @@ -0,0 +1,27 @@ +package org.jeecg.modules.inquiry.entity; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.io.Serializable; + +/** + * 后台成本价 / 销售价 / 利润点的一行。不入库,列表和详情响应里临时带上。 + *

+ * name 是费用名,最后一行固定为「费用合计」。text 是金额或百分比。 + */ +@Data +@Accessors(chain = true) +@ApiModel("报价逐行展示") +public class InquiryPriceLine implements Serializable { + + private static final long serialVersionUID = 1L; + + @ApiModelProperty("费用名") + private String name; + + @ApiModelProperty("金额或百分比") + private String text; +} 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 2d10446..dbdef8f 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 @@ -107,6 +107,28 @@ public class InquiryTicket implements Serializable { @ApiModelProperty("毛利率展示") private String profitRate; + /** + * 成本价逐行。不入库,列表/详情 enrich 时按报价记录重算。 + * 空运整列为空。最后一行是「费用合计」。 + */ + @TableField(exist = false) + @ApiModelProperty("成本价逐行(展示)") + private List costLines; + + /** + * 销售价逐行。不入库。还没调价的海运/陆运整列为空。 + */ + @TableField(exist = false) + @ApiModelProperty("销售价逐行(展示)") + private List saleLines; + + /** + * 利润点逐行。不入库。没调过价为空。最后一行是整单利润点。 + */ + @TableField(exist = false) + @ApiModelProperty("利润点逐行(展示)") + private List profitLines; + @ApiModelProperty("是否命中 TMS") private Integer tmsHit; diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java index bfff547..2041af1 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java @@ -18,6 +18,7 @@ import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateVersionMapper; import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService; import org.jeecg.modules.inquiry.template.QuoteTemplateAiParseClient; import org.jeecg.modules.inquiry.template.QuoteTemplateExcelSupport; +import org.jeecg.modules.inquiry.template.QuoteSheetFileName; import org.jeecg.modules.inquiry.template.QuoteTemplateMatcher; import org.jeecg.modules.inquiry.template.QuoteTemplateObjectStore; import org.jeecg.modules.inquiry.template.QuoteTemplateSnapshotRenderer; @@ -770,7 +771,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl + * 职责:从已保存的报价明细铺开展示。不改调价算法,不把各行金额加总冒充费用合计。 + * 海运/陆运:成本是调价前生效价(手工同名盖 TMS,多出来的补上);销售是最新调价。 + * 空运:成本整列空;销售是当前生效价;利润点只在调过价之后,对调价前销售价。 + * 副作用:改 ticket 的三列逐行;手工盖价算出新的官方总价时,同时改成本价和利润点字符串。不写库。 + * 调用:{@link InquiryTicketPriceSupport#apply} 末尾。列表 enrich 与保存报价后的 update 都走那里。 + */ +public final class InquiryQuoteLineSupport { + + static final String TOTAL_NAME = "费用合计"; + + private static final String[] BUCKETS = {"polCostItems", "oceanCostItems", "podCostItems"}; + + private InquiryQuoteLineSupport() { + } + + /** + * 按已经算好的总价字符串,补上三列逐行。 + * + * @param ticket 已写入 costPrice / salePrice / profitRate 的工单 + * @param quotes 该单全部报价,顺序不限,内部按来源取最后一条 + */ + public static void fill(InquiryTicket ticket, List quotes) { + if (ticket == null) { + return; + } + boolean air = InquiryTicketPriceSupport.isAir(ticket.getBusinessLine()); + InquiryQuote lastTms = null; + InquiryQuote lastManual = null; + InquiryQuote lastAdjust = null; + if (quotes != null) { + for (InquiryQuote quote : quotes) { + if (quote == null) { + continue; + } + if (InquiryQuoteDisplaySupport.isSalesAdjust(quote.getSource(), quote.getSourceLabel())) { + lastAdjust = quote; + } else if (InquiryQuoteDisplaySupport.isTms(quote.getSource(), quote.getSourceLabel())) { + lastTms = quote; + } else { + lastManual = quote; + } + } + } + List tmsFees = feesOf(lastTms); + List manualFees = feesOf(lastManual); + List adjustFees = feesOf(lastAdjust); + + String costTotal = ""; + List costFees = new ArrayList(); + if (!air) { + costTotal = StringUtils.defaultString(ticket.getCostPrice()); + if (!manualFees.isEmpty() && !tmsFees.isEmpty()) { + costFees = overlay(tmsFees, manualFees); + String recomputed = overlayTotal(priceText(lastTms), tmsFees, costFees); + if (StringUtils.isNotBlank(recomputed)) { + costTotal = recomputed; + ticket.setCostPrice(costTotal); + } + } else if (!manualFees.isEmpty()) { + costFees = manualFees; + } else { + costFees = tmsFees; + } + } else { + ticket.setCostPrice(""); + } + + boolean adjusted = lastAdjust != null; + String saleTotal = ""; + List saleFees = new ArrayList(); + if (air) { + saleTotal = StringUtils.defaultString(ticket.getSalePrice()); + InquiryQuote saleQuote = lastAdjust != null ? lastAdjust : (lastManual != null ? lastManual : lastTms); + saleFees = feesOf(saleQuote); + } else if (adjusted) { + saleTotal = StringUtils.defaultString(ticket.getSalePrice()); + saleFees = adjustFees; + } else { + ticket.setSalePrice(""); + ticket.setProfitRate(""); + } + + List baselineFees; + if (air) { + if (!manualFees.isEmpty() && !tmsFees.isEmpty()) { + baselineFees = overlay(tmsFees, manualFees); + } else if (!manualFees.isEmpty()) { + baselineFees = manualFees; + } else { + baselineFees = tmsFees; + } + } else { + baselineFees = costFees; + } + + String profitTotal = ""; + if (adjusted && StringUtils.isNotBlank(saleTotal)) { + String locked = profitBaseline(lastAdjust); + String baseTotal; + if (StringUtils.isNotBlank(locked)) { + baseTotal = locked; + } else if (air) { + baseTotal = lastManual != null ? priceText(lastManual) : priceText(lastTms); + } else { + baseTotal = costTotal; + } + profitTotal = StringUtils.defaultString(InquiryTicketPriceSupport.profitText(saleTotal, baseTotal)); + ticket.setProfitRate(profitTotal); + } else if (!adjusted) { + ticket.setProfitRate(""); + } + + if (air) { + ticket.setCostLines(Collections.emptyList()); + ticket.setSaleLines(moneyLines(saleFees, saleTotal)); + ticket.setProfitLines(adjusted && StringUtils.isNotBlank(profitTotal) + ? profitAgainst(baselineFees, saleFees, profitTotal) + : Collections.emptyList()); + return; + } + if (!adjusted) { + ticket.setCostLines(moneyLines(costFees, costTotal)); + ticket.setSaleLines(Collections.emptyList()); + ticket.setProfitLines(Collections.emptyList()); + return; + } + List rows = align(costFees, saleFees); + ticket.setCostLines(project(rows, true, costTotal)); + ticket.setSaleLines(project(rows, false, saleTotal)); + ticket.setProfitLines(profitFromAligned(rows, profitTotal)); + } + + /** + * 手工盖 TMS:同名按出现顺序盖金额,没有的保留,手工多出来的补在后面。 + * 同名两行不并成一行。 + */ + static List overlay(List base, List incoming) { + List rows = new ArrayList(); + for (FeeRow row : base) { + rows.add(row.copy()); + } + boolean[] used = new boolean[rows.size()]; + if (incoming == null) { + return rows; + } + for (FeeRow item : incoming) { + int idx = firstUnused(rows, used, item.name); + if (idx < 0) { + rows.add(item.copy()); + continue; + } + used[idx] = true; + FeeRow old = rows.get(idx).copy(); + old.text = item.text; + old.amount = item.amount; + if (StringUtils.isNotBlank(item.currency)) { + old.currency = item.currency; + } + rows.set(idx, old); + } + return rows; + } + + /** + * 费用合计:先用 TMS 官方总价,再加同币种差额。币种不同的行不进差额,也不做汇率。 + */ + static String overlayTotal(String tmsPriceText, List oldRows, List newRows) { + BigDecimal base = InquiryTicketPriceSupport.parseAmount(tmsPriceText); + if (base == null) { + return StringUtils.trimToEmpty(tmsPriceText); + } + String totalCcy = currencyOf(tmsPriceText); + boolean[] used = new boolean[oldRows == null ? 0 : oldRows.size()]; + BigDecimal delta = BigDecimal.ZERO; + if (newRows != null) { + for (FeeRow neu : newRows) { + if (neu.amount == null) { + continue; + } + String lineCcy = StringUtils.isNotBlank(neu.currency) ? neu.currency : totalCcy; + if (StringUtils.isNotBlank(lineCcy) && StringUtils.isNotBlank(totalCcy) && !lineCcy.equals(totalCcy)) { + continue; + } + int idx = firstUnused(oldRows, used, neu.name); + if (idx < 0) { + delta = delta.add(neu.amount); + continue; + } + used[idx] = true; + FeeRow old = oldRows.get(idx); + if (old.amount == null) { + continue; + } + delta = delta.add(neu.amount.subtract(old.amount)); + } + } + return formatMoney(base.add(delta), totalCcy); + } + + private static List moneyLines(List fees, String totalText) { + List lines = new ArrayList(); + if (fees != null) { + for (FeeRow fee : fees) { + lines.add(line(fee.name, fee.text)); + } + } + if (StringUtils.isNotBlank(totalText)) { + lines.add(line(TOTAL_NAME, totalText)); + } + return lines; + } + + private static List project(List rows, boolean costSide, String totalText) { + List lines = new ArrayList(); + for (Aligned row : rows) { + FeeRow side = costSide ? row.left : row.right; + lines.add(line(row.name, side == null ? "" : side.text)); + } + if (StringUtils.isNotBlank(totalText)) { + lines.add(line(TOTAL_NAME, totalText)); + } + return lines; + } + + /** + * 利润点按销售价的费用顺序,对调价前同名行(按出现顺序,不并行)。 + * 对不上或调价前金额为 0:这一行留空。费用合计用整单利润点。 + */ + private static List profitAgainst(List baseline, List sale, String totalProfit) { + boolean[] used = new boolean[baseline == null ? 0 : baseline.size()]; + List lines = new ArrayList(); + if (sale != null) { + for (FeeRow row : sale) { + int idx = firstUnused(baseline, used, row.name); + FeeRow base = idx < 0 ? null : baseline.get(idx); + if (idx >= 0) { + used[idx] = true; + } + lines.add(line(row.name, percentOf(base, row))); + } + } + if (StringUtils.isNotBlank(totalProfit)) { + lines.add(line(TOTAL_NAME, totalProfit)); + } + return lines; + } + + private static List profitFromAligned(List rows, String totalProfit) { + List lines = new ArrayList(); + for (Aligned row : rows) { + lines.add(line(row.name, percentOf(row.left, row.right))); + } + if (StringUtils.isNotBlank(totalProfit)) { + lines.add(line(TOTAL_NAME, totalProfit)); + } + return lines; + } + + private static String percentOf(FeeRow before, FeeRow after) { + if (before == null || after == null || before.amount == null || after.amount == null) { + return ""; + } + if (before.amount.compareTo(BigDecimal.ZERO) == 0) { + return ""; + } + return InquiryTicketPriceSupport.profitText(after.text, before.text); + } + + private static List align(List left, List right) { + List safeRight = right == null ? Collections.emptyList() : right; + boolean[] used = new boolean[safeRight.size()]; + List out = new ArrayList(); + if (left != null) { + for (FeeRow row : left) { + int idx = firstUnused(safeRight, used, row.name); + FeeRow match = null; + if (idx >= 0) { + used[idx] = true; + match = safeRight.get(idx); + } + out.add(new Aligned(row.name, row, match)); + } + } + for (int i = 0; i < safeRight.size(); i++) { + if (!used[i]) { + FeeRow row = safeRight.get(i); + out.add(new Aligned(row.name, null, row)); + } + } + return out; + } + + private static int firstUnused(List rows, boolean[] used, String name) { + if (rows == null) { + return -1; + } + for (int i = 0; i < rows.size(); i++) { + if (used != null && i < used.length && used[i]) { + continue; + } + if (sameFee(name, rows.get(i).name)) { + return i; + } + } + return -1; + } + + /** + * 费用名对齐:去空格后全等,或只差「费 / 费用 / 用」。操作费不等于码头操作费。 + */ + static boolean sameFee(String left, String right) { + String a = feeKey(left); + String b = feeKey(right); + if (a.isEmpty() || b.isEmpty()) { + return false; + } + if (a.equals(b)) { + return true; + } + if (a.equals(b + "用") || a.equals(b + "费用") || b.equals(a + "用") || b.equals(a + "费用")) { + return true; + } + return (a + "费").equals(b) || (b + "费").equals(a); + } + + private static String feeKey(String name) { + String raw = StringUtils.trimToEmpty(name).replace(" ", "").replace("(", "(").replace(")", ")"); + int slash = raw.indexOf('/'); + if (slash < 0) { + slash = raw.indexOf('/'); + } + if (slash > 0) { + raw = raw.substring(0, slash); + } + return raw.trim(); + } + + private static List feesOf(InquiryQuote quote) { + JSONObject payload = payloadOf(quote); + if (payload == null) { + return new ArrayList(); + } + List rows = readFees(payload.get("fee_lines")); + if (rows.isEmpty()) { + rows = readFees(payload.get("fee_rows")); + } + if (rows.isEmpty()) { + JSONObject selected = asObject(payload.get("selected_air_option")); + if (selected == null) { + selected = asObject(payload.get("selectedAirOption")); + } + if (selected != null) { + rows = readFees(firstPresent(selected, "departureCharges", "departure_charges", "feeItems")); + } + } + if (rows.isEmpty()) { + JSONArray options = asArray(payload.get("airOptions")); + if (options == null) { + options = asArray(payload.get("air_options")); + } + if (options != null) { + for (int i = 0; i < options.size(); i++) { + JSONObject option = asObject(options.get(i)); + if (option == null) { + continue; + } + rows = readFees(firstPresent(option, "departureCharges", "departure_charges", "feeItems")); + if (!rows.isEmpty()) { + break; + } + } + } + } + // 扁平明细为空才读海运三段。某一段有 fee_rows_ 就用它,没有才用原桶,避免同一段加两遍。 + if (rows.isEmpty()) { + for (String bucket : BUCKETS) { + List keyed = readFees(payload.get("fee_rows_" + bucket)); + if (!keyed.isEmpty()) { + rows.addAll(keyed); + } else { + rows.addAll(readFees(payload.get(bucket))); + } + } + } + return rows; + } + + private static Object firstPresent(JSONObject obj, String... keys) { + for (String key : keys) { + if (obj.get(key) != null) { + return obj.get(key); + } + } + return null; + } + + private static List readFees(Object raw) { + JSONArray array = asArray(raw); + List rows = new ArrayList(); + if (array == null) { + return rows; + } + for (int i = 0; i < array.size(); i++) { + FeeRow row = toFee(asObject(array.get(i))); + if (row != null) { + rows.add(row); + } + } + return rows; + } + + private static FeeRow toFee(JSONObject item) { + if (item == null) { + return null; + } + String name = firstText(item, "name", "chargeName", "feeName", "label"); + if (StringUtils.isBlank(name) || isSubtotal(name) || isTotalAlias(name)) { + return null; + } + String amount = firstText(item, "amount", "totalPrice", "value"); + if (StringUtils.isBlank(amount) || "-".equals(amount)) { + amount = firstText(item, "unitPrice", "unit_price"); + } + if (StringUtils.isBlank(amount) || "-".equals(amount)) { + return null; + } + String currency = firstText(item, "currency", "currencyCode").toUpperCase(); + if ("RMB".equals(currency)) { + currency = "CNY"; + } + String text = amount; + String upper = amount.toUpperCase(); + if (StringUtils.isNotBlank(currency) && !upper.startsWith(currency) && !upper.startsWith("RMB")) { + text = currency + " " + amount; + } + FeeRow row = new FeeRow(); + row.name = name; + row.text = text; + row.currency = StringUtils.isNotBlank(currency) ? currency : currencyOf(text); + row.amount = InquiryTicketPriceSupport.parseAmount(text); + return row; + } + + private static boolean isSubtotal(String name) { + return feeKey(name).contains("小计"); + } + + private static boolean isTotalAlias(String name) { + String key = feeKey(name); + return "费用合计".equals(key) || "费用总计".equals(key) || "总销售价".equals(key) + || "总成本价".equals(key) || "汇总价".equals(key) || "总价".equals(key) + || "报价合计".equals(key); + } + + private static String profitBaseline(InquiryQuote quote) { + JSONObject payload = payloadOf(quote); + if (payload == null) { + return ""; + } + String locked = firstText(payload, "profit_baseline", "profitBaseline"); + return StringUtils.trimToEmpty(locked); + } + + private static JSONObject payloadOf(InquiryQuote quote) { + if (quote == null || StringUtils.isBlank(quote.getPayloadJson())) { + return null; + } + try { + return JSON.parseObject(quote.getPayloadJson()); + } catch (Exception ignored) { + return null; + } + } + + private static JSONObject asObject(Object raw) { + if (raw instanceof JSONObject) { + return (JSONObject) raw; + } + if (raw instanceof String && StringUtils.isNotBlank((String) raw)) { + try { + Object parsed = JSON.parse((String) raw); + if (parsed instanceof String) { + parsed = JSON.parse((String) parsed); + } + if (parsed instanceof JSONObject) { + return (JSONObject) parsed; + } + } catch (Exception ignored) { + return null; + } + } + return null; + } + + private static JSONArray asArray(Object raw) { + if (raw instanceof JSONArray) { + return (JSONArray) raw; + } + if (raw instanceof List) { + JSONArray array = new JSONArray(); + for (Object item : (List) raw) { + array.add(item); + } + return array; + } + if (raw instanceof String && StringUtils.isNotBlank((String) raw)) { + try { + Object parsed = JSON.parse((String) raw); + if (parsed instanceof String) { + parsed = JSON.parse((String) parsed); + } + if (parsed instanceof JSONArray) { + return (JSONArray) parsed; + } + } catch (Exception ignored) { + return null; + } + } + return null; + } + + private static String firstText(JSONObject obj, String... keys) { + for (String key : keys) { + String text = StringUtils.trimToEmpty(obj.getString(key)); + if (StringUtils.isNotBlank(text)) { + return text; + } + } + return ""; + } + + private static String priceText(InquiryQuote quote) { + return quote == null ? "" : StringUtils.trimToEmpty(quote.getPriceText()); + } + + private static String currencyOf(String priceText) { + String text = StringUtils.trimToEmpty(priceText).toUpperCase(); + if (text.startsWith("RMB")) { + return "CNY"; + } + if (text.length() >= 3 && Character.isLetter(text.charAt(0)) + && Character.isLetter(text.charAt(1)) && Character.isLetter(text.charAt(2))) { + return text.substring(0, 3); + } + return ""; + } + + private static String formatMoney(BigDecimal amount, String currency) { + String body = amount.setScale(2, RoundingMode.HALF_UP).toPlainString(); + if (StringUtils.isBlank(currency)) { + return body; + } + return currency + " " + body; + } + + private static InquiryPriceLine line(String name, String text) { + InquiryPriceLine row = new InquiryPriceLine(); + row.setName(name); + row.setText(StringUtils.defaultString(text)); + return row; + } + + /** 一条费用的展示金额。amount 是比利润用的数字,text 是原样带币种的写法。 */ + static final class FeeRow { + private String name; + private String text; + private String currency; + private BigDecimal amount; + + private FeeRow copy() { + FeeRow row = new FeeRow(); + row.name = name; + row.text = text; + row.currency = currency; + row.amount = amount; + return row; + } + } + + /** 成本侧与销售侧对上的一行。某一侧没有时为 null。 */ + private static final class Aligned { + private final String name; + private final FeeRow left; + private final FeeRow right; + + private Aligned(String name, FeeRow left, FeeRow right) { + this.name = name; + this.left = left; + this.right = right; + } + } +} 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 0434f6c..2cfb41e 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 @@ -15,7 +15,7 @@ import java.util.regex.Pattern; *

* 海运/陆运:TMS、产品报价是成本;销售调价后才有销售价,利润点=(销售-成本)/成本。 * 空运:成本价始终空;TMS/航线/调价都是销售侧;利润点只在销售调过价后算。 - * 副作用:只改传入 ticket 的三个展示字段,不写库。调用方决定是否 updateById。 + * 副作用:只改传入 ticket 的展示字段(三个总价字符串和三列逐行),不写库。调用方决定是否 updateById。 */ public final class InquiryTicketPriceSupport { @@ -65,17 +65,19 @@ public final class InquiryTicketPriceSupport { ticket.setSalePrice(StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice); ticket.setProfitRate(""); } - return; - } - String cost = StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice; - ticket.setCostPrice(StringUtils.defaultString(cost)); - if (StringUtils.isNotBlank(adjustPrice)) { - ticket.setSalePrice(adjustPrice); - ticket.setProfitRate(profitText(adjustPrice, cost)); } else { - ticket.setSalePrice(""); - ticket.setProfitRate(""); + String cost = StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice; + ticket.setCostPrice(StringUtils.defaultString(cost)); + if (StringUtils.isNotBlank(adjustPrice)) { + ticket.setSalePrice(adjustPrice); + ticket.setProfitRate(profitText(adjustPrice, cost)); + } else { + ticket.setSalePrice(""); + ticket.setProfitRate(""); + } } + // 三个总价定完后,按同一批报价拆成逐行。有手工明细盖 TMS 时,这里会改写成本价和利润点。 + InquiryQuoteLineSupport.fill(ticket, quotes); } static boolean isAir(String businessLine) { diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteSheetFileName.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteSheetFileName.java new file mode 100644 index 0000000..1650913 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteSheetFileName.java @@ -0,0 +1,57 @@ +package org.jeecg.modules.inquiry.template; + +import org.apache.commons.lang3.StringUtils; + +/** + * 系统生成报价单的对外文件名:工单号接后台模板名,再加 .xlsx。 + * + *

主账填的是 Excel。PDF 由智能体转完后再改成 .pdf。 + * 航线、产品上传的附件不走本类。 + */ +public final class QuoteSheetFileName { + + private static final String[] SUFFIXES = {".xlsx", ".xls", ".xlsm", ".pdf"}; + + private QuoteSheetFileName() { + } + + /** + * 工单号原样接上后台模板名,并带 .xlsx。模板名为空时退回「工单号_quote.xlsx」。 + * + * @param workOrderNo 工单号 + * @param templateName 后台模板名称 + * @return 对外文件名 + */ + public static String of(String workOrderNo, String templateName) { + String no = StringUtils.defaultString(workOrderNo).trim(); + String name = plain(templateName); + if (StringUtils.isBlank(no)) { + return StringUtils.defaultIfBlank(name, "quote") + ".xlsx"; + } + if (StringUtils.isBlank(name)) { + return no + "_quote.xlsx"; + } + return no + name + ".xlsx"; + } + + /** 去掉已知后缀和路径非法字符,中文保留。 */ + static String plain(String name) { + String text = StringUtils.defaultString(name).trim(); + String lower = text.toLowerCase(); + for (String ext : SUFFIXES) { + if (lower.endsWith(ext)) { + text = text.substring(0, text.length() - ext.length()).trim(); + break; + } + } + StringBuilder out = new StringBuilder(); + for (int i = 0; i < text.length(); i++) { + char ch = text.charAt(i); + if (ch < 32 || "\\/:*?\"<>|".indexOf(ch) >= 0) { + continue; + } + out.append(ch); + } + return out.toString().trim(); + } +} 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 new file mode 100644 index 0000000..88bbbc1 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryQuoteLineSupportTest.java @@ -0,0 +1,216 @@ +package org.jeecg.modules.inquiry.support; + +import org.jeecg.modules.inquiry.entity.InquiryPriceLine; +import org.jeecg.modules.inquiry.entity.InquiryQuote; +import org.jeecg.modules.inquiry.entity.InquiryTicket; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 后台三列逐行:费用名 + 金额,最后一行费用合计用官方总价。 + */ +public class InquiryQuoteLineSupportTest { + + @Test + public void seaUnadjustedShowsCostLinesAndLeavesSaleEmpty() { + InquiryTicket ticket = ticket("SEA"); + InquiryTicketPriceSupport.apply(ticket, Collections.singletonList(quote( + "TMS", "TMS标准报价", "CNY 1200.00", + "{\"currency\":\"CNY\",\"total\":\"1200.00\",\"fee_lines\":[" + + "{\"name\":\"运输费\",\"amount\":\"1000\",\"currency\":\"CNY\"}," + + "{\"name\":\"卸货费\",\"amount\":\"200\",\"currency\":\"CNY\"}" + + "]}"))); + assertEquals("CNY 1200.00", ticket.getCostPrice()); + assertEquals("", ticket.getSalePrice()); + assertEquals("", 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()); + } + + @Test + public void seaAdjustsOneFeeAndKeepsOfficialTotal() { + InquiryTicket ticket = ticket("LAND"); + InquiryTicketPriceSupport.apply(ticket, Arrays.asList( + quote("TMS", "TMS标准报价", "CNY 1200.00", + "{\"currency\":\"CNY\",\"total\":\"1200.00\",\"fee_lines\":[" + + "{\"name\":\"运输费\",\"amount\":\"1000\",\"currency\":\"CNY\"}," + + "{\"name\":\"卸货费\",\"amount\":\"200\",\"currency\":\"CNY\"}]}"), + quote("SALES_ADJUST", "销售调价", "CNY 1220.00", + "{\"currency\":\"CNY\",\"total\":\"1220.00\",\"profit_baseline\":\"CNY 1200.00\",\"fee_lines\":[" + + "{\"name\":\"运输费\",\"amount\":\"1000\",\"currency\":\"CNY\"}," + + "{\"name\":\"卸货费\",\"amount\":\"220\",\"currency\":\"CNY\"}]}"))); + assertLines(ticket.getCostLines(), + "运输费", "CNY 1000", + "卸货费", "CNY 200", + "费用合计", "CNY 1200.00"); + assertLines(ticket.getSaleLines(), + "运输费", "CNY 1000", + "卸货费", "CNY 220", + "费用合计", "CNY 1220.00"); + assertLines(ticket.getProfitLines(), + "运输费", "0%", + "卸货费", "10%", + "费用合计", "1.67%"); + assertEquals("1.67%", ticket.getProfitRate()); + } + + @Test + public void totalOnlyAdjustLeavesFeePercentsAtZero() { + InquiryTicket ticket = ticket("SEA"); + InquiryTicketPriceSupport.apply(ticket, Arrays.asList( + quote("TMS", "TMS标准报价", "CNY 2000.00", + "{\"currency\":\"CNY\",\"total\":\"2000.00\",\"fee_lines\":[" + + "{\"name\":\"增点费\",\"amount\":\"100\",\"currency\":\"CNY\"}," + + "{\"name\":\"拆板费\",\"amount\":\"100\",\"currency\":\"CNY\"}]}"), + quote("SALES_ADJUST", "销售调价", "CNY 2100.00", + "{\"currency\":\"CNY\",\"total\":\"2100.00\",\"fee_lines\":[" + + "{\"name\":\"增点费\",\"amount\":\"100\",\"currency\":\"CNY\"}," + + "{\"name\":\"拆板费\",\"amount\":\"100\",\"currency\":\"CNY\"}]}"))); + assertLines(ticket.getProfitLines(), + "增点费", "0%", + "拆板费", "0%", + "费用合计", "5%"); + assertEquals("费用合计", ticket.getSaleLines().get(2).getName()); + assertEquals("CNY 2100.00", ticket.getSaleLines().get(2).getText()); + } + + @Test + public void secondAdjustComparesToFirstBaseline() { + InquiryTicket ticket = ticket("LAND"); + InquiryTicketPriceSupport.apply(ticket, Arrays.asList( + quote("TMS", "TMS标准报价", "CNY 1000.00", + "{\"currency\":\"CNY\",\"total\":\"1000\",\"fee_lines\":[" + + "{\"name\":\"运输费\",\"amount\":\"1000\",\"currency\":\"CNY\"}]}"), + quote("SALES_ADJUST", "销售调价", "CNY 1100.00", + "{\"currency\":\"CNY\",\"total\":\"1100\",\"profit_baseline\":\"1000\",\"fee_lines\":[" + + "{\"name\":\"运输费\",\"amount\":\"1100\",\"currency\":\"CNY\"}]}"), + quote("SALES_ADJUST", "销售调价", "CNY 1300.00", + "{\"currency\":\"CNY\",\"total\":\"1300\",\"profit_baseline\":\"1000\",\"fee_lines\":[" + + "{\"name\":\"运输费\",\"amount\":\"1300\",\"currency\":\"CNY\"}]}"))); + assertLines(ticket.getSaleLines(), "运输费", "CNY 1300", "费用合计", "CNY 1300.00"); + assertLines(ticket.getProfitLines(), "运输费", "30%", "费用合计", "30%"); + } + + @Test + public void airCostStaysEmptyUntilAdjustThenProfitUsesSaleBaseline() { + InquiryTicket before = ticket("AIR"); + InquiryQuote tms = quote("TMS", "TMS标准报价", "CNY 2000.00", + "{\"currency\":\"CNY\",\"total\":\"2000.00\",\"fee_lines\":[" + + "{\"name\":\"空运费\",\"amount\":\"1800\",\"currency\":\"CNY\"}," + + "{\"name\":\"增点费\",\"amount\":\"100\",\"currency\":\"CNY\"}," + + "{\"name\":\"拆板费\",\"amount\":\"100\",\"currency\":\"CNY\"}]}"); + InquiryTicketPriceSupport.apply(before, Collections.singletonList(tms)); + assertTrue(before.getCostLines() == null || before.getCostLines().isEmpty()); + assertEquals("", before.getCostPrice()); + assertEquals("CNY 2000.00", before.getSalePrice()); + assertTrue(before.getProfitLines() == null || before.getProfitLines().isEmpty()); + assertLines(before.getSaleLines(), + "空运费", "CNY 1800", + "增点费", "CNY 100", + "拆板费", "CNY 100", + "费用合计", "CNY 2000.00"); + + InquiryTicket after = ticket("空运"); + InquiryTicketPriceSupport.apply(after, Arrays.asList( + tms, + quote("SALES_ADJUST", "销售调价", "CNY 2100.00", + "{\"currency\":\"CNY\",\"total\":\"2100.00\",\"profit_baseline\":\"CNY 2000.00\",\"fee_lines\":[" + + "{\"name\":\"空运费\",\"amount\":\"1800\",\"currency\":\"CNY\"}," + + "{\"name\":\"增点费\",\"amount\":\"100\",\"currency\":\"CNY\"}," + + "{\"name\":\"拆板费\",\"amount\":\"110\",\"currency\":\"CNY\"}]}"))); + assertTrue(after.getCostLines() == null || after.getCostLines().isEmpty()); + assertLines(after.getProfitLines(), + "空运费", "0%", + "增点费", "0%", + "拆板费", "10%", + "费用合计", "5%"); + } + + @Test + public void airOfficialTotalIsNotTheSumOfDepartureCharges() { + InquiryTicket ticket = ticket("AIR"); + InquiryTicketPriceSupport.apply(ticket, Collections.singletonList(quote( + "TMS", "TMS标准报价", "CNY 7918.25", + "{\"currency\":\"CNY\",\"total\":\"7918.25\",\"selected_air_option\":{" + + "\"departureCharges\":[" + + "{\"name\":\"增点费\",\"amount\":\"100.0\",\"currency\":\"CNY\"}," + + "{\"name\":\"拆板费\",\"amount\":\"100.0\",\"currency\":\"USD\"}" + + "]}}"))); + assertLines(ticket.getSaleLines(), + "增点费", "CNY 100.0", + "拆板费", "USD 100.0", + "费用合计", "CNY 7918.25"); + } + + @Test + public void duplicateFeeNamesStaySeparate() { + InquiryTicket ticket = ticket("LAND"); + InquiryTicketPriceSupport.apply(ticket, Arrays.asList( + quote("TMS", "TMS标准报价", "CNY 10000.00", + "{\"currency\":\"CNY\",\"total\":\"10000\",\"fee_lines\":[" + + "{\"name\":\"运输费\",\"amount\":\"7000\",\"currency\":\"CNY\"}," + + "{\"name\":\"运输费\",\"amount\":\"3000\",\"currency\":\"CNY\"}]}"), + quote("SALES_ADJUST", "销售调价", "CNY 11000.00", + "{\"currency\":\"CNY\",\"total\":\"11000\",\"fee_lines\":[" + + "{\"name\":\"运输费\",\"amount\":\"7700\",\"currency\":\"CNY\"}," + + "{\"name\":\"运输费\",\"amount\":\"3300\",\"currency\":\"CNY\"}]}"))); + assertEquals(3, ticket.getCostLines().size()); + assertEquals("CNY 7000", ticket.getCostLines().get(0).getText()); + assertEquals("CNY 3000", ticket.getCostLines().get(1).getText()); + assertLines(ticket.getProfitLines(), "运输费", "10%", "运输费", "10%", "费用合计", "10%"); + } + + @Test + public void manualOverlayKeepsUntouchedTmsFee() { + InquiryTicket ticket = ticket("SEA"); + InquiryTicketPriceSupport.apply(ticket, Arrays.asList( + quote("TMS", "TMS标准报价", "CNY 1200.00", + "{\"currency\":\"CNY\",\"total\":\"1200.00\",\"fee_lines\":[" + + "{\"name\":\"运输费\",\"amount\":\"1000\",\"currency\":\"CNY\"}," + + "{\"name\":\"卸货费\",\"amount\":\"200\",\"currency\":\"CNY\"}]}"), + quote("PRODUCT", "手工报价", "CNY 320.00", + "{\"currency\":\"CNY\",\"total\":\"320\",\"fee_lines\":[" + + "{\"name\":\"卸货费\",\"amount\":\"300\",\"currency\":\"CNY\"}," + + "{\"name\":\"配送费\",\"amount\":\"20\",\"currency\":\"CNY\"}]}"))); + assertLines(ticket.getCostLines(), + "运输费", "CNY 1000", + "卸货费", "CNY 300", + "配送费", "CNY 20", + "费用合计", "CNY 1320.00"); + assertEquals("CNY 1320.00", ticket.getCostPrice()); + assertTrue(ticket.getSaleLines() == null || ticket.getSaleLines().isEmpty()); + } + + private static void assertLines(List lines, String... nameText) { + assertEquals(nameText.length / 2, lines.size()); + for (int i = 0; i < nameText.length; i += 2) { + assertEquals(nameText[i], lines.get(i / 2).getName()); + assertEquals(nameText[i + 1], lines.get(i / 2).getText()); + } + } + + private static InquiryTicket ticket(String line) { + InquiryTicket t = new InquiryTicket(); + t.setBusinessLine(line); + return t; + } + + private static InquiryQuote quote(String source, String label, String price, String payload) { + InquiryQuote q = new InquiryQuote(); + q.setSource(source); + q.setSourceLabel(label); + q.setPriceText(price); + q.setPayloadJson(payload); + return q; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteSheetFileNameTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteSheetFileNameTest.java new file mode 100644 index 0000000..cd142f3 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteSheetFileNameTest.java @@ -0,0 +1,30 @@ +package org.jeecg.modules.inquiry.template; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * 系统报价单对外文件名:工单号接模板名,并带 .xlsx。 + */ +public class QuoteSheetFileNameTest { + + @Test + public void joinsWorkOrderAndTemplateNameWithoutSuffix() { + assertEquals( + "WO202609100001陆运国内运输拼车报价单.xlsx", + QuoteSheetFileName.of("WO202609100001", "陆运国内运输拼车报价单")); + } + + @Test + public void dropsExtensionOnTemplateName() { + assertEquals( + "WO202609100001海运报价单模板.xlsx", + QuoteSheetFileName.of("WO202609100001", "海运报价单模板.xlsx")); + } + + @Test + public void blankTemplateFallsBackWithoutSuffix() { + assertEquals("WO202609100001_quote.xlsx", QuoteSheetFileName.of("WO202609100001", "")); + } +} diff --git a/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css b/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css index a1fd4e2..699d581 100644 --- a/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css +++ b/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css @@ -570,8 +570,8 @@ body { .ticket-list-table .col-w-quote { width: 140px; } .ticket-list-table .col-w-attach { width: 160px; } .ticket-list-table .col-w-status { width: 84px; } -.ticket-list-table .col-w-price { width: 100px; } -.ticket-list-table .col-w-margin { width: 72px; } +.ticket-list-table .col-w-price { width: 168px; } +.ticket-list-table .col-w-margin { width: 132px; } .ticket-list-table .col-w-ex { width: 110px; } .ticket-list-table .col-w-reason { width: 140px; } .ticket-list-table .col-w-time { width: 132px; } @@ -627,6 +627,20 @@ body { white-space: nowrap; } +.ticket-price-lines { + display: flex; + flex-direction: column; + gap: 2px; + white-space: normal; + font-size: 11px; + line-height: 1.45; + color: #0f172a; +} + +.ticket-price-line.is-total { + font-weight: 800; +} + .ticket-status { display: inline-flex !important; align-items: center !important; diff --git a/inquiry-backend/src/views/inquiry/workOrder/index.vue b/inquiry-backend/src/views/inquiry/workOrder/index.vue index 9a92289..b6d9535 100644 --- a/inquiry-backend/src/views/inquiry/workOrder/index.vue +++ b/inquiry-backend/src/views/inquiry/workOrder/index.vue @@ -19,6 +19,8 @@ import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue"; /** 单条费用明细:名称 + 带币种金额,对齐企微价格明细。 */ type TicketQuoteFee = { name: string; amount: string }; +/** 成本价 / 销售价 / 利润点的一行。最后一行名称是「费用合计」。 */ +type PriceLine = { name: string; text: string }; type TicketQuote = { source: string; price: string; @@ -43,6 +45,9 @@ type TicketRow = { costPrice: string; salePrice: string; profit: string; + costLines: PriceLine[]; + saleLines: PriceLine[]; + profitLines: PriceLine[]; eventException: string; systemException: string; systemExceptionReason: string; @@ -102,6 +107,17 @@ function dash(v: any) { return s ? s : "-"; } +/** 主账逐行。没有行时页面退回原来的总价字符串。 */ +function asPriceLines(raw: any): PriceLine[] { + if (!Array.isArray(raw)) return []; + return raw + .map((row) => ({ + name: String(row?.name ?? "").trim(), + text: String(row?.text ?? "").trim(), + })) + .filter((row) => row.name); +} + /** * 后台报价来源:TMS标准报价 / 手工报价 / 销售调价。 * 禁止把产品报价或销售调价再显示成 TMS。 @@ -386,6 +402,9 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline costPrice: dash(t.costPrice), salePrice: dash(t.salePrice), profit: dash(t.profitRate), + costLines: asPriceLines(t.costLines), + saleLines: asPriceLines(t.saleLines), + profitLines: asPriceLines(t.profitLines), eventException: dash(t.eventException), systemException: dash(t.systemException), systemExceptionReason: dash(t.systemExceptionReason), @@ -692,13 +711,43 @@ function refresh() { {{ row.status }} - {{ row.costPrice }} +

+
+ {{ line.name }}:{{ line.text || "-" }} +
+
+ {{ row.costPrice }} - {{ row.salePrice }} +
+
+ {{ line.name }}:{{ line.text || "-" }} +
+
+ {{ row.salePrice }} - {{ row.profit }} +
+
+ {{ line.name }}:{{ line.text || "-" }} +
+
+ {{ row.profit }} {{ row.eventException }} @@ -804,9 +853,48 @@ function refresh() { 价格与利润
-
{{ current.costPrice }}
-
{{ current.salePrice }}
-
{{ current.profit }}
+
+ +
+
+ {{ line.name }}:{{ line.text || "-" }} +
+
+ {{ current.costPrice }} +
+
+ +
+
+ {{ line.name }}:{{ line.text || "-" }} +
+
+ {{ current.salePrice }} +
+
+ +
+
+ {{ line.name }}:{{ line.text || "-" }} +
+
+ {{ current.profit }} +