Compare commits

..
28 changed files with 1493 additions and 245 deletions
+18 -5
View File
@@ -118,15 +118,28 @@ class HttpLedger:
facts: dict[str, str],
business_line: str = "",
) -> dict[str, Any]:
"""
打主账查 TMS。查价可能慢于默认 10s:单独放宽,仍禁止无限等。
若仍超时,上层 query_tms_resilient 会回读主账:已 persistHit 有价则按成功续办。
"""
body: dict[str, Any] = {"work_order_no": work_order_no, "facts": facts}
line = (business_line or str((facts or {}).get("业务线") or "")).strip()
if line:
body["businessLine"] = line
out = self._post(
"/inquiry/agent/tms/query",
body,
idem=f"tms:{work_order_no}",
)
# TMS 出站 + persistHit 常超过默认 10s,超时会被误判成接口异常。
prev_timeout = self._client.timeout_seconds
self._client.timeout_seconds = max(float(prev_timeout or 10), 45.0)
self._client.close()
try:
out = self._post(
"/inquiry/agent/tms/query",
body,
idem=f"tms:{work_order_no}",
)
finally:
self._client.timeout_seconds = prev_timeout
self._client.close()
if "classification" not in out and not out.get("ok"):
return {
"ok": False,
+12 -6
View File
@@ -1212,6 +1212,7 @@ class AirTextInquiryFlow:
from agent.policy.system_exception import (
confirm_tms_quote_if_tech,
query_tms_resilient,
recover_tms_query_from_ledger,
)
tms = query_tms_resilient(self._ledger, work_order_no=no, facts=facts)
@@ -1238,12 +1239,17 @@ class AirTextInquiryFlow:
):
self.drop_current_bookmark(sender_id)
return "system_exception"
if tms.get("classification") == "TMS_QUERY_INCOMPLETE":
logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification"))
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
sess.phase = "tms_tech"
self._save(sess)
return "tms_tech"
# 主账已落价但回包超时:按成功续办,禁止再报接口异常
recovered = recover_tms_query_from_ledger(self._ledger, work_order_no=no)
if recovered is not None:
tms = recovered
else:
if tms.get("classification") == "TMS_QUERY_INCOMPLETE":
logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification"))
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
sess.phase = "tms_tech"
self._save(sess)
return "tms_tech"
if not tms.get("has_price"):
# ok=true 才是 TMS 已查过、确认没价
self._emit_tms_miss(reply, no)
+13 -6
View File
@@ -390,7 +390,10 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
_ = retrying
no = (sess.work_order_no or "").strip()
if not tms.get("ok"):
from agent.policy.system_exception import confirm_tms_quote_if_tech
from agent.policy.system_exception import (
confirm_tms_quote_if_tech,
recover_tms_query_from_ledger,
)
if confirm_tms_quote_if_tech(
ledger=self._ledger,
@@ -402,11 +405,15 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
):
self.drop_current_bookmark(sess.sender_id)
return "system_exception"
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
sess.phase = "tms_tech"
self._save(sess)
self._park_tms_retry(sess)
return "tms_tech"
recovered = recover_tms_query_from_ledger(self._ledger, work_order_no=no)
if recovered is not None:
tms = recovered
else:
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
sess.phase = "tms_tech"
self._save(sess)
self._park_tms_retry(sess)
return "tms_tech"
self._clear_parked_confirm(sess.sender_id)
if not tms.get("has_price"):
self._emit_tms_miss(reply, no)
+11 -6
View File
@@ -416,6 +416,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
from agent.policy.system_exception import (
confirm_tms_quote_if_tech,
query_tms_resilient,
recover_tms_query_from_ledger,
)
tms = query_tms_resilient(
@@ -443,12 +444,16 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
):
self.drop_current_bookmark(sender_id)
return "system_exception"
if tms.get("classification") == "TMS_QUERY_INCOMPLETE":
logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification"))
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
sess.phase = "tms_tech"
self._save(sess)
return "tms_tech"
recovered = recover_tms_query_from_ledger(self._ledger, work_order_no=no)
if recovered is not None:
tms = recovered
else:
if tms.get("classification") == "TMS_QUERY_INCOMPLETE":
logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification"))
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
sess.phase = "tms_tech"
self._save(sess)
return "tms_tech"
if not tms.get("has_price"):
self._emit_tms_miss(reply, no)
sess.phase = "tms_miss"
+98 -3
View File
@@ -310,8 +310,85 @@ def reply_paused_ticket(*, ticket: Any, reply: ReplyFn) -> None:
reply(copy.system_exception_fallback(kind, names=names))
def _quote_has_usable_total(quote: dict[str, Any] | None) -> bool:
"""报价包是否已有可用于出卡的总价。"""
src = dict(quote or {})
total = str(src.get("total") or src.get("salePrice") or src.get("total_amount") or "").strip()
return bool(total and total != "-")
def recover_tms_query_from_ledger(ledger: Any, *, work_order_no: str) -> dict[str, Any] | None:
"""
查价超时/失败后回读主账:若已 persistHit 落成已报价+销售价,按查价成功续办。
产品口径:主账已经查到价,只是智能体等回包超时,不能标 TMS异常、不能给销售发接口异常,
应直接推当前工单报价。无价才走真正的系统异常。
"""
no = (work_order_no or "").strip()
if not no or ledger is None:
return None
getter = getattr(ledger, "get_for_agent", None)
snap: Any = None
if callable(getter):
try:
snap = getter(work_order_no=no, sender_id="")
except TypeError:
try:
snap = getter(work_order_no=no)
except Exception: # noqa: BLE001
snap = None
except Exception: # noqa: BLE001
snap = None
if not isinstance(snap, dict) or not snap:
getter2 = getattr(ledger, "get_ticket", None)
if callable(getter2):
try:
snap = getter2(work_order_no=no)
except Exception: # noqa: BLE001
snap = None
if not isinstance(snap, dict) or not snap:
return None
status = str(snap.get("status") or "").strip()
tms_hit = snap.get("tms_hit") in (1, "1", True, "true")
sale = str(snap.get("sale_price") or snap.get("salePrice") or "").strip()
quote = dict(snap.get("quote") or {})
tms_quote = dict(snap.get("tms_quote") or snap.get("tmsQuote") or {})
if not _quote_has_usable_total(quote) and _quote_has_usable_total(tms_quote):
quote = dict(tms_quote)
if not _quote_has_usable_total(quote) and sale and sale != "-":
quote = dict(quote)
quote["total"] = sale
quote.setdefault("source", "TMS")
quote.setdefault("source_label", "TMS标准报价")
priced = status == "已报价" and (
tms_hit or (sale and sale != "-") or _quote_has_usable_total(quote)
)
if not priced:
return None
if not _quote_has_usable_total(quote):
return None
logger.warning(
"tms.query 超时/失败但主账已有价,按成功续办 wo=%s total=%s",
no,
quote.get("total"),
)
return {
"ok": True,
"has_price": True,
"classification": "HAS_PRICE",
"quote": quote,
"recovered": True,
"source": "LEDGER_AFTER_TIMEOUT",
}
def query_tms_resilient(ledger: Any, **kwargs: Any) -> dict[str, Any]:
"""TMS 查价:最多 3 次,ok=true(含无价)即成功。"""
"""
TMS 查价:最多 3 次,ok=true(含无价)即成功。
三次仍失败时,再读主账:若查价过程中已落库有价(常见于智能体超时、主账已 persistHit),
按成功回包返回,避免误标 TMS异常。
"""
getter = getattr(ledger, "query_tms", None)
if not callable(getter):
return {"ok": False, "classification": "TMS_TECH_FAILURE", "has_price": False, "quote": {}}
@@ -319,7 +396,14 @@ def query_tms_resilient(ledger: Any, **kwargs: Any) -> dict[str, Any]:
lambda: getter(**kwargs),
is_ok=lambda x: bool((x or {}).get("ok")),
)
return out if isinstance(out, dict) else {"ok": False, "classification": "TMS_TECH_FAILURE"}
result = out if isinstance(out, dict) else {"ok": False, "classification": "TMS_TECH_FAILURE"}
if result.get("ok"):
return result
no = str(kwargs.get("work_order_no") or "").strip()
recovered = recover_tms_query_from_ledger(ledger, work_order_no=no) if no else None
if recovered is not None:
return recovered
return result
def confirm_tms_quote_if_tech(
@@ -332,9 +416,20 @@ def confirm_tms_quote_if_tech(
ticket_status: str = "询价中",
facts: dict[str, Any] | None = None,
) -> bool:
"""技术失败则标 TMS异常并回话告警。返回是否已按系统异常处理。"""
"""
技术失败则标 TMS异常并回话告警。返回是否已按系统异常处理。
主账其实已有价时不再标异常(兜底:调用方应优先走 query_tms_resilient 恢复)。
"""
if not is_tms_tech_failure(tms):
return False
# 最后一道闸:主账其实已有价时禁止再打 TMS异常(正常应已在 query_tms_resilient 恢复)。
if recover_tms_query_from_ledger(ledger, work_order_no=work_order_no) is not None:
logger.warning(
"confirm_tms_quote_if_tech 跳过标异常:主账已有价 wo=%s",
work_order_no,
)
return False
err = str(tms.get("classification") or tms.get("error") or "TMS_TECH_FAILURE").strip()
confirm_system_exception(
event=SystemExceptionEvent(
+17 -3
View File
@@ -178,7 +178,8 @@ _GENERIC_KEYWORDS = frozenset(
_LAND_KEYWORD_ALIASES = {
"国内拼车": ("国内运输拼车", "拼车"),
"国内零担": ("国内运输拼车", "国内长途/零担", "零担"),
"国内整车": ("国内运输整车", "整车"),
# 「整车」不能当国内整车别名:跨境整车 / 中港整车都含这两个字。
"国内整车": ("国内运输整车",),
"跨境整车": ("跨境整车",),
"跨境集拼": ("跨境集拼",),
"跨境零担": ("跨境集拼",),
@@ -198,6 +199,19 @@ def _keyword_needles(kw: str) -> list[str]:
return out
def _needle_in(text: str, needle: str) -> bool:
"""
陆运针:普通包含。单独「整车」不命中跨境/中港,避免国内整车模板抢走跨境单。
"""
hay = (text or "").lower()
n = (needle or "").strip().lower()
if not hay or not n or n not in hay:
return False
if n == "整车" and ("跨境" in hay or "中港" in hay):
return False
return True
def _keyword_score(raw: Any, haystack: str, type_val: str) -> tuple[int, int]:
"""
运输类型上的命中优先,其次全文词长。
@@ -212,11 +226,11 @@ def _keyword_score(raw: Any, haystack: str, type_val: str) -> tuple[int, int]:
if k in _GENERIC_KEYWORDS:
continue
needles = _keyword_needles(kw)
if type_val and any(n in type_val for n in needles):
if type_val and any(_needle_in(type_val, n) for n in needles):
type_hits += 1
length += len(k)
any_hit = True
elif any(n in haystack for n in needles):
elif any(_needle_in(haystack, n) for n in needles):
length += len(k)
any_hit = True
if not any_hit:
@@ -143,6 +143,61 @@ class QuoteTemplateMatchTests(unittest.TestCase):
self.assertIsNotNone(hit)
self.assertEqual(hit.name, "跨境整车")
def test_land_admin_domestic_ftl_keyword_does_not_steal_cross(self) -> None:
"""后台「国内整车」不得靠「整车」二字抢走跨境整车(WO202610030032)。"""
hit = pick_quote_template(
templates=[
{
"templateId": "dom",
"name": "国内陆运整车报价单",
"bizType": "陆运",
"keywords": ["国内整车"],
"isDefault": False,
},
{
"templateId": "xb",
"name": "跨境整车报价单",
"bizType": "陆运",
"keywords": ["东南亚"],
"isDefault": False,
},
],
business_line="LAND",
facts={
"运输类型": "跨境整车",
"线路类别": "东南亚",
"运输分类": "普件(东南亚、中亚、中欧涉及国内+国外段)",
"起运港": "上海",
"目的港": "春疆货场",
},
)
self.assertIsNotNone(hit)
self.assertEqual(hit.name, "跨境整车报价单")
def test_land_admin_domestic_ftl_still_hits_domestic_type(self) -> None:
hit = pick_quote_template(
templates=[
{
"templateId": "dom",
"name": "国内陆运整车报价单",
"bizType": "陆运",
"keywords": ["国内整车"],
"isDefault": False,
},
{
"templateId": "xb",
"name": "跨境整车报价单",
"bizType": "陆运",
"keywords": ["东南亚"],
"isDefault": False,
},
],
business_line="LAND",
facts={"运输类型": "国内运输整车", "线路类别": "国内长途/零担"},
)
self.assertIsNotNone(hit)
self.assertEqual(hit.name, "国内陆运整车报价单")
def test_land_falls_back_to_default(self) -> None:
hit = pick_quote_template(
templates=[
@@ -22,8 +22,11 @@ from agent.policy.system_exception import (
SystemExceptionEvent,
call_with_retries,
confirm_system_exception,
confirm_tms_quote_if_tech,
extract_work_order_nos,
is_tms_tech_failure,
query_tms_resilient,
recover_tms_query_from_ledger,
should_mark_ticket,
ticket_is_paused,
)
@@ -196,6 +199,89 @@ class TestSystemExceptionRules(unittest.TestCase):
self.assertEqual(out.get("classification"), "TMS_TECH_FAILURE")
class TestTmsTimeoutRecoverPriced(unittest.TestCase):
"""查到价但智能体超时:主账已有价则按成功续办,不标 TMS异常。"""
def setUp(self) -> None:
self.ledger = MemoryLedger()
created = self.ledger.create_ticket(
sender_id="sales1",
business_line="AIR",
facts={"起运港": "南京", "目的港": "吉隆坡", "品名": "茶叶"},
)
self.wo = str(created.get("work_order_no") or "")
self.ledger.upsert_quote(
work_order_no=self.wo,
quote={
"total": "CNY 5001.00",
"currency": "CNY",
"source": "TMS",
"source_label": "TMS标准报价",
},
to_status="已报价",
)
def test_recover_from_ledger_when_already_quoted(self) -> None:
got = recover_tms_query_from_ledger(self.ledger, work_order_no=self.wo)
self.assertIsNotNone(got)
assert got is not None
self.assertTrue(got.get("ok"))
self.assertTrue(got.get("has_price"))
self.assertTrue(got.get("recovered"))
self.assertIn("5001", str((got.get("quote") or {}).get("total") or ""))
def test_query_resilient_recovers_after_tech_fail(self) -> None:
self.ledger.query_tms = ( # type: ignore[method-assign]
lambda **_kwargs: {
"ok": False,
"classification": "TMS_TECH_FAILURE",
"has_price": False,
"quote": {},
}
)
out = query_tms_resilient(
self.ledger, work_order_no=self.wo, facts={"起运港": "南京", "目的港": "吉隆坡"}
)
self.assertTrue(out.get("ok"))
self.assertTrue(out.get("has_price"))
self.assertTrue(out.get("recovered"))
self.assertEqual(out.get("classification"), "HAS_PRICE")
def test_confirm_skips_mark_when_ledger_priced(self) -> None:
replies: list[str] = []
marked = confirm_tms_quote_if_tech(
ledger=self.ledger,
reply=replies.append,
work_order_no=self.wo,
tms={"ok": False, "classification": "TMS_TECH_FAILURE"},
sender_id="sales1",
ticket_status="询价中",
)
self.assertFalse(marked)
self.assertEqual(replies, [])
snap = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
self.assertFalse(str(snap.get("system_exception") or "").strip())
def test_true_tech_fail_still_marks_when_no_price(self) -> None:
created = self.ledger.create_ticket(
sender_id="sales2",
business_line="AIR",
facts={"起运港": "南京", "目的港": "吉隆坡", "品名": "茶叶"},
)
wo2 = str(created.get("work_order_no") or "")
replies: list[str] = []
marked = confirm_tms_quote_if_tech(
ledger=self.ledger,
reply=replies.append,
work_order_no=wo2,
tms={"ok": False, "classification": "TMS_TECH_FAILURE"},
sender_id="sales2",
ticket_status="询价中",
)
self.assertTrue(marked)
self.assertTrue(any("TMS接口异常" in x for x in replies))
class TestConfirmSystemException(unittest.TestCase):
def setUp(self) -> None:
self.ledger = MemoryLedger()
@@ -5,10 +5,14 @@ import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.SecurityUtils;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.modules.inquiry.domain.TicketStatuses;
import org.jeecg.modules.inquiry.entity.InquiryQuote;
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import org.jeecg.modules.inquiry.entity.InquiryTicketStatusLog;
import org.jeecg.modules.inquiry.mapper.InquiryQuoteMapper;
import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper;
import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper;
import org.jeecg.modules.inquiry.service.IInquiryDashboardService;
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
import org.springframework.beans.factory.annotation.Autowired;
@@ -22,6 +26,7 @@ import java.time.LocalTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
@@ -34,8 +39,11 @@ import java.util.stream.Collectors;
/**
* 看板实时聚合实现(无快照依赖)。
* <p>
* 计算口径:询价总数、待处理、TMS 命中率、异常完成率、手工报价占比、
* 已成交、成交率、反馈率;内部协同时长本期无协同表时返回「-」。
* 九指标与原型看板一致(5+4 栅格):询价总量、报价工单、标准报价命中率、
* 平均报价销售额、手工工单占比、待处理工单、报价成功率、报价有效率、
* 内部报价平均响应。问号 tip 只写计算公式,禁止「本期无协同明细 / N单无勾选…」占位。
* 内部报价平均响应 = AVG(首次 PRODUCT 报价时间 − 分发时间),
* 分发=bind_collab_group/activate_air_group。
* 人员排名与工单列表同一套发起人解析(企微 id → 员工姓名),禁止只认库内空 sales_name。
* 成本趋势:近 7 日工单按线路聚合均价(成本价优先,缺省用销售价),非浏览器直连 TMS。
* 线程:只读查询,无写锁。
@@ -46,6 +54,9 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("MM-dd");
private static final int COST_ROUTE_LIMIT = 16;
/** 协同「分发」:海运/陆运建群、空运群激活。 */
private static final List<String> COLLAB_DISPATCH_ACTIONS = Arrays.asList(
"bind_collab_group", "activate_air_group");
private static final String[] COST_COLORS = {
"#2563eb", "#3b82f6", "#0891b2", "#0e7490", "#6366f1", "#8b5cf6",
"#a855f7", "#c026d3", "#db2777", "#e11d48", "#dc2626", "#ea580c",
@@ -62,6 +73,10 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
private InquiryTicketMapper ticketMapper;
@Autowired
private InquiryStaffRoleMapper staffRoleMapper;
@Autowired
private InquiryTicketStatusLogMapper statusLogMapper;
@Autowired
private InquiryQuoteMapper quoteMapper;
@Override
public Map<String, Object> metrics(String range) {
@@ -94,28 +109,24 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
long pending = total - deal - lost - closed;
long tmsHit = tickets.stream().filter(t -> t.getTmsHit() != null && t.getTmsHit() == 1).count();
long manual = tickets.stream().filter(t -> t.getManualQuote() != null && t.getManualQuote() == 1).count();
long hasEventExc = tickets.stream().filter(t -> hasExc(t.getEventException())).count();
long hasSysExc = tickets.stream().filter(t -> hasExc(t.getSystemException())).count();
long abnormalTotal = hasEventExc + hasSysExc;
// 简化:已关闭且曾有异常视为异常处理完成
long abnormalDone = tickets.stream()
.filter(t -> TicketStatuses.CLOSED.equals(t.getStatus()))
.filter(t -> hasExc(t.getEventException()) || hasExc(t.getSystemException())
|| (t.getCloseReason() != null && t.getCloseReason().contains("异常")))
.count();
String avgResp = avgCollabResponse(tickets);
String avgSale = avgHighlightedSale(tickets);
List<Map<String, Object>> metricCards = new ArrayList<>();
metricCards.add(card("inquiry", "询价总数", "周期内询价工单总数", String.valueOf(total), false));
metricCards.add(card("pending", "待处理工单", "工单总数 − 已成交 − 未成交 − 已关闭", pending + " 单", false));
// 顺序对齐原型 5 列栅格:上排总量/报价/TMS/销售额/手工,下排待处理/成功率/有效率/响应
metricCards.add(card("inquiry", "询价总量", "周期内询价工单总数", String.valueOf(total), false));
metricCards.add(card("quoted", "报价工单", "已报价工单数(已报价 + 议价中 + 已成交 + 未成交)",
quoted + " 单", false));
metricCards.add(card("tms", "标准报价命中率", "命中 TMS 标准价的询价数 ÷ 询价总数 × 100%", pct(tmsHit, total), false));
metricCards.add(card("abnormal", "异常处理完成率", "已关闭异常工单数 ÷ 异常工单总数 × 100%",
abnormalTotal == 0 ? "100%" : pct(abnormalDone, Math.max(abnormalTotal, 1)), false));
metricCards.add(card("manual", "手工报价占比", "人工报价的询价数 ÷ 询价总数 × 100%", pct(manual, total), false));
metricCards.add(card("accepted", "已成交工单", "状态=已成交的工单数量", String.valueOf(deal), false));
metricCards.add(card("dealRate", "报价成交率", "已成交数 ÷ 已报价相关数 × 100%", pct(deal, Math.max(quoted, 1)), false));
metricCards.add(card("avgSale", "平均报价销售额", "AVG(已勾选报价的销售价)", avgSale, false));
metricCards.add(card("manual", "手工工单占比", "人工报价的询价数 ÷ 询价总数 × 100%", pct(manual, total), false));
metricCards.add(card("pending", "待处理工单", "工单总数 − 已成交 − 未成交 − 已关闭", pending + " 单", false));
metricCards.add(card("dealRate", "报价成功率", "已成交数 ÷ 已报价数 × 100%", pct(deal, Math.max(quoted, 1)), false));
long feedbackNumer = deal + lost;
metricCards.add(card("feedback", "报价反馈率", "(已成交 + 未成交)÷ 工单数 × 100%", pct(feedbackNumer, total), false));
metricCards.add(card("avgResp", "内部协同平均响应时长", "本期无协同明细表,暂无数据", "-", true));
metricCards.add(card("feedback", "报价有效率", "(已成交 + 未成交)÷ 工单数 × 100%", pct(feedbackNumer, total), false));
metricCards.add(card("avgResp", "内部报价平均响应",
"AVG(首次有效回复时间 − 分发时间)", avgResp, true));
// 近 7 日趋势 + 成本:一次拉周内工单,避免 7 次 count
Date weekFrom = Date.from(today.minusDays(6).atStartOfDay(ZONE).toInstant());
@@ -416,10 +427,6 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
return time.toInstant().atZone(ZONE).toLocalDate();
}
private static boolean hasExc(String v) {
return v != null && !v.trim().isEmpty() && !"-".equals(v.trim());
}
private static String pct(long numer, long denom) {
if (denom <= 0) {
return "0.0%";
@@ -428,6 +435,217 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
return p.toPlainString() + "%";
}
/**
* 平均报价销售额:勾选报价(highlighted=1)的销售价平均。
* <p>
* 金额优先报价 priceText,没有再用工单 salePrice。没有可解析样本返回「-」,
* 禁止写成 0.0%(这不是比率)。问号文案由调用方固定为公式,这里不算 tip。
*/
private String avgHighlightedSale(List<InquiryTicket> tickets) {
if (tickets == null || tickets.isEmpty()) {
return "-";
}
List<String> nos = tickets.stream()
.filter(t -> t != null && StringUtils.isNotBlank(t.getWorkOrderNo()))
.map(InquiryTicket::getWorkOrderNo)
.distinct()
.collect(Collectors.toList());
if (nos.isEmpty()) {
return "-";
}
Map<String, String> ticketSale = new HashMap<>();
for (InquiryTicket t : tickets) {
if (t == null || StringUtils.isBlank(t.getWorkOrderNo())) {
continue;
}
if (StringUtils.isNotBlank(t.getSalePrice())) {
ticketSale.put(t.getWorkOrderNo().trim(), t.getSalePrice());
}
}
Map<String, String> saleByWo = new HashMap<>();
List<InquiryQuote> quotes = quoteMapper.selectList(
new LambdaQueryWrapper<InquiryQuote>()
.in(InquiryQuote::getWorkOrderNo, nos)
.eq(InquiryQuote::getHighlighted, 1));
if (quotes != null) {
for (InquiryQuote q : quotes) {
if (q == null || StringUtils.isBlank(q.getWorkOrderNo())) {
continue;
}
String wo = q.getWorkOrderNo().trim();
String amt = firstNonBlank(q.getPriceText(), ticketSale.get(wo));
if (StringUtils.isNotBlank(amt)) {
saleByWo.put(wo, amt);
}
}
}
BigDecimal sum = BigDecimal.ZERO;
int n = 0;
for (String amt : saleByWo.values()) {
BigDecimal money = parseMoney(amt);
if (money == null) {
continue;
}
sum = sum.add(money);
n++;
}
if (n <= 0) {
return "-";
}
return formatAvgSale(sum.divide(BigDecimal.valueOf(n), 2, RoundingMode.HALF_UP));
}
/** 平均销售额展示:整数不留小数,否则最多两位。 */
static String formatAvgSale(BigDecimal avg) {
if (avg == null) {
return "-";
}
BigDecimal stripped = avg.stripTrailingZeros();
if (stripped.scale() <= 0) {
return stripped.toPlainString();
}
return avg.setScale(2, RoundingMode.HALF_UP).toPlainString();
}
private static String firstNonBlank(String a, String b) {
if (StringUtils.isNotBlank(a) && !"-".equals(a.trim())) {
return a.trim();
}
if (StringUtils.isNotBlank(b) && !"-".equals(b.trim())) {
return b.trim();
}
return "";
}
/**
* 内部协同平均响应时长。
* <p>
* 分发时间:状态日志最早的 bind_collab_group / activate_air_group;
* 首次有效回复:分发之后最早的 PRODUCT 报价(航线/产品手工价,不含销售调价与 TMS)。
* 无样本返回「-」。
*/
private String avgCollabResponse(List<InquiryTicket> tickets) {
if (tickets == null || tickets.isEmpty()) {
return "-";
}
List<String> nos = tickets.stream()
.filter(t -> t != null && StringUtils.isNotBlank(t.getWorkOrderNo()))
.map(InquiryTicket::getWorkOrderNo)
.distinct()
.collect(Collectors.toList());
if (nos.isEmpty()) {
return "-";
}
Map<String, Date> dispatchByWo = loadEarliestDispatch(nos);
if (dispatchByWo.isEmpty()) {
return "-";
}
Map<String, List<Date>> productTimesByWo = loadProductQuoteTimes(
new ArrayList<>(dispatchByWo.keySet()));
long sumMs = 0L;
int samples = 0;
for (Map.Entry<String, Date> e : dispatchByWo.entrySet()) {
Date dispatch = e.getValue();
if (dispatch == null) {
continue;
}
Date reply = firstReplyAfter(productTimesByWo.get(e.getKey()), dispatch);
if (reply == null) {
continue;
}
long ms = reply.getTime() - dispatch.getTime();
if (ms < 0) {
continue;
}
sumMs += ms;
samples++;
}
if (samples <= 0) {
return "-";
}
return formatDurationMs(sumMs / samples);
}
private Map<String, Date> loadEarliestDispatch(List<String> workOrderNos) {
Map<String, Date> out = new HashMap<>();
if (workOrderNos == null || workOrderNos.isEmpty()) {
return out;
}
List<InquiryTicketStatusLog> logs = statusLogMapper.selectList(
new LambdaQueryWrapper<InquiryTicketStatusLog>()
.in(InquiryTicketStatusLog::getWorkOrderNo, workOrderNos)
.in(InquiryTicketStatusLog::getAction, COLLAB_DISPATCH_ACTIONS)
.orderByAsc(InquiryTicketStatusLog::getCreateTime));
if (logs == null) {
return out;
}
for (InquiryTicketStatusLog log : logs) {
if (log == null || log.getCreateTime() == null || StringUtils.isBlank(log.getWorkOrderNo())) {
continue;
}
out.putIfAbsent(log.getWorkOrderNo().trim(), log.getCreateTime());
}
return out;
}
private Map<String, List<Date>> loadProductQuoteTimes(List<String> workOrderNos) {
Map<String, List<Date>> out = new HashMap<>();
if (workOrderNos == null || workOrderNos.isEmpty()) {
return out;
}
List<InquiryQuote> quotes = quoteMapper.selectList(
new LambdaQueryWrapper<InquiryQuote>()
.in(InquiryQuote::getWorkOrderNo, workOrderNos)
.eq(InquiryQuote::getSource, "PRODUCT")
.orderByAsc(InquiryQuote::getCreateTime));
if (quotes == null) {
return out;
}
for (InquiryQuote q : quotes) {
if (q == null || q.getCreateTime() == null || StringUtils.isBlank(q.getWorkOrderNo())) {
continue;
}
out.computeIfAbsent(q.getWorkOrderNo().trim(), k -> new ArrayList<>()).add(q.getCreateTime());
}
return out;
}
/** 分发之后的首条 PRODUCT 报价时间;没有则 null。 */
static Date firstReplyAfter(List<Date> times, Date dispatch) {
if (times == null || times.isEmpty() || dispatch == null) {
return null;
}
long from = dispatch.getTime();
for (Date t : times) {
if (t != null && t.getTime() >= from) {
return t;
}
}
return null;
}
/**
* 毫秒 → 展示串:不足 1 分钟用秒;否则 XmYs / XhYm。
* 单测可直接调。
*/
static String formatDurationMs(long ms) {
if (ms < 0) {
return "-";
}
long totalSec = ms / 1000L;
if (totalSec < 60) {
return totalSec + "s";
}
long totalMin = totalSec / 60L;
long sec = totalSec % 60L;
if (totalMin < 60) {
return totalMin + "m" + sec + "s";
}
long hours = totalMin / 60L;
long min = totalMin % 60L;
return hours + "h" + min + "m";
}
private static Map<String, Object> card(String key, String label, String tip, String value, boolean isTime) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("key", key);
@@ -646,13 +646,13 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
/**
* 查找已缓存预览图。
* 只用 v2 文件名:旧版 LibreOffice 直转 PNG 可能非等比拉伸,废弃 *-snapshot.png。
* v5:不再经 POI 改打印属性,避免主题蓝底/合并条带被拆格。
*/
private Path findCachedSnapshot(String versionId) throws Exception {
Path previewDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH, "preview");
Files.createDirectories(previewDir);
Path cachedPng = previewDir.resolve(versionId + "-snapshot-v4.png");
Path cachedPdf = previewDir.resolve(versionId + "-snapshot-v4.pdf");
Path cachedPng = previewDir.resolve(versionId + "-snapshot-v5.png");
Path cachedPdf = previewDir.resolve(versionId + "-snapshot-v5.pdf");
if (Files.isRegularFile(cachedPng) && Files.size(cachedPng) > 0) {
return cachedPng;
}
@@ -665,8 +665,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
private Path generateAndCacheSnapshot(InquiryQuoteTemplateVersion ver) throws Exception {
Path previewDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH, "preview");
Files.createDirectories(previewDir);
Path cachedPng = previewDir.resolve(ver.getId() + "-snapshot-v4.png");
Path cachedPdf = previewDir.resolve(ver.getId() + "-snapshot-v4.pdf");
Path cachedPng = previewDir.resolve(ver.getId() + "-snapshot-v5.png");
Path cachedPdf = previewDir.resolve(ver.getId() + "-snapshot-v5.pdf");
Path source = resolveObjectPath(ver.getObjectKey());
QuoteTemplateSnapshotRenderer.PreviewBytes rendered = snapshotRenderer.render(source, ver.getId());
Path target = rendered.isImage() ? cachedPng : cachedPdf;
@@ -123,6 +123,8 @@ public final class QuoteTemplateExcelSupport {
FILL_BY_LABEL.put("始发站", "origin");
FILL_BY_LABEL.put("目的地", "destination");
FILL_BY_LABEL.put("货物品名", "commodity");
// 陆运跨境集拼表头是「货物类型」,与货物品名同一事实码;禁止用「货物品名」前缀误匹配后写进蓝底表头格
FILL_BY_LABEL.put("货物类型", "commodity");
FILL_BY_LABEL.put("车型/数量", "vehicle_qty");
FILL_BY_LABEL.put("运输类型", "运输类型");
FILL_BY_LABEL.put("线路类别", "线路类别");
@@ -667,6 +669,7 @@ public final class QuoteTemplateExcelSupport {
continue;
}
}
cellRef = redirectLandHeaderBandMapping(wb, cellRef);
writeCell(wb, cellRef, String.valueOf(val));
}
}
@@ -2712,21 +2715,81 @@ public final class QuoteTemplateExcelSupport {
}
/**
* 线路数据起点:若 mapping 的 dataStartRow 落在「4.2米/3T」类车型表头,下移一行。
* 线路数据起点:若 mapping 的 dataStartRow 落在「4.2米/3T」或「始发地/元/KG」类表头,下移到空白数据行。
* 跨境集拼:蓝底「始发地 | 目的地 | 2-500KG…」是表头,禁止当第一行数据写(会把蓝底盖成白底并把「始发地」改成城市名)。
*/
static int resolveRouteDataStartRow(Sheet sheet, int rowIdx0, int headerRow1Based) {
if (sheet == null || rowIdx0 < 0) {
return rowIdx0;
}
if (rowLooksLikeVehicleHeader(sheet, rowIdx0)) {
return rowIdx0 + 1;
int cur = rowIdx0;
for (int i = 0; i < 3; i++) {
if (rowLooksLikeVehicleHeader(sheet, cur) || rowLooksLikeLandRouteMatrixHeader(sheet, cur)) {
cur++;
continue;
}
break;
}
// headerRow 下一行若是车型副表头,再下一行才是数据
int header0 = headerRow1Based > 0 ? headerRow1Based - 1 : -1;
if (header0 >= 0 && rowIdx0 == header0 + 1 && rowLooksLikeVehicleHeader(sheet, rowIdx0)) {
return rowIdx0 + 1;
if (header0 >= 0 && cur == header0) {
cur = header0 + 1;
if (rowLooksLikeVehicleHeader(sheet, cur) || rowLooksLikeLandRouteMatrixHeader(sheet, cur)) {
cur++;
}
}
return rowIdx0;
return cur;
}
/**
* 陆运线路矩阵表头:同行有「始发地+目的地」,或「元/KG」分档,或燃油/报关费列名。
*/
static boolean rowLooksLikeLandRouteMatrixHeader(Sheet sheet, int rowIdx) {
if (sheet == null || rowIdx < 0) {
return false;
}
Row row = sheet.getRow(rowIdx);
if (row == null) {
return false;
}
DataFormatter fmt = new DataFormatter();
boolean origin = false;
boolean dest = false;
int feeHits = 0;
for (Cell cell : row) {
if (cell == null) {
continue;
}
String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)).replaceAll("\\s+", "");
if (text.isEmpty()) {
continue;
}
if ("始发地".equals(text) || "始发站".equals(text)) {
origin = true;
}
if ("目的地".equals(text)) {
dest = true;
}
if (text.contains("元/KG") || text.contains("元/kg") || text.contains("最低收费")
|| text.contains("燃油附加") || text.contains("报关费") || text.contains("提货费")
|| text.contains("口岸") || "税率".equals(text) || "币种".equals(text)) {
feeHits++;
}
}
return (origin && dest) || feeHits >= 2;
}
/** 线路矩阵单个表头格:始发地、目的地、分档单价、费种名。 */
static boolean isLandRouteMatrixHeaderCell(String text) {
if (StringUtils.isBlank(text)) {
return false;
}
String t = text.replaceAll("\\s+", "").trim();
if ("始发地".equals(t) || "始发站".equals(t) || "目的地".equals(t) || "最低收费".equals(t)
|| "币种".equals(t) || "税率".equals(t) || "时效".equals(t)) {
return true;
}
return t.contains("元/KG") || t.contains("元/kg") || t.contains("燃油附加") || t.contains("报关费")
|| t.contains("提货费") || t.contains("口岸核放");
}
/** 行内出现「米/」「尺」「3T」「5T」等车型表头文案。 */
@@ -3077,6 +3140,12 @@ public final class QuoteTemplateExcelSupport {
if (isYellowFixed(cell)) {
return;
}
String exist = readCellText(cell);
// 蓝底/费用表头格禁止当数据写:跨境集拼「始发地」被写成「上海」会拆掉主题蓝条
if (isFeeTableHeader(exist) || isLandInquiryHeaderBandLabel(exist)
|| isLandRouteMatrixHeaderCell(exist) || isSectionTitle(sheet, cell)) {
return;
}
writeNumericOrText(cell, value);
// 映射表写出的备注同样要换行,否则只看得见半截
applyRemarkWrap(sheet, row, cell, value);
@@ -4261,7 +4330,8 @@ public final class QuoteTemplateExcelSupport {
// 标签格「报价编号:」只换冒号后的值,避免整格被工单号顶掉看起来像残缺
String existing = readCellText(cell);
// 映射点常落在合并标签格 / 20GP 表头上,不能把原字盖掉
if (isFeeTableHeader(existing) || isLabelLike(existing) || isSectionTitle(sheet, cell)) {
if (isFeeTableHeader(existing) || isLabelLike(existing) || isSectionTitle(sheet, cell)
|| isLandInquiryHeaderBandLabel(existing)) {
return;
}
int colon = Math.max(existing.indexOf(':'), existing.indexOf(':'));
@@ -4362,8 +4432,13 @@ public final class QuoteTemplateExcelSupport {
* 标签右侧的可填格。海运询价单标签常是 E4:G4 合并,值在 H4:J4;
* 不能再取合并区内的 F4,否则会把「起运港」四字盖掉。
* 表头「币别」右侧是 20GP,对不上就空着,不要映到箱型列。
* 陆运跨境集拼等:蓝底「业务员/客户名称/始发地/货物类型」是表头,值在下一空白行,禁止写进表头格把蓝底盖掉。
*/
private static String resolveFillTarget(Sheet sheet, Cell labelCell, String sheetName, String text, DataFormatter fmt) {
String landBandRef = resolveLandHeaderBandFillTarget(sheet, labelCell, sheetName, text);
if (StringUtils.isNotBlank(landBandRef)) {
return landBandRef;
}
int idx = Math.max(text.indexOf(':'), text.indexOf(':'));
// 「RFQ No:」或冒号后已有样例:值写在本格冒号后,不跨到右侧单选/标题合并格
// 注意:CellReference(Cell) 的 formatAsString 可能已带 sheet 名,禁止再拼 sheetName! 否则变成 Sheet2!Sheet2!B6
@@ -4425,6 +4500,109 @@ public final class QuoteTemplateExcelSupport {
return wideEmpty != null ? wideEmpty : firstEmpty;
}
/**
* 陆运询价条带:同一行多个蓝底短标签(业务员、客户名称、始发地、货物类型),
* 值写在该标签合并区正下方空白行,不写进表头格,以免蓝底被盖成白底。
*
* @return 下一行可填格引用;不是条带表头则空
*/
static String resolveLandHeaderBandFillTarget(Sheet sheet, Cell labelCell, String sheetName, String text) {
if (sheet == null || labelCell == null || StringUtils.isBlank(text)) {
return null;
}
if (!isLandInquiryHeaderBandLabel(text)) {
return null;
}
int rowIdx = labelCell.getRowIndex();
if (!rowLooksLikeLandInquiryHeaderBand(sheet, rowIdx)) {
return null;
}
int firstCol = labelCell.getColumnIndex();
for (int i = 0; i < sheet.getNumMergedRegions(); i++) {
org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(i);
if (range.isInRange(rowIdx, labelCell.getColumnIndex())) {
firstCol = range.getFirstColumn();
break;
}
}
int valueRow = rowIdx + 1;
// 表头下若还有车型副表头,再下一行才是询价条带值(与线路矩阵无关的窄条)
if (rowLooksLikeVehicleHeader(sheet, valueRow)) {
valueRow++;
}
Row next = sheet.getRow(valueRow);
if (next == null) {
next = sheet.createRow(valueRow);
}
int[] dest = resolveMergeOrigin(sheet, valueRow, firstCol);
Cell cand = next.getCell(dest[1]);
String exist = readCellText(cand);
if (isFeeTableHeader(exist) || isLabelLike(exist) || isLandInquiryHeaderBandLabel(exist)
|| isSectionTitle(sheet, cand) || isUnsuitableValueCell(exist)) {
return null;
}
return sheetName + "!" + new CellReference(dest[0], dest[1]).formatAsString();
}
/**
* 跨境集拼询价条带上的短标签。不含「时效」:费用矩阵也有同名列,不能整行当下表头。
*/
static boolean isLandInquiryHeaderBandLabel(String text) {
if (StringUtils.isBlank(text)) {
return false;
}
String compact = text.replace(":", "").replace(":", "").replaceAll("\\s+", "").trim();
return "业务员".equals(compact) || "客户名称".equals(compact) || "始发地".equals(compact)
|| "货物类型".equals(compact) || "条款".equals(compact) || "发货日期".equals(compact);
}
/**
* 一行里出现不少于 3 个询价条带标签,视为陆运蓝底表头行(截图正确版第 5 行)。
*/
static boolean rowLooksLikeLandInquiryHeaderBand(Sheet sheet, int rowIdx) {
if (sheet == null || rowIdx < 0) {
return false;
}
Row row = sheet.getRow(rowIdx);
if (row == null) {
return false;
}
DataFormatter fmt = new DataFormatter();
int hits = 0;
for (Cell cell : row) {
if (cell == null) {
continue;
}
String t = StringUtils.trimToEmpty(fmt.formatCellValue(cell));
if (isLandInquiryHeaderBandLabel(t)) {
hits++;
}
}
return hits >= 3;
}
/**
* mapping.fields 若误指蓝底询价条带表头格,改写到正下方空白行。
*/
static String redirectLandHeaderBandMapping(Workbook wb, String cellRef) {
ParsedRef pref = ParsedRef.parse(cellRef);
if (pref == null || wb == null) {
return cellRef;
}
Sheet sh = wb.getSheet(pref.sheet);
if (sh == null && wb.getNumberOfSheets() > 0) {
sh = wb.getSheetAt(0);
}
if (sh == null) {
return cellRef;
}
Row row = sh.getRow(pref.row);
Cell cell = row == null ? null : row.getCell(pref.col);
String text = readCellText(cell);
String redirected = resolveLandHeaderBandFillTarget(sh, cell, sh.getSheetName(), text);
return StringUtils.isNotBlank(redirected) ? redirected : cellRef;
}
/**
* 映射点不够时,按标签把询价七项 / 合计写到标签右侧空格。
* 币别若落在费用表头行则跳过,避免 CNY 盖掉 20GP。
@@ -4753,6 +4931,10 @@ public final class QuoteTemplateExcelSupport {
// 禁止反向:词典标签以格子开头(「整柜或拼柜」startsWith「整柜」),
// 否则第一次写入的「整柜」会被二次扫描当成标签,跨栏写进送货地址(WO202609240027)。
if (dk.length() >= 3 && compact.length() >= dk.length() && compact.startsWith(dk)) {
// 「货物类型」不能被「货物品名」前缀命中后写进蓝底表头
if ("货物品名".equals(dk) && compact.startsWith("货物类型")) {
continue;
}
return e.getValue();
}
}
@@ -145,7 +145,8 @@ public final class QuoteTemplateMatcher {
static {
LAND_KEYWORD_ALIASES.put("国内拼车", new String[] {"国内运输拼车", "拼车"});
LAND_KEYWORD_ALIASES.put("国内零担", new String[] {"国内运输拼车", "国内长途/零担", "零担"});
LAND_KEYWORD_ALIASES.put("国内整车", new String[] {"国内运输整车", "整车"});
// 「整车」不能当国内整车别名:跨境整车 / 中港整车都含这两个字,会把跨境单配成国内表。
LAND_KEYWORD_ALIASES.put("国内整车", new String[] {"国内运输整车"});
LAND_KEYWORD_ALIASES.put("跨境整车", new String[] {"跨境整车"});
LAND_KEYWORD_ALIASES.put("跨境集拼", new String[] {"跨境集拼"});
LAND_KEYWORD_ALIASES.put("跨境零担", new String[] {"跨境集拼"});
@@ -155,8 +156,7 @@ public final class QuoteTemplateMatcher {
if (text == null || text.isEmpty() || kw == null || kw.isEmpty()) {
return false;
}
String hay = text.toLowerCase(Locale.ROOT);
if (hay.contains(kw.toLowerCase(Locale.ROOT))) {
if (containsLandNeedle(text, kw)) {
return true;
}
String[] aliases = LAND_KEYWORD_ALIASES.get(kw);
@@ -164,13 +164,31 @@ public final class QuoteTemplateMatcher {
return false;
}
for (String alias : aliases) {
if (hay.contains(alias.toLowerCase(Locale.ROOT))) {
if (containsLandNeedle(text, alias)) {
return true;
}
}
return false;
}
/**
* 陆运针:普通 contains;单独「整车」不命中跨境/中港(避免国内整车模板抢走跨境单)。
*/
static boolean containsLandNeedle(String text, String needle) {
if (text == null || needle == null || needle.isEmpty()) {
return false;
}
String hay = text.toLowerCase(Locale.ROOT);
String n = needle.toLowerCase(Locale.ROOT);
if (!hay.contains(n)) {
return false;
}
if ("整车".equals(n) && (hay.contains("跨境") || hay.contains("中港"))) {
return false;
}
return true;
}
static boolean skipKeyword(String kw) {
if (kw == null || kw.isEmpty()) {
return true;
@@ -5,11 +5,6 @@ import org.apache.commons.lang3.StringUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.poi.ss.usermodel.PrintSetup;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@@ -18,7 +13,6 @@ import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.charset.StandardCharsets;
@@ -36,6 +30,7 @@ import java.util.stream.Stream;
* 为何不用 LibreOffice 直接转 PNG:其 pdf→png 滤镜在部分版本会把页内容非等比拉伸,
* 后台预览出现「字被拉高/压扁」。PDFBox 按页 MediaBox 均匀换算 DPI,保证 1:1 像素比例。
* 为何放在主账:后台预览不应依赖智能体进程;与出单 PDF 错开靠文件锁互斥。
* 禁止 POI 先改打印再转:会丢掉 WPS 主题色与合并条带(跨境集拼蓝底表头拆成独立格)。
* 禁止:接到企微回调 / 智能体出单热路径;禁止本类起第二台 soffice 并行。
* 副作用:写临时目录与预览缓存;短阻塞当前 HTTP 线程(仅管理端预览)。
*/
@@ -89,8 +84,8 @@ public class QuoteTemplateSnapshotRenderer {
try {
Path localXlsx = workDir.resolve("source.xlsx");
Files.copy(xlsxPath, localXlsx, StandardCopyOption.REPLACE_EXISTING);
// 预览专用打印版式:按表内容宽高选横/竖,避免宽表硬塞竖版 A4 显得挤扁
preparePreviewPrintLayout(localXlsx);
// 禁止 POI 重写后再转:主题色/合并条带会被拆成独立格子(跨境集拼蓝底表头)。
// 原表打印属性原样交给 soffice,保证后台截图与 WPS 打开一致。
Path pdf = convert(soffice, localXlsx, workDir, "pdf:calc_pdf_Export", ".pdf");
if (pdf == null || !Files.isRegularFile(pdf)) {
throw new IllegalStateException("LibreOffice 未生成 PDF");
@@ -112,80 +107,6 @@ public class QuoteTemplateSnapshotRenderer {
}
}
/**
* 预览前改写打印属性:按已用区域宽高比选横向/纵向 A4,并按页适配。
* 只动打印,不改单元格业务内容;写回同一 xlsx 供 soffice 使用。
*/
private void preparePreviewPrintLayout(Path xlsx) throws IOException {
try (InputStream in = Files.newInputStream(xlsx);
Workbook wb = WorkbookFactory.create(in)) {
for (int s = 0; s < wb.getNumberOfSheets(); s++) {
Sheet sheet = wb.getSheetAt(s);
if (sheet == null) {
continue;
}
boolean landscape = preferLandscape(sheet);
sheet.setMargin(Sheet.LeftMargin, 0.4);
sheet.setMargin(Sheet.RightMargin, 0.4);
sheet.setMargin(Sheet.TopMargin, 0.5);
sheet.setMargin(Sheet.BottomMargin, 0.5);
sheet.setHorizontallyCenter(true);
sheet.setVerticallyCenter(false);
sheet.setAutobreaks(true);
sheet.setFitToPage(true);
PrintSetup ps = sheet.getPrintSetup();
ps.setPaperSize(PrintSetup.A4_PAPERSIZE);
ps.setLandscape(landscape);
ps.setFitWidth((short) 1);
// 只按页宽适配:不把矮表纵向撑满整页,减少页底空白(最终仍靠 PNG 裁白边)
ps.setFitHeight((short) 0);
}
try (OutputStream out = Files.newOutputStream(xlsx, StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE)) {
wb.write(out);
}
}
}
/**
* 粗估工作表内容宽高:列宽合计 vs 行高合计,宽更大则预览用横向。
* 单位换算只需相对比较,不要求绝对毫米精度。
*/
private static boolean preferLandscape(Sheet sheet) {
int firstRow = Math.max(sheet.getFirstRowNum(), 0);
int lastRow = sheet.getLastRowNum();
if (lastRow < firstRow) {
return false;
}
int maxCol = 0;
for (int r = firstRow; r <= lastRow && r <= firstRow + 120; r++) {
Row row = sheet.getRow(r);
if (row == null) {
continue;
}
short last = row.getLastCellNum();
if (last > maxCol) {
maxCol = last;
}
}
if (maxCol <= 0) {
return false;
}
double widthPx = 0;
for (int c = 0; c < maxCol; c++) {
// POI 列宽:1/256 字符宽;约 7px/字符(96dpi 经验值)
widthPx += (sheet.getColumnWidth(c) / 256.0) * 7.0;
}
double heightPx = 0;
int rowLimit = Math.min(lastRow, firstRow + 120);
for (int r = firstRow; r <= rowLimit; r++) {
Row row = sheet.getRow(r);
float points = row != null ? row.getHeightInPoints() : sheet.getDefaultRowHeightInPoints();
heightPx += points * (96.0 / 72.0);
}
return widthPx > heightPx * 1.08;
}
/**
* PDF 首页等比例渲 PNG:PDFBox 按页尺寸 × DPI 均匀换算,禁止非等比拉伸。
* 渲完后裁掉近白页边,避免 A4 页底大块空白占预览高度。
@@ -30,4 +30,32 @@ public class InquiryDashboardServiceImplTest {
air.setBusinessLine("AIR");
Assert.assertEquals("空运全线", InquiryDashboardServiceImpl.routeLabel(air));
}
@Test
public void formatDurationMsMatchesDashboardDisplay() {
Assert.assertEquals("42s", InquiryDashboardServiceImpl.formatDurationMs(42_000L));
Assert.assertEquals("8m42s", InquiryDashboardServiceImpl.formatDurationMs((8 * 60 + 42) * 1000L));
Assert.assertEquals("1h5m", InquiryDashboardServiceImpl.formatDurationMs((65 * 60) * 1000L));
Assert.assertEquals("-", InquiryDashboardServiceImpl.formatDurationMs(-1L));
}
@Test
public void formatAvgSaleIsAmountNotPercent() {
Assert.assertEquals("-", InquiryDashboardServiceImpl.formatAvgSale(null));
Assert.assertEquals("12800", InquiryDashboardServiceImpl.formatAvgSale(new BigDecimal("12800.00")));
Assert.assertEquals("12.50", InquiryDashboardServiceImpl.formatAvgSale(new BigDecimal("12.5")));
}
@Test
public void firstReplyAfterSkipsQuotesBeforeDispatch() {
java.util.Date dispatch = new java.util.Date(1_000_000L);
java.util.List<java.util.Date> times = java.util.Arrays.asList(
new java.util.Date(500_000L),
new java.util.Date(1_200_000L),
new java.util.Date(2_000_000L));
Assert.assertEquals(new java.util.Date(1_200_000L),
InquiryDashboardServiceImpl.firstReplyAfter(times, dispatch));
Assert.assertNull(InquiryDashboardServiceImpl.firstReplyAfter(
java.util.Collections.singletonList(new java.util.Date(100L)), dispatch));
}
}
@@ -1135,6 +1135,122 @@ public class QuoteTemplateFillTest {
}
}
/**
* 跨境集拼线路矩阵:dataStartRow 若落在蓝底「始发地/目的地」表头,必须下移;
* 不得把城市名写进表头格盖掉蓝底。
*/
@Test
public void landGroupageRouteRegionSkipsBlueMatrixHeader() throws Exception {
Path src = Files.createTempFile("quote-land-matrix-src", ".xlsx");
Path out = Files.createTempFile("quote-land-matrix-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("跨境集拼");
org.apache.poi.ss.usermodel.CellStyle blue = wb.createCellStyle();
blue.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.ROYAL_BLUE.getIndex());
blue.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
org.apache.poi.ss.usermodel.Row head = row(sheet, 7);
org.apache.poi.ss.usermodel.Cell o = head.createCell(0);
o.setCellValue("始发地");
o.setCellStyle(blue);
org.apache.poi.ss.usermodel.Cell d = head.createCell(1);
d.setCellValue("目的地");
d.setCellStyle(blue);
org.apache.poi.ss.usermodel.Cell kg = head.createCell(3);
kg.setCellValue("2-500KG\n元/KG");
kg.setCellStyle(blue);
row(sheet, 8);
row(sheet, 16).createCell(0).setCellValue("查验处理费用");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject(true);
mapping.put("sheet", "跨境集拼");
JSONArray regions = new JSONArray();
JSONObject region = new JSONObject(true);
region.put("headerRow", 8);
region.put("dataStartRow", 8);
region.put("stopAtText", JSONArray.parseArray("[\"查验处理费用\"]"));
JSONObject columns = new JSONObject(true);
columns.put("origin", "A");
columns.put("destination", "B");
region.put("columns", columns);
regions.add(region);
mapping.put("routeRegions", regions);
JSONObject facts = new JSONObject(true);
facts.put("origin", "上海");
facts.put("destination", "胡志明");
facts.put("route_rows", QuoteTemplateExcelSupport.parseFeeRows(
"[{\"origin\":\"上海\",\"destination\":\"胡志明\"}]"));
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertEquals("始发地", read(sheet, 7, 0));
assertEquals("目的地", read(sheet, 7, 1));
assertEquals("上海", read(sheet, 8, 0));
assertEquals("胡志明", read(sheet, 8, 1));
org.apache.poi.ss.usermodel.CellStyle st = sheet.getRow(7).getCell(0).getCellStyle();
assertEquals(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND, st.getFillPattern());
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* 陆运跨境集拼:蓝底询价条带是表头(业务员/客户名称/始发地/货物类型),
* 值写下一空白行,禁止盖掉表头字与蓝底。
*/
@Test
public void landGroupageHeaderBandWritesValuesOnNextRow() throws Exception {
Path src = Files.createTempFile("quote-land-groupage-src", ".xlsx");
Path out = Files.createTempFile("quote-land-groupage-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("跨境集拼");
org.apache.poi.ss.usermodel.CellStyle blue = wb.createCellStyle();
blue.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.ROYAL_BLUE.getIndex());
blue.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
org.apache.poi.ss.usermodel.Row head = row(sheet, 4);
String[] labels = {"业务员", "客户名称", "始发地", "条款", "发货日期", "货物类型"};
int[] cols = {1, 3, 5, 7, 9, 13};
for (int i = 0; i < labels.length; i++) {
org.apache.poi.ss.usermodel.Cell c = head.createCell(cols[i]);
c.setCellValue(labels[i]);
c.setCellStyle(blue);
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, cols[i], cols[i] + 1));
}
row(sheet, 5);
org.apache.poi.ss.usermodel.Row matrixHead = row(sheet, 7);
matrixHead.createCell(0).setCellValue("始发地");
matrixHead.createCell(1).setCellValue("目的地");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject facts = new JSONObject(true);
facts.put("sales_name", "LuoXiaoHua");
facts.put("origin", "上海");
facts.put("commodity", "茶叶");
facts.put("quote_date", "2026-10-09");
QuoteTemplateExcelSupport.fillWorkbook(src, out, new JSONObject(), facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertEquals("业务员", read(sheet, 4, 1));
assertEquals("客户名称", read(sheet, 4, 3));
assertEquals("始发地", read(sheet, 4, 5));
assertEquals("货物类型", read(sheet, 4, 13));
assertEquals("LuoXiaoHua", read(sheet, 5, 1));
assertEquals("上海", read(sheet, 5, 5));
assertEquals("茶叶", read(sheet, 5, 13));
assertEquals("2026-10-09", read(sheet, 5, 9));
org.apache.poi.ss.usermodel.CellStyle salesStyle = sheet.getRow(4).getCell(1).getCellStyle();
assertEquals(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND, salesStyle.getFillPattern());
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* 陆运国内拼车模板是标签栏,不是「费用名称+金额」表。
* TMS 的 元/CBM、送货费、时效必须写到标签右侧,不能落到 G 列或整张空着。
@@ -133,6 +133,36 @@ public class QuoteTemplateMatcherTest {
assertEquals("跨境整车", hit.get("name"));
}
@Test
public void landAdminDomesticFtlKeywordDoesNotStealCrossFtl() {
// 后台国内整车模板关键词常写「国内整车」;别名「整车」不得抢走跨境整车(WO202610030032)。
List<Map<String, Object>> list = new ArrayList<>();
list.add(tpl("国内陆运整车报价单", "陆运", false, "国内整车"));
list.add(tpl("跨境整车报价单", "陆运", false, "东南亚"));
Map<String, Object> facts = new HashMap<>();
facts.put("运输类型", "跨境整车");
facts.put("线路类别", "东南亚");
facts.put("运输分类", "普件(东南亚、中亚、中欧涉及国内+国外段)");
facts.put("起运港", "上海");
facts.put("目的港", "春疆货场");
Map<String, Object> hit = QuoteTemplateMatcher.pick(list, "LAND", facts, null);
assertNotNull(hit);
assertEquals("跨境整车报价单", hit.get("name"));
}
@Test
public void landAdminDomesticFtlStillHitsDomesticType() {
List<Map<String, Object>> list = new ArrayList<>();
list.add(tpl("国内陆运整车报价单", "陆运", false, "国内整车"));
list.add(tpl("跨境整车报价单", "陆运", false, "东南亚"));
Map<String, Object> facts = new HashMap<>();
facts.put("运输类型", "国内运输整车");
facts.put("线路类别", "国内长途/零担");
Map<String, Object> hit = QuoteTemplateMatcher.pick(list, "LAND", facts, null);
assertNotNull(hit);
assertEquals("国内陆运整车报价单", hit.get("name"));
}
@Test
public void landAdminKeywordDomesticLclMatchesTransportType() {
List<Map<String, Object>> list = new ArrayList<>();
+1 -1
View File
@@ -2,7 +2,7 @@
VITE_USE_MOCK = false
# 网站标题(浏览器标签后缀;缺省会显示「页面名 undefined」)
VITE_GLOB_APP_TITLE = 友通达询价系统
VITE_GLOB_APP_TITLE = 友通达智能体运营后台
# 简称,用于生产 _app.config.js 变量名;勿空格/数字开头
VITE_GLOB_APP_SHORT_NAME = YtdInquiryAdmin
+1 -1
View File
@@ -29,7 +29,7 @@ export function useTitle() {
const tTitle = t(route?.meta?.title as string);
// 生产 _app.config.js 若漏打 VITE_GLOB_APP_TITLE,title 会是 undefined → 标签出现「报价模板 undefined」
const appTitle = title || '友通达询价系统';
const appTitle = title || '友通达智能体运营后台';
pageTitle.value = tTitle ? `${tTitle} - ${appTitle}` : `${appTitle}`;
},
{ immediate: true }
@@ -80,7 +80,7 @@
const profileOpen = ref(false);
const passwordOpen = ref(false);
const pageTitle = computed(() => String(route.meta?.title || '询价机器人后台'));
const pageTitle = computed(() => String(route.meta?.title || '友通达智能体运营后台'));
const crumb = computed(() => (route.meta?.crumb ? String(route.meta.crumb) : ''));
const username = computed(() => userStore.getUserInfo?.username || '');
@@ -207,7 +207,7 @@
<div class="admin-brand-icon">
<ProtoIcon name="bot" :size="16" />
</div>
<div class="admin-brand-title">询价机器人</div>
<div class="admin-brand-title">友通达智能体运营后台</div>
</div>
<nav class="admin-nav">
<template v-for="group in navGroups" :key="group.title">
@@ -0,0 +1,148 @@
<script setup lang="ts">
/**
* 长内容折叠:超过 maxHeight 时收起,底部下箭头点开展开,再点收起。
* 用于工单列表「询价需求 / 报价信息」和详情同名区块,避免一行撑满整屏。
* 副作用:无写库。内容变化(contentKey)时重新量高并默认收起。
*/
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
const props = withDefaults(
defineProps<{
/** 收起时最大高度(px) */
maxHeight?: number;
/** 内容身份:工单号/区块名变化时重测并收起 */
contentKey?: string | number;
/** 有费用明细等:即使收起后高度不够,也显示下箭头 */
forceToggle?: boolean;
/**
* 收起时是否用 maxHeight 裁切内容。
* 列表报价只藏费用明细、摘要必须完整露出时关掉,避免「CNY / 第二条报价」被切半。
*/
clipCollapsed?: boolean;
}>(),
{
maxHeight: 120,
contentKey: "",
forceToggle: false,
clipCollapsed: true,
},
);
const bodyRef = ref<HTMLElement | null>(null);
const expanded = ref(false);
const overflow = ref(false);
let ro: ResizeObserver | null = null;
function measure() {
const el = bodyRef.value;
if (!el) {
overflow.value = !!props.forceToggle;
return;
}
const natural = el.scrollHeight;
overflow.value = !!props.forceToggle || natural > props.maxHeight + 2;
if (!overflow.value) {
expanded.value = false;
}
}
function toggle() {
if (!overflow.value) return;
expanded.value = !expanded.value;
nextTick(measure);
}
onMounted(() => {
nextTick(measure);
if (typeof ResizeObserver !== "undefined" && bodyRef.value) {
ro = new ResizeObserver(() => measure());
ro.observe(bodyRef.value);
}
});
onBeforeUnmount(() => {
ro?.disconnect();
ro = null;
});
watch(
() => [props.contentKey, props.maxHeight, props.forceToggle],
() => {
expanded.value = false;
nextTick(measure);
},
);
</script>
<template>
<div
class="expand-clamp"
:class="{ 'is-expanded': expanded, 'is-overflow': props.clipCollapsed && overflow && !expanded }"
>
<div
ref="bodyRef"
class="expand-clamp-body"
:style="
props.clipCollapsed && overflow && !expanded ? { maxHeight: maxHeight + 'px' } : undefined
"
>
<slot :expanded="expanded" />
</div>
<button
v-if="overflow"
type="button"
class="expand-clamp-toggle"
:class="{ 'is-open': expanded }"
:title="expanded ? '收起' : '展开全部'"
@click.stop="toggle"
>
<ProtoIcon name="chevron" :size="14" />
</button>
</div>
</template>
<style scoped>
.expand-clamp {
position: relative;
min-width: 0;
}
.expand-clamp-body {
overflow: visible;
}
.expand-clamp.is-overflow .expand-clamp-body {
overflow: hidden;
}
.expand-clamp.is-overflow:not(.is-expanded)::after {
content: "";
pointer-events: none;
position: absolute;
left: 0;
right: 0;
bottom: 22px;
height: 28px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0), #fff 90%);
}
.expand-clamp-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
margin-top: 2px;
padding: 2px 0 0;
border: 0;
background: transparent;
color: #64748b;
cursor: pointer;
line-height: 1;
}
.expand-clamp-toggle:hover {
color: #1d4ed8;
}
.expand-clamp-toggle :deep(svg) {
transition: transform 0.15s ease;
}
.expand-clamp-toggle.is-open :deep(svg) {
transform: rotate(180deg);
}
</style>
@@ -66,7 +66,7 @@
<div class="login-brand-mark">
<ProtoIcon name="bot" :size="22" />
</div>
<h1>询价机器人后台</h1>
<h1>友通达智能体运营后台</h1>
<p>管理询价工单、人员配置与系统权限</p>
</div>
@@ -31,19 +31,19 @@ export type CostRoute = {
points: Array<{ x: number; y: number; index: number }>;
};
/** 今日视角九指标(与原型默认展示一致) */
/** 今日视角九指标(与原型 5 列栅格、主账 InquiryDashboardServiceImpl 一致) */
export const todayMetrics: MetricCard[] = [
{ key: "inquiry", label: "询价总数", tip: "周期内询价工单总数", value: "128" },
{ key: "pending", label: "待处理工单", tip: "工单总数 − 已成交 − 未成交 − 已关闭", value: "39 单" },
{ key: "inquiry", label: "询价总量", tip: "周期内询价工单总数", value: "128" },
{ key: "quoted", label: "报价工单", tip: "已报价工单数(已报价 + 议价中 + 已成交 + 未成交)", value: "92 单" },
{ key: "tms", label: "标准报价命中率", tip: "命中 TMS 标准价的询价数 ÷ 询价总数 × 100%", value: "67.8%" },
{ key: "abnormal", label: "异常处理完成率", tip: "已关闭异常工单数 ÷ 异常工单总数 × 100%", value: "86.4%" },
{ key: "manual", label: "手工报价占比", tip: "人工报价的询价数 ÷ 询价总数 × 100%", value: "32.0%" },
{ key: "accepted", label: "已成交工单", tip: "状态=已成交的工单数量", value: "31" },
{ key: "dealRate", label: "报价成交率", tip: "已成交数 ÷ 已报价数 × 100%", value: "34.8%" },
{ key: "feedback", label: "报价反馈率", tip: "(已成交 + 未成交 − 关闭)÷ 工单数 × 100%", value: "60.2%" },
{ key: "avgSale", label: "平均报价销售额", tip: "AVG(已勾选报价的销售价)", value: "12800" },
{ key: "manual", label: "手工工单占比", tip: "人工报价的询价数 ÷ 询价总数 × 100%", value: "32.0%" },
{ key: "pending", label: "待处理工单", tip: "工单总数 − 已成交 − 未成交 − 已关闭", value: "39 单" },
{ key: "dealRate", label: "报价成功率", tip: "已成交数 ÷ 已报价数 × 100%", value: "34.8%" },
{ key: "feedback", label: "报价有效率", tip: "(已成交 + 未成交)÷ 工单数 × 100%", value: "60.2%" },
{
key: "avgResp",
label: "内部协同平均响应时长",
label: "内部报价平均响应",
tip: "AVG(首次有效回复时间 − 分发时间)",
value: "8m42s",
isTime: true,
@@ -70,6 +70,9 @@ body {
font-size: 11px !important;
font-weight: 900 !important;
line-height: 1.25 !important;
white-space: normal !important;
flex: 1;
min-width: 0;
}
.admin-nav {
@@ -476,9 +479,19 @@ body {
.ticket-list-table td.col-sticky-left,
.ticket-list-table td.col-sticky-right,
.ticket-list-table th.col-sticky-left,
.ticket-list-table th.col-sticky-right {
.ticket-list-table th.col-sticky-right,
.ticket-list-table td.col-quote,
.ticket-list-table td.col-price,
.ticket-list-table td.col-demand {
word-break: normal;
overflow-wrap: normal;
overflow-wrap: break-word;
}
.ticket-list-table .quote-source,
.ticket-list-table .quote-price,
.ticket-list-table .quote-fee-line,
.ticket-list-table .ticket-price-cell {
white-space: nowrap;
}
.ticket-list-table .attach-tags {
@@ -558,7 +571,7 @@ body {
.ticket-list-table {
table-layout: fixed !important;
width: max-content !important;
min-width: 1480px !important;
min-width: 1640px !important;
max-width: none !important;
border-collapse: separate !important;
border-spacing: 0 !important;
@@ -566,8 +579,8 @@ body {
.ticket-list-table .col-w-id { width: 132px; }
.ticket-list-table .col-w-user { width: 72px; }
.ticket-list-table .col-w-demand { width: 200px; }
.ticket-list-table .col-w-quote { width: 140px; }
.ticket-list-table .col-w-demand { width: 220px; }
.ticket-list-table .col-w-quote { width: 250px; }
.ticket-list-table .col-w-attach { width: 160px; }
.ticket-list-table .col-w-status { width: 84px; }
.ticket-list-table .col-w-price { width: 168px; }
@@ -469,11 +469,11 @@
}
.ref-metric-tip {
position: absolute;
left: 0;
left: 50%;
bottom: calc(100% + 8px);
z-index: 6;
z-index: 40;
width: max-content;
max-width: 240px;
max-width: 280px;
border: 1px solid #dfe5ee;
background: #0f172a;
color: #f8fafc;
@@ -485,26 +485,26 @@
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
pointer-events: none;
opacity: 0;
transform: translateY(4px);
transform: translate(-50%, 4px);
transition: opacity 140ms ease, transform 140ms ease;
white-space: normal;
}
.ref-metric-tip::after {
content: "";
position: absolute;
left: 10px;
left: 50%;
top: 100%;
width: 8px;
height: 8px;
border-right: 1px solid #dfe5ee;
border-bottom: 1px solid #dfe5ee;
background: #0f172a;
transform: translateY(-4px) rotate(45deg);
transform: translate(-50%, -4px) rotate(45deg);
}
.ref-metric-help:hover .ref-metric-tip,
.ref-metric-help:focus-visible .ref-metric-tip {
opacity: 1;
transform: translateY(0);
transform: translate(-50%, 0);
}
.ref-quality-value {
margin-top: 14px;
@@ -0,0 +1,230 @@
/**
* 工单详情「询价需求」格子:按运输方式 + 陆运运输类型展示不同必填项。
*
* 口径对齐销售侧询价确认卡(inquiry_field_lines):
* 空运看件数/重量/体积/包装;海运看货量/整柜拼柜/箱型箱量;
* 陆运拼车看体积重量,整车/中港零担看车型数量,中港整车不强制车型。
* 只做展示,不改六态、不写主账。
*/
/** 详情格子一行:标签 + 值(空则「-」) */
export type DemandField = { label: string; value: string };
const LAND_FTL_TYPES = new Set(["国内运输整车", "跨境整车", "中港零担/集拼"]);
const LAND_CUSTOMS_TYPES = new Set(["跨境集拼", "跨境整车", "中港整车", "中港零担/集拼"]);
const LAND_LABEL: Record<string, string> = {
起运港: "始发站",
目的港: "目的地",
品名: "货物品名",
毛重: "重量(KG)",
体积: "体积(CBM)",
"车型/数量": "车型/数量",
通关口岸: "通关口岸",
报价日期: "报价日期",
};
/**
* JSON 字符串 / 对象都解开成扁平键值。坏 JSON 当空。
* 协同快照里的车型等也要能展示,调用方自己决定合并顺序。
*/
export function parseFactsMap(raw: unknown): Record<string, string> {
if (raw == null) return {};
let obj: any = raw;
if (typeof raw === "string" && raw.trim()) {
try {
obj = JSON.parse(raw);
} catch {
return {};
}
}
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return {};
const out: Record<string, string> = {};
for (const key of Object.keys(obj)) {
const val = obj[key];
if (val == null) continue;
if (typeof val === "object") continue;
const text = String(val).trim();
if (text) out[key] = text;
}
return out;
}
/** 询价事实优先,协同只补空键(中港整车车型常在协同里)。 */
export function mergeInquiryFacts(ticket: any): Record<string, string> {
const inquiry = parseFactsMap(ticket?.factsJson ?? ticket?.facts);
const collab = parseFactsMap(ticket?.collabFactsJson ?? ticket?.collabFacts);
const out: Record<string, string> = { ...inquiry };
for (const [k, v] of Object.entries(collab)) {
if (!out[k]) out[k] = v;
}
return out;
}
function pick(facts: Record<string, string>, ...keys: string[]): string {
for (const key of keys) {
const v = (facts[key] || "").trim();
if (v) return v;
}
return "";
}
function dash(v: unknown): string {
const s = (v ?? "").toString().trim();
return s ? s : "-";
}
function columnText(v: unknown): string {
if (v == null || v === "") return "";
const s = String(v).trim();
if (!s || s === "-") return "";
return s;
}
function businessLineOf(ticket: any): "AIR" | "SEA" | "LAND" | "MULTI" | "" {
const raw = String(ticket?.businessLine ?? "").trim().toUpperCase();
if (raw === "AIR" || raw === "SEA" || raw === "LAND" || raw === "MULTI") return raw;
const label = String(ticket?.bizTypeLabel ?? ticket?.bizType ?? "").trim();
if (label.includes("空运")) return "AIR";
if (label.includes("海运")) return "SEA";
if (label.includes("陆运")) return "LAND";
if (label.includes("多段") || label.includes("联运")) return "MULTI";
return "";
}
function transportModeLabel(ticket: any, line: string): string {
const label = String(ticket?.bizTypeLabel ?? "").trim();
if (label) return label;
if (line === "AIR") return "空运";
if (line === "SEA") return "海运";
if (line === "LAND") return "陆运";
if (line === "MULTI") return "多段联运";
return "";
}
/** 当前陆运类型要展示的货物内部键。中港整车不要把车型当成询价必填。 */
function landCargoKeys(landType: string): string[] {
if (landType === "中港整车") {
return ["起运港", "目的港", "品名", "报价日期"];
}
if (LAND_FTL_TYPES.has(landType)) {
return ["起运港", "目的港", "品名", "车型/数量", "报价日期"];
}
return ["起运港", "目的港", "品名", "体积", "毛重", "报价日期"];
}
function push(fields: DemandField[], label: string, value: string) {
fields.push({ label, value: dash(value) });
}
/**
* 工单详情询价需求格子。
* 列字段(origin / pieces / containerSpec 等)作 facts 空时的兜底,兼容旧单。
*/
export function buildDemandFields(ticket: any): DemandField[] {
const facts = mergeInquiryFacts(ticket);
const line = businessLineOf(ticket);
const mode = transportModeLabel(ticket, line);
const origin = pick(facts, "起运港", "起运地") || columnText(ticket?.origin);
const dest = pick(facts, "目的港", "目的地") || columnText(ticket?.destination);
const goods = pick(facts, "品名", "货物品名") || columnText(ticket?.goodsName);
const quoteDate =
pick(facts, "报价日期") || columnText(ticket?.quoteDate)?.toString().slice(0, 10) || "";
const customer = pick(facts, "客户名称") || columnText(ticket?.customerName);
const fields: DemandField[] = [];
push(fields, "运输方式", mode);
if (line === "SEA") {
push(fields, "起运港", origin);
push(fields, "目的港", dest);
push(fields, "品名", goods);
push(fields, "货物数量", pick(facts, "货量", "货物数量", "件数") || columnText(ticket?.cargoQty) || columnText(ticket?.pieces));
push(fields, "整柜或拼柜", pick(facts, "整柜或拼柜") || columnText(ticket?.fclLcl));
push(fields, "箱型箱量", pick(facts, "箱型箱量") || columnText(ticket?.containerSpec));
push(fields, "贸易条款", pick(facts, "贸易条款"));
push(fields, "运输分类", pick(facts, "运输分类"));
push(fields, "报价日期", quoteDate);
} else if (line === "LAND") {
const landType = pick(facts, "运输类型");
push(fields, "运输类型", landType);
push(fields, "线路类别", pick(facts, "线路类别"));
push(fields, "运输分类", pick(facts, "运输分类"));
const cargoVals: Record<string, string> = {
起运港: origin,
目的港: dest,
品名: goods,
体积: pick(facts, "体积") || columnText(ticket?.volumeCbm),
毛重: pick(facts, "毛重", "重量") || columnText(ticket?.weightKg),
"车型/数量": pick(facts, "车型/数量", "车型"),
报价日期: quoteDate,
};
for (const key of landCargoKeys(landType)) {
push(fields, LAND_LABEL[key] || key, cargoVals[key] || pick(facts, key));
}
if (LAND_CUSTOMS_TYPES.has(landType)) {
push(fields, "通关口岸", pick(facts, "通关口岸"));
}
// 中港整车询价不强制车型,协同里有值仍展示,避免后台看不到 53 尺这类原文。
if (landType === "中港整车" && pick(facts, "车型/数量", "车型")) {
push(fields, "车型/数量", pick(facts, "车型/数量", "车型"));
}
} else if (line === "AIR") {
push(fields, "起运地", origin);
push(fields, "目的地", dest);
push(fields, "品名", goods);
push(fields, "件数", pick(facts, "件数") || columnText(ticket?.pieces));
push(fields, "重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
push(fields, "体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
push(fields, "包装类型", pick(facts, "包装方式", "包装类型"));
push(fields, "报价日期", quoteDate);
} else {
// 多段或未识别:至少把路线和货量列出来,避免整块空白。
push(fields, "起运 / 目的", origin || dest ? `${origin || "-"} → ${dest || "-"}` : "");
push(fields, "品名", goods);
push(fields, "件数", pick(facts, "件数") || columnText(ticket?.pieces));
push(fields, "重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
push(fields, "体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
const box = pick(facts, "箱型箱量") || columnText(ticket?.containerSpec);
if (box) push(fields, "箱型箱量", box);
const vehicle = pick(facts, "车型/数量", "车型");
if (vehicle) push(fields, "车型/数量", vehicle);
}
push(fields, "客户", customer);
return fields;
}
/**
* 列表「询价需求」第二行货量:按方式只写该方式关心的量,避免陆运整车全是件数「-」。
*/
export function quantitySummary(ticket: any): string {
const facts = mergeInquiryFacts(ticket);
const line = businessLineOf(ticket);
const landType = pick(facts, "运输类型");
const parts: string[] = [];
const add = (label: string, raw: string) => {
const v = (raw || "").trim();
if (v && v !== "-") parts.push(`${label} ${v}`);
};
if (line === "SEA") {
add("货物数量", pick(facts, "货量", "货物数量") || columnText(ticket?.cargoQty));
add("整柜或拼柜", pick(facts, "整柜或拼柜") || columnText(ticket?.fclLcl));
add("箱型箱量", pick(facts, "箱型箱量") || columnText(ticket?.containerSpec));
return parts.join(" · ");
}
if (line === "LAND") {
if (landType) add("运输类型", landType);
if (LAND_FTL_TYPES.has(landType) || (landType === "中港整车" && pick(facts, "车型/数量"))) {
add("车型/数量", pick(facts, "车型/数量", "车型"));
} else {
add("体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
add("重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
}
return parts.join(" · ");
}
add("件数", pick(facts, "件数") || columnText(ticket?.pieces));
add("重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
add("体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
return parts.join(" · ");
}
@@ -16,6 +16,12 @@ import {
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
import ExpandableClamp from "/@/views/inquiry/components/ExpandableClamp.vue";
import {
buildDemandFields,
quantitySummary,
type DemandField,
} from "/@/views/inquiry/workOrder/demandFields";
/** 单条费用明细:名称 + 带币种金额,对齐企微价格明细。 */
type TicketQuoteFee = { name: string; amount: string };
@@ -61,6 +67,8 @@ type TicketRow = {
weightKg: string;
volumeCbm: string;
goodsName: string;
/** 询价需求格子:按运输方式/运输类型变化 */
demandFields: DemandField[];
updateTime: string;
lostReason: string;
nextFollowupAt: string;
@@ -300,7 +308,8 @@ function looksLikeRawFp(v: any) {
/**
* 询价需求摘要:对齐旧后台 requirementSummaryLines。
* 首行 = 业务线 · 路线;后续 = 货量 / 品名。结构化字段优先,不信 demandDetail 管道串。
* 首行 = 业务线 · 路线;后续 = 该运输方式关心的货量 / 品名。
* 陆运整车写车型数量,海运写箱型箱量;不信 demandDetail 管道串。
*/
function requirementSummaryLines(t: any): string[] {
const businessLine = String(t?.businessLine ?? "").trim().toUpperCase();
@@ -313,37 +322,11 @@ function requirementSummaryLines(t: any): string[] {
const lines: string[] = [];
if (heading) lines.push(heading);
const isAir = businessLine === "AIR" || lineLabel.includes("空运");
const isSea = businessLine === "SEA" || lineLabel.includes("海运");
const isLand = businessLine === "LAND" || lineLabel.includes("陆运");
const quantities: string[] = [];
const pieces = t?.pieces;
const weight = t?.weightKg;
const volume = t?.volumeCbm;
const plain = (v: any) => {
if (v == null || v === "") return "";
const n = Number(v);
return Number.isFinite(n) ? String(n) : String(v).trim();
};
if (isAir) {
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
if (weight != null && weight !== "") quantities.push(`重量(KG) ${plain(weight)}kg`);
if (volume != null && volume !== "") quantities.push(`体积(CBM) ${plain(volume)}CBM`);
} else if (isSea) {
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
if (weight != null && weight !== "") quantities.push(`重量(KG) ${plain(weight)}kg`);
if (volume != null && volume !== "") quantities.push(`体积(CBM) ${plain(volume)}CBM`);
} else if (isLand) {
if (volume != null && volume !== "") quantities.push(`体积(CBM) ${plain(volume)}CBM`);
if (weight != null && weight !== "") quantities.push(`重量(KG) ${plain(weight)}kg`);
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
} else {
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
if (weight != null && weight !== "") quantities.push(`重量(KG) ${plain(weight)}kg`);
if (volume != null && volume !== "") quantities.push(`体积(CBM) ${plain(volume)}CBM`);
}
if (quantities.length) lines.push(quantities.join(" · "));
const qty = quantitySummary(t);
if (qty) lines.push(qty);
const isLand = businessLine === "LAND" || lineLabel.includes("陆运");
const isAir = businessLine === "AIR" || lineLabel.includes("空运");
const cargo: string[] = [];
const goods = String(t?.goodsName ?? "").trim();
if (goods) cargo.push(`${isLand ? "货物品名" : "品名"} ${goods}`);
@@ -418,6 +401,7 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
weightKg: fmtMeasure(t.weightKg, "kg"),
volumeCbm: fmtMeasure(t.volumeCbm, "CBM"),
goodsName: dash(t.goodsName),
demandFields: buildDemandFields(t),
updateTime: fmtTime(t.updateTime || t.createTime),
lostReason: dash(t.lostReason || t.lost_reason),
nextFollowupAt: fmtTime(t.nextFollowupAt || t.next_followup_at),
@@ -425,11 +409,8 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
};
}
function routeText(row: TicketRow) {
if (row.origin !== "-" || row.destination !== "-") {
return `${row.origin === "-" ? "" : row.origin} → ${row.destination === "-" ? "" : row.destination}`.trim();
}
return row.demandTitle || "-";
function quoteHasFees(row: TicketRow) {
return (row.quotes || []).some((q) => (q.fees || []).length > 0);
}
/** 列表询价需求第二行起:与旧后台 summary-cell 多行展示一致。 */
@@ -670,18 +651,26 @@ function refresh() {
<td class="align-top">
<span style="font-size: 12px; font-weight: 700">{{ row.initiator }}</span>
</td>
<td class="align-top">
<div class="summary-cell">
<strong>{{ row.demandTitle }}</strong>
<span
v-for="(line, idx) in demandDetailLines(row)"
:key="row.id + '-demand-' + idx"
>{{ line }}</span
>
</div>
<td class="align-top col-demand">
<ExpandableClamp :content-key="row.id + '-demand'" :max-height="132">
<div class="summary-cell">
<strong>{{ row.demandTitle }}</strong>
<span
v-for="(line, idx) in demandDetailLines(row)"
:key="row.id + '-demand-' + idx"
>{{ line }}</span
>
</div>
</ExpandableClamp>
</td>
<td class="align-top">
<template v-if="row.quotes.length">
<td class="align-top col-quote">
<ExpandableClamp
v-if="row.quotes.length"
v-slot="{ expanded }"
:content-key="row.id + '-quote-' + row.quotes.length"
:force-toggle="quoteHasFees(row)"
:clip-collapsed="false"
>
<div
v-for="q in row.quotes"
:key="q.source + q.price"
@@ -689,18 +678,20 @@ function refresh() {
style="margin-bottom: 8px"
>
<div class="quote-source">{{ q.source }}</div>
<div
v-for="(fee, fi) in q.fees"
:key="q.source + '-fee-' + fi"
class="quote-fee-line"
>
{{ fee.name }}:{{ fee.amount }}
</div>
<div class="quote-price" :style="q.highlight ? { color: '#1d4ed8' } : undefined">
{{ q.price }}
</div>
<template v-if="expanded && q.fees.length">
<div
v-for="(fee, fi) in q.fees"
:key="q.source + '-fee-' + fi"
class="quote-fee-line"
>
{{ fee.name }}:{{ fee.amount }}
</div>
</template>
</div>
</template>
</ExpandableClamp>
<span v-else style="color: #94a3b8">-</span>
</td>
<td class="align-top">
@@ -896,7 +887,12 @@ function refresh() {
<span v-else>{{ current.profit }}</span>
</div>
</div>
<div v-if="current.quotes.length" class="ticket-detail-demand">
<ExpandableClamp
v-if="current.quotes.length"
class="ticket-detail-demand"
:content-key="current.id + '-detail-quote-' + current.quotes.length"
:max-height="220"
>
<div
v-for="q in current.quotes"
:key="q.source + q.price + (q.time || '')"
@@ -922,28 +918,32 @@ function refresh() {
合计 {{ q.price }}
</div>
</div>
</div>
</ExpandableClamp>
</section>
<section class="ticket-detail-section">
<div class="ticket-detail-title">
<ProtoIcon name="route" :size="14" />询价需求
</div>
<div class="ticket-detail-demand">
<ExpandableClamp
class="ticket-detail-demand"
:content-key="current.id + '-detail-demand-' + current.demandFields.length"
:max-height="220"
>
<div class="summary-cell">
<strong>{{ current.demandTitle }}</strong>
<span v-for="(line, idx) in demandDetailLines(current)" :key="'detail-demand-' + idx">{{ line }}</span>
</div>
<div class="ticket-detail-grid" style="margin-top: 8px">
<div class="ticket-detail-item"><label>业务类型</label><span>{{ dash(current.bizType) }}</span></div>
<div class="ticket-detail-item"><label>起运 / 目的</label><span>{{ routeText(current) }}</span></div>
<div class="ticket-detail-item"><label>件数</label><span>{{ current.pieces }}</span></div>
<div class="ticket-detail-item"><label>重量(KG)</label><span>{{ current.weightKg }}</span></div>
<div class="ticket-detail-item"><label>体积(CBM)</label><span>{{ current.volumeCbm }}</span></div>
<div class="ticket-detail-item"><label>品名</label><span>{{ current.goodsName }}</span></div>
<div class="ticket-detail-item"><label>客户</label><span>{{ current.customerName }}</span></div>
<div
v-for="(item, idx) in current.demandFields"
:key="'demand-field-' + idx + '-' + item.label"
class="ticket-detail-item"
>
<label>{{ item.label }}</label><span>{{ item.value }}</span>
</div>
</div>
</div>
</ExpandableClamp>
</section>
<section class="ticket-detail-section">
@@ -5,7 +5,7 @@
<div class="app-loading-dots">
<span class="dot dot-spin"><i></i><i></i><i></i><i></i></span>
</div>
<div class="app-loading-title">友通达询价系统</div>
<div class="app-loading-title">友通达智能体运营后台</div>
</div>
</div>
</template>
@@ -0,0 +1,63 @@
import { buildDemandFields, quantitySummary } from "/@/views/inquiry/workOrder/demandFields";
test("land FTL shows vehicle not pieces", () => {
const rows = buildDemandFields({
businessLine: "LAND",
bizTypeLabel: "陆运",
origin: "上海",
destination: "春疆货场",
goodsName: "茶叶",
factsJson: JSON.stringify({
运输类型: "国内运输整车",
线路类别: "国内长途/零担",
运输分类: "普件",
"车型/数量": "53尺(16.5厢车)",
起运港: "上海",
目的港: "春疆货场",
品名: "茶叶",
报价日期: "2026-10-03",
}),
});
const map = Object.fromEntries(rows.map((r) => [r.label, r.value]));
expect(map["运输方式"]).toBe("陆运");
expect(map["运输类型"]).toBe("国内运输整车");
expect(map["车型/数量"]).toBe("53尺(16.5厢车)");
expect(map["始发站"]).toBe("上海");
expect(map["货物品名"]).toBe("茶叶");
expect(map["件数"]).toBeUndefined();
});
test("sea shows container spec", () => {
const rows = buildDemandFields({
businessLine: "SEA",
bizTypeLabel: "海运",
factsJson: JSON.stringify({
起运港: "上海",
目的港: "汉堡",
品名: "配件",
货量: "1x40HQ",
整柜或拼柜: "整柜",
箱型箱量: "40HQ*1",
贸易条款: "FOB",
运输分类: "Port to Port",
报价日期: "2026-10-03",
}),
});
const map = Object.fromEntries(rows.map((r) => [r.label, r.value]));
expect(map["箱型箱量"]).toBe("40HQ*1");
expect(map["整柜或拼柜"]).toBe("整柜");
expect(map["件数"]).toBeUndefined();
});
test("land LTL quantity summary uses volume", () => {
const qty = quantitySummary({
businessLine: "LAND",
factsJson: JSON.stringify({
运输类型: "国内运输拼车",
体积: "10CBM",
毛重: "50kg",
}),
});
expect(qty).toContain("体积(CBM) 10CBM");
expect(qty).not.toContain("车型");
});