陆运产品价无费名时默认陆运费;看板指标与工单列表共用起止日,趋势按日铺点。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 明细上。没点名的费用原样保留。
|
||||
|
||||
@@ -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()
|
||||
|
||||
+5
-2
@@ -27,7 +27,10 @@ public class InquiryDashboardController {
|
||||
|
||||
@ApiOperation("看板指标")
|
||||
@GetMapping("/metrics")
|
||||
public Result<Map<String, Object>> metrics(@RequestParam(name = "range", defaultValue = "today") String range) {
|
||||
return Result.OK(dashboardService.metrics(range));
|
||||
public Result<Map<String, Object>> 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));
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -9,9 +9,13 @@ public interface IInquiryDashboardService {
|
||||
|
||||
/**
|
||||
* 返回看板指标:metrics + trend + rankings + costDates/costRoutes。
|
||||
* rankings 与工单列表同套发起人解析;costRoutes 为近 7 日线路均价(主账工单投影)。
|
||||
* rankings 与工单列表同套发起人解析;costRoutes 为选定周期内线路均价(主账工单投影)。
|
||||
* <p>
|
||||
* 有 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<String, Object> metrics(String range);
|
||||
Map<String, Object> metrics(String range, String dateFrom, String dateTo);
|
||||
}
|
||||
|
||||
+47
-27
@@ -78,19 +78,14 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
private InquiryQuoteMapper quoteMapper;
|
||||
|
||||
@Override
|
||||
public Map<String, Object> 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<String, Object> 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<InquiryTicket> qw = new LambdaQueryWrapper<InquiryTicket>()
|
||||
.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<InquiryTicket> weekQw = new LambdaQueryWrapper<InquiryTicket>()
|
||||
.ge(InquiryTicket::getCreateTime, weekFrom)
|
||||
.le(InquiryTicket::getCreateTime, to);
|
||||
InquiryDataScopeSupport.applySelfOnly(weekQw);
|
||||
List<InquiryTicket> weekTickets = ticketMapper.selectList(weekQw);
|
||||
|
||||
List<String> trendDates = new ArrayList<>(7);
|
||||
List<Map<String, Object>> trend = new ArrayList<>(7);
|
||||
// 趋势 / 成本:按选定起止日逐日铺点(单日则一点;长周期最多 62 天防图爆炸)
|
||||
List<String> trendDates = new ArrayList<>();
|
||||
List<Map<String, Object>> trend = new ArrayList<>();
|
||||
Map<LocalDate, Integer> 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<String, Object> 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<Map<String, Object>> rankings = buildRankings(tickets, staffByWecom, staffByName);
|
||||
List<Map<String, Object>> costRoutes = buildCostRoutes(weekTickets, dayIndex, trendDates);
|
||||
List<Map<String, Object>> costRoutes = buildCostRoutes(tickets, dayIndex, trendDates);
|
||||
|
||||
Map<String, Object> 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。
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -56,11 +56,15 @@ const activePoint = ref<string | null>(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) => ({
|
||||
|
||||
Reference in New Issue
Block a user