新增附加费计费标识,优化附加费管理逻辑

This commit is contained in:
wuweihua
2026-11-12 14:43:42 +08:00
parent cf336f7a67
commit 90147ed4e3
10 changed files with 1339 additions and 39 deletions
@@ -73,4 +73,10 @@ public class PaySurcharge
private BigDecimal weightMax;
private Integer sortNo;
/**
* 是否计费并落库:false 不写入 pay_surcharge;null/缺省视为要计费(兼容应付全量保存)。
* 仅作为前端传参,不落库字段。
*/
private Boolean needBill;
}
@@ -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<Integer> 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<PayRemoteBook.Hit> 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<PayWeightBand> usable = usableBands(product.getWeightBands());
List<PayWeightBand> 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<String> 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<PayWeightBand> deliveryBands(List<PayWeightBand> bands, PayProduct product)
{
List<String> 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<PayWeightBand> 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<PayWeightBand> forZone(List<PayWeightBand> bands, String zoneName)
{
if (zoneName == null || zoneName.isBlank())
{
return bands;
}
List<PayWeightBand> 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<PayRemoteBook.Hit> remoteHits, PayOrderCostResult result)
{
if (product.getSurcharges() == null)
{
return;
}
List<PayCostLine> 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<PayCostLine> keepHighestByName(List<PayCostLine> lines)
{
List<PayCostLine> 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<PayCostLine> 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<PayWeightBand> 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<PayRemoteBook.Hit> 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<PayCostLine> 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<PayCostLine> 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<PayWeightBand> usableBands(List<PayWeightBand> bands)
{
List<PayWeightBand> 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<String> zones(List<PayWeightBand> bands)
{
Set<String> 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<PayWeightBand> 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<PayWeightBand> 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;
}
}
@@ -18,7 +18,8 @@ import com.bms.price.domain.PaySurcharge;
import com.bms.price.domain.PayWeightBand;
/**
* 用渠道上的应付产品,按订单重量、尺寸、邮编和是否退件计算应付。
* 应付(AP)计费:用渠道应付产品按重量、尺寸、邮编和是否退件计算。
* 打单应收请用 {@link LabelArCostCalculator},两者独立,互不影响。
* 重量段价格默认是该段的一票价格;单位写明每公斤时才乘重量。
*/
public final class PayCostCalculator
@@ -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<PaySurcharge> 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<PaySurcharge> rows = product.getSurcharges();
if (rows == null || rows.isEmpty())
{
return;
}
List<PaySurcharge> kept = new ArrayList<PaySurcharge>();
for (PaySurcharge fee : rows)
{
if (fee != null && !Boolean.FALSE.equals(fee.getNeedBill()))
{
kept.add(fee);
}
}
product.setSurcharges(kept);
}
/**
* 按附加费名称映射 prc_surcharge.sc_id;币种不能为空:附加费 -> 价卡 -> EUR。
*/
@@ -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())
{
@@ -75,12 +75,22 @@
/>
</el-tab-pane>
<el-tab-pane :label="`附加费(${(pane.item.surcharges || []).length})`" name="fee">
<el-tab-pane :label="feeTabLabel(pane)" name="fee">
<div class="pay-edit-bar">
<el-input v-model="pane.feeKeyword" clearable placeholder="筛选附加费用、地区、价格、货币或费用解释" class="pay-filter" @input="pane.feePageNo = 1" />
<el-button v-if="editable" size="small" type="primary" plain @click="addFee(pane)">添加附加费</el-button>
<el-button v-if="editable" size="small" type="primary" plain @click="openFeePicker(pane)">添加附加费</el-button>
<template v-if="billSelect">
<el-button size="small" @click="setFeesBill(pane, true)">全选计费</el-button>
<el-button size="small" @click="setFeesBill(pane, false)">取消全选</el-button>
<span class="pay-bill-tip">勾选后才会写入 pay_surcharge;默认不勾选</span>
</template>
</div>
<el-table :data="pageFees(pane)" border size="small" empty-text="没有附加费">
<el-table-column v-if="billSelect" label="是否计费" width="88" align="center" fixed="left">
<template #default="{ row }">
<el-checkbox :model-value="row.needBill === true" @update:model-value="v => { row.needBill = !!v }" />
</template>
</el-table-column>
<el-table-column prop="feeName" label="附加费用" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-select
@@ -227,6 +237,12 @@
</el-tab-pane>
</el-tabs>
</section>
<pay-surcharge-picker-dialog
v-model="feePickerVisible"
:exclude-sc-ids="feePickerExcludeIds"
@confirm="onFeePickerConfirm"
/>
</div>
</template>
@@ -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)
startEdit(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('没有可添加的附加费(可能已全部存在)')
}
}
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);
@@ -0,0 +1,234 @@
<template>
<el-dialog
v-model="visible"
title="选择附加费配置"
width="920px"
append-to-body
destroy-on-close
@opened="onOpened"
>
<div class="picker-bar">
<el-input
v-model="keyword"
clearable
placeholder="搜索附加费名称、计费方式、币种、国家或备注"
class="picker-search"
/>
<span class="picker-meta">共 {{ filteredRows.length }} 项,已选 {{ picked.length }} 项</span>
</div>
<el-table
ref="tableRef"
v-loading="loading"
:data="filteredRows"
row-key="scId"
border
size="small"
max-height="420"
empty-text="暂无附加费配置"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="46" :selectable="canSelect" />
<el-table-column prop="scName" label="附加费名称" min-width="160" show-overflow-tooltip />
<el-table-column label="计费方式" width="120" align="center">
<template #default="{ row }">{{ chargeTypeLabel(row.chargeType) }}</template>
</el-table-column>
<el-table-column label="计费数值" width="130" align="right">
<template #default="{ row }">{{ formatValue(row) }}</template>
</el-table-column>
<el-table-column prop="currency" label="币种" width="80" align="center" />
<el-table-column label="国家/分区" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ scopeText(row) }}</template>
</el-table-column>
<el-table-column prop="scNote" label="备注" min-width="160" show-overflow-tooltip />
</el-table>
<div class="picker-hint">勾选后点确定写入当前产品附加费表;已存在相同配置的不会重复添加。</div>
<template #footer>
<el-button @click="visible = false">取 消</el-button>
<el-button type="primary" :disabled="!picked.length" @click="submit">确 定</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { listSurchargeOptions } from '@/api/price/surcharge'
const props = defineProps({
modelValue: { type: Boolean, default: false },
/** 已在表中的 scId,用于禁用重复勾选 */
excludeScIds: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const { proxy } = getCurrentInstance()
const tableRef = ref()
const loading = ref(false)
const keyword = ref('')
const rows = ref([])
const pickedIds = ref(new Set())
const syncing = ref(false)
const visible = computed({
get: () => props.modelValue,
set: v => emit('update:modelValue', v)
})
const excludeSet = computed(() => new Set((props.excludeScIds || []).map(id => String(id))))
const chargeTypeLabelMap = {
MANUAL: '暂不计费',
ALWAYS: '每票都收',
POSTCODE: '命中邮编',
ZONE: '命中分区',
EXTRA_KG: '超重续收',
WEIGHT: '超重',
SIZE: '超尺寸',
RETURN: '退件',
FIXED: '每票都收',
RATE: '暂不计费'
}
function chargeTypeLabel(val) {
if (!val) return chargeTypeLabelMap.MANUAL
const key = String(val).toUpperCase()
return chargeTypeLabelMap[key] || val
}
function formatValue(row) {
if (row == null || row.chargeValue == null || row.chargeValue === '') return '-'
const v = Number(row.chargeValue)
if (!Number.isFinite(v)) return row.chargeValue
if (String(row.chargeType || '').toUpperCase() === 'RATE') return (v * 100).toFixed(2) + '%'
return String(v)
}
function scopeText(row) {
const parts = []
if (row.countryCode) parts.push(row.countryCode)
if (row.blockTitle) parts.push(row.blockTitle)
return parts.join(' / ') || '全部'
}
function canSelect(row) {
if (row?.scId == null) return false
return !excludeSet.value.has(String(row.scId))
}
const filteredRows = computed(() => {
const text = String(keyword.value || '').trim().toLowerCase()
const list = rows.value || []
if (!text) return list
return list.filter(row => {
const bag = [
row.scName,
chargeTypeLabel(row.chargeType),
row.chargeType,
row.currency,
row.countryCode,
row.blockTitle,
row.scNote,
row.chargeValue
].map(v => String(v ?? '').toLowerCase())
return bag.some(item => item.includes(text))
})
})
const picked = computed(() => {
const ids = pickedIds.value
return (rows.value || []).filter(row => row?.scId != null && ids.has(String(row.scId)) && canSelect(row))
})
function onSelectionChange(selection) {
if (syncing.value) return
const visibleIds = new Set((filteredRows.value || []).map(row => String(row.scId)))
const next = new Set(pickedIds.value)
for (const id of visibleIds) {
next.delete(id)
}
for (const row of selection || []) {
if (row?.scId != null && canSelect(row)) next.add(String(row.scId))
}
pickedIds.value = next
}
function restoreSelection() {
const table = tableRef.value
if (!table) return
syncing.value = true
table.clearSelection()
for (const row of filteredRows.value || []) {
if (row?.scId != null && pickedIds.value.has(String(row.scId))) {
table.toggleRowSelection(row, true)
}
}
nextTick(() => {
syncing.value = false
})
}
async function loadRows() {
loading.value = true
try {
const res = await listSurchargeOptions({ ssCode: 'on' })
rows.value = res.data || []
} catch (e) {
rows.value = []
} finally {
loading.value = false
nextTick(() => restoreSelection())
}
}
function onOpened() {
keyword.value = ''
pickedIds.value = new Set()
nextTick(() => {
tableRef.value?.clearSelection?.()
})
if (!rows.value.length) {
loadRows()
}
}
function submit() {
const list = picked.value
if (!list.length) {
proxy.$modal.msgWarning('请先勾选要添加的附加费')
return
}
emit('confirm', list)
visible.value = false
}
watch(keyword, () => {
nextTick(() => restoreSelection())
})
watch(() => props.modelValue, v => {
if (v && !rows.value.length) loadRows()
})
</script>
<style scoped>
.picker-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.picker-search {
width: 360px;
}
.picker-meta {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.picker-hint {
margin-top: 10px;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.5;
}
</style>
@@ -87,7 +87,7 @@
</div>
<el-drawer v-model="detailVisible" :title="detailTitle" size="78%" destroy-on-close append-to-body>
<pay-config-detail :product="detailProduct" :editable="false" />
<pay-config-detail :product="detailProduct" :editable="false" :bill-select="true" />
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
@@ -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 {
+109 -7
View File
@@ -21,10 +21,19 @@
<span>{{ cardTitle }}</span>
<span class="header-tip">{{ headerTip }}</span>
</div>
<div class="header-actions">
<el-button
type="danger"
plain
icon="Delete"
:disabled="!selectedProducts.length"
@click="removeSelectedProducts"
>批量删除</el-button>
<el-button type="primary" plain icon="Upload" @click="openImport" v-hasPermi="['price:label:import', 'price:label:add']">
导入价卡
</el-button>
</div>
</div>
</template>
<el-alert
@@ -36,7 +45,18 @@
class="meta-alert"
/>
<el-table v-loading="loadingProducts" :data="productRows" border size="small" max-height="420" empty-text="请点击右上角「导入价卡」">
<el-table
ref="productTableRef"
v-loading="loadingProducts"
:data="productRows"
border
size="small"
max-height="420"
row-key="rowKey"
empty-text="请点击右上角「导入价卡」"
@selection-change="onProductSelect"
>
<el-table-column type="selection" width="48" align="center" />
<el-table-column prop="productName" label="产品" min-width="140" show-overflow-tooltip />
<el-table-column prop="sheetName" label="报价工作表" min-width="140" show-overflow-tooltip />
<el-table-column prop="countryScope" label="国家/口岸" min-width="110" show-overflow-tooltip />
@@ -54,9 +74,10 @@
<span v-else>新记录</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center" fixed="right">
<el-table-column label="操作" width="140" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" icon="View" @click="openDetail(row)">查看</el-button>
<el-button link type="primary" icon="Edit" @click="openDetail(row)">编辑</el-button>
<el-button link type="danger" icon="Delete" @click="removeProduct(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -79,6 +100,7 @@
<pay-config-detail
:product="detailProduct"
:editable="true"
:bill-select="true"
:saving-row-key="savingRowKey"
@save-row="saveDetailRow"
/>
@@ -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;
}
@@ -30,11 +30,11 @@
<el-table-column :label="colExp" min-width="170">
<template #default="{ row }">{{ row.expireTime || '-' }}</template>
</el-table-column>
<el-table-column :label="colStatus" width="100" align="center">
<!-- <el-table-column :label="colStatus" width="100" align="center">
<template #default="{ row }">
<el-tag :type="isDeletedVersion(row) ? 'danger' : versionStatusTag(row.status)" size="small">{{ statusText(row) }}</el-tag>
</template>
</el-table-column>
</el-table-column> -->
<el-table-column :label="colCreate" min-width="170" align="center">
<template #default="{ row }">{{ row.createTime || '-' }}</template>
</el-table-column>