diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py index 751814d..3fdcab6 100644 --- a/inquiry-agent/agent/llm/extract_text.py +++ b/inquiry-agent/agent/llm/extract_text.py @@ -961,7 +961,32 @@ def _parse_fee_rows(text: str) -> list[dict[str, str]]: return rows -def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = None) -> dict[str, Any]: +def _default_product_fee_name(business_line: str = "", transport_mode: str = "") -> str: + """ + 产品文字未点费用名时的默认费目。 + + 必须跟本单运输方式走:陆运→陆运费、空运→空运费、其余→海运费。 + 禁止陆运单无费名时硬编码「海运费」(WO202610090118「价格为1RMB/KG」)。 + """ + line = (business_line or "").strip().upper() + mode = (transport_mode or "").strip() + if line == "LAND" or mode in {"陆运", "LAND", "land"}: + return "陆运费" + if line == "AIR" or mode in {"空运", "AIR", "air"}: + return "空运费" + if line == "SEA" or mode in {"海运", "SEA", "sea"}: + return "海运费" + # 未传业务线时保持旧默认,避免海运调用方漏参后费名变空。 + return "海运费" + + +def extract_product_quote( + text: str, + *, + injected: Optional[dict[str, Any]] = None, + business_line: str = "", + transport_mode: str = "", +) -> dict[str, Any]: """ 产品文字报价:先按费用行抽,再退回合计/币别。注入优先。 @@ -970,6 +995,7 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non 「空运费 单价…金额…」必须保留空运费,金额用「金额」后面的数,不用单价。 陆运集拼:「2-500KG 单价CNY990,燃油附加费率1%,税率6%」走分档单价+费率, 禁止把公斤档下限「2」当成燃油附加费金额(WO021)。 + 无费用名时按 business_line/transport_mode 默认陆运费/空运费/海运费,禁止陆运硬套海运费。 """ if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")): out = dict(injected) @@ -1015,15 +1041,19 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non if not amount: return {} ccy = _currency_of(raw) + default_fee = _default_product_fee_name(business_line, transport_mode) # 句里已经点了费用名(空运费)时,不能退回默认海运费。 named = re.search( r"([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)", scrubbed or raw, ) - fee_name = (named.group(1) if named else "") or "海运费" - if fee_name in {"费用", "燃油附加费"}: - # 「燃油附加费率」里的「燃油附加费」不是费用行名;无其它费名时用运费。 - fee_name = "海运费" if fee_name == "费用" else "陆运费" + fee_name = (named.group(1) if named else "") or default_fee + if fee_name == "费用": + # 句里只有「费用」没有运输方式前缀时,跟本单默认运费。 + fee_name = default_fee + elif fee_name == "燃油附加费": + # 「燃油附加费率」误抽成费名时:陆运/空运用本单默认;海运旧行为落陆运费,避免当海运费金额。 + fee_name = default_fee if default_fee != "海运费" else "陆运费" out = { "total": amount, "currency": ccy, diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index 7732800..9edeba3 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -629,7 +629,9 @@ def apply_air_quote( fields = extract_air_collab_fields(text) if fields: flow.ledger.patch_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id) - quote = injected_quote or extract_product_quote(text) or {} + quote = injected_quote or extract_product_quote( + text, business_line="AIR", transport_mode="空运" + ) or {} if not quote: send_group_text(client, chat_id, copy.air_quote_not_recognized()) return "quote_empty" diff --git a/inquiry-agent/agent/policy/multi_group_ops.py b/inquiry-agent/agent/policy/multi_group_ops.py index 91c6b85..85cab8c 100644 --- a/inquiry-agent/agent/policy/multi_group_ops.py +++ b/inquiry-agent/agent/policy/multi_group_ops.py @@ -152,7 +152,15 @@ def handle_multi_inbound( ticket = _reload(flow, no) or ticket segments = load_segments(ticket) target = next((seg for seg in segments if seg.mode == mode), target) - quote = extract_product_quote(text or "", injected=injected_quote) + quote = extract_product_quote( + text or "", + injected=injected_quote, + business_line=str(mode or "").upper(), + transport_mode=( + "陆运" if str(mode or "").upper() == "LAND" + else ("空运" if str(mode or "").upper() == "AIR" else "海运") + ), + ) if not quote: if fields: return "multi_collab_saved" diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index 8152f95..758db4d 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -929,7 +929,13 @@ def apply_product_quote( merged = dict(sess.collab_facts) merged.update(fields) sess.collab_facts = merged - quote = extract_product_quote(text, injected=injected_quote) + line = str(view.get("business_line") or view.get("businessLine") or "").strip().upper() + quote = extract_product_quote( + text, + injected=injected_quote, + business_line=line, + transport_mode="陆运" if line == "LAND" else ("海运" if line == "SEA" else ""), + ) if not quote: logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200]) send_group_text(client, chat_id, copy.group_quote_not_recognized()) @@ -941,7 +947,9 @@ def apply_product_quote( send_group_text(client, chat_id, copy.group_ask_tms_or_product()) return "wait_tms_choice" # 分档单价是完整人工价(含费率),禁止和旧错误行(如燃油附加费=2)相加。 + # 旧错默认「海运费」的陆运产品价:重报时按本单默认运费对齐后再盖,避免海运费+陆运费并排。 if _quote_fee_rows(base) and not (quote.get("weightPrices") or []): + base = _align_wrong_default_freight(base, quote, business_line=line) quote = merge_quote_fees(base, quote) _save_product_quote(flow, no, quote, sess) ticket2 = _reload(flow, chat_id, no) or ticket @@ -1627,6 +1635,45 @@ def _merged_total( return str(src.get("total") or ""), header_ccy +def _align_wrong_default_freight( + base: dict[str, Any] | None, + overlay: dict[str, Any] | None, + *, + business_line: str = "", +) -> dict[str, Any]: + """ + 陆运/空运旧产品价若误落成别的运输方式默认费名,重报前改成与新报价同名,便于按行盖价。 + + 只动「海运费/陆运费/空运费/运输费」这类主运费默认名,不碰操作费等真实分项。 + """ + src = dict(base or {}) + line = (business_line or "").strip().upper() + want = {"LAND": "陆运费", "AIR": "空运费", "SEA": "海运费"}.get(line, "") + if not want: + return src + new_names = { + str(x.get("name") or "").strip() + for x in (overlay or {}).get("fee_rows") or (overlay or {}).get("fee_lines") or [] + if isinstance(x, dict) + } + if want not in new_names: + return src + mains = {"海运费", "陆运费", "空运费", "运输费"} + rows = _quote_fee_rows(src) + changed = False + for row in rows: + name = str(row.get("name") or "").strip() + if name in mains and name != want: + row["name"] = want + changed = True + if not changed: + return src + out = dict(src) + out["fee_rows"] = rows + out["fee_lines"] = rows + return out + + def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None) -> dict[str, Any]: """ 产品按行改价盖到原 TMS 明细上。没点名的费用原样保留。 diff --git a/inquiry-agent/tests/test_land_group_collab.py b/inquiry-agent/tests/test_land_group_collab.py index bde13c6..64a777c 100644 --- a/inquiry-agent/tests/test_land_group_collab.py +++ b/inquiry-agent/tests/test_land_group_collab.py @@ -360,6 +360,42 @@ class LandGroupCollabTests(unittest.TestCase): "只有这张单的销售能调价。航线请继续报价,价格未改。", ) + def test_land_bare_price_defaults_to_land_fee(self) -> None: + """陆运「价格为1RMB/KG」无费用名时默认陆运费,禁止落成海运费(WO118)。""" + from agent.llm.extract_text import extract_product_quote + + land = extract_product_quote("价格为1RMB/KG", business_line="LAND") + rows = list(land.get("fee_rows") or []) + self.assertTrue(rows) + self.assertEqual(str(rows[0].get("name") or ""), "陆运费") + self.assertEqual(str(land.get("total") or ""), "1") + sea = extract_product_quote("价格为1RMB/KG", business_line="SEA") + self.assertEqual(str((sea.get("fee_rows") or [{}])[0].get("name") or ""), "海运费") + air = extract_product_quote("价格为1RMB/KG", business_line="AIR") + self.assertEqual(str((air.get("fee_rows") or [{}])[0].get("name") or ""), "空运费") + + def test_land_requote_replaces_wrong_sea_fee_name(self) -> None: + """旧陆运产品价误落海运费时,重报陆运费应盖掉而不是并排两行。""" + from agent.policy.sea_group_ops import _align_wrong_default_freight, merge_quote_fees + + base = { + "fee_rows": [{"name": "海运费", "amount": "1", "currency": "CNY"}], + "fee_lines": [{"name": "海运费", "amount": "1", "currency": "CNY"}], + "total": "1", + "source_label": "产品报价", + } + overlay = { + "fee_rows": [{"name": "陆运费", "amount": "2", "currency": "CNY"}], + "fee_lines": [{"name": "陆运费", "amount": "2", "currency": "CNY"}], + "total": "2", + "source_label": "产品报价", + } + aligned = _align_wrong_default_freight(base, overlay, business_line="LAND") + merged = merge_quote_fees(aligned, overlay) + names = [str(x.get("name") or "") for x in (merged.get("fee_rows") or [])] + self.assertEqual(names, ["陆运费"]) + self.assertEqual(str((merged.get("fee_rows") or [{}])[0].get("amount") or ""), "2") + if __name__ == "__main__": unittest.main() diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryDashboardController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryDashboardController.java index 7d2f7d1..1433600 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryDashboardController.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryDashboardController.java @@ -27,7 +27,10 @@ public class InquiryDashboardController { @ApiOperation("看板指标") @GetMapping("/metrics") - public Result> metrics(@RequestParam(name = "range", defaultValue = "today") String range) { - return Result.OK(dashboardService.metrics(range)); + public Result> metrics( + @RequestParam(name = "range", defaultValue = "today") String range, + @RequestParam(name = "dateFrom", required = false) String dateFrom, + @RequestParam(name = "dateTo", required = false) String dateTo) { + return Result.OK(dashboardService.metrics(range, dateFrom, dateTo)); } } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryDashboardService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryDashboardService.java index 56825cd..9f20836 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryDashboardService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryDashboardService.java @@ -9,9 +9,13 @@ public interface IInquiryDashboardService { /** * 返回看板指标:metrics + trend + rankings + costDates/costRoutes。 - * rankings 与工单列表同套发起人解析;costRoutes 为近 7 日线路均价(主账工单投影)。 + * rankings 与工单列表同套发起人解析;costRoutes 为选定周期内线路均价(主账工单投影)。 + *

+ * 有 dateFrom/dateTo 时以显式日期为准(与工单列表筛选同源);否则回退 range=today|7d|30d。 * - * @param range today|7d|30d,默认 today + * @param range today|7d|30d,默认 today(无显式日期时) + * @param dateFrom 起日 yyyy-MM-dd,可空 + * @param dateTo 止日 yyyy-MM-dd,可空 */ - Map metrics(String range); + Map metrics(String range, String dateFrom, String dateTo); } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java index c317abb..7a67868 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java @@ -78,19 +78,14 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService { private InquiryQuoteMapper quoteMapper; @Override - public Map metrics(String range) { - LocalDate today = LocalDate.now(ZONE); - LocalDate fromDate; - if ("7d".equalsIgnoreCase(range)) { - fromDate = today.minusDays(6); - } else if ("30d".equalsIgnoreCase(range)) { - fromDate = today.minusDays(29); - } else { - fromDate = today; - } + public Map metrics(String range, String dateFromStr, String dateToStr) { + LocalDate[] bounds = resolveMetricBounds(range, dateFromStr, dateToStr); + LocalDate fromDate = bounds[0]; + LocalDate toDate = bounds[1]; Date from = Date.from(fromDate.atStartOfDay(ZONE).toInstant()); - Date to = Date.from(LocalDateTime.of(today, LocalTime.MAX).atZone(ZONE).toInstant()); + Date to = Date.from(LocalDateTime.of(toDate, LocalTime.MAX).atZone(ZONE).toInstant()); + // 指标 / 排名 / 趋势 / 成本:同一批工单、同一日期窗,与工单列表 dateFrom~dateTo 对齐 LambdaQueryWrapper qw = new LambdaQueryWrapper() .ge(InquiryTicket::getCreateTime, from) .le(InquiryTicket::getCreateTime, to); @@ -127,28 +122,24 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService { metricCards.add(card("avgResp", "内部报价平均响应", "AVG(首次有效回复时间 − 分发时间)", avgResp, true)); - // 近 7 日趋势 + 成本:一次拉周内工单,避免 7 次 count - Date weekFrom = Date.from(today.minusDays(6).atStartOfDay(ZONE).toInstant()); - LambdaQueryWrapper weekQw = new LambdaQueryWrapper() - .ge(InquiryTicket::getCreateTime, weekFrom) - .le(InquiryTicket::getCreateTime, to); - InquiryDataScopeSupport.applySelfOnly(weekQw); - List weekTickets = ticketMapper.selectList(weekQw); - - List trendDates = new ArrayList<>(7); - List> trend = new ArrayList<>(7); + // 趋势 / 成本:按选定起止日逐日铺点(单日则一点;长周期最多 62 天防图爆炸) + List trendDates = new ArrayList<>(); + List> trend = new ArrayList<>(); Map dayIndex = new HashMap<>(); - for (int i = 6; i >= 0; i--) { - LocalDate d = today.minusDays(i); - dayIndex.put(d, trendDates.size()); - String label = d.format(DAY_FMT); + LocalDate cursor = fromDate; + int dayGuard = 0; + while (!cursor.isAfter(toDate) && dayGuard < 62) { + dayIndex.put(cursor, trendDates.size()); + String label = cursor.format(DAY_FMT); trendDates.add(label); Map p = new HashMap<>(); p.put("date", label); p.put("value", 0L); trend.add(p); + cursor = cursor.plusDays(1); + dayGuard++; } - for (InquiryTicket t : weekTickets) { + for (InquiryTicket t : tickets) { LocalDate d = toLocalDate(t.getCreateTime()); Integer idx = d == null ? null : dayIndex.get(d); if (idx == null) { @@ -174,10 +165,12 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService { } List> rankings = buildRankings(tickets, staffByWecom, staffByName); - List> costRoutes = buildCostRoutes(weekTickets, dayIndex, trendDates); + List> costRoutes = buildCostRoutes(tickets, dayIndex, trendDates); Map result = new LinkedHashMap<>(); result.put("range", range == null ? "today" : range); + result.put("dateFrom", fromDate.toString()); + result.put("dateTo", toDate.toString()); result.put("metrics", metricCards); result.put("trend", trend); result.put("rankings", rankings); @@ -186,6 +179,33 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService { return result; } + /** + * 解析看板统计起止日。 + * 优先用显式 dateFrom/dateTo(与工单列表同源);否则按 range=today|7d|30d 相对「今天」。 + */ + private static LocalDate[] resolveMetricBounds(String range, String dateFromStr, String dateToStr) { + if (StringUtils.isNotBlank(dateFromStr) && StringUtils.isNotBlank(dateToStr)) { + LocalDate fromDate = LocalDate.parse(dateFromStr.trim()); + LocalDate toDate = LocalDate.parse(dateToStr.trim()); + if (toDate.isBefore(fromDate)) { + LocalDate tmp = fromDate; + fromDate = toDate; + toDate = tmp; + } + return new LocalDate[]{fromDate, toDate}; + } + LocalDate today = LocalDate.now(ZONE); + LocalDate fromDate; + if ("7d".equalsIgnoreCase(range)) { + fromDate = today.minusDays(6); + } else if ("30d".equalsIgnoreCase(range)) { + fromDate = today.minusDays(29); + } else { + fromDate = today; + } + return new LocalDate[]{fromDate, today}; + } + /** * 人员排名:与工单列表 enrichDisplayFields 同口径。 * 聚合键优先 salesWecomId(同人合并),展示名走 resolveSalesDisplayName。 diff --git a/inquiry-backend/src/views/inquiry/dashboard/dashboard.api.ts b/inquiry-backend/src/views/inquiry/dashboard/dashboard.api.ts index 8d5363f..b1d5e99 100644 --- a/inquiry-backend/src/views/inquiry/dashboard/dashboard.api.ts +++ b/inquiry-backend/src/views/inquiry/dashboard/dashboard.api.ts @@ -1,5 +1,6 @@ /** * 分析看板 API。 + * 显式传 dateFrom/dateTo,与工单列表日期筛选同源;range 仅作无日期时的回退。 */ import { defHttp } from '/@/utils/http/axios'; @@ -7,5 +8,20 @@ enum Api { metrics = '/inquiry/dashboard/metrics', } -export const getDashboardMetrics = (range = 'today') => - defHttp.get({ url: Api.metrics, params: { range } }); +export type DashboardMetricsParams = { + range?: string; + dateFrom?: string; + dateTo?: string; +}; + +export const getDashboardMetrics = (params: DashboardMetricsParams | string = 'today') => { + const query = + typeof params === 'string' + ? { range: params } + : { + range: params.range || 'today', + dateFrom: params.dateFrom, + dateTo: params.dateTo, + }; + return defHttp.get({ url: Api.metrics, params: query }); +}; diff --git a/inquiry-backend/src/views/inquiry/dashboard/index.vue b/inquiry-backend/src/views/inquiry/dashboard/index.vue index 7161215..5f37d13 100644 --- a/inquiry-backend/src/views/inquiry/dashboard/index.vue +++ b/inquiry-backend/src/views/inquiry/dashboard/index.vue @@ -56,11 +56,15 @@ const activePoint = ref(null); const INQUIRY_PLOT_TOP = 36; const INQUIRY_PLOT_BOTTOM = 188; +/** + * 粗粒度 range 仅作后端无 dateFrom/dateTo 时的回退;正式请求一律带显式日期。 + * 注意:不可再把「单日」映射成 today——today 是日历今天,不是所选那一天。 + */ function resolveRange(): string { const from = new Date(dateFrom.value); const to = new Date(dateTo.value); const days = Math.max(0, Math.round((to.getTime() - from.getTime()) / 86400000)); - if (days <= 0) return "today"; + if (days <= 0) return "1d"; if (days <= 6) return "7d"; return "30d"; } @@ -88,7 +92,12 @@ function onDateRangeChange() { async function loadMetrics() { loading.value = true; try { - const res: any = await getDashboardMetrics(resolveRange()); + const req = { + range: resolveRange(), + dateFrom: dateFrom.value, + dateTo: dateTo.value, + }; + const res: any = await getDashboardMetrics(req); const data = res?.result || res || {}; todayMetrics.value = (data.metrics || []) as MetricCard[]; inquiryTrend.value = ((data.trend || []) as any[]).map((p) => ({