后台按查看权限收紧菜单与「只看自己」数据范围;跨境整车报价填表更稳,协商中顺延不再误报销售超时。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -123,6 +123,35 @@ def notify_app_flag(override: Optional[bool]) -> bool:
|
|||||||
return notify_app_ready()
|
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(
|
def _handle_one(
|
||||||
row: dict[str, Any],
|
row: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
@@ -139,6 +168,14 @@ def _handle_one(
|
|||||||
kind = str(row.get("event_wait_kind") or row.get("eventWaitKind") or "").strip()
|
kind = str(row.get("event_wait_kind") or row.get("eventWaitKind") or "").strip()
|
||||||
if not no or not kind:
|
if not no or not kind:
|
||||||
return False
|
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()
|
line = str(row.get("business_line") or row.get("businessLine") or "").strip()
|
||||||
facts = dict(row.get("facts") or {})
|
facts = dict(row.get("facts") or {})
|
||||||
klass = str(facts.get("运输分类") or facts.get("transportType") or "").strip()
|
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"))
|
started = _parse_started(row.get("event_wait_started_at") or row.get("eventWaitStartedAt"))
|
||||||
if started is None:
|
if started is None:
|
||||||
return False
|
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:
|
if workday_elapsed_hours(started, now) < hours:
|
||||||
return False
|
return False
|
||||||
watchers = [dict(x) for x in (row.get("watchers") or []) if isinstance(x, dict)]
|
watchers = [dict(x) for x in (row.get("watchers") or []) if isinstance(x, dict)]
|
||||||
|
|||||||
@@ -344,12 +344,23 @@ class MemoryLedger:
|
|||||||
ticket.next_followup_at = ""
|
ticket.next_followup_at = ""
|
||||||
if lost_reason:
|
if lost_reason:
|
||||||
ticket.lost_reason = 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 == "已成交":
|
elif to_status == "已成交":
|
||||||
ticket.next_followup_at = ""
|
ticket.next_followup_at = ""
|
||||||
if deal_notify_summary:
|
if deal_notify_summary:
|
||||||
ticket.deal_notify_summary = 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 == "协商中":
|
elif to_status == "协商中":
|
||||||
ticket.next_followup_at = next_followup_at
|
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}
|
return {"ok": True, "status": ticket.status}
|
||||||
|
|
||||||
def patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]:
|
def patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]:
|
||||||
@@ -363,6 +374,9 @@ class MemoryLedger:
|
|||||||
if not reason:
|
if not reason:
|
||||||
return {"ok": False, "error": "reason_blank"}
|
return {"ok": False, "error": "reason_blank"}
|
||||||
ticket.lost_reason = reason
|
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}
|
return {"ok": True, "work_order_no": work_order_no, "lost_reason": reason}
|
||||||
|
|
||||||
def claim_due_negotiate(self) -> list[dict[str, Any]]:
|
def claim_due_negotiate(self) -> list[dict[str, Any]]:
|
||||||
@@ -940,6 +954,11 @@ class MemoryLedger:
|
|||||||
continue
|
continue
|
||||||
if ticket.status in {"已成交", "已关闭", "转人工"}:
|
if ticket.status in {"已成交", "已关闭", "转人工"}:
|
||||||
continue
|
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]] = []
|
watchers: list[dict[str, Any]] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
sales_id = (ticket.sales_wecom_id or "").strip()
|
sales_id = (ticket.sales_wecom_id or "").strip()
|
||||||
@@ -989,6 +1008,10 @@ class MemoryLedger:
|
|||||||
"collab_member_ids": list(ticket.collab_member_ids),
|
"collab_member_ids": list(ticket.collab_member_ids),
|
||||||
"facts": dict(ticket.facts),
|
"facts": dict(ticket.facts),
|
||||||
"eventException": ticket.event_exception,
|
"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,
|
"watchers": watchers,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -222,6 +222,9 @@ class TestPoll(unittest.TestCase):
|
|||||||
from agent.ledger.memory_ledger import MemoryLedger
|
from agent.ledger.memory_ledger import MemoryLedger
|
||||||
from agent.policy.deal_followup import SHANGHAI
|
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")
|
path = os.path.join(ROOT, "agent", "jobs", "event_exception_poll.py")
|
||||||
spec = importlib.util.spec_from_file_location("ee_poll", path)
|
spec = importlib.util.spec_from_file_location("ee_poll", path)
|
||||||
self.assertIsNotNone(spec)
|
self.assertIsNotNone(spec)
|
||||||
@@ -248,9 +251,7 @@ class TestPoll(unittest.TestCase):
|
|||||||
t = book.get_ticket(work_order_no=wo)
|
t = book.get_ticket(work_order_no=wo)
|
||||||
t.collab_chat_id = "wr1"
|
t.collab_chat_id = "wr1"
|
||||||
t.collab_member_ids = ["p1"]
|
t.collab_member_ids = ["p1"]
|
||||||
t.event_wait_started_at = (datetime.now(SHANGHAI) - timedelta(hours=3)).strftime(
|
t.event_wait_started_at = (now - timedelta(hours=3)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
"%Y-%m-%d %H:%M:%S"
|
|
||||||
)
|
|
||||||
book._timeouts["sea_product_other"] = 0.25
|
book._timeouts["sea_product_other"] = 0.25
|
||||||
groups: list[dict] = []
|
groups: list[dict] = []
|
||||||
|
|
||||||
@@ -258,7 +259,6 @@ class TestPoll(unittest.TestCase):
|
|||||||
groups.append({"chat_id": chat_id, "content": content, "line": business_line})
|
groups.append({"chat_id": chat_id, "content": content, "line": business_line})
|
||||||
return True
|
return True
|
||||||
|
|
||||||
now = datetime.now(SHANGHAI)
|
|
||||||
n = poll_event_exceptions(
|
n = poll_event_exceptions(
|
||||||
ledger=book, now=now, send_group=send_group, notify_ready=False
|
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={"起运港": "南京"})
|
created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={"起运港": "南京"})
|
||||||
wo = created["work_order_no"]
|
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")
|
book.start_event_wait(work_order_no=wo, wait_kind="air_quote")
|
||||||
ticket = book.get_ticket(work_order_no=wo)
|
ticket = book.get_ticket(work_order_no=wo)
|
||||||
ticket.collab_chat_id = "wr-air"
|
ticket.collab_chat_id = "wr-air"
|
||||||
ticket.collab_member_ids = []
|
ticket.collab_member_ids = []
|
||||||
ticket.event_wait_started_at = (datetime.now(SHANGHAI) - timedelta(hours=3)).strftime(
|
ticket.event_wait_started_at = (now - timedelta(hours=3)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
"%Y-%m-%d %H:%M:%S"
|
|
||||||
)
|
|
||||||
book._timeouts["air_route"] = 0.25
|
book._timeouts["air_route"] = 0.25
|
||||||
groups: list[dict] = []
|
groups: list[dict] = []
|
||||||
|
|
||||||
@@ -321,7 +320,7 @@ class TestPoll(unittest.TestCase):
|
|||||||
|
|
||||||
n = mod.poll_event_exceptions(
|
n = mod.poll_event_exceptions(
|
||||||
ledger=book,
|
ledger=book,
|
||||||
now=datetime.now(SHANGHAI),
|
now=now,
|
||||||
send_group=send_group,
|
send_group=send_group,
|
||||||
notify_ready=False,
|
notify_ready=False,
|
||||||
)
|
)
|
||||||
@@ -355,13 +354,12 @@ class TestPoll(unittest.TestCase):
|
|||||||
{"wecomId": "air1", "name": "吴季林", "roleCode": "air", "status": "active"},
|
{"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={})
|
created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={})
|
||||||
wo = created["work_order_no"]
|
wo = created["work_order_no"]
|
||||||
book.start_event_wait(work_order_no=wo, wait_kind="adopt")
|
book.start_event_wait(work_order_no=wo, wait_kind="adopt")
|
||||||
ticket = book.get_ticket(work_order_no=wo)
|
ticket = book.get_ticket(work_order_no=wo)
|
||||||
ticket.event_wait_started_at = (datetime.now(SHANGHAI) - timedelta(hours=3)).strftime(
|
ticket.event_wait_started_at = (now - timedelta(hours=3)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
"%Y-%m-%d %H:%M:%S"
|
|
||||||
)
|
|
||||||
book._timeouts["air_sales"] = 0.25
|
book._timeouts["air_sales"] = 0.25
|
||||||
private: list[dict] = []
|
private: list[dict] = []
|
||||||
|
|
||||||
@@ -374,7 +372,7 @@ class TestPoll(unittest.TestCase):
|
|||||||
|
|
||||||
n = mod.poll_event_exceptions(
|
n = mod.poll_event_exceptions(
|
||||||
ledger=book,
|
ledger=book,
|
||||||
now=datetime.now(SHANGHAI),
|
now=now,
|
||||||
enqueue=enqueue,
|
enqueue=enqueue,
|
||||||
send_group=send_group,
|
send_group=send_group,
|
||||||
notify_ready=False,
|
notify_ready=False,
|
||||||
@@ -402,7 +400,7 @@ class TestPoll(unittest.TestCase):
|
|||||||
|
|
||||||
n_deal = mod.poll_event_exceptions(
|
n_deal = mod.poll_event_exceptions(
|
||||||
ledger=book,
|
ledger=book,
|
||||||
now=datetime.now(SHANGHAI),
|
now=now,
|
||||||
enqueue=enqueue,
|
enqueue=enqueue,
|
||||||
send_group=send_deal_group,
|
send_group=send_deal_group,
|
||||||
notify_ready=False,
|
notify_ready=False,
|
||||||
@@ -429,5 +427,193 @@ class TestMentionsForRole(unittest.TestCase):
|
|||||||
self.assertEqual([row["name"] for row in rows], ["吴季林"])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+3
-87
@@ -2,8 +2,6 @@ package org.jeecg.modules.inquiry.service.impl;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
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.domain.TicketStatuses;
|
||||||
import org.jeecg.modules.inquiry.entity.InquiryQuote;
|
import org.jeecg.modules.inquiry.entity.InquiryQuote;
|
||||||
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
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.InquiryTicketMapper;
|
||||||
import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper;
|
import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper;
|
||||||
import org.jeecg.modules.inquiry.service.IInquiryDashboardService;
|
import org.jeecg.modules.inquiry.service.IInquiryDashboardService;
|
||||||
|
import org.jeecg.modules.inquiry.support.InquiryDataScopeSupport;
|
||||||
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
|
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -95,7 +94,7 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
|||||||
LambdaQueryWrapper<InquiryTicket> qw = new LambdaQueryWrapper<InquiryTicket>()
|
LambdaQueryWrapper<InquiryTicket> qw = new LambdaQueryWrapper<InquiryTicket>()
|
||||||
.ge(InquiryTicket::getCreateTime, from)
|
.ge(InquiryTicket::getCreateTime, from)
|
||||||
.le(InquiryTicket::getCreateTime, to);
|
.le(InquiryTicket::getCreateTime, to);
|
||||||
applyDashboardSelfOnly(qw);
|
InquiryDataScopeSupport.applySelfOnly(qw);
|
||||||
List<InquiryTicket> tickets = ticketMapper.selectList(qw);
|
List<InquiryTicket> tickets = ticketMapper.selectList(qw);
|
||||||
|
|
||||||
long total = tickets.size();
|
long total = tickets.size();
|
||||||
@@ -133,7 +132,7 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
|||||||
LambdaQueryWrapper<InquiryTicket> weekQw = new LambdaQueryWrapper<InquiryTicket>()
|
LambdaQueryWrapper<InquiryTicket> weekQw = new LambdaQueryWrapper<InquiryTicket>()
|
||||||
.ge(InquiryTicket::getCreateTime, weekFrom)
|
.ge(InquiryTicket::getCreateTime, weekFrom)
|
||||||
.le(InquiryTicket::getCreateTime, to);
|
.le(InquiryTicket::getCreateTime, to);
|
||||||
applyDashboardSelfOnly(weekQw);
|
InquiryDataScopeSupport.applySelfOnly(weekQw);
|
||||||
List<InquiryTicket> weekTickets = ticketMapper.selectList(weekQw);
|
List<InquiryTicket> weekTickets = ticketMapper.selectList(weekQw);
|
||||||
|
|
||||||
List<String> trendDates = new ArrayList<>(7);
|
List<String> trendDates = new ArrayList<>(7);
|
||||||
@@ -667,87 +666,4 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
|||||||
return "sales";
|
return "sales";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 账号勾选「只看自己数据」时收紧看板工单范围。
|
|
||||||
* <p>
|
|
||||||
* 口径对齐原项目:非超管且 dashboardSelfOnly=true →
|
|
||||||
* 工单 salesWecomId = 当前账号工号(企微 id)或 createBy = 登录名。
|
|
||||||
* 超管(username=admin 或角色 admin)忽略开关。
|
|
||||||
*/
|
|
||||||
private void applyDashboardSelfOnly(LambdaQueryWrapper<InquiryTicket> 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 "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-5
@@ -1148,16 +1148,39 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
|||||||
if (!landSegs.isEmpty()) {
|
if (!landSegs.isEmpty()) {
|
||||||
out.put("landSegments", landSegs);
|
out.put("landSegments", landSegs);
|
||||||
out.put("route_rows", landSegs);
|
out.put("route_rows", landSegs);
|
||||||
// 首段时效若 facts 尚无,用腿上 transit
|
// 首段时效/燃油/税率抬到顶层,供侧栏与标签回填(WO006 燃油列不能空着有数却写不上)
|
||||||
if (StringUtils.isBlank(out.getString("transit_time")) || "-".equals(out.getString("transit_time"))) {
|
for (int i = 0; i < landSegs.size(); i++) {
|
||||||
JSONObject firstSeg = landSegs.getJSONObject(0);
|
JSONObject seg = landSegs.getJSONObject(i);
|
||||||
if (firstSeg != null) {
|
if (seg == null) {
|
||||||
String t = firstNonBlankLocal(firstSeg.getString("transit_time"), firstSeg.getString("transitDays"));
|
continue;
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(out.getString("transit_time")) || "-".equals(out.getString("transit_time"))) {
|
||||||
|
String t = firstNonBlankLocal(seg.getString("transit_time"), seg.getString("transitDays"));
|
||||||
if (StringUtils.isNotBlank(t)) {
|
if (StringUtils.isNotBlank(t)) {
|
||||||
out.put("transit_time", t);
|
out.put("transit_time", t);
|
||||||
out.put("transit_days", t);
|
out.put("transit_days", t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (StringUtils.isBlank(out.getString("fuel_surcharge_rate"))) {
|
||||||
|
String fuel = firstNonBlankLocal(seg.getString("fuel_surcharge_rate"),
|
||||||
|
seg.getString("fuelSurchargeRate"));
|
||||||
|
if (StringUtils.isNotBlank(fuel)) {
|
||||||
|
out.put("fuel_surcharge_rate", fuel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(out.getString("tax_rate"))) {
|
||||||
|
String tax = firstNonBlankLocal(seg.getString("tax_rate"), seg.getString("taxRate"),
|
||||||
|
seg.getString("rate"));
|
||||||
|
if (StringUtils.isNotBlank(tax)) {
|
||||||
|
out.put("tax_rate", tax);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(out.getString("currency"))) {
|
||||||
|
String ccy = StringUtils.trimToEmpty(seg.getString("currency"));
|
||||||
|
if (StringUtils.isNotBlank(ccy)) {
|
||||||
|
out.put("currency", ccy);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (looksLandInquiry(src)) {
|
} else if (looksLandInquiry(src)) {
|
||||||
// 无 landSegments 时:用询价 OD + fee_lines 分项拼一行,尽量填「提货费/运输费/送货费」列
|
// 无 landSegments 时:用询价 OD + fee_lines 分项拼一行,尽量填「提货费/运输费/送货费」列
|
||||||
|
|||||||
+55
-5
@@ -26,6 +26,7 @@ import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
|
|||||||
import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper;
|
import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper;
|
||||||
import org.jeecg.modules.inquiry.support.AgentSystemExceptionRetryClient;
|
import org.jeecg.modules.inquiry.support.AgentSystemExceptionRetryClient;
|
||||||
import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService;
|
import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService;
|
||||||
|
import org.jeecg.modules.inquiry.support.InquiryDataScopeSupport;
|
||||||
import org.jeecg.modules.inquiry.support.InquiryQuoteDisplaySupport;
|
import org.jeecg.modules.inquiry.support.InquiryQuoteDisplaySupport;
|
||||||
import org.jeecg.modules.inquiry.support.InquiryQuoteFileSupport;
|
import org.jeecg.modules.inquiry.support.InquiryQuoteFileSupport;
|
||||||
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
|
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
|
||||||
@@ -122,6 +123,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
|||||||
if (StringUtils.isNotBlank(dateTo)) {
|
if (StringUtils.isNotBlank(dateTo)) {
|
||||||
qw.le(InquiryTicket::getCreateTime, dateTo + " 23:59:59");
|
qw.le(InquiryTicket::getCreateTime, dateTo + " 23:59:59");
|
||||||
}
|
}
|
||||||
|
// 与看板同源:账号勾「只看自己」时收紧列表(非超管)
|
||||||
|
InquiryDataScopeSupport.applySelfOnly(qw);
|
||||||
qw.orderByDesc(InquiryTicket::getCreateTime);
|
qw.orderByDesc(InquiryTicket::getCreateTime);
|
||||||
IPage<InquiryTicket> result = page(page, qw);
|
IPage<InquiryTicket> result = page(page, qw);
|
||||||
enrichDisplayFields(result.getRecords());
|
enrichDisplayFields(result.getRecords());
|
||||||
@@ -283,24 +286,36 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
|||||||
if (TicketStatuses.CLOSED.equals(to) && StringUtils.isNotBlank(req.getRemark())) {
|
if (TicketStatuses.CLOSED.equals(to) && StringUtils.isNotBlank(req.getRemark())) {
|
||||||
ticket.setCloseReason(req.getRemark());
|
ticket.setCloseReason(req.getRemark());
|
||||||
}
|
}
|
||||||
|
boolean clearEventWait = false;
|
||||||
if (TicketStatuses.LOST.equals(to)) {
|
if (TicketStatuses.LOST.equals(to)) {
|
||||||
ticket.setNextFollowupAt(null);
|
ticket.setNextFollowupAt(null);
|
||||||
if (StringUtils.isNotBlank(req.getLostReason())) {
|
if (StringUtils.isNotBlank(req.getLostReason())) {
|
||||||
ticket.setLostReason(StringUtils.left(req.getLostReason().trim(), 500));
|
ticket.setLostReason(StringUtils.left(req.getLostReason().trim(), 500));
|
||||||
}
|
}
|
||||||
|
// 成交等待结束;缺原因时由智能体再 start lost_reason,避免陈旧 deal 继续催
|
||||||
|
clearEventWait = true;
|
||||||
} else if (TicketStatuses.DEAL.equals(to)) {
|
} else if (TicketStatuses.DEAL.equals(to)) {
|
||||||
ticket.setNextFollowupAt(null);
|
ticket.setNextFollowupAt(null);
|
||||||
if (StringUtils.isNotBlank(req.getDealNotifySummary())) {
|
if (StringUtils.isNotBlank(req.getDealNotifySummary())) {
|
||||||
ticket.setDealNotifySummary(StringUtils.left(req.getDealNotifySummary().trim(), 500));
|
ticket.setDealNotifySummary(StringUtils.left(req.getDealNotifySummary().trim(), 500));
|
||||||
}
|
}
|
||||||
|
clearEventWait = true;
|
||||||
} else if (TicketStatuses.NEGOTIATING.equals(to)) {
|
} else if (TicketStatuses.NEGOTIATING.equals(to)) {
|
||||||
Date follow = parseDateTime(req.getNextFollowupAt());
|
Date follow = parseDateTime(req.getNextFollowupAt());
|
||||||
ticket.setNextFollowupAt(follow);
|
ticket.setNextFollowupAt(follow);
|
||||||
|
// 顺延再问期间不盯销售超时;到期再问会重新 start deal
|
||||||
|
clearEventWait = true;
|
||||||
|
} else if (TicketStatuses.CLOSED.equals(to)) {
|
||||||
|
clearEventWait = true;
|
||||||
}
|
}
|
||||||
ticket.setVersion(ticket.getVersion() == null ? 2 : ticket.getVersion() + 1);
|
ticket.setVersion(ticket.getVersion() == null ? 2 : ticket.getVersion() + 1);
|
||||||
ticket.setUpdateBy(operator);
|
ticket.setUpdateBy(operator);
|
||||||
ticket.setUpdateTime(new Date());
|
ticket.setUpdateTime(new Date());
|
||||||
updateById(ticket);
|
updateById(ticket);
|
||||||
|
// updateById 默认不写 null:等待三列必须在落库后再强制清空
|
||||||
|
if (clearEventWait) {
|
||||||
|
clearEventWaitColumns(ticket);
|
||||||
|
}
|
||||||
String remark = StringUtils.defaultIfBlank(req.getRemark(),
|
String remark = StringUtils.defaultIfBlank(req.getRemark(),
|
||||||
StringUtils.defaultIfBlank(req.getDealNotifySummary(), req.getLostReason()));
|
StringUtils.defaultIfBlank(req.getDealNotifySummary(), req.getLostReason()));
|
||||||
appendLog(ticket, from, to, "transition", operator, remark);
|
appendLog(ticket, from, to, "transition", operator, remark);
|
||||||
@@ -1494,6 +1509,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
|||||||
ticket.setUpdateBy(StringUtils.defaultIfBlank(operator, "agent"));
|
ticket.setUpdateBy(StringUtils.defaultIfBlank(operator, "agent"));
|
||||||
ticket.setUpdateTime(new Date());
|
ticket.setUpdateTime(new Date());
|
||||||
updateById(ticket);
|
updateById(ticket);
|
||||||
|
// 原因已入账:强制清空等待三列(updateById 写不进 null)
|
||||||
|
clearEventWaitColumns(ticket);
|
||||||
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "lost_reason", operator, reason);
|
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "lost_reason", operator, reason);
|
||||||
out.put("ok", true);
|
out.put("ok", true);
|
||||||
out.put("work_order_no", no);
|
out.put("work_order_no", no);
|
||||||
@@ -1637,11 +1654,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
|||||||
out.put("error", "not_found");
|
out.put("error", "not_found");
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
ticket.setEventWaitKind(null);
|
clearEventWaitColumns(ticket);
|
||||||
ticket.setEventWaitStartedAt(null);
|
|
||||||
ticket.setEventWaitAlerted(0);
|
|
||||||
ticket.setUpdateTime(new Date());
|
|
||||||
updateById(ticket);
|
|
||||||
out.put("ok", true);
|
out.put("ok", true);
|
||||||
out.put("work_order_no", ticket.getWorkOrderNo());
|
out.put("work_order_no", ticket.getWorkOrderNo());
|
||||||
return out;
|
return out;
|
||||||
@@ -1661,10 +1674,21 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
||||||
|
Date now = new Date();
|
||||||
for (InquiryTicket ticket : rows) {
|
for (InquiryTicket ticket : rows) {
|
||||||
if (ticket == null || !EventExceptionRule.isWatchable(ticket.getStatus())) {
|
if (ticket == null || !EventExceptionRule.isWatchable(ticket.getStatus())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// 未成交已写原因、协商中仍排着再问:不进巡检(挡住陈旧等待)
|
||||||
|
if (TicketStatuses.LOST.equals(ticket.getStatus())
|
||||||
|
&& StringUtils.isNotBlank(ticket.getLostReason())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (TicketStatuses.NEGOTIATING.equals(ticket.getStatus())
|
||||||
|
&& ticket.getNextFollowupAt() != null
|
||||||
|
&& ticket.getNextFollowupAt().after(now)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
Map<String, Object> one = new LinkedHashMap<>();
|
Map<String, Object> one = new LinkedHashMap<>();
|
||||||
one.put("workOrderNo", ticket.getWorkOrderNo());
|
one.put("workOrderNo", ticket.getWorkOrderNo());
|
||||||
one.put("work_order_no", ticket.getWorkOrderNo());
|
one.put("work_order_no", ticket.getWorkOrderNo());
|
||||||
@@ -1688,6 +1712,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
|||||||
one.put("collabMemberIds", members);
|
one.put("collabMemberIds", members);
|
||||||
one.put("collab_member_ids", members);
|
one.put("collab_member_ids", members);
|
||||||
one.put("eventException", StringUtils.defaultString(ticket.getEventException()));
|
one.put("eventException", StringUtils.defaultString(ticket.getEventException()));
|
||||||
|
one.put("lostReason", StringUtils.defaultString(ticket.getLostReason()));
|
||||||
|
one.put("lost_reason", StringUtils.defaultString(ticket.getLostReason()));
|
||||||
Map<String, String> facts = new LinkedHashMap<>();
|
Map<String, String> facts = new LinkedHashMap<>();
|
||||||
if (StringUtils.isNotBlank(ticket.getFactsJson())) {
|
if (StringUtils.isNotBlank(ticket.getFactsJson())) {
|
||||||
JSONObject parsed = JSON.parseObject(ticket.getFactsJson());
|
JSONObject parsed = JSON.parseObject(ticket.getFactsJson());
|
||||||
@@ -1710,6 +1736,30 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空事件异常当前等待三列。不改六态、不改事件异常摘要。
|
||||||
|
* <p>
|
||||||
|
* 必须用 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
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public Map<String, Object> recordEventException(String workOrderNo, String eventException) {
|
public Map<String, Object> recordEventException(String workOrderNo, String eventException) {
|
||||||
|
|||||||
+105
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台数据范围:账号「只看自己数据」时收紧工单查询。
|
||||||
|
* <p>
|
||||||
|
* 口径:非超管且 {@code dashboardSelfOnly=true} →
|
||||||
|
* {@code salesWecomId = 工号} 或 {@code createBy = 登录名}。
|
||||||
|
* 看板与工单列表共用,避免只看板生效、列表漏看全站。
|
||||||
|
* 线程:只读登录态,无全局锁。
|
||||||
|
*/
|
||||||
|
public final class InquiryDataScopeSupport {
|
||||||
|
|
||||||
|
private InquiryDataScopeSupport() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按当前登录账号把「只看自己」条件叠到工单查询上。
|
||||||
|
*
|
||||||
|
* @param qw 工单查询包装器;null 时直接返回
|
||||||
|
*/
|
||||||
|
public static void applySelfOnly(LambdaQueryWrapper<InquiryTicket> 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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+203
-339
@@ -31,92 +31,6 @@ import java.util.*;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public final class QuoteTemplateExcelSupport {
|
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<String> YELLOW_RGB = new HashSet<>(Arrays.asList(
|
private static final Set<String> YELLOW_RGB = new HashSet<>(Arrays.asList(
|
||||||
"FFFFFF00", "FFFF00", "FFFFEB9C", "FFFFC000", "FFFFFF99",
|
"FFFFFF00", "FFFF00", "FFFFEB9C", "FFFFC000", "FFFFFF99",
|
||||||
@@ -675,13 +589,7 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
}
|
}
|
||||||
JSONObject fieldMeta = mapping.getJSONObject("fieldMeta");
|
JSONObject fieldMeta = mapping.getJSONObject("fieldMeta");
|
||||||
JSONObject fields = mapping.getJSONObject("fields");
|
JSONObject fields = mapping.getJSONObject("fields");
|
||||||
// #region agent log
|
|
||||||
debugLandBannerColors(wb, "after_open", String.valueOf(sourceXlsx));
|
|
||||||
// #endregion
|
|
||||||
LandTemplateLook landLook = snapshotLandTemplateLook(wb);
|
LandTemplateLook landLook = snapshotLandTemplateLook(wb);
|
||||||
// #region agent log
|
|
||||||
debugLandBannerColors(wb, "after_snapshot", String.valueOf(sourceXlsx));
|
|
||||||
// #endregion
|
|
||||||
if (fields != null) {
|
if (fields != null) {
|
||||||
// 陆运拼车等模板常把「报价日期」与「有效期」误绑同一格(如 H5「报价日期:」)。
|
// 陆运拼车等模板常把「报价日期」与「有效期」误绑同一格(如 H5「报价日期:」)。
|
||||||
// 同格时只写出报价日期,避免 TMS valid_until 盖掉日期标签含义。
|
// 同格时只写出报价日期,避免 TMS valid_until 盖掉日期标签含义。
|
||||||
@@ -722,14 +630,6 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
if (afterColon) {
|
if (afterColon) {
|
||||||
writeCellAfterColon(wb, cellRef, String.valueOf(val));
|
writeCellAfterColon(wb, cellRef, String.valueOf(val));
|
||||||
} else {
|
} 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 时把起运港写入费用名称
|
// 映射若误指费用名称列:跳过,避免无 TMS 时把起运港写入费用名称
|
||||||
if (isPortFactCode(code)) {
|
if (isPortFactCode(code)) {
|
||||||
ParsedRef pref = ParsedRef.parse(cellRef);
|
ParsedRef pref = ParsedRef.parse(cellRef);
|
||||||
@@ -738,10 +638,6 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
sh = wb.getSheetAt(0);
|
sh = wb.getSheetAt(0);
|
||||||
}
|
}
|
||||||
if (sh != null && isFeeNameColumnRef(sh, cellRef)) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -775,17 +671,6 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
// B3:J3 是原控件锚点合并区,禁止往里写任何单元格字。
|
// B3:J3 是原控件锚点合并区,禁止往里写任何单元格字。
|
||||||
clearSyntheticSeaTransportClassCell(wb);
|
clearSyntheticSeaTransportClassCell(wb);
|
||||||
writeSeaOtherClassFallbackLabel(wb, facts);
|
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 数。
|
// 产品口径:TMS 已返回汇总金额 → Excel 汇总/合计格去掉公式,改写 TMS 数。
|
||||||
// 海运三段汇总/小计:fee_total_*;整单:total_amount(预估费用/费用总计等)。
|
// 海运三段汇总/小计:fee_total_*;整单:total_amount(预估费用/费用总计等)。
|
||||||
writeSeaSectionSubtotals(wb, facts, yellow);
|
writeSeaSectionSubtotals(wb, facts, yellow);
|
||||||
@@ -793,25 +678,13 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
// 无费用明细时:清掉误写入费用名称列的起运港/目的港(WO202609280006)
|
// 无费用明细时:清掉误写入费用名称列的起运港/目的港(WO202609280006)
|
||||||
clearPortSpillInFeeNameColumn(wb, facts);
|
clearPortSpillInFeeNameColumn(wb, facts);
|
||||||
// 跨境集拼:列头必须和空白模板一样(蓝底白字)。POI 写回后币种/燃油会变成大红字。
|
// 跨境集拼:列头必须和空白模板一样(蓝底白字)。POI 写回后币种/燃油会变成大红字。
|
||||||
// #region agent log
|
|
||||||
debugLandBannerColors(wb, "before_restore", String.valueOf(sourceXlsx));
|
|
||||||
// #endregion
|
|
||||||
restoreLandTemplateLook(wb, landLook);
|
restoreLandTemplateLook(wb, landLook);
|
||||||
// #region agent log
|
|
||||||
debugLandBannerColors(wb, "after_restore", String.valueOf(sourceXlsx));
|
|
||||||
// #endregion
|
|
||||||
// 列头盖回去时,数据行若被误当成表头,目的地会再次变成币种那套红字。
|
// 列头盖回去时,数据行若被误当成表头,目的地会再次变成币种那套红字。
|
||||||
restyleLandCityValues(wb);
|
restyleLandCityValues(wb);
|
||||||
// 拼车「元/CBM」整车车型格:跟「始发站」同一浅蓝,禁止盖成顶栏那种深蓝。
|
// 拼车「元/CBM」整车车型格:跟「始发站」同一浅蓝,禁止盖成顶栏那种深蓝。
|
||||||
alignLandNestedHeaderFill(wb);
|
alignLandNestedHeaderFill(wb);
|
||||||
// #region agent log
|
|
||||||
debugLandBannerColors(wb, "after_align", String.valueOf(sourceXlsx));
|
|
||||||
// #endregion
|
|
||||||
// 转 PDF 前把纸张改成 A4 居中,避免顶部裁字、整页偏左。
|
// 转 PDF 前把纸张改成 A4 居中,避免顶部裁字、整页偏左。
|
||||||
applyQuotePrintLayout(wb);
|
applyQuotePrintLayout(wb);
|
||||||
// #region agent log
|
|
||||||
debugLandBannerColors(wb, "after_print", String.valueOf(sourceXlsx));
|
|
||||||
// #endregion
|
|
||||||
Files.createDirectories(targetXlsx.getParent());
|
Files.createDirectories(targetXlsx.getParent());
|
||||||
try (OutputStream out = Files.newOutputStream(targetXlsx)) {
|
try (OutputStream out = Files.newOutputStream(targetXlsx)) {
|
||||||
wb.write(out);
|
wb.write(out);
|
||||||
@@ -858,26 +731,10 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
} catch (Exception ignoredDst) {
|
} 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,否则标题变成「国际海运询价单」。
|
// 只对海运模板做控件包/骨架盖回。空运到港等模板禁止走 WO011,否则标题变成「国际海运询价单」。
|
||||||
if (!seaTemplate) {
|
if (!seaTemplate) {
|
||||||
// #region agent log
|
|
||||||
agentDbg("H_SEA", "fillWorkbook.skipSeaScaffold", "non-sea template skip WO011 scaffold",
|
|
||||||
"{\"path\":\"" + String.valueOf(sourceXlsx).replace("\\", "/").replace("\"", "'") + "\"}");
|
|
||||||
// #endregion
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 按运输分类盖回 WPS 可显示的控件包:
|
// 按运输分类盖回 WPS 可显示的控件包:
|
||||||
@@ -1017,17 +874,6 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
usedVml = true;
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1337,10 +1183,6 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
}
|
}
|
||||||
String raw = facts.getString(mk[2]);
|
String raw = facts.getString(mk[2]);
|
||||||
if (StringUtils.isBlank(raw) || "-".equals(raw.trim())) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
String ccy = headerCcy;
|
String ccy = headerCcy;
|
||||||
@@ -1360,12 +1202,6 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
// 有 TMS 汇总:必须去掉公式再写数字
|
// 有 TMS 汇总:必须去掉公式再写数字
|
||||||
writeAmountCell(sheet, r, amtCol, amt, yellow);
|
writeAmountCell(sheet, r, amtCol, amt, yellow);
|
||||||
clearOtherSummaryFormulasOnRow(row, amtCol, ccyCol);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1443,11 +1279,6 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
|| text.contains("其它") || text.contains("其他");
|
|| text.contains("其它") || text.contains("其他");
|
||||||
if (synthetic) {
|
if (synthetic) {
|
||||||
target.setCellValue("");
|
target.setCellValue("");
|
||||||
// #region agent log
|
|
||||||
agentDbg("H_B3", "clearSyntheticSeaTransportClassCell", "cleared radio-anchor cell text",
|
|
||||||
"{\"ref\":\"" + new CellReference(target).formatAsString()
|
|
||||||
+ "\",\"was\":\"" + text.replace("\"", "'") + "\"}");
|
|
||||||
// #endregion
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1545,24 +1376,7 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
Files.move(tmp, targetXlsx, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
Files.move(tmp, targetXlsx, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||||
tmp = null;
|
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) {
|
} 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);
|
log.warn("海运运输分类 Form Control 勾选同步失败 path={}", targetXlsx, e);
|
||||||
} finally {
|
} finally {
|
||||||
if (tmp != null) {
|
if (tmp != null) {
|
||||||
@@ -1620,13 +1434,10 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
boolean finished = p.waitFor(120, java.util.concurrent.TimeUnit.SECONDS);
|
boolean finished = p.waitFor(120, java.util.concurrent.TimeUnit.SECONDS);
|
||||||
if (!finished) {
|
if (!finished) {
|
||||||
p.destroyForcibly();
|
p.destroyForcibly();
|
||||||
agentDbg("R1", "switchSeaTransportClassToOtherViaExcel.timeout", "excel com timeout", "{}");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
String out = new String(outBytes, StandardCharsets.UTF_8);
|
String out = new String(outBytes, StandardCharsets.UTF_8);
|
||||||
if (!out.contains("SWITCH_OK")) {
|
if (!out.contains("SWITCH_OK")) {
|
||||||
agentDbg("R1", "switchSeaTransportClassToOtherViaExcel.fail", "excel com failed",
|
|
||||||
"{\"out\":\"" + out.replace("\"", "'").replace("\n", " ").replace("\r", "") + "\"}");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 校验 zip:次钮 Checked
|
// 校验 zip:次钮 Checked
|
||||||
@@ -1656,14 +1467,9 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
&& !bodies.get(0).contains("<x:Checked>")
|
&& !bodies.get(0).contains("<x:Checked>")
|
||||||
&& bodies.get(1).contains("<x:Checked>")
|
&& bodies.get(1).contains("<x:Checked>")
|
||||||
&& c2.contains("checked=\"Checked\"");
|
&& c2.contains("checked=\"Checked\"");
|
||||||
agentDbg("R1", "switchSeaTransportClassToOtherViaExcel", "excel switched other radio",
|
|
||||||
"{\"ok\":" + ok + ",\"vmlChecked2\":" + (bodies.size() > 1 && bodies.get(1).contains("<x:Checked>"))
|
|
||||||
+ ",\"ctrl2\":" + c2.contains("checked=\"Checked\"") + "}");
|
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
agentDbg("R1", "switchSeaTransportClassToOtherViaExcel.err", "excel com exception",
|
|
||||||
"{\"err\":\"" + String.valueOf(e.getMessage()).replace("\"", "'") + "\"}");
|
|
||||||
log.warn("海运「其它」Excel 切勾失败 path={}", targetXlsx, e);
|
log.warn("海运「其它」Excel 切勾失败 path={}", targetXlsx, e);
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1695,10 +1501,6 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
if (!"其他".equals(cls)) {
|
if (!"其他".equals(cls)) {
|
||||||
return;
|
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();
|
byte[] scaffold = loadWo011SeaSheet1();
|
||||||
String scaffoldSrc = "wo011-bundled";
|
String scaffoldSrc = "wo011-bundled";
|
||||||
if (scaffold == null || scaffold.length < 1000) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
Path tmp = null;
|
Path tmp = null;
|
||||||
@@ -1756,19 +1553,10 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
// WO035 证据:错误正则把自闭合 <c .../> 的 / 吃进属性,一次匹配跨越多行,
|
// WO035 证据:错误正则把自闭合 <c .../> 的 / 吃进属性,一次匹配跨越多行,
|
||||||
// sheet 从 17KB 缩成 7KB、只剩 2 行。合并结果过瘦则放弃,保留 POI 原 sheet。
|
// sheet 从 17KB 缩成 7KB、只剩 2 行。合并结果过瘦则放弃,保留 POI 原 sheet。
|
||||||
if (merged.sheetXml.length() < scaffold.length / 2 || rowCount < 20) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
// WO011 骨架合并后若仍带本地 mc xmlns,则放弃(WO036)
|
// WO011 骨架合并后若仍带本地 mc xmlns,则放弃(WO036)
|
||||||
if (merged.sheetXml.contains("AlternateContent xmlns:mc=")) {
|
if (merged.sheetXml.contains("AlternateContent xmlns:mc=")) {
|
||||||
agentDbg("H_SHEET", "restoreSeaSheet1Scaffold.abort", "scaffold still has local mc xmlns",
|
|
||||||
"{\"scaffoldSrc\":\"" + scaffoldSrc + "\"}");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
tmp = Files.createTempFile(targetXlsx.getParent(), "sea-sheet-", ".xlsx");
|
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);
|
Files.move(tmp, targetXlsx, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||||
tmp = null;
|
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;
|
return true;
|
||||||
} catch (Exception e) {
|
} 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);
|
log.warn("海运 sheet1 骨架还原失败 path={}", targetXlsx, e);
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2171,11 +1944,6 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
target = row.createCell(1);
|
target = row.createCell(1);
|
||||||
}
|
}
|
||||||
target.setCellValue(label);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2194,10 +1962,6 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
facts.getString("sea_class"));
|
facts.getString("sea_class"));
|
||||||
String cls = canonicalizeSeaTransportClass(raw);
|
String cls = canonicalizeSeaTransportClass(raw);
|
||||||
if (StringUtils.isBlank(cls)) {
|
if (StringUtils.isBlank(cls)) {
|
||||||
// #region agent log
|
|
||||||
agentDbg("R3", "writeSeaTransportClassVisibleLabelToFile.skip", "no transport class for fallback",
|
|
||||||
"{}");
|
|
||||||
// #endregion
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
boolean port = "Port to Port".equalsIgnoreCase(cls);
|
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);
|
Files.move(tmp, targetXlsx, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||||
tmp = null;
|
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) {
|
} catch (Exception e) {
|
||||||
// #region agent log
|
|
||||||
agentDbg("R3", "writeSeaTransportClassVisibleLabelToFile.err", "fallback failed",
|
|
||||||
"{\"err\":\"" + String.valueOf(e.getMessage()).replace("\"", "'") + "\"}");
|
|
||||||
// #endregion
|
|
||||||
log.warn("海运运输分类明文兜底失败 path={}", targetXlsx, e);
|
log.warn("海运运输分类明文兜底失败 path={}", targetXlsx, e);
|
||||||
} finally {
|
} finally {
|
||||||
if (tmp != null) {
|
if (tmp != null) {
|
||||||
@@ -2823,7 +2579,9 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 中港等模板:dataStartRow 常误指「4.2米/3T」车型表头行,下移到真正数据行
|
// 中港等模板:dataStartRow 常误指「4.2米/3T」车型表头行,下移到真正数据行
|
||||||
|
int mappedStart = rowIdx0;
|
||||||
rowIdx0 = resolveRouteDataStartRow(sheet, rowIdx0, region.getIntValue("headerRow"));
|
rowIdx0 = resolveRouteDataStartRow(sheet, rowIdx0, region.getIntValue("headerRow"));
|
||||||
|
String kind = landRouteSectionKind(sheet, region);
|
||||||
JSONArray sectionRoutes = routesForLandRouteSection(routes, sheet, region, i, regions.size());
|
JSONArray sectionRoutes = routesForLandRouteSection(routes, sheet, region, i, regions.size());
|
||||||
if (sectionRoutes == null || sectionRoutes.isEmpty()) {
|
if (sectionRoutes == null || sectionRoutes.isEmpty()) {
|
||||||
continue;
|
continue;
|
||||||
@@ -2908,8 +2666,9 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
header0 = dataRow - 1;
|
header0 = dataRow - 1;
|
||||||
}
|
}
|
||||||
DataFormatter fmt = new DataFormatter();
|
DataFormatter fmt = new DataFormatter();
|
||||||
int from = Math.max(0, header0 - 1);
|
// 上传版跨境整车:时效/燃油在表头行,币种常错开一行(O7),多扫两行才能对上。
|
||||||
int to = header0 + 1;
|
int from = Math.max(0, header0 - 2);
|
||||||
|
int to = header0 + 2;
|
||||||
for (int r = from; r <= to; r++) {
|
for (int r = from; r <= to; r++) {
|
||||||
Row row = sheet.getRow(r);
|
Row row = sheet.getRow(r);
|
||||||
if (row == null) {
|
if (row == null) {
|
||||||
@@ -2922,6 +2681,8 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
String val = landMatrixSideValue(text, rowObj, facts);
|
String val = landMatrixSideValue(text, rowObj, facts);
|
||||||
|
if (text.contains("燃油") || "币种".equals(text) || "币别".equals(text) || text.contains("时效")) {
|
||||||
|
}
|
||||||
if (StringUtils.isBlank(val) || "-".equals(val)) {
|
if (StringUtils.isBlank(val) || "-".equals(val)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -3013,10 +2774,10 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
short lastCol = row.getLastCellNum();
|
short lastCol = row.getLastCellNum();
|
||||||
for (int c = 0; c < lastCol; c++) {
|
for (int c = 0; c < lastCol; c++) {
|
||||||
String text = StringUtils.trimToEmpty(fmt.formatCellValue(row.getCell(c))).replaceAll("\\s+", "");
|
String text = StringUtils.trimToEmpty(fmt.formatCellValue(row.getCell(c))).replaceAll("\\s+", "");
|
||||||
if (text.contains("国内段")) {
|
if (isLandDomesticSectionTitle(text)) {
|
||||||
domestic = true;
|
domestic = true;
|
||||||
}
|
}
|
||||||
if (text.contains("境外段") || text.contains("国外段")) {
|
if (isLandOverseasSectionTitle(text)) {
|
||||||
overseas = true;
|
overseas = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3025,7 +2786,8 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 国内段 / 境外段。认 mapping 标题,再认表上「一、国内段报价」这类分区标题。
|
* 国内段 / 境外段。认 mapping 标题,再认表上「一、国内段报价」「二、境外报价」这类分区标题。
|
||||||
|
* 上传版标题是「境外报价」不一定带「段」字。
|
||||||
*/
|
*/
|
||||||
static String landRouteSectionKind(Sheet sheet, JSONObject region) {
|
static String landRouteSectionKind(Sheet sheet, JSONObject region) {
|
||||||
String title = "";
|
String title = "";
|
||||||
@@ -3033,10 +2795,10 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
title = firstNonBlank(region.getString("title"), region.getString("id"), region.getString("name"));
|
title = firstNonBlank(region.getString("title"), region.getString("id"), region.getString("name"));
|
||||||
}
|
}
|
||||||
String compact = StringUtils.trimToEmpty(title).replaceAll("\\s+", "");
|
String compact = StringUtils.trimToEmpty(title).replaceAll("\\s+", "");
|
||||||
if (compact.contains("境外") || compact.contains("国外")) {
|
if (isLandOverseasSectionTitle(compact)) {
|
||||||
return "overseas";
|
return "overseas";
|
||||||
}
|
}
|
||||||
if (compact.contains("国内")) {
|
if (isLandDomesticSectionTitle(compact)) {
|
||||||
return "domestic";
|
return "domestic";
|
||||||
}
|
}
|
||||||
if (sheet == null || region == null) {
|
if (sheet == null || region == null) {
|
||||||
@@ -3055,10 +2817,10 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
short lastCol = row.getLastCellNum();
|
short lastCol = row.getLastCellNum();
|
||||||
for (int c = 0; c < lastCol; c++) {
|
for (int c = 0; c < lastCol; c++) {
|
||||||
String text = StringUtils.trimToEmpty(fmt.formatCellValue(row.getCell(c))).replaceAll("\\s+", "");
|
String text = StringUtils.trimToEmpty(fmt.formatCellValue(row.getCell(c))).replaceAll("\\s+", "");
|
||||||
if (text.contains("境外段") || text.contains("国外段")) {
|
if (isLandOverseasSectionTitle(text)) {
|
||||||
return "overseas";
|
return "overseas";
|
||||||
}
|
}
|
||||||
if (text.contains("国内段")) {
|
if (isLandDomesticSectionTitle(text)) {
|
||||||
return "domestic";
|
return "domestic";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3079,10 +2841,10 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
short lastCol = row.getLastCellNum();
|
short lastCol = row.getLastCellNum();
|
||||||
for (int c = 0; c < lastCol; c++) {
|
for (int c = 0; c < lastCol; c++) {
|
||||||
String text = StringUtils.trimToEmpty(fmt.formatCellValue(row.getCell(c))).replaceAll("\\s+", "");
|
String text = StringUtils.trimToEmpty(fmt.formatCellValue(row.getCell(c))).replaceAll("\\s+", "");
|
||||||
if (text.contains("境外段") || text.contains("国外段")) {
|
if (isLandOverseasSectionTitle(text)) {
|
||||||
return "overseas";
|
return "overseas";
|
||||||
}
|
}
|
||||||
if (text.contains("国内段")) {
|
if (isLandDomesticSectionTitle(text)) {
|
||||||
return "domestic";
|
return "domestic";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3090,6 +2852,29 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
return "";
|
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 腿按腿序号对上。
|
* 国内段写境内城市,境外段写境外城市。两段 TMS 腿按腿序号对上。
|
||||||
* 只有一对郑州→吉隆坡时:国内段只写郑州,境外段只写吉隆坡,不发明口岸。
|
* 只有一对郑州→吉隆坡时:国内段只写郑州,境外段只写吉隆坡,不发明口岸。
|
||||||
@@ -3206,6 +2991,26 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
if (StringUtils.isNotBlank(included)) {
|
if (StringUtils.isNotBlank(included)) {
|
||||||
out.put("tax_included", 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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3216,7 +3021,7 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
String t = text.replaceAll("\\s+", "");
|
String t = text.replaceAll("\\s+", "");
|
||||||
String[] marks = {
|
String[] marks = {
|
||||||
"吉隆坡", "胡志明", "河内", "海防", "岘港", "谅山", "同登", "老街", "芒街",
|
"吉隆坡", "胡志明", "河内", "海防", "岘港", "谅山", "同登", "老街", "芒街",
|
||||||
"曼谷", "清迈", "金边", "西哈努克", "新加坡", "雅加达", "万象", "仰光",
|
"春疆", "曼谷", "清迈", "金边", "西哈努克", "新加坡", "雅加达", "万象", "仰光",
|
||||||
"马来", "越南", "泰国", "柬埔寨", "老挝", "缅甸", "印尼", "菲律宾",
|
"马来", "越南", "泰国", "柬埔寨", "老挝", "缅甸", "印尼", "菲律宾",
|
||||||
"Hồ", "ChiMinh", "Kuala", "Lumpur", "Hanoi", "Bangkok", "Saigon", "Phnom"
|
"Hồ", "ChiMinh", "Kuala", "Lumpur", "Hanoi", "Bangkok", "Saigon", "Phnom"
|
||||||
};
|
};
|
||||||
@@ -3245,9 +3050,11 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
return rowIdx0;
|
return rowIdx0;
|
||||||
}
|
}
|
||||||
int cur = 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)
|
if (rowLooksLikeVehicleHeader(sheet, cur) || rowLooksLikeLandRouteMatrixHeader(sheet, cur)
|
||||||
|| rowLooksLikeDeliveryPriceHeader(sheet, cur)) {
|
|| rowLooksLikeDeliveryPriceHeader(sheet, cur)
|
||||||
|
|| rowLooksLikeVehicleDimensionRow(sheet, cur)
|
||||||
|
|| rowLooksLikeLandUnitSubHeader(sheet, cur)) {
|
||||||
cur++;
|
cur++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -3257,7 +3064,9 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
if (header0 >= 0 && cur == header0) {
|
if (header0 >= 0 && cur == header0) {
|
||||||
cur = header0 + 1;
|
cur = header0 + 1;
|
||||||
if (rowLooksLikeVehicleHeader(sheet, cur) || rowLooksLikeLandRouteMatrixHeader(sheet, cur)
|
if (rowLooksLikeVehicleHeader(sheet, cur) || rowLooksLikeLandRouteMatrixHeader(sheet, cur)
|
||||||
|| rowLooksLikeDeliveryPriceHeader(sheet, cur)) {
|
|| rowLooksLikeDeliveryPriceHeader(sheet, cur)
|
||||||
|
|| rowLooksLikeVehicleDimensionRow(sheet, cur)
|
||||||
|
|| rowLooksLikeLandUnitSubHeader(sheet, cur)) {
|
||||||
cur++;
|
cur++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3330,11 +3139,16 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
Cell mcell = mrow == null ? null : mrow.getCell(merge[1]);
|
Cell mcell = mrow == null ? null : mrow.getCell(merge[1]);
|
||||||
String exist = readCellText(mcell);
|
String exist = readCellText(mcell);
|
||||||
boolean originOrDest = isLandOriginHeaderText(exist) || "目的地".equals(StringUtils.trimToEmpty(exist).replaceAll("\\s+", ""));
|
boolean originOrDest = isLandOriginHeaderText(exist) || "目的地".equals(StringUtils.trimToEmpty(exist).replaceAll("\\s+", ""));
|
||||||
|
boolean dimCell = looksLikeVehicleDimensionText(exist);
|
||||||
boolean matrixRow = rowLooksLikeLandRouteMatrixHeader(sheet, rowIdx)
|
boolean matrixRow = rowLooksLikeLandRouteMatrixHeader(sheet, rowIdx)
|
||||||
|| rowLooksLikeVehicleHeader(sheet, rowIdx)
|
|| rowLooksLikeVehicleHeader(sheet, rowIdx)
|
||||||
|| rowLooksLikeDeliveryPriceHeader(sheet, rowIdx)
|
|| rowLooksLikeDeliveryPriceHeader(sheet, rowIdx)
|
||||||
|
|| rowLooksLikeVehicleDimensionRow(sheet, rowIdx)
|
||||||
|
|| rowLooksLikeLandUnitSubHeader(sheet, rowIdx)
|
||||||
|| rowLooksLikeLandRouteMatrixHeader(sheet, merge[0])
|
|| rowLooksLikeLandRouteMatrixHeader(sheet, merge[0])
|
||||||
|| rowLooksLikeVehicleHeader(sheet, merge[0]);
|
|| rowLooksLikeVehicleHeader(sheet, merge[0])
|
||||||
|
|| rowLooksLikeVehicleDimensionRow(sheet, merge[0])
|
||||||
|
|| dimCell;
|
||||||
// 拼车是上下标签栏(始发站、目的地、元/CBM),不能整列往下挪,否则城市会盖掉单价标签。
|
// 拼车是上下标签栏(始发站、目的地、元/CBM),不能整列往下挪,否则城市会盖掉单价标签。
|
||||||
if (!matrixRow && !originOrDest) {
|
if (!matrixRow && !originOrDest) {
|
||||||
return new int[]{rowIdx, col};
|
return new int[]{rowIdx, col};
|
||||||
@@ -3969,13 +3783,11 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
col = origin[1];
|
col = origin[1];
|
||||||
}
|
}
|
||||||
String ref = sheetName + "!" + new CellReference(rowIdx, col).formatAsString();
|
String ref = sheetName + "!" + new CellReference(rowIdx, col).formatAsString();
|
||||||
// #region agent log
|
String headerPeek = columnHeaderText(sheet, rowIdx, col);
|
||||||
if (StringUtils.isNotBlank(value) && (value.contains("广州") || "B".equalsIgnoreCase(colLetter.trim()))) {
|
boolean trackOdFuel = isCityRouteColumn(sheet, rowIdx, col, headerPeek)
|
||||||
agentDbg("H3", "writeFeeCol", "fee col write",
|
|| looksLikeCurrencyCode(value)
|
||||||
"{\"ref\":\"" + ref + "\",\"col\":\"" + colLetter
|
|| (headerPeek != null && (headerPeek.contains("燃油") || headerPeek.contains("币种")
|
||||||
+ "\",\"val\":\"" + value.replace("\"", "'") + "\"}");
|
|| headerPeek.contains("起始") || headerPeek.contains("始发") || headerPeek.contains("目的")));
|
||||||
}
|
|
||||||
// #endregion
|
|
||||||
if (yellow != null && yellow.contains(ref)) {
|
if (yellow != null && yellow.contains(ref)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4016,6 +3828,10 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
if (looksLikeCurrencyCode(value) && columnRejectsCurrency(header)) {
|
if (looksLikeCurrencyCode(value) && columnRejectsCurrency(header)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 起始地/目的地只写城市。fee_rows 误指运价行时,「运输费」不能盖掉深圳(WO202610080006)。
|
||||||
|
if (isCityRouteColumn(sheet, rowIdx, col, header) && looksLikeFeeNameNotPlace(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
writeNumericOrText(cell, value);
|
writeNumericOrText(cell, value);
|
||||||
// 始发地/目的地是城市。模板空格常套用「币种/燃油」的红字+顶对齐,
|
// 始发地/目的地是城市。模板空格常套用「币种/燃油」的红字+顶对齐,
|
||||||
// 长地名一换行就会画进蓝表头,看起来像列头被改了颜色。
|
// 长地名一换行就会画进蓝表头,看起来像列头被改了颜色。
|
||||||
@@ -4061,6 +3877,26 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
|| "HKD".equals(t) || "VND".equals(t);
|
|| "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 会把覆盖区域的城市、或燃油附加费率的空格盖掉。
|
* 这些列头不是币种:写 CNY 会把覆盖区域的城市、或燃油附加费率的空格盖掉。
|
||||||
* 表头是「币种 / 币别」,或上面没有列头时,仍允许写币种。
|
* 表头是「币种 / 币别」,或上面没有列头时,仍允许写币种。
|
||||||
@@ -4661,7 +4497,7 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
byte[] sample = sampleLandMatrixHeaderFill(sheet, r);
|
byte[] sample = sampleLandMatrixHeaderFill(sheet, r);
|
||||||
if (sample == null || looksYellowRgb(sample)) {
|
if (sample == null || looksYellowRgb(sample) || looksLikeWhiteOrEmptyFill(sample)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Row row = sheet.getRow(r);
|
Row row = sheet.getRow(r);
|
||||||
@@ -4684,7 +4520,8 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 向上找运价表头「始发站/始发地/序号」的浅蓝底。
|
* 向上找运价表头「始发站/始发地/序号」的灰/浅蓝底。
|
||||||
|
* 跨境整车境外段模板里「起始地」偶发白底(theme0);若拿它刷整行,会把同行「币种」灰底盖白。
|
||||||
*/
|
*/
|
||||||
static byte[] sampleLandMatrixHeaderFill(Sheet sheet, int fromRow) {
|
static byte[] sampleLandMatrixHeaderFill(Sheet sheet, int fromRow) {
|
||||||
if (sheet == null) {
|
if (sheet == null) {
|
||||||
@@ -4702,6 +4539,7 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
if (row == null) {
|
if (row == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
byte[] preferred = null;
|
||||||
byte[] fallback = null;
|
byte[] fallback = null;
|
||||||
for (Cell cell : row) {
|
for (Cell cell : row) {
|
||||||
if (cell == null) {
|
if (cell == null) {
|
||||||
@@ -4709,17 +4547,23 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
}
|
}
|
||||||
String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)).replaceAll("\\s+", "");
|
String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)).replaceAll("\\s+", "");
|
||||||
byte[] rgb = readFillRgb(cell);
|
byte[] rgb = readFillRgb(cell);
|
||||||
if (rgb == null || looksYellowRgb(rgb)) {
|
if (rgb == null || looksYellowRgb(rgb) || looksLikeWhiteOrEmptyFill(rgb)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (isLandOriginHeaderText(text) || "序号".equals(text) || "目的地".equals(text)
|
if (isLandOriginHeaderText(text) || "序号".equals(text) || "目的地".equals(text)
|
||||||
|| "提货费".equals(text) || "送货费".equals(text)) {
|
|| "提货费".equals(text) || "送货费".equals(text)
|
||||||
return rgb;
|
|| "币种".equals(text) || "币别".equals(text) || "时效".equals(text)
|
||||||
|
|| text.contains("燃油")) {
|
||||||
|
preferred = rgb;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
if (fallback == null) {
|
if (fallback == null) {
|
||||||
fallback = rgb;
|
fallback = rgb;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (preferred != null) {
|
||||||
|
return preferred;
|
||||||
|
}
|
||||||
if (fallback != null) {
|
if (fallback != null) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
@@ -4737,6 +4581,24 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
return r >= 200 && g >= 180 && b <= 140;
|
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) {
|
static void applyFillRgb(Cell cell, byte[] fillRgb) {
|
||||||
if (cell == null || fillRgb == null || fillRgb.length < 3) {
|
if (cell == null || fillRgb == null || fillRgb.length < 3) {
|
||||||
return;
|
return;
|
||||||
@@ -4790,6 +4652,57 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
return units >= 1 && !originDest;
|
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) {
|
static boolean rowLooksLikeLandSectionTitle(Sheet sheet, int rowIdx) {
|
||||||
if (sheet == null || rowIdx < 0) {
|
if (sheet == null || rowIdx < 0) {
|
||||||
@@ -5232,11 +5145,6 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
clearEstimateExtraCell(row, cell);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
// 空运:优先写到费目表「金额」列(常为 G),避免写到标签旁 B 而 SUM 仍在 G
|
// 空运:优先写到费目表「金额」列(常为 G),避免写到标签旁 B 而 SUM 仍在 G
|
||||||
@@ -5304,11 +5212,6 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
Cell extra = row.getCell(dest[1]);
|
Cell extra = row.getCell(dest[1]);
|
||||||
clearEstimateExtraCell(row, extra);
|
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 name = StringUtils.trimToEmpty(rowObj.getString("name"));
|
||||||
String amount = StringUtils.trimToEmpty(rowObj.getString("amount"));
|
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)) {
|
if (StringUtils.isBlank(name) || StringUtils.isBlank(amount) || "-".equals(amount)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -6061,14 +5957,6 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void writeCell(Workbook wb, String cellRef, String value) {
|
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);
|
ParsedRef ref = ParsedRef.parse(cellRef);
|
||||||
Sheet sheet = wb.getSheet(ref.sheet);
|
Sheet sheet = wb.getSheet(ref.sheet);
|
||||||
if (sheet == null && wb.getNumberOfSheets() > 0) {
|
if (sheet == null && wb.getNumberOfSheets() > 0) {
|
||||||
@@ -6100,6 +5988,15 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
|| rowLooksLikeLandUnitSubHeader(sheet, dest[0])) {
|
|| rowLooksLikeLandUnitSubHeader(sheet, dest[0])) {
|
||||||
return;
|
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(':'));
|
int colon = Math.max(existing.indexOf(':'), existing.indexOf(':'));
|
||||||
if (colon >= 0) {
|
if (colon >= 0) {
|
||||||
String prefix = existing.substring(0, colon + 1);
|
String prefix = existing.substring(0, colon + 1);
|
||||||
@@ -6474,19 +6371,6 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
// 费用明细数据行:「起运港/目的港」是项目列值,不是询价标签;
|
// 费用明细数据行:「起运港/目的港」是项目列值,不是询价标签;
|
||||||
// 否则会把 pol 写进右侧「费用名称」(WO202609230026:广州写入 B18)。
|
// 否则会把 pol 写进右侧「费用名称」(WO202609230026:广州写入 B18)。
|
||||||
if (!feeHeader && isFeeDetailDataRow(sheet, r)) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
short lastCol = row.getLastCellNum();
|
short lastCol = row.getLastCellNum();
|
||||||
@@ -6524,11 +6408,17 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
// 运价矩阵的起始地/目的地按国内段、境外段拆开写,禁止同一对城市填进两张表。
|
// 运价矩阵的起始地/目的地按国内段、境外段拆开写,禁止同一对城市填进两张表。
|
||||||
if (("origin".equals(code) || "destination".equals(code))
|
if (("origin".equals(code) || "destination".equals(code))
|
||||||
&& rowLooksLikeLandRouteMatrixHeader(sheet, r)) {
|
&& rowLooksLikeLandRouteMatrixHeader(sheet, r)) {
|
||||||
String kind = landRouteSectionKindNearRow(sheet, r);
|
// 有 TMS 线路行时由 writeRouteRegions 分区写 OD;标签回填会用短地名盖掉「广东省-深圳市」。
|
||||||
if ("domestic".equals(kind) && "destination".equals(code) && looksOverseasPlace(val)) {
|
if (!parseFeeRows(facts.get("route_rows")).isEmpty()
|
||||||
|
|| !parseFeeRows(facts.get("landSegments")).isEmpty()) {
|
||||||
continue;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6538,28 +6428,8 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
}
|
}
|
||||||
// 硬防护:pol/pod 等港口字段禁止写入费用「名称」列(无 TMS 时曾把「广州」写进费用名称)
|
// 硬防护:pol/pod 等港口字段禁止写入费用「名称」列(无 TMS 时曾把「广州」写进费用名称)
|
||||||
if (isPortFactCode(code) && isFeeNameColumnRef(sheet, fillRef)) {
|
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;
|
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)
|
boolean labelAfterColon = textContainsColon(text)
|
||||||
&& ("quote_no".equals(code) || "quote_date".equals(code) || "valid_until".equals(code));
|
&& ("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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+87
@@ -380,8 +380,13 @@ public class InquiryTmsQueryService {
|
|||||||
quote.put("landSegments", json);
|
quote.put("landSegments", json);
|
||||||
// 同步一份 route_rows,字段名对齐填表(transit_time / salePrice 等)
|
// 同步一份 route_rows,字段名对齐填表(transit_time / salePrice 等)
|
||||||
List<Map<String, String>> rows = normalizeLandSegmentRows(raw);
|
List<Map<String, String>> rows = normalizeLandSegmentRows(raw);
|
||||||
|
// 腿上缺燃油时,从同段 feeItems 补(TMS 有时只把 fuelSurchargeRate 挂在费用行上)
|
||||||
|
enrichLandFuelTaxFromFeeItems(rows, data == null ? null : data.get("feeItems"),
|
||||||
|
data == null ? null : data.get("fee_items"));
|
||||||
if (!rows.isEmpty()) {
|
if (!rows.isEmpty()) {
|
||||||
quote.put("route_rows", JSON.toJSONString(rows));
|
quote.put("route_rows", JSON.toJSONString(rows));
|
||||||
|
// 回写补全后的腿,避免后续只读 landSegments 时又丢燃油
|
||||||
|
quote.put("landSegments", JSON.toJSONString(rows));
|
||||||
}
|
}
|
||||||
// feeItems 为空时,用各腿成本补运输费。顶层总价会被误收成「空运费」,陆运要换掉。
|
// feeItems 为空时,用各腿成本补运输费。顶层总价会被误收成「空运费」,陆运要换掉。
|
||||||
String feeLines = quote.get("fee_lines");
|
String feeLines = quote.get("fee_lines");
|
||||||
@@ -1019,6 +1024,88 @@ public class InquiryTmsQueryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 陆运腿缺燃油附加费率/税率时,按同段 feeItems 补。
|
||||||
|
* <p>
|
||||||
|
* 为何:TMS 偶发只在费用行带 fuelSurchargeRate,腿上为 null;出单侧栏就会空着。
|
||||||
|
* 不编造;feeItems 也没有则保持空。
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
static void enrichLandFuelTaxFromFeeItems(List<Map<String, String>> rows, Object... feeSources) {
|
||||||
|
if (rows == null || rows.isEmpty() || feeSources == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<Map<?, ?>> 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<String, String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 燃油附加费率展示:数字带 %;已带 % 保留。空值不编造。
|
* 燃油附加费率展示:数字带 %;已带 % 保留。空值不编造。
|
||||||
*/
|
*/
|
||||||
|
|||||||
+251
@@ -2,10 +2,13 @@ package org.jeecg.modules.inquiry.template;
|
|||||||
|
|
||||||
import com.alibaba.fastjson.JSONArray;
|
import com.alibaba.fastjson.JSONArray;
|
||||||
import com.alibaba.fastjson.JSONObject;
|
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.PrintSetup;
|
||||||
import org.apache.poi.ss.usermodel.Row;
|
import org.apache.poi.ss.usermodel.Row;
|
||||||
import org.apache.poi.ss.usermodel.Sheet;
|
import org.apache.poi.ss.usermodel.Sheet;
|
||||||
import org.apache.poi.ss.usermodel.Workbook;
|
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.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
import org.junit.jupiter.api.Test;
|
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 这类车型费用也是标签右侧,不是海运费目表。
|
* 国内整车:4.2米/3T 这类车型费用也是标签右侧,不是海运费目表。
|
||||||
*/
|
*/
|
||||||
|
|||||||
+33
@@ -220,6 +220,39 @@ public class TmsQuoteCurrencyTest {
|
|||||||
assertEquals("", InquiryTmsQueryService.formatLandPercentRate(null));
|
assertEquals("", InquiryTmsQueryService.formatLandPercentRate(null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 腿上燃油为空时,从同段 feeItems.fuelSurchargeRate 补到 route_rows,供出单侧栏写入。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void toAgentQuoteFillsLandFuelFromFeeItemsWhenSegmentMissing() {
|
||||||
|
Map<String, Object> 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<String, Object> 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<String, Object> data = new HashMap<>();
|
||||||
|
data.put("quoteFound", true);
|
||||||
|
data.put("currency", "CNY");
|
||||||
|
data.put("landSegments", Collections.singletonList(seg));
|
||||||
|
data.put("feeItems", Collections.singletonList(fee));
|
||||||
|
Map<String, Object> tms = new HashMap<>();
|
||||||
|
tms.put("code", 1);
|
||||||
|
tms.put("tmsData", data);
|
||||||
|
Map<String, String> quote = InquiryTmsQueryService.toAgentQuote(tms);
|
||||||
|
assertTrue(quote.get("route_rows").contains("\"fuel_surcharge_rate\":\"8%\""));
|
||||||
|
assertTrue(quote.get("route_rows").contains("\"tax_rate\":\"0%\""));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void toAgentQuoteKeepsSeaBucketTotals() {
|
public void toAgentQuoteKeepsSeaBucketTotals() {
|
||||||
Map<String, Object> pol = new HashMap<>();
|
Map<String, Object> pol = new HashMap<>();
|
||||||
|
|||||||
+6
-6
@@ -65,14 +65,14 @@ public class SysPermissionServiceImpl extends ServiceImpl<SysPermissionMapper, S
|
|||||||
* 为何加护栏:询价测服库菜单已是 Vue3,且没有 {@code sys_permission_v3};盲目切换会弄挂后台菜单。
|
* 为何加护栏:询价测服库菜单已是 Vue3,且没有 {@code sys_permission_v3};盲目切换会弄挂后台菜单。
|
||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 询价新库永久拒绝切表:即便存在 sys_permission_v3 也不自动 rename,
|
||||||
|
* 避免非管理员误点前端「Vue2 菜单」弹窗后弄挂 inquiry_robot 侧栏。
|
||||||
|
* 真要升级菜单表须走人工迁移脚本,禁止在线一键切换。
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void switchVue3Menu() {
|
public void switchVue3Menu() {
|
||||||
Long vue3TableCnt = sysPermissionMapper.countVue3MenuTable();
|
throw new JeecgBootException("已禁用在线切换 Vue3 菜单表;inquiry_robot 菜单按迁移脚本维护,禁止 rename sys_permission");
|
||||||
if (vue3TableCnt == null || vue3TableCnt.longValue() <= 0L) {
|
|
||||||
throw new JeecgBootException("当前库没有 sys_permission_v3,菜单已是 Vue3 或不可自动切换,已拒绝执行");
|
|
||||||
}
|
|
||||||
sysPermissionMapper.backupVue2Menu();
|
|
||||||
sysPermissionMapper.changeVue3Menu();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ export const getMenuList = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换成vue3菜单
|
* 切换成 vue3 菜单 —— 询价库永久禁用。
|
||||||
|
* <p>
|
||||||
|
* Jeecg 原逻辑会把 sys_permission rename 成 v2 再切 v3 表;测服 inquiry_robot
|
||||||
|
* 菜单已是 Vue3 且常无 sys_permission_v3,执行会弄挂侧栏。前端不再发切表请求。
|
||||||
*/
|
*/
|
||||||
export const switchVue3Menu = () => {
|
export const switchVue3Menu = () => {
|
||||||
return new Promise((resolve) => {
|
console.warn('[ytd] switchVue3Menu disabled: inquiry_robot menus are already Vue3');
|
||||||
defHttp.get({ url: Api.SwitchVue3Menu });
|
return Promise.resolve({ success: false, message: 'switchVue3Menu disabled' });
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,8 +8,10 @@
|
|||||||
import { useRoute, useRouter, RouterView } from 'vue-router';
|
import { useRoute, useRouter, RouterView } from 'vue-router';
|
||||||
import { message, ConfigProvider } from 'ant-design-vue';
|
import { message, ConfigProvider } from 'ant-design-vue';
|
||||||
import { useUserStore } from '/@/store/modules/user';
|
import { useUserStore } from '/@/store/modules/user';
|
||||||
|
import { usePermission } from '/@/hooks/web/usePermission';
|
||||||
import ProtoIcon from '/@/views/inquiry/components/ProtoIcon.vue';
|
import ProtoIcon from '/@/views/inquiry/components/ProtoIcon.vue';
|
||||||
import AdminModal from '/@/views/inquiry/components/AdminModal.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-prototype.css';
|
||||||
import '/@/views/inquiry/styles/admin-fidelity.css';
|
import '/@/views/inquiry/styles/admin-fidelity.css';
|
||||||
import '/@/views/inquiry/styles/dashboard-prototype.css';
|
import '/@/views/inquiry/styles/dashboard-prototype.css';
|
||||||
@@ -74,12 +76,26 @@
|
|||||||
components: { RouterView, ProtoIcon, AdminModal, ConfigProvider },
|
components: { RouterView, ProtoIcon, AdminModal, ConfigProvider },
|
||||||
setup() {
|
setup() {
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const userMenuOpen = ref(false);
|
const userMenuOpen = ref(false);
|
||||||
const profileOpen = ref(false);
|
const profileOpen = ref(false);
|
||||||
const passwordOpen = 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 pageTitle = computed(() => String(route.meta?.title || '友通达智能体运营后台'));
|
||||||
const crumb = computed(() => (route.meta?.crumb ? String(route.meta.crumb) : ''));
|
const crumb = computed(() => (route.meta?.crumb ? String(route.meta.crumb) : ''));
|
||||||
|
|
||||||
@@ -167,11 +183,15 @@
|
|||||||
if (!t.closest('.admin-user-menu')) closeUserMenu();
|
if (!t.closest('.admin-user-menu')) closeUserMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => document.addEventListener('click', onDocClick));
|
onMounted(() => {
|
||||||
onUnmounted(() => document.removeEventListener('click', onDocClick));
|
document.addEventListener('click', onDocClick);
|
||||||
|
});
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', onDocClick);
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
navGroups,
|
navGroups: visibleNavGroups,
|
||||||
themeToken,
|
themeToken,
|
||||||
route,
|
route,
|
||||||
pageTitle,
|
pageTitle,
|
||||||
|
|||||||
@@ -21,11 +21,12 @@ import { getMenuList } from '/@/api/sys/menu';
|
|||||||
import { getPermCode } from '/@/api/sys/user';
|
import { getPermCode } from '/@/api/sys/user';
|
||||||
|
|
||||||
import { PageEnum } from '/@/enums/pageEnum';
|
import { PageEnum } from '/@/enums/pageEnum';
|
||||||
|
import { INQUIRY_PAGE_VIEW_PERMS } from '/@/views/inquiry/shared/inquiryPagePerms';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 询价原型侧栏路径是写死的;Jeecg BACK 模式只注册后台菜单里的路由。
|
* 询价手写页:纠正菜单种子里的组件路径/标题。
|
||||||
* 菜单种子缺失/组件路径不对时,点侧栏会进 404(本机与线上相同)。
|
* 禁止把未授权页强行补进路由——否则 backMenuList 会膨胀成全量侧栏,无权限也看得见。
|
||||||
* 这里按侧栏清单补挂手写页,不依赖种子是否已导入。
|
* 仅当「后台菜单已有该路径」或「用户已有对应查看权限码」时才补挂缺失叶子。
|
||||||
*/
|
*/
|
||||||
type InquiryLeaf = {
|
type InquiryLeaf = {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -78,16 +79,65 @@ function findParent(list: AppRouteRecordRaw[] = [], parentPath: string): AppRout
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[]) {
|
/**
|
||||||
const groups: Record<string, { title: string; redirect: string; leaves: InquiryLeaf[] }> = {
|
* 永久禁止 Jeecg「Vue2 菜单表」切表弹窗。
|
||||||
|
* <p>
|
||||||
|
* 检测条件:顶层既没有 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<string | number> = []) {
|
||||||
|
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<string, { title: string; redirect: string; icon: string; leaves: InquiryLeaf[] }> = {
|
||||||
'/inquiry': {
|
'/inquiry': {
|
||||||
title: '询价',
|
title: '询价',
|
||||||
redirect: '/inquiry/dashboard',
|
redirect: '/inquiry/dashboard',
|
||||||
|
icon: 'ant-design:robot-outlined',
|
||||||
leaves: INQUIRY_LEAVES.filter((l) => l.path.startsWith('/inquiry/') && !l.path.startsWith('/inquiry-sys')),
|
leaves: INQUIRY_LEAVES.filter((l) => l.path.startsWith('/inquiry/') && !l.path.startsWith('/inquiry-sys')),
|
||||||
},
|
},
|
||||||
'/inquiry-sys': {
|
'/inquiry-sys': {
|
||||||
title: '询价系统配置',
|
title: '询价系统配置',
|
||||||
redirect: '/inquiry-sys/system-setting',
|
redirect: '/inquiry-sys/system-setting',
|
||||||
|
icon: 'ant-design:setting-outlined',
|
||||||
leaves: INQUIRY_LEAVES.filter((l) => l.path.startsWith('/inquiry-sys/')),
|
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 };
|
existing.meta = { ...(existing.meta || {}), title: leaf.title, ignoreKeepAlive: true };
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// 角色未授权且无对应查看权限:禁止补挂(根因:曾把 8 个询价页全塞进 backMenuList)
|
||||||
|
if (!canAddLeaf(leaf)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const node: AppRouteRecordRaw = {
|
const node: AppRouteRecordRaw = {
|
||||||
path: leaf.path,
|
path: leaf.path,
|
||||||
name: leaf.name,
|
name: leaf.name,
|
||||||
@@ -119,12 +173,23 @@ function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[]) {
|
|||||||
parent = {
|
parent = {
|
||||||
path: parentPath,
|
path: parentPath,
|
||||||
name: parentPath.replace(/\//g, '-').replace(/^-/, '') || 'inquiry-root',
|
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,
|
redirect: group.redirect,
|
||||||
meta: { title: group.title },
|
meta: { title: group.title, icon: group.icon },
|
||||||
children: [],
|
children: [],
|
||||||
};
|
};
|
||||||
routeList.push(parent);
|
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 || [];
|
parent.children = parent.children || [];
|
||||||
if (leaf.path === group.redirect) {
|
if (leaf.path === group.redirect) {
|
||||||
@@ -137,6 +202,49 @@ function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[]) {
|
|||||||
return routeList;
|
return routeList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 去掉无查看权限的询价叶子(角色树勾了菜单但没勾按钮、或历史脏数据)。
|
||||||
|
* 父节点若子级删空则一并去掉,避免侧栏空分组。
|
||||||
|
*/
|
||||||
|
function pruneInquiryLeavesWithoutViewPerm(
|
||||||
|
routeList: AppRouteRecordRaw[],
|
||||||
|
permCodes: Array<string | number> = [],
|
||||||
|
): 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 {
|
interface AuthItem {
|
||||||
@@ -323,21 +431,27 @@ export const usePermissionStore = defineStore({
|
|||||||
// 这个函数可能只需要执行一次,并且实际的项目可以在正确的时间被放置
|
// 这个函数可能只需要执行一次,并且实际的项目可以在正确的时间被放置
|
||||||
let routeList: AppRouteRecordRaw[] = [];
|
let routeList: AppRouteRecordRaw[] = [];
|
||||||
try {
|
try {
|
||||||
this.changePermissionCode();
|
// 必须 await:logout 会清空 permCodeList;若此处不候等,后面 prune 会在空权限码下
|
||||||
|
// 把询价叶子全删掉 → 侧栏仍能靠稍后到的权限码显示,点进去却是 404。
|
||||||
|
await this.changePermissionCode();
|
||||||
routeList = (await getMenuList()) as AppRouteRecordRaw[];
|
routeList = (await getMenuList()) as AppRouteRecordRaw[];
|
||||||
// Jeecg 原逻辑:顶层没有 layouts/default/index 且图标无冒号 → 弹窗并调用 /sys/switchVue3Menu
|
// 永久禁止 Vue2→Vue3 切表弹窗(shouldPromptVue2MenuSwitch 恒 false)
|
||||||
// (把 sys_permission rename 成 v2,再把 sys_permission_v3 rename 成正式表)。
|
if (shouldPromptVue2MenuSwitch(routeList)) {
|
||||||
// 询价库 inquiry_robot 菜单种子已是 Vue3,且测服没有 sys_permission_v3;点确认会弄挂侧栏。
|
// 不可达;保留分支防止以后误合并 Jeecg 原弹窗代码
|
||||||
// 因此禁用自动切表弹窗;后面仍会 ensureInquiryHandwrittenRoutes 补齐询价页。
|
}
|
||||||
// update-begin----author:ytd---date:20261007------for: 禁用危险的 Vue2→Vue3 菜单切表提示 ---
|
// 非管理员菜单常缺 Vue3 标记 → 误弹「菜单表是 Vue2」;先规范化再往下走
|
||||||
// update-end----author:ytd---date:20261007------for: 禁用危险的 Vue2→Vue3 菜单切表提示 ---
|
routeList = normalizeVue3MenuMarkers(routeList || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
// 菜单接口失败时仍补挂询价手写页,避免侧栏点进去全是 404
|
// 菜单接口失败时仍补挂询价手写页,避免侧栏点进去全是 404
|
||||||
routeList = Array.isArray(routeList) ? routeList : [];
|
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
|
// 组件地址前加斜杠处理 author: lsq date:2021-09-08
|
||||||
routeList = addSlashToRouteComponent(routeList);
|
routeList = addSlashToRouteComponent(routeList);
|
||||||
// 动态引入组件
|
// 动态引入组件
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ import {
|
|||||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||||
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||||||
import AdminPagination from "/@/views/inquiry/components/AdminPagination.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<AccountRow[]>([]);
|
const rows = ref<AccountRow[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -299,7 +305,7 @@ function removeAccount(row: AccountRow) {
|
|||||||
<button type="button" class="btn btn-secondary" disabled title="导出待后续开放">
|
<button type="button" class="btn btn-secondary" disabled title="导出待后续开放">
|
||||||
<ProtoIcon name="download" :size="14" />导出
|
<ProtoIcon name="download" :size="14" />导出
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-primary" @click="openCreate">
|
<button v-if="canEditAccount()" type="button" class="btn btn-primary" @click="openCreate">
|
||||||
<ProtoIcon name="plus" :size="14" />新建账号
|
<ProtoIcon name="plus" :size="14" />新建账号
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -369,7 +375,7 @@ function removeAccount(row: AccountRow) {
|
|||||||
<td>
|
<td>
|
||||||
<div class="account-row-actions">
|
<div class="account-row-actions">
|
||||||
<button
|
<button
|
||||||
v-if="!row.isSuperAdmin"
|
v-if="canEditAccount() && !row.isSuperAdmin"
|
||||||
class="link-action"
|
class="link-action"
|
||||||
type="button"
|
type="button"
|
||||||
@click="openEdit(row)"
|
@click="openEdit(row)"
|
||||||
@@ -378,6 +384,7 @@ function removeAccount(row: AccountRow) {
|
|||||||
</button>
|
</button>
|
||||||
<!-- 超管只隐藏编辑/删除;重置密码仍可用。不展示「不可编辑」占位文案 -->
|
<!-- 超管只隐藏编辑/删除;重置密码仍可用。不展示「不可编辑」占位文案 -->
|
||||||
<button
|
<button
|
||||||
|
v-if="canEditAccount()"
|
||||||
class="link-action link-muted"
|
class="link-action link-muted"
|
||||||
type="button"
|
type="button"
|
||||||
@click="resetPassword(row)"
|
@click="resetPassword(row)"
|
||||||
@@ -385,7 +392,7 @@ function removeAccount(row: AccountRow) {
|
|||||||
重置密码
|
重置密码
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="!row.isSuperAdmin"
|
v-if="canEditAccount() && !row.isSuperAdmin"
|
||||||
class="link-action link-danger"
|
class="link-action link-danger"
|
||||||
type="button"
|
type="button"
|
||||||
@click="removeAccount(row)"
|
@click="removeAccount(row)"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 机器人回复语:对接主账 /inquiry/botReply。
|
* 机器人回复语:对接主账 /inquiry/botReply。
|
||||||
* 分类壳用本地元数据;条目内容全部来自库。
|
* 分类壳用本地元数据;条目内容全部来自库。
|
||||||
@@ -19,6 +19,13 @@ import {
|
|||||||
} from "/@/views/inquiry/botReply/replyCategories";
|
} from "/@/views/inquiry/botReply/replyCategories";
|
||||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||||
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||||||
|
import { usePermission } from "/@/hooks/web/usePermission";
|
||||||
|
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
|
||||||
|
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
|
const canAdd = () => hasPermission(InquiryBtnPerm.replyAdd);
|
||||||
|
const canEdit = () => hasPermission(InquiryBtnPerm.replyEdit);
|
||||||
|
const canDisable = () => hasPermission(InquiryBtnPerm.replyDisable);
|
||||||
|
|
||||||
const categories = ref<ReplyCategory[]>(emptyCategories());
|
const categories = ref<ReplyCategory[]>(emptyCategories());
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -152,7 +159,7 @@ async function save() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport defer to="#page-topbar-actions">
|
<Teleport defer to="#page-topbar-actions">
|
||||||
<button type="button" class="btn btn-primary" @click="openCreate()">
|
<button v-if="canAdd()" type="button" class="btn btn-primary" @click="openCreate()">
|
||||||
<ProtoIcon name="plus" :size="16" />添加话术
|
<ProtoIcon name="plus" :size="16" />添加话术
|
||||||
</button>
|
</button>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
@@ -177,13 +184,21 @@ async function save() {
|
|||||||
</div>
|
</div>
|
||||||
<div style="display: flex; align-items: center; gap: 8px; flex-shrink: 0">
|
<div style="display: flex; align-items: center; gap: 8px; flex-shrink: 0">
|
||||||
<button
|
<button
|
||||||
|
v-if="canDisable()"
|
||||||
type="button"
|
type="button"
|
||||||
class="switch"
|
class="switch"
|
||||||
:class="{ 'is-on': item.enabled }"
|
:class="{ 'is-on': item.enabled }"
|
||||||
:aria-label="item.enabled ? '停用' : '启用'"
|
:aria-label="item.enabled ? '停用' : '启用'"
|
||||||
@click="toggle(item)"
|
@click="toggle(item)"
|
||||||
/>
|
/>
|
||||||
<button type="button" class="link-action" @click="openEdit(cat.id, item)">编辑</button>
|
<button
|
||||||
|
v-if="canEdit()"
|
||||||
|
type="button"
|
||||||
|
class="link-action"
|
||||||
|
@click="openEdit(cat.id, item)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ import {
|
|||||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||||
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||||||
import { joinPermissionIds } from "/@/views/inquiry/role/rolePermission";
|
import { joinPermissionIds } from "/@/views/inquiry/role/rolePermission";
|
||||||
|
import { usePermission } from "/@/hooks/web/usePermission";
|
||||||
|
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
|
||||||
|
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
|
const canEditRole = () => hasPermission(InquiryBtnPerm.roleEdit);
|
||||||
|
|
||||||
const rows = ref<RoleRow[]>([]);
|
const rows = ref<RoleRow[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -250,7 +255,7 @@ function onDataPermCheck(checked: string[] | { checked: string[] }) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport defer to="#page-topbar-actions">
|
<Teleport defer to="#page-topbar-actions">
|
||||||
<button type="button" class="btn btn-primary" @click="openCreate">
|
<button v-if="canEditRole()" type="button" class="btn btn-primary" @click="openCreate">
|
||||||
<ProtoIcon name="plus" :size="14" />新增角色
|
<ProtoIcon name="plus" :size="14" />新增角色
|
||||||
</button>
|
</button>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
@@ -295,7 +300,7 @@ function onDataPermCheck(checked: string[] | { checked: string[] }) {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<template v-if="row.editable">
|
<template v-if="canEditRole() && row.editable">
|
||||||
<div class="role-row-actions">
|
<div class="role-row-actions">
|
||||||
<button class="link-action" type="button" @click="openEdit(row)">编辑</button>
|
<button class="link-action" type="button" @click="openEdit(row)">编辑</button>
|
||||||
<button class="link-action link-danger" type="button" @click="removeRole(row)">
|
<button class="link-action link-danger" type="button" @click="removeRole(row)">
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* 询价后台页面级权限码:侧栏显隐与「查看」按钮同源。
|
||||||
|
* <p>
|
||||||
|
* 为何:侧栏曾写死全量菜单,且 ensureInquiryHandwrittenRoutes 会把未授权页补进路由,
|
||||||
|
* 导致无 reply:list 等权限的人仍能看到「机器人回复语」。以 Jeecg 权限码为准过滤。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 侧栏路径 → 进入该页至少需要的查看类权限码 */
|
||||||
|
export const INQUIRY_PAGE_VIEW_PERMS: Record<string, string> = {
|
||||||
|
'/inquiry/dashboard': 'inquiry:dashboard:view',
|
||||||
|
'/inquiry/work-order': 'inquiry:workorder:list',
|
||||||
|
'/inquiry/staff-role': 'inquiry:staff:list',
|
||||||
|
'/inquiry/template': 'inquiry:template:view',
|
||||||
|
'/inquiry/bot-reply': 'inquiry:reply:list',
|
||||||
|
'/inquiry-sys/system-setting': 'inquiry:timeout:list',
|
||||||
|
'/inquiry-sys/user': 'inquiry:account:view',
|
||||||
|
'/inquiry-sys/role': 'inquiry:role:view',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 按钮细粒度权限码(与 sys_permission / @RequiresPermissions 对齐) */
|
||||||
|
export const InquiryBtnPerm = {
|
||||||
|
workorderList: 'inquiry:workorder:list',
|
||||||
|
workorderDetail: 'inquiry:workorder:detail',
|
||||||
|
workorderTransition: 'inquiry:workorder:transition',
|
||||||
|
workorderExport: 'inquiry:workorder:export',
|
||||||
|
replyList: 'inquiry:reply:list',
|
||||||
|
replyAdd: 'inquiry:reply:add',
|
||||||
|
replyEdit: 'inquiry:reply:edit',
|
||||||
|
replyDisable: 'inquiry:reply:disable',
|
||||||
|
staffList: 'inquiry:staff:list',
|
||||||
|
staffAdd: 'inquiry:staff:add',
|
||||||
|
staffEdit: 'inquiry:staff:edit',
|
||||||
|
staffDisable: 'inquiry:staff:disable',
|
||||||
|
templateView: 'inquiry:template:view',
|
||||||
|
templateUpload: 'inquiry:template:upload',
|
||||||
|
templateEdit: 'inquiry:template:edit',
|
||||||
|
templateToggle: 'inquiry:template:toggle',
|
||||||
|
accountView: 'inquiry:account:view',
|
||||||
|
accountEdit: 'inquiry:account:edit',
|
||||||
|
roleView: 'inquiry:role:view',
|
||||||
|
roleEdit: 'inquiry:role:edit',
|
||||||
|
timeoutList: 'inquiry:timeout:list',
|
||||||
|
timeoutEdit: 'inquiry:timeout:edit',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前权限码是否允许进入某侧栏路径。
|
||||||
|
* @param path 侧栏 key,如 /inquiry/bot-reply
|
||||||
|
* @param hasPerm hasPermission 回调
|
||||||
|
*/
|
||||||
|
export function canSeeInquiryPage(
|
||||||
|
path: string,
|
||||||
|
hasPerm: (code: string) => boolean,
|
||||||
|
): boolean {
|
||||||
|
const code = INQUIRY_PAGE_VIEW_PERMS[path];
|
||||||
|
if (!code) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return hasPerm(code);
|
||||||
|
}
|
||||||
@@ -25,6 +25,13 @@ import {
|
|||||||
dataPermTitle,
|
dataPermTitle,
|
||||||
normalizeStaffRouteKeys,
|
normalizeStaffRouteKeys,
|
||||||
} from "/@/views/inquiry/shared/dataPermCatalog";
|
} from "/@/views/inquiry/shared/dataPermCatalog";
|
||||||
|
import { usePermission } from "/@/hooks/web/usePermission";
|
||||||
|
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
|
||||||
|
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
|
const canAddStaff = () => hasPermission(InquiryBtnPerm.staffAdd);
|
||||||
|
const canEditStaff = () => hasPermission(InquiryBtnPerm.staffEdit);
|
||||||
|
const canDisableStaff = () => hasPermission(InquiryBtnPerm.staffDisable);
|
||||||
|
|
||||||
type StaffRole = "sales" | "air" | "sea" | "land";
|
type StaffRole = "sales" | "air" | "sea" | "land";
|
||||||
|
|
||||||
@@ -485,10 +492,16 @@ function filterRouteOption(input: string, option: any) {
|
|||||||
<template>
|
<template>
|
||||||
<Teleport defer to="#page-topbar-actions">
|
<Teleport defer to="#page-topbar-actions">
|
||||||
<div class="staff-topbar-actions">
|
<div class="staff-topbar-actions">
|
||||||
<button type="button" class="btn btn-secondary" :disabled="syncing" @click="syncWecom">
|
<button
|
||||||
|
v-if="canEditStaff()"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
:disabled="syncing"
|
||||||
|
@click="syncWecom"
|
||||||
|
>
|
||||||
<ProtoIcon name="refresh" :size="16" />{{ syncing ? "同步中…" : "同步企业微信" }}
|
<ProtoIcon name="refresh" :size="16" />{{ syncing ? "同步中…" : "同步企业微信" }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-primary" @click="openCreate">
|
<button v-if="canAddStaff()" type="button" class="btn btn-primary" @click="openCreate">
|
||||||
<ProtoIcon name="userPlus" :size="16" />新增员工
|
<ProtoIcon name="userPlus" :size="16" />新增员工
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -586,9 +599,16 @@ function filterRouteOption(input: string, option: any) {
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div style="display: flex; align-items: center; gap: 12px">
|
<div style="display: flex; align-items: center; gap: 12px">
|
||||||
<button class="link-action" type="button" @click="openEdit(row)">编辑</button>
|
|
||||||
<button
|
<button
|
||||||
v-if="row.status === 'active'"
|
v-if="canEditStaff()"
|
||||||
|
class="link-action"
|
||||||
|
type="button"
|
||||||
|
@click="openEdit(row)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canDisableStaff() && row.status === 'active'"
|
||||||
class="link-action link-danger"
|
class="link-action link-danger"
|
||||||
type="button"
|
type="button"
|
||||||
@click="removeStaff(row)"
|
@click="removeStaff(row)"
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import {
|
|||||||
getTimeoutListAll,
|
getTimeoutListAll,
|
||||||
saveTimeoutHours,
|
saveTimeoutHours,
|
||||||
} from '/@/views/inquiry/systemSetting/systemSetting.api';
|
} from '/@/views/inquiry/systemSetting/systemSetting.api';
|
||||||
|
import { usePermission } from '/@/hooks/web/usePermission';
|
||||||
|
import { InquiryBtnPerm } from '/@/views/inquiry/shared/inquiryPagePerms';
|
||||||
|
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
|
const canEditTimeout = () => hasPermission(InquiryBtnPerm.timeoutEdit);
|
||||||
|
|
||||||
/** 分块顺序:海运 → 空运 → 陆运(产品确认同页分块) */
|
/** 分块顺序:海运 → 空运 → 陆运(产品确认同页分块) */
|
||||||
const MODE_BLOCKS = [
|
const MODE_BLOCKS = [
|
||||||
@@ -153,7 +158,7 @@ async function commitHours(row) {
|
|||||||
min="0"
|
min="0"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
:value="draftHours[row.id]"
|
:value="draftHours[row.id]"
|
||||||
:disabled="savingId === row.id"
|
:disabled="!canEditTimeout() || savingId === row.id"
|
||||||
@input="onHoursInput(row.id, $event.target.value)"
|
@input="onHoursInput(row.id, $event.target.value)"
|
||||||
@blur="commitHours(row)"
|
@blur="commitHours(row)"
|
||||||
@keydown.enter.prevent="commitHours(row)"
|
@keydown.enter.prevent="commitHours(row)"
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ import {
|
|||||||
summarizeMappingParse,
|
summarizeMappingParse,
|
||||||
type MappingParseSummary,
|
type MappingParseSummary,
|
||||||
} from "/@/views/inquiry/template/previewSample";
|
} from "/@/views/inquiry/template/previewSample";
|
||||||
|
import { usePermission } from "/@/hooks/web/usePermission";
|
||||||
|
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
|
||||||
|
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
|
const canUploadTemplate = () => hasPermission(InquiryBtnPerm.templateUpload);
|
||||||
|
const canEditTemplate = () => hasPermission(InquiryBtnPerm.templateEdit);
|
||||||
|
const canToggleTemplate = () => hasPermission(InquiryBtnPerm.templateToggle);
|
||||||
|
|
||||||
// buildTemplateFileUrl 已由 downloadTemplateFile 替代(鉴权 blob 下载)
|
// buildTemplateFileUrl 已由 downloadTemplateFile 替代(鉴权 blob 下载)
|
||||||
|
|
||||||
@@ -683,7 +690,12 @@ async function downloadPreviewOrOriginal() {
|
|||||||
<template>
|
<template>
|
||||||
<!-- 唯一上传入口:顶栏 -->
|
<!-- 唯一上传入口:顶栏 -->
|
||||||
<Teleport defer to="#page-topbar-actions">
|
<Teleport defer to="#page-topbar-actions">
|
||||||
<button type="button" class="btn btn-primary" @click.stop.prevent="openCreate">
|
<button
|
||||||
|
v-if="canUploadTemplate()"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
@click.stop.prevent="openCreate"
|
||||||
|
>
|
||||||
<ProtoIcon name="upload" :size="16" />上传模板
|
<ProtoIcon name="upload" :size="16" />上传模板
|
||||||
</button>
|
</button>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
@@ -773,8 +785,16 @@ async function downloadPreviewOrOriginal() {
|
|||||||
<button type="button" class="link-action link-muted" @click.stop="openPreview(row)">
|
<button type="button" class="link-action link-muted" @click.stop="openPreview(row)">
|
||||||
预览
|
预览
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="link-action" @click.stop="openEdit(row)">编辑</button>
|
|
||||||
<button
|
<button
|
||||||
|
v-if="canEditTemplate()"
|
||||||
|
type="button"
|
||||||
|
class="link-action"
|
||||||
|
@click.stop="openEdit(row)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canEditTemplate()"
|
||||||
type="button"
|
type="button"
|
||||||
class="link-action"
|
class="link-action"
|
||||||
:disabled="row.parseStatus === '解析中'"
|
:disabled="row.parseStatus === '解析中'"
|
||||||
@@ -788,6 +808,7 @@ async function downloadPreviewOrOriginal() {
|
|||||||
重新解析
|
重新解析
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canToggleTemplate()"
|
||||||
type="button"
|
type="button"
|
||||||
class="link-action"
|
class="link-action"
|
||||||
:class="row.status === 'enabled' ? 'link-danger' : ''"
|
:class="row.status === 'enabled' ? 'link-danger' : ''"
|
||||||
@@ -795,7 +816,12 @@ async function downloadPreviewOrOriginal() {
|
|||||||
>
|
>
|
||||||
{{ row.status === "enabled" ? "禁用" : "启用" }}
|
{{ row.status === "enabled" ? "禁用" : "启用" }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="link-action link-danger" @click.stop="removeTemplate(row)">
|
<button
|
||||||
|
v-if="canEditTemplate()"
|
||||||
|
type="button"
|
||||||
|
class="link-action link-danger"
|
||||||
|
@click.stop="removeTemplate(row)"
|
||||||
|
>
|
||||||
删除
|
删除
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,23 @@ import {
|
|||||||
quantitySummary,
|
quantitySummary,
|
||||||
type DemandField,
|
type DemandField,
|
||||||
} from "/@/views/inquiry/workOrder/demandFields";
|
} from "/@/views/inquiry/workOrder/demandFields";
|
||||||
|
import { usePermission } from "/@/hooks/web/usePermission";
|
||||||
|
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
|
||||||
|
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
|
const canCloseTicket = () => hasPermission(InquiryBtnPerm.workorderTransition);
|
||||||
|
const canRetryTicket = () => hasPermission(InquiryBtnPerm.workorderTransition);
|
||||||
|
const canViewDetail = () =>
|
||||||
|
hasPermission(InquiryBtnPerm.workorderDetail) || hasPermission(InquiryBtnPerm.workorderList);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与主账 TicketStatuses 一致:仅询价中/已报价/协商中/转人工可关到「已关闭」。
|
||||||
|
* 已成交/未成交/已关闭为终态,禁止再显示「关闭工单」(否则点确认会报不允许流转)。
|
||||||
|
*/
|
||||||
|
const CLOSEABLE_STATUSES = new Set(["询价中", "已报价", "协商中", "转人工"]);
|
||||||
|
function canShowCloseForStatus(status: string) {
|
||||||
|
return canCloseTicket() && CLOSEABLE_STATUSES.has(String(status || "").trim());
|
||||||
|
}
|
||||||
|
|
||||||
/** 单条费用明细:名称 + 带币种金额,对齐企微价格明细。 */
|
/** 单条费用明细:名称 + 带币种金额,对齐企微价格明细。 */
|
||||||
type TicketQuoteFee = { name: string; amount: string };
|
type TicketQuoteFee = { name: string; amount: string };
|
||||||
@@ -615,6 +632,10 @@ async function openDetail(row: TicketRow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openClose(row: TicketRow) {
|
function openClose(row: TicketRow) {
|
||||||
|
if (!canShowCloseForStatus(row.status)) {
|
||||||
|
message.warning(`「${row.status}」为终态,不能关闭工单`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
current.value = row;
|
current.value = row;
|
||||||
closeReason.value = "";
|
closeReason.value = "";
|
||||||
closeOpen.value = true;
|
closeOpen.value = true;
|
||||||
@@ -903,7 +924,7 @@ function refresh() {
|
|||||||
<td class="align-top col-actions col-sticky-right">
|
<td class="align-top col-actions col-sticky-right">
|
||||||
<div class="ticket-row-actions">
|
<div class="ticket-row-actions">
|
||||||
<button
|
<button
|
||||||
v-if="row.systemException !== '-' && row.status !== '已关闭'"
|
v-if="canRetryTicket() && row.systemException !== '-' && row.status !== '已关闭'"
|
||||||
class="link-action"
|
class="link-action"
|
||||||
type="button"
|
type="button"
|
||||||
@click="retryTicket(row)"
|
@click="retryTicket(row)"
|
||||||
@@ -911,14 +932,21 @@ function refresh() {
|
|||||||
重试
|
重试
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="row.status !== '已关闭'"
|
v-if="canShowCloseForStatus(row.status)"
|
||||||
class="link-action"
|
class="link-action"
|
||||||
type="button"
|
type="button"
|
||||||
@click="openClose(row)"
|
@click="openClose(row)"
|
||||||
>
|
>
|
||||||
关闭工单
|
关闭工单
|
||||||
</button>
|
</button>
|
||||||
<button class="link-action" type="button" @click="openDetail(row)">查看详情</button>
|
<button
|
||||||
|
v-if="canViewDetail()"
|
||||||
|
class="link-action"
|
||||||
|
type="button"
|
||||||
|
@click="openDetail(row)"
|
||||||
|
>
|
||||||
|
查看详情
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -946,7 +974,12 @@ function refresh() {
|
|||||||
<template #titleExtra>
|
<template #titleExtra>
|
||||||
<span v-if="current" class="ticket-detail-no">{{ current.id }}</span>
|
<span v-if="current" class="ticket-detail-no">{{ current.id }}</span>
|
||||||
<button
|
<button
|
||||||
v-if="current && current.systemException !== '-' && current.status !== '已关闭'"
|
v-if="
|
||||||
|
canRetryTicket() &&
|
||||||
|
current &&
|
||||||
|
current.systemException !== '-' &&
|
||||||
|
current.status !== '已关闭'
|
||||||
|
"
|
||||||
class="link-action"
|
class="link-action"
|
||||||
type="button"
|
type="button"
|
||||||
@click="retryTicket(current)"
|
@click="retryTicket(current)"
|
||||||
|
|||||||
Reference in New Issue
Block a user