diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PaySurcharge.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PaySurcharge.java index c44a1167..3c5fcae6 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/domain/PaySurcharge.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PaySurcharge.java @@ -73,4 +73,10 @@ public class PaySurcharge private BigDecimal weightMax; private Integer sortNo; + + /** + * 是否计费并落库:false 不写入 pay_surcharge;null/缺省视为要计费(兼容应付全量保存)。 + * 仅作为前端传参,不落库字段。 + */ + private Boolean needBill; } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/pay/LabelArCostCalculator.java b/bms-back/bms-system/src/main/java/com/bms/price/pay/LabelArCostCalculator.java new file mode 100644 index 00000000..b58df64d --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/pay/LabelArCostCalculator.java @@ -0,0 +1,830 @@ +package com.bms.price.pay; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import com.bms.common.enums.PaymentMesEnum; +import com.bms.price.domain.PayCostLine; +import com.bms.price.domain.PayOrderCostResult; +import com.bms.price.domain.PayProduct; +import com.bms.price.domain.PaySurcharge; +import com.bms.price.domain.PayWeightBand; + +/** + * 打单套餐应收计费(与应付 {@link PayCostCalculator} 独立)。 + * 按打单 AR 价卡的重量段、附加费计算;后续仅改本类,不影响应付计费。 + * 重量段价格默认是该段的一票价格;单位写明每公斤时才乘重量。 + */ +public final class LabelArCostCalculator +{ + private static final ZoneId SHANGHAI = ZoneId.of("GMT+8"); + + private static final Set REFUND_STATUS_SET = Set.of(9, 10); + + private LabelArCostCalculator() + { + } + + /** + * 按重量、尺寸和邮编计算一票打单应收。不指定地区,也不传入偏远命中结果。 + * + * @param product 当前价卡版本,含重量段和附加费 + * @param weight 计费重,公斤 + * @param length 长,厘米 + * @param width 宽,厘米 + * @param height 高,厘米 + * @param postcode 收件邮编 + * @param bizDate 取价日,用来判断附加费是否在有效期内 + * @param orderStatus 订单状态,退件时才收退件类附加费 + * @return 费用明细和算不了的附加费说明 + */ + public static PayOrderCostResult calculate(PayProduct product, BigDecimal weight, BigDecimal length, BigDecimal width, + BigDecimal height, String postcode, Date bizDate, Integer orderStatus) + { + return calculate(product, weight, length, width, height, postcode, null, bizDate, orderStatus); + } + + /** + * 在已经选定价卡地区时计算打单应收。偏远仍按价卡上的邮编条件判断。 + * + * @param product 当前价卡版本 + * @param weight 计费重,公斤 + * @param length 长,厘米 + * @param width 宽,厘米 + * @param height 高,厘米 + * @param postcode 收件邮编 + * @param zoneName 邮编对上的价卡地区,空表示不按地区过滤 + * @param bizDate 取价日 + * @param orderStatus 订单状态 + * @return 费用明细和算不了的附加费说明 + */ + public static PayOrderCostResult calculate(PayProduct product, BigDecimal weight, BigDecimal length, BigDecimal width, + BigDecimal height, String postcode, String zoneName, Date bizDate, Integer orderStatus) + { + return calculate(product, weight, length, width, height, postcode, zoneName, bizDate, orderStatus, null); + } + + /** + * 计算一票打单应收。remoteHits 为空列表表示产品有偏远邮编但这次没命中;为 null 表示没有这份配置,仍按价卡上的邮编条件。 + * + * @param product 当前价卡版本 + * @param weight 计费重,公斤 + * @param length 长,厘米 + * @param width 宽,厘米 + * @param height 高,厘米 + * @param postcode 收件邮编 + * @param zoneName 价卡地区 + * @param bizDate 取价日 + * @param orderStatus 订单状态 + * @param remoteHits 偏远邮编命中结果 + * @return 费用明细和算不了的附加费说明 + */ + public static PayOrderCostResult calculate(PayProduct product, BigDecimal weight, BigDecimal length, BigDecimal width, + BigDecimal height, String postcode, String zoneName, Date bizDate, Integer orderStatus, List remoteHits) + { + PayOrderCostResult result = new PayOrderCostResult(); + result.setFees(new ArrayList<>()); + result.setSkipped(new ArrayList<>()); + if (product == null) + { + result.setMessage(PaymentMesEnum.BMS_PAY_NO_PRODUCT.getCnMes()); + return result; + } + result.setProductId(product.getProductId()); + result.setProductName(product.getProductName()); + if (weight == null || weight.compareTo(BigDecimal.ZERO) <= 0) + { + result.setMessage(PaymentMesEnum.BMS_PAY_NO_WEIGHT.getCnMes()); + return result; + } + List usable = usableBands(product.getWeightBands()); + List bands = forZone(usable, zoneName); + if (zoneName != null && !zoneName.isBlank() && bands.isEmpty() && hasZoneFee(product, zoneName)) + { + bands = deliveryBands(usable, product); + } + if (zoneName != null && !zoneName.isBlank() && bands.isEmpty()) + { + result.setMessage("邮编对应的地区「" + zoneName + "」不在价卡重量段里"); + return result; + } + Set zones = zones(bands); + if (zones.size() > 1) + { + result.setMessage("产品「" + product.getProductName() + "」有多个地区(" + String.join("、", zones) + + "),邮编没有对上其中的地区,无法确定运费"); + return result; + } + PayWeightBand band = matchBand(bands, weight); + if (band == null) + { + PayWeightBand top = highestBand(bands); + if (top == null || weight.compareTo(top.getWeightMax()) <= 0 + || !hasExtraKg(product, zoneName, top.getWeightMax(), top.getSectionName())) + { + result.setMessage(outOfRange(product, bands, weight)); + return result; + } + band = top; + } + String currency = currencyOf(product, band); + BigDecimal freight = bandAmount(band, weight); + String freightRemark = band.getWeightLabel() + " " + plain(band.getPrice()) + " " + blank(band.getPriceUnit()); + if (zoneName != null && !zoneName.isBlank()) + { + freightRemark = freightRemark + ",地区 " + zoneName.replace('\n', ' '); + } + PayCostLine freightLine = line("BASIC_FREIGHT", "基础运费", 0L, freight, currency, freightRemark); + result.getFees().add(freightLine); + result.setCurrency(currency); + addSurcharges(product, weight, length, width, height, postcode, zoneName, bizDate, orderStatus, freight, currency, + band.getSectionName(), remoteHits, result); + BigDecimal total = BigDecimal.ZERO; + for (PayCostLine fee : result.getFees()) + { + total = total.add(fee.getAmount()); + } + result.setPayTotal(total.setScale(2, RoundingMode.HALF_UP)); + result.setMessage(summary(result)); + return result; + } + + /** 邮编落到偏远分区,而价卡重量段只有大陆派送费时,运费用大陆档。 */ + private static boolean hasZoneFee(PayProduct product, String zoneName) + { + if (product.getSurcharges() == null) + { + return false; + } + for (PaySurcharge fee : product.getSurcharges()) + { + if (fee != null && PaySurchargeRules.ZONE.equals(fee.getCalcRule()) && PayZoneMatcher.sameZone(fee.getZoneName(), zoneName)) + { + return true; + } + } + return false; + } + + private static List deliveryBands(List bands, PayProduct product) + { + List remoteZones = new ArrayList<>(); + if (product.getSurcharges() != null) + { + for (PaySurcharge fee : product.getSurcharges()) + { + if (fee != null && PaySurchargeRules.ZONE.equals(fee.getCalcRule()) && fee.getZoneName() != null && !fee.getZoneName().isBlank()) + { + remoteZones.add(fee.getZoneName()); + } + } + } + List kept = new ArrayList<>(); + for (PayWeightBand band : bands) + { + boolean remote = false; + for (String zone : remoteZones) + { + if (PayZoneMatcher.sameZone(band.getZoneName(), zone)) + { + remote = true; + break; + } + } + if (!remote) + { + kept.add(band); + } + } + return kept; + } + + /** 首重对更重的包裹仍然收取,超出部分由续重另计。 */ + private static boolean weightAtLeast(PaySurcharge fee, BigDecimal weight) + { + if (fee.getWeightMin() == null || weight == null) + { + return true; + } + return weight.compareTo(fee.getWeightMin()) >= 0; + } + + private static boolean weightIn(PaySurcharge fee, BigDecimal weight) + { + if (weight == null || (fee.getWeightMin() == null && fee.getWeightMax() == null)) + { + return false; + } + if (fee.getWeightMin() != null && weight.compareTo(fee.getWeightMin()) < 0) + { + return false; + } + return fee.getWeightMax() == null || weight.compareTo(fee.getWeightMax()) <= 0; + } + + private static List forZone(List bands, String zoneName) + { + if (zoneName == null || zoneName.isBlank()) + { + return bands; + } + List kept = new ArrayList<>(); + for (PayWeightBand band : bands) + { + if (PayZoneMatcher.sameZone(band.getZoneName(), zoneName)) + { + kept.add(band); + } + } + return kept; + } + + /** + * 添加相关附加费,根据计费规则去判断 + * @param product + * @param weight + * @param length + * @param width + * @param height + * @param postcode + * @param zoneName + * @param bizDate + * @param orderStatus + * @param freight + * @param currency + * @param freightSection + * @param remoteHits + * @param result + */ + private static void addSurcharges(PayProduct product, BigDecimal weight, BigDecimal length, BigDecimal width, + BigDecimal height, String postcode, String zoneName, Date bizDate, Integer orderStatus, BigDecimal freight, String currency, + String freightSection, List remoteHits, PayOrderCostResult result) + { + if (product.getSurcharges() == null) + { + return; + } + List sized = new ArrayList<>(); + for (PaySurcharge fee : product.getSurcharges()) + { + if (fee == null || !inForce(fee, bizDate)) + { + continue; + } + String rule = fee.getCalcRule() == null || fee.getCalcRule().isBlank() ? PaySurchargeRules.MANUAL : fee.getCalcRule(); + if (remoteHits != null && remoteFee(fee)) + { + String name = blank(fee.getFeeName()); + if (postcode == null || postcode.isBlank()) + { + result.getSkipped().add(name + ":订单没有邮编"); + } + else if (bookApplies(remoteHits, fee, rule)) + { + String remark = "邮编 " + postcode.trim(); + if (PaySurchargeRules.EXTRA_KG.equals(rule)) + { + addExtraKg(result.getFees(), fee, weight, currency); + } + else if (perKg(fee)) + { + addPerKg(result.getFees(), fee, weight, currency, remark); + } + else + { + addFlat(result.getFees(), fee, currency, remark); + } + } + continue; + } + boolean postcodeDriven = PaySurchargeRules.POSTCODE.equals(rule) || !blank(fee.getPostcodeParam()).isEmpty(); + if (!postcodeDriven && zoneName != null && !zoneName.isBlank() && fee.getZoneName() != null && !fee.getZoneName().isBlank() + && !PayZoneMatcher.sameZone(fee.getZoneName(), zoneName)) + { + continue; + } + String name = blank(fee.getFeeName()); + if (PaySurchargeRules.POSTCODE.equals(rule)) + { + String param = blank(fee.getPostcodeParam()).isEmpty() ? fee.getCalcParam() : fee.getPostcodeParam(); + if (postcode == null || postcode.isBlank()) + { + result.getSkipped().add(name + ":订单没有邮编"); + } + else if (PaySurchargeRules.matchesPostcode(param, postcode) && weightAtLeast(fee, weight)) + { + addFlat(result.getFees(), fee, currency, "邮编 " + postcode.trim()); + } + } + else if (PaySurchargeRules.ZONE.equals(rule)) + { + boolean already = result.getFees().stream().anyMatch(line -> name.equals(line.getFeeName())); + if (!already && zoneName != null && !zoneName.isBlank() && PayZoneMatcher.sameZone(fee.getZoneName(), zoneName) && weightIn(fee, weight)) + { + addFlat(result.getFees(), fee, currency, "地区 " + zoneName.replace('\n', ' ')); + } + } + else if (PaySurchargeRules.WEIGHT.equals(rule)) + { + if (PaySurchargeRules.matchesWeight(fee.getCalcParam(), weight)) + { + addFlat(result.getFees(), fee, currency, "计费重超过 " + fee.getCalcParam() + "kg"); + } + } + // 超出尺寸费用 + else if (PaySurchargeRules.SIZE.equals(rule)) + { + if (length == null || width == null || height == null) + { + result.getSkipped().add(name + ":订单没有长宽高"); + } + else if (PaySurchargeRules.matchesSize(fee.getCalcParam(), length, width, height)) + { + PayCostLine matched = matchedFee(fee, freight, currency, "尺寸命中 " + fee.getCalcParam()); + if (matched != null) + { + sized.add(matched); + } + } + } + // 退件费用 + else if (PaySurchargeRules.RETURN.equals(rule)) + { + if (orderStatus != null && REFUND_STATUS_SET.contains(orderStatus)) + { + addFlat(result.getFees(), fee, currency, "订单已退件"); + } + } + // 超重费 + else if (PaySurchargeRules.EXTRA_KG.equals(rule)) + { + if (postcodeDriven) + { + if (postcode == null || postcode.isBlank()) + { + result.getSkipped().add(name + ":订单没有邮编"); + } + else if (PaySurchargeRules.matchesPostcode(fee.getPostcodeParam(), postcode)) + { + addExtraKg(result.getFees(), fee, weight, currency); + } + } + else if (sameSection(fee.getSectionName(), freightSection)) + { + addExtraKg(result.getFees(), fee, weight, currency); + } + } + else if (PaySurchargeRules.ALWAYS.equals(rule)) + { + PayCostLine matched = matchedFee(fee, freight, currency, blank(fee.getChargeText())); + if (matched != null) + { + result.getFees().add(matched); + } + } + else if (!name.isEmpty() && !name.contains("报价说明")) + { + result.getSkipped().add(name + ":价卡没有可计算的条件,暂不计费"); + } + } + result.getFees().addAll(keepHighestByName(sized)); + } + + /** 同一费用名的尺寸档同时命中时,只收较高的一档,例如 70% 覆盖 35%。 */ + private static List keepHighestByName(List lines) + { + List kept = new ArrayList<>(); + for (PayCostLine line : lines) + { + PayCostLine same = null; + for (PayCostLine exists : kept) + { + if (exists.getFeeName().equals(line.getFeeName())) + { + same = exists; + break; + } + } + if (same == null) + { + kept.add(line); + } + else if (line.getAmount().compareTo(same.getAmount()) > 0) + { + kept.set(kept.indexOf(same), line); + } + } + return kept; + } + + private static void addExtraKg(List fees, PaySurcharge fee, BigDecimal weight, String currency) + { + if (fee.getAmount() == null || weight == null) + { + return; + } + BigDecimal threshold; + try + { + threshold = new BigDecimal(fee.getCalcParam() == null ? "" : fee.getCalcParam().trim()); + } + catch (NumberFormatException ex) + { + return; + } + if (weight.compareTo(threshold) <= 0) + { + return; + } + String feeCurrency = normalize(fee.getCurrency()); + if (!feeCurrency.isEmpty() && !feeCurrency.equals(currency)) + { + return; + } + BigDecimal over = weight.subtract(threshold); + BigDecimal amount = fee.getAmount().multiply(over).setScale(2, RoundingMode.HALF_UP); + long scId = fee.getSurchargeId() == null ? 0L : fee.getSurchargeId(); + String remark = "超出 " + plain(over) + "kg × " + plain(fee.getAmount()) + " " + blank(fee.getPriceUnit()); + fees.add(line("SURCHARGE", blank(fee.getFeeName()), scId, amount, currency, remark)); + } + + /** 同一地区有多张服务表时,续重只跟本次首重所在的那一张表。 */ + private static boolean sameSection(String feeSection, String freightSection) + { + if (feeSection == null || feeSection.isBlank() || freightSection == null || freightSection.isBlank()) + { + return true; + } + return feeSection.trim().equals(freightSection.trim()); + } + + /** 重量超过最高档,且该地区、该服务表有每公斤续重时,基础运费先用最高档。 */ + private static boolean hasExtraKg(PayProduct product, String zoneName, BigDecimal threshold, String section) + { + if (product.getSurcharges() == null || threshold == null) + { + return false; + } + for (PaySurcharge fee : product.getSurcharges()) + { + if (fee == null || !PaySurchargeRules.EXTRA_KG.equals(fee.getCalcRule()) || fee.getAmount() == null + || !blank(fee.getPostcodeParam()).isEmpty()) + { + continue; + } + if (zoneName != null && !zoneName.isBlank() && fee.getZoneName() != null && !fee.getZoneName().isBlank() + && !PayZoneMatcher.sameZone(fee.getZoneName(), zoneName)) + { + continue; + } + if (!sameSection(fee.getSectionName(), section)) + { + continue; + } + try + { + if (fee.getCalcParam() != null && new BigDecimal(fee.getCalcParam().trim()).compareTo(threshold) == 0) + { + return true; + } + } + catch (NumberFormatException ex) + { + continue; + } + } + return false; + } + + private static PayWeightBand highestBand(List bands) + { + PayWeightBand top = null; + for (PayWeightBand band : bands) + { + if (top == null || band.getWeightMax().compareTo(top.getWeightMax()) > 0) + { + top = band; + } + } + return top; + } + + private static boolean remoteFee(PaySurcharge fee) + { + String name = blank(fee.getFeeName()).toLowerCase(Locale.ROOT); + return name.contains("偏远") || name.contains("岛屿") || name.contains("remote") || name.contains("island"); + } + + /** 邮编配置命中后,金额相同的那一档才收;分区写明时还要对上地区。 */ + private static boolean bookApplies(List hits, PaySurcharge fee, String rule) + { + boolean extra = PaySurchargeRules.EXTRA_KG.equals(rule); + boolean zoned = !blank(fee.getZoneName()).isEmpty(); + for (PayRemoteBook.Hit hit : hits) + { + if (hit == null || !sameFeeName(hit.getFeeName(), fee.getFeeName())) + { + continue; + } + boolean amountKnown = hit.getAmount() != null && fee.getAmount() != null; + if (amountKnown && hit.getAmount().compareTo(fee.getAmount()) != 0 && !extra) + { + continue; + } + boolean areaKnown = !blank(hit.getZoneName()).isEmpty() || !blank(hit.getRegionName()).isEmpty(); + if (zoned && areaKnown && !sameArea(hit, fee)) + { + continue; + } + if (extra && zoned && !sameArea(hit, fee)) + { + continue; + } + if (extra && amountKnown && hit.getAmount().compareTo(fee.getAmount()) != 0 && !sameArea(hit, fee)) + { + continue; + } + return true; + } + return false; + } + + private static boolean sameFeeName(String bookName, String feeName) + { + String book = feeKey(bookName); + String fee = feeKey(feeName); + if (book.isEmpty()) + { + return fee.contains("偏远") || fee.contains("岛屿") || fee.contains("remote") || fee.contains("island"); + } + return fee.contains(book) || book.contains(fee); + } + + private static String feeKey(String name) + { + return blank(name).toLowerCase(Locale.ROOT).replace("地区", "").replace(" ", ""); + } + + private static boolean sameArea(PayRemoteBook.Hit hit, PaySurcharge fee) + { + return PayZoneMatcher.sameZone(fee.getZoneName(), hit.getZoneName()) + || PayZoneMatcher.sameZone(fee.getZoneName(), hit.getRegionName()); + } + + private static boolean perKg(PaySurcharge fee) + { + String unit = blank(fee.getPriceUnit()).toUpperCase(Locale.ROOT); + return unit.contains("KG") || unit.contains("公斤"); + } + + private static void addPerKg(List fees, PaySurcharge fee, BigDecimal weight, String currency, String remark) + { + if (fee.getAmount() == null || weight == null) + { + return; + } + String feeCurrency = normalize(fee.getCurrency()); + if (!feeCurrency.isEmpty() && !feeCurrency.equals(currency)) + { + return; + } + BigDecimal amount = fee.getAmount().multiply(weight).setScale(2, RoundingMode.HALF_UP); + long scId = fee.getSurchargeId() == null ? 0L : fee.getSurchargeId(); + fees.add(line("SURCHARGE", blank(fee.getFeeName()), scId, amount, currency, remark + " × " + plain(weight) + "kg")); + } + + private static void addFlat(List fees, PaySurcharge fee, String currency, String remark) + { + PayCostLine line = matchedFee(fee, null, currency, remark); + if (line != null && !percent(fee)) + { + fees.add(line); + } + } + + private static PayCostLine matchedFee(PaySurcharge fee, BigDecimal freight, String currency, String remark) + { + if (fee.getAmount() == null) + { + return null; + } + BigDecimal amount = fee.getAmount(); + if (percent(fee)) + { + if (freight == null) + { + return null; + } + amount = freight.multiply(fee.getAmount()).divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP); + } + else + { + amount = amount.setScale(2, RoundingMode.HALF_UP); + String feeCurrency = normalize(fee.getCurrency()); + if (!feeCurrency.isEmpty() && !feeCurrency.equals(currency)) + { + return null; + } + } + long scId = fee.getSurchargeId() == null ? 0L : fee.getSurchargeId(); + return line("SURCHARGE", blank(fee.getFeeName()), scId, amount, currency, remark); + } + + private static boolean percent(PaySurcharge fee) + { + String charge = fee.getChargeText() == null ? "" : fee.getChargeText(); + return charge.contains("%") || "%".equals(fee.getCurrency()); + } + + private static boolean inForce(PaySurcharge fee, Date bizDate) + { + if (bizDate == null || (fee.getEffectiveStart() == null && fee.getEffectiveEnd() == null)) + { + return true; + } + LocalDate day = toLocal(bizDate); + if (fee.getEffectiveStart() != null && day.isBefore(toLocal(fee.getEffectiveStart()))) + { + return false; + } + if (fee.getEffectiveEnd() != null && day.isAfter(toLocal(fee.getEffectiveEnd()))) + { + return false; + } + return true; + } + + private static List usableBands(List bands) + { + List usable = new ArrayList<>(); + if (bands == null) + { + return usable; + } + for (PayWeightBand band : bands) + { + if (band != null && band.getPrice() != null && band.getWeightMin() != null && band.getWeightMax() != null) + { + usable.add(band); + } + } + usable.sort((left, right) -> left.getWeightMin().compareTo(right.getWeightMin())); + return usable; + } + + private static Set zones(List bands) + { + Set zones = new LinkedHashSet<>(); + for (PayWeightBand band : bands) + { + if (band.getZoneName() != null && !band.getZoneName().isBlank()) + { + zones.add(band.getZoneName().trim()); + } + } + return zones; + } + + private static PayWeightBand matchBand(List bands, BigDecimal weight) + { + for (PayWeightBand band : bands) + { + if (weight.compareTo(band.getWeightMin()) >= 0 && weight.compareTo(band.getWeightMax()) <= 0) + { + return band; + } + } + for (int i = 0; i < bands.size() - 1; i++) + { + PayWeightBand current = bands.get(i); + PayWeightBand next = bands.get(i + 1); + BigDecimal gap = next.getWeightMin().subtract(current.getWeightMax()); + if (weight.compareTo(current.getWeightMax()) > 0 && weight.compareTo(next.getWeightMin()) < 0 + && gap.compareTo(new BigDecimal("0.01")) <= 0) + { + return next; + } + } + return null; + } + + private static String outOfRange(PayProduct product, List bands, BigDecimal weight) + { + String name = blank(product.getProductName()); + if (bands.isEmpty()) + { + return "产品「" + name + "」没有可匹配的重量段"; + } + BigDecimal max = bands.get(bands.size() - 1).getWeightMax(); + if (weight.compareTo(max) > 0) + { + return "计费重 " + plain(weight) + "kg 超出「" + name + "」的重量段,最大 " + plain(max) + "kg"; + } + return "计费重 " + plain(weight) + "kg 没有落在「" + name + "」的重量段里"; + } + + private static BigDecimal bandAmount(PayWeightBand band, BigDecimal weight) + { + BigDecimal price = band.getPrice(); + if (perKilogram(band)) + { + return price.multiply(weight).setScale(2, RoundingMode.HALF_UP); + } + return price.setScale(2, RoundingMode.HALF_UP); + } + + private static boolean perKilogram(PayWeightBand band) + { + String blob = (blank(band.getPriceUnit()) + " " + blank(band.getChargeName())).toLowerCase(Locale.ROOT); + return blob.contains("/kg") || blob.contains("每公斤") || blob.contains("每千克") || blob.contains("/公斤"); + } + + private static String currencyOf(PayProduct product, PayWeightBand band) + { + String unit = blank(band.getPriceUnit()).toUpperCase(Locale.ROOT); + if (unit.contains("GBP") || unit.contains("英镑")) + { + return "GBP"; + } + if (unit.contains("EUR") || unit.contains("欧元")) + { + return "EUR"; + } + if (unit.contains("USD") || unit.contains("美元")) + { + return "USD"; + } + if (unit.contains("CNY") || unit.contains("RMB") || unit.contains("人民币")) + { + return "CNY"; + } + String currency = blank(product.getCurrency()); + return currency.isEmpty() ? "CNY" : currency; + } + + private static PayCostLine line(String feeType, String feeName, Long scId, BigDecimal amount, String currency, String remark) + { + PayCostLine line = new PayCostLine(); + line.setFeeType(feeType); + line.setFeeName(feeName); + line.setScId(scId); + line.setAmount(amount.setScale(2, RoundingMode.HALF_UP)); + line.setCurrency(currency); + line.setRemark(remark == null ? "" : remark.trim()); + return line; + } + + private static String summary(PayOrderCostResult result) + { + String text = "打单应收 " + plain(result.getPayTotal()) + " " + result.getCurrency(); + if (result.getSkipped() != null && !result.getSkipped().isEmpty()) + { + text = text + ",另有 " + result.getSkipped().size() + " 项附加费未自动计算"; + } + return text; + } + + private static LocalDate toLocal(Date date) + { + if (date instanceof java.sql.Date sql) + { + return sql.toLocalDate(); + } + return date.toInstant().atZone(SHANGHAI).toLocalDate(); + } + + private static String plain(BigDecimal value) + { + return value == null ? "" : value.stripTrailingZeros().toPlainString(); + } + + private static String blank(String value) + { + return value == null ? "" : value.trim(); + } + + private static String normalize(String currency) + { + if (currency == null || currency.isBlank() || "%".equals(currency.trim())) + { + return ""; + } + String value = currency.trim().toUpperCase(Locale.ROOT); + if ("英镑".equals(currency)) + { + return "GBP"; + } + if ("欧元".equals(currency)) + { + return "EUR"; + } + return value; + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/pay/PayCostCalculator.java b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayCostCalculator.java index 2b73980c..2118e836 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/pay/PayCostCalculator.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayCostCalculator.java @@ -18,7 +18,8 @@ import com.bms.price.domain.PaySurcharge; import com.bms.price.domain.PayWeightBand; /** - * 用渠道上的应付产品,按订单重量、尺寸、邮编和是否退件计算应付。 + * 应付(AP)计费:用渠道应付产品按重量、尺寸、邮编和是否退件计算。 + * 打单应收请用 {@link LabelArCostCalculator},两者独立,互不影响。 * 重量段价格默认是该段的一票价格;单位写明每公斤时才乘重量。 */ public final class PayCostCalculator diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PayProductServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PayProductServiceImpl.java index b9e60317..85c56002 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PayProductServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PayProductServiceImpl.java @@ -461,6 +461,8 @@ public class PayProductServiceImpl implements IPayProductService { product.setExtraLines(new ArrayList<>()); } + // 计数前先按「是否计费」过滤,与落库行数一致 + retainBillableSurcharges(product); product.setWeightCount(product.getWeightBands().size()); product.setSurchargeCount(product.getSurcharges().size()); product.setExtraCount(product.getExtraLines().size()); @@ -1529,6 +1531,7 @@ public class PayProductServiceImpl implements IPayProductService private void insertSurcharges(PayProduct product) { + retainBillableSurcharges(product); List rows = product.getSurcharges(); if (rows == null || rows.isEmpty()) { @@ -1547,6 +1550,27 @@ public class PayProductServiceImpl implements IPayProductService } } + /** + * 仅保留需要计费的附加费:needBill=false 不入库;null 视为要计费。 + */ + private void retainBillableSurcharges(PayProduct product) + { + List rows = product.getSurcharges(); + if (rows == null || rows.isEmpty()) + { + return; + } + List kept = new ArrayList(); + for (PaySurcharge fee : rows) + { + if (fee != null && !Boolean.FALSE.equals(fee.getNeedBill())) + { + kept.add(fee); + } + } + product.setSurcharges(kept); + } + /** * 按附加费名称映射 prc_surcharge.sc_id;币种不能为空:附加费 -> 价卡 -> EUR。 */ diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/TmsPriceQuoteServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/TmsPriceQuoteServiceImpl.java index b147991f..8b3780ab 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/TmsPriceQuoteServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/TmsPriceQuoteServiceImpl.java @@ -42,7 +42,7 @@ import com.bms.price.domain.PrcQuote; import com.bms.price.domain.PrcQuoteFee; import com.bms.price.domain.PrcSurchargeRel; import com.bms.price.domain.PrcZone; -import com.bms.price.pay.PayCostCalculator; +import com.bms.price.pay.LabelArCostCalculator; import com.bms.price.mapper.PrcZoneDetailMapper; import com.bms.price.domain.vo.TmsPriceBillRequest; import com.bms.price.domain.vo.TmsPriceQuoteFeeLine; @@ -209,7 +209,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService return result; } - /** LABEL 费用已由 PayCostCalculator 算完,不再叠加销售附加费。 */ + /** LABEL 费用已由 LabelArCostCalculator 算完,不再叠加销售附加费。 */ private void applySalesSurchargesIfNeeded(TmsPriceQuoteResult result) { if (result == null || SRC_LABEL.equals(result.getMatchSource())) @@ -665,7 +665,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService } /** - * 打单套餐计费:渠道 AR → product_id,套餐+时间 → term,PayCostCalculator 算费。 + * 打单套餐计费:渠道 AR → product_id,套餐+时间 → term,LabelArCostCalculator 算费(与应付独立)。 */ private TmsPriceQuoteResult doLabelQuote(TmsPriceQuoteRequest request, PrcCustomerPriceSet bind, PrcPriceSet set, String productCode, String productName, String countryCode, String countryName, @@ -707,7 +707,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService height = billReq.getPkgHeight(); orderStatus = billReq.getOrderStatus(); } - PayOrderCostResult cost = PayCostCalculator.calculate(product, weight, length, width, height, + PayOrderCostResult cost = LabelArCostCalculator.calculate(product, weight, length, width, height, request.getDestPostcode(), bizDate, orderStatus); if (cost == null || cost.getFees() == null || cost.getFees().isEmpty()) { diff --git a/bms-front/src/views/price/basic/pay/components/PayConfigDetail.vue b/bms-front/src/views/price/basic/pay/components/PayConfigDetail.vue index 990319ce..269a4d70 100644 --- a/bms-front/src/views/price/basic/pay/components/PayConfigDetail.vue +++ b/bms-front/src/views/price/basic/pay/components/PayConfigDetail.vue @@ -75,12 +75,22 @@ /> - +
- 添加附加费 + 添加附加费 +
+ + + @@ -235,6 +251,7 @@ import { h, onMounted, watch } from 'vue' import { ElInput } from 'element-plus' import { listSurchargeOptions } from '@/api/price/surcharge' +import PaySurchargePickerDialog from './PaySurchargePickerDialog.vue' const props = defineProps({ product: { @@ -249,6 +266,11 @@ const props = defineProps({ type: Boolean, default: false }, + /** 打单套餐:附加费表显示「是否计费」勾选,仅勾选行入库 */ + billSelect: { + type: Boolean, + default: false + }, savingRowKey: { type: [String, Number], default: '' @@ -517,24 +539,93 @@ function filteredFees(pane) { return filterRows(pane.item.surcharges || [], pane.feeKeyword, ['feeName', 'zoneName', 'chargeText', 'amount', 'currency', 'priceUnit', 'feeDesc', 'calcRule', 'calcParam', 'postcodeParam']) } -function addFee(pane) { - if (!pane.item.surcharges) pane.item.surcharges = [] - const currency = String(pane.item.currency || 'EUR').trim().toUpperCase() || 'EUR' - const row = { - feeName: '', - scId: undefined, - zoneName: '', - chargeText: '', - amount: '', - priceUnit: currency + '/ITEM', - currency, - feeDesc: '', - calcRule: 'MANUAL', - calcParam: '' +function feeTabLabel(pane) { + const fees = pane.item.surcharges || [] + if (!props.billSelect) return '附加费(' + fees.length + ')' + const n = fees.filter(f => f && f.needBill === true).length + return '附加费(已选 ' + n + '/' + fees.length + ')' +} + +function setFeesBill(pane, value) { + const fees = pane.item.surcharges || [] + for (const fee of fees) { + if (fee) fee.needBill = !!value + } +} + +const { proxy } = getCurrentInstance() +const feePickerVisible = ref(false) +const feePickerPane = ref(null) + +const feePickerExcludeIds = computed(() => { + const fees = feePickerPane.value?.item?.surcharges || [] + return fees.map(fee => fee?.scId).filter(id => id != null) +}) + +function openFeePicker(pane) { + feePickerPane.value = pane + feePickerVisible.value = true +} + +function toCalcRule(chargeType) { + const t = String(chargeType || '').trim().toUpperCase() + if (!t || t === 'RATE') return 'MANUAL' + if (t === 'FIXED') return 'ALWAYS' + const allowed = ['ALWAYS', 'POSTCODE', 'ZONE', 'EXTRA_KG', 'WEIGHT', 'SIZE', 'RETURN', 'MANUAL'] + return allowed.includes(t) ? t : 'MANUAL' +} + +function configToFeeRow(cfg, product) { + const currency = String(cfg?.currency || product?.currency || 'EUR').trim().toUpperCase() || 'EUR' + const amount = cfg?.chargeValue + const isPercent = String(cfg?.chargeType || '').toUpperCase() === 'RATE' + return { + feeName: String(cfg?.scName || '').trim(), + scId: cfg?.scId, + zoneName: String(cfg?.blockTitle || cfg?.countryCode || '').trim(), + chargeText: '', + amount: amount == null ? '' : amount, + currency, + priceUnit: isPercent ? '%' : (currency + '/ITEM'), + feeDesc: String(cfg?.scNote || '').trim(), + calcRule: toCalcRule(cfg?.chargeType), + calcParam: '', + // 从配置添加默认要计费 + needBill: true + } +} + +function onFeePickerConfirm(list) { + const pane = feePickerPane.value + if (!pane?.item) return + if (!pane.item.surcharges) pane.item.surcharges = [] + const existed = new Set( + (pane.item.surcharges || []).map(fee => fee?.scId).filter(id => id != null).map(id => String(id)) + ) + let added = 0 + let skipped = 0 + for (const cfg of list || []) { + if (cfg?.scId != null && existed.has(String(cfg.scId))) { + skipped++ + continue + } + const row = configToFeeRow(cfg, pane.item) + if (!row.feeName) continue + pane.item.surcharges.push(row) + if (row.scId != null) existed.add(String(row.scId)) + added++ + } + if (added > 0) { + pane.feePageNo = Math.ceil(pane.item.surcharges.length / pageSize) || 1 + pane.tab = 'fee' + } + if (added && skipped) { + proxy.$modal.msgSuccess(`已添加 ${added} 项,跳过重复 ${skipped} 项`) + } else if (added) { + proxy.$modal.msgSuccess(`已添加 ${added} 项附加费`) + } else { + proxy.$modal.msgWarning('没有可添加的附加费(可能已全部存在)') } - pane.item.surcharges.push(row) - startEdit(row) - pane.feePageNo = Math.ceil(pane.item.surcharges.length / pageSize) || 1 } function removeRow(list, row) { @@ -643,9 +734,14 @@ function statusText(status) { .pay-edit-bar { display: flex; align-items: center; + flex-wrap: wrap; gap: 8px; margin-bottom: 8px; } +.pay-bill-tip { + color: var(--el-text-color-secondary); + font-size: 12px; +} .pay-hint { margin: 0 0 8px; color: var(--el-text-color-secondary); diff --git a/bms-front/src/views/price/basic/pay/components/PaySurchargePickerDialog.vue b/bms-front/src/views/price/basic/pay/components/PaySurchargePickerDialog.vue new file mode 100644 index 00000000..3b7f099a --- /dev/null +++ b/bms-front/src/views/price/basic/pay/components/PaySurchargePickerDialog.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/bms-front/src/views/price/label/components/LabelImportPanel.vue b/bms-front/src/views/price/label/components/LabelImportPanel.vue index 82f5a758..66d0efa0 100644 --- a/bms-front/src/views/price/label/components/LabelImportPanel.vue +++ b/bms-front/src/views/price/label/components/LabelImportPanel.vue @@ -87,7 +87,7 @@ - + @@ -187,7 +187,9 @@ async function onFile(uploadFile) { const data = res.data || {} data.products = (data.products || []).map((item, index) => ({ ...item, - rowKey: `${item.sheetName || ''}-${item.productName || ''}-${index}` + rowKey: `${item.sheetName || ''}-${item.productName || ''}-${index}`, + // 解析后默认不计费,勾选后才入 pay_surcharge + surcharges: (item.surcharges || []).map(fee => ({ ...fee, needBill: fee?.needBill === true })) })) preview.value = data const picked = data.products.filter(row => canSelect(row)) @@ -310,12 +312,17 @@ async function submit(payload = {}) { const productsPayload = (payload.products && payload.products.length) ? payload.products : selected.value.map(row => ({ ...row, productType: 'AR' })) + const filteredProducts = productsPayload.map(product => ({ + ...product, + productType: product.productType || 'AR', + surcharges: (product.surcharges || []).filter(fee => fee && fee.needBill === true) + })) const res = await saveLabelImport({ priceSetId: payload.priceSetId || null, priceSetName: String(payload.priceSetName || '').trim(), priceSetNote: payload.priceSetNote || '', overwrite: payload.overwrite != null ? payload.overwrite : overwrite.value, - products: productsPayload + products: filteredProducts }) const data = res.data || {} return { diff --git a/bms-front/src/views/price/label/create.vue b/bms-front/src/views/price/label/create.vue index f25d9a21..4c23f18a 100644 --- a/bms-front/src/views/price/label/create.vue +++ b/bms-front/src/views/price/label/create.vue @@ -21,9 +21,18 @@ {{ cardTitle }} {{ headerTip }} - - 导入价卡 - +
+ 批量删除 + + 导入价卡 + +
@@ -36,7 +45,18 @@ class="meta-alert" /> - + + @@ -54,9 +74,10 @@ 新记录 - + @@ -79,6 +100,7 @@ @@ -104,6 +126,8 @@ const form = reactive({ basic: createEmptyBasicInfo() }) const productRows = ref([]) +const productTableRef = ref() +const selectedProducts = ref([]) const importMeta = reactive({ sourceFile: '', supplierName: '', @@ -141,7 +165,7 @@ const pageBanner = computed(() => { const headerTip = computed(() => { if (editVersionMode.value) return '可重新导入或修改明细;完成后覆盖本版本,不新增版本号' if (newVersionMode.value) return '导入后生成待生效新版本;生效时间请回详情页设置' - return '打单价卡与应付独立;解析后回填到此表,查看明细后可在行内保存修改' + return '打单价卡与应付独立;解析后回填到此表,可删除不需要的产品,编辑明细后点完成保存' }) function goBack() { @@ -196,7 +220,9 @@ async function loadEditProducts() { } } })) - productRows.value = details + // 库中已落库的附加费默认勾选(保留原计费项) + productRows.value = markSurchargeBillFlags(details, true) + selectedProducts.value = [] importMeta.sourceFile = details[0]?.sourceFile || '' importMeta.supplierName = details[0]?.supplierName || '' importMeta.overwrite = true @@ -209,8 +235,71 @@ async function openImport() { importRef.value?.open() } +function markSurchargeBillFlags(products, defaultNeedBill) { + return (products || []).map(product => ({ + ...product, + surcharges: (product.surcharges || []).map(fee => ({ + ...fee, + needBill: fee?.needBill === true ? true : (fee?.needBill === false ? false : !!defaultNeedBill) + })) + })) +} + +function withBillableSurcharges(products) { + return (products || []).map(product => ({ + ...product, + surcharges: (product.surcharges || []).filter(fee => fee && fee.needBill === true) + })) +} + +function onProductSelect(rows) { + selectedProducts.value = rows || [] +} + +function productKey(row) { + return row?.rowKey || `${row?.sheetName || ''}-${row?.productName || ''}` +} + +async function removeProduct(row) { + if (!row) return + try { + await proxy.$modal.confirm(`确认从本次导入中移除产品「${row.productName || row.sheetName || ''}」?`) + } catch { + return + } + const key = productKey(row) + productRows.value = productRows.value.filter(item => productKey(item) !== key) + selectedProducts.value = selectedProducts.value.filter(item => productKey(item) !== key) + if (detailRowKey.value === key) { + detailVisible.value = false + } + proxy.$modal.msgSuccess('已删除') +} + +async function removeSelectedProducts() { + const rows = selectedProducts.value || [] + if (!rows.length) { + proxy.$modal.msgWarning('请先勾选要删除的产品') + return + } + try { + await proxy.$modal.confirm(`确认删除所选 ${rows.length} 个产品?`) + } catch { + return + } + const keys = new Set(rows.map(productKey)) + productRows.value = productRows.value.filter(item => !keys.has(productKey(item))) + selectedProducts.value = [] + if (keys.has(detailRowKey.value)) { + detailVisible.value = false + } + proxy.$modal.msgSuccess('已批量删除') +} + function onImportConfirm(payload) { - productRows.value = payload?.products || [] + // 价卡解析行默认不计费,需手动勾选 + productRows.value = markSurchargeBillFlags(payload?.products || [], false) + selectedProducts.value = [] importMeta.sourceFile = payload?.sourceFile || '' importMeta.supplierName = payload?.supplierName || '' importMeta.effectiveDate = payload?.effectiveDate || '' @@ -226,6 +315,10 @@ function openDetail(row) { } function onDetailClosed() { + // 关闭抽屉时回写勾选结果(不依赖行内「保存」) + if (detailProduct.value && detailRowKey.value) { + applyEditedProduct() + } detailProduct.value = null detailRowKey.value = '' savingRowKey.value = '' @@ -303,6 +396,10 @@ async function finish() { proxy.$modal.msgWarning('请先导入并确认价卡产品') return } + // 抽屉未关闭时先回写勾选,避免丢失 + if (detailVisible.value && detailProduct.value && detailRowKey.value) { + applyEditedProduct() + } const tip = editVersionMode.value ? `将覆盖版本 V${versionNo.value} 的 ${productRows.value.length} 个产品,确认继续?` : newVersionMode.value @@ -317,7 +414,7 @@ async function finish() { try { const payload = { overwrite: editVersionMode.value ? true : importMeta.overwrite, - products: productRows.value + products: withBillableSurcharges(productRows.value) } if (editVersionMode.value) { payload.priceSetId = priceSetId.value @@ -367,6 +464,11 @@ watch([priceSetId, versionNo, () => route.query.editVersion, () => route.query.n color: #909399; font-weight: normal; } +.header-actions { + display: flex; + align-items: center; + gap: 8px; +} .meta-alert { margin-bottom: 12px; } diff --git a/bms-front/src/views/price/set/components/detail/SetVersionTable.vue b/bms-front/src/views/price/set/components/detail/SetVersionTable.vue index 1aa9c57f..d3caa626 100644 --- a/bms-front/src/views/price/set/components/detail/SetVersionTable.vue +++ b/bms-front/src/views/price/set/components/detail/SetVersionTable.vue @@ -30,11 +30,11 @@ - +