空运报价明细改从 departureCharges 回填,并按计费重补全单价数量与金额。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-22 16:52:56 +08:00
co-authored by Cursor
parent c83c826223
commit 3a07dba2fb
4 changed files with 400 additions and 33 deletions
@@ -21,7 +21,6 @@ import org.jeecg.modules.inquiry.template.QuoteTemplateExcelSupport;
import org.jeecg.modules.inquiry.template.QuoteTemplateMatcher;
import org.jeecg.modules.inquiry.template.QuoteTemplateObjectStore;
import org.jeecg.modules.inquiry.template.QuoteTemplateSnapshotRenderer;
import org.jeecg.modules.inquiry.tms.InquiryTmsQueryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@@ -866,6 +865,12 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
if (rows.isEmpty()) {
rows = QuoteTemplateExcelSupport.parseFeeRows(q.get("fee_rows"));
}
// 空运:已落库 quote 常只有一条 fee_lines,完整明细在 airOptions.departureCharges
JSONArray fromAir = QuoteTemplateExcelSupport.parseAirDepartureChargeRows(
q.get("airOptions"), q.get("air_options"), chargeable, currency);
if (!fromAir.isEmpty() && (rows.isEmpty() || rows.size() < fromAir.size())) {
rows = fromAir;
}
if (rows.isEmpty()) {
String freight = first(q, "freight");
if (StringUtils.isNotBlank(freight) && !"-".equals(freight)) {
@@ -903,9 +908,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
lineMaps.add(m);
}
Map<String, Object> enrichCtx = new LinkedHashMap<>();
enrichCtx.put("chargeableWeightKg", chargeable);
InquiryTmsQueryService.enrichAirWeightFeeLines(lineMaps, enrichCtx);
// 出单路径自带重算(单位/单价/数量/金额)
QuoteTemplateExcelSupport.enrichAirWeightFeeRows(lineMaps, chargeable);
JSONArray enriched = new JSONArray();
for (Map<String, String> m : lineMaps) {
// Fastjson 只要 Map<String,Object>,不能直接塞 Map<String,String>。
@@ -771,11 +771,12 @@ public final class QuoteTemplateExcelSupport {
log.warn("费用区行数仍越过 stop,截断 region={} wrote={}/{}", region.getString("id"), r, rows.size());
break;
}
// 单行黄底:保留模板占位文案,不覆盖
if (rowLooksYellowFixed(sheet, rowIdx)) {
continue;
}
// 不整行跳过:备注「发生时收取」常标黄,整行跳过会把费用名留成模板样例(如 ZUH)。
// 黄格仍由 writeFeeCol 逐格跳过。
JSONObject rowObj = rows.getJSONObject(r);
writeFeeCol(sheet, rowIdx, columns.getString("project"),
firstNonBlank(rowObj.getString("project"), rowObj.getString("segment")),
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());
@@ -812,6 +813,29 @@ public final class QuoteTemplateExcelSupport {
}
}
}
// 明细行数少于模板占位时,清掉后面非黄底样例行(避免 ZUH/文件费残留)
clearFeeRowsAfter(sheet, startRow + rows.size(), stopRow, columns, yellow, sheet.getSheetName());
}
}
/**
* 清空 [fromRow, stopRow) 费用列;黄底固定行保留(如「发生时收取」占位)。
*/
private static void clearFeeRowsAfter(Sheet sheet, int fromRow, int stopRow, JSONObject columns,
Set<String> yellow, String sheetName) {
if (sheet == null || columns == null || fromRow >= stopRow) {
return;
}
// 备注列常黄底「发生时收取」:不清 remark,只清费用数据列;黄格由 writeFeeCol 跳过
String[] keys = {"project", "name", "unit", "quantity", "unit_price", "amount", "currency", "container_rate"};
for (int r = fromRow; r < stopRow; r++) {
for (String key : keys) {
String colLetter = columns.getString(key);
if (StringUtils.isBlank(colLetter)) {
continue;
}
writeFeeCol(sheet, r, colLetter, "", yellow, sheetName);
}
}
}
@@ -1466,7 +1490,8 @@ public final class QuoteTemplateExcelSupport {
boolean needUnit = StringUtils.isBlank(out.getString("unit"));
boolean needPrice = StringUtils.isBlank(out.getString("unit_price"));
boolean needQty = StringUtils.isBlank(out.getString("quantity"));
if (!needUnit && !needPrice && !needQty) {
boolean needProject = StringUtils.isBlank(out.getString("project"));
if (!needUnit && !needPrice && !needQty && !needProject) {
return out;
}
Map<String, Integer> header = findFeeTableColumnsAbove(sheet, Math.max(dataStartRow, 0));
@@ -1485,9 +1510,157 @@ public final class QuoteTemplateExcelSupport {
if (needQty && header.get("quantity") != null) {
out.put("quantity", CellReference.convertNumToColString(header.get("quantity")));
}
if (needProject && header.get("project") != null) {
out.put("project", CellReference.convertNumToColString(header.get("project")));
}
if (StringUtils.isBlank(out.getString("name")) && header.get("name") != null) {
out.put("name", CellReference.convertNumToColString(header.get("name")));
}
return out;
}
/**
* 空运 airOptions[].departureCharges → 费用行(出单用)。
* 已落库 payload 常把 airOptions 存成 JSON 字符串。
*/
public static JSONArray parseAirDepartureChargeRows(Object airOptions, Object airOptionsAlt,
String chargeableWeightKg, String headerCurrency) {
JSONArray out = new JSONArray();
Object raw = airOptions != null ? airOptions : airOptionsAlt;
JSONArray opts = parseFeeRows(raw);
if (opts.isEmpty()) {
return out;
}
List<Map<String, String>> lines = new ArrayList<>();
for (int i = 0; i < opts.size(); i++) {
JSONObject opt = opts.getJSONObject(i);
if (opt == null) {
continue;
}
JSONArray charges = parseFeeRows(firstNonNull(opt.get("departureCharges"), opt.get("departure_charges"),
opt.get("charges"), opt.get("feeItems")));
String optCcy = firstNonBlank(opt.getString("currency"), headerCurrency);
for (int c = 0; c < charges.size(); c++) {
JSONObject ch = charges.getJSONObject(c);
if (ch == null) {
continue;
}
String name = firstNonBlank(ch.getString("chargeName"), ch.getString("feeName"), ch.getString("name"));
String unitPrice = firstNonBlank(ch.getString("unitPrice"), ch.getString("unit_price"),
ch.getString("salePrice"), ch.getString("amount"));
if (StringUtils.isBlank(name) || StringUtils.isBlank(unitPrice)) {
continue;
}
Map<String, String> line = new LinkedHashMap<>();
line.put("name", name);
line.put("amount", unitPrice);
line.put("unit_price", unitPrice);
// 空运模板「项目」列:运费归空运段,其它归起运港
line.put("project", isAirWeightFeeName(name) ? "空运段" : "起运港");
String unit = firstNonBlank(ch.getString("unit"), ch.getString("unitName"));
if (StringUtils.isNotBlank(unit)) {
line.put("unit", unit);
}
String ccy = firstNonBlank(ch.getString("currency"), optCcy);
if (StringUtils.isNotBlank(ccy)) {
line.put("currency", ccy);
}
lines.add(line);
}
if (!lines.isEmpty()) {
break;
}
}
enrichAirWeightFeeRows(lines, chargeableWeightKg);
for (Map<String, String> line : lines) {
out.add(JSON.parseObject(JSON.toJSONString(line)));
}
return out;
}
private static Object firstNonNull(Object... vals) {
if (vals == null) {
return null;
}
for (Object v : vals) {
if (v != null) {
return v;
}
}
return null;
}
/**
* 空运运输费:单价 × 计费重 = 行金额。
* 出单时调用;有单价+数量则重算金额(数值比较,避免 11.00≠11.0 漏算)。
*/
public static void enrichAirWeightFeeRows(List<Map<String, String>> lines, String chargeableWeightKg) {
if (lines == null || lines.isEmpty()) {
return;
}
String chargeable = StringUtils.trimToEmpty(chargeableWeightKg);
if (chargeable.isEmpty() || "-".equals(chargeable)) {
return;
}
for (Map<String, String> line : lines) {
if (line == null || !isAirWeightFeeName(line.get("name"))) {
continue;
}
String unitPrice = firstNonBlank(line.get("unit_price"), line.get("unitPrice"));
String qty = StringUtils.trimToEmpty(line.get("quantity"));
String amount = StringUtils.trimToEmpty(line.get("amount"));
String unit = StringUtils.trimToEmpty(line.get("unit"));
if (qty.isEmpty()) {
line.put("quantity", chargeable);
qty = chargeable;
}
// TMS 常给「板数」;按计费重计价统一 KG
line.put("unit", "KG");
if (unitPrice.isEmpty() && !amount.isEmpty()) {
line.put("unit_price", amount);
unitPrice = amount;
}
if (!unitPrice.isEmpty() && !qty.isEmpty()) {
String product = multiplyDecimal(unitPrice, qty);
if (!product.isEmpty() && (amount.isEmpty() || !sameDecimal(amount, product))) {
line.put("amount", product);
}
}
}
}
public static boolean isAirWeightFeeName(String name) {
String n = StringUtils.trimToEmpty(name);
if (n.isEmpty()) {
return false;
}
if (n.contains("提") || n.contains("送") || n.contains("人工") || n.contains("报关")
|| n.contains("操作") || n.contains("文件") || n.contains("杂费")) {
return false;
}
return n.contains("空运") || n.contains("运输费") || "运费".equals(n);
}
static boolean sameDecimal(String left, String right) {
try {
java.math.BigDecimal a = new java.math.BigDecimal(left.trim().replace(",", ""));
java.math.BigDecimal b = new java.math.BigDecimal(right.trim().replace(",", ""));
return a.compareTo(b) == 0;
} catch (Exception e) {
return StringUtils.equals(left, right);
}
}
static String multiplyDecimal(String left, String right) {
try {
java.math.BigDecimal a = new java.math.BigDecimal(left.trim().replace(",", ""));
java.math.BigDecimal b = new java.math.BigDecimal(right.trim().replace(",", ""));
return a.multiply(b).stripTrailingZeros().toPlainString();
} catch (Exception e) {
return "";
}
}
/**
* 金额/数量/单价写成数字,SUM 才能加算;名称等仍写文本。
*/
@@ -1821,7 +1994,15 @@ public final class QuoteTemplateExcelSupport {
}
for (int c = 0; c < last; c++) {
String text = StringUtils.trimToEmpty(readCellText(row.getCell(c)));
if (isFeeNameHeader(text)) {
String primary = text;
int slash = text.indexOf('/');
if (slash > 0) {
primary = text.substring(0, slash).trim();
}
// 空运表头「项目 | 费用名称」两列:项目单独记,勿盖掉费用名称
if ("项目".equals(primary) || "项目".equals(text) || text.startsWith("项目/")) {
cols.put("project", c);
} else if (isFeeNameHeader(text)) {
cols.put("name", c);
} else if (isContainerTypeHeader(text)) {
cols.put(normalizeContainerType(text), c);
@@ -2351,24 +2532,29 @@ public final class QuoteTemplateExcelSupport {
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) {
boolean feeHeader = !parseFeeHeaderRow(sheet, r).isEmpty();
// 费用明细数据行:「起运港/目的港」是项目列值,不是询价标签;
// 否则会把 pol=ZUH 写进右侧「费用名称」,盖掉提送货费/人工费。
if (!feeHeader && isFeeDetailDataRow(sheet, r)) {
continue;
}
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;
@@ -2404,6 +2590,38 @@ public final class QuoteTemplateExcelSupport {
return text != null && (text.contains(":") || text.contains(":"));
}
/**
* 费用明细表数据行(表头「费用名称/金额」之下、汇总行之上)。
* 这些行的「起运港」是项目分段,不是询价单「起运港」标签。
*/
static boolean isFeeDetailDataRow(Sheet sheet, int rowIdx) {
if (sheet == null || rowIdx < 0) {
return false;
}
if (!parseFeeHeaderRow(sheet, rowIdx).isEmpty()) {
return false;
}
Row row = sheet.getRow(rowIdx);
if (row != null) {
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("总销售价")) {
return false;
}
}
}
for (int r = rowIdx - 1; r >= Math.max(0, rowIdx - 40); r--) {
if (!parseFeeHeaderRow(sheet, r).isEmpty()) {
return true;
}
}
return false;
}
private static String matchLabel(String normalized, Map<String, String> dict) {
if (dict == null || dict.isEmpty() || StringUtils.isBlank(normalized)) {
return null;
@@ -527,6 +527,12 @@ public class InquiryTmsQueryService {
}
}
}
// 空运:明细在 airOptions[].departureCharges(chargeName/unitPrice),不是顶层 feeItems
List<Map<String, String>> airCharges = extractAirDepartureCharges(data, headerCurrency);
if (!airCharges.isEmpty() && (lines.isEmpty() || lines.size() < airCharges.size())) {
lines.clear();
lines.addAll(airCharges);
}
if (lines.isEmpty()) {
addFeeLine(lines, "空运费", firstText(data.get("airFreight"), data.get("freight"), freight), headerCurrency, null);
addFeeLine(lines, "操作费", firstText(data.get("operateFee"), data.get("operationFee"), data.get("handlingFee")), headerCurrency, null);
@@ -541,6 +547,64 @@ public class InquiryTmsQueryService {
return JSON.toJSONString(lines);
}
/**
* 空运舱位选项上的 departureCharges → 扁平费用行。
* TMS 字段:chargeName / unitPrice / unit / currency;行金额出单时再按计费重补。
*/
@SuppressWarnings("unchecked")
static List<Map<String, String>> extractAirDepartureCharges(Map<String, Object> data, String headerCurrency) {
List<Map<String, String>> out = new ArrayList<>();
if (data == null) {
return out;
}
Object raw = first(data.get("airOptions"), data.get("air_options"));
if (raw instanceof String) {
try {
raw = JSON.parse(String.valueOf(raw));
} catch (Exception e) {
return out;
}
}
if (!(raw instanceof List)) {
return out;
}
for (Object optObj : (List<?>) raw) {
if (!(optObj instanceof Map)) {
continue;
}
Map<?, ?> opt = (Map<?, ?>) optObj;
Object charges = first(opt.get("departureCharges"), opt.get("departure_charges"),
opt.get("charges"), opt.get("feeItems"));
if (!(charges instanceof List)) {
continue;
}
String optCcy = firstText(opt.get("currency"), headerCurrency);
for (Object one : (List<?>) charges) {
if (!(one instanceof Map)) {
continue;
}
Map<?, ?> ch = (Map<?, ?>) one;
String name = firstText(ch.get("chargeName"), ch.get("feeName"), ch.get("name"));
String unitPrice = firstText(ch.get("unitPrice"), ch.get("unit_price"), ch.get("salePrice"), ch.get("amount"));
if (StringUtils.isBlank(name) || StringUtils.isBlank(unitPrice)) {
continue;
}
String lineCcy = firstText(ch.get("currency"), optCcy, headerCurrency);
Map<String, String> extras = new LinkedHashMap<>();
putExtra(extras, "unit", firstText(ch.get("unit"), ch.get("unitName")));
putExtra(extras, "unit_price", unitPrice);
putExtra(extras, "project", isAirWeightFeeName(name) ? "空运段" : "起运港");
// 先把单价写入 amount;运输费会在 enrich 里改成 单价×计费重
addFeeLine(out, name, unitPrice, lineCcy, extras);
}
// 通常只用首个舱位选项的费用明细
if (!out.isEmpty()) {
break;
}
}
return out;
}
/**
* 空运运输费:单价 × 计费重 = 行金额。
* <p>
@@ -568,25 +632,35 @@ public class InquiryTmsQueryService {
line.put("quantity", chargeable);
qty = chargeable;
}
if (StringUtils.isBlank(unit)) {
line.put("unit", "KG");
}
// TMS 常给单位「板数」;按计费重计价时统一写成 KG
line.put("unit", "KG");
// 有数量、无单价:当前 amount 当作单价(TMS 把费率塞进 salePrice/amount)
if (StringUtils.isBlank(unitPrice) && StringUtils.isNotBlank(amount)) {
line.put("unit_price", amount);
unitPrice = amount;
}
// 单价与金额相同(或金额空):按 单价×数量 重算行金额
if (StringUtils.isNotBlank(unitPrice) && StringUtils.isNotBlank(qty)
&& (StringUtils.isBlank(amount) || amount.equals(unitPrice))) {
// 有单价+数量:行金额必须是 单价×数量(勿用字符串 equals:11.00 ≠ 11.0 会漏算)
if (StringUtils.isNotBlank(unitPrice) && StringUtils.isNotBlank(qty)) {
String product = multiplyDecimal(unitPrice, qty);
if (StringUtils.isNotBlank(product)) {
if (StringUtils.isNotBlank(product)
&& (StringUtils.isBlank(amount) || !sameDecimal(amount, product))) {
line.put("amount", product);
}
}
}
}
/** 数值相等(忽略尾零与小数位写法差异)。 */
static boolean sameDecimal(String left, String right) {
try {
java.math.BigDecimal a = new java.math.BigDecimal(left.trim().replace(",", ""));
java.math.BigDecimal b = new java.math.BigDecimal(right.trim().replace(",", ""));
return a.compareTo(b) == 0;
} catch (Exception e) {
return StringUtils.equals(left, right);
}
}
/** 空运按计费重的费目:运输费 / 空运费;不含提送货、人工、报关等固定费。 */
public static boolean isAirWeightFeeName(String name) {
String n = StringUtils.trimToEmpty(name);
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -260,4 +261,74 @@ public class TmsQuoteCurrencyTest {
assertTrue(quote.get("fee_lines").contains("\"amount\":\"6.0\""));
assertTrue(quote.get("fee_lines").contains("\"amount\":\"123.0\""));
}
@Test
public void toAgentQuoteUsesAirDepartureCharges() {
Map<String, Object> c1 = new HashMap<>();
c1.put("chargeName", "运输费");
c1.put("unitPrice", 11.0);
c1.put("unit", "板数");
c1.put("currency", "CNY");
Map<String, Object> c2 = new HashMap<>();
c2.put("chargeName", "提送货费");
c2.put("unitPrice", 6.0);
c2.put("unit", "板数");
c2.put("currency", "CNY");
Map<String, Object> c3 = new HashMap<>();
c3.put("chargeName", "人工费");
c3.put("unitPrice", 123.0);
c3.put("unit", "柜");
c3.put("currency", "CNY");
Map<String, Object> option = new HashMap<>();
option.put("optionNo", "AIR-OPT01");
option.put("salePrice", 183370.0);
option.put("currency", "CNY");
option.put("chargeableWeightKg", 16670.0);
option.put("departureCharges", java.util.Arrays.asList(c1, c2, c3));
Map<String, Object> data = new HashMap<>();
data.put("quoteFound", true);
data.put("totalSalePrice", "183370.0");
data.put("currency", "CNY");
data.put("chargeableWeightKg", "16670.0000");
data.put("feeItems", Collections.emptyList());
data.put("airOptions", Collections.singletonList(option));
Map<String, Object> tms = new HashMap<>();
tms.put("code", 1);
tms.put("tmsData", data);
Map<String, String> quote = InquiryTmsQueryService.toAgentQuote(tms);
String fees = quote.get("fee_lines");
assertTrue(fees.contains("运输费"));
assertTrue(fees.contains("提送货费"));
assertTrue(fees.contains("人工费"));
assertTrue(fees.contains("\"amount\":\"183370\""));
assertTrue(fees.contains("\"amount\":\"6.0\"") || fees.contains("\"amount\":\"6\""));
assertTrue(fees.contains("\"amount\":\"123.0\"") || fees.contains("\"amount\":\"123\""));
assertTrue(fees.contains("\"unit\":\"KG\""));
assertTrue(fees.contains("\"quantity\":\"16670.0000\""));
}
@Test
public void enrichAirWeightRecalculatesWhenAmountLooksLikeRateWithDecimals() {
// 单价已是 11.00、数量已是计费重,但金额仍是 11.00(字符串不等于 11.0 也要重算)
Map<String, String> freight = new LinkedHashMap<>();
freight.put("name", "运输费");
freight.put("unit", "KG");
freight.put("unit_price", "11.00");
freight.put("quantity", "16670.00");
freight.put("amount", "11.00");
freight.put("currency", "CNY");
Map<String, String> labor = new LinkedHashMap<>();
labor.put("name", "人工费");
labor.put("amount", "123.00");
labor.put("currency", "CNY");
List<Map<String, String>> lines = new ArrayList<>();
lines.add(freight);
lines.add(labor);
Map<String, Object> data = new HashMap<>();
data.put("chargeableWeightKg", "16670.0000");
InquiryTmsQueryService.enrichAirWeightFeeLines(lines, data);
assertEquals("183370", lines.get(0).get("amount"));
assertEquals("11.00", lines.get(0).get("unit_price"));
assertEquals("123.00", lines.get(1).get("amount"));
}
}