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

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 BigDecimal weightMax;
private Integer sortNo; 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; import com.bms.price.domain.PayWeightBand;
/** /**
* 用渠道上的应付产品,按订单重量、尺寸、邮编和是否退件计算应付。 * 应付(AP)计费:用渠道应付产品按重量、尺寸、邮编和是否退件计算。
* 打单应收请用 {@link LabelArCostCalculator},两者独立,互不影响。
* 重量段价格默认是该段的一票价格;单位写明每公斤时才乘重量。 * 重量段价格默认是该段的一票价格;单位写明每公斤时才乘重量。
*/ */
public final class PayCostCalculator public final class PayCostCalculator
@@ -461,6 +461,8 @@ public class PayProductServiceImpl implements IPayProductService
{ {
product.setExtraLines(new ArrayList<>()); product.setExtraLines(new ArrayList<>());
} }
// 计数前先按「是否计费」过滤,与落库行数一致
retainBillableSurcharges(product);
product.setWeightCount(product.getWeightBands().size()); product.setWeightCount(product.getWeightBands().size());
product.setSurchargeCount(product.getSurcharges().size()); product.setSurchargeCount(product.getSurcharges().size());
product.setExtraCount(product.getExtraLines().size()); product.setExtraCount(product.getExtraLines().size());
@@ -1529,6 +1531,7 @@ public class PayProductServiceImpl implements IPayProductService
private void insertSurcharges(PayProduct product) private void insertSurcharges(PayProduct product)
{ {
retainBillableSurcharges(product);
List<PaySurcharge> rows = product.getSurcharges(); List<PaySurcharge> rows = product.getSurcharges();
if (rows == null || rows.isEmpty()) 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。 * 按附加费名称映射 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.PrcQuoteFee;
import com.bms.price.domain.PrcSurchargeRel; import com.bms.price.domain.PrcSurchargeRel;
import com.bms.price.domain.PrcZone; 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.mapper.PrcZoneDetailMapper;
import com.bms.price.domain.vo.TmsPriceBillRequest; import com.bms.price.domain.vo.TmsPriceBillRequest;
import com.bms.price.domain.vo.TmsPriceQuoteFeeLine; import com.bms.price.domain.vo.TmsPriceQuoteFeeLine;
@@ -209,7 +209,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
return result; return result;
} }
/** LABEL 费用已由 PayCostCalculator 算完,不再叠加销售附加费。 */ /** LABEL 费用已由 LabelArCostCalculator 算完,不再叠加销售附加费。 */
private void applySalesSurchargesIfNeeded(TmsPriceQuoteResult result) private void applySalesSurchargesIfNeeded(TmsPriceQuoteResult result)
{ {
if (result == null || SRC_LABEL.equals(result.getMatchSource())) 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, private TmsPriceQuoteResult doLabelQuote(TmsPriceQuoteRequest request, PrcCustomerPriceSet bind,
PrcPriceSet set, String productCode, String productName, String countryCode, String countryName, PrcPriceSet set, String productCode, String productName, String countryCode, String countryName,
@@ -707,7 +707,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
height = billReq.getPkgHeight(); height = billReq.getPkgHeight();
orderStatus = billReq.getOrderStatus(); 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); request.getDestPostcode(), bizDate, orderStatus);
if (cost == null || cost.getFees() == null || cost.getFees().isEmpty()) if (cost == null || cost.getFees() == null || cost.getFees().isEmpty())
{ {
@@ -75,12 +75,22 @@
/> />
</el-tab-pane> </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"> <div class="pay-edit-bar">
<el-input v-model="pane.feeKeyword" clearable placeholder="筛选附加费用、地区、价格、货币或费用解释" class="pay-filter" @input="pane.feePageNo = 1" /> <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> </div>
<el-table :data="pageFees(pane)" border size="small" empty-text="没有附加费"> <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> <el-table-column prop="feeName" label="附加费用" min-width="180" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<el-select <el-select
@@ -227,6 +237,12 @@
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
</section> </section>
<pay-surcharge-picker-dialog
v-model="feePickerVisible"
:exclude-sc-ids="feePickerExcludeIds"
@confirm="onFeePickerConfirm"
/>
</div> </div>
</template> </template>
@@ -235,6 +251,7 @@
import { h, onMounted, watch } from 'vue' import { h, onMounted, watch } from 'vue'
import { ElInput } from 'element-plus' import { ElInput } from 'element-plus'
import { listSurchargeOptions } from '@/api/price/surcharge' import { listSurchargeOptions } from '@/api/price/surcharge'
import PaySurchargePickerDialog from './PaySurchargePickerDialog.vue'
const props = defineProps({ const props = defineProps({
product: { product: {
@@ -249,6 +266,11 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false default: false
}, },
/** 打单套餐:附加费表显示「是否计费」勾选,仅勾选行入库 */
billSelect: {
type: Boolean,
default: false
},
savingRowKey: { savingRowKey: {
type: [String, Number], type: [String, Number],
default: '' 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']) return filterRows(pane.item.surcharges || [], pane.feeKeyword, ['feeName', 'zoneName', 'chargeText', 'amount', 'currency', 'priceUnit', 'feeDesc', 'calcRule', 'calcParam', 'postcodeParam'])
} }
function addFee(pane) { function feeTabLabel(pane) {
if (!pane.item.surcharges) pane.item.surcharges = [] const fees = pane.item.surcharges || []
const currency = String(pane.item.currency || 'EUR').trim().toUpperCase() || 'EUR' if (!props.billSelect) return '附加费(' + fees.length + ')'
const row = { const n = fees.filter(f => f && f.needBill === true).length
feeName: '', return '附加费(已选 ' + n + '/' + fees.length + ')'
scId: undefined, }
zoneName: '',
chargeText: '', function setFeesBill(pane, value) {
amount: '', const fees = pane.item.surcharges || []
priceUnit: currency + '/ITEM', for (const fee of fees) {
currency, if (fee) fee.needBill = !!value
feeDesc: '', }
calcRule: 'MANUAL', }
calcParam: ''
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) { function removeRow(list, row) {
@@ -643,9 +734,14 @@ function statusText(status) {
.pay-edit-bar { .pay-edit-bar {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 8px; gap: 8px;
margin-bottom: 8px; margin-bottom: 8px;
} }
.pay-bill-tip {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.pay-hint { .pay-hint {
margin: 0 0 8px; margin: 0 0 8px;
color: var(--el-text-color-secondary); 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> </div>
<el-drawer v-model="detailVisible" :title="detailTitle" size="78%" destroy-on-close append-to-body> <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> <template #footer>
<el-button @click="detailVisible = false">关闭</el-button> <el-button @click="detailVisible = false">关闭</el-button>
</template> </template>
@@ -187,7 +187,9 @@ async function onFile(uploadFile) {
const data = res.data || {} const data = res.data || {}
data.products = (data.products || []).map((item, index) => ({ data.products = (data.products || []).map((item, index) => ({
...item, ...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 preview.value = data
const picked = data.products.filter(row => canSelect(row)) const picked = data.products.filter(row => canSelect(row))
@@ -310,12 +312,17 @@ async function submit(payload = {}) {
const productsPayload = (payload.products && payload.products.length) const productsPayload = (payload.products && payload.products.length)
? payload.products ? payload.products
: selected.value.map(row => ({ ...row, productType: 'AR' })) : 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({ const res = await saveLabelImport({
priceSetId: payload.priceSetId || null, priceSetId: payload.priceSetId || null,
priceSetName: String(payload.priceSetName || '').trim(), priceSetName: String(payload.priceSetName || '').trim(),
priceSetNote: payload.priceSetNote || '', priceSetNote: payload.priceSetNote || '',
overwrite: payload.overwrite != null ? payload.overwrite : overwrite.value, overwrite: payload.overwrite != null ? payload.overwrite : overwrite.value,
products: productsPayload products: filteredProducts
}) })
const data = res.data || {} const data = res.data || {}
return { return {
+112 -10
View File
@@ -21,9 +21,18 @@
<span>{{ cardTitle }}</span> <span>{{ cardTitle }}</span>
<span class="header-tip">{{ headerTip }}</span> <span class="header-tip">{{ headerTip }}</span>
</div> </div>
<el-button type="primary" plain icon="Upload" @click="openImport" v-hasPermi="['price:label:import', 'price:label:add']"> <div class="header-actions">
导入价卡 <el-button
</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> </div>
</template> </template>
@@ -36,7 +45,18 @@
class="meta-alert" 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="productName" label="产品" min-width="140" show-overflow-tooltip />
<el-table-column prop="sheetName" 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 /> <el-table-column prop="countryScope" label="国家/口岸" min-width="110" show-overflow-tooltip />
@@ -54,9 +74,10 @@
<span v-else>新记录</span> <span v-else>新记录</span>
</template> </template>
</el-table-column> </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 }"> <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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -79,6 +100,7 @@
<pay-config-detail <pay-config-detail
:product="detailProduct" :product="detailProduct"
:editable="true" :editable="true"
:bill-select="true"
:saving-row-key="savingRowKey" :saving-row-key="savingRowKey"
@save-row="saveDetailRow" @save-row="saveDetailRow"
/> />
@@ -104,6 +126,8 @@ const form = reactive({
basic: createEmptyBasicInfo() basic: createEmptyBasicInfo()
}) })
const productRows = ref([]) const productRows = ref([])
const productTableRef = ref()
const selectedProducts = ref([])
const importMeta = reactive({ const importMeta = reactive({
sourceFile: '', sourceFile: '',
supplierName: '', supplierName: '',
@@ -141,7 +165,7 @@ const pageBanner = computed(() => {
const headerTip = computed(() => { const headerTip = computed(() => {
if (editVersionMode.value) return '可重新导入或修改明细;完成后覆盖本版本,不新增版本号' if (editVersionMode.value) return '可重新导入或修改明细;完成后覆盖本版本,不新增版本号'
if (newVersionMode.value) return '导入后生成待生效新版本;生效时间请回详情页设置' if (newVersionMode.value) return '导入后生成待生效新版本;生效时间请回详情页设置'
return '打单价卡与应付独立;解析后回填到此表,查看明细后可在行内保存修改' return '打单价卡与应付独立;解析后回填到此表,可删除不需要的产品,编辑明细后点完成保存'
}) })
function goBack() { 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.sourceFile = details[0]?.sourceFile || ''
importMeta.supplierName = details[0]?.supplierName || '' importMeta.supplierName = details[0]?.supplierName || ''
importMeta.overwrite = true importMeta.overwrite = true
@@ -209,8 +235,71 @@ async function openImport() {
importRef.value?.open() 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) { function onImportConfirm(payload) {
productRows.value = payload?.products || [] // 价卡解析行默认不计费,需手动勾选
productRows.value = markSurchargeBillFlags(payload?.products || [], false)
selectedProducts.value = []
importMeta.sourceFile = payload?.sourceFile || '' importMeta.sourceFile = payload?.sourceFile || ''
importMeta.supplierName = payload?.supplierName || '' importMeta.supplierName = payload?.supplierName || ''
importMeta.effectiveDate = payload?.effectiveDate || '' importMeta.effectiveDate = payload?.effectiveDate || ''
@@ -226,6 +315,10 @@ function openDetail(row) {
} }
function onDetailClosed() { function onDetailClosed() {
// 关闭抽屉时回写勾选结果(不依赖行内「保存」)
if (detailProduct.value && detailRowKey.value) {
applyEditedProduct()
}
detailProduct.value = null detailProduct.value = null
detailRowKey.value = '' detailRowKey.value = ''
savingRowKey.value = '' savingRowKey.value = ''
@@ -303,6 +396,10 @@ async function finish() {
proxy.$modal.msgWarning('请先导入并确认价卡产品') proxy.$modal.msgWarning('请先导入并确认价卡产品')
return return
} }
// 抽屉未关闭时先回写勾选,避免丢失
if (detailVisible.value && detailProduct.value && detailRowKey.value) {
applyEditedProduct()
}
const tip = editVersionMode.value const tip = editVersionMode.value
? `将覆盖版本 V${versionNo.value} 的 ${productRows.value.length} 个产品,确认继续?` ? `将覆盖版本 V${versionNo.value} 的 ${productRows.value.length} 个产品,确认继续?`
: newVersionMode.value : newVersionMode.value
@@ -317,7 +414,7 @@ async function finish() {
try { try {
const payload = { const payload = {
overwrite: editVersionMode.value ? true : importMeta.overwrite, overwrite: editVersionMode.value ? true : importMeta.overwrite,
products: productRows.value products: withBillableSurcharges(productRows.value)
} }
if (editVersionMode.value) { if (editVersionMode.value) {
payload.priceSetId = priceSetId.value payload.priceSetId = priceSetId.value
@@ -367,6 +464,11 @@ watch([priceSetId, versionNo, () => route.query.editVersion, () => route.query.n
color: #909399; color: #909399;
font-weight: normal; font-weight: normal;
} }
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.meta-alert { .meta-alert {
margin-bottom: 12px; margin-bottom: 12px;
} }
@@ -30,11 +30,11 @@
<el-table-column :label="colExp" min-width="170"> <el-table-column :label="colExp" min-width="170">
<template #default="{ row }">{{ row.expireTime || '-' }}</template> <template #default="{ row }">{{ row.expireTime || '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column :label="colStatus" width="100" align="center"> <!-- <el-table-column :label="colStatus" width="100" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="isDeletedVersion(row) ? 'danger' : versionStatusTag(row.status)" size="small">{{ statusText(row) }}</el-tag> <el-tag :type="isDeletedVersion(row) ? 'danger' : versionStatusTag(row.status)" size="small">{{ statusText(row) }}</el-tag>
</template> </template>
</el-table-column> </el-table-column> -->
<el-table-column :label="colCreate" min-width="170" align="center"> <el-table-column :label="colCreate" min-width="170" align="center">
<template #default="{ row }">{{ row.createTime || '-' }}</template> <template #default="{ row }">{{ row.createTime || '-' }}</template>
</el-table-column> </el-table-column>