From b070a9b7a7999dfef1e6ab1f0fc0de20cccccd09 Mon Sep 17 00:00:00 2001 From: linmianxun <634841751@qq.com> Date: Sun, 11 Oct 2026 13:04:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=8C=89=E6=9F=A5=E7=9C=8B?= =?UTF-8?q?=E6=9D=83=E9=99=90=E6=94=B6=E7=B4=A7=E8=8F=9C=E5=8D=95=E4=B8=8E?= =?UTF-8?q?=E3=80=8C=E5=8F=AA=E7=9C=8B=E8=87=AA=E5=B7=B1=E3=80=8D=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E8=8C=83=E5=9B=B4=EF=BC=9B=E8=B7=A8=E5=A2=83=E6=95=B4?= =?UTF-8?q?=E8=BD=A6=E6=8A=A5=E4=BB=B7=E5=A1=AB=E8=A1=A8=E6=9B=B4=E7=A8=B3?= =?UTF-8?q?=EF=BC=8C=E5=8D=8F=E5=95=86=E4=B8=AD=E9=A1=BA=E5=BB=B6=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=AF=AF=E6=8A=A5=E9=94=80=E5=94=AE=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../agent/jobs/event_exception_poll.py | 40 ++ inquiry-agent/agent/ledger/memory_ledger.py | 23 + inquiry-agent/tests/test_event_exception.py | 212 ++++++- .../impl/InquiryDashboardServiceImpl.java | 90 +-- .../impl/InquiryQuoteTemplateServiceImpl.java | 33 +- .../impl/InquiryWorkOrderServiceImpl.java | 60 +- .../support/InquiryDataScopeSupport.java | 105 ++++ .../template/QuoteTemplateExcelSupport.java | 542 +++++++----------- .../inquiry/tms/InquiryTmsQueryService.java | 87 +++ .../template/QuoteTemplateFillTest.java | 251 ++++++++ .../inquiry/tms/TmsQuoteCurrencyTest.java | 33 ++ .../impl/SysPermissionServiceImpl.java | 12 +- inquiry-backend/src/api/sys/menu.ts | 10 +- inquiry-backend/src/layouts/inquiry/index.vue | 26 +- .../src/store/modules/permission.ts | 146 ++++- .../src/views/inquiry/account/index.vue | 13 +- .../src/views/inquiry/botReply/index.vue | 21 +- .../src/views/inquiry/role/index.vue | 9 +- .../views/inquiry/shared/inquiryPagePerms.ts | 60 ++ .../src/views/inquiry/staffRole/index.vue | 28 +- .../src/views/inquiry/systemSetting/index.vue | 7 +- .../src/views/inquiry/template/index.vue | 32 +- .../src/views/inquiry/workOrder/index.vue | 41 +- 23 files changed, 1383 insertions(+), 498 deletions(-) create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryDataScopeSupport.java create mode 100644 inquiry-backend/src/views/inquiry/shared/inquiryPagePerms.ts diff --git a/inquiry-agent/agent/jobs/event_exception_poll.py b/inquiry-agent/agent/jobs/event_exception_poll.py index 359fd7e..27646af 100644 --- a/inquiry-agent/agent/jobs/event_exception_poll.py +++ b/inquiry-agent/agent/jobs/event_exception_poll.py @@ -123,6 +123,35 @@ def notify_app_flag(override: Optional[bool]) -> bool: return notify_app_ready() +def _skip_sales_timeout( + *, + status: str, + wait_kind: str, + next_followup_at: str, + lost_reason: str, + now: datetime, +) -> str: + """ + 成交跟进相关等待在终态/顺延窗内不应再告警。 + + 返回跳过原因;空串表示继续按小时判定。 + 协商中:只要还排着再问时刻(next_followup_at > now),就不发销售超时; + 再问认领后会清空该时刻并重新 start deal,那时才重新计时。 + """ + st = (status or "").strip() + _ = wait_kind + if st == "未成交" and (lost_reason or "").strip(): + return "lost_reason_recorded" + if st == "协商中": + raw = (next_followup_at or "").strip() + if not raw: + return "" + due = _parse_started(raw) + if due is None or due > now: + return "negotiate_followup_pending" + return "" + + def _handle_one( row: dict[str, Any], *, @@ -139,6 +168,14 @@ def _handle_one( kind = str(row.get("event_wait_kind") or row.get("eventWaitKind") or "").strip() if not no or not kind: return False + status = str(row.get("status") or "").strip() + followup = str(row.get("next_followup_at") or row.get("nextFollowupAt") or "").strip() + lost_reason = str(row.get("lost_reason") or row.get("lostReason") or "").strip() + # 产品口径:协商中顺延再问期间不算「销售无响应」;未成交已写原因不再催。 + # 即使 clear_wait 失败留下陈旧 deal 等待,这里也要挡住(WO202610060024 / WO202610070034)。 + skip_reason = _skip_sales_timeout( + status=status, wait_kind=kind, next_followup_at=followup, lost_reason=lost_reason, now=now + ) line = str(row.get("business_line") or row.get("businessLine") or "").strip() facts = dict(row.get("facts") or {}) klass = str(facts.get("运输分类") or facts.get("transportType") or "").strip() @@ -154,6 +191,9 @@ def _handle_one( started = _parse_started(row.get("event_wait_started_at") or row.get("eventWaitStartedAt")) if started is None: return False + if skip_reason: + logger.info("事件异常跳过 wo=%s kind=%s reason=%s", no, kind, skip_reason) + return False if workday_elapsed_hours(started, now) < hours: return False watchers = [dict(x) for x in (row.get("watchers") or []) if isinstance(x, dict)] diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py index d89a756..15c6014 100644 --- a/inquiry-agent/agent/ledger/memory_ledger.py +++ b/inquiry-agent/agent/ledger/memory_ledger.py @@ -344,12 +344,23 @@ class MemoryLedger: ticket.next_followup_at = "" if lost_reason: ticket.lost_reason = lost_reason + # 成交等待结束;若仍缺原因由智能体再 start lost_reason + ticket.event_wait_kind = "" + ticket.event_wait_started_at = "" + ticket.event_wait_alerted = 0 elif to_status == "已成交": ticket.next_followup_at = "" if deal_notify_summary: ticket.deal_notify_summary = deal_notify_summary + ticket.event_wait_kind = "" + ticket.event_wait_started_at = "" + ticket.event_wait_alerted = 0 elif to_status == "协商中": ticket.next_followup_at = next_followup_at + # 顺延再问期间不盯成交超时;到期再问会重新 start deal + ticket.event_wait_kind = "" + ticket.event_wait_started_at = "" + ticket.event_wait_alerted = 0 return {"ok": True, "status": ticket.status} def patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]: @@ -363,6 +374,9 @@ class MemoryLedger: if not reason: return {"ok": False, "error": "reason_blank"} ticket.lost_reason = reason + ticket.event_wait_kind = "" + ticket.event_wait_started_at = "" + ticket.event_wait_alerted = 0 return {"ok": True, "work_order_no": work_order_no, "lost_reason": reason} def claim_due_negotiate(self) -> list[dict[str, Any]]: @@ -940,6 +954,11 @@ class MemoryLedger: continue if ticket.status in {"已成交", "已关闭", "转人工"}: continue + # 未成交已写原因、协商中仍排着再问:不进巡检列表 + if ticket.status == "未成交" and (ticket.lost_reason or "").strip(): + continue + if ticket.status == "协商中" and (ticket.next_followup_at or "").strip(): + continue watchers: list[dict[str, Any]] = [] seen: set[str] = set() sales_id = (ticket.sales_wecom_id or "").strip() @@ -989,6 +1008,10 @@ class MemoryLedger: "collab_member_ids": list(ticket.collab_member_ids), "facts": dict(ticket.facts), "eventException": ticket.event_exception, + "lost_reason": ticket.lost_reason, + "lostReason": ticket.lost_reason, + "next_followup_at": ticket.next_followup_at, + "nextFollowupAt": ticket.next_followup_at, "watchers": watchers, } ) diff --git a/inquiry-agent/tests/test_event_exception.py b/inquiry-agent/tests/test_event_exception.py index be14b0d..b1e29b5 100644 --- a/inquiry-agent/tests/test_event_exception.py +++ b/inquiry-agent/tests/test_event_exception.py @@ -222,6 +222,9 @@ class TestPoll(unittest.TestCase): from agent.ledger.memory_ledger import MemoryLedger from agent.policy.deal_followup import SHANGHAI + # 钉在周五,避免周日跑单测时工作日折算为 0 + now = datetime(2026, 10, 9, 18, 0, 0, tzinfo=SHANGHAI) + path = os.path.join(ROOT, "agent", "jobs", "event_exception_poll.py") spec = importlib.util.spec_from_file_location("ee_poll", path) self.assertIsNotNone(spec) @@ -248,9 +251,7 @@ class TestPoll(unittest.TestCase): t = book.get_ticket(work_order_no=wo) t.collab_chat_id = "wr1" t.collab_member_ids = ["p1"] - t.event_wait_started_at = (datetime.now(SHANGHAI) - timedelta(hours=3)).strftime( - "%Y-%m-%d %H:%M:%S" - ) + t.event_wait_started_at = (now - timedelta(hours=3)).strftime("%Y-%m-%d %H:%M:%S") book._timeouts["sea_product_other"] = 0.25 groups: list[dict] = [] @@ -258,7 +259,6 @@ class TestPoll(unittest.TestCase): groups.append({"chat_id": chat_id, "content": content, "line": business_line}) return True - now = datetime.now(SHANGHAI) n = poll_event_exceptions( ledger=book, now=now, send_group=send_group, notify_ready=False ) @@ -305,13 +305,12 @@ class TestPoll(unittest.TestCase): ) created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={"起运港": "南京"}) wo = created["work_order_no"] + now = datetime(2026, 10, 9, 18, 0, 0, tzinfo=SHANGHAI) book.start_event_wait(work_order_no=wo, wait_kind="air_quote") ticket = book.get_ticket(work_order_no=wo) ticket.collab_chat_id = "wr-air" ticket.collab_member_ids = [] - ticket.event_wait_started_at = (datetime.now(SHANGHAI) - timedelta(hours=3)).strftime( - "%Y-%m-%d %H:%M:%S" - ) + ticket.event_wait_started_at = (now - timedelta(hours=3)).strftime("%Y-%m-%d %H:%M:%S") book._timeouts["air_route"] = 0.25 groups: list[dict] = [] @@ -321,7 +320,7 @@ class TestPoll(unittest.TestCase): n = mod.poll_event_exceptions( ledger=book, - now=datetime.now(SHANGHAI), + now=now, send_group=send_group, notify_ready=False, ) @@ -355,13 +354,12 @@ class TestPoll(unittest.TestCase): {"wecomId": "air1", "name": "吴季林", "roleCode": "air", "status": "active"}, ] ) + now = datetime(2026, 10, 9, 18, 0, 0, tzinfo=SHANGHAI) created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={}) wo = created["work_order_no"] book.start_event_wait(work_order_no=wo, wait_kind="adopt") ticket = book.get_ticket(work_order_no=wo) - ticket.event_wait_started_at = (datetime.now(SHANGHAI) - timedelta(hours=3)).strftime( - "%Y-%m-%d %H:%M:%S" - ) + ticket.event_wait_started_at = (now - timedelta(hours=3)).strftime("%Y-%m-%d %H:%M:%S") book._timeouts["air_sales"] = 0.25 private: list[dict] = [] @@ -374,7 +372,7 @@ class TestPoll(unittest.TestCase): n = mod.poll_event_exceptions( ledger=book, - now=datetime.now(SHANGHAI), + now=now, enqueue=enqueue, send_group=send_group, notify_ready=False, @@ -402,7 +400,7 @@ class TestPoll(unittest.TestCase): n_deal = mod.poll_event_exceptions( ledger=book, - now=datetime.now(SHANGHAI), + now=now, enqueue=enqueue, send_group=send_deal_group, notify_ready=False, @@ -429,5 +427,193 @@ class TestMentionsForRole(unittest.TestCase): self.assertEqual([row["name"] for row in rows], ["吴季林"]) +class TestDealOutcomeStopsTimeout(unittest.TestCase): + """对应现网截图:协商中顺延 / 未成交已写原因后不应再发销售响应超时。""" + + def _poll_mod(self): + import importlib.util + + path = os.path.join(ROOT, "agent", "jobs", "event_exception_poll.py") + spec = importlib.util.spec_from_file_location("ee_poll_deal_stop", path) + self.assertIsNotNone(spec) + self.assertIsNotNone(spec.loader) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + def _book_with_deal_wait(self): + from datetime import datetime, timedelta + + from agent.ledger.memory_ledger import MemoryLedger + from agent.policy.deal_followup import SHANGHAI + + book = MemoryLedger() + book.set_sea_staff( + [ + { + "wecomId": "sales1", + "name": "罗小华", + "roleCode": "sales", + "status": "active", + } + ] + ) + created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={}) + wo = created["work_order_no"] + book.transition(work_order_no=wo, to_status="已报价") + book.start_event_wait(work_order_no=wo, wait_kind="deal") + ticket = book.get_ticket(work_order_no=wo) + ticket.collab_chat_id = "wr-deal" + ticket.event_wait_started_at = (datetime.now(SHANGHAI) - timedelta(hours=72)).strftime( + "%Y-%m-%d %H:%M:%S" + ) + book._timeouts["air_sales"] = 48 + return book, wo + + def test_negotiate_clears_wait_no_timeout(self) -> None: + from datetime import datetime + + from agent.policy.deal_followup import SHANGHAI + from agent.policy.deal_outcome import finish_deal_outcome + + book, wo = self._book_with_deal_wait() + result = finish_deal_outcome( + ledger=book, + work_order_no=wo, + outcome="协商中", + facts={}, + quote={"total": "100"}, + business_line="AIR", + sales_name="罗小华", + notify_ready=False, + ) + self.assertTrue(result.get("ok")) + ticket = book.get_ticket(work_order_no=wo) + self.assertEqual(ticket.status, "协商中") + self.assertEqual(ticket.event_wait_kind, "") + groups: list[dict] = [] + + n = self._poll_mod().poll_event_exceptions( + ledger=book, + now=datetime.now(SHANGHAI), + send_group=lambda **kwargs: groups.append(kwargs) or True, + notify_ready=False, + ) + self.assertEqual(n, 0) + self.assertEqual(groups, []) + + def test_lost_with_reason_clears_wait_no_timeout(self) -> None: + from datetime import datetime + + from agent.policy.deal_followup import SHANGHAI + from agent.policy.deal_outcome import finish_deal_outcome + + book, wo = self._book_with_deal_wait() + result = finish_deal_outcome( + ledger=book, + work_order_no=wo, + outcome="未成交", + facts={}, + quote={"total": "100"}, + business_line="AIR", + sales_name="罗小华", + lost_reason="客户不要了", + notify_ready=False, + ) + self.assertTrue(result.get("ok")) + self.assertEqual(result.get("phase"), "done") + ticket = book.get_ticket(work_order_no=wo) + self.assertEqual(ticket.status, "未成交") + self.assertEqual(ticket.lost_reason, "客户不要了") + self.assertEqual(ticket.event_wait_kind, "") + groups: list[dict] = [] + + n = self._poll_mod().poll_event_exceptions( + ledger=book, + now=datetime.now(SHANGHAI), + send_group=lambda **kwargs: groups.append(kwargs) or True, + notify_ready=False, + ) + self.assertEqual(n, 0) + self.assertEqual(groups, []) + + def test_stale_deal_wait_during_negotiate_is_skipped(self) -> None: + """截图回归:协商中已顺延,即使库里仍留着陈旧 deal 等待,也不再发超时。""" + from datetime import datetime + + from agent.policy.deal_followup import SHANGHAI + from agent.policy.deal_outcome import finish_deal_outcome + + book, wo = self._book_with_deal_wait() + ticket = book.get_ticket(work_order_no=wo) + started = ticket.event_wait_started_at + result = finish_deal_outcome( + ledger=book, + work_order_no=wo, + outcome="协商中", + facts={}, + quote={"total": "100"}, + business_line="AIR", + sales_name="罗小华", + notify_ready=False, + ) + self.assertTrue(result.get("ok")) + ticket = book.get_ticket(work_order_no=wo) + self.assertEqual(ticket.status, "协商中") + self.assertTrue((ticket.next_followup_at or "").strip()) + # 人为留下陈旧等待(模拟 clear/迁态未清干净),巡检必须跳过 + ticket.event_wait_kind = "deal" + ticket.event_wait_started_at = started + ticket.event_wait_alerted = 0 + groups: list[dict] = [] + + n = self._poll_mod().poll_event_exceptions( + ledger=book, + now=datetime.now(SHANGHAI), + send_group=lambda **kwargs: groups.append(kwargs) or True, + notify_ready=False, + ) + self.assertEqual(n, 0) + self.assertEqual(groups, []) + + def test_stale_deal_wait_after_lost_reason_is_skipped(self) -> None: + """截图回归:未成交原因已记,陈旧 deal 等待不再触发销售超时。""" + from datetime import datetime + + from agent.policy.deal_followup import SHANGHAI + from agent.policy.deal_outcome import finish_deal_outcome + + book, wo = self._book_with_deal_wait() + ticket = book.get_ticket(work_order_no=wo) + started = ticket.event_wait_started_at + result = finish_deal_outcome( + ledger=book, + work_order_no=wo, + outcome="未成交", + facts={}, + quote={"total": "100"}, + business_line="AIR", + sales_name="罗小华", + lost_reason="客户不要了", + notify_ready=False, + ) + self.assertTrue(result.get("ok")) + ticket = book.get_ticket(work_order_no=wo) + self.assertEqual(ticket.lost_reason, "客户不要了") + ticket.event_wait_kind = "deal" + ticket.event_wait_started_at = started + ticket.event_wait_alerted = 0 + groups: list[dict] = [] + + n = self._poll_mod().poll_event_exceptions( + ledger=book, + now=datetime.now(SHANGHAI), + send_group=lambda **kwargs: groups.append(kwargs) or True, + notify_ready=False, + ) + self.assertEqual(n, 0) + self.assertEqual(groups, []) + + if __name__ == "__main__": unittest.main() 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 d1a456e..c317abb 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 @@ -2,8 +2,6 @@ package org.jeecg.modules.inquiry.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; 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; @@ -14,6 +12,7 @@ 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.InquiryDataScopeSupport; import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -95,7 +94,7 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService { LambdaQueryWrapper qw = new LambdaQueryWrapper() .ge(InquiryTicket::getCreateTime, from) .le(InquiryTicket::getCreateTime, to); - applyDashboardSelfOnly(qw); + InquiryDataScopeSupport.applySelfOnly(qw); List tickets = ticketMapper.selectList(qw); long total = tickets.size(); @@ -133,7 +132,7 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService { LambdaQueryWrapper weekQw = new LambdaQueryWrapper() .ge(InquiryTicket::getCreateTime, weekFrom) .le(InquiryTicket::getCreateTime, to); - applyDashboardSelfOnly(weekQw); + InquiryDataScopeSupport.applySelfOnly(weekQw); List weekTickets = ticketMapper.selectList(weekQw); List trendDates = new ArrayList<>(7); @@ -667,87 +666,4 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService { return "sales"; } - /** - * 账号勾选「只看自己数据」时收紧看板工单范围。 - *

- * 口径对齐原项目:非超管且 dashboardSelfOnly=true → - * 工单 salesWecomId = 当前账号工号(企微 id)或 createBy = 登录名。 - * 超管(username=admin 或角色 admin)忽略开关。 - */ - private void applyDashboardSelfOnly(LambdaQueryWrapper qw) { - LoginUser login = currentLoginUser(); - if (login == null) { - return; - } - if (isAdministrator(login)) { - return; - } - if (!Boolean.TRUE.equals(login.getDashboardSelfOnly())) { - return; - } - String username = StringUtils.trimToEmpty(login.getUsername()); - String wecom = dashboardWecomId(login); - qw.and(w -> { - boolean any = false; - if (StringUtils.isNotBlank(wecom)) { - w.eq(InquiryTicket::getSalesWecomId, wecom); - any = true; - } - if (StringUtils.isNotBlank(username)) { - if (any) { - w.or(); - } - w.eq(InquiryTicket::getCreateBy, username); - any = true; - } - if (!any) { - // 未绑企微且无用户名:宁可不看数据,也不漏看全站 - w.apply("1 = 0"); - } - }); - } - - private static LoginUser currentLoginUser() { - try { - Object p = SecurityUtils.getSubject().getPrincipal(); - if (p instanceof LoginUser) { - return (LoginUser) p; - } - } catch (Exception ignored) { - // 无登录上下文时不做过滤 - } - return null; - } - - /** 超管:登录名 admin,或 Shiro 角色 admin/管理员。 */ - private static boolean isAdministrator(LoginUser login) { - if (login == null) { - return false; - } - if ("admin".equalsIgnoreCase(StringUtils.trimToEmpty(login.getUsername()))) { - return true; - } - try { - return SecurityUtils.getSubject().hasRole("admin") - || SecurityUtils.getSubject().hasRole("管理员"); - } catch (Exception e) { - return false; - } - } - - /** - * 看板「只看自己」用的企微工号。 - * 不用直接调 getWorkNo:测服打包时基座 LoginUser 可能还没有这个方法。 - */ - private static String dashboardWecomId(LoginUser login) { - if (login == null) { - return ""; - } - try { - Object v = login.getClass().getMethod("getWorkNo").invoke(login); - return v == null ? "" : StringUtils.trimToEmpty(String.valueOf(v)); - } catch (ReflectiveOperationException ignored) { - return ""; - } - } } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java index d6649a7..7625136 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 @@ -1148,16 +1148,39 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl result = page(page, qw); enrichDisplayFields(result.getRecords()); @@ -283,24 +286,36 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl one = new LinkedHashMap<>(); one.put("workOrderNo", ticket.getWorkOrderNo()); one.put("work_order_no", ticket.getWorkOrderNo()); @@ -1688,6 +1712,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl facts = new LinkedHashMap<>(); if (StringUtils.isNotBlank(ticket.getFactsJson())) { JSONObject parsed = JSON.parseObject(ticket.getFactsJson()); @@ -1710,6 +1736,30 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl + * 必须用 UpdateWrapper 强制写 null:MyBatis-Plus updateById 默认跳过 null 字段, + * 否则协商中/未成交后库里仍残留 deal 等待,巡检会继续催(WO202610060024 / WO202610070034)。 + */ + private void clearEventWaitColumns(InquiryTicket ticket) { + if (ticket == null || StringUtils.isBlank(ticket.getId())) { + return; + } + Date now = new Date(); + lambdaUpdate() + .eq(InquiryTicket::getId, ticket.getId()) + .set(InquiryTicket::getEventWaitKind, null) + .set(InquiryTicket::getEventWaitStartedAt, null) + .set(InquiryTicket::getEventWaitAlerted, 0) + .set(InquiryTicket::getUpdateTime, now) + .update(); + ticket.setEventWaitKind(null); + ticket.setEventWaitStartedAt(null); + ticket.setEventWaitAlerted(0); + ticket.setUpdateTime(now); + } + @Override @Transactional(rollbackFor = Exception.class) public Map recordEventException(String workOrderNo, String eventException) { diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryDataScopeSupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryDataScopeSupport.java new file mode 100644 index 0000000..286c525 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryDataScopeSupport.java @@ -0,0 +1,105 @@ +package org.jeecg.modules.inquiry.support; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.apache.commons.lang3.StringUtils; +import org.apache.shiro.SecurityUtils; +import org.jeecg.common.system.vo.LoginUser; +import org.jeecg.modules.inquiry.entity.InquiryTicket; + +/** + * 后台数据范围:账号「只看自己数据」时收紧工单查询。 + *

+ * 口径:非超管且 {@code dashboardSelfOnly=true} → + * {@code salesWecomId = 工号} 或 {@code createBy = 登录名}。 + * 看板与工单列表共用,避免只看板生效、列表漏看全站。 + * 线程:只读登录态,无全局锁。 + */ +public final class InquiryDataScopeSupport { + + private InquiryDataScopeSupport() { + } + + /** + * 按当前登录账号把「只看自己」条件叠到工单查询上。 + * + * @param qw 工单查询包装器;null 时直接返回 + */ + public static void applySelfOnly(LambdaQueryWrapper qw) { + if (qw == null) { + return; + } + LoginUser login = currentLoginUser(); + if (login == null) { + return; + } + if (isAdministrator(login)) { + return; + } + if (!Boolean.TRUE.equals(login.getDashboardSelfOnly())) { + return; + } + String username = StringUtils.trimToEmpty(login.getUsername()); + String wecom = wecomId(login); + qw.and(w -> { + boolean any = false; + if (StringUtils.isNotBlank(wecom)) { + w.eq(InquiryTicket::getSalesWecomId, wecom); + any = true; + } + if (StringUtils.isNotBlank(username)) { + if (any) { + w.or(); + } + w.eq(InquiryTicket::getCreateBy, username); + any = true; + } + if (!any) { + // 未绑企微且无用户名:宁可不看数据,也不漏看全站 + w.apply("1 = 0"); + } + }); + } + + private static LoginUser currentLoginUser() { + try { + Object p = SecurityUtils.getSubject().getPrincipal(); + if (p instanceof LoginUser) { + return (LoginUser) p; + } + } catch (Exception ignored) { + // 无登录上下文 + } + return null; + } + + /** 超管:登录名 admin,或 Shiro 角色 admin/管理员。 */ + private static boolean isAdministrator(LoginUser login) { + if (login == null) { + return false; + } + if ("admin".equalsIgnoreCase(StringUtils.trimToEmpty(login.getUsername()))) { + return true; + } + try { + return SecurityUtils.getSubject().hasRole("admin") + || SecurityUtils.getSubject().hasRole("管理员"); + } catch (Exception e) { + return false; + } + } + + /** + * 企微工号。反射读 getWorkNo,兼容测服旧 LoginUser 无该方法的包。 + */ + public static String wecomId(LoginUser login) { + if (login == null) { + return ""; + } + try { + Object v = login.getClass().getMethod("getWorkNo").invoke(login); + return v == null ? "" : StringUtils.trimToEmpty(String.valueOf(v)); + } catch (ReflectiveOperationException ignored) { + return ""; + } + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java index 1c8d646..c8872fa 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java @@ -31,92 +31,6 @@ import java.util.*; @Slf4j public final class QuoteTemplateExcelSupport { - // #region agent log - /** 调试:出单底色。会话 446444=陆运整车顶栏;保留旧路径兼容。 */ - private static void agentDbg(String hypothesisId, String location, String message, String dataJson) { - try { - String line = "{\"sessionId\":\"446444\",\"runId\":\"post-fix\",\"hypothesisId\":\"" - + hypothesisId + "\",\"location\":\"" + location - + "\",\"message\":\"" + String.valueOf(message).replace("\\", "\\\\").replace("\"", "'") - + "\",\"data\":" + (dataJson == null || dataJson.isEmpty() ? "{}" : dataJson) - + ",\"timestamp\":" + System.currentTimeMillis() + "}\n"; - byte[] bytes = line.getBytes(StandardCharsets.UTF_8); - // 主账进程账号写不了开发机目录;优先落到 inquiry-new/logs。 - String[] paths = { - "E:/wwwroot/inquiry-new/logs/debug-446444.log", - "C:/Users/ytd_extdev_0903/incoming/debug-446444.log", - System.getProperty("java.io.tmpdir") + "/debug-446444.log", - "d:/own/友通达询价智能体(新)/debug-446444.log" - }; - for (String p : paths) { - try { - Path path = Paths.get(p); - if (path.getParent() != null && !Files.exists(path.getParent())) { - continue; - } - Files.write(path, bytes, StandardOpenOption.CREATE, StandardOpenOption.APPEND); - } catch (Exception ignoredOne) { - // 换下一路径 - } - } - } catch (Exception ignored) { - // 调试日志失败不影响出单 - } - } - - /** 采样顶栏/运价表头填充色,验证陆运整车蓝底是否被改写。 */ - private static void debugLandBannerColors(Workbook wb, String phase, String sourceHint) { - try { - if (wb == null || wb.getNumberOfSheets() <= 0) { - return; - } - Sheet sheet = wb.getSheetAt(0); - if (sheet == null) { - return; - } - String name = String.valueOf(sheet.getSheetName()); - boolean landish = name.contains("整车") || name.contains("拼车") || name.contains("陆运") - || String.valueOf(sourceHint).contains("整车") || String.valueOf(sourceHint).contains("拼车"); - if (!landish && sheet.getLastRowNum() < 2) { - return; - } - boolean[] keep = markLandHeaderRows(sheet); - StringBuilder sb = new StringBuilder(); - sb.append("{\"phase\":\"").append(phase).append("\",\"sheet\":\"") - .append(name.replace("\"", "'")).append("\",\"src\":\"") - .append(String.valueOf(sourceHint).replace("\\", "/").replace("\"", "'")) - .append("\",\"keep0to3\":["); - for (int r = 0; r <= 3 && r < keep.length; r++) { - if (r > 0) { - sb.append(','); - } - sb.append(keep[r]); - } - sb.append("],\"cells\":["); - int[][] pts = {{0, 1}, {0, 2}, {1, 1}, {1, 2}, {7, 1}, {7, 8}, {8, 3}}; - DataFormatter fmt = new DataFormatter(); - boolean first = true; - for (int[] pt : pts) { - Row row = sheet.getRow(pt[0]); - Cell cell = row == null ? null : row.getCell(pt[1]); - String text = cell == null ? "" : StringUtils.trimToEmpty(fmt.formatCellValue(cell)); - byte[] rgb = readFillRgb(cell); - String hex = rgb == null ? "" : String.format("%02X%02X%02X", rgb[0] & 0xff, rgb[1] & 0xff, rgb[2] & 0xff); - if (!first) { - sb.append(','); - } - first = false; - sb.append("{\"r\":").append(pt[0]).append(",\"c\":").append(pt[1]) - .append(",\"t\":\"").append(text.replace("\"", "'").replace("\\", "/")) - .append("\",\"fill\":\"").append(hex).append("\"}"); - } - sb.append("]}"); - agentDbg("H1", "land.banner." + phase, "land banner fill sample", sb.toString()); - } catch (Exception ignored) { - // 采样失败不影响出单 - } - } - // #endregion private static final Set YELLOW_RGB = new HashSet<>(Arrays.asList( "FFFFFF00", "FFFF00", "FFFFEB9C", "FFFFC000", "FFFFFF99", @@ -675,13 +589,7 @@ public final class QuoteTemplateExcelSupport { } JSONObject fieldMeta = mapping.getJSONObject("fieldMeta"); JSONObject fields = mapping.getJSONObject("fields"); - // #region agent log - debugLandBannerColors(wb, "after_open", String.valueOf(sourceXlsx)); - // #endregion LandTemplateLook landLook = snapshotLandTemplateLook(wb); - // #region agent log - debugLandBannerColors(wb, "after_snapshot", String.valueOf(sourceXlsx)); - // #endregion if (fields != null) { // 陆运拼车等模板常把「报价日期」与「有效期」误绑同一格(如 H5「报价日期:」)。 // 同格时只写出报价日期,避免 TMS valid_until 盖掉日期标签含义。 @@ -722,14 +630,6 @@ public final class QuoteTemplateExcelSupport { if (afterColon) { writeCellAfterColon(wb, cellRef, String.valueOf(val)); } else { - // #region agent log - if ("pol".equals(code) || "起运港".equals(code) - || String.valueOf(val).contains("广州") || String.valueOf(val).contains("Guangzhou")) { - agentDbg("H2", "fillWorkbook.fields", "mapping field write", - "{\"code\":\"" + code + "\",\"cellRef\":\"" + cellRef - + "\",\"val\":\"" + String.valueOf(val).replace("\"", "'") + "\"}"); - } - // #endregion // 映射若误指费用名称列:跳过,避免无 TMS 时把起运港写入费用名称 if (isPortFactCode(code)) { ParsedRef pref = ParsedRef.parse(cellRef); @@ -738,10 +638,6 @@ public final class QuoteTemplateExcelSupport { sh = wb.getSheetAt(0); } if (sh != null && isFeeNameColumnRef(sh, cellRef)) { - // #region agent log - agentDbg("H2", "fillWorkbook.fields.blockFeeName", "blocked mapping pol into fee name", - "{\"code\":\"" + code + "\",\"cellRef\":\"" + cellRef + "\"}"); - // #endregion continue; } } @@ -775,17 +671,6 @@ public final class QuoteTemplateExcelSupport { // B3:J3 是原控件锚点合并区,禁止往里写任何单元格字。 clearSyntheticSeaTransportClassCell(wb); writeSeaOtherClassFallbackLabel(wb, facts); - // #region agent log - agentDbg("T1", "fillWorkbook.facts", "sea subtotal facts present", - "{\"fee_total_pol\":\"" + String.valueOf(facts.getString("fee_total_polCostItems")).replace("\"", "'") - + "\",\"fee_total_ocean\":\"" + String.valueOf(facts.getString("fee_total_oceanCostItems")).replace("\"", "'") - + "\",\"fee_total_pod\":\"" + String.valueOf(facts.getString("fee_total_podCostItems")).replace("\"", "'") - + "\",\"total_amount\":\"" + String.valueOf(facts.getString("total_amount")).replace("\"", "'") - + "\",\"currency\":\"" + String.valueOf(facts.getString("currency")).replace("\"", "'") - + "\",\"sea_class\":\"" + String.valueOf(firstNonBlank( - facts.getString("运输分类"), facts.getString("transport_class"), - facts.getString("sea_class"))).replace("\"", "'") + "\"}"); - // #endregion // 产品口径:TMS 已返回汇总金额 → Excel 汇总/合计格去掉公式,改写 TMS 数。 // 海运三段汇总/小计:fee_total_*;整单:total_amount(预估费用/费用总计等)。 writeSeaSectionSubtotals(wb, facts, yellow); @@ -793,25 +678,13 @@ public final class QuoteTemplateExcelSupport { // 无费用明细时:清掉误写入费用名称列的起运港/目的港(WO202609280006) clearPortSpillInFeeNameColumn(wb, facts); // 跨境集拼:列头必须和空白模板一样(蓝底白字)。POI 写回后币种/燃油会变成大红字。 - // #region agent log - debugLandBannerColors(wb, "before_restore", String.valueOf(sourceXlsx)); - // #endregion restoreLandTemplateLook(wb, landLook); - // #region agent log - debugLandBannerColors(wb, "after_restore", String.valueOf(sourceXlsx)); - // #endregion // 列头盖回去时,数据行若被误当成表头,目的地会再次变成币种那套红字。 restyleLandCityValues(wb); // 拼车「元/CBM」整车车型格:跟「始发站」同一浅蓝,禁止盖成顶栏那种深蓝。 alignLandNestedHeaderFill(wb); - // #region agent log - debugLandBannerColors(wb, "after_align", String.valueOf(sourceXlsx)); - // #endregion // 转 PDF 前把纸张改成 A4 居中,避免顶部裁字、整页偏左。 applyQuotePrintLayout(wb); - // #region agent log - debugLandBannerColors(wb, "after_print", String.valueOf(sourceXlsx)); - // #endregion Files.createDirectories(targetXlsx.getParent()); try (OutputStream out = Files.newOutputStream(targetXlsx)) { wb.write(out); @@ -858,26 +731,10 @@ public final class QuoteTemplateExcelSupport { } catch (Exception ignoredDst) { // 仅调试 } - // #region agent log - agentDbg("H7", "land.theme.after_write", "restored land theme fills; strip mixed rgb", - "{\"fixed\":" + themeFixed - + ",\"srcFg\":\"" + String.valueOf(srcFg).replace("\"", "'") - + "\",\"dstTheme\":\"" + String.valueOf(dstTheme).replace("\"", "'") - + "\",\"dstCollapsed\":" + dstCollapsed - + ",\"dstMixed\":" + dstMixed - + ",\"fill2\":\"" + fill2Sample - + "\",\"src\":\"" + String.valueOf(sourceXlsx).replace("\\", "/").replace("\"", "'") - + "\",\"dst\":\"" + String.valueOf(targetXlsx).replace("\\", "/").replace("\"", "'") - + "\"}"); - // #endregion } } // 只对海运模板做控件包/骨架盖回。空运到港等模板禁止走 WO011,否则标题变成「国际海运询价单」。 if (!seaTemplate) { - // #region agent log - agentDbg("H_SEA", "fillWorkbook.skipSeaScaffold", "non-sea template skip WO011 scaffold", - "{\"path\":\"" + String.valueOf(sourceXlsx).replace("\\", "/").replace("\"", "'") + "\"}"); - // #endregion return; } // 按运输分类盖回 WPS 可显示的控件包: @@ -1017,17 +874,6 @@ public final class QuoteTemplateExcelSupport { usedVml = true; } } - // #region agent log - agentDbg("R2", "readSeaDrawingParts", "preserved template drawings", - "{\"count\":" + out.size() - + ",\"srcDrawBytes\":" + (srcDraw == null ? 0 : srcDraw.length) - + ",\"srcVmlBytes\":" + (srcVml == null ? 0 : srcVml.length) - + ",\"usedWo011Draw\":" + usedDraw - + ",\"usedWo011Vml\":" + usedVml - + ",\"finalDrawBytes\":" + (out.get("xl/drawings/drawing1.xml") == null - ? 0 : out.get("xl/drawings/drawing1.xml").length) - + ",\"mode\":\"wo011-draw+vml+styles\"}"); - // #endregion return out; } @@ -1337,10 +1183,6 @@ public final class QuoteTemplateExcelSupport { } String raw = facts.getString(mk[2]); if (StringUtils.isBlank(raw) || "-".equals(raw.trim())) { - // #region agent log - agentDbg("T2", "writeSeaSectionSubtotals.skip", "no subtotal fact", - "{\"row\":" + r + ",\"marker\":\"" + mk[0] + "\",\"key\":\"" + mk[2] + "\"}"); - // #endregion continue; } String ccy = headerCcy; @@ -1360,12 +1202,6 @@ public final class QuoteTemplateExcelSupport { // 有 TMS 汇总:必须去掉公式再写数字 writeAmountCell(sheet, r, amtCol, amt, yellow); clearOtherSummaryFormulasOnRow(row, amtCol, ccyCol); - // #region agent log - agentDbg("T2", "writeSeaSectionSubtotals.write", "wrote section subtotal", - "{\"row\":" + r + ",\"marker\":\"" + mk[0] + "\",\"ccy\":\"" + ccy - + "\",\"amt\":\"" + amt.replace("\"", "'") - + "\",\"ccyCol\":" + ccyCol + ",\"amtCol\":" + amtCol + "}"); - // #endregion break; } } @@ -1443,11 +1279,6 @@ public final class QuoteTemplateExcelSupport { || text.contains("其它") || text.contains("其他"); if (synthetic) { target.setCellValue(""); - // #region agent log - agentDbg("H_B3", "clearSyntheticSeaTransportClassCell", "cleared radio-anchor cell text", - "{\"ref\":\"" + new CellReference(target).formatAsString() - + "\",\"was\":\"" + text.replace("\"", "'") + "\"}"); - // #endregion } return; } @@ -1545,24 +1376,7 @@ public final class QuoteTemplateExcelSupport { Files.move(tmp, targetXlsx, java.nio.file.StandardCopyOption.REPLACE_EXISTING); tmp = null; } - // #region agent log - agentDbg("R1", "syncSeaTransportClassFormControls", "patched form control pack by transport class", - "{\"cls\":\"" + String.valueOf(cls).replace("\"", "'") - + "\",\"wantOther\":" + wantOther - + ",\"useOtherChecked2\":" + useOtherChecked2 - + ",\"touchedCtrl\":" + touchedCtrl + ",\"touchedVml\":" + touchedVml - + ",\"restoredDrawing\":" + restoredDrawing - + ",\"restoredStyles\":" + restoredStyles - + ",\"stylesBytes\":" + (stdStyles == null ? 0 : stdStyles.length) - + ",\"drawBytes\":" + (pickDraw == null ? 0 : pickDraw.length) - + ",\"vmlBytes\":" + (pickVml == null ? 0 : pickVml.length) - + ",\"mode\":\"" + mode + "\"}"); - // #endregion } catch (Exception e) { - // #region agent log - agentDbg("R1", "syncSeaTransportClassFormControls.err", "patch failed", - "{\"err\":\"" + String.valueOf(e.getMessage()).replace("\"", "'") + "\"}"); - // #endregion log.warn("海运运输分类 Form Control 勾选同步失败 path={}", targetXlsx, e); } finally { if (tmp != null) { @@ -1620,13 +1434,10 @@ public final class QuoteTemplateExcelSupport { boolean finished = p.waitFor(120, java.util.concurrent.TimeUnit.SECONDS); if (!finished) { p.destroyForcibly(); - agentDbg("R1", "switchSeaTransportClassToOtherViaExcel.timeout", "excel com timeout", "{}"); return false; } String out = new String(outBytes, StandardCharsets.UTF_8); if (!out.contains("SWITCH_OK")) { - agentDbg("R1", "switchSeaTransportClassToOtherViaExcel.fail", "excel com failed", - "{\"out\":\"" + out.replace("\"", "'").replace("\n", " ").replace("\r", "") + "\"}"); return false; } // 校验 zip:次钮 Checked @@ -1656,14 +1467,9 @@ public final class QuoteTemplateExcelSupport { && !bodies.get(0).contains("") && bodies.get(1).contains("") && c2.contains("checked=\"Checked\""); - agentDbg("R1", "switchSeaTransportClassToOtherViaExcel", "excel switched other radio", - "{\"ok\":" + ok + ",\"vmlChecked2\":" + (bodies.size() > 1 && bodies.get(1).contains("")) - + ",\"ctrl2\":" + c2.contains("checked=\"Checked\"") + "}"); return ok; } } catch (Exception e) { - agentDbg("R1", "switchSeaTransportClassToOtherViaExcel.err", "excel com exception", - "{\"err\":\"" + String.valueOf(e.getMessage()).replace("\"", "'") + "\"}"); log.warn("海运「其它」Excel 切勾失败 path={}", targetXlsx, e); return false; } finally { @@ -1695,10 +1501,6 @@ public final class QuoteTemplateExcelSupport { if (!"其他".equals(cls)) { return; } - // #region agent log - agentDbg("H_B3", "writeSeaOtherClassFallbackLabel", "skip B3 write keep radio anchors empty", - "{\"cls\":\"其他\",\"reason\":\"B3 is radio anchor; scaffold restore keeps radios\"}"); - // #endregion } /** @@ -1721,11 +1523,6 @@ public final class QuoteTemplateExcelSupport { byte[] scaffold = loadWo011SeaSheet1(); String scaffoldSrc = "wo011-bundled"; if (scaffold == null || scaffold.length < 1000) { - // #region agent log - agentDbg("H_SHEET", "restoreSeaSheet1Scaffold.skip", "no scaffold sheet1", - "{\"scaffoldBytes\":" + (scaffold == null ? 0 : scaffold.length) - + ",\"scaffoldSrc\":\"" + scaffoldSrc + "\"}"); - // #endregion return false; } Path tmp = null; @@ -1756,19 +1553,10 @@ public final class QuoteTemplateExcelSupport { // WO035 证据:错误正则把自闭合 的 / 吃进属性,一次匹配跨越多行, // sheet 从 17KB 缩成 7KB、只剩 2 行。合并结果过瘦则放弃,保留 POI 原 sheet。 if (merged.sheetXml.length() < scaffold.length / 2 || rowCount < 20) { - // #region agent log - agentDbg("H_SHEET", "restoreSeaSheet1Scaffold.abort", "merged sheet too thin, keep POI sheet", - "{\"scaffoldBytes\":" + scaffold.length - + ",\"mergedSheetBytes\":" + merged.sheetXml.length() - + ",\"rowCount\":" + rowCount - + ",\"poiCells\":" + poiValues.size() + "}"); - // #endregion return false; } // WO011 骨架合并后若仍带本地 mc xmlns,则放弃(WO036) if (merged.sheetXml.contains("AlternateContent xmlns:mc=")) { - agentDbg("H_SHEET", "restoreSeaSheet1Scaffold.abort", "scaffold still has local mc xmlns", - "{\"scaffoldSrc\":\"" + scaffoldSrc + "\"}"); return false; } tmp = Files.createTempFile(targetXlsx.getParent(), "sea-sheet-", ".xlsx"); @@ -1802,23 +1590,8 @@ public final class QuoteTemplateExcelSupport { } Files.move(tmp, targetXlsx, java.nio.file.StandardCopyOption.REPLACE_EXISTING); tmp = null; - // #region agent log - agentDbg("H_SHEET", "restoreSeaSheet1Scaffold", "restored template sheet1 scaffold", - "{\"scaffoldSrc\":\"" + scaffoldSrc + "\"" - + ",\"scaffoldBytes\":" + scaffold.length - + ",\"poiCells\":" + poiValues.size() - + ",\"mergedSheetBytes\":" + merged.sheetXml.length() - + ",\"sstCount\":" + merged.sstCount - + ",\"patchedCells\":" + merged.patchedCells - + ",\"rowCount\":" + rowCount - + ",\"acHasLocalMc\":" + merged.sheetXml.contains("AlternateContent xmlns:mc=") + "}"); - // #endregion return true; } catch (Exception e) { - // #region agent log - agentDbg("H_SHEET", "restoreSeaSheet1Scaffold.err", "scaffold restore failed", - "{\"err\":\"" + String.valueOf(e.getMessage()).replace("\"", "'") + "\"}"); - // #endregion log.warn("海运 sheet1 骨架还原失败 path={}", targetXlsx, e); return false; } finally { @@ -2171,11 +1944,6 @@ public final class QuoteTemplateExcelSupport { target = row.createCell(1); } target.setCellValue(label); - // #region agent log - agentDbg("R3", "writeSeaTransportClassVisibleLabel", "wrote visible radio label for WPS", - "{\"ref\":\"" + new CellReference(target).formatAsString() - + "\",\"cls\":\"" + cls.replace("\"", "'") + "\",\"port\":" + port + "}"); - // #endregion return; } } @@ -2194,10 +1962,6 @@ public final class QuoteTemplateExcelSupport { facts.getString("sea_class")); String cls = canonicalizeSeaTransportClass(raw); if (StringUtils.isBlank(cls)) { - // #region agent log - agentDbg("R3", "writeSeaTransportClassVisibleLabelToFile.skip", "no transport class for fallback", - "{}"); - // #endregion return; } boolean port = "Port to Port".equalsIgnoreCase(cls); @@ -2258,15 +2022,7 @@ public final class QuoteTemplateExcelSupport { } Files.move(tmp, targetXlsx, java.nio.file.StandardCopyOption.REPLACE_EXISTING); tmp = null; - // #region agent log - agentDbg("R3", "writeSeaTransportClassVisibleLabelToFile", "B3 visible radio text for other or scaffold-fail", - "{\"cls\":\"" + cls.replace("\"", "'") + "\",\"port\":" + port + "}"); - // #endregion } catch (Exception e) { - // #region agent log - agentDbg("R3", "writeSeaTransportClassVisibleLabelToFile.err", "fallback failed", - "{\"err\":\"" + String.valueOf(e.getMessage()).replace("\"", "'") + "\"}"); - // #endregion log.warn("海运运输分类明文兜底失败 path={}", targetXlsx, e); } finally { if (tmp != null) { @@ -2823,7 +2579,9 @@ public final class QuoteTemplateExcelSupport { continue; } // 中港等模板:dataStartRow 常误指「4.2米/3T」车型表头行,下移到真正数据行 + int mappedStart = rowIdx0; rowIdx0 = resolveRouteDataStartRow(sheet, rowIdx0, region.getIntValue("headerRow")); + String kind = landRouteSectionKind(sheet, region); JSONArray sectionRoutes = routesForLandRouteSection(routes, sheet, region, i, regions.size()); if (sectionRoutes == null || sectionRoutes.isEmpty()) { continue; @@ -2908,8 +2666,9 @@ public final class QuoteTemplateExcelSupport { header0 = dataRow - 1; } DataFormatter fmt = new DataFormatter(); - int from = Math.max(0, header0 - 1); - int to = header0 + 1; + // 上传版跨境整车:时效/燃油在表头行,币种常错开一行(O7),多扫两行才能对上。 + int from = Math.max(0, header0 - 2); + int to = header0 + 2; for (int r = from; r <= to; r++) { Row row = sheet.getRow(r); if (row == null) { @@ -2922,6 +2681,8 @@ public final class QuoteTemplateExcelSupport { continue; } String val = landMatrixSideValue(text, rowObj, facts); + if (text.contains("燃油") || "币种".equals(text) || "币别".equals(text) || text.contains("时效")) { + } if (StringUtils.isBlank(val) || "-".equals(val)) { continue; } @@ -3013,10 +2774,10 @@ public final class QuoteTemplateExcelSupport { short lastCol = row.getLastCellNum(); for (int c = 0; c < lastCol; c++) { String text = StringUtils.trimToEmpty(fmt.formatCellValue(row.getCell(c))).replaceAll("\\s+", ""); - if (text.contains("国内段")) { + if (isLandDomesticSectionTitle(text)) { domestic = true; } - if (text.contains("境外段") || text.contains("国外段")) { + if (isLandOverseasSectionTitle(text)) { overseas = true; } } @@ -3025,7 +2786,8 @@ public final class QuoteTemplateExcelSupport { } /** - * 国内段 / 境外段。认 mapping 标题,再认表上「一、国内段报价」这类分区标题。 + * 国内段 / 境外段。认 mapping 标题,再认表上「一、国内段报价」「二、境外报价」这类分区标题。 + * 上传版标题是「境外报价」不一定带「段」字。 */ static String landRouteSectionKind(Sheet sheet, JSONObject region) { String title = ""; @@ -3033,10 +2795,10 @@ public final class QuoteTemplateExcelSupport { title = firstNonBlank(region.getString("title"), region.getString("id"), region.getString("name")); } String compact = StringUtils.trimToEmpty(title).replaceAll("\\s+", ""); - if (compact.contains("境外") || compact.contains("国外")) { + if (isLandOverseasSectionTitle(compact)) { return "overseas"; } - if (compact.contains("国内")) { + if (isLandDomesticSectionTitle(compact)) { return "domestic"; } if (sheet == null || region == null) { @@ -3055,10 +2817,10 @@ public final class QuoteTemplateExcelSupport { short lastCol = row.getLastCellNum(); for (int c = 0; c < lastCol; c++) { String text = StringUtils.trimToEmpty(fmt.formatCellValue(row.getCell(c))).replaceAll("\\s+", ""); - if (text.contains("境外段") || text.contains("国外段")) { + if (isLandOverseasSectionTitle(text)) { return "overseas"; } - if (text.contains("国内段")) { + if (isLandDomesticSectionTitle(text)) { return "domestic"; } } @@ -3079,10 +2841,10 @@ public final class QuoteTemplateExcelSupport { short lastCol = row.getLastCellNum(); for (int c = 0; c < lastCol; c++) { String text = StringUtils.trimToEmpty(fmt.formatCellValue(row.getCell(c))).replaceAll("\\s+", ""); - if (text.contains("境外段") || text.contains("国外段")) { + if (isLandOverseasSectionTitle(text)) { return "overseas"; } - if (text.contains("国内段")) { + if (isLandDomesticSectionTitle(text)) { return "domestic"; } } @@ -3090,6 +2852,29 @@ public final class QuoteTemplateExcelSupport { return ""; } + /** 分区标题:国内段报价 / 一、国内段报价。 */ + static boolean isLandDomesticSectionTitle(String text) { + if (StringUtils.isBlank(text)) { + return false; + } + String t = text.replaceAll("\\s+", ""); + return t.contains("国内段") || t.contains("国内报价"); + } + + /** + * 分区标题:境外段报价 / 二、境外报价(上传版常不带「段」)。 + * 不用光秃秃的「境外」两字,避免误伤备注里的「境外」字样。 + */ + static boolean isLandOverseasSectionTitle(String text) { + if (StringUtils.isBlank(text)) { + return false; + } + String t = text.replaceAll("\\s+", ""); + return t.contains("境外段") || t.contains("国外段") || t.contains("境外报价") + || t.contains("国外报价") || (t.contains("境外") && t.contains("报价")) + || (t.contains("国外") && t.contains("报价")); + } + /** * 国内段写境内城市,境外段写境外城市。两段 TMS 腿按腿序号对上。 * 只有一对郑州→吉隆坡时:国内段只写郑州,境外段只写吉隆坡,不发明口岸。 @@ -3206,6 +2991,26 @@ public final class QuoteTemplateExcelSupport { if (StringUtils.isNotBlank(included)) { out.put("tax_included", included); } + // 跨境分段只拆 OD,车型/运价必须原样带上;否则国内段 3T 列对不上 vehicleType,4000 写不进去。 + String vehicleType = firstNonBlank(row.getString("vehicleType"), row.getString("vehicle_type")); + if (StringUtils.isNotBlank(vehicleType)) { + out.put("vehicleType", vehicleType); + out.put("vehicle_type", vehicleType); + } + String sale = firstNonBlank(row.getString("salePrice"), row.getString("costPrice"), row.getString("amount")); + if (StringUtils.isNotBlank(sale)) { + if (StringUtils.isNotBlank(row.getString("salePrice"))) { + out.put("salePrice", row.getString("salePrice")); + } + if (StringUtils.isNotBlank(row.getString("costPrice"))) { + out.put("costPrice", row.getString("costPrice")); + } + if (StringUtils.isNotBlank(row.getString("amount"))) { + out.put("amount", row.getString("amount")); + } else { + out.put("amount", sale); + } + } return out; } @@ -3216,7 +3021,7 @@ public final class QuoteTemplateExcelSupport { String t = text.replaceAll("\\s+", ""); String[] marks = { "吉隆坡", "胡志明", "河内", "海防", "岘港", "谅山", "同登", "老街", "芒街", - "曼谷", "清迈", "金边", "西哈努克", "新加坡", "雅加达", "万象", "仰光", + "春疆", "曼谷", "清迈", "金边", "西哈努克", "新加坡", "雅加达", "万象", "仰光", "马来", "越南", "泰国", "柬埔寨", "老挝", "缅甸", "印尼", "菲律宾", "Hồ", "ChiMinh", "Kuala", "Lumpur", "Hanoi", "Bangkok", "Saigon", "Phnom" }; @@ -3245,9 +3050,11 @@ public final class QuoteTemplateExcelSupport { return rowIdx0; } int cur = rowIdx0; - for (int i = 0; i < 3; i++) { + for (int i = 0; i < 4; i++) { if (rowLooksLikeVehicleHeader(sheet, cur) || rowLooksLikeLandRouteMatrixHeader(sheet, cur) - || rowLooksLikeDeliveryPriceHeader(sheet, cur)) { + || rowLooksLikeDeliveryPriceHeader(sheet, cur) + || rowLooksLikeVehicleDimensionRow(sheet, cur) + || rowLooksLikeLandUnitSubHeader(sheet, cur)) { cur++; continue; } @@ -3257,7 +3064,9 @@ public final class QuoteTemplateExcelSupport { if (header0 >= 0 && cur == header0) { cur = header0 + 1; if (rowLooksLikeVehicleHeader(sheet, cur) || rowLooksLikeLandRouteMatrixHeader(sheet, cur) - || rowLooksLikeDeliveryPriceHeader(sheet, cur)) { + || rowLooksLikeDeliveryPriceHeader(sheet, cur) + || rowLooksLikeVehicleDimensionRow(sheet, cur) + || rowLooksLikeLandUnitSubHeader(sheet, cur)) { cur++; } } @@ -3330,11 +3139,16 @@ public final class QuoteTemplateExcelSupport { Cell mcell = mrow == null ? null : mrow.getCell(merge[1]); String exist = readCellText(mcell); boolean originOrDest = isLandOriginHeaderText(exist) || "目的地".equals(StringUtils.trimToEmpty(exist).replaceAll("\\s+", "")); + boolean dimCell = looksLikeVehicleDimensionText(exist); boolean matrixRow = rowLooksLikeLandRouteMatrixHeader(sheet, rowIdx) || rowLooksLikeVehicleHeader(sheet, rowIdx) || rowLooksLikeDeliveryPriceHeader(sheet, rowIdx) + || rowLooksLikeVehicleDimensionRow(sheet, rowIdx) + || rowLooksLikeLandUnitSubHeader(sheet, rowIdx) || rowLooksLikeLandRouteMatrixHeader(sheet, merge[0]) - || rowLooksLikeVehicleHeader(sheet, merge[0]); + || rowLooksLikeVehicleHeader(sheet, merge[0]) + || rowLooksLikeVehicleDimensionRow(sheet, merge[0]) + || dimCell; // 拼车是上下标签栏(始发站、目的地、元/CBM),不能整列往下挪,否则城市会盖掉单价标签。 if (!matrixRow && !originOrDest) { return new int[]{rowIdx, col}; @@ -3969,13 +3783,11 @@ public final class QuoteTemplateExcelSupport { col = origin[1]; } String ref = sheetName + "!" + new CellReference(rowIdx, col).formatAsString(); - // #region agent log - if (StringUtils.isNotBlank(value) && (value.contains("广州") || "B".equalsIgnoreCase(colLetter.trim()))) { - agentDbg("H3", "writeFeeCol", "fee col write", - "{\"ref\":\"" + ref + "\",\"col\":\"" + colLetter - + "\",\"val\":\"" + value.replace("\"", "'") + "\"}"); - } - // #endregion + String headerPeek = columnHeaderText(sheet, rowIdx, col); + boolean trackOdFuel = isCityRouteColumn(sheet, rowIdx, col, headerPeek) + || looksLikeCurrencyCode(value) + || (headerPeek != null && (headerPeek.contains("燃油") || headerPeek.contains("币种") + || headerPeek.contains("起始") || headerPeek.contains("始发") || headerPeek.contains("目的"))); if (yellow != null && yellow.contains(ref)) { return; } @@ -4016,6 +3828,10 @@ public final class QuoteTemplateExcelSupport { if (looksLikeCurrencyCode(value) && columnRejectsCurrency(header)) { return; } + // 起始地/目的地只写城市。fee_rows 误指运价行时,「运输费」不能盖掉深圳(WO202610080006)。 + if (isCityRouteColumn(sheet, rowIdx, col, header) && looksLikeFeeNameNotPlace(value)) { + return; + } writeNumericOrText(cell, value); // 始发地/目的地是城市。模板空格常套用「币种/燃油」的红字+顶对齐, // 长地名一换行就会画进蓝表头,看起来像列头被改了颜色。 @@ -4061,6 +3877,26 @@ public final class QuoteTemplateExcelSupport { || "HKD".equals(t) || "VND".equals(t); } + /** + * 费用名,不是城市。写进「起始地/目的地」会把深圳盖成运输费。 + * 只拦常见运价费目,不拦「春疆货场」这类地名。 + */ + static boolean looksLikeFeeNameNotPlace(String value) { + if (StringUtils.isBlank(value)) { + return false; + } + String t = value.trim().replaceAll("\\s+", ""); + if ("运输费".equals(t) || "提货费".equals(t) || "送货费".equals(t) || "派送费".equals(t) + || "陆运费".equals(t) || "干线费".equals(t) || "空运费".equals(t) || "海运费".equals(t) + || "运费".equals(t) || "燃油附加费".equals(t) || "操作费".equals(t) || "文件费".equals(t) + || "提送货费".equals(t) || "人工费".equals(t)) { + return true; + } + // 短费目名(…费)且不像货场/口岸地名 + return t.length() <= 8 && t.endsWith("费") && !t.contains("场") && !t.contains("港") + && !t.contains("站") && !t.contains("仓"); + } + /** * 这些列头不是币种:写 CNY 会把覆盖区域的城市、或燃油附加费率的空格盖掉。 * 表头是「币种 / 币别」,或上面没有列头时,仍允许写币种。 @@ -4661,7 +4497,7 @@ public final class QuoteTemplateExcelSupport { continue; } byte[] sample = sampleLandMatrixHeaderFill(sheet, r); - if (sample == null || looksYellowRgb(sample)) { + if (sample == null || looksYellowRgb(sample) || looksLikeWhiteOrEmptyFill(sample)) { continue; } Row row = sheet.getRow(r); @@ -4684,7 +4520,8 @@ public final class QuoteTemplateExcelSupport { } /** - * 向上找运价表头「始发站/始发地/序号」的浅蓝底。 + * 向上找运价表头「始发站/始发地/序号」的灰/浅蓝底。 + * 跨境整车境外段模板里「起始地」偶发白底(theme0);若拿它刷整行,会把同行「币种」灰底盖白。 */ static byte[] sampleLandMatrixHeaderFill(Sheet sheet, int fromRow) { if (sheet == null) { @@ -4702,6 +4539,7 @@ public final class QuoteTemplateExcelSupport { if (row == null) { continue; } + byte[] preferred = null; byte[] fallback = null; for (Cell cell : row) { if (cell == null) { @@ -4709,17 +4547,23 @@ public final class QuoteTemplateExcelSupport { } String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)).replaceAll("\\s+", ""); byte[] rgb = readFillRgb(cell); - if (rgb == null || looksYellowRgb(rgb)) { + if (rgb == null || looksYellowRgb(rgb) || looksLikeWhiteOrEmptyFill(rgb)) { continue; } if (isLandOriginHeaderText(text) || "序号".equals(text) || "目的地".equals(text) - || "提货费".equals(text) || "送货费".equals(text)) { - return rgb; + || "提货费".equals(text) || "送货费".equals(text) + || "币种".equals(text) || "币别".equals(text) || "时效".equals(text) + || text.contains("燃油")) { + preferred = rgb; + break; } if (fallback == null) { fallback = rgb; } } + if (preferred != null) { + return preferred; + } if (fallback != null) { return fallback; } @@ -4737,6 +4581,24 @@ public final class QuoteTemplateExcelSupport { return r >= 200 && g >= 180 && b <= 140; } + /** 近白底:不能当表头采样色,否则会把灰底「币种」刷白。 */ + static boolean looksLikeWhiteOrEmptyFill(byte[] rgb) { + if (rgb == null || rgb.length < 3) { + return true; + } + int r = rgb[0] & 0xff; + int g = rgb[1] & 0xff; + int b = rgb[2] & 0xff; + return r >= 245 && g >= 245 && b >= 245; + } + + static String rgbHex(byte[] rgb) { + if (rgb == null || rgb.length < 3) { + return ""; + } + return String.format("%02X%02X%02X", rgb[0] & 0xff, rgb[1] & 0xff, rgb[2] & 0xff); + } + static void applyFillRgb(Cell cell, byte[] fillRgb) { if (cell == null || fillRgb == null || fillRgb.length < 3) { return; @@ -4790,6 +4652,57 @@ public final class QuoteTemplateExcelSupport { return units >= 1 && !originDest; } + /** + * 跨境整车车型尺寸副表头:如 4.1*2.1*2.3M / 12.8*2.32*2.7M。 + * 运价必须写在下一行,不能盖掉尺寸说明(WO125 曾把 4000 写进 D8)。 + */ + static boolean rowLooksLikeVehicleDimensionRow(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 text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)); + if (looksLikeVehicleDimensionText(text)) { + hits++; + if (hits >= 2) { + return true; + } + } + } + return false; + } + + /** 单个车型尺寸格:长*宽*高M 或 长x宽x高M。 */ + static boolean looksLikeVehicleDimensionText(String text) { + if (StringUtils.isBlank(text)) { + return false; + } + String t = text.replaceAll("\\s+", "").toUpperCase(Locale.ROOT); + if (t.length() < 5 || t.length() > 40) { + return false; + } + int stars = 0; + for (int i = 0; i < t.length(); i++) { + char ch = t.charAt(i); + if (ch == '*' || ch == 'X' || ch == '×') { + stars++; + } + } + if (stars < 2) { + return false; + } + return t.matches("^[0-9.]+[*X×][0-9.]+[*X×][0-9.]+M?$"); + } + /** 「项目一:零担报价 / 整车报价」「项目二:其它费用」。 */ static boolean rowLooksLikeLandSectionTitle(Sheet sheet, int rowIdx) { if (sheet == null || rowIdx < 0) { @@ -5232,11 +5145,6 @@ public final class QuoteTemplateExcelSupport { clearEstimateExtraCell(row, cell); } } - // #region agent log - agentDbg("T3", "overwriteEstimateTotal.sea", "wrote total at fee columns", - "{\"row\":" + r + ",\"ccyCol\":" + ccyCol + ",\"amtCol\":" + amtCol - + ",\"ccy\":\"" + ccy + "\",\"amt\":\"" + norm.replace("\"", "'") + "\"}"); - // #endregion continue; } // 空运:优先写到费目表「金额」列(常为 G),避免写到标签旁 B 而 SUM 仍在 G @@ -5304,11 +5212,6 @@ public final class QuoteTemplateExcelSupport { Cell extra = row.getCell(dest[1]); clearEstimateExtraCell(row, extra); } - // #region agent log - agentDbg("T3", "overwriteEstimateTotal.air", "wrote tms total over SUM", - "{\"row\":" + r + ",\"col\":" + targetCol - + ",\"amt\":\"" + norm.replace("\"", "'") + "\"}"); - // #endregion } } } @@ -5480,13 +5383,6 @@ public final class QuoteTemplateExcelSupport { } String name = StringUtils.trimToEmpty(rowObj.getString("name")); String amount = StringUtils.trimToEmpty(rowObj.getString("amount")); - // #region agent log - if (StringUtils.isNotBlank(name)) { - agentDbg("H3", "writeFeeAmountsByName", "fee row candidate", - "{\"name\":\"" + name.replace("\"", "'") + "\",\"amount\":\"" - + amount.replace("\"", "'") + "\"}"); - } - // #endregion if (StringUtils.isBlank(name) || StringUtils.isBlank(amount) || "-".equals(amount)) { continue; } @@ -6061,14 +5957,6 @@ public final class QuoteTemplateExcelSupport { } private static void writeCell(Workbook wb, String cellRef, String value) { - // #region agent log - if (StringUtils.isNotBlank(value) && (value.contains("广州") || value.contains("Guangzhou") - || "广州".equals(value.trim()))) { - agentDbg("H5", "writeCell", "cell write pol-like", - "{\"cellRef\":\"" + String.valueOf(cellRef).replace("\"", "'") - + "\",\"val\":\"" + value.replace("\"", "'") + "\"}"); - } - // #endregion ParsedRef ref = ParsedRef.parse(cellRef); Sheet sheet = wb.getSheet(ref.sheet); if (sheet == null && wb.getNumberOfSheets() > 0) { @@ -6100,6 +5988,15 @@ public final class QuoteTemplateExcelSupport { || rowLooksLikeLandUnitSubHeader(sheet, dest[0])) { return; } + // mapping.fields 误指燃油/时效列时:CNY 不能经 writeCell 落进去(与 writeFeeCol 同规则)。 + String header = columnHeaderText(sheet, dest[0], dest[1]); + if (looksLikeCurrencyCode(text) && columnRejectsCurrency(header)) { + return; + } + // 起始地列禁止被费用名污染(与 writeFeeCol 同规则)。 + if (isCityRouteColumn(sheet, dest[0], dest[1], header) && looksLikeFeeNameNotPlace(text)) { + return; + } int colon = Math.max(existing.indexOf(':'), existing.indexOf(':')); if (colon >= 0) { String prefix = existing.substring(0, colon + 1); @@ -6474,19 +6371,6 @@ public final class QuoteTemplateExcelSupport { // 费用明细数据行:「起运港/目的港」是项目列值,不是询价标签; // 否则会把 pol 写进右侧「费用名称」(WO202609230026:广州写入 B18)。 if (!feeHeader && isFeeDetailDataRow(sheet, r)) { - // #region agent log - Row peek = sheet.getRow(r); - String peek0 = peek == null ? "" : StringUtils.trimToEmpty( - new DataFormatter().formatCellValue(peek.getCell(0))); - String peek1 = peek == null ? "" : StringUtils.trimToEmpty( - new DataFormatter().formatCellValue(peek.getCell(1))); - if (peek0.contains("起运港") || peek1.contains("起运港") - || peek0.contains("空运段") || peek1.contains("空运段")) { - agentDbg("H1", "writeFactsByLabel.skipFeeDetail", "skipped fee detail row", - "{\"row\":" + r + ",\"c0\":\"" + peek0.replace("\"", "'") - + "\",\"c1\":\"" + peek1.replace("\"", "'") + "\"}"); - } - // #endregion continue; } short lastCol = row.getLastCellNum(); @@ -6524,11 +6408,17 @@ public final class QuoteTemplateExcelSupport { // 运价矩阵的起始地/目的地按国内段、境外段拆开写,禁止同一对城市填进两张表。 if (("origin".equals(code) || "destination".equals(code)) && rowLooksLikeLandRouteMatrixHeader(sheet, r)) { - String kind = landRouteSectionKindNearRow(sheet, r); - if ("domestic".equals(kind) && "destination".equals(code) && looksOverseasPlace(val)) { + // 有 TMS 线路行时由 writeRouteRegions 分区写 OD;标签回填会用短地名盖掉「广东省-深圳市」。 + if (!parseFeeRows(facts.get("route_rows")).isEmpty() + || !parseFeeRows(facts.get("landSegments")).isEmpty()) { continue; } - if ("overseas".equals(kind) && "origin".equals(code) && looksDomesticPlace(val)) { + String kind = landRouteSectionKindNearRow(sheet, r); + boolean skipDomDest = "domestic".equals(kind) && "destination".equals(code) + && looksOverseasPlace(val); + boolean skipOsOrigin = "overseas".equals(kind) && "origin".equals(code) + && looksDomesticPlace(val); + if (skipDomDest || skipOsOrigin) { continue; } } @@ -6538,28 +6428,8 @@ public final class QuoteTemplateExcelSupport { } // 硬防护:pol/pod 等港口字段禁止写入费用「名称」列(无 TMS 时曾把「广州」写进费用名称) if (isPortFactCode(code) && isFeeNameColumnRef(sheet, fillRef)) { - // #region agent log - agentDbg("H1", "writeFactsByLabel.blockFeeName", "blocked pol/pod into fee name col", - "{\"row\":" + r + ",\"code\":\"" + code + "\",\"fillRef\":\"" + fillRef - + "\",\"text\":\"" + text.replace("\"", "'") - + "\",\"val\":\"" + val.replace("\"", "'") + "\"}"); - // #endregion continue; } - // #region agent log - if ("pol".equals(code) || text.contains("起运港") - || "fcl_or_lcl".equals(code) || text.contains("整柜") - || "运输分类".equals(code) || text.contains("送货地址")) { - boolean feeDetail = isFeeDetailDataRow(sheet, r); - agentDbg("S1", "writeFactsByLabel.sea", "label matched sea-sensitive", - "{\"sheet\":\"" + sheetName + "\",\"row\":" + r + ",\"col\":" + c - + ",\"text\":\"" + text.replace("\"", "'") - + "\",\"code\":\"" + code + "\",\"fillRef\":\"" + fillRef - + "\",\"feeHeader\":" + feeHeader - + ",\"feeDetailDataRow\":" + feeDetail - + ",\"val\":\"" + val.replace("\"", "'") + "\"}"); - } - // #endregion boolean labelAfterColon = textContainsColon(text) && ("quote_no".equals(code) || "quote_date".equals(code) || "valid_until".equals(code)); // 编号/日期类:标签同格冒号后允许写黄区;其它字段仍跳过固定区 @@ -6667,12 +6537,6 @@ public final class QuoteTemplateExcelSupport { } } } - // #region agent log - if (cleared > 0) { - agentDbg("H1", "clearPortSpillInFeeNameColumn", "cleared port spill in fee names", - "{\"cleared\":" + cleared + "}"); - } - // #endregion } /** diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java index e86dc70..bf4997f 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java @@ -380,8 +380,13 @@ public class InquiryTmsQueryService { quote.put("landSegments", json); // 同步一份 route_rows,字段名对齐填表(transit_time / salePrice 等) List> rows = normalizeLandSegmentRows(raw); + // 腿上缺燃油时,从同段 feeItems 补(TMS 有时只把 fuelSurchargeRate 挂在费用行上) + enrichLandFuelTaxFromFeeItems(rows, data == null ? null : data.get("feeItems"), + data == null ? null : data.get("fee_items")); if (!rows.isEmpty()) { quote.put("route_rows", JSON.toJSONString(rows)); + // 回写补全后的腿,避免后续只读 landSegments 时又丢燃油 + quote.put("landSegments", JSON.toJSONString(rows)); } // feeItems 为空时,用各腿成本补运输费。顶层总价会被误收成「空运费」,陆运要换掉。 String feeLines = quote.get("fee_lines"); @@ -1019,6 +1024,88 @@ public class InquiryTmsQueryService { } } + /** + * 陆运腿缺燃油附加费率/税率时,按同段 feeItems 补。 + *

+ * 为何:TMS 偶发只在费用行带 fuelSurchargeRate,腿上为 null;出单侧栏就会空着。 + * 不编造;feeItems 也没有则保持空。 + */ + @SuppressWarnings("unchecked") + static void enrichLandFuelTaxFromFeeItems(List> rows, Object... feeSources) { + if (rows == null || rows.isEmpty() || feeSources == null) { + return; + } + List> fees = new ArrayList<>(); + for (Object src : feeSources) { + if (src == null) { + continue; + } + Object parsed = src; + if (src instanceof String) { + try { + parsed = JSON.parse(String.valueOf(src)); + } catch (Exception ex) { + continue; + } + } + if (parsed instanceof List) { + for (Object one : (List) parsed) { + if (one instanceof Map) { + fees.add((Map) one); + } + } + } + } + if (fees.isEmpty()) { + return; + } + for (Map row : rows) { + if (row == null) { + continue; + } + boolean needFuel = StringUtils.isBlank(row.get("fuel_surcharge_rate")) + && StringUtils.isBlank(row.get("fuelSurchargeRate")); + boolean needTax = StringUtils.isBlank(row.get("tax_rate")) + && StringUtils.isBlank(row.get("taxRate")); + if (!needFuel && !needTax) { + continue; + } + String seg = firstText(row.get("segmentSequence"), "1"); + String leg = firstText(row.get("routeLegSequence"), ""); + for (Map fee : fees) { + String feeSeg = firstText(fee.get("segmentSequence"), fee.get("segment_sequence"), "1"); + String feeLeg = firstText(fee.get("routeLegSequence"), fee.get("route_leg_sequence"), ""); + if (!seg.equals(feeSeg)) { + continue; + } + if (StringUtils.isNotBlank(leg) && StringUtils.isNotBlank(feeLeg) && !leg.equals(feeLeg)) { + continue; + } + if (needFuel) { + String fuel = formatLandPercentRate(firstText( + fee.get("fuelSurchargeRate"), fee.get("fuel_surcharge_rate"))); + if (StringUtils.isNotBlank(fuel)) { + row.put("fuel_surcharge_rate", fuel); + row.put("fuelSurchargeRate", fuel); + needFuel = false; + } + } + if (needTax) { + // 费用行 rate 是税率;禁止拿无关字段冒充 + String tax = formatLandTaxRate(firstText(fee.get("rate"), fee.get("tax_rate"), fee.get("taxRate"))); + if (StringUtils.isNotBlank(tax)) { + row.put("tax_rate", tax); + row.put("taxRate", tax); + needTax = false; + } + } + if (!needFuel && !needTax) { + break; + } + } + } + } + /** * 燃油附加费率展示:数字带 %;已带 % 保留。空值不编造。 */ 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 9121b62..47391ed 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 @@ -2,10 +2,13 @@ package org.jeecg.modules.inquiry.template; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; +import org.apache.poi.ss.usermodel.FillPatternType; 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.xssf.usermodel.XSSFCellStyle; +import org.apache.poi.xssf.usermodel.XSSFColor; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.junit.jupiter.api.Test; @@ -1736,6 +1739,127 @@ public class QuoteTemplateFillTest { } } + /** + * 跨境整车分段切片后仍须带上车型与运价;尺寸副表头行不能被 4000 盖掉,运价写在下一行。 + */ + @Test + public void landCrossBorderFtlKeepsVehiclePriceAfterOdSlice() throws Exception { + Path src = Files.createTempFile("quote-land-cb-price-src", ".xlsx"); + Path out = Files.createTempFile("quote-land-cb-price-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("跨境整车"); + row(sheet, 5).createCell(0).setCellValue("一、国内段报价"); + org.apache.poi.ss.usermodel.Row head1 = row(sheet, 7); + head1.createCell(1).setCellValue("起始地"); + head1.createCell(2).setCellValue("目的地"); + head1.createCell(3).setCellValue("3T"); + head1.createCell(4).setCellValue("5T"); + org.apache.poi.ss.usermodel.Row dim1 = row(sheet, 8); + dim1.createCell(3).setCellValue("4.1*2.1*2.3M"); + dim1.createCell(4).setCellValue("6.5*2.4*2.5M"); + row(sheet, 24).createCell(0).setCellValue("二、境外报价"); + org.apache.poi.ss.usermodel.Row head2 = row(sheet, 26); + head2.createCell(1).setCellValue("起始地"); + head2.createCell(2).setCellValue("目的地"); + head2.createCell(3).setCellValue("1.25T"); + head2.createCell(4).setCellValue("2.5T"); + org.apache.poi.ss.usermodel.Row dim2 = row(sheet, 27); + dim2.createCell(3).setCellValue("2.8x1.5x1.6M"); + dim2.createCell(4).setCellValue("3.50x1.6x1.6M"); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject mapping = new JSONObject(true); + mapping.put("sheet", "跨境整车"); + JSONArray regions = new JSONArray(); + JSONObject inland = new JSONObject(true); + inland.put("title", "国内段报价"); + inland.put("headerRow", 8); + inland.put("dataStartRow", 8); + JSONObject cols1 = new JSONObject(true); + cols1.put("origin", "B"); + cols1.put("destination", "C"); + JSONObject rates1 = new JSONObject(true); + rates1.put("3T", "D"); + rates1.put("5T", "E"); + cols1.put("vehicleRates", rates1); + inland.put("columns", cols1); + regions.add(inland); + JSONObject overseas = new JSONObject(true); + overseas.put("title", "境外报价"); + overseas.put("headerRow", 27); + overseas.put("dataStartRow", 27); + JSONObject cols2 = new JSONObject(true); + cols2.put("origin", "B"); + cols2.put("destination", "C"); + JSONObject rates2 = new JSONObject(true); + rates2.put("1.25T", "D"); + rates2.put("2.5T", "E"); + cols2.put("vehicleRates", rates2); + overseas.put("columns", cols2); + regions.add(overseas); + mapping.put("routeRegions", regions); + JSONObject facts = new JSONObject(true); + facts.put("origin", "深圳"); + facts.put("destination", "春疆货场"); + facts.put("currency", "CNY"); + facts.put("route_rows", + "[{\"origin\":\"广东省-深圳市\",\"destination\":\"谅山-春疆货场\"," + + "\"vehicleType\":\"3T\",\"amount\":\"4000.00\",\"currency\":\"CNY\"}]"); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = filled.getSheetAt(0); + assertEquals("4.1*2.1*2.3M", read(sheet, 8, 3)); + assertEquals("广东省-深圳市", read(sheet, 9, 1)); + assertEquals("", read(sheet, 9, 2)); + assertEquals("4000.00", read(sheet, 9, 3)); + assertEquals("", read(sheet, 9, 4)); + assertEquals("2.8x1.5x1.6M", read(sheet, 27, 3)); + assertEquals("", read(sheet, 28, 1)); + assertEquals("谅山-春疆货场", read(sheet, 28, 2)); + assertEquals("", read(sheet, 28, 3)); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + + /** + * 境外段「起始地」若是白底,对齐表头时不能拿它去刷「币种」灰底。 + */ + @Test + public void landCrossBorderFtlAlignHeaderSkipsWhiteOriginFill() throws Exception { + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("跨境整车"); + Row head = sheet.createRow(26); + head.createCell(1).setCellValue("起始地"); + head.createCell(2).setCellValue("目的地"); + head.createCell(3).setCellValue("1.25T"); + head.createCell(4).setCellValue("2.5T"); + head.createCell(14).setCellValue("币种"); + XSSFCellStyle white = (XSSFCellStyle) wb.createCellStyle(); + white.setFillForegroundColor(new XSSFColor(new byte[]{(byte) 0xFF, (byte) 0xFF, (byte) 0xFF}, null)); + white.setFillPattern(FillPatternType.SOLID_FOREGROUND); + XSSFCellStyle grey = (XSSFCellStyle) wb.createCellStyle(); + grey.setFillForegroundColor(new XSSFColor(new byte[]{(byte) 0xCF, (byte) 0xCF, (byte) 0xCF}, null)); + grey.setFillPattern(FillPatternType.SOLID_FOREGROUND); + head.getCell(1).setCellStyle(white); + head.getCell(2).setCellStyle(white); + head.getCell(3).setCellStyle(grey); + head.getCell(4).setCellStyle(grey); + head.getCell(14).setCellStyle(grey); + byte[] sample = QuoteTemplateExcelSupport.sampleLandMatrixHeaderFill(sheet, 26); + assertFalse(QuoteTemplateExcelSupport.looksLikeWhiteOrEmptyFill(sample)); + assertEquals("CFCFCF", String.format("%02X%02X%02X", + sample[0] & 0xff, sample[1] & 0xff, sample[2] & 0xff)); + QuoteTemplateExcelSupport.alignLandNestedHeaderFill(wb); + byte[] currencyFill = QuoteTemplateExcelSupport.readFillRgb(head.getCell(14)); + assertEquals("CFCFCF", String.format("%02X%02X%02X", + currencyFill[0] & 0xff, currencyFill[1] & 0xff, currencyFill[2] & 0xff)); + } + } + /** * 跨境整车国内段只写境内起点,境外段只写境外终点,不能两段都写成郑州→吉隆坡。 */ @@ -1819,6 +1943,133 @@ public class QuoteTemplateFillTest { } } + /** + * WO202610080006:跨境整车上传版表头是「时效 | 币种(错行) | 燃油 | 税率 | 含税合计」。 + * 起始地不能被费用名「运输费」盖掉;CNY 只能进币种列;燃油附加费率要写到燃油列。 + */ + @Test + public void landCrossBorderFtlKeepsOriginCurrencyFuelOnStaggeredHeaders() throws Exception { + Path src = Files.createTempFile("quote-land-cb-wo006-src", ".xlsx"); + Path out = Files.createTempFile("quote-land-cb-wo006-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("跨境整车报价模板"); + row(sheet, 4).createCell(0).setCellValue("一、国内段报价"); + org.apache.poi.ss.usermodel.Row band1 = row(sheet, 5); + band1.createCell(0).setCellValue("国内段报价"); + band1.createCell(1).setCellValue("路线"); + band1.createCell(3).setCellValue("车型"); + band1.createCell(13).setCellValue("时效"); + band1.createCell(15).setCellValue("燃油附加费率"); + band1.createCell(16).setCellValue("税率"); + band1.createCell(17).setCellValue("含税合计"); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(5, 7, 13, 13)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(5, 7, 15, 15)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(5, 7, 16, 16)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(5, 7, 17, 17)); + org.apache.poi.ss.usermodel.Row head1 = row(sheet, 6); + head1.createCell(1).setCellValue("起始地"); + head1.createCell(2).setCellValue("目的地"); + head1.createCell(3).setCellValue("3T"); + head1.createCell(4).setCellValue("5T"); + // 币种只印在第 7 行 O 列,和时效/燃油不在同一行(上传版真实布局) + head1.createCell(14).setCellValue("币种"); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(6, 7, 1, 1)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(6, 7, 2, 2)); + row(sheet, 7).createCell(3).setCellValue("4.1*2.1*2.3M"); + row(sheet, 23).createCell(0).setCellValue("二、境外报价"); + org.apache.poi.ss.usermodel.Row band2 = row(sheet, 24); + band2.createCell(0).setCellValue("境外报价"); + band2.createCell(13).setCellValue("时效"); + band2.createCell(15).setCellValue("燃油附加费率"); + band2.createCell(16).setCellValue("税率"); + band2.createCell(17).setCellValue("含税合计"); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(24, 26, 13, 13)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(24, 26, 15, 15)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(24, 26, 16, 16)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(24, 26, 17, 17)); + org.apache.poi.ss.usermodel.Row head2 = row(sheet, 25); + head2.createCell(1).setCellValue("起始地"); + head2.createCell(2).setCellValue("目的地"); + head2.createCell(3).setCellValue("1.25T"); + head2.createCell(14).setCellValue("币种"); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(25, 26, 1, 1)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(25, 26, 2, 2)); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject mapping = new JSONObject(true); + mapping.put("sheet", "跨境整车报价模板"); + // 映射误把 currency 指到燃油列 P9:writeCell 路径也必须拒绝 + JSONObject fields = new JSONObject(true); + fields.put("currency", "跨境整车报价模板!P9"); + mapping.put("fields", fields); + JSONArray regions = new JSONArray(); + JSONObject inland = new JSONObject(true); + inland.put("title", "国内段报价"); + // headerRow 只指到带「时效/燃油」的第 6 行;币种在下一行,侧栏扫描必须仍能找到 + inland.put("headerRow", 6); + inland.put("dataStartRow", 9); + JSONObject cols1 = new JSONObject(true); + cols1.put("origin", "B"); + cols1.put("destination", "C"); + inland.put("columns", cols1); + regions.add(inland); + JSONObject overseas = new JSONObject(true); + overseas.put("title", "境外报价"); + overseas.put("headerRow", 25); + overseas.put("dataStartRow", 28); + JSONObject cols2 = new JSONObject(true); + cols2.put("origin", "B"); + cols2.put("destination", "C"); + overseas.put("columns", cols2); + regions.add(overseas); + mapping.put("routeRegions", regions); + // fee_rows 在 routeRegions 之后写:误指运价行时不能把「运输费」盖掉起始地 + JSONObject feeRows = new JSONObject(true); + feeRows.put("anchor", "跨境整车报价模板!B9"); + JSONObject feeCols = new JSONObject(true); + feeCols.put("name", "B"); + feeCols.put("amount", "D"); + feeRows.put("columns", feeCols); + mapping.put("fee_rows", feeRows); + JSONObject facts = new JSONObject(true); + facts.put("origin", "深圳"); + facts.put("destination", "春疆货场"); + facts.put("currency", "CNY"); + facts.put("transit_days", "N+1"); + facts.put("fuel_surcharge_rate", "8%"); + facts.put("tax_rate", "0%"); + facts.put("route_rows", QuoteTemplateExcelSupport.parseFeeRows( + "[{\"origin\":\"深圳\",\"destination\":\"春疆货场\",\"currency\":\"CNY\"," + + "\"transit_time\":\"N+1\",\"fuel_surcharge_rate\":\"8%\",\"tax_rate\":\"0%\"," + + "\"amount\":\"1200\"}]")); + facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows( + "[{\"name\":\"运输费\",\"amount\":\"1200\",\"currency\":\"CNY\"}]")); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = filled.getSheetAt(0); + assertEquals("起始地", read(sheet, 6, 1)); + // 国内段只写境内起点;春疆货场是境外终点,不能被「运输费」盖掉起点 + assertEquals("深圳", read(sheet, 8, 1)); + assertEquals("", read(sheet, 8, 2)); + assertEquals("N+1", read(sheet, 8, 13)); + assertEquals("CNY", read(sheet, 8, 14)); + assertEquals("8%", read(sheet, 8, 15)); + assertEquals("0%", read(sheet, 8, 16)); + assertFalse("CNY".equalsIgnoreCase(read(sheet, 8, 15))); + assertFalse("运输费".equals(read(sheet, 8, 1))); + assertEquals("币种", read(sheet, 6, 14)); + assertEquals("燃油附加费率", read(sheet, 5, 15)); + // 境外段只写境外终点 + assertEquals("", read(sheet, 27, 1)); + assertEquals("春疆货场", read(sheet, 27, 2)); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + /** * 国内整车:4.2米/3T 这类车型费用也是标签右侧,不是海运费目表。 */ diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java index 730d308..ce46887 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java @@ -220,6 +220,39 @@ public class TmsQuoteCurrencyTest { assertEquals("", InquiryTmsQueryService.formatLandPercentRate(null)); } + /** + * 腿上燃油为空时,从同段 feeItems.fuelSurchargeRate 补到 route_rows,供出单侧栏写入。 + */ + @Test + public void toAgentQuoteFillsLandFuelFromFeeItemsWhenSegmentMissing() { + Map seg = new HashMap<>(); + seg.put("segmentSequence", 1); + seg.put("routeLegSequence", 1); + seg.put("origin", "深圳"); + seg.put("destination", "春疆货场"); + seg.put("costPrice", "1200"); + seg.put("currency", "CNY"); + Map fee = new HashMap<>(); + fee.put("segmentSequence", 1); + fee.put("routeLegSequence", 1); + fee.put("feeName", "运输费"); + fee.put("amount", 1200); + fee.put("currency", "CNY"); + fee.put("fuelSurchargeRate", 8); + fee.put("rate", 0); + Map data = new HashMap<>(); + data.put("quoteFound", true); + data.put("currency", "CNY"); + data.put("landSegments", Collections.singletonList(seg)); + data.put("feeItems", Collections.singletonList(fee)); + Map tms = new HashMap<>(); + tms.put("code", 1); + tms.put("tmsData", data); + Map quote = InquiryTmsQueryService.toAgentQuote(tms); + assertTrue(quote.get("route_rows").contains("\"fuel_surcharge_rate\":\"8%\"")); + assertTrue(quote.get("route_rows").contains("\"tax_rate\":\"0%\"")); + } + @Test public void toAgentQuoteKeepsSeaBucketTotals() { Map pol = new HashMap<>(); diff --git a/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysPermissionServiceImpl.java b/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysPermissionServiceImpl.java index fe46766..bd419a9 100644 --- a/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysPermissionServiceImpl.java +++ b/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysPermissionServiceImpl.java @@ -65,14 +65,14 @@ public class SysPermissionServiceImpl extends ServiceImpl */ + /** + * 询价新库永久拒绝切表:即便存在 sys_permission_v3 也不自动 rename, + * 避免非管理员误点前端「Vue2 菜单」弹窗后弄挂 inquiry_robot 侧栏。 + * 真要升级菜单表须走人工迁移脚本,禁止在线一键切换。 + */ @Override public void switchVue3Menu() { - Long vue3TableCnt = sysPermissionMapper.countVue3MenuTable(); - if (vue3TableCnt == null || vue3TableCnt.longValue() <= 0L) { - throw new JeecgBootException("当前库没有 sys_permission_v3,菜单已是 Vue3 或不可自动切换,已拒绝执行"); - } - sysPermissionMapper.backupVue2Menu(); - sysPermissionMapper.changeVue3Menu(); + throw new JeecgBootException("已禁用在线切换 Vue3 菜单表;inquiry_robot 菜单按迁移脚本维护,禁止 rename sys_permission"); } @Override diff --git a/inquiry-backend/src/api/sys/menu.ts b/inquiry-backend/src/api/sys/menu.ts index ea873df..1753388 100644 --- a/inquiry-backend/src/api/sys/menu.ts +++ b/inquiry-backend/src/api/sys/menu.ts @@ -24,10 +24,12 @@ export const getMenuList = () => { }; /** - * 切换成vue3菜单 + * 切换成 vue3 菜单 —— 询价库永久禁用。 + *

+ * Jeecg 原逻辑会把 sys_permission rename 成 v2 再切 v3 表;测服 inquiry_robot + * 菜单已是 Vue3 且常无 sys_permission_v3,执行会弄挂侧栏。前端不再发切表请求。 */ export const switchVue3Menu = () => { - return new Promise((resolve) => { - defHttp.get({ url: Api.SwitchVue3Menu }); - }); + console.warn('[ytd] switchVue3Menu disabled: inquiry_robot menus are already Vue3'); + return Promise.resolve({ success: false, message: 'switchVue3Menu disabled' }); }; diff --git a/inquiry-backend/src/layouts/inquiry/index.vue b/inquiry-backend/src/layouts/inquiry/index.vue index c3d3c25..ff9f929 100644 --- a/inquiry-backend/src/layouts/inquiry/index.vue +++ b/inquiry-backend/src/layouts/inquiry/index.vue @@ -8,8 +8,10 @@ import { useRoute, useRouter, RouterView } from 'vue-router'; import { message, ConfigProvider } from 'ant-design-vue'; import { useUserStore } from '/@/store/modules/user'; + import { usePermission } from '/@/hooks/web/usePermission'; import ProtoIcon from '/@/views/inquiry/components/ProtoIcon.vue'; import AdminModal from '/@/views/inquiry/components/AdminModal.vue'; + import { canSeeInquiryPage } from '/@/views/inquiry/shared/inquiryPagePerms'; import '/@/views/inquiry/styles/admin-prototype.css'; import '/@/views/inquiry/styles/admin-fidelity.css'; import '/@/views/inquiry/styles/dashboard-prototype.css'; @@ -74,12 +76,26 @@ components: { RouterView, ProtoIcon, AdminModal, ConfigProvider }, setup() { const userStore = useUserStore(); + const { hasPermission } = usePermission(); const route = useRoute(); const router = useRouter(); const userMenuOpen = ref(false); const profileOpen = ref(false); const passwordOpen = ref(false); + /** + * 侧栏按查看权限码过滤;无权限的分组整组隐藏。 + * 不再渲染写死全量 navGroups,避免「没权限仍看见菜单」。 + */ + const visibleNavGroups = computed(() => + navGroups + .map((g) => ({ + ...g, + items: g.items.filter((item) => canSeeInquiryPage(item.key, hasPermission)), + })) + .filter((g) => g.items.length > 0), + ); + const pageTitle = computed(() => String(route.meta?.title || '友通达智能体运营后台')); const crumb = computed(() => (route.meta?.crumb ? String(route.meta.crumb) : '')); @@ -167,11 +183,15 @@ if (!t.closest('.admin-user-menu')) closeUserMenu(); } - onMounted(() => document.addEventListener('click', onDocClick)); - onUnmounted(() => document.removeEventListener('click', onDocClick)); + onMounted(() => { + document.addEventListener('click', onDocClick); + }); + onUnmounted(() => { + document.removeEventListener('click', onDocClick); + }); return { - navGroups, + navGroups: visibleNavGroups, themeToken, route, pageTitle, diff --git a/inquiry-backend/src/store/modules/permission.ts b/inquiry-backend/src/store/modules/permission.ts index a8655cc..6f8fee4 100644 --- a/inquiry-backend/src/store/modules/permission.ts +++ b/inquiry-backend/src/store/modules/permission.ts @@ -21,11 +21,12 @@ import { getMenuList } from '/@/api/sys/menu'; import { getPermCode } from '/@/api/sys/user'; import { PageEnum } from '/@/enums/pageEnum'; +import { INQUIRY_PAGE_VIEW_PERMS } from '/@/views/inquiry/shared/inquiryPagePerms'; /** - * 询价原型侧栏路径是写死的;Jeecg BACK 模式只注册后台菜单里的路由。 - * 菜单种子缺失/组件路径不对时,点侧栏会进 404(本机与线上相同)。 - * 这里按侧栏清单补挂手写页,不依赖种子是否已导入。 + * 询价手写页:纠正菜单种子里的组件路径/标题。 + * 禁止把未授权页强行补进路由——否则 backMenuList 会膨胀成全量侧栏,无权限也看得见。 + * 仅当「后台菜单已有该路径」或「用户已有对应查看权限码」时才补挂缺失叶子。 */ type InquiryLeaf = { path: string; @@ -78,16 +79,65 @@ function findParent(list: AppRouteRecordRaw[] = [], parentPath: string): AppRout return null; } -function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[]) { - const groups: Record = { +/** + * 永久禁止 Jeecg「Vue2 菜单表」切表弹窗。 + *

+ * 检测条件:顶层既没有 component===layouts/default/index,图标也不含冒号。 + * 非管理员若只分到缺标记的询价菜单,会被误判;点确认会 rename sys_permission,测服无 v3 表会弄挂侧栏。 + * 本函数恒为 false,禁止再恢复弹窗逻辑。 + */ +function shouldPromptVue2MenuSwitch(_routeList: AppRouteRecordRaw[]): boolean { + return false; +} + +/** + * 给菜单补齐 Vue3 标记,避免未来误恢复 Jeecg 检测时再次弹窗。 + * 副作用:就地改 component / meta.icon;不改业务权限。 + */ +function normalizeVue3MenuMarkers(routeList: AppRouteRecordRaw[]): AppRouteRecordRaw[] { + for (const item of routeList || []) { + const comp = String((item as any).component || ''); + // Jeecg 检测认 layouts/default/index;LAYOUT 也会被 transform 成询价壳 + if (!comp || comp.toUpperCase() === 'LAYOUT') { + (item as any).component = 'layouts/default/index'; + } + item.meta = item.meta || ({} as any); + const icon = String((item.meta as any).icon || ''); + if (!icon) { + (item.meta as any).icon = 'ant-design:menu-outlined'; + } else if (!icon.includes(':')) { + (item.meta as any).icon = `ant-design:${icon}`; + } + } + return routeList; +} + +function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[], permCodes: Array = []) { + const codeSet = new Set((permCodes || []).map(String)); + const canAddLeaf = (leaf: InquiryLeaf) => { + // 后台菜单已下发该路径:只纠正组件,不算越权补挂 + if (findRouteByPath(routeList, leaf.path)) { + return true; + } + const need = INQUIRY_PAGE_VIEW_PERMS[leaf.path]; + // 无权限码表时不补挂(宁可少菜单,也不把未授权页塞进侧栏) + if (!need) { + return false; + } + return codeSet.has(need); + }; + + const groups: Record = { '/inquiry': { title: '询价', redirect: '/inquiry/dashboard', + icon: 'ant-design:robot-outlined', leaves: INQUIRY_LEAVES.filter((l) => l.path.startsWith('/inquiry/') && !l.path.startsWith('/inquiry-sys')), }, '/inquiry-sys': { title: '询价系统配置', redirect: '/inquiry-sys/system-setting', + icon: 'ant-design:setting-outlined', leaves: INQUIRY_LEAVES.filter((l) => l.path.startsWith('/inquiry-sys/')), }, }; @@ -105,6 +155,10 @@ function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[]) { existing.meta = { ...(existing.meta || {}), title: leaf.title, ignoreKeepAlive: true }; continue; } + // 角色未授权且无对应查看权限:禁止补挂(根因:曾把 8 个询价页全塞进 backMenuList) + if (!canAddLeaf(leaf)) { + continue; + } const node: AppRouteRecordRaw = { path: leaf.path, name: leaf.name, @@ -119,12 +173,23 @@ function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[]) { parent = { path: parentPath, name: parentPath.replace(/\//g, '-').replace(/^-/, '') || 'inquiry-root', - component: 'LAYOUT' as any, + // 必须用 layouts/default/index + 带冒号图标,满足 Jeecg Vue3 菜单检测(实际仍映射到询价壳) + component: 'layouts/default/index' as any, redirect: group.redirect, - meta: { title: group.title }, + meta: { title: group.title, icon: group.icon }, children: [], }; routeList.push(parent); + } else { + // 已有父级也补标记,防止非管理员菜单缺图标被误判 Vue2 + const pComp = String((parent as any).component || ''); + if (!pComp || pComp.toUpperCase() === 'LAYOUT') { + (parent as any).component = 'layouts/default/index'; + } + parent.meta = { + ...(parent.meta || {}), + icon: (parent.meta as any)?.icon || group.icon, + }; } parent.children = parent.children || []; if (leaf.path === group.redirect) { @@ -137,6 +202,49 @@ function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[]) { return routeList; } +/** + * 去掉无查看权限的询价叶子(角色树勾了菜单但没勾按钮、或历史脏数据)。 + * 父节点若子级删空则一并去掉,避免侧栏空分组。 + */ +function pruneInquiryLeavesWithoutViewPerm( + routeList: AppRouteRecordRaw[], + permCodes: Array = [], +): AppRouteRecordRaw[] { + const codeSet = new Set((permCodes || []).map(String)); + // 权限码尚未就绪时禁止裁剪:否则会把 getMenuList 结果清空 → 重登 404 + if (codeSet.size === 0) { + return routeList; + } + const norm = (p: string) => { + const s = String(p || ''); + return s.startsWith('/') ? s : `/${s}`; + }; + const walk = (list: AppRouteRecordRaw[] = []): AppRouteRecordRaw[] => { + const out: AppRouteRecordRaw[] = []; + for (const item of list) { + const path = norm(item.path || ''); + const need = INQUIRY_PAGE_VIEW_PERMS[path]; + if (need && !codeSet.has(need)) { + continue; + } + const next = { ...item }; + if (next.children?.length) { + next.children = walk(next.children as AppRouteRecordRaw[]); + // 询价分组下无子页则不保留空壳 + if ( + (path === '/inquiry' || path === '/inquiry-sys') && + (!next.children || next.children.length === 0) + ) { + continue; + } + } + out.push(next); + } + return out; + }; + return walk(routeList); +} + // 系统权限 interface AuthItem { @@ -323,21 +431,27 @@ export const usePermissionStore = defineStore({ // 这个函数可能只需要执行一次,并且实际的项目可以在正确的时间被放置 let routeList: AppRouteRecordRaw[] = []; try { - this.changePermissionCode(); + // 必须 await:logout 会清空 permCodeList;若此处不候等,后面 prune 会在空权限码下 + // 把询价叶子全删掉 → 侧栏仍能靠稍后到的权限码显示,点进去却是 404。 + await this.changePermissionCode(); routeList = (await getMenuList()) as AppRouteRecordRaw[]; - // Jeecg 原逻辑:顶层没有 layouts/default/index 且图标无冒号 → 弹窗并调用 /sys/switchVue3Menu - // (把 sys_permission rename 成 v2,再把 sys_permission_v3 rename 成正式表)。 - // 询价库 inquiry_robot 菜单种子已是 Vue3,且测服没有 sys_permission_v3;点确认会弄挂侧栏。 - // 因此禁用自动切表弹窗;后面仍会 ensureInquiryHandwrittenRoutes 补齐询价页。 - // update-begin----author:ytd---date:20261007------for: 禁用危险的 Vue2→Vue3 菜单切表提示 --- - // update-end----author:ytd---date:20261007------for: 禁用危险的 Vue2→Vue3 菜单切表提示 --- + // 永久禁止 Vue2→Vue3 切表弹窗(shouldPromptVue2MenuSwitch 恒 false) + if (shouldPromptVue2MenuSwitch(routeList)) { + // 不可达;保留分支防止以后误合并 Jeecg 原弹窗代码 + } + // 非管理员菜单常缺 Vue3 标记 → 误弹「菜单表是 Vue2」;先规范化再往下走 + routeList = normalizeVue3MenuMarkers(routeList || []); } catch (error) { console.error(error); // 菜单接口失败时仍补挂询价手写页,避免侧栏点进去全是 404 routeList = Array.isArray(routeList) ? routeList : []; } - // 无论菜单接口成功与否,都按侧栏清单补齐手写路由 - routeList = ensureInquiryHandwrittenRoutes(routeList || []); + // 只纠正/补挂「已有菜单或已有查看权限」的手写页,禁止灌入未授权路由 + const permCodes = this.getPermCodeList || []; + routeList = ensureInquiryHandwrittenRoutes(routeList || [], permCodes); + routeList = pruneInquiryLeavesWithoutViewPerm(routeList, permCodes); + // 补挂后再标一次(ensure 新建的父级也要带 Vue3 标记) + routeList = normalizeVue3MenuMarkers(routeList); // 组件地址前加斜杠处理 author: lsq date:2021-09-08 routeList = addSlashToRouteComponent(routeList); // 动态引入组件 diff --git a/inquiry-backend/src/views/inquiry/account/index.vue b/inquiry-backend/src/views/inquiry/account/index.vue index daf0fb1..42b5ae1 100644 --- a/inquiry-backend/src/views/inquiry/account/index.vue +++ b/inquiry-backend/src/views/inquiry/account/index.vue @@ -19,6 +19,12 @@ import { import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue"; import AdminModal from "/@/views/inquiry/components/AdminModal.vue"; import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue"; +import { usePermission } from "/@/hooks/web/usePermission"; +import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms"; + +const { hasPermission } = usePermission(); +/** 账号新建/编辑/删除/重置密码均属 edit 权限 */ +const canEditAccount = () => hasPermission(InquiryBtnPerm.accountEdit); const rows = ref([]); const loading = ref(false); @@ -299,7 +305,7 @@ function removeAccount(row: AccountRow) { - @@ -369,7 +375,7 @@ function removeAccount(row: AccountRow) {