From 58df358577e9ccad0038a85078d806ef15cbab74 Mon Sep 17 00:00:00 2001 From: linmianxun <634841751@qq.com> Date: Mon, 5 Oct 2026 18:00:08 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9C=8B=E6=9D=BF=E5=B9=B3=E5=9D=87=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E6=8C=89=E5=8D=8F=E5=90=8C=E5=88=86=E5=8F=91=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=EF=BC=9B=E6=9F=A5=E4=BB=B7=E5=A4=B1=E8=B4=A5=E5=8F=AF?= =?UTF-8?q?=E4=BB=8E=E4=B8=BB=E8=B4=A6=E6=81=A2=E5=A4=8D=EF=BC=9B=E6=8A=A5?= =?UTF-8?q?=E4=BB=B7=E6=A8=A1=E6=9D=BF=E5=8C=B9=E9=85=8D=E4=B8=8E=E5=A1=AB?= =?UTF-8?q?=E8=A1=A8=E6=9B=B4=E7=A8=B3=EF=BC=8C=E5=90=8E=E5=8F=B0=E6=94=B9?= =?UTF-8?q?=E5=90=8D=E8=BF=90=E8=90=A5=E5=90=8E=E5=8F=B0=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- inquiry-agent/agent/ledger/http_ledger.py | 23 +- inquiry-agent/agent/policy/air_text_flow.py | 18 +- inquiry-agent/agent/policy/land_text_flow.py | 19 +- inquiry-agent/agent/policy/sea_text_flow.py | 17 +- .../agent/policy/system_exception.py | 101 ++++++- inquiry-agent/agent/quote_templates/match.py | 20 +- .../tests/test_quote_template_match.py | 55 ++++ inquiry-agent/tests/test_system_exception.py | 86 ++++++ .../impl/InquiryDashboardServiceImpl.java | 266 ++++++++++++++++-- .../impl/InquiryQuoteTemplateServiceImpl.java | 10 +- .../template/QuoteTemplateExcelSupport.java | 198 ++++++++++++- .../template/QuoteTemplateMatcher.java | 26 +- .../QuoteTemplateSnapshotRenderer.java | 85 +----- .../impl/InquiryDashboardServiceImplTest.java | 28 ++ .../template/QuoteTemplateFillTest.java | 116 ++++++++ .../template/QuoteTemplateMatcherTest.java | 30 ++ inquiry-backend/.env.production | 2 +- inquiry-backend/src/hooks/web/useTitle.ts | 2 +- inquiry-backend/src/layouts/inquiry/index.vue | 4 +- .../inquiry/components/ExpandableClamp.vue | 148 ++++++++++ .../src/views/inquiry/login/index.vue | 2 +- .../src/views/inquiry/mock/dashboard.ts | 18 +- .../views/inquiry/styles/admin-fidelity.css | 23 +- .../inquiry/styles/dashboard-prototype.css | 14 +- .../views/inquiry/workOrder/demandFields.ts | 230 +++++++++++++++ .../src/views/inquiry/workOrder/index.vue | 132 ++++----- .../src/views/sys/login/TokenLoginPage.vue | 2 +- inquiry-backend/tests/demandFields.spec.ts | 63 +++++ 28 files changed, 1493 insertions(+), 245 deletions(-) create mode 100644 inquiry-backend/src/views/inquiry/components/ExpandableClamp.vue create mode 100644 inquiry-backend/src/views/inquiry/workOrder/demandFields.ts create mode 100644 inquiry-backend/tests/demandFields.spec.ts diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py index 92984c8..5a59331 100644 --- a/inquiry-agent/agent/ledger/http_ledger.py +++ b/inquiry-agent/agent/ledger/http_ledger.py @@ -118,15 +118,28 @@ class HttpLedger: facts: dict[str, str], business_line: str = "", ) -> dict[str, Any]: + """ + 打主账查 TMS。查价可能慢于默认 10s:单独放宽,仍禁止无限等。 + + 若仍超时,上层 query_tms_resilient 会回读主账:已 persistHit 有价则按成功续办。 + """ body: dict[str, Any] = {"work_order_no": work_order_no, "facts": facts} line = (business_line or str((facts or {}).get("业务线") or "")).strip() if line: body["businessLine"] = line - out = self._post( - "/inquiry/agent/tms/query", - body, - idem=f"tms:{work_order_no}", - ) + # TMS 出站 + persistHit 常超过默认 10s,超时会被误判成接口异常。 + prev_timeout = self._client.timeout_seconds + self._client.timeout_seconds = max(float(prev_timeout or 10), 45.0) + self._client.close() + try: + out = self._post( + "/inquiry/agent/tms/query", + body, + idem=f"tms:{work_order_no}", + ) + finally: + self._client.timeout_seconds = prev_timeout + self._client.close() if "classification" not in out and not out.get("ok"): return { "ok": False, diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index 0247839..286ca08 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -1212,6 +1212,7 @@ class AirTextInquiryFlow: from agent.policy.system_exception import ( confirm_tms_quote_if_tech, query_tms_resilient, + recover_tms_query_from_ledger, ) tms = query_tms_resilient(self._ledger, work_order_no=no, facts=facts) @@ -1238,12 +1239,17 @@ class AirTextInquiryFlow: ): self.drop_current_bookmark(sender_id) return "system_exception" - if tms.get("classification") == "TMS_QUERY_INCOMPLETE": - logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification")) - reply("TMS 查询失败,请稍后重试。工单已创建:" + no) - sess.phase = "tms_tech" - self._save(sess) - return "tms_tech" + # 主账已落价但回包超时:按成功续办,禁止再报接口异常 + recovered = recover_tms_query_from_ledger(self._ledger, work_order_no=no) + if recovered is not None: + tms = recovered + else: + if tms.get("classification") == "TMS_QUERY_INCOMPLETE": + logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification")) + reply("TMS 查询失败,请稍后重试。工单已创建:" + no) + sess.phase = "tms_tech" + self._save(sess) + return "tms_tech" if not tms.get("has_price"): # ok=true 才是 TMS 已查过、确认没价 self._emit_tms_miss(reply, no) diff --git a/inquiry-agent/agent/policy/land_text_flow.py b/inquiry-agent/agent/policy/land_text_flow.py index 3dd7ac0..527b3de 100644 --- a/inquiry-agent/agent/policy/land_text_flow.py +++ b/inquiry-agent/agent/policy/land_text_flow.py @@ -390,7 +390,10 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): _ = retrying no = (sess.work_order_no or "").strip() if not tms.get("ok"): - from agent.policy.system_exception import confirm_tms_quote_if_tech + from agent.policy.system_exception import ( + confirm_tms_quote_if_tech, + recover_tms_query_from_ledger, + ) if confirm_tms_quote_if_tech( ledger=self._ledger, @@ -402,11 +405,15 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): ): self.drop_current_bookmark(sess.sender_id) return "system_exception" - reply("TMS 查询失败,请稍后重试。工单已创建:" + no) - sess.phase = "tms_tech" - self._save(sess) - self._park_tms_retry(sess) - return "tms_tech" + recovered = recover_tms_query_from_ledger(self._ledger, work_order_no=no) + if recovered is not None: + tms = recovered + else: + reply("TMS 查询失败,请稍后重试。工单已创建:" + no) + sess.phase = "tms_tech" + self._save(sess) + self._park_tms_retry(sess) + return "tms_tech" self._clear_parked_confirm(sess.sender_id) if not tms.get("has_price"): self._emit_tms_miss(reply, no) diff --git a/inquiry-agent/agent/policy/sea_text_flow.py b/inquiry-agent/agent/policy/sea_text_flow.py index 5bd626e..619c3a2 100644 --- a/inquiry-agent/agent/policy/sea_text_flow.py +++ b/inquiry-agent/agent/policy/sea_text_flow.py @@ -416,6 +416,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): from agent.policy.system_exception import ( confirm_tms_quote_if_tech, query_tms_resilient, + recover_tms_query_from_ledger, ) tms = query_tms_resilient( @@ -443,12 +444,16 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): ): self.drop_current_bookmark(sender_id) return "system_exception" - if tms.get("classification") == "TMS_QUERY_INCOMPLETE": - logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification")) - reply("TMS 查询失败,请稍后重试。工单已创建:" + no) - sess.phase = "tms_tech" - self._save(sess) - return "tms_tech" + recovered = recover_tms_query_from_ledger(self._ledger, work_order_no=no) + if recovered is not None: + tms = recovered + else: + if tms.get("classification") == "TMS_QUERY_INCOMPLETE": + logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification")) + reply("TMS 查询失败,请稍后重试。工单已创建:" + no) + sess.phase = "tms_tech" + self._save(sess) + return "tms_tech" if not tms.get("has_price"): self._emit_tms_miss(reply, no) sess.phase = "tms_miss" diff --git a/inquiry-agent/agent/policy/system_exception.py b/inquiry-agent/agent/policy/system_exception.py index fc6d4f0..4a53cf2 100644 --- a/inquiry-agent/agent/policy/system_exception.py +++ b/inquiry-agent/agent/policy/system_exception.py @@ -310,8 +310,85 @@ def reply_paused_ticket(*, ticket: Any, reply: ReplyFn) -> None: reply(copy.system_exception_fallback(kind, names=names)) +def _quote_has_usable_total(quote: dict[str, Any] | None) -> bool: + """报价包是否已有可用于出卡的总价。""" + src = dict(quote or {}) + total = str(src.get("total") or src.get("salePrice") or src.get("total_amount") or "").strip() + return bool(total and total != "-") + + +def recover_tms_query_from_ledger(ledger: Any, *, work_order_no: str) -> dict[str, Any] | None: + """ + 查价超时/失败后回读主账:若已 persistHit 落成已报价+销售价,按查价成功续办。 + + 产品口径:主账已经查到价,只是智能体等回包超时,不能标 TMS异常、不能给销售发接口异常, + 应直接推当前工单报价。无价才走真正的系统异常。 + """ + no = (work_order_no or "").strip() + if not no or ledger is None: + return None + getter = getattr(ledger, "get_for_agent", None) + snap: Any = None + if callable(getter): + try: + snap = getter(work_order_no=no, sender_id="") + except TypeError: + try: + snap = getter(work_order_no=no) + except Exception: # noqa: BLE001 + snap = None + except Exception: # noqa: BLE001 + snap = None + if not isinstance(snap, dict) or not snap: + getter2 = getattr(ledger, "get_ticket", None) + if callable(getter2): + try: + snap = getter2(work_order_no=no) + except Exception: # noqa: BLE001 + snap = None + if not isinstance(snap, dict) or not snap: + return None + status = str(snap.get("status") or "").strip() + tms_hit = snap.get("tms_hit") in (1, "1", True, "true") + sale = str(snap.get("sale_price") or snap.get("salePrice") or "").strip() + quote = dict(snap.get("quote") or {}) + tms_quote = dict(snap.get("tms_quote") or snap.get("tmsQuote") or {}) + if not _quote_has_usable_total(quote) and _quote_has_usable_total(tms_quote): + quote = dict(tms_quote) + if not _quote_has_usable_total(quote) and sale and sale != "-": + quote = dict(quote) + quote["total"] = sale + quote.setdefault("source", "TMS") + quote.setdefault("source_label", "TMS标准报价") + priced = status == "已报价" and ( + tms_hit or (sale and sale != "-") or _quote_has_usable_total(quote) + ) + if not priced: + return None + if not _quote_has_usable_total(quote): + return None + logger.warning( + "tms.query 超时/失败但主账已有价,按成功续办 wo=%s total=%s", + no, + quote.get("total"), + ) + return { + "ok": True, + "has_price": True, + "classification": "HAS_PRICE", + "quote": quote, + "recovered": True, + "source": "LEDGER_AFTER_TIMEOUT", + } + + def query_tms_resilient(ledger: Any, **kwargs: Any) -> dict[str, Any]: - """TMS 查价:最多 3 次,ok=true(含无价)即成功。""" + """ + TMS 查价:最多 3 次,ok=true(含无价)即成功。 + + 三次仍失败时,再读主账:若查价过程中已落库有价(常见于智能体超时、主账已 persistHit), + 按成功回包返回,避免误标 TMS异常。 + """ getter = getattr(ledger, "query_tms", None) if not callable(getter): return {"ok": False, "classification": "TMS_TECH_FAILURE", "has_price": False, "quote": {}} @@ -319,7 +396,14 @@ def query_tms_resilient(ledger: Any, **kwargs: Any) -> dict[str, Any]: lambda: getter(**kwargs), is_ok=lambda x: bool((x or {}).get("ok")), ) - return out if isinstance(out, dict) else {"ok": False, "classification": "TMS_TECH_FAILURE"} + result = out if isinstance(out, dict) else {"ok": False, "classification": "TMS_TECH_FAILURE"} + if result.get("ok"): + return result + no = str(kwargs.get("work_order_no") or "").strip() + recovered = recover_tms_query_from_ledger(ledger, work_order_no=no) if no else None + if recovered is not None: + return recovered + return result def confirm_tms_quote_if_tech( @@ -332,9 +416,20 @@ def confirm_tms_quote_if_tech( ticket_status: str = "询价中", facts: dict[str, Any] | None = None, ) -> bool: - """技术失败则标 TMS异常并回话告警。返回是否已按系统异常处理。""" + """ + 技术失败则标 TMS异常并回话告警。返回是否已按系统异常处理。 + + 主账其实已有价时不再标异常(兜底:调用方应优先走 query_tms_resilient 恢复)。 + """ if not is_tms_tech_failure(tms): return False + # 最后一道闸:主账其实已有价时禁止再打 TMS异常(正常应已在 query_tms_resilient 恢复)。 + if recover_tms_query_from_ledger(ledger, work_order_no=work_order_no) is not None: + logger.warning( + "confirm_tms_quote_if_tech 跳过标异常:主账已有价 wo=%s", + work_order_no, + ) + return False err = str(tms.get("classification") or tms.get("error") or "TMS_TECH_FAILURE").strip() confirm_system_exception( event=SystemExceptionEvent( diff --git a/inquiry-agent/agent/quote_templates/match.py b/inquiry-agent/agent/quote_templates/match.py index 7835248..05e2f6f 100644 --- a/inquiry-agent/agent/quote_templates/match.py +++ b/inquiry-agent/agent/quote_templates/match.py @@ -178,7 +178,8 @@ _GENERIC_KEYWORDS = frozenset( _LAND_KEYWORD_ALIASES = { "国内拼车": ("国内运输拼车", "拼车"), "国内零担": ("国内运输拼车", "国内长途/零担", "零担"), - "国内整车": ("国内运输整车", "整车"), + # 「整车」不能当国内整车别名:跨境整车 / 中港整车都含这两个字。 + "国内整车": ("国内运输整车",), "跨境整车": ("跨境整车",), "跨境集拼": ("跨境集拼",), "跨境零担": ("跨境集拼",), @@ -198,6 +199,19 @@ def _keyword_needles(kw: str) -> list[str]: return out +def _needle_in(text: str, needle: str) -> bool: + """ + 陆运针:普通包含。单独「整车」不命中跨境/中港,避免国内整车模板抢走跨境单。 + """ + hay = (text or "").lower() + n = (needle or "").strip().lower() + if not hay or not n or n not in hay: + return False + if n == "整车" and ("跨境" in hay or "中港" in hay): + return False + return True + + def _keyword_score(raw: Any, haystack: str, type_val: str) -> tuple[int, int]: """ 运输类型上的命中优先,其次全文词长。 @@ -212,11 +226,11 @@ def _keyword_score(raw: Any, haystack: str, type_val: str) -> tuple[int, int]: if k in _GENERIC_KEYWORDS: continue needles = _keyword_needles(kw) - if type_val and any(n in type_val for n in needles): + if type_val and any(_needle_in(type_val, n) for n in needles): type_hits += 1 length += len(k) any_hit = True - elif any(n in haystack for n in needles): + elif any(_needle_in(haystack, n) for n in needles): length += len(k) any_hit = True if not any_hit: diff --git a/inquiry-agent/tests/test_quote_template_match.py b/inquiry-agent/tests/test_quote_template_match.py index 6b0cdcb..88095fe 100644 --- a/inquiry-agent/tests/test_quote_template_match.py +++ b/inquiry-agent/tests/test_quote_template_match.py @@ -143,6 +143,61 @@ class QuoteTemplateMatchTests(unittest.TestCase): self.assertIsNotNone(hit) self.assertEqual(hit.name, "跨境整车") + def test_land_admin_domestic_ftl_keyword_does_not_steal_cross(self) -> None: + """后台「国内整车」不得靠「整车」二字抢走跨境整车(WO202610030032)。""" + hit = pick_quote_template( + templates=[ + { + "templateId": "dom", + "name": "国内陆运整车报价单", + "bizType": "陆运", + "keywords": ["国内整车"], + "isDefault": False, + }, + { + "templateId": "xb", + "name": "跨境整车报价单", + "bizType": "陆运", + "keywords": ["东南亚"], + "isDefault": False, + }, + ], + business_line="LAND", + facts={ + "运输类型": "跨境整车", + "线路类别": "东南亚", + "运输分类": "普件(东南亚、中亚、中欧涉及国内+国外段)", + "起运港": "上海", + "目的港": "春疆货场", + }, + ) + self.assertIsNotNone(hit) + self.assertEqual(hit.name, "跨境整车报价单") + + def test_land_admin_domestic_ftl_still_hits_domestic_type(self) -> None: + hit = pick_quote_template( + templates=[ + { + "templateId": "dom", + "name": "国内陆运整车报价单", + "bizType": "陆运", + "keywords": ["国内整车"], + "isDefault": False, + }, + { + "templateId": "xb", + "name": "跨境整车报价单", + "bizType": "陆运", + "keywords": ["东南亚"], + "isDefault": False, + }, + ], + business_line="LAND", + facts={"运输类型": "国内运输整车", "线路类别": "国内长途/零担"}, + ) + self.assertIsNotNone(hit) + self.assertEqual(hit.name, "国内陆运整车报价单") + def test_land_falls_back_to_default(self) -> None: hit = pick_quote_template( templates=[ diff --git a/inquiry-agent/tests/test_system_exception.py b/inquiry-agent/tests/test_system_exception.py index aa7fa40..be14d8a 100644 --- a/inquiry-agent/tests/test_system_exception.py +++ b/inquiry-agent/tests/test_system_exception.py @@ -22,8 +22,11 @@ from agent.policy.system_exception import ( SystemExceptionEvent, call_with_retries, confirm_system_exception, + confirm_tms_quote_if_tech, extract_work_order_nos, is_tms_tech_failure, + query_tms_resilient, + recover_tms_query_from_ledger, should_mark_ticket, ticket_is_paused, ) @@ -196,6 +199,89 @@ class TestSystemExceptionRules(unittest.TestCase): self.assertEqual(out.get("classification"), "TMS_TECH_FAILURE") +class TestTmsTimeoutRecoverPriced(unittest.TestCase): + """查到价但智能体超时:主账已有价则按成功续办,不标 TMS异常。""" + + def setUp(self) -> None: + self.ledger = MemoryLedger() + created = self.ledger.create_ticket( + sender_id="sales1", + business_line="AIR", + facts={"起运港": "南京", "目的港": "吉隆坡", "品名": "茶叶"}, + ) + self.wo = str(created.get("work_order_no") or "") + self.ledger.upsert_quote( + work_order_no=self.wo, + quote={ + "total": "CNY 5001.00", + "currency": "CNY", + "source": "TMS", + "source_label": "TMS标准报价", + }, + to_status="已报价", + ) + + def test_recover_from_ledger_when_already_quoted(self) -> None: + got = recover_tms_query_from_ledger(self.ledger, work_order_no=self.wo) + self.assertIsNotNone(got) + assert got is not None + self.assertTrue(got.get("ok")) + self.assertTrue(got.get("has_price")) + self.assertTrue(got.get("recovered")) + self.assertIn("5001", str((got.get("quote") or {}).get("total") or "")) + + def test_query_resilient_recovers_after_tech_fail(self) -> None: + self.ledger.query_tms = ( # type: ignore[method-assign] + lambda **_kwargs: { + "ok": False, + "classification": "TMS_TECH_FAILURE", + "has_price": False, + "quote": {}, + } + ) + out = query_tms_resilient( + self.ledger, work_order_no=self.wo, facts={"起运港": "南京", "目的港": "吉隆坡"} + ) + self.assertTrue(out.get("ok")) + self.assertTrue(out.get("has_price")) + self.assertTrue(out.get("recovered")) + self.assertEqual(out.get("classification"), "HAS_PRICE") + + def test_confirm_skips_mark_when_ledger_priced(self) -> None: + replies: list[str] = [] + marked = confirm_tms_quote_if_tech( + ledger=self.ledger, + reply=replies.append, + work_order_no=self.wo, + tms={"ok": False, "classification": "TMS_TECH_FAILURE"}, + sender_id="sales1", + ticket_status="询价中", + ) + self.assertFalse(marked) + self.assertEqual(replies, []) + snap = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1") + self.assertFalse(str(snap.get("system_exception") or "").strip()) + + def test_true_tech_fail_still_marks_when_no_price(self) -> None: + created = self.ledger.create_ticket( + sender_id="sales2", + business_line="AIR", + facts={"起运港": "南京", "目的港": "吉隆坡", "品名": "茶叶"}, + ) + wo2 = str(created.get("work_order_no") or "") + replies: list[str] = [] + marked = confirm_tms_quote_if_tech( + ledger=self.ledger, + reply=replies.append, + work_order_no=wo2, + tms={"ok": False, "classification": "TMS_TECH_FAILURE"}, + sender_id="sales2", + ticket_status="询价中", + ) + self.assertTrue(marked) + self.assertTrue(any("TMS接口异常" in x for x in replies)) + + class TestConfirmSystemException(unittest.TestCase): def setUp(self) -> None: self.ledger = MemoryLedger() 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 7e21429..bed9e9a 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 @@ -5,10 +5,14 @@ import org.apache.commons.lang3.StringUtils; import org.apache.shiro.SecurityUtils; import org.jeecg.common.system.vo.LoginUser; import org.jeecg.modules.inquiry.domain.TicketStatuses; +import org.jeecg.modules.inquiry.entity.InquiryQuote; import org.jeecg.modules.inquiry.entity.InquiryStaffRole; import org.jeecg.modules.inquiry.entity.InquiryTicket; +import org.jeecg.modules.inquiry.entity.InquiryTicketStatusLog; +import org.jeecg.modules.inquiry.mapper.InquiryQuoteMapper; import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper; import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper; +import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper; import org.jeecg.modules.inquiry.service.IInquiryDashboardService; import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport; import org.springframework.beans.factory.annotation.Autowired; @@ -22,6 +26,7 @@ import java.time.LocalTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Arrays; import java.util.Comparator; import java.util.Date; import java.util.HashMap; @@ -34,8 +39,11 @@ import java.util.stream.Collectors; /** * 看板实时聚合实现(无快照依赖)。 *

- * 计算口径:询价总数、待处理、TMS 命中率、异常完成率、手工报价占比、 - * 已成交、成交率、反馈率;内部协同时长本期无协同表时返回「-」。 + * 九指标与原型看板一致(5+4 栅格):询价总量、报价工单、标准报价命中率、 + * 平均报价销售额、手工工单占比、待处理工单、报价成功率、报价有效率、 + * 内部报价平均响应。问号 tip 只写计算公式,禁止「本期无协同明细 / N单无勾选…」占位。 + * 内部报价平均响应 = AVG(首次 PRODUCT 报价时间 − 分发时间), + * 分发=bind_collab_group/activate_air_group。 * 人员排名与工单列表同一套发起人解析(企微 id → 员工姓名),禁止只认库内空 sales_name。 * 成本趋势:近 7 日工单按线路聚合均价(成本价优先,缺省用销售价),非浏览器直连 TMS。 * 线程:只读查询,无写锁。 @@ -46,6 +54,9 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService { private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("MM-dd"); private static final int COST_ROUTE_LIMIT = 16; + /** 协同「分发」:海运/陆运建群、空运群激活。 */ + private static final List COLLAB_DISPATCH_ACTIONS = Arrays.asList( + "bind_collab_group", "activate_air_group"); private static final String[] COST_COLORS = { "#2563eb", "#3b82f6", "#0891b2", "#0e7490", "#6366f1", "#8b5cf6", "#a855f7", "#c026d3", "#db2777", "#e11d48", "#dc2626", "#ea580c", @@ -62,6 +73,10 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService { private InquiryTicketMapper ticketMapper; @Autowired private InquiryStaffRoleMapper staffRoleMapper; + @Autowired + private InquiryTicketStatusLogMapper statusLogMapper; + @Autowired + private InquiryQuoteMapper quoteMapper; @Override public Map metrics(String range) { @@ -94,28 +109,24 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService { long pending = total - deal - lost - closed; long tmsHit = tickets.stream().filter(t -> t.getTmsHit() != null && t.getTmsHit() == 1).count(); long manual = tickets.stream().filter(t -> t.getManualQuote() != null && t.getManualQuote() == 1).count(); - long hasEventExc = tickets.stream().filter(t -> hasExc(t.getEventException())).count(); - long hasSysExc = tickets.stream().filter(t -> hasExc(t.getSystemException())).count(); - long abnormalTotal = hasEventExc + hasSysExc; - // 简化:已关闭且曾有异常视为异常处理完成 - long abnormalDone = tickets.stream() - .filter(t -> TicketStatuses.CLOSED.equals(t.getStatus())) - .filter(t -> hasExc(t.getEventException()) || hasExc(t.getSystemException()) - || (t.getCloseReason() != null && t.getCloseReason().contains("异常"))) - .count(); + + String avgResp = avgCollabResponse(tickets); + String avgSale = avgHighlightedSale(tickets); List> metricCards = new ArrayList<>(); - metricCards.add(card("inquiry", "询价总数", "周期内询价工单总数", String.valueOf(total), false)); - metricCards.add(card("pending", "待处理工单", "工单总数 − 已成交 − 未成交 − 已关闭", pending + " 单", false)); + // 顺序对齐原型 5 列栅格:上排总量/报价/TMS/销售额/手工,下排待处理/成功率/有效率/响应 + metricCards.add(card("inquiry", "询价总量", "周期内询价工单总数", String.valueOf(total), false)); + metricCards.add(card("quoted", "报价工单", "已报价工单数(已报价 + 议价中 + 已成交 + 未成交)", + quoted + " 单", false)); metricCards.add(card("tms", "标准报价命中率", "命中 TMS 标准价的询价数 ÷ 询价总数 × 100%", pct(tmsHit, total), false)); - metricCards.add(card("abnormal", "异常处理完成率", "已关闭异常工单数 ÷ 异常工单总数 × 100%", - abnormalTotal == 0 ? "100%" : pct(abnormalDone, Math.max(abnormalTotal, 1)), false)); - metricCards.add(card("manual", "手工报价占比", "人工报价的询价数 ÷ 询价总数 × 100%", pct(manual, total), false)); - metricCards.add(card("accepted", "已成交工单", "状态=已成交的工单数量", String.valueOf(deal), false)); - metricCards.add(card("dealRate", "报价成交率", "已成交数 ÷ 已报价相关数 × 100%", pct(deal, Math.max(quoted, 1)), false)); + metricCards.add(card("avgSale", "平均报价销售额", "AVG(已勾选报价的销售价)", avgSale, false)); + metricCards.add(card("manual", "手工工单占比", "人工报价的询价数 ÷ 询价总数 × 100%", pct(manual, total), false)); + metricCards.add(card("pending", "待处理工单", "工单总数 − 已成交 − 未成交 − 已关闭", pending + " 单", false)); + metricCards.add(card("dealRate", "报价成功率", "已成交数 ÷ 已报价数 × 100%", pct(deal, Math.max(quoted, 1)), false)); long feedbackNumer = deal + lost; - metricCards.add(card("feedback", "报价反馈率", "(已成交 + 未成交)÷ 工单数 × 100%", pct(feedbackNumer, total), false)); - metricCards.add(card("avgResp", "内部协同平均响应时长", "本期无协同明细表,暂无数据", "-", true)); + metricCards.add(card("feedback", "报价有效率", "(已成交 + 未成交)÷ 工单数 × 100%", pct(feedbackNumer, total), false)); + metricCards.add(card("avgResp", "内部报价平均响应", + "AVG(首次有效回复时间 − 分发时间)", avgResp, true)); // 近 7 日趋势 + 成本:一次拉周内工单,避免 7 次 count Date weekFrom = Date.from(today.minusDays(6).atStartOfDay(ZONE).toInstant()); @@ -416,10 +427,6 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService { return time.toInstant().atZone(ZONE).toLocalDate(); } - private static boolean hasExc(String v) { - return v != null && !v.trim().isEmpty() && !"-".equals(v.trim()); - } - private static String pct(long numer, long denom) { if (denom <= 0) { return "0.0%"; @@ -428,6 +435,217 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService { return p.toPlainString() + "%"; } + /** + * 平均报价销售额:勾选报价(highlighted=1)的销售价平均。 + *

+ * 金额优先报价 priceText,没有再用工单 salePrice。没有可解析样本返回「-」, + * 禁止写成 0.0%(这不是比率)。问号文案由调用方固定为公式,这里不算 tip。 + */ + private String avgHighlightedSale(List tickets) { + if (tickets == null || tickets.isEmpty()) { + return "-"; + } + List nos = tickets.stream() + .filter(t -> t != null && StringUtils.isNotBlank(t.getWorkOrderNo())) + .map(InquiryTicket::getWorkOrderNo) + .distinct() + .collect(Collectors.toList()); + if (nos.isEmpty()) { + return "-"; + } + Map ticketSale = new HashMap<>(); + for (InquiryTicket t : tickets) { + if (t == null || StringUtils.isBlank(t.getWorkOrderNo())) { + continue; + } + if (StringUtils.isNotBlank(t.getSalePrice())) { + ticketSale.put(t.getWorkOrderNo().trim(), t.getSalePrice()); + } + } + Map saleByWo = new HashMap<>(); + List quotes = quoteMapper.selectList( + new LambdaQueryWrapper() + .in(InquiryQuote::getWorkOrderNo, nos) + .eq(InquiryQuote::getHighlighted, 1)); + if (quotes != null) { + for (InquiryQuote q : quotes) { + if (q == null || StringUtils.isBlank(q.getWorkOrderNo())) { + continue; + } + String wo = q.getWorkOrderNo().trim(); + String amt = firstNonBlank(q.getPriceText(), ticketSale.get(wo)); + if (StringUtils.isNotBlank(amt)) { + saleByWo.put(wo, amt); + } + } + } + BigDecimal sum = BigDecimal.ZERO; + int n = 0; + for (String amt : saleByWo.values()) { + BigDecimal money = parseMoney(amt); + if (money == null) { + continue; + } + sum = sum.add(money); + n++; + } + if (n <= 0) { + return "-"; + } + return formatAvgSale(sum.divide(BigDecimal.valueOf(n), 2, RoundingMode.HALF_UP)); + } + + /** 平均销售额展示:整数不留小数,否则最多两位。 */ + static String formatAvgSale(BigDecimal avg) { + if (avg == null) { + return "-"; + } + BigDecimal stripped = avg.stripTrailingZeros(); + if (stripped.scale() <= 0) { + return stripped.toPlainString(); + } + return avg.setScale(2, RoundingMode.HALF_UP).toPlainString(); + } + + private static String firstNonBlank(String a, String b) { + if (StringUtils.isNotBlank(a) && !"-".equals(a.trim())) { + return a.trim(); + } + if (StringUtils.isNotBlank(b) && !"-".equals(b.trim())) { + return b.trim(); + } + return ""; + } + + /** + * 内部协同平均响应时长。 + *

+ * 分发时间:状态日志最早的 bind_collab_group / activate_air_group; + * 首次有效回复:分发之后最早的 PRODUCT 报价(航线/产品手工价,不含销售调价与 TMS)。 + * 无样本返回「-」。 + */ + private String avgCollabResponse(List tickets) { + if (tickets == null || tickets.isEmpty()) { + return "-"; + } + List nos = tickets.stream() + .filter(t -> t != null && StringUtils.isNotBlank(t.getWorkOrderNo())) + .map(InquiryTicket::getWorkOrderNo) + .distinct() + .collect(Collectors.toList()); + if (nos.isEmpty()) { + return "-"; + } + Map dispatchByWo = loadEarliestDispatch(nos); + if (dispatchByWo.isEmpty()) { + return "-"; + } + Map> productTimesByWo = loadProductQuoteTimes( + new ArrayList<>(dispatchByWo.keySet())); + long sumMs = 0L; + int samples = 0; + for (Map.Entry e : dispatchByWo.entrySet()) { + Date dispatch = e.getValue(); + if (dispatch == null) { + continue; + } + Date reply = firstReplyAfter(productTimesByWo.get(e.getKey()), dispatch); + if (reply == null) { + continue; + } + long ms = reply.getTime() - dispatch.getTime(); + if (ms < 0) { + continue; + } + sumMs += ms; + samples++; + } + if (samples <= 0) { + return "-"; + } + return formatDurationMs(sumMs / samples); + } + + private Map loadEarliestDispatch(List workOrderNos) { + Map out = new HashMap<>(); + if (workOrderNos == null || workOrderNos.isEmpty()) { + return out; + } + List logs = statusLogMapper.selectList( + new LambdaQueryWrapper() + .in(InquiryTicketStatusLog::getWorkOrderNo, workOrderNos) + .in(InquiryTicketStatusLog::getAction, COLLAB_DISPATCH_ACTIONS) + .orderByAsc(InquiryTicketStatusLog::getCreateTime)); + if (logs == null) { + return out; + } + for (InquiryTicketStatusLog log : logs) { + if (log == null || log.getCreateTime() == null || StringUtils.isBlank(log.getWorkOrderNo())) { + continue; + } + out.putIfAbsent(log.getWorkOrderNo().trim(), log.getCreateTime()); + } + return out; + } + + private Map> loadProductQuoteTimes(List workOrderNos) { + Map> out = new HashMap<>(); + if (workOrderNos == null || workOrderNos.isEmpty()) { + return out; + } + List quotes = quoteMapper.selectList( + new LambdaQueryWrapper() + .in(InquiryQuote::getWorkOrderNo, workOrderNos) + .eq(InquiryQuote::getSource, "PRODUCT") + .orderByAsc(InquiryQuote::getCreateTime)); + if (quotes == null) { + return out; + } + for (InquiryQuote q : quotes) { + if (q == null || q.getCreateTime() == null || StringUtils.isBlank(q.getWorkOrderNo())) { + continue; + } + out.computeIfAbsent(q.getWorkOrderNo().trim(), k -> new ArrayList<>()).add(q.getCreateTime()); + } + return out; + } + + /** 分发之后的首条 PRODUCT 报价时间;没有则 null。 */ + static Date firstReplyAfter(List times, Date dispatch) { + if (times == null || times.isEmpty() || dispatch == null) { + return null; + } + long from = dispatch.getTime(); + for (Date t : times) { + if (t != null && t.getTime() >= from) { + return t; + } + } + return null; + } + + /** + * 毫秒 → 展示串:不足 1 分钟用秒;否则 XmYs / XhYm。 + * 单测可直接调。 + */ + static String formatDurationMs(long ms) { + if (ms < 0) { + return "-"; + } + long totalSec = ms / 1000L; + if (totalSec < 60) { + return totalSec + "s"; + } + long totalMin = totalSec / 60L; + long sec = totalSec % 60L; + if (totalMin < 60) { + return totalMin + "m" + sec + "s"; + } + long hours = totalMin / 60L; + long min = totalMin % 60L; + return hours + "h" + min + "m"; + } + private static Map card(String key, String label, String tip, String value, boolean isTime) { Map m = new LinkedHashMap<>(); m.put("key", key); 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 2041af1..e34920b 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 @@ -646,13 +646,13 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl 0) { return cachedPng; } @@ -665,8 +665,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl 0 ? headerRow1Based - 1 : -1; - if (header0 >= 0 && rowIdx0 == header0 + 1 && rowLooksLikeVehicleHeader(sheet, rowIdx0)) { - return rowIdx0 + 1; + if (header0 >= 0 && cur == header0) { + cur = header0 + 1; + if (rowLooksLikeVehicleHeader(sheet, cur) || rowLooksLikeLandRouteMatrixHeader(sheet, cur)) { + cur++; + } } - return rowIdx0; + return cur; + } + + /** + * 陆运线路矩阵表头:同行有「始发地+目的地」,或「元/KG」分档,或燃油/报关费列名。 + */ + static boolean rowLooksLikeLandRouteMatrixHeader(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(); + boolean origin = false; + boolean dest = false; + int feeHits = 0; + for (Cell cell : row) { + if (cell == null) { + continue; + } + String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)).replaceAll("\\s+", ""); + if (text.isEmpty()) { + continue; + } + if ("始发地".equals(text) || "始发站".equals(text)) { + origin = true; + } + if ("目的地".equals(text)) { + dest = true; + } + if (text.contains("元/KG") || text.contains("元/kg") || text.contains("最低收费") + || text.contains("燃油附加") || text.contains("报关费") || text.contains("提货费") + || text.contains("口岸") || "税率".equals(text) || "币种".equals(text)) { + feeHits++; + } + } + return (origin && dest) || feeHits >= 2; + } + + /** 线路矩阵单个表头格:始发地、目的地、分档单价、费种名。 */ + 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)) { + return true; + } + return t.contains("元/KG") || t.contains("元/kg") || t.contains("燃油附加") || t.contains("报关费") + || t.contains("提货费") || t.contains("口岸核放"); } /** 行内出现「米/」「尺」「3T」「5T」等车型表头文案。 */ @@ -3077,6 +3140,12 @@ public final class QuoteTemplateExcelSupport { if (isYellowFixed(cell)) { return; } + String exist = readCellText(cell); + // 蓝底/费用表头格禁止当数据写:跨境集拼「始发地」被写成「上海」会拆掉主题蓝条 + if (isFeeTableHeader(exist) || isLandInquiryHeaderBandLabel(exist) + || isLandRouteMatrixHeaderCell(exist) || isSectionTitle(sheet, cell)) { + return; + } writeNumericOrText(cell, value); // 映射表写出的备注同样要换行,否则只看得见半截 applyRemarkWrap(sheet, row, cell, value); @@ -4261,7 +4330,8 @@ public final class QuoteTemplateExcelSupport { // 标签格「报价编号:」只换冒号后的值,避免整格被工单号顶掉看起来像残缺 String existing = readCellText(cell); // 映射点常落在合并标签格 / 20GP 表头上,不能把原字盖掉 - if (isFeeTableHeader(existing) || isLabelLike(existing) || isSectionTitle(sheet, cell)) { + if (isFeeTableHeader(existing) || isLabelLike(existing) || isSectionTitle(sheet, cell) + || isLandInquiryHeaderBandLabel(existing)) { return; } int colon = Math.max(existing.indexOf(':'), existing.indexOf(':')); @@ -4362,8 +4432,13 @@ public final class QuoteTemplateExcelSupport { * 标签右侧的可填格。海运询价单标签常是 E4:G4 合并,值在 H4:J4; * 不能再取合并区内的 F4,否则会把「起运港」四字盖掉。 * 表头「币别」右侧是 20GP,对不上就空着,不要映到箱型列。 + * 陆运跨境集拼等:蓝底「业务员/客户名称/始发地/货物类型」是表头,值在下一空白行,禁止写进表头格把蓝底盖掉。 */ private static String resolveFillTarget(Sheet sheet, Cell labelCell, String sheetName, String text, DataFormatter fmt) { + String landBandRef = resolveLandHeaderBandFillTarget(sheet, labelCell, sheetName, text); + if (StringUtils.isNotBlank(landBandRef)) { + return landBandRef; + } int idx = Math.max(text.indexOf(':'), text.indexOf(':')); // 「RFQ No:」或冒号后已有样例:值写在本格冒号后,不跨到右侧单选/标题合并格 // 注意:CellReference(Cell) 的 formatAsString 可能已带 sheet 名,禁止再拼 sheetName! 否则变成 Sheet2!Sheet2!B6 @@ -4425,6 +4500,109 @@ public final class QuoteTemplateExcelSupport { return wideEmpty != null ? wideEmpty : firstEmpty; } + /** + * 陆运询价条带:同一行多个蓝底短标签(业务员、客户名称、始发地、货物类型), + * 值写在该标签合并区正下方空白行,不写进表头格,以免蓝底被盖成白底。 + * + * @return 下一行可填格引用;不是条带表头则空 + */ + static String resolveLandHeaderBandFillTarget(Sheet sheet, Cell labelCell, String sheetName, String text) { + if (sheet == null || labelCell == null || StringUtils.isBlank(text)) { + return null; + } + if (!isLandInquiryHeaderBandLabel(text)) { + return null; + } + int rowIdx = labelCell.getRowIndex(); + if (!rowLooksLikeLandInquiryHeaderBand(sheet, rowIdx)) { + return null; + } + int firstCol = labelCell.getColumnIndex(); + for (int i = 0; i < sheet.getNumMergedRegions(); i++) { + org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(i); + if (range.isInRange(rowIdx, labelCell.getColumnIndex())) { + firstCol = range.getFirstColumn(); + break; + } + } + int valueRow = rowIdx + 1; + // 表头下若还有车型副表头,再下一行才是询价条带值(与线路矩阵无关的窄条) + if (rowLooksLikeVehicleHeader(sheet, valueRow)) { + valueRow++; + } + Row next = sheet.getRow(valueRow); + if (next == null) { + next = sheet.createRow(valueRow); + } + int[] dest = resolveMergeOrigin(sheet, valueRow, firstCol); + Cell cand = next.getCell(dest[1]); + String exist = readCellText(cand); + if (isFeeTableHeader(exist) || isLabelLike(exist) || isLandInquiryHeaderBandLabel(exist) + || isSectionTitle(sheet, cand) || isUnsuitableValueCell(exist)) { + return null; + } + return sheetName + "!" + new CellReference(dest[0], dest[1]).formatAsString(); + } + + /** + * 跨境集拼询价条带上的短标签。不含「时效」:费用矩阵也有同名列,不能整行当下表头。 + */ + static boolean isLandInquiryHeaderBandLabel(String text) { + if (StringUtils.isBlank(text)) { + return false; + } + String compact = text.replace(":", "").replace(":", "").replaceAll("\\s+", "").trim(); + return "业务员".equals(compact) || "客户名称".equals(compact) || "始发地".equals(compact) + || "货物类型".equals(compact) || "条款".equals(compact) || "发货日期".equals(compact); + } + + /** + * 一行里出现不少于 3 个询价条带标签,视为陆运蓝底表头行(截图正确版第 5 行)。 + */ + static boolean rowLooksLikeLandInquiryHeaderBand(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 hits = 0; + for (Cell cell : row) { + if (cell == null) { + continue; + } + String t = StringUtils.trimToEmpty(fmt.formatCellValue(cell)); + if (isLandInquiryHeaderBandLabel(t)) { + hits++; + } + } + return hits >= 3; + } + + /** + * mapping.fields 若误指蓝底询价条带表头格,改写到正下方空白行。 + */ + static String redirectLandHeaderBandMapping(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; + } + Row row = sh.getRow(pref.row); + Cell cell = row == null ? null : row.getCell(pref.col); + String text = readCellText(cell); + String redirected = resolveLandHeaderBandFillTarget(sh, cell, sh.getSheetName(), text); + return StringUtils.isNotBlank(redirected) ? redirected : cellRef; + } + /** * 映射点不够时,按标签把询价七项 / 合计写到标签右侧空格。 * 币别若落在费用表头行则跳过,避免 CNY 盖掉 20GP。 @@ -4753,6 +4931,10 @@ public final class QuoteTemplateExcelSupport { // 禁止反向:词典标签以格子开头(「整柜或拼柜」startsWith「整柜」), // 否则第一次写入的「整柜」会被二次扫描当成标签,跨栏写进送货地址(WO202609240027)。 if (dk.length() >= 3 && compact.length() >= dk.length() && compact.startsWith(dk)) { + // 「货物类型」不能被「货物品名」前缀命中后写进蓝底表头 + if ("货物品名".equals(dk) && compact.startsWith("货物类型")) { + continue; + } return e.getValue(); } } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcher.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcher.java index d8a5da1..bb93ee7 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcher.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcher.java @@ -145,7 +145,8 @@ public final class QuoteTemplateMatcher { static { LAND_KEYWORD_ALIASES.put("国内拼车", new String[] {"国内运输拼车", "拼车"}); LAND_KEYWORD_ALIASES.put("国内零担", new String[] {"国内运输拼车", "国内长途/零担", "零担"}); - LAND_KEYWORD_ALIASES.put("国内整车", new String[] {"国内运输整车", "整车"}); + // 「整车」不能当国内整车别名:跨境整车 / 中港整车都含这两个字,会把跨境单配成国内表。 + LAND_KEYWORD_ALIASES.put("国内整车", new String[] {"国内运输整车"}); LAND_KEYWORD_ALIASES.put("跨境整车", new String[] {"跨境整车"}); LAND_KEYWORD_ALIASES.put("跨境集拼", new String[] {"跨境集拼"}); LAND_KEYWORD_ALIASES.put("跨境零担", new String[] {"跨境集拼"}); @@ -155,8 +156,7 @@ public final class QuoteTemplateMatcher { if (text == null || text.isEmpty() || kw == null || kw.isEmpty()) { return false; } - String hay = text.toLowerCase(Locale.ROOT); - if (hay.contains(kw.toLowerCase(Locale.ROOT))) { + if (containsLandNeedle(text, kw)) { return true; } String[] aliases = LAND_KEYWORD_ALIASES.get(kw); @@ -164,13 +164,31 @@ public final class QuoteTemplateMatcher { return false; } for (String alias : aliases) { - if (hay.contains(alias.toLowerCase(Locale.ROOT))) { + if (containsLandNeedle(text, alias)) { return true; } } return false; } + /** + * 陆运针:普通 contains;单独「整车」不命中跨境/中港(避免国内整车模板抢走跨境单)。 + */ + static boolean containsLandNeedle(String text, String needle) { + if (text == null || needle == null || needle.isEmpty()) { + return false; + } + String hay = text.toLowerCase(Locale.ROOT); + String n = needle.toLowerCase(Locale.ROOT); + if (!hay.contains(n)) { + return false; + } + if ("整车".equals(n) && (hay.contains("跨境") || hay.contains("中港"))) { + return false; + } + return true; + } + static boolean skipKeyword(String kw) { if (kw == null || kw.isEmpty()) { return true; diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateSnapshotRenderer.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateSnapshotRenderer.java index 33b206d..ef496bb 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateSnapshotRenderer.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateSnapshotRenderer.java @@ -5,11 +5,6 @@ import org.apache.commons.lang3.StringUtils; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.rendering.ImageType; import org.apache.pdfbox.rendering.PDFRenderer; -import org.apache.poi.ss.usermodel.PrintSetup; -import org.apache.poi.ss.usermodel.Row; -import org.apache.poi.ss.usermodel.Sheet; -import org.apache.poi.ss.usermodel.Workbook; -import org.apache.poi.ss.usermodel.WorkbookFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -18,7 +13,6 @@ import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; import java.nio.charset.StandardCharsets; @@ -36,6 +30,7 @@ import java.util.stream.Stream; * 为何不用 LibreOffice 直接转 PNG:其 pdf→png 滤镜在部分版本会把页内容非等比拉伸, * 后台预览出现「字被拉高/压扁」。PDFBox 按页 MediaBox 均匀换算 DPI,保证 1:1 像素比例。 * 为何放在主账:后台预览不应依赖智能体进程;与出单 PDF 错开靠文件锁互斥。 + * 禁止 POI 先改打印再转:会丢掉 WPS 主题色与合并条带(跨境集拼蓝底表头拆成独立格)。 * 禁止:接到企微回调 / 智能体出单热路径;禁止本类起第二台 soffice 并行。 * 副作用:写临时目录与预览缓存;短阻塞当前 HTTP 线程(仅管理端预览)。 */ @@ -89,8 +84,8 @@ public class QuoteTemplateSnapshotRenderer { try { Path localXlsx = workDir.resolve("source.xlsx"); Files.copy(xlsxPath, localXlsx, StandardCopyOption.REPLACE_EXISTING); - // 预览专用打印版式:按表内容宽高选横/竖,避免宽表硬塞竖版 A4 显得挤扁 - preparePreviewPrintLayout(localXlsx); + // 禁止 POI 重写后再转:主题色/合并条带会被拆成独立格子(跨境集拼蓝底表头)。 + // 原表打印属性原样交给 soffice,保证后台截图与 WPS 打开一致。 Path pdf = convert(soffice, localXlsx, workDir, "pdf:calc_pdf_Export", ".pdf"); if (pdf == null || !Files.isRegularFile(pdf)) { throw new IllegalStateException("LibreOffice 未生成 PDF"); @@ -112,80 +107,6 @@ public class QuoteTemplateSnapshotRenderer { } } - /** - * 预览前改写打印属性:按已用区域宽高比选横向/纵向 A4,并按页适配。 - * 只动打印,不改单元格业务内容;写回同一 xlsx 供 soffice 使用。 - */ - private void preparePreviewPrintLayout(Path xlsx) throws IOException { - try (InputStream in = Files.newInputStream(xlsx); - Workbook wb = WorkbookFactory.create(in)) { - for (int s = 0; s < wb.getNumberOfSheets(); s++) { - Sheet sheet = wb.getSheetAt(s); - if (sheet == null) { - continue; - } - boolean landscape = preferLandscape(sheet); - sheet.setMargin(Sheet.LeftMargin, 0.4); - sheet.setMargin(Sheet.RightMargin, 0.4); - sheet.setMargin(Sheet.TopMargin, 0.5); - sheet.setMargin(Sheet.BottomMargin, 0.5); - sheet.setHorizontallyCenter(true); - sheet.setVerticallyCenter(false); - sheet.setAutobreaks(true); - sheet.setFitToPage(true); - PrintSetup ps = sheet.getPrintSetup(); - ps.setPaperSize(PrintSetup.A4_PAPERSIZE); - ps.setLandscape(landscape); - ps.setFitWidth((short) 1); - // 只按页宽适配:不把矮表纵向撑满整页,减少页底空白(最终仍靠 PNG 裁白边) - ps.setFitHeight((short) 0); - } - try (OutputStream out = Files.newOutputStream(xlsx, StandardOpenOption.TRUNCATE_EXISTING, - StandardOpenOption.WRITE)) { - wb.write(out); - } - } - } - - /** - * 粗估工作表内容宽高:列宽合计 vs 行高合计,宽更大则预览用横向。 - * 单位换算只需相对比较,不要求绝对毫米精度。 - */ - private static boolean preferLandscape(Sheet sheet) { - int firstRow = Math.max(sheet.getFirstRowNum(), 0); - int lastRow = sheet.getLastRowNum(); - if (lastRow < firstRow) { - return false; - } - int maxCol = 0; - for (int r = firstRow; r <= lastRow && r <= firstRow + 120; r++) { - Row row = sheet.getRow(r); - if (row == null) { - continue; - } - short last = row.getLastCellNum(); - if (last > maxCol) { - maxCol = last; - } - } - if (maxCol <= 0) { - return false; - } - double widthPx = 0; - for (int c = 0; c < maxCol; c++) { - // POI 列宽:1/256 字符宽;约 7px/字符(96dpi 经验值) - widthPx += (sheet.getColumnWidth(c) / 256.0) * 7.0; - } - double heightPx = 0; - int rowLimit = Math.min(lastRow, firstRow + 120); - for (int r = firstRow; r <= rowLimit; r++) { - Row row = sheet.getRow(r); - float points = row != null ? row.getHeightInPoints() : sheet.getDefaultRowHeightInPoints(); - heightPx += points * (96.0 / 72.0); - } - return widthPx > heightPx * 1.08; - } - /** * PDF 首页等比例渲 PNG:PDFBox 按页尺寸 × DPI 均匀换算,禁止非等比拉伸。 * 渲完后裁掉近白页边,避免 A4 页底大块空白占预览高度。 diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImplTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImplTest.java index b5b6145..33172a6 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImplTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImplTest.java @@ -30,4 +30,32 @@ public class InquiryDashboardServiceImplTest { air.setBusinessLine("AIR"); Assert.assertEquals("空运全线", InquiryDashboardServiceImpl.routeLabel(air)); } + + @Test + public void formatDurationMsMatchesDashboardDisplay() { + Assert.assertEquals("42s", InquiryDashboardServiceImpl.formatDurationMs(42_000L)); + Assert.assertEquals("8m42s", InquiryDashboardServiceImpl.formatDurationMs((8 * 60 + 42) * 1000L)); + Assert.assertEquals("1h5m", InquiryDashboardServiceImpl.formatDurationMs((65 * 60) * 1000L)); + Assert.assertEquals("-", InquiryDashboardServiceImpl.formatDurationMs(-1L)); + } + + @Test + public void formatAvgSaleIsAmountNotPercent() { + Assert.assertEquals("-", InquiryDashboardServiceImpl.formatAvgSale(null)); + Assert.assertEquals("12800", InquiryDashboardServiceImpl.formatAvgSale(new BigDecimal("12800.00"))); + Assert.assertEquals("12.50", InquiryDashboardServiceImpl.formatAvgSale(new BigDecimal("12.5"))); + } + + @Test + public void firstReplyAfterSkipsQuotesBeforeDispatch() { + java.util.Date dispatch = new java.util.Date(1_000_000L); + java.util.List times = java.util.Arrays.asList( + new java.util.Date(500_000L), + new java.util.Date(1_200_000L), + new java.util.Date(2_000_000L)); + Assert.assertEquals(new java.util.Date(1_200_000L), + InquiryDashboardServiceImpl.firstReplyAfter(times, dispatch)); + Assert.assertNull(InquiryDashboardServiceImpl.firstReplyAfter( + java.util.Collections.singletonList(new java.util.Date(100L)), dispatch)); + } } 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 d5b25d9..7ae0e22 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 @@ -1135,6 +1135,122 @@ public class QuoteTemplateFillTest { } } + /** + * 跨境集拼线路矩阵:dataStartRow 若落在蓝底「始发地/目的地」表头,必须下移; + * 不得把城市名写进表头格盖掉蓝底。 + */ + @Test + public void landGroupageRouteRegionSkipsBlueMatrixHeader() throws Exception { + Path src = Files.createTempFile("quote-land-matrix-src", ".xlsx"); + Path out = Files.createTempFile("quote-land-matrix-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("跨境集拼"); + org.apache.poi.ss.usermodel.CellStyle blue = wb.createCellStyle(); + blue.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.ROYAL_BLUE.getIndex()); + blue.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND); + org.apache.poi.ss.usermodel.Row head = row(sheet, 7); + org.apache.poi.ss.usermodel.Cell o = head.createCell(0); + o.setCellValue("始发地"); + o.setCellStyle(blue); + org.apache.poi.ss.usermodel.Cell d = head.createCell(1); + d.setCellValue("目的地"); + d.setCellStyle(blue); + org.apache.poi.ss.usermodel.Cell kg = head.createCell(3); + kg.setCellValue("2-500KG\n元/KG"); + kg.setCellStyle(blue); + row(sheet, 8); + row(sheet, 16).createCell(0).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 region = new JSONObject(true); + region.put("headerRow", 8); + region.put("dataStartRow", 8); + region.put("stopAtText", JSONArray.parseArray("[\"查验处理费用\"]")); + JSONObject columns = new JSONObject(true); + columns.put("origin", "A"); + columns.put("destination", "B"); + region.put("columns", columns); + regions.add(region); + mapping.put("routeRegions", regions); + JSONObject facts = new JSONObject(true); + facts.put("origin", "上海"); + facts.put("destination", "胡志明"); + facts.put("route_rows", QuoteTemplateExcelSupport.parseFeeRows( + "[{\"origin\":\"上海\",\"destination\":\"胡志明\"}]")); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = filled.getSheetAt(0); + assertEquals("始发地", read(sheet, 7, 0)); + assertEquals("目的地", read(sheet, 7, 1)); + assertEquals("上海", read(sheet, 8, 0)); + assertEquals("胡志明", read(sheet, 8, 1)); + org.apache.poi.ss.usermodel.CellStyle st = sheet.getRow(7).getCell(0).getCellStyle(); + assertEquals(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND, st.getFillPattern()); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + + /** + * 陆运跨境集拼:蓝底询价条带是表头(业务员/客户名称/始发地/货物类型), + * 值写下一空白行,禁止盖掉表头字与蓝底。 + */ + @Test + public void landGroupageHeaderBandWritesValuesOnNextRow() throws Exception { + Path src = Files.createTempFile("quote-land-groupage-src", ".xlsx"); + Path out = Files.createTempFile("quote-land-groupage-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("跨境集拼"); + org.apache.poi.ss.usermodel.CellStyle blue = wb.createCellStyle(); + blue.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.ROYAL_BLUE.getIndex()); + blue.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND); + org.apache.poi.ss.usermodel.Row head = row(sheet, 4); + String[] labels = {"业务员", "客户名称", "始发地", "条款", "发货日期", "货物类型"}; + int[] cols = {1, 3, 5, 7, 9, 13}; + for (int i = 0; i < labels.length; i++) { + org.apache.poi.ss.usermodel.Cell c = head.createCell(cols[i]); + c.setCellValue(labels[i]); + c.setCellStyle(blue); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, cols[i], cols[i] + 1)); + } + row(sheet, 5); + org.apache.poi.ss.usermodel.Row matrixHead = row(sheet, 7); + matrixHead.createCell(0).setCellValue("始发地"); + matrixHead.createCell(1).setCellValue("目的地"); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject facts = new JSONObject(true); + facts.put("sales_name", "LuoXiaoHua"); + facts.put("origin", "上海"); + facts.put("commodity", "茶叶"); + facts.put("quote_date", "2026-10-09"); + QuoteTemplateExcelSupport.fillWorkbook(src, out, new JSONObject(), facts); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = filled.getSheetAt(0); + assertEquals("业务员", read(sheet, 4, 1)); + assertEquals("客户名称", read(sheet, 4, 3)); + assertEquals("始发地", read(sheet, 4, 5)); + assertEquals("货物类型", read(sheet, 4, 13)); + assertEquals("LuoXiaoHua", read(sheet, 5, 1)); + assertEquals("上海", read(sheet, 5, 5)); + assertEquals("茶叶", read(sheet, 5, 13)); + assertEquals("2026-10-09", read(sheet, 5, 9)); + org.apache.poi.ss.usermodel.CellStyle salesStyle = sheet.getRow(4).getCell(1).getCellStyle(); + assertEquals(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND, salesStyle.getFillPattern()); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + /** * 陆运国内拼车模板是标签栏,不是「费用名称+金额」表。 * TMS 的 元/CBM、送货费、时效必须写到标签右侧,不能落到 G 列或整张空着。 diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcherTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcherTest.java index 932b08b..3f78913 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcherTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcherTest.java @@ -133,6 +133,36 @@ public class QuoteTemplateMatcherTest { assertEquals("跨境整车", hit.get("name")); } + @Test + public void landAdminDomesticFtlKeywordDoesNotStealCrossFtl() { + // 后台国内整车模板关键词常写「国内整车」;别名「整车」不得抢走跨境整车(WO202610030032)。 + List> list = new ArrayList<>(); + list.add(tpl("国内陆运整车报价单", "陆运", false, "国内整车")); + list.add(tpl("跨境整车报价单", "陆运", false, "东南亚")); + Map facts = new HashMap<>(); + facts.put("运输类型", "跨境整车"); + facts.put("线路类别", "东南亚"); + facts.put("运输分类", "普件(东南亚、中亚、中欧涉及国内+国外段)"); + facts.put("起运港", "上海"); + facts.put("目的港", "春疆货场"); + Map hit = QuoteTemplateMatcher.pick(list, "LAND", facts, null); + assertNotNull(hit); + assertEquals("跨境整车报价单", hit.get("name")); + } + + @Test + public void landAdminDomesticFtlStillHitsDomesticType() { + List> list = new ArrayList<>(); + list.add(tpl("国内陆运整车报价单", "陆运", false, "国内整车")); + list.add(tpl("跨境整车报价单", "陆运", false, "东南亚")); + Map facts = new HashMap<>(); + facts.put("运输类型", "国内运输整车"); + facts.put("线路类别", "国内长途/零担"); + Map hit = QuoteTemplateMatcher.pick(list, "LAND", facts, null); + assertNotNull(hit); + assertEquals("国内陆运整车报价单", hit.get("name")); + } + @Test public void landAdminKeywordDomesticLclMatchesTransportType() { List> list = new ArrayList<>(); diff --git a/inquiry-backend/.env.production b/inquiry-backend/.env.production index c1a8d0e..96ec847 100644 --- a/inquiry-backend/.env.production +++ b/inquiry-backend/.env.production @@ -2,7 +2,7 @@ VITE_USE_MOCK = false # 网站标题(浏览器标签后缀;缺省会显示「页面名 undefined」) -VITE_GLOB_APP_TITLE = 友通达询价系统 +VITE_GLOB_APP_TITLE = 友通达智能体运营后台 # 简称,用于生产 _app.config.js 变量名;勿空格/数字开头 VITE_GLOB_APP_SHORT_NAME = YtdInquiryAdmin diff --git a/inquiry-backend/src/hooks/web/useTitle.ts b/inquiry-backend/src/hooks/web/useTitle.ts index de3d5f7..77aa0d4 100644 --- a/inquiry-backend/src/hooks/web/useTitle.ts +++ b/inquiry-backend/src/hooks/web/useTitle.ts @@ -29,7 +29,7 @@ export function useTitle() { const tTitle = t(route?.meta?.title as string); // 生产 _app.config.js 若漏打 VITE_GLOB_APP_TITLE,title 会是 undefined → 标签出现「报价模板 undefined」 - const appTitle = title || '友通达询价系统'; + const appTitle = title || '友通达智能体运营后台'; pageTitle.value = tTitle ? `${tTitle} - ${appTitle}` : `${appTitle}`; }, { immediate: true } diff --git a/inquiry-backend/src/layouts/inquiry/index.vue b/inquiry-backend/src/layouts/inquiry/index.vue index 44e9c10..c3d3c25 100644 --- a/inquiry-backend/src/layouts/inquiry/index.vue +++ b/inquiry-backend/src/layouts/inquiry/index.vue @@ -80,7 +80,7 @@ const profileOpen = ref(false); const passwordOpen = ref(false); - const pageTitle = computed(() => String(route.meta?.title || '询价机器人后台')); + const pageTitle = computed(() => String(route.meta?.title || '友通达智能体运营后台')); const crumb = computed(() => (route.meta?.crumb ? String(route.meta.crumb) : '')); const username = computed(() => userStore.getUserInfo?.username || ''); @@ -207,7 +207,7 @@

-
询价机器人
+
友通达智能体运营后台