看板平均响应按协同分发统计;查价失败可从主账恢复;报价模板匹配与填表更稳,后台改名运营后台。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+242
-24
@@ -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);
|
||||
|
||||
+5
-5
@@ -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;
|
||||
|
||||
+190
-8
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+22
-4
@@ -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;
|
||||
|
||||
+3
-82
@@ -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 页底大块空白占预览高度。
|
||||
|
||||
+28
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+116
@@ -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 列或整张空着。
|
||||
|
||||
+30
@@ -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<>();
|
||||
|
||||
Reference in New Issue
Block a user