完善陆海运 TMS 字段与报价单填表:费用数量默认、清空止行安全,写入失败已报价则续办。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+12
-2
@@ -813,8 +813,18 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
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("weight_kg", first(src, "毛重", "重量", "weight_kg"));
|
||||
out.put("volume_cbm", first(src, "体积", "volume_cbm", "volume"));
|
||||
// 去掉单位后缀,避免模板「KG/CBM」列旁再写一遍单位
|
||||
String w = out.getString("weight_kg");
|
||||
if (StringUtils.isNotBlank(w)) {
|
||||
out.put("weight_kg", w.replaceAll("(?i)\\s*(KG|KGS|公斤)\\s*$", "").trim());
|
||||
}
|
||||
String vol = out.getString("volume_cbm");
|
||||
if (StringUtils.isNotBlank(vol)) {
|
||||
out.put("volume_cbm", vol.replaceAll("(?i)\\s*(CBM|立方米|方)\\s*$", "").trim());
|
||||
}
|
||||
out.put("quote_date", first(src, "报价日期", "quote_date", "询价日期"));
|
||||
out.put("trade_terms", first(src, "贸易条款", "trade_terms"));
|
||||
out.put("运输分类", first(src, "运输分类"));
|
||||
|
||||
+202
-14
@@ -595,9 +595,10 @@ public final class QuoteTemplateExcelSupport {
|
||||
// 模板「预估总价」是 SUM。POI 写完数字不会重算,缓存仍是 0。
|
||||
// 企微/WPS 预览常不重算,销售会看到汇总为 0。这里把公式缓存刷成当前值。
|
||||
refreshFormulas(wb);
|
||||
// 刷公式后:预估费用若 SUM 了「发生时收取」固定费,会与 TMS 总销售价不一致。
|
||||
// 有 total_amount 时再按标签写回,以总销售价为准。
|
||||
// 其它标签可再写一遍;合计单独处理,避免「标签旁一个数 + SUM 格另一个数」。
|
||||
writeFactsByLabel(wb, facts, yellow);
|
||||
// 预估费用以 TMS 总销售价为准:盖掉 SUM(常把提送货/人工费加进去),本行只留一个数。
|
||||
overwriteEstimateTotal(wb, facts);
|
||||
// 转 PDF 前把纸张改成 A4 居中,避免顶部裁字、整页偏左。
|
||||
applyQuotePrintLayout(wb);
|
||||
Files.createDirectories(targetXlsx.getParent());
|
||||
@@ -779,7 +780,9 @@ public final class QuoteTemplateExcelSupport {
|
||||
yellow, sheet.getSheetName());
|
||||
writeFeeCol(sheet, rowIdx, columns.getString("name"), rowObj.getString("name"), yellow, sheet.getSheetName());
|
||||
writeFeeCol(sheet, rowIdx, columns.getString("unit"), rowObj.getString("unit"), yellow, sheet.getSheetName());
|
||||
writeFeeCol(sheet, rowIdx, columns.getString("quantity"), rowObj.getString("quantity"), yellow, sheet.getSheetName());
|
||||
writeFeeCol(sheet, rowIdx, columns.getString("quantity"),
|
||||
feeQuantityOrDefault(rowObj.getString("quantity"), rowObj.getString("name")),
|
||||
yellow, sheet.getSheetName());
|
||||
writeFeeCol(sheet, rowIdx, columns.getString("unit_price"),
|
||||
firstNonBlank(rowObj.getString("unit_price"), rowObj.getString("unitPrice")),
|
||||
yellow, sheet.getSheetName());
|
||||
@@ -820,15 +823,29 @@ public final class QuoteTemplateExcelSupport {
|
||||
|
||||
/**
|
||||
* 清空 [fromRow, stopRow) 费用列;黄底固定行保留(如「发生时收取」占位)。
|
||||
* <p>
|
||||
* stopAtText 未命中时 {@link #findStopRow} 会回 Integer.MAX_VALUE。
|
||||
* 若原样循环清空,会捅到 Excel 最大行(1048576)并抛 IllegalArgumentException,整张出单失败。
|
||||
* 因此未命中时只清到表内已有末行,绝不 createRow 扩到上限。
|
||||
*/
|
||||
private static void clearFeeRowsAfter(Sheet sheet, int fromRow, int stopRow, JSONObject columns,
|
||||
Set<String> yellow, String sheetName) {
|
||||
if (sheet == null || columns == null || fromRow >= stopRow) {
|
||||
if (sheet == null || columns == null || fromRow < 0) {
|
||||
return;
|
||||
}
|
||||
int lastExisting = sheet.getLastRowNum();
|
||||
int end = stopRow;
|
||||
if (end == Integer.MAX_VALUE || end > lastExisting + 1) {
|
||||
end = lastExisting + 1;
|
||||
}
|
||||
// XSSF 合法行号 0..1048575;end 是开区间上界
|
||||
end = Math.min(end, 1048575 + 1);
|
||||
if (fromRow >= end) {
|
||||
return;
|
||||
}
|
||||
// 备注列常黄底「发生时收取」:不清 remark,只清费用数据列;黄格由 writeFeeCol 跳过
|
||||
String[] keys = {"project", "name", "unit", "quantity", "unit_price", "amount", "currency", "container_rate"};
|
||||
for (int r = fromRow; r < stopRow; r++) {
|
||||
for (int r = fromRow; r < end; r++) {
|
||||
for (String key : keys) {
|
||||
String colLetter = columns.getString(key);
|
||||
if (StringUtils.isBlank(colLetter)) {
|
||||
@@ -1317,8 +1334,11 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
|
||||
private static int findStopRow(Sheet sheet, JSONArray stopAtText, int fromRow) {
|
||||
if (sheet == null || stopAtText == null || stopAtText.isEmpty()) {
|
||||
return Integer.MAX_VALUE;
|
||||
if (sheet == null) {
|
||||
return 0;
|
||||
}
|
||||
if (stopAtText == null || stopAtText.isEmpty()) {
|
||||
return sheet.getLastRowNum() + 1;
|
||||
}
|
||||
DataFormatter fmt = new DataFormatter();
|
||||
List<String> needles = new ArrayList<>();
|
||||
@@ -1329,7 +1349,7 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
}
|
||||
if (needles.isEmpty()) {
|
||||
return Integer.MAX_VALUE;
|
||||
return sheet.getLastRowNum() + 1;
|
||||
}
|
||||
int last = sheet.getLastRowNum();
|
||||
for (int r = Math.max(0, fromRow); r <= last; r++) {
|
||||
@@ -1352,7 +1372,8 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
}
|
||||
}
|
||||
return Integer.MAX_VALUE;
|
||||
// 未命中:停在表末,禁止回 Integer.MAX_VALUE(清空费用占位时会捅爆 Excel 行上限)
|
||||
return last + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1440,7 +1461,9 @@ public final class QuoteTemplateExcelSupport {
|
||||
int r = startRow + i;
|
||||
writeFeeCol(sheet, r, columns.getString("name"), rowObj.getString("name"), yellow, sheet.getSheetName());
|
||||
writeFeeCol(sheet, r, columns.getString("unit"), rowObj.getString("unit"), yellow, sheet.getSheetName());
|
||||
writeFeeCol(sheet, r, columns.getString("quantity"), rowObj.getString("quantity"), yellow, sheet.getSheetName());
|
||||
writeFeeCol(sheet, r, columns.getString("quantity"),
|
||||
feeQuantityOrDefault(rowObj.getString("quantity"), rowObj.getString("name")),
|
||||
yellow, sheet.getSheetName());
|
||||
writeFeeCol(sheet, r, columns.getString("unit_price"), rowObj.getString("unit_price"), yellow, sheet.getSheetName());
|
||||
writeFeeCol(sheet, r, columns.getString("amount"), rowObj.getString("amount"), yellow, sheet.getSheetName());
|
||||
if (columns.containsKey("currency")) {
|
||||
@@ -1456,6 +1479,10 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (StringUtils.isBlank(colLetter)) {
|
||||
return;
|
||||
}
|
||||
// 清空不存在的行:不必 createRow,避免 stop 未命中时把表撑到 Excel 上限
|
||||
if (rowIdx < 0 || rowIdx > 1048575) {
|
||||
return;
|
||||
}
|
||||
int col = CellReference.convertColStringToIndex(colLetter.trim());
|
||||
String ref = sheetName + "!" + new CellReference(rowIdx, col).formatAsString();
|
||||
if (yellow != null && yellow.contains(ref)) {
|
||||
@@ -1463,6 +1490,9 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
Row row = sheet.getRow(rowIdx);
|
||||
if (row == null) {
|
||||
if (StringUtils.isBlank(value)) {
|
||||
return;
|
||||
}
|
||||
row = sheet.createRow(rowIdx);
|
||||
}
|
||||
Cell cell = row.getCell(col);
|
||||
@@ -1561,6 +1591,14 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (StringUtils.isNotBlank(unit)) {
|
||||
line.put("unit", unit);
|
||||
}
|
||||
// 固定费无数量时默认 1;运输费留给 enrich 写计费重
|
||||
String qty = firstNonBlank(ch.getString("quantity"), ch.getString("chargeQuantity"),
|
||||
ch.getString("charge_quantity"));
|
||||
if (StringUtils.isNotBlank(qty)) {
|
||||
line.put("quantity", qty);
|
||||
} else if (!isAirWeightFeeName(name)) {
|
||||
line.put("quantity", "1");
|
||||
}
|
||||
String ccy = firstNonBlank(ch.getString("currency"), optCcy);
|
||||
if (StringUtils.isNotBlank(ccy)) {
|
||||
line.put("currency", ccy);
|
||||
@@ -1629,6 +1667,26 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 费用行数量:固定费(提送货/人工等)无 quantity 时默认 1;
|
||||
* 空运运输费缺数量时不要填 1(应由计费重 enrich,误填 1 会把金额算错观感)。
|
||||
*/
|
||||
static String feeQuantityOrDefault(String quantity, String feeName) {
|
||||
String q = StringUtils.trimToEmpty(quantity);
|
||||
if (!q.isEmpty() && !"-".equals(q)) {
|
||||
return q;
|
||||
}
|
||||
if (isAirWeightFeeName(feeName)) {
|
||||
return "";
|
||||
}
|
||||
return "1";
|
||||
}
|
||||
|
||||
/** 兼容旧调用:无费目名时按固定费处理(默认 1)。 */
|
||||
static String feeQuantityOrDefault(String quantity) {
|
||||
return feeQuantityOrDefault(quantity, "");
|
||||
}
|
||||
|
||||
public static boolean isAirWeightFeeName(String name) {
|
||||
String n = StringUtils.trimToEmpty(name);
|
||||
if (n.isEmpty()) {
|
||||
@@ -1681,6 +1739,123 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预估费用 / 预估总价行只保留一个总价。
|
||||
* <p>
|
||||
* 为何:模板金额列是 SUM,刷公式后常把「发生时收取」的提送货/人工费加进去;
|
||||
* 若再按标签把 TMS total 写到标签右侧空格,销售会看到同行两个数(如 183370 与 183499)。
|
||||
* 有 total_amount 时以它为准,写入原 SUM 格(或本行最右金额格),并清空同行其它多余数字。
|
||||
*/
|
||||
static void overwriteEstimateTotal(Workbook wb, JSONObject facts) {
|
||||
if (wb == null || facts == null) {
|
||||
return;
|
||||
}
|
||||
String total = firstFact(facts, "total_amount", "total", "报价合计", "预估费用", "预估总价");
|
||||
if (StringUtils.isBlank(total) || "-".equals(total.trim())) {
|
||||
return;
|
||||
}
|
||||
String norm = stripMoneyPrefix(total.trim());
|
||||
Double num;
|
||||
try {
|
||||
num = Double.parseDouble(norm.replace(",", ""));
|
||||
} catch (NumberFormatException e) {
|
||||
num = null;
|
||||
}
|
||||
for (int s = 0; s < wb.getNumberOfSheets(); s++) {
|
||||
Sheet sheet = wb.getSheetAt(s);
|
||||
if (sheet == null) {
|
||||
continue;
|
||||
}
|
||||
for (int r = 0; r <= sheet.getLastRowNum(); r++) {
|
||||
Row row = sheet.getRow(r);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
int labelCol = -1;
|
||||
for (Cell cell : row) {
|
||||
if (cell == null) {
|
||||
continue;
|
||||
}
|
||||
String t = StringUtils.trimToEmpty(readCellText(cell));
|
||||
if (t.contains("预估费用") || t.contains("预估总价") || t.contains("费用总计")
|
||||
|| t.contains("报价合计") || t.contains("总销售价")) {
|
||||
labelCol = cell.getColumnIndex();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (labelCol < 0) {
|
||||
continue;
|
||||
}
|
||||
Cell target = null;
|
||||
List<Cell> extras = new ArrayList<>();
|
||||
short last = row.getLastCellNum();
|
||||
for (int c = labelCol + 1; c < last; c++) {
|
||||
int[] dest = resolveMergeOrigin(sheet, r, c);
|
||||
Cell cell = row.getCell(dest[1]);
|
||||
if (cell == null) {
|
||||
continue;
|
||||
}
|
||||
String exist = readCellText(cell);
|
||||
if (isFeeTableHeader(exist) || isLabelLike(exist) || isSectionTitle(sheet, cell)) {
|
||||
continue;
|
||||
}
|
||||
if (cell.getCellType() == CellType.FORMULA) {
|
||||
if (target == null) {
|
||||
target = cell;
|
||||
} else {
|
||||
extras.add(cell);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (cell.getCellType() == CellType.NUMERIC
|
||||
|| looksLikeMoneyText(exist)) {
|
||||
extras.add(cell);
|
||||
}
|
||||
}
|
||||
if (target == null && !extras.isEmpty()) {
|
||||
// 无公式时取最右金额格作为唯一总价格
|
||||
target = extras.remove(extras.size() - 1);
|
||||
}
|
||||
if (target == null) {
|
||||
// 标签右侧尚无格:落在金额列常见位置(与费目表头对齐时由调用方保证)
|
||||
target = row.getCell(labelCol + 1);
|
||||
if (target == null) {
|
||||
target = row.createCell(labelCol + 1);
|
||||
}
|
||||
}
|
||||
if (num != null) {
|
||||
target.setCellValue(num.doubleValue());
|
||||
} else {
|
||||
target.setCellValue(norm);
|
||||
}
|
||||
for (Cell extra : extras) {
|
||||
if (extra == null || extra == target) {
|
||||
continue;
|
||||
}
|
||||
// POI 旧版无 setBlank;清空即可,避免同行残留第二份总价
|
||||
extra.setCellValue("");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否像金额文本(含小数或纯数字),用于清掉预估费用行上误写的第二份数。 */
|
||||
private static boolean looksLikeMoneyText(String raw) {
|
||||
if (StringUtils.isBlank(raw) || "-".equals(raw.trim())) {
|
||||
return false;
|
||||
}
|
||||
String t = stripMoneyPrefix(raw.trim()).replace(",", "");
|
||||
if (!t.matches("-?\\d+(\\.\\d+)?")) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Double.parseDouble(t);
|
||||
return true;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回填后重算公式,把 SUM 等缓存从 0 刷成真实合计。
|
||||
* 失败不抛:出单仍要发出,只记日志。
|
||||
@@ -1922,7 +2097,8 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
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, qtyCol,
|
||||
feeQuantityOrDefault(rowObj.getString("quantity"), rowObj.getString("name")), 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);
|
||||
@@ -2552,6 +2728,10 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (StringUtils.isBlank(code)) {
|
||||
continue;
|
||||
}
|
||||
// 合计由 overwriteEstimateTotal 独占写入,避免标签旁与 SUM 格各写一份
|
||||
if ("total_amount".equals(code)) {
|
||||
continue;
|
||||
}
|
||||
if ("currency".equals(code) && feeHeader) {
|
||||
continue;
|
||||
}
|
||||
@@ -2635,13 +2815,21 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (dict.containsKey(primary)) {
|
||||
return dict.get(primary);
|
||||
}
|
||||
// 模板常写成「重 量:」「体 积:」,去空白后再对
|
||||
String compact = primary.replace(":", "").replaceAll("\\s+", "");
|
||||
if (dict.containsKey(compact)) {
|
||||
return dict.get(compact);
|
||||
}
|
||||
String noColon = primary.replace(":", "").trim();
|
||||
for (Map.Entry<String, String> e : dict.entrySet()) {
|
||||
String dk = e.getKey().replace(":", ":").replace(":", "").trim();
|
||||
if (dk.length() < 3 || noColon.length() < 3) {
|
||||
String dk = e.getKey().replace(":", ":").replace(":", "").replaceAll("\\s+", "").trim();
|
||||
if (dk.length() < 2 || compact.length() < 2) {
|
||||
continue;
|
||||
}
|
||||
if (dk.equalsIgnoreCase(noColon) || noColon.startsWith(dk) || dk.startsWith(noColon)) {
|
||||
if (dk.equalsIgnoreCase(compact) || compact.equals(dk)) {
|
||||
return e.getValue();
|
||||
}
|
||||
if (dk.length() >= 3 && (compact.startsWith(dk) || dk.startsWith(compact))) {
|
||||
return e.getValue();
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -792,6 +792,9 @@ public class InquiryTmsQueryService {
|
||||
Map<String, String> extras = new LinkedHashMap<>();
|
||||
putExtra(extras, "unit", firstText(ch.get("unit"), ch.get("unitName")));
|
||||
putExtra(extras, "unit_price", unitPrice);
|
||||
// 固定费无数量时默认 1;运输费 enrich 会改成计费重
|
||||
String qty = firstText(ch.get("quantity"), ch.get("chargeQuantity"), ch.get("charge_quantity"));
|
||||
putExtra(extras, "quantity", StringUtils.isBlank(qty) ? "1" : qty);
|
||||
putExtra(extras, "project", isAirWeightFeeName(name) ? "空运段" : "起运港");
|
||||
// 先把单价写入 amount;运输费会在 enrich 里改成 单价×计费重
|
||||
addFeeLine(out, name, unitPrice, lineCcy, extras);
|
||||
|
||||
+8
-14
@@ -10,11 +10,12 @@ import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 海运出站前把销售口语整理成 TMS V2 字段。
|
||||
* 海运出站字段整理。
|
||||
* <p>
|
||||
* 对照接口文档 6.3:FCL 必须带 containerType + containerQty(如 40HQ / 1),
|
||||
* loadType 只能是 FCL/LCL,cargoQty 必须是数字。销售卡片仍看原文「整柜 / 1x40HQ / 15件」。
|
||||
* 禁止:把口语原样扔给 TMS;在这里猜港口。
|
||||
* 产品口径(与陆运一致):整柜/拼柜等业务枚举<strong>不再</strong>翻成 FCL/LCL,销售原文直传。
|
||||
* 箱型箱量、货量仍只做「去单位 / 拆箱型」的格式整理(20GP*1→20GP+1、15件→15),
|
||||
* 避免带「件」「吨」触发 TMS 1001;这不是中英字典映射。
|
||||
* 禁止:在这里猜港口。
|
||||
*/
|
||||
public final class SeaTmsFieldNormalizer {
|
||||
|
||||
@@ -38,7 +39,7 @@ public final class SeaTmsFieldNormalizer {
|
||||
}
|
||||
|
||||
/**
|
||||
* 整柜/拼柜 → FCL/LCL。已经是 FCL/LCL 的原样(大写)。
|
||||
* 整柜/拼柜原文直传(trim)。已是 FCL/LCL 的旧快照也原样放过,不做双向翻译。
|
||||
*/
|
||||
public static String loadType(Object raw) {
|
||||
if (raw == null) {
|
||||
@@ -48,14 +49,7 @@ public final class SeaTmsFieldNormalizer {
|
||||
if (text.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String upper = text.toUpperCase(Locale.ROOT);
|
||||
if ("FCL".equals(upper) || text.contains("整柜") || text.contains("整箱")) {
|
||||
return "FCL";
|
||||
}
|
||||
if ("LCL".equals(upper) || text.contains("拼柜") || text.contains("拼箱")) {
|
||||
return "LCL";
|
||||
}
|
||||
return upper;
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,7 +115,7 @@ public final class SeaTmsFieldNormalizer {
|
||||
}
|
||||
|
||||
/**
|
||||
* 口语箱型落到 TMS 码:40HQ / 40GP / 20GP / 45HQ。
|
||||
* 箱型尺码落到标准码:40HQ / 40GP / 20GP / 45HQ(从销售原文拆出,非中英字典表)。
|
||||
*/
|
||||
static String toTmsType(String size, String suffix) {
|
||||
if (size == null) {
|
||||
|
||||
+14
-45
@@ -1,68 +1,37 @@
|
||||
package org.jeecg.modules.inquiry.tms;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 陆运 TMS 6.1.1 枚举:销售中文 → 出站码。
|
||||
* 陆运运输类型 / 线路类别出站:产品要求原文直传,不再翻成 TMS 英文枚举。
|
||||
* <p>
|
||||
* 职责:只在打 TMS 前翻译运输类型、线路类别。工单/卡片/拉人仍用中文。
|
||||
* 禁止:把运输分类、中港装载方式译进出站;发明表外码。
|
||||
* 职责:统一 trim;空串表示未填。工单/卡片/拉人本来就是中文,出站与之一致。
|
||||
* 禁止:再把「跨境整车」写成 CROSS_BORDER_FTL;把运输分类、中港装载方式塞进出站。
|
||||
* 历史英文码若仍传入,原样放过(兼容旧快照),不做双向翻译。
|
||||
*/
|
||||
public final class TmsLandEnumMapper {
|
||||
|
||||
private static final Map<String, String> TYPE = new LinkedHashMap<String, String>();
|
||||
private static final Map<String, String> ROUTE = new LinkedHashMap<String, String>();
|
||||
|
||||
static {
|
||||
TYPE.put("国内运输拼车", "DOMESTIC_LTL");
|
||||
TYPE.put("国内运输整车", "DOMESTIC_FTL");
|
||||
TYPE.put("跨境集拼", "CROSS_BORDER_LTL");
|
||||
TYPE.put("跨境整车", "CROSS_BORDER_FTL");
|
||||
TYPE.put("中港整车", "CHINA_HK");
|
||||
TYPE.put("中港零担/集拼", "CHINA_HK");
|
||||
TYPE.put("中港", "CHINA_HK");
|
||||
TYPE.put("DOMESTIC_LTL", "DOMESTIC_LTL");
|
||||
TYPE.put("DOMESTIC_FTL", "DOMESTIC_FTL");
|
||||
TYPE.put("CROSS_BORDER_LTL", "CROSS_BORDER_LTL");
|
||||
TYPE.put("CROSS_BORDER_FTL", "CROSS_BORDER_FTL");
|
||||
TYPE.put("CHINA_HK", "CHINA_HK");
|
||||
ROUTE.put("国内长途/零担", "DOMESTIC_LONG_LTL");
|
||||
ROUTE.put("国内城配/拖车/打包", "DOMESTIC_CITY_DIST");
|
||||
ROUTE.put("中港/中亚/中欧", "CN_HK_CA_EU");
|
||||
ROUTE.put("东南亚", "SOUTHEAST_ASIA");
|
||||
ROUTE.put("DOMESTIC_LONG_LTL", "DOMESTIC_LONG_LTL");
|
||||
ROUTE.put("DOMESTIC_CITY_DIST", "DOMESTIC_CITY_DIST");
|
||||
ROUTE.put("CN_HK_CA_EU", "CN_HK_CA_EU");
|
||||
ROUTE.put("SOUTHEAST_ASIA", "SOUTHEAST_ASIA");
|
||||
}
|
||||
|
||||
private TmsLandEnumMapper() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param raw 销售中文或已是 TMS 码
|
||||
* @return 6.1.1 landTransportType;对不上返回空串,禁止用中文顶上
|
||||
* @param raw 销售中文(或历史英文码)
|
||||
* @return trim 后原文;空则 ""
|
||||
*/
|
||||
public static String transportType(String raw) {
|
||||
if (raw == null) {
|
||||
return "";
|
||||
}
|
||||
String key = raw.trim();
|
||||
String code = TYPE.get(key);
|
||||
return code == null ? "" : code;
|
||||
return trimOrEmpty(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param raw 销售中文或已是 TMS 码
|
||||
* @return 6.1.1 routeCategory;对不上返回空串
|
||||
* @param raw 销售中文(或历史英文码)
|
||||
* @return trim 后原文;空则 ""
|
||||
*/
|
||||
public static String routeCategory(String raw) {
|
||||
return trimOrEmpty(raw);
|
||||
}
|
||||
|
||||
private static String trimOrEmpty(String raw) {
|
||||
if (raw == null) {
|
||||
return "";
|
||||
}
|
||||
String key = raw.trim();
|
||||
String code = ROUTE.get(key);
|
||||
return code == null ? "" : code;
|
||||
return raw.trim();
|
||||
}
|
||||
}
|
||||
|
||||
+13
-9
@@ -18,9 +18,9 @@ import java.util.regex.Pattern;
|
||||
* 职责:把工单 facts 里的中文键写成 TMS 段;运输分类只留在工单,不进出站。
|
||||
* 拼车/跨境集拼带体积重量;国内整车/跨境整车/中港零担带「车型/数量」一项再拆成出站车型与台数;
|
||||
* 中港整车不带车型/数量。通关口岸有值才放。
|
||||
* 中港整车、中港零担/集拼出站类型都是 CHINA_HK。
|
||||
* 类型/线路出站必须是 TMS 6.1.1 英文枚举,线路键名 routeCategory。
|
||||
* 禁止:按空运要包装件数;把分类当查价条件;直连 TMS。
|
||||
* 中港整车、中港零担/集拼:出站类型仍用销售中文「中港整车」「中港零担/集拼」(产品要求不做英文枚举映射)。
|
||||
* 类型/线路键名仍是 landTransportType / routeCategory,值用中文原文。
|
||||
* 禁止:按空运要包装件数;把分类当查价条件;直连 TMS;再把中文翻成 CROSS_BORDER_*。
|
||||
*/
|
||||
public final class TmsLandSegmentMapper {
|
||||
|
||||
@@ -91,15 +91,19 @@ public final class TmsLandSegmentMapper {
|
||||
missing.add(key);
|
||||
}
|
||||
}
|
||||
String typeEnum = text(seg.get("landTransportType"));
|
||||
// 出站已改为中文原文;拦网同时认中文集合与历史英文码,避免旧快照误拦。
|
||||
String typeOnSeg = text(seg.get("landTransportType"));
|
||||
String cnType = text(facts == null ? null : facts.get("运输类型"));
|
||||
boolean lcl = LCL.contains(cnType)
|
||||
|| "DOMESTIC_LTL".equals(typeEnum)
|
||||
|| "CROSS_BORDER_LTL".equals(typeEnum);
|
||||
boolean hkFull = "中港整车".equals(cnType);
|
||||
|| LCL.contains(typeOnSeg)
|
||||
|| "DOMESTIC_LTL".equals(typeOnSeg)
|
||||
|| "CROSS_BORDER_LTL".equals(typeOnSeg);
|
||||
boolean hkFull = "中港整车".equals(cnType) || "中港整车".equals(typeOnSeg);
|
||||
boolean ftl = FTL.contains(cnType)
|
||||
|| "DOMESTIC_FTL".equals(typeEnum)
|
||||
|| "CROSS_BORDER_FTL".equals(typeEnum)
|
||||
|| FTL.contains(typeOnSeg)
|
||||
|| "DOMESTIC_FTL".equals(typeOnSeg)
|
||||
|| "CROSS_BORDER_FTL".equals(typeOnSeg)
|
||||
|| "CHINA_HK".equals(typeOnSeg)
|
||||
|| "中港零担/集拼".equals(cnType);
|
||||
if (lcl) {
|
||||
if (seg.get("volumeCbm") == null) {
|
||||
|
||||
+4
-4
@@ -92,8 +92,8 @@ public final class TmsV2RequestMapper {
|
||||
put(target, "packageType", first(source, "packageType", "packagingType"));
|
||||
// 空运选填:有值才带。贸易条款不进出站,只给后台模板关键词匹配。
|
||||
applyAirOptionalFields(source, target);
|
||||
// 海运出站:口语必须先整理。FCL 要 containerType+containerQty,loadType 只能 FCL/LCL,
|
||||
// cargoQty 只能是数字。销售侧「整柜 / 1x40HQ / 15件」原样留在工单,不在这里改。
|
||||
// 海运出站:货量去单位、箱型拆码;整柜/拼柜改为原文直传(不再 FCL/LCL)。
|
||||
// FCL 要 containerType+containerQty;销售侧「整柜 / 1x40HQ / 15件」卡片仍看原文。
|
||||
applySeaFields(source, target);
|
||||
applyLandFields(source, target);
|
||||
return compact(target);
|
||||
@@ -144,8 +144,8 @@ public final class TmsV2RequestMapper {
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运出站:类型+线路用 6.1.1 枚举;线路字段名是 routeCategory。
|
||||
* 分类和中港装载即使误放在 source 也不抄走。中文不得原样出站。
|
||||
* 陆运出站:类型+线路用销售中文原文(产品要求不做英文枚举映射);线路字段名是 routeCategory。
|
||||
* 分类和中港装载即使误放在 source 也不抄走。
|
||||
*/
|
||||
private static void applyLandFields(Map<String, Object> source, Map<String, Object> target) {
|
||||
String type = textOf(first(source, "landTransportType"));
|
||||
|
||||
+79
-5
@@ -221,8 +221,8 @@ public class QuoteTemplateFillTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 空运模板「预估总价」是 SUM。回填后必须把公式缓存刷成合计,
|
||||
* 否则企微/WPS 预览仍显示模板里的 0。
|
||||
* 空运模板「预估总价」原是 SUM。有 total_amount(TMS 总销售价)时改写成该数,
|
||||
* 避免刷公式后把固定费加进去、或标签旁再写一份形成两个总价。
|
||||
*/
|
||||
@Test
|
||||
public void refreshSumCacheAfterWritingFeeAmount() throws Exception {
|
||||
@@ -260,9 +260,7 @@ public class QuoteTemplateFillTest {
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook formulas = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
org.apache.poi.ss.usermodel.Cell sum = formulas.getSheetAt(0).getRow(22).getCell(6);
|
||||
assertEquals(CellType.FORMULA, sum.getCellType());
|
||||
assertEquals("SUM(G17:G22)", sum.getCellFormula());
|
||||
// 公式格的数字缓存 = 企微预览看到的值;没刷缓存会是 0
|
||||
assertEquals(CellType.NUMERIC, sum.getCellType());
|
||||
assertEquals(14669.60, sum.getNumericCellValue(), 0.01);
|
||||
assertEquals("单价", formulas.getSheetAt(0).getRow(15).getCell(4).getStringCellValue());
|
||||
} finally {
|
||||
@@ -271,6 +269,82 @@ public class QuoteTemplateFillTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 空运段 + 起运港固定费时:SUM 会大于 TMS 总销售价;
|
||||
* 预估费用行只能出现 total_amount 一个数,不能标签旁与金额列各一份。
|
||||
*/
|
||||
@Test
|
||||
public void estimateTotalKeepsSingleTmsAmount() throws Exception {
|
||||
Path src = Files.createTempFile("quote-est-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-est-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("报价");
|
||||
org.apache.poi.ss.usermodel.Row head = sheet.createRow(10);
|
||||
head.createCell(0).setCellValue("项目");
|
||||
head.createCell(1).setCellValue("费用名称");
|
||||
head.createCell(6).setCellValue("金额");
|
||||
sheet.createRow(11).createCell(1).setCellValue("运输费");
|
||||
sheet.createRow(12).createCell(1).setCellValue("提送货费");
|
||||
sheet.createRow(13).createCell(1).setCellValue("人工费");
|
||||
org.apache.poi.ss.usermodel.Row total = sheet.createRow(15);
|
||||
total.createCell(0).setCellValue("预估费用");
|
||||
total.createCell(6).setCellFormula("SUM(G12:G14)");
|
||||
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("total_amount", "183370.00");
|
||||
facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows(
|
||||
"["
|
||||
+ "{\"name\":\"运输费\",\"amount\":\"183370.00\",\"currency\":\"CNY\",\"unit\":\"KG\",\"quantity\":\"16670\",\"unit_price\":\"11\"},"
|
||||
+ "{\"name\":\"提送货费\",\"amount\":\"6.00\",\"currency\":\"CNY\",\"unit\":\"板数\",\"unit_price\":\"6\"},"
|
||||
+ "{\"name\":\"人工费\",\"amount\":\"123.00\",\"currency\":\"CNY\",\"unit\":\"柜\",\"unit_price\":\"123\"}"
|
||||
+ "]"));
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook got = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
org.apache.poi.ss.usermodel.Row row = got.getSheetAt(0).getRow(15);
|
||||
int numericCount = 0;
|
||||
Double only = null;
|
||||
for (int c = 1; c < row.getLastCellNum(); c++) {
|
||||
org.apache.poi.ss.usermodel.Cell cell = row.getCell(c);
|
||||
if (cell == null) {
|
||||
continue;
|
||||
}
|
||||
if (cell.getCellType() == CellType.NUMERIC) {
|
||||
numericCount++;
|
||||
only = cell.getNumericCellValue();
|
||||
} else if (cell.getCellType() == CellType.FORMULA) {
|
||||
numericCount++;
|
||||
only = cell.getNumericCellValue();
|
||||
} else if (cell.getCellType() == CellType.STRING
|
||||
&& cell.getStringCellValue() != null
|
||||
&& cell.getStringCellValue().trim().matches("-?\\d+(\\.\\d+)?")) {
|
||||
numericCount++;
|
||||
only = Double.parseDouble(cell.getStringCellValue().trim());
|
||||
}
|
||||
}
|
||||
assertEquals(1, numericCount);
|
||||
assertEquals(183370.00, only, 0.01);
|
||||
assertEquals(CellType.NUMERIC, row.getCell(6).getCellType());
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void feeQuantityDefaultsToOneWhenBlank() {
|
||||
assertEquals("1", QuoteTemplateExcelSupport.feeQuantityOrDefault(null, "提送货费"));
|
||||
assertEquals("1", QuoteTemplateExcelSupport.feeQuantityOrDefault("", "人工费"));
|
||||
assertEquals("1", QuoteTemplateExcelSupport.feeQuantityOrDefault("-", "操作费"));
|
||||
assertEquals("", QuoteTemplateExcelSupport.feeQuantityOrDefault(null, "运输费"));
|
||||
assertEquals("", QuoteTemplateExcelSupport.feeQuantityOrDefault("", "空运费"));
|
||||
assertEquals("16670", QuoteTemplateExcelSupport.feeQuantityOrDefault("16670", "运输费"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 出单后必须 A4 水平居中,抬头行高够放下中英文公司名。
|
||||
* 否则转 PDF 会顶部裁字、整页偏左。
|
||||
|
||||
+3
-3
@@ -15,9 +15,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
public class SeaTmsFieldNormalizerTest {
|
||||
|
||||
@Test
|
||||
public void loadTypeFclLcl() {
|
||||
assertEquals("FCL", SeaTmsFieldNormalizer.loadType("整柜"));
|
||||
assertEquals("LCL", SeaTmsFieldNormalizer.loadType("拼柜"));
|
||||
public void loadTypePassesChinese() {
|
||||
assertEquals("整柜", SeaTmsFieldNormalizer.loadType("整柜"));
|
||||
assertEquals("拼柜", SeaTmsFieldNormalizer.loadType("拼柜"));
|
||||
assertEquals("FCL", SeaTmsFieldNormalizer.loadType("FCL"));
|
||||
}
|
||||
|
||||
|
||||
+14
-8
@@ -5,24 +5,30 @@ import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* 陆运 TMS 枚举:销售中文不得原样出站。不打真实 TMS。
|
||||
* 陆运类型/线路:产品要求原文直传,不再翻英文枚举。
|
||||
*/
|
||||
public class TmsLandEnumMapperTest {
|
||||
|
||||
@Test
|
||||
public void pincheCityDistMatchesTms61() {
|
||||
assertEquals("DOMESTIC_LTL", TmsLandEnumMapper.transportType("国内运输拼车"));
|
||||
assertEquals("DOMESTIC_CITY_DIST", TmsLandEnumMapper.routeCategory("国内城配/拖车/打包"));
|
||||
public void transportTypePassesChinese() {
|
||||
assertEquals("国内运输拼车", TmsLandEnumMapper.transportType("国内运输拼车"));
|
||||
assertEquals("跨境整车", TmsLandEnumMapper.transportType(" 跨境整车 "));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void hkSplitTypesBothMapChinaHk() {
|
||||
assertEquals("CHINA_HK", TmsLandEnumMapper.transportType("中港整车"));
|
||||
assertEquals("CHINA_HK", TmsLandEnumMapper.transportType("中港零担/集拼"));
|
||||
public void routeCategoryPassesChinese() {
|
||||
assertEquals("国内城配/拖车/打包", TmsLandEnumMapper.routeCategory("国内城配/拖车/打包"));
|
||||
assertEquals("东南亚", TmsLandEnumMapper.routeCategory("东南亚"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void alreadyEnumStays() {
|
||||
public void blankBecomesEmpty() {
|
||||
assertEquals("", TmsLandEnumMapper.transportType(null));
|
||||
assertEquals("", TmsLandEnumMapper.routeCategory(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void legacyEnglishCodesPassThrough() {
|
||||
assertEquals("DOMESTIC_LTL", TmsLandEnumMapper.transportType("DOMESTIC_LTL"));
|
||||
assertEquals("SOUTHEAST_ASIA", TmsLandEnumMapper.routeCategory("SOUTHEAST_ASIA"));
|
||||
}
|
||||
|
||||
+9
-9
@@ -27,8 +27,8 @@ public class TmsLandSegmentMapperTest {
|
||||
facts.put("毛重", "50");
|
||||
facts.put("报价日期", "2026-09-17");
|
||||
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
|
||||
assertEquals("CROSS_BORDER_LTL", seg.get("landTransportType"));
|
||||
assertEquals("SOUTHEAST_ASIA", seg.get("routeCategory"));
|
||||
assertEquals("跨境集拼", seg.get("landTransportType"));
|
||||
assertEquals("东南亚", seg.get("routeCategory"));
|
||||
assertFalse(seg.containsKey("landRouteCategory"));
|
||||
assertNull(seg.get("landClass"));
|
||||
assertFalse(seg.containsKey("hkLoadType"));
|
||||
@@ -46,7 +46,7 @@ public class TmsLandSegmentMapperTest {
|
||||
facts.put("车型/数量", "9米6/1");
|
||||
facts.put("体积", "99");
|
||||
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
|
||||
assertEquals("CHINA_HK", seg.get("landTransportType"));
|
||||
assertEquals("中港零担/集拼", seg.get("landTransportType"));
|
||||
assertEquals("9米6", seg.get("vehicleType"));
|
||||
assertNull(seg.get("volumeCbm"));
|
||||
assertFalse(seg.containsKey("hkLoadType"));
|
||||
@@ -62,7 +62,7 @@ public class TmsLandSegmentMapperTest {
|
||||
facts.put("目的港", "香港");
|
||||
facts.put("品名", "衣服");
|
||||
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
|
||||
assertEquals("CHINA_HK", seg.get("landTransportType"));
|
||||
assertEquals("中港整车", seg.get("landTransportType"));
|
||||
assertNull(seg.get("vehicleType"));
|
||||
assertNull(seg.get("quantity"));
|
||||
java.util.List<String> missing = TmsLandSegmentMapper.missingQuery(
|
||||
@@ -85,7 +85,7 @@ public class TmsLandSegmentMapperTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void wo011PincheCityDistUsesTmsEnums() {
|
||||
public void wo011PincheCityDistPassesChinese() {
|
||||
Map<String, Object> facts = new HashMap<String, Object>();
|
||||
facts.put("运输类型", "国内运输拼车");
|
||||
facts.put("线路类别", "国内城配/拖车/打包");
|
||||
@@ -95,8 +95,8 @@ public class TmsLandSegmentMapperTest {
|
||||
facts.put("体积", "10CBM");
|
||||
facts.put("毛重", "10KG");
|
||||
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
|
||||
assertEquals("DOMESTIC_LTL", seg.get("landTransportType"));
|
||||
assertEquals("DOMESTIC_CITY_DIST", seg.get("routeCategory"));
|
||||
assertEquals("国内运输拼车", seg.get("landTransportType"));
|
||||
assertEquals("国内城配/拖车/打包", seg.get("routeCategory"));
|
||||
assertFalse(seg.containsKey("landRouteCategory"));
|
||||
java.util.List<String> missing = TmsLandSegmentMapper.missingQuery(
|
||||
"WO202609170011", "2026-09-17", seg, facts);
|
||||
@@ -116,8 +116,8 @@ public class TmsLandSegmentMapperTest {
|
||||
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
|
||||
java.util.List<String> missing = TmsLandSegmentMapper.missingQuery(
|
||||
"WO202609170005", "2026-09-17", seg);
|
||||
assertEquals("DOMESTIC_LTL", seg.get("landTransportType"));
|
||||
assertEquals("DOMESTIC_LONG_LTL", seg.get("routeCategory"));
|
||||
assertEquals("国内运输拼车", seg.get("landTransportType"));
|
||||
assertEquals("国内长途/零担", seg.get("routeCategory"));
|
||||
assertNull(seg.get("quantity"));
|
||||
assertEquals(java.util.Collections.emptyList(), missing);
|
||||
}
|
||||
|
||||
+4
-4
@@ -79,7 +79,7 @@ public class TmsV2RequestMapperTest {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
|
||||
assertEquals(new java.math.BigDecimal("20"), segs.get(0).get("cargoQty"));
|
||||
assertEquals("FCL", segs.get(0).get("loadType"));
|
||||
assertEquals("整柜", segs.get(0).get("loadType"));
|
||||
assertEquals(items, segs.get(0).get("containerItems"));
|
||||
assertFalse(segs.get(0).containsKey("quantity"));
|
||||
assertFalse(segs.get(0).containsKey("weightKg"));
|
||||
@@ -102,7 +102,7 @@ public class TmsV2RequestMapperTest {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
|
||||
assertEquals(new java.math.BigDecimal("15"), segs.get(0).get("cargoQty"));
|
||||
assertEquals("FCL", segs.get(0).get("loadType"));
|
||||
assertEquals("整柜", segs.get(0).get("loadType"));
|
||||
assertEquals("40HQ", segs.get(0).get("containerType"));
|
||||
assertEquals(Integer.valueOf(1), segs.get(0).get("containerQty"));
|
||||
}
|
||||
@@ -126,8 +126,8 @@ public class TmsV2RequestMapperTest {
|
||||
Map<String, Object> body = TmsV2RequestMapper.quote(internal);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
|
||||
assertEquals("CROSS_BORDER_LTL", segs.get(0).get("landTransportType"));
|
||||
assertEquals("SOUTHEAST_ASIA", segs.get(0).get("routeCategory"));
|
||||
assertEquals("跨境集拼", segs.get(0).get("landTransportType"));
|
||||
assertEquals("东南亚", segs.get(0).get("routeCategory"));
|
||||
assertFalse(segs.get(0).containsKey("landRouteCategory"));
|
||||
assertFalse(segs.get(0).containsKey("landClass"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user