产品调价继承工单币种(港币不再默成美元);陆运集拼空分档只填询价重量那一档,跨境整车按已选线路写起讫。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-12 19:22:44 +08:00
co-authored by Cursor
parent 3a4c440354
commit 97f0219dfd
8 changed files with 678 additions and 47 deletions
@@ -1176,6 +1176,14 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
if (!routeRows.isEmpty()) {
QuoteTemplateExcelSupport.copyMissingWeightPrices(landSegs, rawLand);
}
// 销售已选线路透传:跨境整车国内/境外段按 legs 写完整起讫(多备选混在 route_rows 时不能靠同序列过滤)
Object selectedLand = q.get("selected_land_route");
if (selectedLand == null) {
selectedLand = q.get("selectedLandRoute");
}
if (selectedLand != null) {
out.put("selected_land_route", selectedLand);
}
if (!landSegs.isEmpty()) {
out.put("landSegments", landSegs);
out.put("route_rows", landSegs);
@@ -18,7 +18,6 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.*;
/**
@@ -2798,7 +2797,7 @@ public final class QuoteTemplateExcelSupport {
int mappedStart = rowIdx0;
rowIdx0 = resolveRouteDataStartRow(sheet, rowIdx0, region.getIntValue("headerRow"));
String kind = landRouteSectionKind(sheet, region);
JSONArray sectionRoutes = routesForLandRouteSection(routes, sheet, region, i, regions.size());
JSONArray sectionRoutes = routesForLandRouteSection(routes, sheet, region, i, regions.size(), facts);
if (sectionRoutes == null || sectionRoutes.isEmpty()) {
continue;
}
@@ -2858,7 +2857,7 @@ public final class QuoteTemplateExcelSupport {
if (StringUtils.isBlank(col)) {
continue;
}
String price = resolveRouteVehicleRate(rowObj, facts, vk, vehicleRates.size());
String price = resolveRouteVehicleRate(rowObj, facts, vk, vehicleRates.size(), kind);
if (StringUtils.isNotBlank(price) && !"-".equals(price)) {
writeFeeCol(sheet, ri, col, price, yellow, sheet.getSheetName());
}
@@ -2937,6 +2936,19 @@ public final class QuoteTemplateExcelSupport {
return firstNonBlank(rowObj.getString("amount"), rowObj.getString("salePrice"),
rowObj.getString("costPrice"));
}
// 集拼矩阵副表头「最低收费」:有分档单价时只用 min_charge;分档壳无单价时回退整票价。
if (header.contains("最低收费")) {
String minCharge = firstNonBlank(rowObj.getString("min_charge"), rowObj.getString("minCharge"));
if (StringUtils.isNotBlank(minCharge) && !"-".equals(minCharge)) {
return formatTierPrice(minCharge);
}
JSONArray tiers = weightPricesOf(rowObj);
if (tiers != null && !tiers.isEmpty() && !anyWeightTierHasPrice(tiers)) {
return formatTierPrice(firstNonBlank(rowObj.getString("salePrice"),
rowObj.getString("costPrice"), rowObj.getString("amount")));
}
return "";
}
return "";
}
@@ -3092,11 +3104,19 @@ public final class QuoteTemplateExcelSupport {
}
/**
* 国内段写境内城市,境外段写境外城市。两段 TMS 腿按腿序号对上。
* 只有一对郑州→吉隆坡时:国内段只写郑州,境外段只写吉隆坡,不发明口岸。
* 国内段 / 境外段各写对应 TMS 腿的完整起讫。
* <p>
* 优先用 selected_land_route.legs(销售已选线路);否则从多条备选里挑一条多腿线路。
* 只有一对「境内→境外」且无两段腿时:国内段只写境内点、境外段只写境外点,不发明口岸。
* WO202610120001:备选混在 route_rows 时若要求整表同 segmentSequence,会退回切片导致口岸空。
*/
static JSONArray routesForLandRouteSection(JSONArray routes, Sheet sheet, JSONObject region,
int regionIndex, int regionCount) {
return routesForLandRouteSection(routes, sheet, region, regionIndex, regionCount, null);
}
static JSONArray routesForLandRouteSection(JSONArray routes, Sheet sheet, JSONObject region,
int regionIndex, int regionCount, JSONObject facts) {
if (routes == null || routes.isEmpty()) {
return routes;
}
@@ -3107,12 +3127,15 @@ public final class QuoteTemplateExcelSupport {
if (StringUtils.isBlank(kind)) {
return routes;
}
JSONArray byLeg = pickLandRouteLegsForSection(routes, kind);
JSONArray preferred = preferredLandRouteLegs(facts, routes);
JSONArray byLeg = pickLandRouteLegsForSection(preferred, kind);
if (byLeg != null && !byLeg.isEmpty()) {
return byLeg;
}
JSONObject seed = preferred != null && !preferred.isEmpty()
? preferred.getJSONObject(0) : routes.getJSONObject(0);
JSONArray sliced = new JSONArray();
JSONObject one = sliceCrossBorderOd(routes.getJSONObject(0), kind);
JSONObject one = sliceCrossBorderOd(seed, kind);
if (one != null && (StringUtils.isNotBlank(one.getString("origin"))
|| StringUtils.isNotBlank(one.getString("destination")))) {
sliced.add(one);
@@ -3120,16 +3143,91 @@ public final class QuoteTemplateExcelSupport {
return sliced;
}
/**
* 出单优先腿:已选线路 legs &gt; 同 segmentSequence 且腿数最多的备选 &gt; 原数组。
*/
static JSONArray preferredLandRouteLegs(JSONObject facts, JSONArray allRoutes) {
if (facts != null) {
Object rawSel = facts.get("selected_land_route");
if (rawSel == null) {
rawSel = facts.get("selectedLandRoute");
}
JSONObject sel = null;
if (rawSel instanceof JSONObject) {
sel = (JSONObject) rawSel;
} else if (rawSel instanceof String && StringUtils.isNotBlank((String) rawSel)) {
try {
Object parsed = JSON.parse((String) rawSel);
if (parsed instanceof JSONObject) {
sel = (JSONObject) parsed;
}
} catch (Exception ignored) {
// 忽略坏 JSON
}
} else if (rawSel instanceof java.util.Map) {
sel = JSON.parseObject(JSON.toJSONString(rawSel));
}
if (sel != null) {
JSONArray legs = parseFeeRows(sel.get("legs"));
if (legs != null && !legs.isEmpty()) {
return legs;
}
}
}
JSONArray grouped = pickPrimaryLandRouteGroup(allRoutes);
return grouped != null && !grouped.isEmpty() ? grouped : allRoutes;
}
/**
* 多条备选(不同 segmentSequence)时,挑腿数最多的一条完整线路;并列取较小序号。
*/
static JSONArray pickPrimaryLandRouteGroup(JSONArray routes) {
if (routes == null || routes.isEmpty()) {
return null;
}
java.util.Map<String, JSONArray> groups = new java.util.LinkedHashMap<>();
for (int i = 0; i < routes.size(); i++) {
JSONObject row = routes.getJSONObject(i);
if (row == null) {
continue;
}
String seq = StringUtils.trimToEmpty(row.getString("segmentSequence"));
if (seq.isEmpty()) {
seq = "1";
}
groups.computeIfAbsent(seq, k -> new JSONArray()).add(row);
}
if (groups.isEmpty()) {
return null;
}
String bestKey = null;
int bestLegs = -1;
int bestSeqNum = Integer.MAX_VALUE;
for (java.util.Map.Entry<String, JSONArray> e : groups.entrySet()) {
int legs = e.getValue() == null ? 0 : e.getValue().size();
int seqNum;
try {
seqNum = Integer.parseInt(e.getKey().trim());
} catch (RuntimeException ex) {
seqNum = Integer.MAX_VALUE;
}
if (legs > bestLegs || (legs == bestLegs && seqNum < bestSeqNum)) {
bestLegs = legs;
bestSeqNum = seqNum;
bestKey = e.getKey();
}
}
return bestKey == null ? null : groups.get(bestKey);
}
/**
* 同一条线路的多段腿:国内段取首腿完整 OD,境外段取末腿完整 OD。
* 不足两腿时返回 null,交给 sliceCrossBorderOd。
*/
static JSONArray pickLandRouteLegsForSection(JSONArray routes, String kind) {
if (routes == null || routes.size() < 2) {
return null;
}
String seq = StringUtils.trimToEmpty(routes.getJSONObject(0).getString("segmentSequence"));
for (int i = 1; i < routes.size(); i++) {
if (!seq.equals(StringUtils.trimToEmpty(routes.getJSONObject(i).getString("segmentSequence")))) {
return null;
}
}
JSONArray ordered = new JSONArray();
ordered.addAll(routes);
ordered.sort((a, b) -> {
@@ -3140,8 +3238,10 @@ public final class QuoteTemplateExcelSupport {
JSONArray out = new JSONArray();
if ("domestic".equals(kind)) {
out.add(ordered.getJSONObject(0));
} else {
} else if ("overseas".equals(kind)) {
out.add(ordered.getJSONObject(ordered.size() - 1));
} else {
return null;
}
return out;
}
@@ -3483,6 +3583,14 @@ public final class QuoteTemplateExcelSupport {
* 禁止「无车型时把同一售价灌进所有费种列」。
*/
static String resolveRouteVehicleRate(JSONObject rowObj, JSONObject facts, String headerKey, int headerCount) {
return resolveRouteVehicleRate(rowObj, facts, headerKey, headerCount, null);
}
/**
* @param sectionKind domestic / overseas / 空;境外段要把国内标注 3T 对成 3.5T 再写价。
*/
static String resolveRouteVehicleRate(JSONObject rowObj, JSONObject facts, String headerKey,
int headerCount, String sectionKind) {
if (StringUtils.isBlank(headerKey)) {
return "";
}
@@ -3517,7 +3625,20 @@ public final class QuoteTemplateExcelSupport {
}
// 跨境集拼是分档公斤价(2-500KG=12 …),不是把同一个 price_per_kg 灌进每一列。
if (looksLikeKgBracketHeader(key)) {
return matchWeightBracketPrice(rowObj, key);
String band = matchWeightBracketPrice(rowObj, key);
if (StringUtils.isNotBlank(band)) {
return band;
}
// TMS 偶发只给分档壳(min/max)+ 整票 costPrice(计价单位:次),无档内单价。
// 禁止灌进所有公斤列;只写入询价重量落入的那一档,避免报价单全空。
return flatLandFeeForEmptyWeightBands(rowObj, facts, key);
}
if (key.contains("最低收费")) {
return formatTierPrice(firstNonBlank(rowObj == null ? null : rowObj.getString("min_charge"),
rowObj == null ? null : rowObj.getString("minCharge"),
rowObj == null ? null : rowObj.getString("salePrice"),
rowObj == null ? null : rowObj.getString("costPrice"),
rowObj == null ? null : rowObj.getString("amount")));
}
if (key.toUpperCase(Locale.ROOT).contains("KG") || key.contains("公斤")) {
return firstNonBlank(rowObj == null ? null : rowObj.getString("price_per_kg"),
@@ -3527,16 +3648,17 @@ public final class QuoteTemplateExcelSupport {
if (key.contains("运输") || key.contains("运费") || key.contains("陆运")) {
return firstNonBlank(rowObj == null ? null : rowObj.getString("salePrice"),
rowObj == null ? null : rowObj.getString("amount"),
rowObj == null ? null : rowObj.getString("costPrice"),
rowObj == null ? null : rowObj.getString("land_freight"),
facts == null ? null : facts.getString("land_freight"));
}
// 2) 车型列:仅当 vehicleType 对得上才写售价
// 2) 车型列:仅当 vehicleType 对得上才写售价(含跨境国内/国外标注换算)
String vehicleType = firstNonBlank(rowObj == null ? null : rowObj.getString("vehicleType"),
rowObj == null ? null : rowObj.getString("vehicle_type"));
String price = firstNonBlank(rowObj == null ? null : rowObj.getString("salePrice"),
rowObj == null ? null : rowObj.getString("costPrice"),
rowObj == null ? null : rowObj.getString("amount"));
if (StringUtils.isNotBlank(vehicleType) && vehicleTypeEquals(vehicleType, key)) {
if (StringUtils.isNotBlank(vehicleType) && vehicleTypeMatchesSection(vehicleType, key, sectionKind)) {
return price;
}
// 3) 仅一列且无车型信息时,允许写入该列(整车单列模板)
@@ -3546,6 +3668,65 @@ public final class QuoteTemplateExcelSupport {
return "";
}
/**
* 车型列是否对得上当前段表头。
* <p>
* 跨境整车:询价/TMS 常写国内标注「3T」,国外段表头是「3.5T」;
* 境外段用国内→国外映射后再比,国内段反之。禁止把 3T 误当成所有 T 列。
*/
static boolean vehicleTypeMatchesSection(String vehicleType, String headerKey, String sectionKind) {
if (vehicleTypeEquals(vehicleType, headerKey)) {
return true;
}
String kind = StringUtils.trimToEmpty(sectionKind);
if ("overseas".equals(kind)) {
String mapped = mapDomesticVehicleToOverseas(vehicleType);
return StringUtils.isNotBlank(mapped) && vehicleTypeEquals(mapped, headerKey);
}
if ("domestic".equals(kind)) {
String mapped = mapOverseasVehicleToDomestic(vehicleType);
return StringUtils.isNotBlank(mapped) && vehicleTypeEquals(mapped, headerKey);
}
return false;
}
/**
* 国内整车标注 → 跨境国外段表头。WO202610080006:3T 对 3.5T。
* 未收录的返回空,禁止瞎猜。
*/
static String mapDomesticVehicleToOverseas(String vehicleType) {
String a = normalizeVehicleToken(vehicleType);
if (a.isEmpty()) {
return "";
}
if ("3T".equals(a) || "3吨".equals(a)) {
return "3.5T";
}
return "";
}
/**
* 国外段标注 → 国内段表头(单腿 TMS 偶发直接回 3.5T 时,国内段仍能写进 3T)。
*/
static String mapOverseasVehicleToDomestic(String vehicleType) {
String a = normalizeVehicleToken(vehicleType);
if (a.isEmpty()) {
return "";
}
if ("3.5T".equals(a) || "3.5吨".equals(a)) {
return "3T";
}
return "";
}
private static String normalizeVehicleToken(String vehicleType) {
if (StringUtils.isBlank(vehicleType)) {
return "";
}
return vehicleType.replace(" ", "").toUpperCase(Locale.ROOT)
.replace("T", "T");
}
/**
* 表头是「2-500KG」「5001KG以上」这类分档,不是「元/KG」单价列。
*/
@@ -3555,6 +3736,8 @@ public final class QuoteTemplateExcelSupport {
/**
* 用 landSegments.weightPrices 对上分档表头。对不上就空着,禁止把一档价抄进所有列。
* <p>
* 档内单价优先读 price,兼容 TMS 偶发的 unitPrice/salePrice/costPrice/amount。
*/
static String matchWeightBracketPrice(JSONObject rowObj, String headerKey) {
JSONArray tiers = weightPricesOf(rowObj);
@@ -3571,12 +3754,122 @@ public final class QuoteTemplateExcelSupport {
boolean open = tier.get("max") == null || StringUtils.isBlank(String.valueOf(tier.get("max")));
int max = open ? -1 : intOf(tier.get("max"));
if (min == range[0] && max == range[1]) {
return formatTierPrice(tier.get("price"));
return formatTierPrice(tierPriceRaw(tier));
}
}
return "";
}
/**
* 分档壳有区间但没有任何档内单价时:把整票 salePrice/costPrice 写入询价重量所在那一档。
* <p>
* 为何:WO202610080043 跨境集拼 TMS 回 weightPrices 只有 min/max,费用是「次」18200;
* 若所有公斤列都空着,销售会以为报价没写入。有真实档内单价时禁止走这条。
* 副作用:只读 row/facts,不改库。
*/
static String flatLandFeeForEmptyWeightBands(JSONObject rowObj, JSONObject facts, String headerKey) {
int[] range = parseKgBracket(headerKey);
if (range == null || rowObj == null) {
return "";
}
JSONArray tiers = weightPricesOf(rowObj);
if (tiers == null || tiers.isEmpty()) {
return "";
}
if (anyWeightTierHasPrice(tiers)) {
return "";
}
String flat = firstNonBlank(rowObj.getString("salePrice"), rowObj.getString("costPrice"),
rowObj.getString("amount"));
if (StringUtils.isBlank(flat) || "-".equals(flat)) {
return "";
}
double weight = inquiryWeightKg(rowObj, facts);
boolean hit;
if (weight >= 0) {
hit = weightInKgBracket(weight, range[0], range[1]);
} else {
// 无询价重量:只写入 weightPrices 里的第一档,避免抄进所有列
JSONObject first = tiers.getJSONObject(0);
int min = first == null ? -1 : intOf(first.get("min"));
boolean open = first == null || first.get("max") == null
|| StringUtils.isBlank(String.valueOf(first.get("max")));
int max = open ? -1 : intOf(first.get("max"));
hit = min == range[0] && max == range[1];
}
return hit ? formatTierPrice(flat) : "";
}
/** 档内是否已有任何可用单价(有则禁止整票价回填)。 */
static boolean anyWeightTierHasPrice(JSONArray tiers) {
if (tiers == null) {
return false;
}
for (int i = 0; i < tiers.size(); i++) {
JSONObject tier = tiers.getJSONObject(i);
if (tier != null && StringUtils.isNotBlank(formatTierPrice(tierPriceRaw(tier)))) {
return true;
}
}
return false;
}
/** TMS 档内单价字段名不统一,按常见键取第一个非空。 */
static Object tierPriceRaw(JSONObject tier) {
if (tier == null) {
return null;
}
Object raw = tier.get("price");
if (raw == null || StringUtils.isBlank(String.valueOf(raw)) || "null".equalsIgnoreCase(String.valueOf(raw))) {
raw = tier.get("unitPrice");
}
if (raw == null || StringUtils.isBlank(String.valueOf(raw)) || "null".equalsIgnoreCase(String.valueOf(raw))) {
raw = tier.get("salePrice");
}
if (raw == null || StringUtils.isBlank(String.valueOf(raw)) || "null".equalsIgnoreCase(String.valueOf(raw))) {
raw = tier.get("costPrice");
}
if (raw == null || StringUtils.isBlank(String.valueOf(raw)) || "null".equalsIgnoreCase(String.valueOf(raw))) {
raw = tier.get("amount");
}
if (raw == null || StringUtils.isBlank(String.valueOf(raw)) || "null".equalsIgnoreCase(String.valueOf(raw))) {
raw = tier.get("pricePerKg");
}
return raw;
}
/** 询价毛重(KG);没有或解析失败返回 -1。 */
static double inquiryWeightKg(JSONObject rowObj, JSONObject facts) {
String raw = firstNonBlank(
rowObj == null ? null : rowObj.getString("weight_kg"),
rowObj == null ? null : rowObj.getString("weightKg"),
facts == null ? null : facts.getString("weight_kg"),
facts == null ? null : facts.getString("weightKg"),
facts == null ? null : facts.getString("毛重"),
facts == null ? null : facts.getString("重量"),
facts == null ? null : facts.getString("weight"));
if (StringUtils.isBlank(raw) || "-".equals(raw)) {
return -1;
}
String num = raw.trim().replace(",", "").replaceAll("[Kk][Gg].*$", "").trim();
try {
return new java.math.BigDecimal(num).doubleValue();
} catch (RuntimeException ex) {
return -1;
}
}
/** 重量是否落在 [min, max];max=-1 表示无上限。 */
static boolean weightInKgBracket(double weight, int min, int max) {
if (weight < 0 || min < 0) {
return false;
}
if (weight < min) {
return false;
}
return max < 0 || weight <= max;
}
/**
* 规范化 route_rows 会丢掉 weightPrices。出单前从原始 landSegments 补回。
*/