机器人显示名与站点标题可配;多段联运列表投影;协同群监听对账;工单附件落存;模板与工单后台补强

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-19 09:41:59 +08:00
co-authored by Cursor
parent 277cdc1c28
commit 2b528758b4
119 changed files with 6760 additions and 3516 deletions
@@ -180,6 +180,18 @@ public class InquiryAgentController {
return Result.OK(out);
}
@ApiOperation("列出绑着协同群的工单(Worker 对账监听,只读)")
@PostMapping("/ticket/listOpenCollab")
public Result<Map<String, Object>> listOpenCollab(HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
Map<String, Object> out = new java.util.LinkedHashMap<>();
out.put("ok", true);
out.put("tickets", workOrderService.listOpenCollabGroups());
return Result.OK(out);
}
@ApiOperation("开始事件异常等待(不改六态)")
@PostMapping("/ticket/startEventWait")
public Result<Map<String, Object>> startEventWait(@RequestBody Map<String, Object> body,
@@ -257,8 +269,10 @@ public class InquiryAgentController {
Map<String, Object> facts = asMap(body == null ? null : body.get("facts"));
@SuppressWarnings("unchecked")
Map<String, Object> quote = asMap(body == null ? null : body.get("quote"));
// 默认 false:激活发模版只发群、不进后台附件;确认落库由智能体显式传 true。
boolean persistAttachment = boolFlag(body, "persistAttachment", "persist_attachment", false);
Map<String, Object> rendered = quoteTemplateService.renderForAgent(
workOrderNo, businessLine, facts, quote, sales);
workOrderNo, businessLine, facts, quote, sales, persistAttachment);
if (rendered != null && Boolean.TRUE.equals(rendered.get("ok"))) {
workOrderService.markQuoteFileIssued(workOrderNo);
}
@@ -477,6 +491,16 @@ public class InquiryAgentController {
return Result.OK(workOrderService.attachFile(body));
}
@ApiOperation("报价附件字节入库(PDF/补传,不改六态)")
@PostMapping("/ticket/attachFileBytes")
public Result<Map<String, Object>> attachFileBytes(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
return Result.OK(workOrderService.attachFileBytes(body));
}
@ApiOperation("产品报价写入新版本")
@PostMapping("/ticket/upsertProductQuote")
public Result<Map<String, Object>> upsertProductQuote(@RequestBody Map<String, Object> body,
@@ -547,6 +571,39 @@ public class InquiryAgentController {
return "";
}
/**
* 读布尔开关;缺省或无法解析时用 defaultValue。
* 为何:激活发模版与确认落库共用 /quote/render,用显式标志区分是否写附件表。
*/
private static boolean boolFlag(Map<String, Object> body, String key1, String key2, boolean defaultValue) {
if (body == null) {
return defaultValue;
}
for (String k : new String[] {key1, key2}) {
if (k == null || !body.containsKey(k)) {
continue;
}
Object v = body.get(k);
if (v == null) {
return defaultValue;
}
if (v instanceof Boolean) {
return (Boolean) v;
}
String s = String.valueOf(v).trim();
if (s.isEmpty() || "null".equalsIgnoreCase(s)) {
return defaultValue;
}
if ("1".equals(s) || "true".equalsIgnoreCase(s) || "yes".equalsIgnoreCase(s)) {
return true;
}
if ("0".equals(s) || "false".equalsIgnoreCase(s) || "no".equalsIgnoreCase(s)) {
return false;
}
}
return defaultValue;
}
@SuppressWarnings("unchecked")
private static Map<String, Object> asMap(Object raw) {
if (raw instanceof Map) {
@@ -258,9 +258,11 @@ public class InquiryQuoteTemplateController {
MediaType media = name.endsWith(".png")
? MediaType.IMAGE_PNG
: MediaType.APPLICATION_PDF;
// 禁止浏览器长缓存:拼接算法升级后旧「左右拼」图会被继续展示
return ResponseEntity.ok()
.contentType(media)
.header(HttpHeaders.CACHE_CONTROL, "private, max-age=3600")
.header(HttpHeaders.CACHE_CONTROL, "no-store, no-cache, must-revalidate")
.header(HttpHeaders.PRAGMA, "no-cache")
.body(new FileSystemResource(path));
}
@@ -102,15 +102,54 @@ public class InquiryWorkOrderController {
@ApiOperation("聚合详情")
@RequiresPermissions("inquiry:workorder:list")
@GetMapping("/view/detail")
public Result<Map<String, Object>> viewDetail(@RequestParam String workOrderNo) {
public Result<Map<String, Object>> viewDetail(
@RequestParam(name = "workOrderNo") String workOrderNo) {
return Result.OK(workOrderService.detailView(workOrderNo));
}
/**
* 工单附件下载:浏览器不直连内网 MinIO,一律经主账鉴权后流式写出。
*/
@AutoLog(value = "工单-附件下载")
@ApiOperation("下载工单附件")
@RequiresPermissions("inquiry:workorder:list")
@GetMapping("/view/attachment/download")
public void downloadAttachment(
@RequestParam(name = "id") String id,
HttpServletResponse response) {
try {
workOrderService.downloadAttachment(id, response);
} catch (IllegalArgumentException | IllegalStateException e) {
try {
response.reset();
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json;charset=UTF-8");
response.setStatus(404);
response.getWriter().write("{\"success\":false,\"message\":\""
+ String.valueOf(e.getMessage()).replace("\"", "'") + "\"}");
} catch (Exception ignored) {
// 写错误响应失败时忽略
}
} catch (Exception e) {
log.error("工单附件下载失败 id={}", id, e);
try {
response.reset();
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json;charset=UTF-8");
response.setStatus(500);
response.getWriter().write("{\"success\":false,\"message\":\"下载失败\"}");
} catch (Exception ignored) {
// ignore
}
}
}
@ApiOperation("按工单号查主表")
@RequiresPermissions("inquiry:workorder:list")
@GetMapping("/queryById")
public Result<InquiryTicket> queryById(@RequestParam(required = false) String id,
@RequestParam(required = false) String workOrderNo) {
public Result<InquiryTicket> queryById(
@RequestParam(name = "id", required = false) String id,
@RequestParam(name = "workOrderNo", required = false) String workOrderNo) {
if (workOrderNo != null && !workOrderNo.isEmpty()) {
Map<String, Object> detail = workOrderService.detailView(workOrderNo);
return Result.OK((InquiryTicket) detail.get("ticket"));
@@ -98,6 +98,14 @@ public class InquiryTicket implements Serializable {
@ApiModelProperty("报价信息(展示)")
private List<InquiryQuote> quotes;
/**
* 列表「附件」摘要。不入库,enrich 时按 inq_attachment 填。
* 详情仍走 detailView 的完整 attachments 列表。
*/
@TableField(exist = false)
@ApiModelProperty("附件元数据(展示)")
private List<InquiryAttachment> attachments;
@ApiModelProperty("成本价展示")
private String costPrice;
@@ -113,8 +113,11 @@ public interface IInquiryQuoteTemplateService extends IService<InquiryQuoteTempl
* <p>
* 只填 Excel 并回文件字节,不转 PDF(LibreOffice 在智能体 Worker 单槽)。
* 失败 message 固定为 QuoteTemplateMatcher.FAIL_MESSAGE。
*
* @param persistAttachment true 时登记 inq_attachment(确认报价落库等);
* false 只回文件供企微发出(激活发模版),不进后台附件列
*/
Map<String, Object> renderForAgent(String workOrderNo, String businessLine,
Map<String, Object> facts, Map<String, Object> quote,
String salesWecomId);
String salesWecomId, boolean persistAttachment);
}
@@ -36,6 +36,17 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
*/
Map<String, Object> detailView(String workOrderNo);
/**
* 后台下载工单附件:按附件 id 鉴权后从对象存储/本地流式写出。
* <p>
* 副作用:只写 HTTP 响应流,不改六态。禁止把内网 MinIO URL 直接暴露给浏览器。
*
* @param attachmentId inq_attachment.id
* @param response HTTP 响应
*/
void downloadAttachment(String attachmentId, javax.servlet.http.HttpServletResponse response)
throws java.io.IOException;
/**
* 六态流转(含关单到「已关闭」)。
*
@@ -141,6 +152,12 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
*/
Map<String, Object> attachFile(Map<String, Object> body);
/**
* 智能体上传报价附件字节(Excel/PDF)并登记 inq_attachment。
* 用于 PDF 转换完成后落库;不改六态。
*/
Map<String, Object> attachFileBytes(Map<String, Object> body);
/**
* 手工报价 / 销售调价写入新版本。销售调价另记一条状态流转备注。
*/
@@ -162,6 +179,12 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
*/
java.util.List<java.util.Map<String, Object>> claimDueNegotiate();
/**
* 列出仍绑着协同群 chat_id 的工单,供 Worker 对账监听名单。
* 只读;含已结束协同(collab_ended),由智能体决定补挂或清人。
*/
java.util.List<java.util.Map<String, Object>> listOpenCollabGroups();
/**
* 开始盯事件异常等待。种类相同不重置起点;种类不同重置并清已告警。不改六态。
*/
@@ -19,6 +19,7 @@ import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.NumberFormat;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
@@ -110,13 +111,15 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
List<Map<String, Object>> metricCards = new ArrayList<>();
// 顺序对齐原型 5 列栅格:上排总量/报价/TMS/销售额/手工,下排待处理/成功率/有效率/响应
metricCards.add(card("inquiry", "询价总量", "周期内询价工单总数", String.valueOf(total), false));
// 件数/金额带千分位;百分比与时长不加(产品:数字千分号,百分比除外)
metricCards.add(card("inquiry", "询价总量", "周期内询价工单总数", formatCount(total), false));
metricCards.add(card("quoted", "报价工单", "已报价工单数(已报价 + 议价中 + 已成交 + 未成交)",
quoted + " 单", false));
formatCount(quoted) + " 单", false));
metricCards.add(card("tms", "标准报价命中率", "命中 TMS 标准价的询价数 ÷ 询价总数 × 100%", pct(tmsHit, total), 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("pending", "待处理工单", "工单总数 − 已成交 − 未成交 − 已关闭",
formatCount(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));
@@ -515,16 +518,36 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
return formatAvgSale(sum.divide(BigDecimal.valueOf(n), 2, RoundingMode.HALF_UP));
}
/** 平均销售额展示:整数不留小数,否则最多两位。 */
/**
* 件数千分位(zh-CN),如 12800 → 12,800。
* 百分比、时长不要走这里。
*/
static String formatCount(long n) {
NumberFormat nf = NumberFormat.getIntegerInstance(Locale.CHINA);
nf.setGroupingUsed(true);
return nf.format(n);
}
/**
* 平均销售额展示:千分位;整数不留小数,否则两位。
* 例:742187952.33 → 742,187,952.33。
*/
static String formatAvgSale(BigDecimal avg) {
if (avg == null) {
return "-";
}
NumberFormat nf = NumberFormat.getNumberInstance(Locale.CHINA);
nf.setGroupingUsed(true);
BigDecimal stripped = avg.stripTrailingZeros();
if (stripped.scale() <= 0) {
return stripped.toPlainString();
nf.setMaximumFractionDigits(0);
nf.setMinimumFractionDigits(0);
return nf.format(stripped);
}
return avg.setScale(2, RoundingMode.HALF_UP).toPlainString();
BigDecimal scaled = avg.setScale(2, RoundingMode.HALF_UP);
nf.setMinimumFractionDigits(2);
nf.setMaximumFractionDigits(2);
return nf.format(scaled);
}
private static String firstNonBlank(String a, String b) {
@@ -12,16 +12,19 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.common.util.CommonUtils;
import org.jeecg.modules.inquiry.entity.InquiryAttachment;
import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplate;
import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplateVersion;
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import org.jeecg.modules.inquiry.mapper.InquiryAttachmentMapper;
import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateMapper;
import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateVersionMapper;
import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper;
import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
import org.jeecg.modules.inquiry.support.InquiryQuoteTemplateBizScope;
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
import org.jeecg.modules.inquiry.support.InquiryWorkOrderAttachmentStore;
import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService;
import org.jeecg.modules.inquiry.template.QuoteTemplateAiParseClient;
import org.jeecg.modules.inquiry.template.QuoteTemplateExcelSupport;
@@ -80,6 +83,12 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
@Autowired(required = false)
private InquiryTicketMapper ticketMapper;
/**
* 出单成功后登记 inq_attachment。测试构造服务时可以没有,此时只回文件不落附件表。
*/
@Autowired(required = false)
private InquiryAttachmentMapper attachmentMapper;
@Autowired
private QuoteTemplateObjectStore objectStore;
@@ -733,13 +742,13 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
/**
* 查找已缓存预览图。
* v5:不再经 POI 改打印属性,避免主题蓝底/合并条带被拆格。
* v10:一律横向适应页宽 + 每页先裁边再上下拼;旧缓存忽略。
*/
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-v5.png");
Path cachedPdf = previewDir.resolve(versionId + "-snapshot-v5.pdf");
Path cachedPng = previewDir.resolve(versionId + "-snapshot-v10.png");
Path cachedPdf = previewDir.resolve(versionId + "-snapshot-v10.pdf");
if (Files.isRegularFile(cachedPng) && Files.size(cachedPng) > 0) {
return cachedPng;
}
@@ -752,8 +761,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-v5.png");
Path cachedPdf = previewDir.resolve(ver.getId() + "-snapshot-v5.pdf");
Path cachedPng = previewDir.resolve(ver.getId() + "-snapshot-v10.png");
Path cachedPdf = previewDir.resolve(ver.getId() + "-snapshot-v10.pdf");
Path source = resolveObjectPath(ver.getObjectKey(), ver.getSha256());
QuoteTemplateSnapshotRenderer.PreviewBytes rendered = snapshotRenderer.render(source, ver.getId());
Path target = rendered.isImage() ? cachedPng : cachedPdf;
@@ -810,13 +819,15 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
/**
* 智能体出单:关键词 → 默认模板 → 失败话术。只填 xlsx。
* <p>
* 副作用:写生成文件到 upload/inquiry/quote-render;成功则模板 useCount+1。
* 副作用:写生成文件到 upload/inquiry/quote-render;成功则模板 useCount+1;
* 仅 persistAttachment=true 时 insert inq_attachment(确认落库)。
* 激活发群模版传 false,避免后台附件列堆满空白报价单模版。
* 线程:跟随 HTTP 请求线程,禁止在此调 LibreOffice。
*/
@Override
public Map<String, Object> renderForAgent(String workOrderNo, String businessLine,
Map<String, Object> facts, Map<String, Object> quote,
String salesWecomId) {
String salesWecomId, boolean persistAttachment) {
Map<String, Object> fail = new LinkedHashMap<>();
fail.put("ok", false);
fail.put("error", "NO_TEMPLATE");
@@ -826,6 +837,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
Map<String, Object> picked = QuoteTemplateMatcher.pick(enabled, businessLine, facts, quote);
if (picked == null) {
log.warn("报价出单无模板 wo={} biz={}", workOrderNo, businessLine);
fail.put("failStage", "PICK_NULL");
return fail;
}
String objectKey = String.valueOf(picked.getOrDefault("objectKey", ""));
@@ -833,6 +845,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
String expectedSha = String.valueOf(picked.getOrDefault("sha256", ""));
if (StringUtils.isBlank(objectKey) || "null".equalsIgnoreCase(objectKey)) {
log.warn("报价出单模板无文件 wo={} template={}", workOrderNo, picked.get("templateId"));
fail.put("failStage", "NO_OBJECT_KEY");
fail.put("templateId", picked.get("templateId"));
return fail;
}
Path source;
@@ -842,6 +856,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
("null".equalsIgnoreCase(expectedSha) ? null : expectedSha));
} catch (Exception e) {
log.warn("报价出单找不到模板文件 wo={} err={}", workOrderNo, e.getMessage());
fail.put("failStage", "RESOLVE_FAILED");
fail.put("templateId", picked.get("templateId"));
return fail;
}
JSONObject mapping;
@@ -851,6 +867,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
: new JSONObject();
} catch (Exception e) {
log.warn("报价出单 mappingJson 非法 wo={} err={}", workOrderNo, e.getMessage());
fail.put("failStage", "BAD_MAPPING");
fail.put("templateId", picked.get("templateId"));
return fail;
}
JSONObject fillFacts = buildFillFacts(
@@ -863,24 +881,86 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
QuoteTemplateExcelSupport.fillWorkbook(source, target, mapping, fillFacts);
byte[] bytes = Files.readAllBytes(target);
bumpUseCount(String.valueOf(picked.get("templateId")));
// 销售看到的名字:工单号 + 后台模板名;附件表带 .xlsx 后缀便于后台识别。
String sheetBase = QuoteSheetFileName.of(safeNo, String.valueOf(picked.getOrDefault("name", "")));
String attachName = sheetBase.endsWith(".xlsx") || sheetBase.endsWith(".XLSX")
? sheetBase : sheetBase + ".xlsx";
String renderedObjectKey = InquiryWorkOrderAttachmentStore.resolveObjectKey(target, attachName, bytes);
// 激活发群:只回字节;确认落库:才写 inq_attachment。
boolean attached = false;
if (persistAttachment) {
attached = persistRenderedAttachment(workOrderNo, attachName, renderedObjectKey, bytes.length);
}
Map<String, Object> ok = new LinkedHashMap<>();
ok.put("ok", true);
ok.put("matchType", picked.get("matchType"));
ok.put("templateId", picked.get("templateId"));
ok.put("templateName", picked.get("name"));
ok.put("versionNo", picked.get("versionNo"));
// 销售看到的名字:工单号 + 后台模板名,不带后缀。磁盘路径仍是带时间戳的 xlsx。
ok.put("fileName", QuoteSheetFileName.of(safeNo, String.valueOf(picked.getOrDefault("name", ""))));
ok.put("fileName", sheetBase);
ok.put("objectKey", renderedObjectKey);
ok.put("filePath", target.toAbsolutePath().toString());
ok.put("fileBase64", java.util.Base64.getEncoder().encodeToString(bytes));
ok.put("fileSize", bytes.length);
ok.put("attachmentSaved", attached);
ok.put("persistAttachmentRequested", persistAttachment);
return ok;
} catch (Exception e) {
log.error("报价出单填表失败 wo={} template={}", workOrderNo, picked.get("templateId"), e);
fail.put("error", "FILL_FAILED");
fail.put("failStage", "FILL_FAILED");
fail.put("templateId", picked.get("templateId"));
return fail;
}
}
/**
* 确认报价等场景:把填好的 Excel 登记到 inq_attachment。
* <p>
* 副作用:insert inq_attachment。失败只记日志,不挡企微发文件。
* 激活发群模版不要调这里——后台附件应只收航线/产品上传与确认落库文件。
*
* @return true 已插入;false 跳过(无工单/无 Mapper/异常)
*/
private boolean persistRenderedAttachment(String workOrderNo, String fileName,
String objectKey, long fileSize) {
if (attachmentMapper == null || ticketMapper == null) {
return false;
}
if (StringUtils.isBlank(workOrderNo) || StringUtils.isBlank(objectKey)) {
return false;
}
try {
InquiryTicket ticket = ticketMapper.selectOne(new LambdaQueryWrapper<InquiryTicket>()
.eq(InquiryTicket::getWorkOrderNo, workOrderNo)
.last("LIMIT 1"));
if (ticket == null) {
log.warn("出单附件登记跳过:工单不存在 wo={}", workOrderNo);
return false;
}
// 同一 objectKey 不重复插,避免 render 重试刷出多行。
Long dup = attachmentMapper.selectCount(new LambdaQueryWrapper<InquiryAttachment>()
.eq(InquiryAttachment::getWorkOrderNo, workOrderNo)
.eq(InquiryAttachment::getObjectKey, objectKey));
if (dup != null && dup > 0) {
return true;
}
InquiryAttachment row = new InquiryAttachment();
row.setTicketId(ticket.getId());
row.setWorkOrderNo(workOrderNo);
row.setFileName(fileName);
row.setObjectKey(objectKey);
row.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
row.setFileSize(fileSize);
row.setCreateTime(new Date());
attachmentMapper.insert(row);
return true;
} catch (Exception e) {
log.warn("出单附件登记失败 wo={} err={}", workOrderNo, e.getMessage());
return false;
}
}
/**
* 把询价中文键与报价费用投影成合同 systemFields,映射点两边都能取到。
*/
@@ -926,7 +1006,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
src.put("customer_name", customer);
src.put("客户名称", customer);
// 报价单「报价日期」格用工单创建日,不拿询价/TMS 报价日期。
// 工单创建日仅作旁路字段;报价单「报价日期」格走询价事实,与群显示一致。
if (ticket != null && ticket.getCreateTime() != null) {
src.put("ticket_create_date",
new java.text.SimpleDateFormat("yyyy-MM-dd").format(ticket.getCreateTime()));
@@ -1036,31 +1116,17 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
if (StringUtils.isNotBlank(vol)) {
out.put("volume_cbm", vol.replaceAll("(?i)\\s*(CBM|立方米|方)\\s*$", "").trim());
}
// 报价单「报价日期」格:优先工单创建日(产品口径)。
// 询价事实里的报价日期只给 TMS 查价/锁舱,不填进这张表的日期格。
// 报价单「报价日期」格与群显示一致:优先询价事实报价日期(空运可为航班日)。
// 没有事实日期时才回退工单创建日,避免空格。
String created = first(src, "ticket_create_date", "createTime", "create_time");
if (StringUtils.isNotBlank(created)) {
String factQuote = first(src, "报价日期", "quote_date", "询价日期", "走货日期", "询价时间",
"走货时间", "发货时间", "发货日期", "出货日期", "出货时间", "提货时间", "提货日期");
if (StringUtils.isNotBlank(factQuote)) {
out.put("quote_date", factQuote.length() >= 10 ? factQuote.substring(0, 10) : factQuote);
} else if (StringUtils.isNotBlank(created)) {
out.put("quote_date", created.length() >= 10 ? created.substring(0, 10) : created);
// #region agent log
try {
java.nio.file.Path dbg = java.nio.file.Paths.get(
"C:/Users/ytd_extdev_0903/incoming/debug-717349.log");
String line = "{\"sessionId\":\"717349\",\"runId\":\"post-fix\",\"hypothesisId\":\"T1\","
+ "\"location\":\"InquiryQuoteTemplateServiceImpl.buildFillFacts\","
+ "\"message\":\"template quote_date from create\","
+ "\"data\":{\"wo\":\"" + StringUtils.defaultString(workOrderNo)
+ "\",\"quote_date\":\"" + out.getString("quote_date")
+ "\",\"fact_quote\":\"" + StringUtils.defaultString(first(src, "报价日期", "quote_date"))
+ "\"},\"timestamp\":" + System.currentTimeMillis() + "}\n";
java.nio.file.Files.write(dbg, line.getBytes(java.nio.charset.StandardCharsets.UTF_8),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND);
} catch (Exception ignore) {
// 调试埋点失败不影响出单
}
// #endregion
} else {
out.put("quote_date", first(src, "报价日期", "quote_date", "询价日期", "走货日期", "询价时间",
"走货时间", "发货时间", "发货日期", "出货日期", "出货时间", "提货时间", "提货日期"));
out.put("quote_date", "");
}
out.put("trade_terms", first(src, "贸易条款", "trade_terms"));
out.put("运输分类", first(src, "运输分类"));
@@ -27,11 +27,13 @@ import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper;
import org.jeecg.modules.inquiry.support.AgentSystemExceptionRetryClient;
import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService;
import org.jeecg.modules.inquiry.support.InquiryDataScopeSupport;
import org.jeecg.modules.inquiry.support.InquiryMultiSegmentSupport;
import org.jeecg.modules.inquiry.support.InquiryQuoteDisplaySupport;
import org.jeecg.modules.inquiry.support.InquiryQuoteFileSupport;
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
import org.jeecg.modules.inquiry.support.InquiryTicketFactsSupport;
import org.jeecg.modules.inquiry.support.InquiryTicketPriceSupport;
import org.jeecg.modules.inquiry.support.InquiryWorkOrderAttachmentStore;
import org.jeecg.modules.inquiry.support.InquiryWorkOrderExcelExport;
import org.jeecg.modules.inquiry.tms.InquiryTmsQueryService;
import org.springframework.beans.factory.annotation.Autowired;
@@ -155,6 +157,50 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
return qw;
}
/**
* 后台附件下载:校验工单数据范围后,从 MinIO/本地流式写出。
* 兼容历史 object_key=http://127.0.0.1:9000/...,浏览器不再直连内网 MinIO。
*/
@Override
public void downloadAttachment(String attachmentId, HttpServletResponse response) throws IOException {
if (StringUtils.isBlank(attachmentId)) {
throw new IllegalArgumentException("附件 id 不能为空");
}
InquiryAttachment row = attachmentMapper.selectById(attachmentId);
if (row == null) {
throw new IllegalArgumentException("附件不存在");
}
InquiryTicket ticket = findByWorkOrderNo(row.getWorkOrderNo());
if (ticket == null) {
throw new IllegalArgumentException("工单不存在: " + row.getWorkOrderNo());
}
InquiryDataScopeSupport.assertTicketBizAllowed(ticket);
java.io.InputStream in = InquiryWorkOrderAttachmentStore.openStream(row.getObjectKey());
if (in == null) {
throw new IllegalStateException("附件文件不存在或对象存储不可达");
}
String fileName = StringUtils.defaultIfBlank(row.getFileName(), "attachment.bin");
String contentType = StringUtils.defaultIfBlank(row.getContentType(), "application/octet-stream");
response.reset();
response.setCharacterEncoding("UTF-8");
response.setContentType(contentType);
String encoded = java.net.URLEncoder.encode(fileName, "UTF-8").replace("+", "%20");
response.setHeader("Content-Disposition",
"attachment; filename=\"" + fileName.replace("\"", "") + "\"; filename*=UTF-8''" + encoded);
if (row.getFileSize() != null && row.getFileSize() > 0) {
response.setContentLengthLong(row.getFileSize());
}
try (java.io.InputStream stream = in;
java.io.OutputStream out = response.getOutputStream()) {
byte[] buf = new byte[8192];
int n;
while ((n = stream.read(buf)) >= 0) {
out.write(buf, 0, n);
}
out.flush();
}
}
@Override
public Map<String, Object> detailView(String workOrderNo) {
InquiryTicket ticket = findByWorkOrderNo(workOrderNo);
@@ -203,6 +249,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
}
Map<String, String> staffNameByWecom = loadStaffNames(tickets);
Map<String, List<InquiryQuote>> quotesByNo = loadQuotesByWorkOrder(tickets);
Map<String, List<InquiryAttachment>> attachByNo = loadAttachmentsByWorkOrder(tickets);
for (InquiryTicket ticket : tickets) {
if (ticket == null) {
continue;
@@ -224,6 +271,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
}
}
ticket.setQuotes(displayRows);
// 列表附件列:与详情同源 inq_attachment,避免列表永远是「-」。
ticket.setAttachments(attachByNo.getOrDefault(ticket.getWorkOrderNo(), Collections.emptyList()));
}
}
@@ -310,6 +359,36 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
return out;
}
/**
* 列表一次取出本页工单附件,避免按单 N+1。
* 副作用:只读 inq_attachment。
*/
private Map<String, List<InquiryAttachment>> loadAttachmentsByWorkOrder(List<InquiryTicket> tickets) {
List<String> nos = new ArrayList<>();
for (InquiryTicket ticket : tickets) {
if (ticket != null && StringUtils.isNotBlank(ticket.getWorkOrderNo())) {
nos.add(ticket.getWorkOrderNo());
}
}
if (nos.isEmpty()) {
return Collections.emptyMap();
}
List<InquiryAttachment> rows = attachmentMapper.selectList(new LambdaQueryWrapper<InquiryAttachment>()
.in(InquiryAttachment::getWorkOrderNo, nos)
.orderByAsc(InquiryAttachment::getCreateTime));
Map<String, List<InquiryAttachment>> out = new HashMap<>();
if (rows == null) {
return out;
}
for (InquiryAttachment row : rows) {
if (row == null || StringUtils.isBlank(row.getWorkOrderNo())) {
continue;
}
out.computeIfAbsent(row.getWorkOrderNo(), k -> new ArrayList<>()).add(row);
}
return out;
}
/**
* 按企微 userid 取员工管理姓名。建单和智能体读单都走这里,禁止把岗位词「销售」当人名。
* 查不到返回空,调用方再用 userid。副作用:只读员工表。
@@ -611,10 +690,20 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
ticket.setFclLcl(StringUtils.trimToNull(facts.get("整柜或拼柜")));
ticket.setContainerSpec(StringUtils.trimToNull(facts.get("箱型箱量")));
ticket.setFactsJson(JSON.toJSONString(facts));
String originTitle = StringUtils.defaultString(ticket.getOrigin());
String destTitle = StringUtils.defaultString(ticket.getDestination());
ticket.setDemandTitle(originTitle + " → " + destTitle);
ticket.setDemandDetail("FP:" + fingerprint(req));
// 多段只带 segments_json:投影到列表列,否则后台起运/目的/品名全空。
boolean multiProjected = InquiryMultiSegmentSupport.projectTicketColumns(ticket, facts);
if (!multiProjected) {
String originTitle = StringUtils.defaultString(ticket.getOrigin());
String destTitle = StringUtils.defaultString(ticket.getDestination());
ticket.setDemandTitle(originTitle + " → " + destTitle);
ticket.setDemandDetail("FP:" + fingerprint(req));
} else if (StringUtils.isBlank(ticket.getDemandDetail())
|| !ticket.getDemandDetail().startsWith("段")) {
// 保留指纹便于相似单检索,段摘要写在 demandDetail 时再附指纹一行。
ticket.setDemandDetail(StringUtils.defaultString(ticket.getDemandDetail())
+ (StringUtils.isNotBlank(ticket.getDemandDetail()) ? "\n" : "")
+ "FP:" + fingerprint(req));
}
ticket.setStatus(TicketStatuses.INQUIRING);
ticket.setVersion(1);
ticket.setCreateBy("agent");
@@ -737,6 +826,9 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
if ("LAND".equals(line)) {
return "陆运";
}
if ("MULTI".equals(line)) {
return "多段联运";
}
return line;
}
@@ -1081,6 +1173,12 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
}
}
InquiryTicketFactsSupport.apply(ticket, facts);
// 多段改写 segments_json 后,同步投影起运/目的等到列表列。
Map<String, String> merged = InquiryTicketFactsSupport.parseFacts(ticket.getFactsJson());
if (merged == null) {
merged = facts;
}
InquiryMultiSegmentSupport.projectTicketColumns(ticket, merged);
ticket.setUpdateTime(new Date());
updateById(ticket);
out.put("ok", true);
@@ -1273,21 +1371,39 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
/**
* 协同结束:在协同字段里打结束标记。不改六态、不清 chat_id(方便群里回话)。
* 转人工冻结后仍允许标记结束:销售要清群时,不能被 handoff_frozen 挡住。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> endCollabGroup(String workOrderNo) {
Map<String, Object> body = new HashMap<>();
body.put("workOrderNo", workOrderNo);
Map<String, String> flag = new LinkedHashMap<>();
flag.put("__ended", "1");
body.put("facts", flag);
Map<String, Object> out = patchCollabFacts(body);
out.put("collab_ended", Boolean.TRUE);
Map<String, Object> out = new LinkedHashMap<>();
InquiryTicket ticket = findByWorkOrderNo(workOrderNo);
if (ticket != null) {
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "end_collab_group", "agent", "协同结束");
if (ticket == null) {
out.put("ok", false);
out.put("error", "ticket_not_found");
out.put("collab_ended", Boolean.FALSE);
return out;
}
Map<String, String> merged = new LinkedHashMap<>();
if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) {
JSONObject old = JSON.parseObject(ticket.getCollabFactsJson());
if (old != null) {
for (String key : old.keySet()) {
if (old.get(key) != null) {
merged.put(key, String.valueOf(old.get(key)));
}
}
}
}
merged.put("__ended", "1");
ticket.setCollabFactsJson(JSON.toJSONString(merged));
ticket.setUpdateTime(new Date());
updateById(ticket);
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "end_collab_group", "agent", "协同结束");
out.put("ok", true);
out.put("facts", merged);
out.put("collab_ended", Boolean.TRUE);
out.put("work_order_no", workOrderNo);
return out;
}
@@ -1319,6 +1435,74 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
return out;
}
/**
* 智能体上传报价附件字节并登记元数据。
* <p>
* 用于 PDF 转换完成后落库(Excel 出单在模板 render 内登记)。
* 副作用:写对象存储 + insert inq_attachment;不改六态。
* 线程:请求线程短事务;上传失败则整单回滚登记。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> attachFileBytes(Map<String, Object> body) {
String no = text(body, "workOrderNo", "work_order_no");
InquiryTicket ticket = findByWorkOrderNo(no);
Map<String, Object> out = new LinkedHashMap<>();
if (ticket == null) {
out.put("ok", false);
out.put("error", "ticket_not_found");
return out;
}
String fileName = text(body, "fileName", "file_name");
if (StringUtils.isBlank(fileName)) {
fileName = no + "_quote.bin";
}
String contentType = text(body, "contentType", "content_type");
String b64 = text(body, "fileBase64", "file_base64");
if (StringUtils.isBlank(b64)) {
out.put("ok", false);
out.put("error", "empty_file");
return out;
}
byte[] bytes;
try {
bytes = java.util.Base64.getDecoder().decode(b64);
} catch (Exception e) {
out.put("ok", false);
out.put("error", "bad_base64");
return out;
}
if (bytes == null || bytes.length == 0) {
out.put("ok", false);
out.put("error", "empty_file");
return out;
}
String objectKey;
try {
objectKey = InquiryWorkOrderAttachmentStore.uploadBytes(
fileName, bytes, contentType);
} catch (Exception e) {
out.put("ok", false);
out.put("error", "upload_failed");
out.put("message", e.getMessage());
return out;
}
InquiryAttachment row = new InquiryAttachment();
row.setTicketId(ticket.getId());
row.setWorkOrderNo(no);
row.setFileName(fileName);
row.setObjectKey(objectKey);
row.setContentType(contentType);
row.setFileSize((long) bytes.length);
row.setCreateTime(new Date());
attachmentMapper.insert(row);
out.put("ok", true);
out.put("work_order_no", no);
out.put("file_name", fileName);
out.put("objectKey", objectKey);
return out;
}
/**
* 手工报价 / 销售调价:新插一条 inq_quote,来源固定为手工报价,不覆盖 TMS 那一行。
* 销售调价必须再写一条状态流转备注(当时时间点)。询价中/协商中可迁已报价。
@@ -1692,20 +1876,63 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
ticket.setUpdateTime(now);
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "negotiate_followup", "agent", "协商再问已发出");
out.add(toDueNegotiateRow(ticket));
// #region agent log
try {
java.nio.file.Path dbg = java.nio.file.Paths.get(
"d:/own/友通达询价智能体(新)/debug-44a132.log");
String line = "{\"sessionId\":\"44a132\",\"runId\":\"post-fix\",\"hypothesisId\":\"H-dup\","
+ "\"location\":\"claimDueNegotiate\",\"message\":\"claimed_clear_followup\","
+ "\"data\":{\"workOrderNo\":\"" + ticket.getWorkOrderNo() + "\"},"
+ "\"timestamp\":" + System.currentTimeMillis() + "}\n";
java.nio.file.Files.write(dbg, line.getBytes(java.nio.charset.StandardCharsets.UTF_8),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND);
} catch (Exception ignored) {
// 调试日志失败不挡认领
}
return out;
}
/**
* 列出仍绑着协同群的工单,供智能体对账 Redis 监听名单。
* <p>
* 只读;上限 500,按更新时间倒序。含 collab_ended,已结束的由 Worker 清人摘监听。
* 不加全局锁,不改六态。
*/
@Override
public List<Map<String, Object>> listOpenCollabGroups() {
List<InquiryTicket> rows = list(new LambdaQueryWrapper<InquiryTicket>()
.isNotNull(InquiryTicket::getCollabChatId)
.ne(InquiryTicket::getCollabChatId, "")
.orderByDesc(InquiryTicket::getUpdateTime)
.last("LIMIT 500"));
List<Map<String, Object>> out = new ArrayList<>();
if (rows == null || rows.isEmpty()) {
return out;
}
for (InquiryTicket ticket : rows) {
if (ticket == null) {
continue;
}
// #endregion
String chat = StringUtils.trimToEmpty(ticket.getCollabChatId());
if (StringUtils.isBlank(chat)) {
continue;
}
Map<String, Object> one = new LinkedHashMap<>();
one.put("work_order_no", ticket.getWorkOrderNo());
one.put("workOrderNo", ticket.getWorkOrderNo());
one.put("status", StringUtils.defaultString(ticket.getStatus()));
one.put("business_line", StringUtils.defaultString(ticket.getBusinessLine()));
one.put("businessLine", StringUtils.defaultString(ticket.getBusinessLine()));
one.put("collab_chat_id", chat);
one.put("collabChatId", chat);
List<String> members = parseMemberIds(ticket.getCollabMemberIds());
one.put("collab_member_ids", members);
one.put("collabMemberIds", members);
one.put("memberIds", members);
Map<String, String> collabFacts = new LinkedHashMap<>();
if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) {
JSONObject parsed = JSON.parseObject(ticket.getCollabFactsJson());
if (parsed != null) {
for (String key : parsed.keySet()) {
if (parsed.get(key) != null) {
collabFacts.put(key, String.valueOf(parsed.get(key)));
}
}
}
}
boolean ended = "1".equals(collabFacts.get("__ended"))
|| "true".equalsIgnoreCase(StringUtils.trimToEmpty(collabFacts.get("__ended")));
one.put("collab_ended", ended);
one.put("collabEnded", ended);
out.add(one);
}
return out;
}
@@ -106,6 +106,8 @@ public final class InquiryDataScopeSupport {
}
sql.append('\'').append(codes.get(i)).append('\'');
}
// 多段联运:有任一业务线权限即可见(与 ticketInScope 对齐)。
sql.append(",'MULTI'");
sql.append(") OR ((business_line IS NULL OR business_line = '') AND (");
for (int i = 0; i < marks.size(); i++) {
if (i > 0) {
@@ -113,6 +115,7 @@ public final class InquiryDataScopeSupport {
}
sql.append("biz_type_label LIKE '%").append(marks.get(i)).append("%'");
}
sql.append(" OR biz_type_label LIKE '%多段%' OR biz_type_label LIKE '%联运%'");
sql.append(")))");
qw.apply(sql.toString());
}
@@ -141,8 +144,11 @@ public final class InquiryDataScopeSupport {
public static List<String> ticketBizOptionsForCurrentUser() {
Set<String> allowed = InquiryQuoteTemplateBizScope.currentAllowedOrNull();
List<String> labels = new ArrayList<String>(InquiryRoleBizScope.orderedLabels(allowed));
if (allowed == null) {
labels.add("多段联运");
// 不限制,或已有任一运输业务权限时,下拉可筛多段联运。
if (allowed == null || !allowed.isEmpty()) {
if (!labels.contains("多段联运")) {
labels.add("多段联运");
}
}
return labels;
}
@@ -0,0 +1,325 @@
package org.jeecg.modules.inquiry.support;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Pattern;
/**
* 多段联运:把 facts.segments_json 投影到工单列表列,供后台检索与摘要展示。
* <p>
* 职责:解析段列表;写入起运/目的/品名/件数/重量/体积/需求标题;不改六态、不建子工单。
* 副作用:只改传入的 ticket / facts 映射(调用方负责落库)。
* 为何:建单时智能体只传 segments_json,若不投影,后台 origin/destination 全空,像「没存工单号」。
*/
public final class InquiryMultiSegmentSupport {
private static final Pattern PROMPT_NOISE = Pattern.compile(
"请确认|请复制粘贴|通过回复|告知我以下|补充以下信息");
private InquiryMultiSegmentSupport() {
}
/**
* 若 facts 含 segments_json,把整票摘要列写到 ticket。
* 整票口径:首段起运 → 末段目的;品名/货量优先取未污染的段值。
*
* @return true 已识别为多段并投影;false 不是多段或解析失败
*/
public static boolean projectTicketColumns(InquiryTicket ticket, Map<String, String> facts) {
if (ticket == null || facts == null || facts.isEmpty()) {
return false;
}
List<JSONObject> segments = parseSegments(facts.get("segments_json"));
if (segments.isEmpty()) {
segments = parseSegments(facts.get("segmentsJson"));
}
if (segments.isEmpty()) {
return false;
}
JSONObject first = segments.get(0);
JSONObject last = segments.get(segments.size() - 1);
Map<String, String> firstFacts = segmentFacts(first);
Map<String, String> lastFacts = segmentFacts(last);
String origin = firstNonBlank(
firstFacts.get("起运港"), firstFacts.get("起运地"), firstFacts.get("始发站"));
String dest = firstNonBlank(
lastFacts.get("目的港"), lastFacts.get("目的地"), lastFacts.get("目的站"));
if (StringUtils.isNotBlank(origin)) {
ticket.setOrigin(origin);
}
if (StringUtils.isNotBlank(dest)) {
ticket.setDestination(dest);
}
String goods = bestGoodsName(segments);
if (StringUtils.isNotBlank(goods)) {
ticket.setGoodsName(goods);
}
Integer pieces = firstInt(segments, "件数");
if (pieces != null) {
ticket.setPieces(pieces);
}
BigDecimal weight = firstDecimal(segments, "毛重", "重量");
if (weight != null) {
ticket.setWeightKg(weight);
}
BigDecimal volume = firstVolume(segments);
if (volume != null) {
ticket.setVolumeCbm(volume);
}
if (StringUtils.isBlank(ticket.getBizTypeLabel())
|| "MULTI".equalsIgnoreCase(ticket.getBizTypeLabel())) {
ticket.setBizTypeLabel("多段联运");
}
String title = "多段联运 · "
+ (StringUtils.isNotBlank(origin) ? origin : "-")
+ " → "
+ (StringUtils.isNotBlank(dest) ? dest : "-");
ticket.setDemandTitle(title);
ticket.setDemandDetail(segmentDetailLines(segments));
return true;
}
/**
* 后台展示用:从 factsJson 解出各段一行摘要。
*/
public static List<String> segmentSummaryLines(String factsJson) {
List<String> lines = new ArrayList<>();
if (StringUtils.isBlank(factsJson)) {
return lines;
}
try {
JSONObject root = JSON.parseObject(factsJson);
if (root == null) {
return lines;
}
List<JSONObject> segments = parseSegments(root.getString("segments_json"));
if (segments.isEmpty()) {
segments = parseSegments(root.getString("segmentsJson"));
}
for (JSONObject seg : segments) {
Map<String, String> f = segmentFacts(seg);
String mode = modeWord(seg.getString("mode"));
int index = seg.getIntValue("index");
String origin = firstNonBlank(f.get("起运港"), f.get("起运地"), f.get("始发站"));
String dest = firstNonBlank(f.get("目的港"), f.get("目的地"), f.get("目的站"));
String goods = cleanText(f.get("品名"));
StringBuilder sb = new StringBuilder();
sb.append("段").append(index > 0 ? index : lines.size() + 1).append(mode);
if (StringUtils.isNotBlank(origin) || StringUtils.isNotBlank(dest)) {
sb.append(" ").append(StringUtils.defaultIfBlank(origin, "-"))
.append(" → ").append(StringUtils.defaultIfBlank(dest, "-"));
}
if (StringUtils.isNotBlank(goods)) {
sb.append(" · ").append(goods);
}
lines.add(sb.toString());
}
} catch (Exception ignore) {
// 坏 JSON 不当摘要
}
return lines;
}
private static List<JSONObject> parseSegments(String raw) {
List<JSONObject> out = new ArrayList<>();
if (StringUtils.isBlank(raw)) {
return out;
}
try {
Object parsed = JSON.parse(raw.trim());
if (parsed instanceof JSONArray) {
JSONArray arr = (JSONArray) parsed;
for (int i = 0; i < arr.size(); i++) {
Object one = arr.get(i);
if (one instanceof JSONObject) {
out.add((JSONObject) one);
}
}
}
} catch (Exception ignore) {
// 解析失败当无段
}
return out;
}
private static Map<String, String> segmentFacts(JSONObject seg) {
Map<String, String> out = new LinkedHashMap<>();
if (seg == null) {
return out;
}
Object raw = seg.get("facts");
if (!(raw instanceof JSONObject)) {
return out;
}
JSONObject facts = (JSONObject) raw;
for (String key : facts.keySet()) {
String val = cleanText(facts.getString(key));
if (StringUtils.isNotBlank(val)) {
out.put(key, val);
}
}
return out;
}
private static String segmentDetailLines(List<JSONObject> segments) {
List<String> lines = new ArrayList<>();
for (JSONObject seg : segments) {
Map<String, String> f = segmentFacts(seg);
String mode = modeWord(seg.getString("mode"));
int index = seg.getIntValue("index");
String origin = firstNonBlank(f.get("起运港"), f.get("起运地"), f.get("始发站"));
String dest = firstNonBlank(f.get("目的港"), f.get("目的地"), f.get("目的站"));
lines.add("段" + (index > 0 ? index : lines.size() + 1) + mode
+ " " + StringUtils.defaultIfBlank(origin, "-")
+ " → " + StringUtils.defaultIfBlank(dest, "-"));
}
return String.join("\n", lines);
}
private static String bestGoodsName(List<JSONObject> segments) {
String fallback = "";
for (JSONObject seg : segments) {
String goods = cleanText(segmentFacts(seg).get("品名"));
if (StringUtils.isBlank(goods)) {
continue;
}
if (!PROMPT_NOISE.matcher(goods).find()) {
return goods;
}
if (StringUtils.isBlank(fallback)) {
fallback = goods;
}
}
return fallback;
}
private static Integer firstInt(List<JSONObject> segments, String key) {
for (JSONObject seg : segments) {
String raw = segmentFacts(seg).get(key);
if (StringUtils.isBlank(raw)) {
continue;
}
String digits = raw.replaceAll("[^0-9]", "");
if (StringUtils.isBlank(digits)) {
continue;
}
try {
return Integer.parseInt(digits);
} catch (NumberFormatException ignore) {
// 下一笔
}
}
return null;
}
private static BigDecimal firstDecimal(List<JSONObject> segments, String... keys) {
for (JSONObject seg : segments) {
Map<String, String> f = segmentFacts(seg);
for (String key : keys) {
String raw = f.get(key);
if (StringUtils.isBlank(raw)) {
continue;
}
String num = raw.replaceAll("[^0-9.]", "");
if (StringUtils.isBlank(num)) {
continue;
}
try {
return new BigDecimal(num);
} catch (Exception ignore) {
// 下一笔
}
}
}
return null;
}
private static BigDecimal firstVolume(List<JSONObject> segments) {
for (JSONObject seg : segments) {
String raw = segmentFacts(seg).get("体积");
if (StringUtils.isBlank(raw)) {
continue;
}
// 尺寸 1.1*2.1*2.2 → 相乘;已是 CBM 数字则直接用。
String[] parts = raw.toLowerCase(Locale.ROOT)
.replace("×", "*").replace("x", "*").split("\\*");
if (parts.length >= 2) {
try {
BigDecimal product = BigDecimal.ONE;
for (String p : parts) {
String n = p.replaceAll("[^0-9.]", "");
if (StringUtils.isBlank(n)) {
product = null;
break;
}
product = product.multiply(new BigDecimal(n));
}
if (product != null) {
return product;
}
} catch (Exception ignore) {
// 退回数字解析
}
}
String num = raw.replaceAll("[^0-9.]", "");
if (StringUtils.isNotBlank(num)) {
try {
return new BigDecimal(num);
} catch (Exception ignore) {
// 下一笔
}
}
}
return null;
}
private static String modeWord(String mode) {
String m = StringUtils.trimToEmpty(mode).toUpperCase(Locale.ROOT);
if ("LAND".equals(m)) {
return "陆运";
}
if ("AIR".equals(m)) {
return "空运";
}
if ("SEA".equals(m)) {
return "海运";
}
return StringUtils.defaultIfBlank(mode, "");
}
private static String cleanText(String value) {
String raw = StringUtils.trimToEmpty(value);
if (raw.isEmpty()) {
return "";
}
if (PROMPT_NOISE.matcher(raw).find() && raw.length() > 24) {
return "";
}
return raw;
}
private static String firstNonBlank(String... values) {
if (values == null) {
return "";
}
for (String v : values) {
if (StringUtils.isNotBlank(v)) {
return v.trim();
}
}
return "";
}
}
@@ -251,12 +251,18 @@ public final class InquiryRoleBizScope {
}
/**
* {@code allowed == null} 表示不限制。多段、认不出的工单对有范围的账号不可见。
* {@code allowed == null} 表示不限制。
* 多段联运任一段都可能涉及空运/海运/陆运:有任一业务范围的账号可见,避免后台像「没存这张单」。
*/
public static boolean ticketInScope(String businessLine, String bizTypeLabel, Set<String> allowed) {
if (allowed == null) {
return true;
}
String line = StringUtils.trimToEmpty(businessLine).toUpperCase(Locale.ROOT);
String label = StringUtils.trimToEmpty(bizTypeLabel);
if ("MULTI".equals(line) || label.contains("多段") || label.contains("联运")) {
return !allowed.isEmpty();
}
String biz = classifyTicketBiz(businessLine, bizTypeLabel);
return StringUtils.isNotBlank(biz) && allowed.contains(biz);
}
@@ -42,6 +42,14 @@ public final class InquiryTicketDisplaySupport {
}
addLine(lines, join(heading, " · "));
// 多段:把各段一行摘要补进需求详情,避免后台只见空路线。
if ("MULTI".equals(businessLine) || containsChinese(lineLabel, "多段") || containsChinese(lineLabel, "联运")) {
List<String> segs = InquiryMultiSegmentSupport.segmentSummaryLines(ticket.getFactsJson());
for (String seg : segs) {
addLine(lines, seg);
}
}
List<String> quantities = new ArrayList<>();
if ("AIR".equals(businessLine) || containsChinese(lineLabel, "空运")) {
addLabel(quantities, "件数", ticket.getPieces());
@@ -0,0 +1,175 @@
package org.jeecg.modules.inquiry.support;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.common.constant.CommonConstant;
import org.jeecg.common.util.CommonUtils;
import org.jeecg.common.util.MinioUtil;
import org.jeecg.common.util.oss.OssBootUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* 工单报价附件字节落对象存储。
* <p>
* 职责:把 Excel/PDF 字节写成可解析的 objectKey(MinIO/OSS/本地)。
* 不写 inq_attachment 行(由 WorkOrderService / 模板出单写入)。
* 为何:出单与 PDF 转换两处都要同一套上传语义,禁止各写一套路径。
*/
@Component
public class InquiryWorkOrderAttachmentStore {
/** 与报价模板同桶分区,但子目录独立,避免和模板原件混在一起。 */
public static final String BIZ_PATH = "inquiry/work-order-files";
private static String uploadPath = "";
private static String uploadType = CommonConstant.UPLOAD_TYPE_LOCAL;
@Value("${jeecg.path.upload:}")
public void setUploadPath(String path) {
uploadPath = StringUtils.defaultString(path);
}
@Value("${jeecg.uploadType:local}")
public void setUploadType(String type) {
uploadType = StringUtils.defaultIfBlank(type, CommonConstant.UPLOAD_TYPE_LOCAL);
}
/**
* 上传附件字节,返回 objectKey(本地相对路径或 MinIO/OSS URL)。
*
* @param fileName 销售可见文件名(含后缀)
* @param bytes 文件内容
* @param contentType MIME,可空
* @return 非空 objectKey
*/
public static String uploadBytes(String fileName, byte[] bytes, String contentType) throws Exception {
if (bytes == null || bytes.length == 0) {
throw new IllegalArgumentException("empty_bytes");
}
String safeName = CommonUtils.getFileName(StringUtils.defaultIfBlank(fileName, "quote.bin"));
String type = StringUtils.defaultIfBlank(uploadType, CommonConstant.UPLOAD_TYPE_LOCAL);
if (CommonConstant.UPLOAD_TYPE_LOCAL.equalsIgnoreCase(type)) {
Path dir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH);
Files.createDirectories(dir);
Path target = dir.resolve(System.currentTimeMillis() + "-" + safeName);
Files.write(target, bytes);
return BIZ_PATH + "/" + target.getFileName().toString();
}
// 库里只存相对键(inquiry/work-order-files/...),禁止存 http://127.0.0.1:9000/...
// 浏览器打不开内网 MinIO;后台下载走主账鉴权代理。
String relative = BIZ_PATH + "/" + System.currentTimeMillis() + "-" + safeName;
try (InputStream in = new ByteArrayInputStream(bytes)) {
if (CommonConstant.UPLOAD_TYPE_MINIO.equalsIgnoreCase(type)) {
String url = MinioUtil.upload(in, relative);
if (StringUtils.isBlank(url)) {
throw new IllegalStateException("minio_upload_empty");
}
return relative;
}
String url = OssBootUtil.upload(in, relative);
if (StringUtils.isBlank(url)) {
throw new IllegalStateException("oss_upload_empty");
}
// OSS 外网 URL 可直接给浏览器;仍优先相对键,下载统一走主账。
return relative;
}
}
/**
* 按 inq_attachment.object_key 打开可读流(本地盘 / MinIO / 兼容历史全量 URL)。
* <p>
* 历史行可能是 http://127.0.0.1:9000/{bucket}/{relative},需剥成桶内对象名。
* 调用方负责关闭流。找不到返回 null。
*/
public static InputStream openStream(String objectKey) {
String key = StringUtils.trimToEmpty(objectKey);
if (StringUtils.isBlank(key)) {
return null;
}
String type = StringUtils.defaultIfBlank(uploadType, CommonConstant.UPLOAD_TYPE_LOCAL);
try {
if (CommonConstant.UPLOAD_TYPE_LOCAL.equalsIgnoreCase(type)
&& !key.startsWith("http://") && !key.startsWith("https://")) {
Path file = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), key.replace("/", java.io.File.separator));
if (!Files.isRegularFile(file)) {
return null;
}
return Files.newInputStream(file);
}
String objectName = toMinioObjectName(key);
if (StringUtils.isBlank(objectName)) {
return null;
}
return MinioUtil.getMinioFile(MinioUtil.getBucketName(), objectName);
} catch (Exception e) {
return null;
}
}
/**
* 把相对键或历史 MinIO URL 收成桶内 objectName。
*/
public static String toMinioObjectName(String objectKey) {
String key = StringUtils.trimToEmpty(objectKey).replace("\\", "/");
if (StringUtils.isBlank(key)) {
return "";
}
if (!key.startsWith("http://") && !key.startsWith("https://")) {
return key.replaceFirst("^/+", "");
}
// http://host:9000/bucket/inquiry/work-order-files/xxx.xlsx
String bucket = StringUtils.defaultString(MinioUtil.getBucketName());
int idx = key.indexOf("/" + bucket + "/");
if (idx >= 0) {
return key.substring(idx + bucket.length() + 2);
}
// 兜底:取 URL path 去掉开头 /
try {
java.net.URI uri = java.net.URI.create(key);
String path = StringUtils.defaultString(uri.getPath()).replaceFirst("^/+", "");
if (StringUtils.isNotBlank(bucket) && path.startsWith(bucket + "/")) {
return path.substring(bucket.length() + 1);
}
return path;
} catch (Exception e) {
return "";
}
}
/**
* 已落盘的填好表:优先原路径登记;MinIO/OSS 时再推一份到对象存储。
* <p>
* 失败不抛给业务:返回本地相对键,后台仍能按 uploadPath 找回。
*
* @param localFile render 写出的本地 xlsx
* @param displayName 销售可见名
* @param bytes 同文件字节
*/
public static String resolveObjectKey(Path localFile, String displayName, byte[] bytes) {
String localKey = null;
if (localFile != null) {
String root = StringUtils.defaultIfBlank(uploadPath, "").replace("\\", "/");
String abs = localFile.toAbsolutePath().toString().replace("\\", "/");
if (StringUtils.isNotBlank(root) && abs.startsWith(root.replace("\\", "/"))) {
localKey = abs.substring(root.length()).replaceFirst("^/+", "");
} else {
localKey = BIZ_PATH + "/render/" + localFile.getFileName();
}
}
String type = StringUtils.defaultIfBlank(uploadType, CommonConstant.UPLOAD_TYPE_LOCAL);
if (CommonConstant.UPLOAD_TYPE_LOCAL.equalsIgnoreCase(type)) {
return StringUtils.defaultIfBlank(localKey, BIZ_PATH + "/" + displayName);
}
try {
return uploadBytes(displayName, bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
} catch (Exception e) {
return StringUtils.defaultIfBlank(localKey, BIZ_PATH + "/" + displayName);
}
}
}
@@ -30,7 +30,6 @@ import java.util.*;
@Slf4j
public final class QuoteTemplateExcelSupport {
private static final Set<String> YELLOW_RGB = new HashSet<>(Arrays.asList(
"FFFFFF00", "FFFF00", "FFFFEB9C", "FFFFC000", "FFFFFF99",
"FFFFCC00", "FFFFD966", "FFFFF2CC", "FFFFE699"
@@ -213,6 +212,12 @@ public final class QuoteTemplateExcelSupport {
private QuoteTemplateExcelSupport() {
}
/**
* TMS 国内车型别名 → 报价单表头(如 6.8厢车 → 6.8/5T)。
* 懒加载 contracts/tms-domestic-vehicle-mapping-v1.json;加载失败则空表,禁止瞎猜。
*/
private static volatile Map<String, String> TMS_DOMESTIC_ALIAS_TO_TEMPLATE;
/**
* 加载 contracts 合同(优先 classpath,其次仓库相对路径由调用方传入)。
*/
@@ -432,8 +437,9 @@ public final class QuoteTemplateExcelSupport {
* @return sheet / colHeaders / rows(每行含 rowNo + cells)
*/
public static JSONObject buildSheetPreviewGrid(Path xlsxPath, JSONObject mapping) throws Exception {
final int maxRows = 40;
final int maxCols = 16;
// 宽表(海运/集拼)常超过 16 列;上限放宽避免预览右侧被截断
final int maxRows = 60;
final int maxCols = 40;
JSONObject out = new JSONObject(true);
try (InputStream in = Files.newInputStream(xlsxPath); Workbook wb = WorkbookFactory.create(in)) {
if (wb.getNumberOfSheets() <= 0) {
@@ -2264,6 +2270,15 @@ public final class QuoteTemplateExcelSupport {
int startRow = resolveFeeDataStartRow(sheet, region, anchorRef.row);
// 映射缺单位/单价/数量列时,按表头补齐,否则明细有值也写不进 Excel
columns = mergeFeeColumnsFromHeader(sheet, startRow, columns);
// 海运三段:表头「项目/Items」就是费目名列,不是空运「项目」骨架列。
// 若误带 project=A=name,会把费目名盖成「起运港」,并冲掉海运费用区标题(WO202610180032)。
String tmsSource = StringUtils.trimToEmpty(region.getString("tmsSource"));
boolean seaFeeBucket = "polCostItems".equals(tmsSource)
|| "oceanCostItems".equals(tmsSource)
|| "podCostItems".equals(tmsSource);
if (seaFeeBucket) {
stripSeaFakeProjectColumn(columns);
}
// 数据起点整行已标黄:本段是模板固定占位(如陆运「项目二」),整段跳过
if (rowLooksYellowFixed(sheet, startRow)) {
log.info("费用区起点为黄底固定区,跳过赋值 region={} startRow={}", region.getString("id"), startRow);
@@ -2288,9 +2303,17 @@ public final class QuoteTemplateExcelSupport {
applyDynamicContainerHeader(sheet, region, columns, facts, yellow);
}
// 空运模板有「空运段/起运港/目的地/其它」骨架时:费用落到对应段,禁止顺填盖掉空运段首行
int projectColIdx = StringUtils.isBlank(columns.getString("project"))
// 海运三段禁止走空运项目骨架(否则费目名列被写成「起运港」)
int projectColIdx = seaFeeBucket || StringUtils.isBlank(columns.getString("project"))
? -1
: CellReference.convertColStringToIndex(columns.getString("project").trim());
// 项目列与费目名同列时当作无项目列,避免盖字
if (projectColIdx >= 0 && StringUtils.isNotBlank(columns.getString("name"))) {
int nameColIdx = CellReference.convertColStringToIndex(columns.getString("name").trim());
if (projectColIdx == nameColIdx) {
projectColIdx = -1;
}
}
boolean placeByProject = projectColIdx >= 0
&& hasAirProjectSkeleton(sheet, startRow, stopRow, projectColIdx);
List<Integer> writeRows = placeByProject
@@ -2309,17 +2332,19 @@ public final class QuoteTemplateExcelSupport {
// 不整行跳过:备注「发生时收取」常标黄,整行跳过会把费用名留成模板样例(如 ZUH)。
// 黄格仍由 writeFeeCol 逐格跳过。
JSONObject rowObj = rows.getJSONObject(r);
String projectVal = normalizeAirProjectLabel(
firstNonBlank(rowObj.getString("project"), rowObj.getString("segment")));
// 已有分段 project 原样用;无则按费目名兜底;附加费名禁止留在「空运段」
if (StringUtils.isBlank(projectVal)) {
projectVal = isAirWeightFeeName(rowObj.getString("name")) ? "空运段" : "起运港";
} else if ("空运段".equals(projectVal) && !isAirWeightFeeName(rowObj.getString("name"))) {
projectVal = "起运港";
if (projectColIdx >= 0) {
String projectVal = normalizeAirProjectLabel(
firstNonBlank(rowObj.getString("project"), rowObj.getString("segment")));
// 已有分段 project 原样用;无则按费目名兜底;附加费名禁止留在「空运段」
if (StringUtils.isBlank(projectVal)) {
projectVal = isAirWeightFeeName(rowObj.getString("name")) ? "空运段" : "起运港";
} else if ("空运段".equals(projectVal) && !isAirWeightFeeName(rowObj.getString("name"))) {
projectVal = "起运港";
}
rowObj.put("project", projectVal);
// 项目列:已在正确分段时只保证骨架文案,禁止把「空运段」骨架改成「起运港」
writeAirProjectCell(sheet, rowIdx, projectColIdx, projectVal, yellow, sheet.getSheetName());
}
rowObj.put("project", projectVal);
// 项目列:已在正确分段时只保证骨架文案,禁止把「空运段」骨架改成「起运港」
writeAirProjectCell(sheet, rowIdx, projectColIdx, projectVal, yellow, sheet.getSheetName());
writeFeeCol(sheet, rowIdx, columns.getString("name"), rowObj.getString("name"), yellow, sheet.getSheetName());
writeFeeCol(sheet, rowIdx, columns.getString("unit"), rowObj.getString("unit"), yellow, sheet.getSheetName());
writeFeeCol(sheet, rowIdx, columns.getString("quantity"),
@@ -3122,6 +3147,7 @@ public final class QuoteTemplateExcelSupport {
writeFeeCol(sheet, ri, col, price, yellow, sheet.getSheetName());
}
}
} else {
}
}
}
@@ -3367,7 +3393,8 @@ public final class QuoteTemplateExcelSupport {
* 国内段 / 境外段各写对应 TMS 腿的完整起讫。
* <p>
* 优先用 selected_land_route.legs(销售已选线路);否则从多条备选里挑一条多腿线路。
* 单腿门到门(境内→境外)时:两段都写完整起讫(产品口径 A,WO202610140014),不发明口岸。
* <b>仅陆运跨境整车</b>:无中转(不足两腿)时只填境外段完整起讫+运价,国内段留空;
* 有中转(≥两腿)时国内段取首腿、境外段取末腿。不发明口岸。
* WO202610120001:备选混在 route_rows 时若要求整表同 segmentSequence,会退回切片导致口岸空。
*/
static JSONArray routesForLandRouteSection(JSONArray routes, Sheet sheet, JSONObject region,
@@ -3388,6 +3415,25 @@ public final class QuoteTemplateExcelSupport {
return routes;
}
JSONArray preferred = preferredLandRouteLegs(facts, routes);
// 仅陆运跨境整车:无中转站(单腿)→ 国内段不写,境外段写完整门到门起讫与运价。
// 识别:运输类型/模板名含「跨境整车」,或表上同时有国内段/境外段分区(该类模板专属)。
boolean ftl = isLandCrossBorderFtl(facts, preferred)
|| sheetHasDomesticAndOverseasSections(sheet);
boolean noTransit = preferred == null || preferred.size() < 2;
if (ftl && noTransit && ("domestic".equals(kind) || "overseas".equals(kind))) {
if ("domestic".equals(kind)) {
return new JSONArray();
}
JSONObject seed = preferred != null && !preferred.isEmpty()
? preferred.getJSONObject(0) : routes.getJSONObject(0);
JSONArray overseasOnly = new JSONArray();
JSONObject one = sliceCrossBorderOd(seed, "overseas_full");
if (one != null && (StringUtils.isNotBlank(one.getString("origin"))
|| StringUtils.isNotBlank(one.getString("destination")))) {
overseasOnly.add(one);
}
return overseasOnly;
}
JSONArray byLeg = pickLandRouteLegsForSection(preferred, kind);
if (byLeg != null && !byLeg.isEmpty()) {
return byLeg;
@@ -3403,6 +3449,44 @@ public final class QuoteTemplateExcelSupport {
return sliced;
}
/**
* 是否陆运跨境整车(产品口径:无中转只填境外段仅作用于此类,不影响集拼/中港/国内整车)。
*/
static boolean isLandCrossBorderFtl(JSONObject facts, JSONArray routes) {
String fromFacts = firstNonBlank(
facts == null ? null : facts.getString("运输类型"),
facts == null ? null : facts.getString("landTransportType"),
facts == null ? null : facts.getString("land_transport_type"),
facts == null ? null : facts.getString("transportType"),
facts == null ? null : facts.getString("templateName"),
facts == null ? null : facts.getString("template_name"));
if (looksLandCrossBorderFtlToken(fromFacts)) {
return true;
}
if (routes != null) {
for (int i = 0; i < routes.size(); i++) {
JSONObject row = routes.getJSONObject(i);
if (row == null) {
continue;
}
if (looksLandCrossBorderFtlToken(firstNonBlank(
row.getString("landTransportType"), row.getString("land_transport_type"),
row.getString("运输类型")))) {
return true;
}
}
}
return false;
}
static boolean looksLandCrossBorderFtlToken(String token) {
if (StringUtils.isBlank(token)) {
return false;
}
String t = token.replaceAll("\\s+", "");
return t.contains("跨境整车") || "CROSS_BORDER_FTL".equalsIgnoreCase(t);
}
/**
* 出单优先腿:已选线路 legs &gt; 同 segmentSequence 且腿数最多的备选 &gt; 原数组。
*/
@@ -3528,9 +3612,13 @@ public final class QuoteTemplateExcelSupport {
String origin = firstNonBlank(row.getString("origin"), row.getString("originCode"));
String dest = firstNonBlank(row.getString("destination"), row.getString("destinationCode"));
JSONObject out = new JSONObject(true);
// 产品口径 A:单腿门到门(境内→境外)时国内段/境外段都写完整起讫,避免半边空白。
boolean doorToDoor = looksDomesticPlace(origin) && looksOverseasPlace(dest);
if (doorToDoor && ("domestic".equals(kind) || "overseas".equals(kind))) {
// overseas_full:跨境整车无中转只写境外段时,完整门到门起讫(不依赖地名识别)。
if ("overseas_full".equals(kind)) {
out.put("origin", origin);
out.put("destination", dest);
} else if (doorToDoor && ("domestic".equals(kind) || "overseas".equals(kind))) {
// 非跨境整车或其它模板仍可走门到门双写;跨境整车无中转已在 routesForLandRouteSection 短路。
out.put("origin", origin);
out.put("destination", dest);
} else if ("domestic".equals(kind)) {
@@ -3602,7 +3690,8 @@ public final class QuoteTemplateExcelSupport {
String t = text.replaceAll("\\s+", "");
String[] marks = {
"吉隆坡", "胡志明", "河内", "海防", "岘港", "谅山", "同登", "老街", "芒街",
"春疆", "曼谷", "清迈", "金边", "西哈努克", "新加坡", "雅加达", "万象", "仰光",
"春疆", "北宁", "BacNinh", "Bac Ninh", "曼谷", "清迈", "金边", "西哈努克",
"新加坡", "雅加达", "万象", "仰光",
"马来", "越南", "泰国", "柬埔寨", "老挝", "缅甸", "印尼", "菲律宾",
"Hồ", "ChiMinh", "Kuala", "Lumpur", "Hanoi", "Bangkok", "Saigon", "Phnom"
};
@@ -3923,7 +4012,9 @@ public final class QuoteTemplateExcelSupport {
String price = firstNonBlank(rowObj == null ? null : rowObj.getString("salePrice"),
rowObj == null ? null : rowObj.getString("costPrice"),
rowObj == null ? null : rowObj.getString("amount"));
if (StringUtils.isNotBlank(vehicleType) && vehicleTypeMatchesSection(vehicleType, key, sectionKind)) {
boolean matched = StringUtils.isNotBlank(vehicleType)
&& vehicleTypeMatchesSection(vehicleType, key, sectionKind);
if (matched) {
return price;
}
// 3) 仅一列且无车型信息时,允许写入该列(整车单列模板)
@@ -3936,25 +4027,114 @@ public final class QuoteTemplateExcelSupport {
/**
* 车型列是否对得上当前段表头。
* <p>
* 跨境整车:询价/TMS 常写国内标注「3T」,国外段表头是「3.5T」;
* 境外段用国内→国外映射后再比,国内段反之。禁止把 3T 误当成所有 T 列。
* 1) 字面/安全子串;2) TMS 国内别名表(6.8厢车→6.8/5T,合同 tms-domestic-vehicle-mapping-v1);
* 3) 跨境整车国内↔境外吨位换算(3T↔3.5T)。禁止未收录别名瞎猜。
*/
static boolean vehicleTypeMatchesSection(String vehicleType, String headerKey, String sectionKind) {
if (vehicleTypeEquals(vehicleType, headerKey)) {
boolean direct = vehicleTypeEquals(vehicleType, headerKey);
String kind = StringUtils.trimToEmpty(sectionKind);
if (direct) {
return true;
}
// TMS 国内别名 → 报价单表头(国内整车 sectionKind 常为空,必须在跨境换算之前做)
String tmsMapped = mapTmsDomesticAliasToTemplate(vehicleType);
if (StringUtils.isNotBlank(tmsMapped) && vehicleTypeEquals(tmsMapped, headerKey)) {
return true;
}
String kind = StringUtils.trimToEmpty(sectionKind);
if ("overseas".equals(kind)) {
String mapped = mapDomesticVehicleToOverseas(vehicleType);
return StringUtils.isNotBlank(mapped) && vehicleTypeEquals(mapped, headerKey);
boolean overseasHit = StringUtils.isNotBlank(mapped) && vehicleTypeEquals(mapped, headerKey);
return overseasHit;
}
if ("domestic".equals(kind)) {
String mapped = mapOverseasVehicleToDomestic(vehicleType);
return StringUtils.isNotBlank(mapped) && vehicleTypeEquals(mapped, headerKey);
boolean domesticHit = StringUtils.isNotBlank(mapped) && vehicleTypeEquals(mapped, headerKey);
return domesticHit;
}
return false;
}
/**
* TMS/询价车型文案 → 国内整车报价单表头。
* <p>
* 例:6.8厢车 → 6.8/5T。未收录返回空。对照表见 contracts/tms-domestic-vehicle-mapping-v1.json。
*/
static String mapTmsDomesticAliasToTemplate(String vehicleType) {
if (StringUtils.isBlank(vehicleType)) {
return "";
}
Map<String, String> map = tmsDomesticAliasToTemplate();
String raw = vehicleType.trim();
String hit = map.get(raw);
if (StringUtils.isNotBlank(hit)) {
return hit;
}
// 去空格后再查一次(TMS 偶发「6.8 厢车」)
String compact = raw.replace(" ", "");
hit = map.get(compact);
if (StringUtils.isNotBlank(hit)) {
return hit;
}
// 键不区分全角斜杠/米字差异时:扫一遍规范化相等
String norm = normalizeVehicleToken(compact);
for (Map.Entry<String, String> e : map.entrySet()) {
if (norm.equals(normalizeVehicleToken(e.getKey()))) {
return e.getValue();
}
}
return "";
}
private static Map<String, String> tmsDomesticAliasToTemplate() {
Map<String, String> local = TMS_DOMESTIC_ALIAS_TO_TEMPLATE;
if (local != null) {
return local;
}
synchronized (QuoteTemplateExcelSupport.class) {
if (TMS_DOMESTIC_ALIAS_TO_TEMPLATE == null) {
TMS_DOMESTIC_ALIAS_TO_TEMPLATE = loadTmsDomesticVehicleMapping();
}
return TMS_DOMESTIC_ALIAS_TO_TEMPLATE;
}
}
/**
* 读 classpath 合同;失败返回空 Map(出单不炸,只是别名对不上)。
*/
private static Map<String, String> loadTmsDomesticVehicleMapping() {
Map<String, String> out = new LinkedHashMap<>();
try {
ClassPathResource res = new ClassPathResource("contracts/tms-domestic-vehicle-mapping-v1.json");
if (!res.exists()) {
log.warn("缺少 contracts/tms-domestic-vehicle-mapping-v1.json,TMS 车型别名无法映射到报价单表头");
return out;
}
try (InputStream in = res.getInputStream()) {
byte[] buf = new byte[4096];
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
int n;
while ((n = in.read(buf)) >= 0) {
bos.write(buf, 0, n);
}
JSONObject root = JSON.parseObject(new String(bos.toByteArray(), StandardCharsets.UTF_8));
JSONObject alias = root == null ? null : root.getJSONObject("domesticAliasToTemplate");
if (alias != null) {
for (String key : alias.keySet()) {
String val = alias.getString(key);
if (StringUtils.isNotBlank(key) && StringUtils.isNotBlank(val)) {
out.put(key.trim(), val.trim());
}
}
}
}
log.info("已加载 TMS 国内车型映射 {} 条", out.size());
} catch (Exception e) {
log.warn("加载 TMS 国内车型映射失败: {}", e.getMessage());
}
return out;
}
/**
* 国内整车标注 → 跨境国外段表头。WO202610080006:3T 对 3.5T。
* 未收录的返回空,禁止瞎猜。
@@ -4399,7 +4579,7 @@ public final class QuoteTemplateExcelSupport {
continue;
}
for (String n : needles) {
if (text.contains(n)) {
if (text.contains(n) || stopTextMatchesAlias(n, text)) {
return r;
}
}
@@ -4409,6 +4589,31 @@ public final class QuoteTemplateExcelSupport {
return last + 1;
}
/**
* 停行别名:映射写「海运费用」,模板常写「海运费/Ocean Freight」。
* 只认分区标题(带 / 或 Ocean),避免费目名「海运舱单费」误停。
*/
static boolean stopTextMatchesAlias(String needle, String cellText) {
if (StringUtils.isBlank(needle) || StringUtils.isBlank(cellText)) {
return false;
}
String n = needle.trim();
String t = cellText.trim();
if ("海运费用".equals(n) || "海运费".equals(n)) {
return t.startsWith("海运费/") || t.startsWith("海运费用/")
|| t.toLowerCase(Locale.ROOT).startsWith("ocean freight")
|| (t.startsWith("海运费") && t.contains("/"));
}
if ("始发港费用汇总".equals(n) || "始发港费用小计".equals(n)) {
return t.contains("始发港费用汇总") || t.contains("始发港费用小计")
|| t.toLowerCase(Locale.ROOT).contains("pol local");
}
if ("目的港费用".equals(n) || "目的港费用汇总".equals(n)) {
return t.startsWith("目的港费用") || t.toLowerCase(Locale.ROOT).contains("destination charges");
}
return false;
}
/**
* 需要的行数若会撞到 stop 行,则在 stop 前插入并复制样式行。
*
@@ -6894,6 +7099,27 @@ public final class QuoteTemplateExcelSupport {
if (isLandQuoteFactCode(matched)) {
return matched;
}
// TMS 别名(6.8厢车)先对到表头(6.8/5T),再取填表码
String templateHeader = mapTmsDomesticAliasToTemplate(name);
if (StringUtils.isNotBlank(templateHeader)) {
if (FILL_BY_LABEL.containsKey(templateHeader)) {
String code = FILL_BY_LABEL.get(templateHeader);
if (isLandQuoteFactCode(code)) {
return code;
}
}
String withFee = templateHeader.endsWith("费用") ? templateHeader : templateHeader + "费用";
if (FILL_BY_LABEL.containsKey(withFee)) {
String code = FILL_BY_LABEL.get(withFee);
if (isLandQuoteFactCode(code)) {
return code;
}
}
String viaLand = LandQuoteTemplateFields.codeForFeeName(templateHeader);
if (isLandQuoteFactCode(viaLand)) {
return viaLand;
}
}
return "";
}
@@ -7014,9 +7240,10 @@ public final class QuoteTemplateExcelSupport {
if (slash > 0) {
primary = text.substring(0, slash).trim();
}
// 空运表头「项目 | 费用名称」两列:项目单独记,勿盖掉费用名称
// 空运:「项目 | 费用名称」两列;海运:「项目/Items」单列即费目名。
// 先记下项目候选,有独立费用名称列才认 project,否则当 name(WO032)。
if ("项目".equals(primary) || "项目".equals(text) || text.startsWith("项目/")) {
cols.put("project", c);
cols.put("_projectCandidate", c);
} else if (isFeeNameHeader(text)) {
cols.put("name", c);
} else if (isContainerTypeHeader(text)) {
@@ -7041,12 +7268,40 @@ public final class QuoteTemplateExcelSupport {
cols.put("unit_price", c);
}
}
Integer projectCand = cols.get("_projectCandidate");
cols.remove("_projectCandidate");
if (projectCand != null) {
Integer nameCol = cols.get("name");
if (nameCol != null && !nameCol.equals(projectCand)) {
cols.put("project", projectCand);
} else {
cols.put("name", projectCand);
}
}
if (!cols.containsKey("name")) {
cols.put("name", 0);
}
return cols;
}
/**
* 海运费用区:去掉误识别的 project 列(与 name 同列或仅有「项目/Items」)。
* 副作用:修改 columns;线程:调用方线程。
*/
static void stripSeaFakeProjectColumn(JSONObject columns) {
if (columns == null) {
return;
}
String project = StringUtils.trimToEmpty(columns.getString("project"));
if (project.isEmpty()) {
return;
}
String name = StringUtils.trimToEmpty(columns.getString("name"));
if (name.isEmpty() || project.equalsIgnoreCase(name)) {
columns.remove("project");
}
}
/**
* 只从费用表头行取列。禁止扫到「货物数量」把数量列认成 A。
*/
@@ -16,13 +16,24 @@ import java.io.InputStream;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Enumeration;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;
/**
* 管理端报价模板「原表截图」:本机 LibreOffice 把 xlsx 转 PDF,再用 PDFBox 等比例渲 PNG。
@@ -30,7 +41,8 @@ import java.util.stream.Stream;
* 为何不用 LibreOffice 直接转 PNG:其 pdf→png 滤镜在部分版本会把页内容非等比拉伸,
* 后台预览出现「字被拉高/压扁」。PDFBox 按页 MediaBox 均匀换算 DPI,保证 1:1 像素比例。
* 为何放在主账:后台预览不应依赖智能体进程;与出单 PDF 错开靠文件锁互斥。
* 禁止 POI 先改打印再转:会丢掉 WPS 主题色与合并条带(跨境集拼蓝底表头拆成独立格)。
* 禁止 POI 重写整簿再转:会丢掉 WPS 主题色与合并条带(跨境集拼蓝底表头拆成独立格)。
* 宽表预览:仅 ZIP 改 worksheet 打印属性(横向 + 适应页宽),避免海运/集拼右侧被 A4 竖版裁切。
* 禁止:接到企微回调 / 智能体出单热路径;禁止本类起第二台 soffice 并行。
* 副作用:写临时目录与预览缓存;短阻塞当前 HTTP 线程(仅管理端预览)。
*/
@@ -84,8 +96,9 @@ public class QuoteTemplateSnapshotRenderer {
try {
Path localXlsx = workDir.resolve("source.xlsx");
Files.copy(xlsxPath, localXlsx, StandardCopyOption.REPLACE_EXISTING);
// 禁止 POI 重写后再转:主题色/合并条带会被拆成独立格子(跨境集拼蓝底表头)。
// 原表打印属性原样交给 soffice,保证后台截图与 WPS 打开一致。
// 禁止 POI 重写整簿:主题色/合并条带会被拆格。
// 仅 ZIP 补丁打印:横向 + 适应页宽 + 实占打印区;多页再在 PNG 阶段拼接。
patchPrintFitLandscape(localXlsx);
Path pdf = convert(soffice, localXlsx, workDir, "pdf:calc_pdf_Export", ".pdf");
if (pdf == null || !Files.isRegularFile(pdf)) {
throw new IllegalStateException("LibreOffice 未生成 PDF");
@@ -108,17 +121,31 @@ public class QuoteTemplateSnapshotRenderer {
}
/**
* PDF 首页等比例渲 PNG:PDFBox 按页尺寸 × DPI 均匀换算,禁止非等比拉伸。
* 渲完后裁掉近白页边,避免 A4 页底大块空白占预览高度。
* PDF 等比例渲 PNG:PDFBox 按页尺寸 × DPI 均匀换算,禁止非等比拉伸。
* <p>
* 宽表可能被 LibreOffice 拆成多页;只渲第 1 页会丢后续条款/盖章。
* 多页一律上下拼接(勿左右拼,否则条款会被甩到右侧像「分成两栏」)。
* 每页先裁近白边再拼,减少页间空白。
*/
private byte[] pdfToPngEqualRatio(Path pdf) throws IOException {
try (PDDocument doc = PDDocument.load(pdf.toFile())) {
if (doc.getNumberOfPages() < 1) {
int pageCount = doc.getNumberOfPages();
if (pageCount < 1) {
return null;
}
PDFRenderer renderer = new PDFRenderer(doc);
BufferedImage image = renderer.renderImageWithDPI(0, PREVIEW_DPI, ImageType.RGB);
List<BufferedImage> pages = new ArrayList<>();
for (int i = 0; i < pageCount; i++) {
// 每页先裁近白边,再上下拼,避免「第1页底空白 + 第2页顶内容」中间留巨大空隙
BufferedImage pageImg = renderer.renderImageWithDPI(i, PREVIEW_DPI, ImageType.RGB);
pages.add(trimNearWhiteMargins(pageImg, 6));
}
BufferedImage image = stitchPdfPages(pages);
int beforeW = image.getWidth();
int beforeH = image.getHeight();
image = trimNearWhiteMargins(image, 8);
int afterW = image.getWidth();
int afterH = image.getHeight();
ByteArrayOutputStream buf = new ByteArrayOutputStream();
if (!ImageIO.write(image, "png", buf)) {
throw new IOException("ImageIO 无法写出 PNG");
@@ -127,6 +154,41 @@ public class QuoteTemplateSnapshotRenderer {
}
}
/**
* 把多页 PDF 渲成的图上下拼成一张。
* <p>
* LibreOffice Calc 导出多页几乎总是「行方向续页」(上→下),不是左右分页。
* 误按横向页左右拼会把第 2 页条款/盖章甩到右侧,中间大块空白——空运也会中招。
*/
static BufferedImage stitchPdfPages(List<BufferedImage> pages) {
if (pages == null || pages.isEmpty()) {
return null;
}
if (pages.size() == 1) {
return pages.get(0);
}
int maxW = 0;
int totalH = 0;
for (BufferedImage p : pages) {
maxW = Math.max(maxW, p.getWidth());
totalH += p.getHeight();
}
BufferedImage out = new BufferedImage(maxW, totalH, BufferedImage.TYPE_INT_RGB);
java.awt.Graphics2D g = out.createGraphics();
try {
g.setColor(java.awt.Color.WHITE);
g.fillRect(0, 0, maxW, totalH);
int y = 0;
for (BufferedImage p : pages) {
g.drawImage(p, 0, y, null);
y += p.getHeight();
}
} finally {
g.dispose();
}
return out;
}
/**
* 裁掉四周近白色边距,四周各留 {@code pad} 像素,避免预览弹窗底下留一大块空白。
* <p>
@@ -221,6 +283,130 @@ public class QuoteTemplateSnapshotRenderer {
return r >= 240 && g >= 240 && b >= 240;
}
/**
* ZIP 级改打印:横向 + fitToWidth,不经 POI,保留主题色与合并条带。
* 只动 xl/worksheets/sheet*.xml;失败则保留原文件(预览可能仍裁切)。
*/
static void patchPrintFitLandscape(Path xlsx) {
if (xlsx == null || !Files.isRegularFile(xlsx)) {
return;
}
Path tmp = xlsx.resolveSibling(xlsx.getFileName().toString() + ".print-fit.tmp");
try {
try (ZipFile zin = new ZipFile(xlsx.toFile());
ZipOutputStream zout = new ZipOutputStream(Files.newOutputStream(tmp))) {
Enumeration<? extends ZipEntry> entries = zin.entries();
while (entries.hasMoreElements()) {
ZipEntry inEntry = entries.nextElement();
byte[] data = readAll(zin.getInputStream(inEntry));
String name = inEntry.getName();
if (name.startsWith("xl/worksheets/sheet") && name.endsWith(".xml")) {
String xml = new String(data, StandardCharsets.UTF_8);
// 一律横向+适应页宽:海运双栏表列数不多但版心宽,按列数阈值会漏补丁再裁右侧。
// 多页时靠「每页先裁边再上下拼」消化空运等续页,避免中间留白。
String patched = patchSheetPrintXml(xml);
if (!patched.equals(xml)) {
data = patched.getBytes(StandardCharsets.UTF_8);
}
}
ZipEntry outEntry = new ZipEntry(name);
zout.putNextEntry(outEntry);
zout.write(data);
zout.closeEntry();
}
}
try {
Files.move(tmp, xlsx, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException ex) {
Files.move(tmp, xlsx, StandardCopyOption.REPLACE_EXISTING);
}
} catch (Exception e) {
log.warn("预览打印补丁跳过 file={} err={}", xlsx.getFileName(), e.getMessage());
try {
Files.deleteIfExists(tmp);
} catch (IOException ignored) {
// ignore
}
}
}
/** 从单元格引用估算用到的最大列号(A=1);估不出时返回 0。 */
static int estimateUsedCols(String xml) {
if (StringUtils.isBlank(xml)) {
return 0;
}
Matcher m = Pattern.compile("\\br=\"([A-Z]+)(\\d+)\"").matcher(xml);
int max = 0;
while (m.find()) {
String col = m.group(1);
int n = 0;
for (int i = 0; i < col.length(); i++) {
n = n * 26 + (col.charAt(i) - 'A' + 1);
}
if (n > max) {
max = n;
}
}
return max;
}
/**
* worksheet XML:打开 fitToPage,横向,适应页宽、高度不限页数。
* 去掉固定 scale,避免宽表仍按 100% 竖版裁右侧。
*/
static String patchSheetPrintXml(String xml) {
if (StringUtils.isBlank(xml)) {
return xml;
}
String out = xml;
if (Pattern.compile("<pageSetUpPr\\b", Pattern.CASE_INSENSITIVE).matcher(out).find()) {
out = out.replaceAll("(?i)<pageSetUpPr\\b[^>]*?/>", "<pageSetUpPr fitToPage=\"1\"/>");
out = out.replaceAll("(?i)<pageSetUpPr\\b[^>]*>\\s*</pageSetUpPr>",
"<pageSetUpPr fitToPage=\"1\"/>");
} else if (Pattern.compile("<sheetPr\\b[^>]*/>", Pattern.CASE_INSENSITIVE).matcher(out).find()) {
out = out.replaceFirst("(?i)<sheetPr(\\b[^/]*)/>",
"<sheetPr$1><pageSetUpPr fitToPage=\"1\"/></sheetPr>");
} else if (Pattern.compile("<sheetPr\\b", Pattern.CASE_INSENSITIVE).matcher(out).find()) {
out = out.replaceFirst("(?i)(<sheetPr\\b[^>]*>)",
"$1<pageSetUpPr fitToPage=\"1\"/>");
} else {
out = out.replaceFirst("(?i)(<worksheet\\b[^>]*>)",
"$1<sheetPr><pageSetUpPr fitToPage=\"1\"/></sheetPr>");
}
Pattern pageSetup = Pattern.compile("(?i)<pageSetup\\b([^>]*?)(/?)>");
Matcher m = pageSetup.matcher(out);
if (m.find()) {
StringBuffer sb = new StringBuffer();
m.reset();
while (m.find()) {
String attrs = m.group(1);
attrs = attrs.replaceAll("(?i)\\s+scale=\"[^\"]*\"", "");
attrs = attrs.replaceAll("(?i)\\s+fitToWidth=\"[^\"]*\"", "");
attrs = attrs.replaceAll("(?i)\\s+fitToHeight=\"[^\"]*\"", "");
attrs = attrs.replaceAll("(?i)\\s+orientation=\"[^\"]*\"", "");
String selfClose = m.group(2);
String repl;
// A3 横向给宽表更多页宽;仍保留 fitToWidth,避免竖版 A4 裁右侧
attrs = attrs.replaceAll("(?i)\\s+paperSize=\"[^\"]*\"", "");
if ("/".equals(selfClose)) {
repl = "<pageSetup" + attrs
+ " paperSize=\"8\" orientation=\"landscape\" fitToWidth=\"1\" fitToHeight=\"0\"/>";
} else {
repl = "<pageSetup" + attrs
+ " paperSize=\"8\" orientation=\"landscape\" fitToWidth=\"1\" fitToHeight=\"0\">";
}
m.appendReplacement(sb, Matcher.quoteReplacement(repl));
}
m.appendTail(sb);
out = sb.toString();
} else {
out = out.replaceFirst("(?i)</worksheet>",
"<pageSetup paperSize=\"8\" orientation=\"landscape\" fitToWidth=\"1\" fitToHeight=\"0\"/></worksheet>");
}
return out;
}
/**
* 调用 soffice 无界面转换;已在文件锁内,禁止再开并行实例。
*/
@@ -0,0 +1,188 @@
{
"schemaVersion": "tms-domestic-vehicle-mapping-v1",
"description": "TMS车型/别名 → 国内整车报价单表头车型;来源 tms国内车型映射表 -ai.xlsx",
"templateToDomesticAliases": {
"4.2米/3T": [
"3T危险品",
"4.2厢车",
"4.2高栏",
"4.2平板",
"4.2(3T)冷藏车",
"4.2冷危车",
"4.2尾板车",
"3T"
],
"6.8/5T": [
"5T冷车",
"5T危险品",
"5T",
"6.8高栏",
"6.8平板",
"6.8厢车",
"6.8(5T)冷藏车",
"6.8危冷车"
],
"7.6米/8T": [
"8T",
"7.6(8T)冷藏车",
"8T危险品",
"8T冷车",
"7.6厢车",
"7.6高栏",
"7.6危冷车",
"7.6飞翼",
"7.6危险品车",
"7.6尾板车"
],
"9.6米/10T": [
"9.6(10T)冷藏车",
"10T危险品",
"10T",
"10T冷车",
"9.6厢车",
"9.6平板",
"9.6高栏",
"9.6飞翼车",
"9.6气垫车",
"9.6恒温车",
"9.6危冷车",
"9.6尾板车"
],
"12.5米/40尺": [
"12.5平板",
"40HQ冷柜",
"40HQ",
"40HQ冷危柜",
"40HQ气垫车",
"12.5飞翼",
"40尺干柜"
],
"13.5米/45尺": [
"13.5飞翼",
"45HQ (13.5厢车)",
"45HQ气垫车",
"45HQ危险品",
"45尺高栏气垫车",
"13.5平板",
"13.5高栏",
"45尺干柜",
"45尺冷柜"
],
"13.75米平板": [
"13.75平板",
"13.75爬梯车"
],
"15米/冷柜": [
"15米冷柜",
"15米箱车",
"15米平板",
"15米低板车"
],
"16.5米/53尺": [
"53尺(16.5厢车)",
"16.5飞翼",
"16.5超重特种车",
"16.5平板",
"16.5高栏",
"16.5冷藏"
],
"17.5米平板": [
"17.5平板",
"58尺(17.5厢车)",
"17.5爬梯车",
"17.5抽拉板",
"17.5超低板"
]
},
"templateToOverseasAliases": {
"4.2米/3T": [
"3.5T"
]
},
"domesticAliasToTemplate": {
"3T危险品": "4.2米/3T",
"4.2厢车": "4.2米/3T",
"4.2高栏": "4.2米/3T",
"4.2平板": "4.2米/3T",
"4.2(3T)冷藏车": "4.2米/3T",
"4.2冷危车": "4.2米/3T",
"4.2尾板车": "4.2米/3T",
"3T": "4.2米/3T",
"4.2米/3T": "4.2米/3T",
"5T冷车": "6.8/5T",
"5T危险品": "6.8/5T",
"5T": "6.8/5T",
"6.8高栏": "6.8/5T",
"6.8平板": "6.8/5T",
"6.8厢车": "6.8/5T",
"6.8(5T)冷藏车": "6.8/5T",
"6.8危冷车": "6.8/5T",
"6.8/5T": "6.8/5T",
"8T": "7.6米/8T",
"7.6(8T)冷藏车": "7.6米/8T",
"8T危险品": "7.6米/8T",
"8T冷车": "7.6米/8T",
"7.6厢车": "7.6米/8T",
"7.6高栏": "7.6米/8T",
"7.6危冷车": "7.6米/8T",
"7.6飞翼": "7.6米/8T",
"7.6危险品车": "7.6米/8T",
"7.6尾板车": "7.6米/8T",
"7.6米/8T": "7.6米/8T",
"9.6(10T)冷藏车": "9.6米/10T",
"10T危险品": "9.6米/10T",
"10T": "9.6米/10T",
"10T冷车": "9.6米/10T",
"9.6厢车": "9.6米/10T",
"9.6平板": "9.6米/10T",
"9.6高栏": "9.6米/10T",
"9.6飞翼车": "9.6米/10T",
"9.6气垫车": "9.6米/10T",
"9.6恒温车": "9.6米/10T",
"9.6危冷车": "9.6米/10T",
"9.6尾板车": "9.6米/10T",
"9.6米/10T": "9.6米/10T",
"12.5平板": "12.5米/40尺",
"40HQ冷柜": "12.5米/40尺",
"40HQ": "12.5米/40尺",
"40HQ冷危柜": "12.5米/40尺",
"40HQ气垫车": "12.5米/40尺",
"12.5飞翼": "12.5米/40尺",
"40尺干柜": "12.5米/40尺",
"12.5米/40尺": "12.5米/40尺",
"13.5飞翼": "13.5米/45尺",
"45HQ (13.5厢车)": "13.5米/45尺",
"45HQ气垫车": "13.5米/45尺",
"45HQ危险品": "13.5米/45尺",
"45尺高栏气垫车": "13.5米/45尺",
"13.5平板": "13.5米/45尺",
"13.5高栏": "13.5米/45尺",
"45尺干柜": "13.5米/45尺",
"45尺冷柜": "13.5米/45尺",
"13.5米/45尺": "13.5米/45尺",
"13.75平板": "13.75米平板",
"13.75爬梯车": "13.75米平板",
"13.75米平板": "13.75米平板",
"15米冷柜": "15米/冷柜",
"15米箱车": "15米/冷柜",
"15米平板": "15米/冷柜",
"15米低板车": "15米/冷柜",
"15米/冷柜": "15米/冷柜",
"53尺(16.5厢车)": "16.5米/53尺",
"16.5飞翼": "16.5米/53尺",
"16.5超重特种车": "16.5米/53尺",
"16.5平板": "16.5米/53尺",
"16.5高栏": "16.5米/53尺",
"16.5冷藏": "16.5米/53尺",
"16.5米/53尺": "16.5米/53尺",
"17.5平板": "17.5米平板",
"58尺(17.5厢车)": "17.5米平板",
"17.5爬梯车": "17.5米平板",
"17.5抽拉板": "17.5米平板",
"17.5超低板": "17.5米平板",
"17.5米平板": "17.5米平板"
},
"overseasAliasToTemplate": {
"3.5T": "4.2米/3T"
}
}
@@ -1,6 +1 @@
<?xml version="1.0" encoding="UTF-8"?>
<xml xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:x="urn:schemas-microsoft-com:office:excel"><o:shapelayout v:ext="edit"><o:idmap data="2" v:ext="edit"/></o:shapelayout><v:shapetype coordsize="21600,21600" id="_x0000_t201" o:spt="201" path="m,l,21600r21600,l21600,xe">
<v:stroke joinstyle="miter"/>
<v:path fillok="f" o:connecttype="rect" o:extrusionok="f" shadowok="f" strokeok="f"/>
<o:lock shapetype="t" v:ext="edit"/>
</v:shapetype><v:shape coordsize="21600,21600" filled="f" id="_x0000_s2049" o:insetmode="auto" o:spt="201" stroked="f" style="position:absolute;left:0pt;top:0pt;margin-left:237.8pt;margin-top:39.65pt;height:14.9pt;width:167.15pt;" type="#_x0000_t201"><v:path fillok="t" shadowok="t" strokeok="t"/><v:fill focussize="0,0" on="f"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock aspectratio="f" rotation="t" v:ext="edit"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font color="#000000" face="宋体" size="180"><span style="mso-spacerun:yes"> Port to Port, 1工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,2,4,44,2,27</x:Anchor><x:FirstButton>1</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape><v:shape coordsize="21600,21600" filled="f" id="_x0000_s2050" o:insetmode="auto" o:spt="201" stroked="f" style="position:absolute;left:0pt;top:0pt;margin-left:237.65pt;margin-top:51.65pt;height:14.95pt;width:157.65pt;" type="#_x0000_t201"><v:path fillok="t" shadowok="t" strokeok="t"/><v:fill focussize="0,0" on="f"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock aspectratio="f" rotation="t" v:ext="edit"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font color="#000000" face="宋体" size="180"><span style="mso-spacerun:yes"> 其它, 3工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Checked>1</x:Checked><x:Anchor>1,5,2,22,4,28,3,0</x:Anchor><x:FirstButton>0</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape></xml>
@@ -42,8 +42,16 @@ public class InquiryDashboardServiceImplTest {
@Test
public void formatAvgSaleIsAmountNotPercent() {
Assert.assertEquals("-", InquiryDashboardServiceImpl.formatAvgSale(null));
Assert.assertEquals("12800", InquiryDashboardServiceImpl.formatAvgSale(new BigDecimal("12800.00")));
Assert.assertEquals("12,800", InquiryDashboardServiceImpl.formatAvgSale(new BigDecimal("12800.00")));
Assert.assertEquals("12.50", InquiryDashboardServiceImpl.formatAvgSale(new BigDecimal("12.5")));
Assert.assertEquals("742,187,952.33",
InquiryDashboardServiceImpl.formatAvgSale(new BigDecimal("742187952.33")));
}
@Test
public void formatCountUsesThousandSeparator() {
Assert.assertEquals("480", InquiryDashboardServiceImpl.formatCount(480));
Assert.assertEquals("12,800", InquiryDashboardServiceImpl.formatCount(12800));
}
@Test
@@ -0,0 +1,44 @@
package org.jeecg.modules.inquiry.support;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 多段建单投影:segments_json → 列表列。
*/
public class InquiryMultiSegmentSupportTest {
@Test
public void projectTicketColumnsFromSegmentsJson() {
InquiryTicket ticket = new InquiryTicket();
ticket.setBusinessLine("MULTI");
ticket.setBizTypeLabel("MULTI");
String segments = "[{\"index\":1,\"mode\":\"LAND\",\"facts\":{\"品名\":\"茶叶\",\"件数\":\"21件\","
+ "\"毛重\":\"11kg\",\"体积\":\"1.1*2.1*2.2\",\"起运港\":\"北京\",\"目的港\":\"上海\"}},"
+ "{\"index\":2,\"mode\":\"AIR\",\"facts\":{\"品名\":\"茶叶\",\"件数\":\"21件\","
+ "\"毛重\":\"11kg\",\"体积\":\"1.1*2.1*2.2\",\"起运港\":\"上海\",\"目的港\":\"北宁\"}}]";
Map<String, String> facts = new HashMap<>();
facts.put("segments_json", segments);
assertTrue(InquiryMultiSegmentSupport.projectTicketColumns(ticket, facts));
assertEquals("北京", ticket.getOrigin());
assertEquals("北宁", ticket.getDestination());
assertEquals("茶叶", ticket.getGoodsName());
assertEquals(Integer.valueOf(21), ticket.getPieces());
assertEquals("多段联运", ticket.getBizTypeLabel());
assertTrue(ticket.getDemandTitle().contains("北京"));
assertTrue(ticket.getDemandTitle().contains("北宁"));
List<String> lines = InquiryMultiSegmentSupport.segmentSummaryLines(
"{\"segments_json\":" + com.alibaba.fastjson.JSON.toJSONString(segments) + "}");
assertEquals(2, lines.size());
assertTrue(lines.get(0).contains("陆运"));
assertTrue(lines.get(1).contains("空运"));
}
}
@@ -98,7 +98,8 @@ public class InquiryRoleBizScopeTest {
assertTrue(InquiryRoleBizScope.ticketInScope("", "陆运", land));
assertFalse(InquiryRoleBizScope.ticketInScope("AIR", "空运", land));
assertFalse(InquiryRoleBizScope.ticketInScope("SEA", "海运", land));
assertFalse(InquiryRoleBizScope.ticketInScope("MULTI", "多段联运", land));
// 多段含陆运段:陆运角色可见,避免后台像没存这张单。
assertTrue(InquiryRoleBizScope.ticketInScope("MULTI", "多段联运", land));
assertTrue(InquiryRoleBizScope.ticketInScope("AIR", "空运", null));
assertEquals(InquiryRoleBizScope.LAND, InquiryRoleBizScope.classifyTicketBiz("land", ""));
assertEquals("", InquiryRoleBizScope.classifyTicketBiz("MULTI", "陆运"));
@@ -966,7 +966,7 @@ public class QuoteTemplateFillTest {
assertEquals("板", one.getString("unit"));
}
}
assertTrue("人工增值费必须并进分桶结果", hasZz);
assertTrue(hasZz, "人工增值费必须并进分桶结果");
assertEquals("空运费", rows.getJSONObject(0).getString("name"));
assertEquals("空运段", rows.getJSONObject(0).getString("project"));
assertTrue(rows.getJSONObject(0).getString("amount").startsWith("183370"));
@@ -2194,6 +2194,7 @@ public class QuoteTemplateFillTest {
JSONObject facts = new JSONObject(true);
facts.put("origin", "深圳");
facts.put("destination", "春疆货场");
facts.put("运输类型", "跨境整车");
facts.put("currency", "CNY");
facts.put("route_rows",
"[{\"origin\":\"广东省-深圳市\",\"destination\":\"谅山-春疆货场\","
@@ -2202,10 +2203,10 @@ public class QuoteTemplateFillTest {
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertEquals("4.1*2.1*2.3M", read(sheet, 8, 3));
assertEquals("广东省-深圳市", read(sheet, 9, 1));
// 产品口径 A:单腿门到门两段都写完整起讫
assertEquals("谅山-春疆货场", read(sheet, 9, 2));
assertEquals("4000", normalizeMoney(read(sheet, 9, 3)));
// 跨境整车无中转:国内段留空,只填境外段;3T→境外 3.5T
assertEquals("", read(sheet, 9, 1));
assertEquals("", read(sheet, 9, 2));
assertEquals("", read(sheet, 9, 3));
assertEquals("", read(sheet, 9, 4));
assertEquals("2.8x1.5x1.6M", read(sheet, 27, 3));
assertEquals("广东省-深圳市", read(sheet, 28, 1));
@@ -2318,6 +2319,7 @@ public class QuoteTemplateFillTest {
JSONObject facts = new JSONObject(true);
facts.put("origin", "广西壮族自治区");
facts.put("destination", "胡志明");
facts.put("运输类型", "跨境整车");
facts.put("currency", "CNY");
facts.put("route_rows",
"[{\"origin\":\"广西壮族自治区\",\"destination\":\"胡志明\","
@@ -2330,12 +2332,13 @@ public class QuoteTemplateFillTest {
// 3T/5T 尺寸副表头必须保留(后台模板有值,出单不能清掉)
assertEquals("4.1*2.1*2.3M", read(sheet, 8, 3));
assertEquals("6.5*2.4*2.5M", read(sheet, 8, 4));
assertEquals("广西壮族自治区", read(sheet, 9, 1));
// 产品口径 A:国内段/境外段都写完整广西→胡志明
assertEquals("胡志明", read(sheet, 9, 2));
assertEquals("12500", normalizeMoney(read(sheet, 9, 3)));
// 跨境整车无中转:国内段留空
assertEquals("", read(sheet, 9, 1));
assertEquals("", read(sheet, 9, 2));
assertEquals("", read(sheet, 9, 3));
assertEquals("广西壮族自治区", read(sheet, 28, 1));
assertEquals("胡志明", read(sheet, 28, 2));
assertEquals("12500", normalizeMoney(read(sheet, 28, 3)));
assertEquals("4.7x1.8x1.8M", read(sheet, 27, 3));
} finally {
Files.deleteIfExists(src);
@@ -2344,7 +2347,7 @@ public class QuoteTemplateFillTest {
}
/**
* 跨境整车单腿门到门:国内段与境外段都写完整起讫(产品口径 A)。
* 跨境整车单腿无中转:只填境外段完整起讫,国内段留空。
*/
@Test
public void landCrossBorderFtlSplitsDomesticAndOverseasOd() throws Exception {
@@ -2401,21 +2404,21 @@ public class QuoteTemplateFillTest {
JSONObject facts = new JSONObject(true);
facts.put("origin", "郑州");
facts.put("destination", "吉隆坡");
facts.put("运输类型", "跨境整车");
facts.put("currency", "CNY");
facts.put("transit_days", "N+3");
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertEquals("起始地", read(sheet, 7, 1));
assertEquals("郑州", read(sheet, 8, 1));
assertEquals("吉隆坡", read(sheet, 8, 2));
// 无中转:国内段留空
assertEquals("", read(sheet, 8, 1));
assertEquals("", read(sheet, 8, 2));
assertEquals("起始地", read(sheet, 26, 1));
assertEquals("郑州", read(sheet, 27, 1));
assertEquals("吉隆坡", read(sheet, 27, 2));
assertEquals("N+3", read(sheet, 8, 15));
assertEquals("CNY", read(sheet, 8, 16));
assertEquals("", read(sheet, 8, 17));
assertEquals("", read(sheet, 8, 18));
assertEquals("", read(sheet, 8, 15));
assertEquals("", read(sheet, 8, 16));
assertEquals("N+3", read(sheet, 27, 15));
assertEquals("CNY", read(sheet, 27, 16));
assertEquals("", read(sheet, 27, 17));
@@ -2620,6 +2623,7 @@ public class QuoteTemplateFillTest {
JSONObject facts = new JSONObject(true);
facts.put("origin", "深圳");
facts.put("destination", "春疆货场");
facts.put("运输类型", "跨境整车");
facts.put("currency", "CNY");
facts.put("transit_days", "N+1");
facts.put("fuel_surcharge_rate", "8%");
@@ -2634,19 +2638,18 @@ public class QuoteTemplateFillTest {
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertEquals("起始地", read(sheet, 6, 1));
// 产品口径 A:单腿门到门两段都写完整起讫;运输费不能盖掉起点
assertEquals("深圳", read(sheet, 8, 1));
assertEquals("春疆货场", read(sheet, 8, 2));
assertEquals("N+1", read(sheet, 8, 13));
assertEquals("CNY", read(sheet, 8, 14));
assertEquals("8%", read(sheet, 8, 15));
assertEquals("0%", read(sheet, 8, 16));
assertFalse("CNY".equalsIgnoreCase(read(sheet, 8, 15)));
// 跨境整车无中转:国内段留空;境外段写完整起讫;运输费不能盖掉起点
assertEquals("", read(sheet, 8, 1));
assertEquals("", read(sheet, 8, 2));
assertFalse("运输费".equals(read(sheet, 8, 1)));
assertEquals("币种", read(sheet, 6, 14));
assertEquals("燃油附加费率", read(sheet, 5, 15));
assertEquals("深圳", read(sheet, 27, 1));
assertEquals("春疆货场", read(sheet, 27, 2));
assertEquals("N+1", read(sheet, 27, 13));
assertEquals("CNY", read(sheet, 27, 14));
assertEquals("8%", read(sheet, 27, 15));
assertEquals("0%", read(sheet, 27, 16));
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
@@ -2684,15 +2687,29 @@ public class QuoteTemplateFillTest {
}
/**
* 报价单「报价日期」格用工单创建日,不拿询价事实里的报价日期(TMS 用)。
* 报价单「报价日期」格与群显示一致:优先询价事实,不被工单创建日盖掉。
*/
@Test
public void buildFillFactsQuoteDatePrefersTicketCreateDate() {
public void buildFillFactsQuoteDatePrefersFactsOverTicketCreateDate() {
Map<String, Object> facts = new HashMap<>();
facts.put("报价日期", "2026-10-18");
facts.put("ticket_create_date", "2026-10-15");
facts.put("起运港", "广西");
facts.put("目的港", "肇庆");
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
.buildFillFacts("WO202610150031", facts, new HashMap<>(), "WuJiLin", "AIR");
assertEquals("2026-10-18", out.getString("quote_date"));
}
/**
* 没有事实报价日期时,报价单才回退工单创建日。
*/
@Test
public void buildFillFactsQuoteDateFallsBackToTicketCreateDate() {
Map<String, Object> facts = new HashMap<>();
facts.put("ticket_create_date", "2026-10-15");
facts.put("起运港", "广西");
facts.put("目的港", "肇庆");
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
.buildFillFacts("WO202610150031", facts, new HashMap<>(), "WuJiLin", "AIR");
assertEquals("2026-10-15", out.getString("quote_date"));
@@ -2914,6 +2931,115 @@ public class QuoteTemplateFillTest {
}
}
/**
* 回归 WO202610180032:始发港费用行很多时必须在「海运费用」前增行,
* 费目名原样写入(不去重),禁止把「项目/Items」当成空运项目列写成「起运港」。
*/
@Test
public void seaManyPolFeesGrowWithoutAirProjectOverwrite() throws Exception {
Path src = Files.createTempFile("quote-sea-many-src", ".xlsx");
Path out = Files.createTempFile("quote-sea-many-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("海运询价单");
row(sheet, 16).createCell(0).setCellValue("始发港费用/Origin Charges");
org.apache.poi.ss.usermodel.Row head = row(sheet, 17);
head.createCell(0).setCellValue("项目/Items");
head.createCell(1).setCellValue("币别/Cur.");
head.createCell(7).setCellValue("金额/Ammount");
// 模板仅预留 3 行,远少于 24 条费用,必触发增行
row(sheet, 18).createCell(0).setCellValue("样例费");
row(sheet, 19).createCell(0).setCellValue("样例费2");
row(sheet, 20).createCell(0).setCellValue("样例费3");
row(sheet, 21).createCell(0).setCellValue("海运费用/Ocean Freight");
org.apache.poi.ss.usermodel.Row oceanHead = row(sheet, 22);
oceanHead.createCell(0).setCellValue("项目/Items");
oceanHead.createCell(7).setCellValue("金额/Ammount");
row(sheet, 23).createCell(0).setCellValue("海运费");
row(sheet, 23).createCell(7).setCellValue("0");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject(true);
mapping.put("fields", new JSONObject());
JSONArray regions = new JSONArray();
JSONObject pol = new JSONObject(true);
pol.put("id", "origin_charges");
pol.put("title", "始发港费用/Origin Charges");
pol.put("tmsSource", "polCostItems");
pol.put("anchor", "海运询价单!A19");
pol.put("stopAtText", JSONArray.parseArray("[\"海运费用\"]"));
pol.put("grow", "insert_before_stop");
pol.put("styleCopyRows", 1);
JSONObject polCols = new JSONObject(true);
polCols.put("name", "A");
polCols.put("currency", "B");
polCols.put("amount", "H");
pol.put("columns", polCols);
regions.add(pol);
JSONObject ocean = new JSONObject(true);
ocean.put("id", "ocean_charges");
ocean.put("title", "海运费用/Ocean Freight");
ocean.put("tmsSource", "oceanCostItems");
ocean.put("anchor", "海运询价单!A24");
ocean.put("stopAtText", JSONArray.parseArray("[\"目的港费用\",\"费用总计\"]"));
ocean.put("grow", "insert_before_stop");
JSONObject oceanCols = new JSONObject(true);
oceanCols.put("name", "A");
oceanCols.put("amount", "H");
ocean.put("columns", oceanCols);
regions.add(ocean);
mapping.put("feeRegions", regions);
StringBuilder feeJson = new StringBuilder("[");
for (int i = 1; i <= 24; i++) {
if (i > 1) {
feeJson.append(',');
}
// 模拟 TMS 多套同名费:每隔 4 行一套订舱/THC/VGM/封条
String[] names = {"订舱费(DCF)", "码头操作费(THC)", "VGM申报费", "封条费(SEAI)"};
String name = names[(i - 1) % 4];
feeJson.append("{\"name\":\"").append(name).append("\",\"amount\":\"")
.append(100 + i).append("\",\"currency\":\"CNY\",\"quantity\":\"1\",\"unit\":\"柜\"}");
}
feeJson.append(']');
JSONObject facts = new JSONObject(true);
facts.put("fee_rows_polCostItems", QuoteTemplateExcelSupport.parseFeeRows(feeJson.toString()));
facts.put("fee_rows_oceanCostItems", QuoteTemplateExcelSupport.parseFeeRows(
"[{\"name\":\"海运费\",\"amount\":\"1230\",\"currency\":\"USD\",\"quantity\":\"1\",\"unit\":\"柜\"}]"));
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
int oceanTitle = -1;
int originCount = 0;
int qiyungang = 0;
for (int r = 0; r <= sheet.getLastRowNum(); r++) {
String a = read(sheet, r, 0);
if (a.contains("海运费用") && a.contains("/")) {
oceanTitle = r;
}
if ("起运港".equals(a.trim())) {
qiyungang++;
}
if (a.contains("订舱费") || a.contains("码头操作费") || a.contains("VGM") || a.contains("封条费")) {
originCount++;
}
}
assertTrue(oceanTitle > 0, "ocean title missing");
assertEquals(24, originCount, "pol fees should all be written without dedupe");
assertEquals(0, qiyungang, "fee name col must not be overwritten with 起运港");
// 海运费用标题应被推到始发港 24 行之后
assertTrue(oceanTitle >= 18 + 24, "oceanTitle=" + oceanTitle);
assertTrue(read(sheet, oceanTitle + 2, 0).contains("海运费")
|| read(sheet, oceanTitle + 1, 0).contains("海运费")
|| read(sheet, oceanTitle + 3, 0).contains("海运费"),
"ocean freight row near title");
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* 回归 WO202609220088:三段汇总写币别+金额;费用总计写在金额列并带币别,不得落在 B 列。
*/
@@ -3011,6 +3137,77 @@ public class QuoteTemplateFillTest {
}
}
/**
* WO202610140036:TMS「6.8厢车」经 tms-domestic-vehicle-mapping-v1 对到表头「6.8/5T」并写入售价。
* 不得误写入 4.2米/3T、7.6米/8T 列。
*/
@Test
public void debugProbeLandFtlTmsVehicle68VanVsTemplateHeader() throws Exception {
Path src = Files.createTempFile("quote-land-ftl-68van-src", ".xlsx");
Path out = Files.createTempFile("quote-land-ftl-68van-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("国内整车");
row(sheet, 7).createCell(1).setCellValue("始发站");
row(sheet, 7).createCell(2).setCellValue("目的地");
row(sheet, 7).createCell(3).setCellValue("车型/费用");
row(sheet, 7).createCell(13).setCellValue("时效");
org.apache.poi.ss.usermodel.Row veh = row(sheet, 8);
veh.createCell(3).setCellValue("4.2米/3T");
veh.createCell(4).setCellValue("6.8/5T");
veh.createCell(5).setCellValue("7.6米/8T");
row(sheet, 9);
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("title", "项目一:整车报价");
region.put("headerRow", 9);
region.put("dataStartRow", 10);
JSONObject cols = new JSONObject(true);
cols.put("origin", "B");
cols.put("destination", "C");
cols.put("transit_time", "N");
JSONObject rates = new JSONObject(true);
rates.put("4.2米/3T", "D");
rates.put("6.8/5T", "E");
rates.put("7.6米/8T", "F");
cols.put("vehicleRates", rates);
region.put("columns", cols);
regions.add(region);
mapping.put("routeRegions", regions);
JSONObject facts = new JSONObject(true);
facts.put("origin", "广东省-深圳市");
facts.put("destination", "江苏省-南京市");
facts.put("运输类型", "国内运输整车");
facts.put("车型/数量", "6.8厢车");
facts.put("currency", "CNY");
facts.put("route_rows",
"[{\"origin\":\"广东省-深圳市\",\"destination\":\"江苏省-南京市\","
+ "\"vehicleType\":\"6.8厢车\",\"salePrice\":\"3200\",\"amount\":\"3200\","
+ "\"transit_time\":\"22H\",\"currency\":\"CNY\"}]");
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
assertEquals("6.8/5T", QuoteTemplateExcelSupport.mapTmsDomesticAliasToTemplate("6.8厢车"));
assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("6.8厢车", "6.8/5T", ""));
assertEquals("3200", QuoteTemplateExcelSupport.resolveRouteVehicleRate(
JSON.parseObject("{\"vehicleType\":\"6.8厢车\",\"salePrice\":\"3200\"}"),
facts, "6.8/5T", 3, ""));
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertEquals("广东省-深圳市", read(sheet, 9, 1));
assertEquals("江苏省-南京市", read(sheet, 9, 2));
assertEquals("", read(sheet, 9, 3));
assertEquals("3200", normalizeMoney(read(sheet, 9, 4)));
assertEquals("", read(sheet, 9, 5));
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* 国内整车:车型格「4.2米/3T」是浅蓝表头,出单后不能变成业务员那种深蓝。
*/