海运补问保留贸易条款;陆运多车型价表按列回填,车型码不进起始地

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-29 10:53:30 +08:00
co-authored by Cursor
parent 56ba8e93b5
commit eca0a7ad93
10 changed files with 809 additions and 18 deletions
@@ -1374,9 +1374,10 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
JSONArray routeRows = QuoteTemplateExcelSupport.parseFeeRows(q.get("route_rows"));
JSONArray rawLand = QuoteTemplateExcelSupport.parseFeeRows(q.get("landSegments"));
JSONArray landSegs = routeRows.isEmpty() ? rawLand : routeRows;
// 已落库的 route_rows 是规范化扁平行,分档公斤价还在原始 landSegments 上。
// 已落库的 route_rows 是规范化扁平行,分档公斤价 / 多车型价表还在原始 landSegments 上。
if (!routeRows.isEmpty()) {
QuoteTemplateExcelSupport.copyMissingWeightPrices(landSegs, rawLand);
QuoteTemplateExcelSupport.copyMissingVehicleRates(landSegs, rawLand);
}
// 销售已选线路透传:跨境整车国内/境外段按 legs 写完整起讫(多备选混在 route_rows 时不能靠同序列过滤)
Object selectedLand = q.get("selected_land_route");
@@ -1389,6 +1390,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
if (!landSegs.isEmpty()) {
// 产品文字价/分档壳偶发无 vehicleType:用询价「车型/数量」补上,否则整车矩阵车型列空、金额只进合计。
enrichLandRouteVehicleFromInquiry(landSegs, out, src);
// 产品多车型挂段时 OD 偶发空:用询价 facts 顶层 origin/destination 回填(境外段目的地不能空)。
fillBlankLandRouteOdFromInquiry(landSegs, out, src);
out.put("landSegments", landSegs);
out.put("route_rows", landSegs);
// 首段时效/燃油/税率抬到顶层,供侧栏与标签回填(WO006 燃油列不能空着有数却写不上)
@@ -1522,6 +1525,41 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
return "";
}
/**
* 线路行缺起运/目的时,用询价顶层 OD / 提货收货地址回填。
* <p>
* 为何:产品多车型价挂段时智能体偶发只带上提货地址、目的港键名对不上,境外段目的地空着。
* 副作用:只改传入行;不写库。
*/
private static void fillBlankLandRouteOdFromInquiry(JSONArray landSegs, JSONObject out,
Map<String, Object> src) {
if (landSegs == null || landSegs.isEmpty()) {
return;
}
String origin = firstNonBlankLocal(
out == null ? null : out.getString("origin"),
first(src, "起运港", "起运地", "始发站", "始发地", "提货地址", "origin", "pol"));
String dest = firstNonBlankLocal(
out == null ? null : out.getString("destination"),
out == null ? null : out.getString("pod"),
first(src, "目的港", "目的地", "收货地址", "送货地址", "destination", "pod"));
if (StringUtils.isBlank(origin) && StringUtils.isBlank(dest)) {
return;
}
for (int i = 0; i < landSegs.size(); i++) {
JSONObject seg = landSegs.getJSONObject(i);
if (seg == null) {
continue;
}
if (StringUtils.isBlank(seg.getString("origin")) && StringUtils.isNotBlank(origin)) {
seg.put("origin", origin);
}
if (StringUtils.isBlank(seg.getString("destination")) && StringUtils.isNotBlank(dest)) {
seg.put("destination", dest);
}
}
}
/**
* 线路行缺 vehicleType 时,从询价「车型/数量」拆出车型补上。
* <p>
@@ -4209,6 +4209,11 @@ public final class QuoteTemplateExcelSupport {
rowObj == null ? null : rowObj.getString("land_freight"),
facts == null ? null : facts.getString("land_freight"));
}
// 1.5) 产品多车型价表:一行挂多个车型价,按表头键对列(5T/8T 同价拆键后分别命中)
String fromMap = matchVehicleRateFromMap(rowObj, key, sectionKind);
if (StringUtils.isNotBlank(fromMap) && !"-".equals(fromMap)) {
return fromMap;
}
// 2) 车型列:仅当 vehicleType 对得上才写售价(含跨境国内/国外标注换算)
String vehicleType = firstNonBlank(rowObj == null ? null : rowObj.getString("vehicleType"),
rowObj == null ? null : rowObj.getString("vehicle_type"));
@@ -4227,6 +4232,67 @@ public final class QuoteTemplateExcelSupport {
return "";
}
/**
* 产品文字多车型价:landSegments.vehicleRates(或 vehicle_rates)按表头车型匹配金额。
* <p>
* 为何:群报「5T/8T:13000 10T:14000」会拆成多键同价;不能只用单行 vehicleType+salePrice,
* 否则只能填一列。对不上的列空着,禁止把第一档价灌进所有车型列。
*/
static String matchVehicleRateFromMap(JSONObject rowObj, String headerKey, String sectionKind) {
JSONObject rates = vehicleRatesOf(rowObj);
if (rates == null || rates.isEmpty() || StringUtils.isBlank(headerKey)) {
return "";
}
for (String vk : rates.keySet()) {
if (StringUtils.isBlank(vk)) {
continue;
}
if (!vehicleTypeMatchesSection(vk, headerKey, sectionKind)) {
continue;
}
String amount = StringUtils.trimToEmpty(rates.getString(vk));
if (StringUtils.isNotBlank(amount) && !"-".equals(amount)) {
return formatTierPrice(amount);
}
}
return "";
}
/**
* 读取行上多车型价表;兼容 camel / snake。值为对象 {5T:13000,...}。
*/
static JSONObject vehicleRatesOf(JSONObject row) {
if (row == null) {
return null;
}
Object raw = row.get("vehicleRates");
if (raw == null) {
raw = row.get("vehicle_rates");
}
if (raw instanceof JSONObject) {
return (JSONObject) raw;
}
if (raw instanceof String) {
String text = StringUtils.trimToEmpty((String) raw);
if (text.isEmpty()) {
return null;
}
try {
Object parsed = JSON.parse(text);
if (parsed instanceof JSONObject) {
return (JSONObject) parsed;
}
} catch (Exception ignore) {
return null;
}
}
if (raw instanceof Map) {
Object converted = JSON.toJSON(raw);
return converted instanceof JSONObject ? (JSONObject) converted : null;
}
return null;
}
/**
* 车型列是否对得上当前段表头。
* <p>
@@ -4673,6 +4739,64 @@ public final class QuoteTemplateExcelSupport {
}
}
/**
* 规范化 route_rows 会丢掉 vehicleRates。出单前从原始 landSegments 补回。
*/
public static void copyMissingVehicleRates(JSONArray rows, JSONArray rawLand) {
if (rows == null || rawLand == null || rawLand.isEmpty()) {
return;
}
for (int i = 0; i < rows.size(); i++) {
JSONObject row = rows.getJSONObject(i);
if (row == null) {
continue;
}
JSONObject existing = vehicleRatesOf(row);
if (existing != null && !existing.isEmpty()) {
continue;
}
JSONObject src = i < rawLand.size() ? rawLand.getJSONObject(i) : null;
if (vehicleRatesOf(src) == null) {
src = findLandSegmentByVehicleRates(rawLand, row);
}
JSONObject rates = vehicleRatesOf(src);
if (rates != null && !rates.isEmpty()) {
row.put("vehicleRates", rates);
row.put("vehicle_rates", rates);
}
}
}
private static JSONObject findLandSegmentByVehicleRates(JSONArray rawLand, JSONObject row) {
if (rawLand == null) {
return null;
}
String origin = StringUtils.trimToEmpty(firstNonBlank(
row == null ? null : row.getString("origin"),
row == null ? null : row.getString("originCode")));
String dest = StringUtils.trimToEmpty(firstNonBlank(
row == null ? null : row.getString("destination"),
row == null ? null : row.getString("destinationCode")));
for (int i = 0; i < rawLand.size(); i++) {
JSONObject one = rawLand.getJSONObject(i);
if (one == null || vehicleRatesOf(one) == null) {
continue;
}
String o = StringUtils.trimToEmpty(firstNonBlank(one.getString("origin"), one.getString("originCode")));
String d = StringUtils.trimToEmpty(firstNonBlank(one.getString("destination"), one.getString("destinationCode")));
if (origin.equals(o) && dest.equals(d)) {
return one;
}
}
for (int i = 0; i < rawLand.size(); i++) {
JSONObject one = rawLand.getJSONObject(i);
if (vehicleRatesOf(one) != null) {
return one;
}
}
return null;
}
private static JSONObject findLandSegmentByOriginDest(JSONArray rawLand, JSONObject row) {
if (rawLand == null || row == null) {
return null;
@@ -5250,12 +5374,16 @@ public final class QuoteTemplateExcelSupport {
/**
* 费用名,不是城市。写进「起始地/目的地」会把深圳盖成运输费。
* 只拦常见运价费目,不拦「春疆货场」这类地名。
* 车型码(5T/40HQ)也不是地名:产品多车型 fee_rows 误写进起始地列时要拦。
*/
static boolean looksLikeFeeNameNotPlace(String value) {
if (StringUtils.isBlank(value)) {
return false;
}
String t = value.trim().replaceAll("\\s+", "");
if (looksLikeVehicleTypeToken(t)) {
return true;
}
if ("运输费".equals(t) || "提货费".equals(t) || "送货费".equals(t) || "派送费".equals(t)
|| "陆运费".equals(t) || "干线费".equals(t) || "空运费".equals(t) || "海运费".equals(t)
|| "运费".equals(t) || "燃油附加费".equals(t) || "操作费".equals(t) || "文件费".equals(t)
@@ -5267,6 +5395,33 @@ public final class QuoteTemplateExcelSupport {
&& !t.contains("站") && !t.contains("仓");
}
/**
* 车型口语/表头码:5T、10T、40HQ、45HQ、3.5T、53尺 等。
* 产品多车型价表键与矩阵列头同形;禁止当费目名找格、当城市写进起始地。
*/
static boolean looksLikeVehicleTypeToken(String value) {
if (StringUtils.isBlank(value)) {
return false;
}
String t = normalizeVehicleCompareToken(value);
if (t.isEmpty()) {
return false;
}
if (t.matches("^\\d+(\\.\\d+)?T$")) {
return true;
}
if (t.matches("^\\d+(HQ|GP|RH)$")) {
return true;
}
if (t.matches("^\\d+尺$")) {
return true;
}
if (t.contains("平板") || t.contains("厢车") || t.contains("冷柜")) {
return t.length() <= 12;
}
return false;
}
/**
* 这些列头不是币种:写 CNY 会把覆盖区域的城市、或燃油附加费率的空格盖掉。
* 表头是「币种 / 币别」,或上面没有列头时,仍允许写币种。
@@ -7530,6 +7685,10 @@ public final class QuoteTemplateExcelSupport {
if (StringUtils.isBlank(name) || StringUtils.isBlank(amount) || "-".equals(amount)) {
continue;
}
// 多车型价键(5T/40HQ)禁止当费目名:会命中矩阵列头,把车型写进国内段起始地。
if (looksLikeVehicleTypeToken(name)) {
continue;
}
Cell nameCell = findCellByFeeName(sheet, name, usedRows);
if (nameCell == null) {
nameCell = findCellByLandFeeAlias(sheet, name, usedRows);
@@ -7954,6 +8113,11 @@ public final class QuoteTemplateExcelSupport {
if (StringUtils.isBlank(text) || isFeeTableHeader(text) || isSectionTitle(sheet, cell)) {
continue;
}
// 跨境整车矩阵列头「10T/40HQ」不是费用名称格
if (looksLikeVehicleTypeToken(text) || isLandRouteMatrixHeaderCell(text)
|| looksLikeVehicleDimensionText(text)) {
continue;
}
if (feeNameMatches(text, want)) {
return cell;
}
@@ -3411,6 +3411,85 @@ public class QuoteTemplateFillTest {
}
}
/**
* 产品多车型价表:一行 vehicleRates 按表头写入多列;禁止无匹配时把第一档灌进所有列。
*/
@Test
public void landProductMultiVehicleRatesFillColumns() throws Exception {
Path src = Files.createTempFile("quote-land-mv-src", ".xlsx");
Path out = Files.createTempFile("quote-land-mv-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("跨境整车");
Row header = row(sheet, 8);
header.createCell(1).setCellValue("起始地");
header.createCell(2).setCellValue("目的地");
header.createCell(3).setCellValue("5T");
header.createCell(4).setCellValue("8T");
header.createCell(5).setCellValue("10T");
header.createCell(6).setCellValue("40HQ");
header.createCell(7).setCellValue("45HQ");
header.createCell(8).setCellValue("备注");
row(sheet, 9);
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject(true);
mapping.put("sheet", "跨境整车");
JSONArray regions = new JSONArray();
JSONObject region = new JSONObject(true);
region.put("title", "项目一");
region.put("headerRow", 9);
region.put("dataStartRow", 10);
JSONObject cols = new JSONObject(true);
cols.put("origin", "B");
cols.put("destination", "C");
cols.put("remark", "I");
JSONObject rates = new JSONObject(true);
rates.put("5T", "D");
rates.put("8T", "E");
rates.put("10T", "F");
rates.put("40HQ", "G");
rates.put("45HQ", "H");
cols.put("vehicleRates", rates);
region.put("columns", cols);
regions.add(region);
mapping.put("routeRegions", regions);
JSONObject facts = new JSONObject(true);
facts.put("origin", "太原富士康");
facts.put("destination", "越南北寧市南山工業區");
facts.put("currency", "CNY");
facts.put("route_rows",
"[{\"origin\":\"太原富士康\",\"destination\":\"越南北寧市南山工業區\","
+ "\"currency\":\"CNY\",\"remark\":\"含0点税\","
+ "\"vehicleRates\":{\"5T\":\"13000\",\"8T\":\"13000\",\"10T\":\"14000\","
+ "\"40HQ\":\"19500\",\"45HQ\":\"19500\"}}]");
assertEquals("13000", QuoteTemplateExcelSupport.resolveRouteVehicleRate(
JSON.parseObject("{\"vehicleRates\":{\"5T\":\"13000\",\"10T\":\"14000\"}}"),
facts, "5T", 5, ""));
assertEquals("14000", QuoteTemplateExcelSupport.resolveRouteVehicleRate(
JSON.parseObject("{\"vehicleRates\":{\"5T\":\"13000\",\"10T\":\"14000\"}}"),
facts, "10T", 5, ""));
assertEquals("", QuoteTemplateExcelSupport.resolveRouteVehicleRate(
JSON.parseObject("{\"vehicleRates\":{\"5T\":\"13000\"}}"),
facts, "40HQ", 5, ""));
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertEquals("太原富士康", read(sheet, 9, 1));
assertEquals("越南北寧市南山工業區", read(sheet, 9, 2));
assertEquals("13000", normalizeMoney(read(sheet, 9, 3)));
assertEquals("13000", normalizeMoney(read(sheet, 9, 4)));
assertEquals("14000", normalizeMoney(read(sheet, 9, 5)));
assertEquals("19500", normalizeMoney(read(sheet, 9, 6)));
assertEquals("19500", normalizeMoney(read(sheet, 9, 7)));
assertEquals("含0点税", read(sheet, 9, 8));
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* 中港:TMS 别名经国内映射后再对等中港表头;跨境:吨位列与 3T 族境外 3.5T;禁止 5T 误命中 3.5T。
*/