Merge remote-tracking branch 'origin/lmx20260922' into feat/air-inquiry-fields-route-20260922

This commit is contained in:
2026-09-22 15:04:44 +08:00
5 changed files with 198 additions and 7 deletions
@@ -21,6 +21,7 @@ import org.jeecg.modules.inquiry.template.QuoteTemplateExcelSupport;
import org.jeecg.modules.inquiry.template.QuoteTemplateMatcher; import org.jeecg.modules.inquiry.template.QuoteTemplateMatcher;
import org.jeecg.modules.inquiry.template.QuoteTemplateObjectStore; import org.jeecg.modules.inquiry.template.QuoteTemplateObjectStore;
import org.jeecg.modules.inquiry.template.QuoteTemplateSnapshotRenderer; 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.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -885,6 +886,32 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
} }
} }
} }
// 空运:已落库的旧明细若只有费率,出单时按计费重补 单位/单价/数量/行金额
if (!rows.isEmpty() && StringUtils.isNotBlank(chargeable)) {
List<Map<String, String>> lineMaps = new ArrayList<>();
for (int i = 0; i < rows.size(); i++) {
JSONObject one = rows.getJSONObject(i);
if (one == null) {
continue;
}
Map<String, String> m = new LinkedHashMap<>();
for (String k : one.keySet()) {
Object v = one.get(k);
if (v != null) {
m.put(k, String.valueOf(v));
}
}
lineMaps.add(m);
}
Map<String, Object> enrichCtx = new LinkedHashMap<>();
enrichCtx.put("chargeableWeightKg", chargeable);
InquiryTmsQueryService.enrichAirWeightFeeLines(lineMaps, enrichCtx);
JSONArray enriched = new JSONArray();
for (Map<String, String> m : lineMaps) {
enriched.add(new JSONObject(m));
}
rows = enriched;
}
for (int i = 0; i < rows.size(); i++) { for (int i = 0; i < rows.size(); i++) {
JSONObject one = rows.getJSONObject(i); JSONObject one = rows.getJSONObject(i);
if (one == null) { if (one == null) {
@@ -51,6 +51,9 @@ public final class QuoteTemplateExcelSupport {
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("报价合计", "total_amount"); FILL_BY_LABEL.put("报价合计", "total_amount");
FILL_BY_LABEL.put("预估费用", "total_amount");
FILL_BY_LABEL.put("预估总价", "total_amount");
FILL_BY_LABEL.put("总销售价", "total_amount");
FILL_BY_LABEL.put("RFQ No", "quote_no"); FILL_BY_LABEL.put("RFQ No", "quote_no");
FILL_BY_LABEL.put("报价单编号", "quote_no"); FILL_BY_LABEL.put("报价单编号", "quote_no");
FILL_BY_LABEL.put("报价编号", "quote_no"); FILL_BY_LABEL.put("报价编号", "quote_no");
@@ -592,6 +595,9 @@ public final class QuoteTemplateExcelSupport {
// 模板「预估总价」是 SUM。POI 写完数字不会重算,缓存仍是 0。 // 模板「预估总价」是 SUM。POI 写完数字不会重算,缓存仍是 0。
// 企微/WPS 预览常不重算,销售会看到汇总为 0。这里把公式缓存刷成当前值。 // 企微/WPS 预览常不重算,销售会看到汇总为 0。这里把公式缓存刷成当前值。
refreshFormulas(wb); refreshFormulas(wb);
// 刷公式后:预估费用若 SUM 了「发生时收取」固定费,会与 TMS 总销售价不一致。
// 有 total_amount 时再按标签写回,以总销售价为准。
writeFactsByLabel(wb, facts, yellow);
// 转 PDF 前把纸张改成 A4 居中,避免顶部裁字、整页偏左。 // 转 PDF 前把纸张改成 A4 居中,避免顶部裁字、整页偏左。
applyQuotePrintLayout(wb); applyQuotePrintLayout(wb);
Files.createDirectories(targetXlsx.getParent()); Files.createDirectories(targetXlsx.getParent());
@@ -740,6 +746,8 @@ public final class QuoteTemplateExcelSupport {
sheet = wb.getSheetAt(0); sheet = wb.getSheetAt(0);
} }
int startRow = resolveFeeDataStartRow(sheet, region, anchorRef.row); int startRow = resolveFeeDataStartRow(sheet, region, anchorRef.row);
// 映射缺单位/单价/数量列时,按表头补齐,否则明细有值也写不进 Excel
columns = mergeFeeColumnsFromHeader(sheet, startRow, columns);
// 数据起点整行已标黄:本段是模板固定占位(如陆运「项目二」),整段跳过 // 数据起点整行已标黄:本段是模板固定占位(如陆运「项目二」),整段跳过
if (rowLooksYellowFixed(sheet, startRow)) { if (rowLooksYellowFixed(sheet, startRow)) {
log.info("费用区起点为黄底固定区,跳过赋值 region={} startRow={}", region.getString("id"), startRow); log.info("费用区起点为黄底固定区,跳过赋值 region={} startRow={}", region.getString("id"), startRow);
@@ -1446,6 +1454,40 @@ public final class QuoteTemplateExcelSupport {
applyRemarkWrap(sheet, row, cell, value); applyRemarkWrap(sheet, row, cell, value);
} }
/**
* AI mapping 常只标 name/amount/currency;空运表头有「单位/单价/数量」时现场补列字母。
* 不覆盖 mapping 已有列,避免改错已对齐的金额列。
*/
static JSONObject mergeFeeColumnsFromHeader(Sheet sheet, int dataStartRow, JSONObject mapped) {
JSONObject out = mapped == null ? new JSONObject(true) : new JSONObject(mapped);
if (sheet == null) {
return out;
}
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) {
return out;
}
Map<String, Integer> header = findFeeTableColumnsAbove(sheet, Math.max(dataStartRow, 0));
if (header == null || header.isEmpty()) {
header = findFeeTableColumns(sheet);
}
if (header == null || header.isEmpty()) {
return out;
}
if (needUnit && header.get("unit") != null) {
out.put("unit", CellReference.convertNumToColString(header.get("unit")));
}
if (needPrice && header.get("unit_price") != null) {
out.put("unit_price", CellReference.convertNumToColString(header.get("unit_price")));
}
if (needQty && header.get("quantity") != null) {
out.put("quantity", CellReference.convertNumToColString(header.get("quantity")));
}
return out;
}
/** /**
* 金额/数量/单价写成数字,SUM 才能加算;名称等仍写文本。 * 金额/数量/单价写成数字,SUM 才能加算;名称等仍写文本。
*/ */
@@ -536,9 +536,81 @@ public class InquiryTmsQueryService {
addFeeLine(lines, "目的地杂费", firstText(data.get("destFee"), data.get("destinationFee")), headerCurrency, null); addFeeLine(lines, "目的地杂费", firstText(data.get("destFee"), data.get("destinationFee")), headerCurrency, null);
addFeeLine(lines, "其他附加费", firstText(other, data.get("otherFee"), data.get("sundryFee")), headerCurrency, null); addFeeLine(lines, "其他附加费", firstText(other, data.get("otherFee"), data.get("sundryFee")), headerCurrency, null);
} }
// 空运按计费重计价:TMS 常把单价写在 salePrice,单位/数量/单价缺省;补齐后再算行金额
enrichAirWeightFeeLines(lines, data);
return JSON.toJSONString(lines); return JSON.toJSONString(lines);
} }
/**
* 空运运输费:单价 × 计费重 = 行金额。
* <p>
* 现象:feeItems 只给 salePrice=11(实为单价),Excel 金额列写成 11,单位/单价/数量空,
* 预估费用 SUM 成 140,而总销售价是 11×计费重。
* 只处理按重计的运输/空运费,提送货费、人工费等固定费不动。
*/
public static void enrichAirWeightFeeLines(List<Map<String, String>> lines, Map<String, Object> data) {
if (lines == null || lines.isEmpty() || data == null) {
return;
}
String chargeable = firstText(data.get("chargeableWeightKg"), data.get("chargeable_weight_kg"));
if (StringUtils.isBlank(chargeable) || "-".equals(chargeable)) {
return;
}
for (Map<String, String> line : lines) {
if (line == null || !isAirWeightFeeName(line.get("name"))) {
continue;
}
String unitPrice = firstText(line.get("unit_price"), line.get("unitPrice"));
String qty = firstText(line.get("quantity"));
String amount = firstText(line.get("amount"));
String unit = firstText(line.get("unit"));
if (StringUtils.isBlank(qty)) {
line.put("quantity", chargeable);
qty = chargeable;
}
if (StringUtils.isBlank(unit)) {
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))) {
String product = multiplyDecimal(unitPrice, qty);
if (StringUtils.isNotBlank(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 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(",", ""));
java.math.BigDecimal product = a.multiply(b);
return product.stripTrailingZeros().toPlainString();
} catch (Exception e) {
return "";
}
}
/** /**
* 海运三段费用桶单独落 quote,供模板 feeRegions 按 tmsSource 分区写,禁止三段共用扁平 fee_lines。 * 海运三段费用桶单独落 quote,供模板 feeRegions 按 tmsSource 分区写,禁止三段共用扁平 fee_lines。
*/ */
@@ -589,8 +661,18 @@ public class InquiryTmsQueryService {
private static void appendFeeLineFromRow(List<Map<String, String>> lines, Map<?, ?> row, String headerCurrency) { private static void appendFeeLineFromRow(List<Map<String, String>> lines, Map<?, ?> row, String headerCurrency) {
String name = firstText(row.get("feeName"), row.get("name"), row.get("chargeName"), String name = firstText(row.get("feeName"), row.get("name"), row.get("chargeName"),
row.get("itemName"), row.get("feeType")); row.get("itemName"), row.get("feeType"));
String amount = firstText(row.get("salePrice"), row.get("amount"), row.get("price"), String unitPrice = firstText(row.get("unitPrice"), row.get("unit_price"));
row.get("feeAmount"), row.get("totalPrice")); // 行金额优先认 amount/totalPrice;salePrice 与单价相同时当作费率,不当行金额
String amount = firstText(row.get("amount"), row.get("totalPrice"), row.get("feeAmount"),
row.get("price"));
String sale = firstText(row.get("salePrice"));
if (StringUtils.isBlank(amount)) {
if (StringUtils.isNotBlank(sale) && StringUtils.isNotBlank(unitPrice) && sale.equals(unitPrice)) {
amount = sale;
} else if (StringUtils.isNotBlank(sale)) {
amount = sale;
}
}
String lineCurrency = TmsQuoteCurrency.fromFeeRow(row); String lineCurrency = TmsQuoteCurrency.fromFeeRow(row);
addFeeLine(lines, name, amount, addFeeLine(lines, name, amount,
StringUtils.isNotBlank(lineCurrency) ? lineCurrency : headerCurrency, StringUtils.isNotBlank(lineCurrency) ? lineCurrency : headerCurrency,
@@ -25,10 +25,10 @@ DELETE FROM `inq_response_timeout`
'rt100000000000000000000000000019' 'rt100000000000000000000000000019'
); );
-- 2) 保留的 4 项:分类与线路解耦,route_category 置空;文案对齐产品确认 -- 2) 保留的 4 项:用 UNHEX 写入 UTF-8,避免客户端字符集把中文写成问号
UPDATE `inq_response_timeout` UPDATE `inq_response_timeout`
SET `route_category`='', SET `route_category`='',
`transport_type`='普件(东南亚、中港常用线路/常规车型)', `transport_type`=CONVERT(UNHEX('e699aee4bbb628e4b89ce58d97e4ba9ae38081e4b8ade6b8afe5b8b8e794a8e7babfe8b7af2fe5b8b8e8a784e8bda6e59e8b29') USING utf8mb4),
`update_by`='system', `update_by`='system',
`update_time`=NOW() `update_time`=NOW()
WHERE `id`='rt100000000000000000000000000007' WHERE `id`='rt100000000000000000000000000007'
@@ -36,7 +36,7 @@ UPDATE `inq_response_timeout`
UPDATE `inq_response_timeout` UPDATE `inq_response_timeout`
SET `route_category`='', SET `route_category`='',
`transport_type`='普件(东南亚、中亚、中欧涉及国内+国外段)', `transport_type`=CONVERT(UNHEX('e699aee4bbb628e4b89ce58d97e4ba9ae38081e4b8ade4ba9ae38081e4b8ade6aca7e6b689e58f8ae59bbde586852be59bbde5a496e6aeb529') USING utf8mb4),
`update_by`='system', `update_by`='system',
`update_time`=NOW() `update_time`=NOW()
WHERE `id`='rt100000000000000000000000000008' WHERE `id`='rt100000000000000000000000000008'
@@ -44,7 +44,7 @@ UPDATE `inq_response_timeout`
UPDATE `inq_response_timeout` UPDATE `inq_response_timeout`
SET `route_category`='', SET `route_category`='',
`transport_type`='普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)', `transport_type`=CONVERT(UNHEX('e699aee4bbb628e4b89ce58d97e4ba9ae38081e4b8ade4ba9ae38081e4b8ade6aca7e6b689e58f8ae5a283e5a496e6aeb5e6b885e6b4bee7ad89e789b9e6ae8ae8a681e6b18229') USING utf8mb4),
`update_by`='system', `update_by`='system',
`update_time`=NOW() `update_time`=NOW()
WHERE `id`='rt100000000000000000000000000009' WHERE `id`='rt100000000000000000000000000009'
@@ -52,7 +52,7 @@ UPDATE `inq_response_timeout`
UPDATE `inq_response_timeout` UPDATE `inq_response_timeout`
SET `route_category`='', SET `route_category`='',
`transport_type`='急件', `transport_type`=CONVERT(UNHEX('e680a5e4bbb6') USING utf8mb4),
`update_by`='system', `update_by`='system',
`update_time`=NOW() `update_time`=NOW()
WHERE `id`='rt100000000000000000000000000010' WHERE `id`='rt100000000000000000000000000010'
@@ -220,4 +220,44 @@ public class TmsQuoteCurrencyTest {
assertEquals("470", quote.get("fee_total_podCostItems")); assertEquals("470", quote.get("fee_total_podCostItems"));
assertTrue(quote.get("fee_rows_oceanCostItems").contains("575")); assertTrue(quote.get("fee_rows_oceanCostItems").contains("575"));
} }
@Test
public void toAgentQuoteAirRateTimesChargeableWeight() {
// TMS 把运费单价塞进 salePrice,数量/单价空;应用计费重算出行金额
Map<String, Object> freight = new HashMap<>();
freight.put("feeName", "运输费");
freight.put("salePrice", "11.0");
freight.put("currency", "CNY");
Map<String, Object> pickup = new HashMap<>();
pickup.put("feeName", "提送货费");
pickup.put("salePrice", "6.0");
pickup.put("currency", "CNY");
pickup.put("remark", "发生时收取");
Map<String, Object> labor = new HashMap<>();
labor.put("feeName", "人工费");
labor.put("salePrice", "123.0");
labor.put("currency", "CNY");
labor.put("remark", "发生时收取");
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", java.util.Arrays.asList(freight, pickup, labor));
Map<String, Object> tms = new HashMap<>();
tms.put("code", 1);
tms.put("tmsData", data);
Map<String, String> quote = InquiryTmsQueryService.toAgentQuote(tms);
assertEquals("183370.0", quote.get("total"));
assertEquals("16670.0000", quote.get("chargeableWeightKg"));
assertTrue(quote.get("fee_lines").contains("\"name\":\"运输费\""));
assertTrue(quote.get("fee_lines").contains("\"unit_price\":\"11.0\""));
assertTrue(quote.get("fee_lines").contains("\"quantity\":\"16670.0000\""));
assertTrue(quote.get("fee_lines").contains("\"unit\":\"KG\""));
assertTrue(quote.get("fee_lines").contains("\"amount\":\"183370\""));
// 固定费保持原金额,不乘计费重
assertTrue(quote.get("fee_lines").contains("\"name\":\"提送货费\""));
assertTrue(quote.get("fee_lines").contains("\"amount\":\"6.0\""));
assertTrue(quote.get("fee_lines").contains("\"amount\":\"123.0\""));
}
} }