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 时也能对上「空运费」等已有行。
+ *
+ * 海运询价单有三段费用表,且「海运费用」先出现在整行标题里。
+ * 必须跳过标题行,列映射取费目上方最近的表头,对不上的费目追加到空行。
+ * 禁止把数量写进「货物数量」标签列,否则会把费目名盖成 1。
*/
public static void writeFeeAmountsByName(Workbook wb, JSONObject facts, Set 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 cols = findFeeTableColumns(sheet);
- if (cols.isEmpty()) {
- cols = fallbackFeeColumns(sheet);
- }
- Integer amountCol = cols.get("amount");
- if (amountCol == null) {
- amountCol = 6;
- }
+ Set usedRows = new HashSet<>();
+ List 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 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 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 cols, JSONObject rowObj,
+ String headerCurrency, String boxType, Set 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 findFeeTableColumns(Sheet sheet) {
- Map 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 cols = parseFeeHeaderRow(sheet, r);
+ if (!cols.isEmpty()) {
+ return cols;
}
}
- if (headerRow < 0) {
+ return new LinkedHashMap<>();
+ }
+
+ /**
+ * 从费目行往上找最近的费用表头。海运有始发/海运/目的三段,列不能都用第一张表。
+ */
+ static Map findFeeTableColumnsAbove(Sheet sheet, int dataRow) {
+ if (sheet == null) {
+ return new LinkedHashMap<>();
+ }
+ for (int r = Math.max(dataRow - 1, 0); r >= 0; r--) {
+ Map cols = parseFeeHeaderRow(sheet, r);
+ if (!cols.isEmpty()) {
+ return cols;
+ }
+ }
+ Map first = findFeeTableColumns(sheet);
+ return first.isEmpty() ? fallbackFeeColumns(sheet) : first;
+ }
+
+ /**
+ * 解析一行是否为费用表头,并记下各列。
+ */
+ static Map parseFeeHeaderRow(Sheet sheet, int r) {
+ Map 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 fallbackFeeColumns(Sheet sheet) {
+ Map found = findFeeTableColumns(sheet);
+ if (!found.isEmpty()) {
+ return found;
+ }
Map 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 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 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 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 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 yellow) {
+ if (wb == null || facts == null) {
+ return;
+ }
+ DataFormatter fmt = new DataFormatter();
+ List 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 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 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 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 usedRows) {
+ if (sheet == null) {
+ return -1;
+ }
+ int maxRow = Math.min(sheet.getLastRowNum() + 8, 80);
+ List headers = new ArrayList<>();
+ for (int r = 0; r <= maxRow; r++) {
+ if (!parseFeeHeaderRow(sheet, r).isEmpty()) {
+ headers.add(r);
+ }
+ }
+ for (Integer header : headers) {
+ Map 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 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;
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/quote-template-mapping-v1.json b/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/quote-template-mapping-v1.json
index afa8d5b..8498312 100644
--- a/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/quote-template-mapping-v1.json
+++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/quote-template-mapping-v1.json
@@ -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.",
diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java
index 3f1e300..d1af42a 100644
--- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java
+++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java
@@ -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 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 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 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();
+ }
}