落地海运协同群:测服靠会话存档听群话,报价单能带上协同字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-15 18:12:45 +08:00
co-authored by Cursor
parent 235ecd8c83
commit 12219cacb1
39 changed files with 4436 additions and 256 deletions
@@ -207,6 +207,36 @@ public class InquiryAgentController {
return Result.OK(workOrderService.findByCollabChat(firstStr(body, "chatId", "chat_id")));
}
@ApiOperation("协同结束(不改六态,这单不能再拉群)")
@PostMapping("/ticket/endCollabGroup")
public Result<Map<String, Object>> endCollabGroup(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
return Result.OK(workOrderService.endCollabGroup(firstStr(body, "workOrderNo", "work_order_no")));
}
@ApiOperation("群附件元数据入库(不改六态)")
@PostMapping("/ticket/attachFile")
public Result<Map<String, Object>> attachFile(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
return Result.OK(workOrderService.attachFile(body));
}
@ApiOperation("产品报价写入新版本")
@PostMapping("/ticket/upsertProductQuote")
public Result<Map<String, Object>> upsertProductQuote(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
return Result.OK(workOrderService.upsertProductQuote(body));
}
private boolean authed(HttpServletRequest http) {
String got = http.getHeader("X-Agent-Token");
if (StringUtils.isBlank(got)) {
@@ -89,4 +89,19 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
* 用协同群 chat_id 找回工单。只读。
*/
Map<String, Object> findByCollabChat(String chatId);
/**
* 协同结束:不改六态,这单不能再拉群。
*/
Map<String, Object> endCollabGroup(String workOrderNo);
/**
* 群附件元数据入库。不改六态。
*/
Map<String, Object> attachFile(Map<String, Object> body);
/**
* 产品报价写入新版本并视情况迁「已报价」。
*/
Map<String, Object> upsertProductQuote(Map<String, Object> body);
}
@@ -538,10 +538,30 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
out.put("pod", first(src, "目的港", "pod", "destination"));
out.put("destination", first(src, "目的港", "destination", "pod"));
out.put("commodity", first(src, "品名", "commodity"));
// 海运七项:合同键是 quantity / fcl_or_lcl / container_type_qty,工单事实是中文键
out.put("quantity", first(src, "货量", "货物数量", "quantity"));
out.put("fcl_or_lcl", first(src, "整柜或拼柜", "fcl_or_lcl"));
out.put("container_type_qty", first(src, "箱型箱量", "container_type_qty"));
out.put("pieces", first(src, "件数", "pieces"));
out.put("weight_kg", first(src, "毛重", "weight_kg"));
out.put("volume_cbm", first(src, "体积", "volume_cbm"));
out.put("quote_date", first(src, "报价日期", "quote_date"));
out.put("quote_date", first(src, "报价日期", "quote_date", "询价日期"));
out.put("trade_terms", first(src, "贸易条款", "trade_terms"));
out.put("cargo_ready_date", first(src, "货好时间", "cargo_ready_date"));
out.put("hs_code", first(src, "商品海关编码", "HS编码", "海关编码", "hs_code"));
String oil = first(src, "是否含油", "has_oil");
String elec = first(src, "是否含电", "has_battery");
String mag = first(src, "是否含磁", "has_magnet");
String oem = first(src, "oil_elec_mag", "是否含油,电,磁");
if (StringUtils.isBlank(oem) && (StringUtils.isNotBlank(oil) || StringUtils.isNotBlank(elec) || StringUtils.isNotBlank(mag))) {
oem = "油:" + (StringUtils.isNotBlank(oil) ? oil : "-")
+ ";电:" + (StringUtils.isNotBlank(elec) ? elec : "-")
+ ";磁:" + (StringUtils.isNotBlank(mag) ? mag : "-");
}
if (StringUtils.isNotBlank(oem)) {
out.put("oil_elec_mag", oem);
out.put("是否含油,电,磁", oem);
}
if (StringUtils.isBlank(out.getString("quote_date"))) {
out.put("quote_date", new java.text.SimpleDateFormat("yyyy-MM-dd").format(new Date()));
}
@@ -756,9 +756,152 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
}
}
out.put("collab_facts", collabFacts);
boolean ended = "1".equals(collabFacts.get("__ended"))
|| "true".equalsIgnoreCase(StringUtils.trimToEmpty(collabFacts.get("__ended")));
out.put("collab_ended", ended);
out.put("status", StringUtils.defaultString(ticket.getStatus()));
out.put("business_line", StringUtils.defaultString(ticket.getBusinessLine()));
out.put("facts", parseFactsJson(ticket.getFactsJson()));
out.put("quote", latestQuotePayload(ticket.getWorkOrderNo()));
return out;
}
/**
* 协同结束:在协同字段里打结束标记。不改六态、不清 chat_id(方便群里回话)。
*/
@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);
InquiryTicket ticket = findByWorkOrderNo(workOrderNo);
if (ticket != null) {
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "end_collab_group", "agent", "协同结束");
}
return out;
}
/**
* 群附件只存元数据。终态拦截在智能体侧,这里不再改六态。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> attachFile(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;
}
InquiryAttachment row = new InquiryAttachment();
row.setTicketId(ticket.getId());
row.setWorkOrderNo(no);
row.setFileName(text(body, "fileName", "file_name"));
row.setObjectKey(text(body, "objectKey", "object_key"));
row.setContentType(text(body, "contentType", "content_type"));
row.setCreateTime(new Date());
attachmentMapper.insert(row);
out.put("ok", true);
out.put("work_order_no", no);
out.put("file_name", row.getFileName());
return out;
}
/**
* 产品报价盖 TMS 价:新插一条 inq_quote,主表售价跟着改。
* 询价中/协商中可迁已报价;已报价只加版本。终态由智能体先拦。
*/
@Override
@Transactional(rollbackFor = Exception.class)
@SuppressWarnings("unchecked")
public Map<String, Object> upsertProductQuote(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;
}
Object raw = body == null ? null : body.get("quote");
Map<String, Object> quote = raw instanceof Map ? (Map<String, Object>) raw : new LinkedHashMap<>();
Long n = quoteMapper.selectCount(new LambdaQueryWrapper<InquiryQuote>()
.eq(InquiryQuote::getWorkOrderNo, no));
int ver = n == null ? 1 : n.intValue() + 1;
InquiryQuote row = new InquiryQuote();
row.setTicketId(ticket.getId());
row.setWorkOrderNo(no);
row.setQuoteVersion(ver);
row.setSource("PRODUCT");
row.setSourceLabel(String.valueOf(quote.getOrDefault("source_label", "产品报价")));
row.setPriceText(String.valueOf(quote.getOrDefault("total", "")));
row.setHighlighted(1);
row.setPayloadJson(JSON.toJSONString(quote));
row.setCreateTime(new Date());
quoteMapper.insert(row);
Object total = quote.get("total");
Object ccy = quote.get("currency");
if (total != null && StringUtils.isNotBlank(String.valueOf(total))) {
String sale = (ccy == null ? "" : String.valueOf(ccy) + " ") + total;
ticket.setSalePrice(sale.trim());
ticket.setUpdateTime(new Date());
updateById(ticket);
}
String to = text(body, "toStatus", "to_status");
if (StringUtils.isBlank(to)) {
to = TicketStatuses.QUOTED;
}
if (TicketStatuses.canTransit(ticket.getStatus(), to)) {
TicketTransitionRequest tr = new TicketTransitionRequest();
tr.setWorkOrderNo(no);
tr.setToStatus(to);
transition(tr, "agent");
}
out.put("ok", true);
out.put("quote_version", ver);
out.put("status", ticket.getStatus());
return out;
}
private static Map<String, String> parseFactsJson(String raw) {
Map<String, String> facts = new LinkedHashMap<>();
if (StringUtils.isBlank(raw)) {
return facts;
}
JSONObject parsed = JSON.parseObject(raw);
if (parsed == null) {
return facts;
}
for (String key : parsed.keySet()) {
if (parsed.get(key) != null) {
facts.put(key, String.valueOf(parsed.get(key)));
}
}
return facts;
}
private Map<String, Object> latestQuotePayload(String workOrderNo) {
Map<String, Object> quote = new LinkedHashMap<>();
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
.orderByDesc(InquiryQuote::getCreateTime)
.last("LIMIT 1"));
if (quotes != null && !quotes.isEmpty() && StringUtils.isNotBlank(quotes.get(0).getPayloadJson())) {
JSONObject parsed = JSON.parseObject(quotes.get(0).getPayloadJson());
if (parsed != null) {
quote.putAll(parsed);
}
}
return quote;
}
private static String memberIdsJson(Map<String, Object> body) {
if (body == null) {
return "[]";
@@ -32,6 +32,45 @@ public final class QuoteTemplateExcelSupport {
"FFFFCC00", "FFFFD966", "FFFFF2CC", "FFFFE699"
));
/**
* 按标签找格子时用的词典。与合同 labelDictionary 对齐,并补上海运询价单上的「费用总计 / RFQ No」。
* 只用于填表兜底,不发明价格。
*/
private static final Map<String, String> FILL_BY_LABEL = new LinkedHashMap<>();
static {
FILL_BY_LABEL.put("询价日期", "quote_date");
FILL_BY_LABEL.put("报价日期", "quote_date");
FILL_BY_LABEL.put("品名", "commodity");
FILL_BY_LABEL.put("货物数量", "quantity");
FILL_BY_LABEL.put("整柜或拼柜", "fcl_or_lcl");
FILL_BY_LABEL.put("箱型箱量", "container_type_qty");
FILL_BY_LABEL.put("起运港", "pol");
FILL_BY_LABEL.put("目的港", "pod");
FILL_BY_LABEL.put("报价有效期", "valid_until");
FILL_BY_LABEL.put("有效期至", "valid_until");
FILL_BY_LABEL.put("费用总计", "total_amount");
FILL_BY_LABEL.put("报价合计", "total_amount");
FILL_BY_LABEL.put("RFQ No", "quote_no");
FILL_BY_LABEL.put("报价单编号", "quote_no");
FILL_BY_LABEL.put("报价编号", "quote_no");
FILL_BY_LABEL.put("贸易条款", "trade_terms");
FILL_BY_LABEL.put("货好时间", "cargo_ready_date");
FILL_BY_LABEL.put("商品海关编码", "hs_code");
FILL_BY_LABEL.put("HS编码", "hs_code");
FILL_BY_LABEL.put("海关编码", "hs_code");
FILL_BY_LABEL.put("是否含油,电,磁", "oil_elec_mag");
FILL_BY_LABEL.put("件数", "pieces");
FILL_BY_LABEL.put("重量", "weight_kg");
FILL_BY_LABEL.put("体积", "volume_cbm");
FILL_BY_LABEL.put("始发地", "origin");
FILL_BY_LABEL.put("目的地", "destination");
FILL_BY_LABEL.put("币别", "currency");
FILL_BY_LABEL.put("业务员", "sales_name");
FILL_BY_LABEL.put("客户名称", "customer_name");
FILL_BY_LABEL.put("联系人", "contact_name");
}
private QuoteTemplateExcelSupport() {
}
@@ -188,6 +227,8 @@ public final class QuoteTemplateExcelSupport {
writeCell(wb, cellRef, String.valueOf(val));
}
}
// 海运询价单映射常只对上 4 个点,且起运港映进了标签合并格。按标签再写一遍正确格子。
writeFactsByLabel(wb, facts, yellow);
writeFeeRows(wb, mapping, facts, yellow);
// 后台常未配 fee_rows:按「空运费」等名称写入金额列,SUM 才能加算
writeFeeAmountsByName(wb, facts, yellow);
@@ -235,7 +276,7 @@ public final class QuoteTemplateExcelSupport {
writeFeeCol(sheet, r, columns.getString("currency"), rowObj.getString("currency"), yellow, sheet.getSheetName());
}
if (columns.containsKey("remark")) {
writeFeeCol(sheet, r, columns.getString("remark"), rowObj.getString("remark"), yellow, sheet.getSheetName());
writeFeeCol(sheet, r, columns.getString("remark"), formatFeeRemark(rowObj.getString("remark")), yellow, sheet.getSheetName());
}
}
}
@@ -258,6 +299,8 @@ public final class QuoteTemplateExcelSupport {
cell = row.createCell(col);
}
writeNumericOrText(cell, value);
// 映射表写出的备注同样要换行,否则只看得见半截
applyRemarkWrap(sheet, row, cell, value);
}
/**
@@ -302,9 +345,21 @@ public final class QuoteTemplateExcelSupport {
if (cell == null || cell.getCellType() != CellType.FORMULA) {
continue;
}
String formula = cell.getCellFormula();
try {
ev.evaluateFormulaCell(cell);
CellType result = ev.evaluateFormulaCell(cell);
// 空的 D*G 会算出错误,海运未用费目行不要留下一个「=」
if (result == CellType.ERROR && StringUtils.isNotBlank(formula)) {
cell.setCellFormula(formula);
}
} catch (Exception e) {
try {
if (StringUtils.isNotBlank(formula)) {
cell.setCellFormula(formula);
}
} catch (Exception ignored) {
// 还原失败不挡出单
}
log.warn("报价公式未重算 {}!{}: {}",
sheet.getSheetName(),
new CellReference(cell).formatAsString(),
@@ -321,7 +376,11 @@ public final class QuoteTemplateExcelSupport {
/**
* 按费用名称把 TMS 费用行写进模板:单位 / 币别 / 单价 / 数量 / 金额 / 备注。
* 映射没有 fee_rows 时也能对上「空运费」等已有行。没有的字段不编、不覆盖模板原字。
* 映射没有 fee_rows 时也能对上「空运费」等已有行。
* <p>
* 海运询价单有三段费用表,且「海运费用」先出现在整行标题里。
* 必须跳过标题行,列映射取费目上方最近的表头,对不上的费目追加到空行。
* 禁止把数量写进「货物数量」标签列,否则会把费目名盖成 1。
*/
public static void writeFeeAmountsByName(Workbook wb, JSONObject facts, Set<String> yellow) {
JSONArray rows = parseFeeRows(facts == null ? null : facts.get("fee_rows"));
@@ -329,16 +388,11 @@ public final class QuoteTemplateExcelSupport {
return;
}
String headerCurrency = facts == null ? "" : StringUtils.trimToEmpty(facts.getString("currency"));
String boxType = firstFact(facts, "container_type_qty", "箱型箱量");
for (int s = 0; s < wb.getNumberOfSheets(); s++) {
Sheet sheet = wb.getSheetAt(s);
Map<String, Integer> cols = findFeeTableColumns(sheet);
if (cols.isEmpty()) {
cols = fallbackFeeColumns(sheet);
}
Integer amountCol = cols.get("amount");
if (amountCol == null) {
amountCol = 6;
}
Set<Integer> usedRows = new HashSet<>();
List<JSONObject> unmatched = new ArrayList<>();
for (int i = 0; i < rows.size(); i++) {
JSONObject rowObj = rows.getJSONObject(i);
if (rowObj == null) {
@@ -349,68 +403,134 @@ public final class QuoteTemplateExcelSupport {
if (StringUtils.isBlank(name) || StringUtils.isBlank(amount) || "-".equals(amount)) {
continue;
}
Cell nameCell = findCellByFeeName(sheet, name);
Cell nameCell = findCellByFeeName(sheet, name, usedRows);
if (nameCell == null) {
unmatched.add(rowObj);
continue;
}
int r = nameCell.getRowIndex();
writeAmountCell(sheet, r, amountCol, amount, yellow);
String lineCurrency = StringUtils.trimToEmpty(rowObj.getString("currency"));
if (StringUtils.isBlank(lineCurrency)) {
lineCurrency = headerCurrency;
usedRows.add(r);
Map<String, Integer> cols = findFeeTableColumnsAbove(sheet, r);
writeFeeDetailRow(sheet, r, cols, rowObj, headerCurrency, boxType, yellow, false);
}
for (JSONObject rowObj : unmatched) {
int empty = findEmptyFeeDataRow(sheet, usedRows);
if (empty < 0) {
log.warn("报价单没有空行可追加费用 {}", rowObj.getString("name"));
continue;
}
writeMappedText(sheet, r, cols.get("currency"), lineCurrency, yellow);
writeMappedText(sheet, r, cols.get("unit"), rowObj.getString("unit"), yellow);
writeMappedNumber(sheet, r, cols.get("quantity"), rowObj.getString("quantity"), yellow);
writeMappedNumber(sheet, r, cols.get("unit_price"), rowObj.getString("unit_price"), yellow);
writeMappedText(sheet, r, cols.get("remark"), rowObj.getString("remark"), yellow);
usedRows.add(empty);
Map<String, Integer> cols = findFeeTableColumnsAbove(sheet, empty);
writeFeeDetailRow(sheet, empty, cols, rowObj, headerCurrency, boxType, yellow, true);
}
}
}
/**
* 只认「费用名称 + 金额」那一行表头,避免把货物信息里的件/KG 当成费用列。
* 写一行费用。writeName=false 时不碰已有费目名,避免把「文件费/Doc.」盖成数量 1。
*/
private static void writeFeeDetailRow(Sheet sheet, int r, Map<String, Integer> cols, JSONObject rowObj,
String headerCurrency, String boxType, Set<String> yellow, boolean writeName) {
if (cols == null || cols.isEmpty()) {
cols = fallbackFeeColumns(sheet);
}
Integer amountCol = cols.get("amount");
if (amountCol == null) {
amountCol = 7;
}
Integer nameCol = cols.get("name");
Integer qtyCol = cols.get("quantity");
// 数量列若和费目名同列,多半是误把「货物数量」当成了费用数量
if (qtyCol != null && nameCol != null && qtyCol.equals(nameCol)) {
qtyCol = null;
}
if (writeName) {
writeMappedText(sheet, r, nameCol == null ? 0 : nameCol, rowObj.getString("name"), yellow);
}
String amount = StringUtils.trimToEmpty(rowObj.getString("amount"));
writeAmountCell(sheet, r, amountCol, amount, yellow);
String lineCurrency = StringUtils.trimToEmpty(rowObj.getString("currency"));
if (StringUtils.isBlank(lineCurrency)) {
lineCurrency = headerCurrency;
}
writeMappedText(sheet, r, cols.get("currency"), lineCurrency, yellow);
writeMappedText(sheet, r, cols.get("unit"), rowObj.getString("unit"), yellow);
writeMappedNumber(sheet, r, qtyCol, rowObj.getString("quantity"), yellow);
writeMappedNumber(sheet, r, cols.get("unit_price"), rowObj.getString("unit_price"), yellow);
writeMappedText(sheet, r, cols.get("remark"), formatFeeRemark(rowObj.getString("remark")), yellow);
Integer boxCol = pickContainerRateColumn(cols, boxType);
if (boxCol != null && !boxCol.equals(amountCol)) {
String rate = firstNonBlank(rowObj.getString("unit_price"), amount);
writeMappedNumber(sheet, r, boxCol, rate, yellow);
}
}
/**
* 只认「费用名称/项目 + 金额」那一行表头,避免把货物信息里的件/KG 当成费用列。
* 海运询价单表头是「项目/Items」不是「费用名称」。
* 币别和数量中间的空列或 CNY 列,按现网空运模板当成单价。
*/
static Map<String, Integer> findFeeTableColumns(Sheet sheet) {
Map<String, Integer> cols = new LinkedHashMap<>();
if (sheet == null) {
return cols;
return new LinkedHashMap<>();
}
int headerRow = -1;
int lastCol = 0;
int maxRow = Math.min(sheet.getLastRowNum(), 40);
int maxRow = Math.min(sheet.getLastRowNum(), 80);
for (int r = 0; r <= maxRow; r++) {
Row row = sheet.getRow(r);
if (row == null) {
continue;
}
boolean hasAmount = false;
boolean hasFeeName = false;
short last = row.getLastCellNum();
for (int c = 0; c < last; c++) {
String text = readCellText(row.getCell(c));
if (text.contains("金额") || text.contains("金額") || "amount".equalsIgnoreCase(text)) {
hasAmount = true;
}
if (text.contains("费用名称") || text.contains("费用名")) {
hasFeeName = true;
}
}
if (hasAmount && hasFeeName) {
headerRow = r;
lastCol = last;
break;
Map<String, Integer> cols = parseFeeHeaderRow(sheet, r);
if (!cols.isEmpty()) {
return cols;
}
}
if (headerRow < 0) {
return new LinkedHashMap<>();
}
/**
* 从费目行往上找最近的费用表头。海运有始发/海运/目的三段,列不能都用第一张表。
*/
static Map<String, Integer> findFeeTableColumnsAbove(Sheet sheet, int dataRow) {
if (sheet == null) {
return new LinkedHashMap<>();
}
for (int r = Math.max(dataRow - 1, 0); r >= 0; r--) {
Map<String, Integer> cols = parseFeeHeaderRow(sheet, r);
if (!cols.isEmpty()) {
return cols;
}
}
Map<String, Integer> first = findFeeTableColumns(sheet);
return first.isEmpty() ? fallbackFeeColumns(sheet) : first;
}
/**
* 解析一行是否为费用表头,并记下各列。
*/
static Map<String, Integer> parseFeeHeaderRow(Sheet sheet, int r) {
Map<String, Integer> cols = new LinkedHashMap<>();
Row row = sheet.getRow(r);
if (row == null) {
return cols;
}
Row row = sheet.getRow(headerRow);
for (int c = 0; c < lastCol; c++) {
boolean hasAmount = false;
boolean hasFeeName = false;
short last = row.getLastCellNum();
for (int c = 0; c < last; c++) {
String text = readCellText(row.getCell(c));
if (isAmountHeader(text)) {
hasAmount = true;
}
if (isFeeNameHeader(text)) {
hasFeeName = true;
}
}
if (!hasAmount || !hasFeeName) {
return cols;
}
for (int c = 0; c < last; c++) {
String text = StringUtils.trimToEmpty(readCellText(row.getCell(c)));
if (text.contains("费用名称") || text.contains("费用名")) {
if (isFeeNameHeader(text)) {
cols.put("name", c);
} else if (isContainerTypeHeader(text)) {
cols.put(normalizeContainerType(text), c);
} else if (text.contains("单位") || text.contains("單位")) {
cols.put("unit", c);
} else if (text.contains("币别") || text.contains("币種") || text.contains("币种")) {
@@ -419,7 +539,7 @@ public final class QuoteTemplateExcelSupport {
cols.put("unit_price", c);
} else if (text.contains("数量") || text.contains("數量")) {
cols.put("quantity", c);
} else if (text.contains("金额") || text.contains("金額") || "amount".equalsIgnoreCase(text)) {
} else if (isAmountHeader(text)) {
cols.put("amount", c);
} else if (text.contains("备注") || text.contains("備註")) {
cols.put("remark", c);
@@ -431,29 +551,25 @@ public final class QuoteTemplateExcelSupport {
cols.put("unit_price", c);
}
}
if (!cols.containsKey("name")) {
cols.put("name", 0);
}
return cols;
}
/**
* 只从费用表头行取列。禁止扫到「货物数量」把数量列认成 A。
*/
private static Map<String, Integer> fallbackFeeColumns(Sheet sheet) {
Map<String, Integer> found = findFeeTableColumns(sheet);
if (!found.isEmpty()) {
return found;
}
Map<String, Integer> cols = new LinkedHashMap<>();
putCol(cols, "amount", findHeaderColumn(sheet, Arrays.asList("金额", "金額", "amount")));
putCol(cols, "currency", findHeaderColumn(sheet, Arrays.asList("币别", "币種", "币种")));
putCol(cols, "unit", findHeaderColumn(sheet, Arrays.asList("单位", "單位")));
putCol(cols, "quantity", findHeaderColumn(sheet, Arrays.asList("数量", "數量")));
putCol(cols, "unit_price", findHeaderColumn(sheet, Arrays.asList("单价", "單價")));
putCol(cols, "remark", findHeaderColumn(sheet, Arrays.asList("备注", "備註")));
if (!cols.containsKey("amount")) {
cols.put("amount", 6);
}
cols.put("amount", 6);
return cols;
}
private static void putCol(Map<String, Integer> cols, String key, int col) {
if (col >= 0) {
cols.put(key, col);
}
}
private static void writeMappedText(Sheet sheet, int rowIdx, Integer col, String value, Set<String> yellow) {
if (col == null || StringUtils.isBlank(value) || "-".equals(value.trim())) {
return;
@@ -502,35 +618,17 @@ public final class QuoteTemplateExcelSupport {
return new JSONArray();
}
private static int findHeaderColumn(Sheet sheet, List<String> titles) {
int maxRow = Math.min(sheet.getLastRowNum(), 40);
for (int r = 0; r <= maxRow; r++) {
Row row = sheet.getRow(r);
if (row == null) {
continue;
}
short last = row.getLastCellNum();
for (int c = 0; c < last; c++) {
Cell cell = row.getCell(c);
String text = readCellText(cell);
if (StringUtils.isBlank(text)) {
continue;
}
for (String t : titles) {
if (text.contains(t)) {
return c;
}
}
}
}
return -1;
}
private static Cell findCellByFeeName(Sheet sheet, String name) {
/**
* 按费目名找数据行。跳过整行标题(「海运费用」先出现在 A29 合并标题里)。
* 只认「海运费↔海运费用」这种加「用/费用」的宽松匹配,不对「操作费」误中「码头操作费」。
*/
private static Cell findCellByFeeName(Sheet sheet, String name, Set<Integer> usedRows) {
String want = name.trim();
Cell fuzzy = null;
int maxRow = Math.min(sheet.getLastRowNum(), 80);
for (int r = 0; r <= maxRow; r++) {
if (usedRows != null && usedRows.contains(r)) {
continue;
}
Row row = sheet.getRow(r);
if (row == null) {
continue;
@@ -539,35 +637,39 @@ public final class QuoteTemplateExcelSupport {
for (int c = 0; c < last; c++) {
Cell cell = row.getCell(c);
String text = readCellText(cell);
if (StringUtils.isBlank(text)) {
if (StringUtils.isBlank(text) || isFeeTableHeader(text) || isSectionTitle(sheet, cell)) {
continue;
}
if (want.equals(text.trim())) {
if (feeNameMatches(text, want)) {
return cell;
}
if (fuzzy == null && text.contains(want) && want.length() >= 2) {
fuzzy = cell;
}
}
}
return fuzzy;
return null;
}
/**
* 费用明细表头原字,回填系统字段时不得覆盖。
* 海运表头带英文「项目/Items」「金额/Ammount」,以及 20GP 箱型列。
*/
static boolean isFeeTableHeader(String text) {
if (StringUtils.isBlank(text)) {
return false;
}
String t = text.trim();
return "项目".equals(t) || "费用名称".equals(t) || "费用名".equals(t)
|| "单位".equals(t) || "單位".equals(t)
|| "币别".equals(t) || "币種".equals(t) || "币种".equals(t)
|| "单价".equals(t) || "單價".equals(t)
|| "数量".equals(t) || "數量".equals(t)
|| "金额".equals(t) || "金額".equals(t)
|| "备注".equals(t) || "備註".equals(t);
if (isContainerTypeHeader(text)) {
return true;
}
String primary = feePrimaryName(text);
return "项目".equals(primary) || "费用名称".equals(primary) || "费用名".equals(primary)
|| "单位".equals(primary) || "單位".equals(primary)
|| "币别".equals(primary) || "币種".equals(primary) || "币种".equals(primary)
|| "单价".equals(primary) || "單價".equals(primary)
|| "数量".equals(primary) || "數量".equals(primary)
|| "金额".equals(primary) || "金額".equals(primary)
|| "备注".equals(primary) || "備註".equals(primary)
|| "Items".equalsIgnoreCase(primary) || "Cur.".equalsIgnoreCase(primary)
|| "Unit".equalsIgnoreCase(primary) || "Qty.".equalsIgnoreCase(primary)
|| "Ammount".equalsIgnoreCase(primary) || "Amount".equalsIgnoreCase(primary);
}
private static String readCellText(Cell cell) {
@@ -589,8 +691,17 @@ public final class QuoteTemplateExcelSupport {
row = sheet.createRow(dest[0]);
}
Cell cell = row.getCell(dest[1]);
CellStyle style = cell == null ? null : cell.getCellStyle();
// 海运模板金额格是 =D31*G31。只改缓存的话,后面 refreshFormulas 会按空的 40HQ 列算回 0。
if (cell != null && cell.getCellType() == CellType.FORMULA) {
row.removeCell(cell);
cell = null;
}
if (cell == null) {
cell = row.createCell(dest[1]);
if (style != null) {
cell.setCellStyle(style);
}
}
String norm = stripMoneyPrefix(amount);
try {
@@ -628,7 +739,93 @@ public final class QuoteTemplateExcelSupport {
if (cell == null) {
cell = row.createCell(dest[1]);
}
cell.setCellValue(value == null ? "" : value);
String text = value == null ? "" : value;
cell.setCellValue(text);
applyRemarkWrap(sheet, row, cell, text);
}
/**
* 海运备注常是「计价单位:柜;加点系数:0.0000000」。
* 模板备注列很窄(约 13 个英文字宽),整句挤一行只能看见半截;
* 按分号拆成两行,销售一眼能看完两段。
*/
static String formatFeeRemark(String raw) {
if (StringUtils.isBlank(raw)) {
return raw;
}
String text = raw.trim();
if (!text.contains("计价") && !text.contains("加点") && !text.contains("计费")) {
return text;
}
return text.replace(';', '\n').replace(';', '\n').replaceAll("\n+", "\n").trim();
}
/**
* 备注打开换行、顶对齐,并按最长一行把列加宽、按行数加高。
* 只加宽备注这类长句所在列,不改其它列,避免整页打印缩放被带跑。
*/
static void applyRemarkWrap(Sheet sheet, Row row, Cell cell, String text) {
if (sheet == null || row == null || cell == null || StringUtils.isBlank(text) || text.length() < 10) {
return;
}
String shown = formatFeeRemark(text);
if (!shown.equals(text)) {
cell.setCellValue(shown);
text = shown;
}
CellStyle copy = sheet.getWorkbook().createCellStyle();
if (cell.getCellStyle() != null) {
copy.cloneStyleFrom(cell.getCellStyle());
}
copy.setWrapText(true);
copy.setShrinkToFit(false);
copy.setVerticalAlignment(VerticalAlignment.TOP);
cell.setCellStyle(copy);
boolean feeRemark = text.contains("\n") || text.contains("计价") || text.contains("加点") || text.contains("计费");
if (feeRemark) {
widenRemarkColumn(sheet, cell.getColumnIndex(), text);
}
int lines = countWrappedLines(sheet, cell.getColumnIndex(), text);
float need = Math.max(row.getHeightInPoints(), Math.min(16f * lines + 6f, 80f));
if (row.getHeightInPoints() < need) {
row.setHeightInPoints(need);
}
}
/**
* 按最长一行估算列宽。中文按 2 个英文字宽,上限 26,避免把整表撑破。
*/
static void widenRemarkColumn(Sheet sheet, int col, String text) {
if (sheet == null || col < 0 || StringUtils.isBlank(text)) {
return;
}
int longest = 0;
for (String part : text.split("\\r?\\n")) {
longest = Math.max(longest, displayWidth(part));
}
int want = Math.min(Math.max(longest + 2, 18), 26);
int current = sheet.getColumnWidth(col) / 256;
if (current < want) {
sheet.setColumnWidth(col, want * 256);
}
}
private static int countWrappedLines(Sheet sheet, int col, String text) {
int charsPerLine = Math.max(sheet.getColumnWidth(col) / 256, 8);
int lines = 0;
for (String part : text.split("\\r?\\n", -1)) {
int width = Math.max(displayWidth(part), 1);
lines += Math.max(1, (width + charsPerLine - 1) / charsPerLine);
}
return Math.max(lines, 1);
}
private static int displayWidth(String text) {
int n = 0;
for (int i = 0; i < text.length(); i++) {
n += text.charAt(i) > 127 ? 2 : 1;
}
return n;
}
private static int[] resolveMergeOrigin(Sheet sheet, int row, int col) {
@@ -662,13 +859,17 @@ public final class QuoteTemplateExcelSupport {
String text = value == null ? "" : value;
// 标签格「报价编号:」只换冒号后的值,避免整格被工单号顶掉看起来像残缺
String existing = readCellText(cell);
// 「币别」右侧常被误映到「单价」表头,不能把 CNY 盖掉单价二字
if (isFeeTableHeader(existing)) {
// 映射点常落在合并标签格 / 20GP 表头上,不能把原字盖掉
if (isFeeTableHeader(existing) || isLabelLike(existing) || isSectionTitle(sheet, cell)) {
return;
}
int colon = Math.max(existing.indexOf(':'), existing.indexOf(':'));
if (colon >= 0 && colon == existing.length() - 1) {
text = existing + text;
if (colon >= 0) {
String prefix = existing.substring(0, colon + 1);
if (colon == existing.length() - 1 || prefix.toUpperCase(Locale.ROOT).contains("RFQ")
|| prefix.contains("编号")) {
text = prefix + text;
}
}
cell.setCellValue(text);
CellStyle style = cell.getCellStyle();
@@ -756,39 +957,137 @@ public final class QuoteTemplateExcelSupport {
}
}
/**
* 标签右侧的可填格。海运询价单标签常是 E4:G4 合并,值在 H4:J4;
* 不能再取合并区内的 F4,否则会把「起运港」四字盖掉。
* 表头「币别」右侧是 20GP,对不上就空着,不要映到箱型列。
*/
private static String resolveFillTarget(Sheet sheet, Cell labelCell, String sheetName, String text, DataFormatter fmt) {
// 同一格「标签:值」
int idx = Math.max(text.indexOf(':'), text.indexOf(':'));
if (idx >= 0 && idx < text.length() - 1 && StringUtils.isNotBlank(text.substring(idx + 1).trim())) {
// 值已在标签格内:映射到本格(生成时会整格重写为纯值,运营需知悉)
// RFQ No:样例 / 报价编号:WO 这类:值在本格冒号后
return sheetName + "!" + new CellReference(labelCell).formatAsString();
}
// 右侧相邻非黄空格或任意右侧格
int rowIdx = labelCell.getRowIndex();
int startCol = labelCell.getColumnIndex() + 1;
for (int i = 0; i < sheet.getNumMergedRegions(); i++) {
org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(i);
if (range.isInRange(rowIdx, labelCell.getColumnIndex())) {
startCol = range.getLastColumn() + 1;
break;
}
}
Row row = labelCell.getRow();
int nextCol = labelCell.getColumnIndex() + 1;
Cell right = row.getCell(nextCol);
if (right == null) {
right = row.createCell(nextCol);
if (row == null) {
return null;
}
String firstEmpty = null;
String wideEmpty = null;
int last = Math.max(row.getLastCellNum(), startCol + 10);
for (int c = startCol; c <= last; c++) {
int[] dest = resolveMergeOrigin(sheet, rowIdx, c);
Cell cand = row.getCell(dest[1]);
String exist = readCellText(cand);
if (isFeeTableHeader(exist) || isLabelLike(exist) || isSectionTitle(sheet, cand)) {
continue;
}
String ref = sheetName + "!" + new CellReference(dest[0], dest[1]).formatAsString();
if (StringUtils.isNotBlank(exist)) {
// 映射已经写过本字段,不要再跨到右侧「航程时间」那一格
if (firstEmpty == null && wideEmpty == null) {
return ref;
}
break;
}
if (firstEmpty == null) {
firstEmpty = ref;
}
if (wideEmpty == null && mergeWidth(sheet, dest[0], dest[1]) >= 2) {
wideEmpty = ref;
}
}
return wideEmpty != null ? wideEmpty : firstEmpty;
}
/**
* 映射点不够时,按标签把询价七项 / 合计写到标签右侧空格。
* 币别若落在费用表头行则跳过,避免 CNY 盖掉 20GP。
*/
static void writeFactsByLabel(Workbook wb, JSONObject facts, Set<String> yellow) {
if (wb == null || facts == null) {
return;
}
DataFormatter fmt = new DataFormatter();
List<String[]> pending = new ArrayList<>();
for (int s = 0; s < wb.getNumberOfSheets(); s++) {
Sheet sheet = wb.getSheetAt(s);
if (sheet == null) {
continue;
}
String sheetName = sheet.getSheetName();
int lastRow = sheet.getLastRowNum();
for (int r = 0; r <= lastRow; r++) {
Row row = sheet.getRow(r);
if (row == null) {
continue;
}
boolean feeHeader = !parseFeeHeaderRow(sheet, r).isEmpty();
short lastCol = row.getLastCellNum();
for (int c = 0; c < lastCol; c++) {
Cell cell = row.getCell(c);
if (cell == null) {
continue;
}
String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell));
if (text.isEmpty() || isSectionTitle(sheet, cell) || isFeeTableHeader(text)) {
continue;
}
String code = matchLabel(text.replace(":", ":").trim(), FILL_BY_LABEL);
if (StringUtils.isBlank(code)) {
continue;
}
if ("currency".equals(code) && feeHeader) {
continue;
}
String val = firstFact(facts, code, text.replace(":", "").replace(":", "").trim());
if (StringUtils.isBlank(val) || "-".equals(val)) {
continue;
}
String fillRef = resolveFillTarget(sheet, cell, sheetName, text, fmt);
if (StringUtils.isBlank(fillRef) || (yellow != null && yellow.contains(fillRef))) {
continue;
}
pending.add(new String[]{fillRef, val});
}
}
}
// 先扫完再写:POI 遍历行时 createCell 会 ConcurrentModificationException
for (String[] one : pending) {
writeCell(wb, one[0], one[1]);
}
return sheetName + "!" + new CellReference(right).formatAsString();
}
private static String matchLabel(String normalized, Map<String, String> dict) {
if (dict == null || dict.isEmpty()) {
if (dict == null || dict.isEmpty() || StringUtils.isBlank(normalized)) {
return null;
}
String key = normalized;
String key = normalized.replace(":", ":").trim();
if (dict.containsKey(key)) {
return dict.get(key);
}
// 去冒号再试
String noColon = key.replace(":", "").trim();
// 「*货物数量/Quantity…」只取斜杠前中文,避免星号和英文把词典对不上
String primary = feePrimaryName(key);
if (dict.containsKey(primary)) {
return dict.get(primary);
}
String noColon = primary.replace(":", "").trim();
for (Map.Entry<String, String> e : dict.entrySet()) {
String dk = e.getKey().replace(":", ":").replace(":", "").trim();
if (dk.length() < 2 || noColon.length() < 2) {
continue;
}
if (dk.equalsIgnoreCase(noColon) || noColon.startsWith(dk) || dk.startsWith(noColon)) {
if (dk.length() >= 2 && noColon.length() >= 2) {
return e.getValue();
}
return e.getValue();
}
}
return null;
@@ -837,6 +1136,225 @@ public final class QuoteTemplateExcelSupport {
return map;
}
/**
* 询价单左侧/右侧说明文字。写进去会把「起运港/POL…」盖成港口名,销售看起来像空表。
*/
static boolean isLabelLike(String text) {
if (StringUtils.isBlank(text) || text.length() < 4) {
return false;
}
if (isFeeTableHeader(text) || isContainerTypeHeader(text)) {
return false;
}
String[] markers = {
"起运港", "目的港", "询价日期", "品名", "货物数量", "整柜或拼柜", "箱型箱量",
"贸易条款", "货好时间", "商品海关编码", "是否含油", "提货地址", "送货地址", "报价有效期", "费用总计",
"始发港", "目的地", "Inquiry Date", "Commodity", "Place of Receipt"
};
for (String m : markers) {
if (text.contains(m) && (text.contains("/") || text.contains(":") || text.contains("(") || text.length() >= 6)) {
return true;
}
}
return false;
}
/**
* 整行分区标题:始发港费用 / 海运费用 / 汇总。模糊匹配「海运费」时必须跳过。
*/
static boolean isSectionTitle(Sheet sheet, Cell cell) {
if (cell == null) {
return false;
}
String text = readCellText(cell);
if (StringUtils.isBlank(text)) {
return false;
}
if (text.contains("汇总") || text.contains("免用箱期") || text.contains("Origin Charges")
|| text.contains("Destination Charges") || text.contains("Local Charges")) {
return true;
}
int row = cell.getRowIndex();
int col = cell.getColumnIndex();
for (int i = 0; i < sheet.getNumMergedRegions(); i++) {
org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(i);
if (range.isInRange(row, col) && range.getLastColumn() - range.getFirstColumn() >= 6
&& (text.contains("费用") || text.toLowerCase(Locale.ROOT).contains("freight")
|| text.toLowerCase(Locale.ROOT).contains("charges"))) {
return true;
}
}
return false;
}
static boolean isAmountHeader(String text) {
if (StringUtils.isBlank(text)) {
return false;
}
String primary = feePrimaryName(text);
return "金额".equals(primary) || "金額".equals(primary)
|| "amount".equalsIgnoreCase(primary) || "ammount".equalsIgnoreCase(primary)
|| text.contains("金额") || text.contains("金額");
}
static boolean isFeeNameHeader(String text) {
if (StringUtils.isBlank(text)) {
return false;
}
String primary = feePrimaryName(text);
return "费用名称".equals(primary) || "费用名".equals(primary) || "项目".equals(primary)
|| text.contains("费用名称") || text.startsWith("项目/") || "项目/Items".equals(text.trim());
}
static boolean isContainerTypeHeader(String text) {
if (StringUtils.isBlank(text)) {
return false;
}
String t = text.trim().toUpperCase(Locale.ROOT).replace(" ", "");
return t.equals("20GP") || t.equals("40GP") || t.equals("40HQ") || t.equals("40HC")
|| t.equals("45HQ") || t.equals("40NOR") || t.equals("20HQ");
}
static String normalizeContainerType(String text) {
return text == null ? "" : text.trim().toUpperCase(Locale.ROOT).replace(" ", "");
}
static String feePrimaryName(String text) {
if (text == null) {
return "";
}
String t = text.trim();
int slash = t.indexOf('/');
if (slash < 0) {
slash = t.indexOf('/');
}
if (slash > 0) {
t = t.substring(0, slash).trim();
}
if (t.startsWith("*")) {
t = t.substring(1).trim();
}
return t;
}
/**
* 海运费=海运费用;文件费=文件费/Doc.。不对「操作费」误中「码头操作费」。
*/
static boolean feeNameMatches(String cellText, String want) {
if (StringUtils.isBlank(cellText) || StringUtils.isBlank(want)) {
return false;
}
String full = cellText.trim();
if (full.equals(want)) {
return true;
}
String primary = feePrimaryName(full);
if (primary.equals(want)) {
return true;
}
return primary.equals(want + "用") || primary.equals(want + "费用")
|| want.equals(primary + "用") || want.equals(primary + "费用");
}
static Integer pickContainerRateColumn(Map<String, Integer> cols, String boxType) {
if (cols == null || StringUtils.isBlank(boxType)) {
return null;
}
String box = boxType.toUpperCase(Locale.ROOT).replace(" ", "");
String[] keys = {"45HQ", "40HQ", "40HC", "40GP", "20GP", "20HQ"};
for (String key : keys) {
if (box.contains(key) && cols.containsKey(key)) {
return cols.get(key);
}
}
return null;
}
/**
* 费用表里费目名为空的数据行,用来追加 TMS 有、模板没预印的费目。
*/
static int findEmptyFeeDataRow(Sheet sheet, Set<Integer> usedRows) {
if (sheet == null) {
return -1;
}
int maxRow = Math.min(sheet.getLastRowNum() + 8, 80);
List<Integer> headers = new ArrayList<>();
for (int r = 0; r <= maxRow; r++) {
if (!parseFeeHeaderRow(sheet, r).isEmpty()) {
headers.add(r);
}
}
for (Integer header : headers) {
Map<String, Integer> cols = parseFeeHeaderRow(sheet, header);
int nameCol = cols.getOrDefault("name", 0);
for (int r = header + 1; r <= maxRow; r++) {
if (!parseFeeHeaderRow(sheet, r).isEmpty()) {
break;
}
Row row = sheet.getRow(r);
Cell first = row == null ? null : row.getCell(nameCol);
if (isSectionTitle(sheet, first)) {
break;
}
if (usedRows != null && usedRows.contains(r)) {
continue;
}
String name = readCellText(first);
if (StringUtils.isBlank(name)) {
return r;
}
}
}
return -1;
}
static int mergeWidth(Sheet sheet, int row, int col) {
for (int i = 0; i < sheet.getNumMergedRegions(); i++) {
org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(i);
if (range.isInRange(row, col)) {
return range.getLastColumn() - range.getFirstColumn() + 1;
}
}
return 1;
}
static String firstFact(JSONObject facts, String... keys) {
if (facts == null) {
return "";
}
for (String k : keys) {
Object v = facts.get(k);
if (v != null && StringUtils.isNotBlank(String.valueOf(v))) {
return String.valueOf(v).trim();
}
}
// 只传了「货好时间」时,按标签词典反查 cargo_ready_date
if (keys != null && keys.length > 0) {
String code = keys[0];
for (Map.Entry<String, String> e : FILL_BY_LABEL.entrySet()) {
if (code.equals(e.getValue())) {
Object v = facts.get(e.getKey());
if (v != null && StringUtils.isNotBlank(String.valueOf(v))) {
return String.valueOf(v).trim();
}
}
}
}
return "";
}
static String firstNonBlank(String... vals) {
if (vals == null) {
return "";
}
for (String v : vals) {
if (StringUtils.isNotBlank(v) && !"-".equals(v.trim())) {
return v.trim();
}
}
return "";
}
/** sheet!A1 解析。 */
private static final class ParsedRef {
final String sheet;
@@ -95,7 +95,11 @@
"重量": "weight_kg",
"重 量": "weight_kg",
"体积": "volume_cbm",
"体 积": "volume_cbm"
"体 积": "volume_cbm",
"费用总计": "total_amount",
"报价合计": "total_amount",
"RFQ No": "quote_no",
"询价单号": "quote_no"
},
"samplePreviewFacts": {
"customer_name": "ABC Trading Co.",
@@ -122,6 +122,46 @@ public class QuoteTemplateFillTest {
assertEquals(1333.6, row.getCell(5).getNumericCellValue(), 0.01);
assertEquals(14669.60, row.getCell(6).getNumericCellValue(), 0.01);
assertEquals("计费重进位+1000KG", row.getCell(7).getStringCellValue());
assertTrue(row.getCell(7).getCellStyle().getWrapText());
assertTrue(row.getHeightInPoints() >= 28f);
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
@Test
public void seaFeeRemarkSplitsAndWidensColumn() throws Exception {
Path src = Files.createTempFile("quote-remark-src", ".xlsx");
Path out = Files.createTempFile("quote-remark-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("海运询价单");
sheet.setColumnWidth(8, 13 * 256);
org.apache.poi.ss.usermodel.Row head = sheet.createRow(17);
head.createCell(0).setCellValue("项目");
head.createCell(7).setCellValue("金额");
head.createCell(8).setCellValue("备注");
sheet.createRow(18).createCell(0).setCellValue("燃油附加费");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject();
mapping.put("fields", new JSONObject());
JSONObject facts = new JSONObject(true);
facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows(
"[{\"name\":\"燃油附加费\",\"amount\":\"15\",\"remark\":\"计价单位:柜;加点系数:0.0000000\"}]"));
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook wb = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = wb.getSheetAt(0);
org.apache.poi.ss.usermodel.Row row = sheet.getRow(18);
String remark = row.getCell(8).getStringCellValue();
assertTrue(remark.contains("计价单位:柜"));
assertTrue(remark.contains("加点系数:0.0000000"));
assertTrue(remark.contains("\n"));
assertTrue(row.getCell(8).getCellStyle().getWrapText());
assertTrue(sheet.getColumnWidth(8) >= 18 * 256);
assertTrue(row.getHeightInPoints() >= 30f);
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
@@ -244,4 +284,223 @@ public class QuoteTemplateFillTest {
Files.deleteIfExists(out);
}
}
@Test
public void buildFillFactsProjectsSeaSevenFields() {
Map<String, Object> facts = new HashMap<>();
facts.put("起运港", "DONGGUAN");
facts.put("目的港", "海防");
facts.put("品名", "机器设备");
facts.put("货量", "15件");
facts.put("整柜或拼柜", "整柜");
facts.put("箱型箱量", "20GP*1");
facts.put("报价日期", "2026-09-15");
Map<String, Object> quote = new HashMap<>();
quote.put("total", "2287.65");
quote.put("currency", "CNY");
quote.put("fee_lines",
"[{\"name\":\"海运费\",\"amount\":\"100\",\"currency\":\"USD\",\"quantity\":\"1\",\"unit\":\"柜\"},"
+ "{\"name\":\"文件费\",\"amount\":\"450\",\"currency\":\"CNY\",\"quantity\":\"1\",\"unit\":\"票\"}]");
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
.buildFillFacts("WO202609150032", facts, quote, "WuJiLin");
assertEquals("DONGGUAN", out.getString("pol"));
assertEquals("海防", out.getString("pod"));
assertEquals("机器设备", out.getString("commodity"));
assertEquals("15件", out.getString("quantity"));
assertEquals("整柜", out.getString("fcl_or_lcl"));
assertEquals("20GP*1", out.getString("container_type_qty"));
assertEquals("2287.65", out.getString("total_amount"));
}
@Test
public void buildFillFactsProjectsCollabFields() {
Map<String, Object> facts = new HashMap<>();
facts.put("起运港", "DONGGUAN");
facts.put("货好时间", "随时可提");
facts.put("是否含油", "否");
facts.put("是否含电", "否");
facts.put("HS编码", "8479");
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
.buildFillFacts("WO202609150041", facts, new HashMap<>(), "WuJiLin");
assertEquals("随时可提", out.getString("cargo_ready_date"));
assertEquals("8479", out.getString("hs_code"));
assertTrue(out.getString("oil_elec_mag").contains("油:否"));
assertTrue(out.getString("oil_elec_mag").contains("电:否"));
assertEquals(2, out.getJSONArray("fee_rows").size());
}
/**
* 海运询价单:起运港标签是 E4:G4 合并,值在 H4:J4。
* 旧映射把 pol 指到 F4,会把标签盖成港口名;费用数量还会误中「货物数量」列把费目名改成 1。
* 出单必须:标签还在、港口/货量写在值格、TMS 金额进金额列。
*/
@Test
public void seaInquiryFormKeepsLabelsAndWritesTmsFees() throws Exception {
Path src = Files.createTempFile("quote-sea-src", ".xlsx");
Path out = Files.createTempFile("quote-sea-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("海运询价单");
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(3, 3, 1, 3));
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(3, 3, 4, 6));
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(3, 3, 7, 9));
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, 1, 3));
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, 4, 6));
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, 7, 9));
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(5, 5, 1, 3));
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 7, 1, 3));
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(28, 28, 0, 9));
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(43, 43, 2, 9));
row(sheet, 3).createCell(0).setCellValue("*询价日期/Inquiry Date:");
row(sheet, 3).createCell(4).setCellValue("起运港/POL(有指定起运港口的必填)");
row(sheet, 4).createCell(0).setCellValue("*品名/Commodity:");
row(sheet, 4).createCell(4).setCellValue("目的港/POD(有指定目的港口的必填)");
row(sheet, 5).createCell(0).setCellValue("*货物数量/Quantity (或提供装箱单/Packing List)");
row(sheet, 6).createCell(0).setCellValue("*整柜或拼柜/FCL or LCL:");
row(sheet, 7).createCell(0).setCellValue("*箱型箱量/Type & Qty of Cont.:");
row(sheet, 17).createCell(0).setCellValue("项目/Items");
row(sheet, 17).createCell(1).setCellValue("币别/Cur.");
row(sheet, 17).createCell(2).setCellValue("20GP");
row(sheet, 17).createCell(7).setCellValue("金额/Ammount");
row(sheet, 28).createCell(0).setCellValue("海运费用/Ocean Freight");
org.apache.poi.ss.usermodel.Row oceanHead = row(sheet, 29);
oceanHead.createCell(0).setCellValue("项目/Items");
oceanHead.createCell(1).setCellValue("币别/Cur.");
oceanHead.createCell(2).setCellValue("20GP");
oceanHead.createCell(7).setCellValue("金额/Ammount");
row(sheet, 30).createCell(0).setCellValue("海运费用/Ocean Freight");
row(sheet, 30).createCell(7).setCellFormula("D31*G31");
org.apache.poi.ss.usermodel.Row destHead = row(sheet, 34);
destHead.createCell(0).setCellValue("项目/Items");
destHead.createCell(1).setCellValue("币别/Cur.");
destHead.createCell(7).setCellValue("金额/Ammount");
row(sheet, 35).createCell(0).setCellValue("文件费/Doc.");
row(sheet, 43).createCell(0).setCellValue("费用总计/Total Costs");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject();
JSONObject fields = new JSONObject();
fields.put("pol", "海运询价单!F4");
fields.put("pod", "海运询价单!F5");
fields.put("currency", "海运询价单!C18");
mapping.put("fields", fields);
JSONObject facts = new JSONObject(true);
facts.put("pol", "DONGGUAN");
facts.put("pod", "海防");
facts.put("commodity", "机器设备");
facts.put("quantity", "15件");
facts.put("fcl_or_lcl", "整柜");
facts.put("container_type_qty", "20GP*1");
facts.put("currency", "CNY");
facts.put("total_amount", "2287.65");
facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows(
"[{\"name\":\"海运费\",\"amount\":\"100\",\"currency\":\"USD\",\"quantity\":\"1\",\"unit\":\"柜\"},"
+ "{\"name\":\"文件费\",\"amount\":\"450\",\"currency\":\"CNY\",\"quantity\":\"1\",\"unit\":\"票\"},"
+ "{\"name\":\"燃油附加费\",\"amount\":\"15\",\"currency\":\"USD\",\"quantity\":\"1\",\"unit\":\"柜\"}]"));
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertTrue(read(sheet, 3, 4).contains("起运港"));
assertEquals("DONGGUAN", read(sheet, 3, 7));
assertTrue(read(sheet, 4, 4).contains("目的港"));
assertEquals("海防", read(sheet, 4, 7));
assertEquals("机器设备", read(sheet, 4, 1));
assertEquals("15件", read(sheet, 5, 1));
assertEquals("整柜", read(sheet, 6, 1));
assertEquals("20GP*1", read(sheet, 7, 1));
assertEquals("20GP", read(sheet, 17, 2));
assertTrue(read(sheet, 28, 0).contains("海运费用"));
assertTrue(read(sheet, 30, 0).contains("海运费用"));
assertEquals(CellType.NUMERIC, sheet.getRow(30).getCell(7).getCellType());
assertEquals(100.0, sheet.getRow(30).getCell(7).getNumericCellValue(), 0.01);
assertEquals("USD", read(sheet, 30, 1));
assertTrue(read(sheet, 35, 0).contains("文件费"));
assertEquals(450.0, sheet.getRow(35).getCell(7).getNumericCellValue(), 0.01);
assertEquals("燃油附加费", read(sheet, 18, 0));
assertEquals(15.0, sheet.getRow(18).getCell(7).getNumericCellValue(), 0.01);
assertEquals(2287.65, sheet.getRow(43).getCell(2).getNumericCellValue(), 0.01);
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
@Test
public void realSeaTemplateWritesPortsAndFees() throws Exception {
Path src = java.nio.file.Paths.get("d:/Desktop/友通达询价智能体重制版/deploy/sql/sea_tpl.xlsx");
if (!Files.isRegularFile(src)) {
return;
}
Path out = Files.createTempFile("quote-sea-real", ".xlsx");
JSONObject mapping = new JSONObject();
JSONObject fields = new JSONObject();
fields.put("pol", "海运询价单!F4");
fields.put("pod", "海运询价单!F5");
fields.put("currency", "海运询价单!C18");
fields.put("valid_until", "海运询价单!B16");
mapping.put("fields", fields);
JSONObject facts = new JSONObject(true);
facts.put("pol", "DONGGUAN");
facts.put("pod", "海防");
facts.put("commodity", "机器设备");
facts.put("quantity", "15件");
facts.put("fcl_or_lcl", "整柜");
facts.put("container_type_qty", "20GP*1");
facts.put("quote_date", "2026-09-15");
facts.put("valid_until", "2026-09-30");
facts.put("currency", "CNY");
facts.put("total_amount", "2287.65");
facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows(
"[{\"name\":\"海运费\",\"amount\":\"100.00\",\"currency\":\"USD\",\"quantity\":\"1\",\"unit\":\"柜\"},"
+ "{\"name\":\"燃油附加费\",\"amount\":\"15.00\",\"currency\":\"USD\",\"quantity\":\"1\",\"unit\":\"柜\"},"
+ "{\"name\":\"操作费\",\"amount\":\"680.00\",\"currency\":\"CNY\",\"quantity\":\"1\",\"unit\":\"票\"},"
+ "{\"name\":\"文件费\",\"amount\":\"450.00\",\"currency\":\"CNY\",\"quantity\":\"1\",\"unit\":\"票\"},"
+ "{\"name\":\"封条费\",\"amount\":\"50.00\",\"currency\":\"CNY\",\"quantity\":\"1\",\"unit\":\"柜\"},"
+ "{\"name\":\"设备费\",\"amount\":\"50.00\",\"currency\":\"CNY\",\"quantity\":\"1\",\"unit\":\"柜\"},"
+ "{\"name\":\"报关费\",\"amount\":\"200.00\",\"currency\":\"CNY\",\"quantity\":\"1\",\"unit\":\"票\"}]"));
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheet("海运询价单");
assertTrue(read(sheet, 3, 4).contains("起运港"));
assertEquals("DONGGUAN", read(sheet, 3, 7));
assertTrue(read(sheet, 4, 4).contains("目的港"));
assertEquals("海防", read(sheet, 4, 7));
assertEquals("机器设备", read(sheet, 4, 1));
assertEquals("15件", read(sheet, 5, 1));
assertEquals("整柜", read(sheet, 6, 1));
assertEquals("20GP*1", read(sheet, 7, 1));
assertEquals("20GP", read(sheet, 17, 2));
assertTrue(read(sheet, 28, 0).contains("海运费用"));
assertTrue(read(sheet, 30, 0).contains("海运费用"));
assertEquals(CellType.NUMERIC, sheet.getRow(30).getCell(7).getCellType());
assertEquals(100.0, sheet.getRow(30).getCell(7).getNumericCellValue(), 0.01);
assertTrue(read(sheet, 35, 0).contains("文件费"));
assertEquals(CellType.NUMERIC, sheet.getRow(35).getCell(7).getCellType());
assertEquals("", read(sheet, 15, 7));
assertEquals(450.0, sheet.getRow(35).getCell(7).getNumericCellValue(), 0.01);
assertEquals("操作费", read(sheet, 18, 0));
assertEquals(680.0, sheet.getRow(18).getCell(7).getNumericCellValue(), 0.01);
assertEquals(2287.65, sheet.getRow(43).getCell(2).getNumericCellValue(), 0.01);
} finally {
Files.deleteIfExists(out);
}
}
private static org.apache.poi.ss.usermodel.Row row(Sheet sheet, int r) {
org.apache.poi.ss.usermodel.Row exist = sheet.getRow(r);
return exist == null ? sheet.createRow(r) : exist;
}
private static String read(Sheet sheet, int r, int c) {
org.apache.poi.ss.usermodel.Row row = sheet.getRow(r);
if (row == null || row.getCell(c) == null) {
return "";
}
org.apache.poi.ss.usermodel.Cell cell = row.getCell(c);
if (cell.getCellType() == CellType.NUMERIC) {
return String.valueOf(cell.getNumericCellValue());
}
return cell.getStringCellValue();
}
}