应付配置的附加费配置完善

This commit is contained in:
liuminghui
2026-11-13 11:57:19 +08:00
parent 6675808dfc
commit 3a104d2d8c
20 changed files with 1369 additions and 310 deletions
@@ -107,20 +107,20 @@ public class PayProductController extends BaseController
payProductService.writeSource(productId, response.getOutputStream());
}
/** 当前版本的附加费计费条件。同一版本只有一份。 */
/** 产品上的附加费计费条件。不按价格版本拆开。 */
@PreAuthorize("@ss.hasAnyPermi('price:pay:query,price:pay:edit')")
@GetMapping("/surchargeRule")
public AjaxResult surchargeRule(@RequestParam Long productId, @RequestParam Long termId)
public AjaxResult surchargeRule(@RequestParam Long productId, @RequestParam(required = false) Long termId)
{
return success(paySurchargeRuleService.list(productId, termId));
}
/** 上一版条件,复制到当前页后再保存。 */
/** 这个产品上的附加费配置操作记录。 */
@PreAuthorize("@ss.hasAnyPermi('price:pay:query,price:pay:edit')")
@GetMapping("/surchargeRule/previous")
public AjaxResult previousSurchargeRule(@RequestParam Long productId)
@GetMapping("/surchargeRule/log")
public AjaxResult surchargeRuleLog(@RequestParam Long productId, @RequestParam(required = false) Long termId)
{
return success(paySurchargeRuleService.previous(productId));
return success(paySurchargeRuleService.logs(productId, termId));
}
@PreAuthorize("@ss.hasPermi('price:pay:edit')")
@@ -0,0 +1,118 @@
package com.bms.price.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/** 应付附加费配置在某一产品版本上的操作记录。 */
public class PaySurchargeRuleLog
{
private Long logId;
private Long productId;
private Long termId;
private Integer versionNo;
/** 1新增 2修改 3删除 */
private String operationType;
private String ruleKey;
private String changeContent;
private String createBy;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
public Long getLogId()
{
return logId;
}
public void setLogId(Long logId)
{
this.logId = logId;
}
public Long getProductId()
{
return productId;
}
public void setProductId(Long productId)
{
this.productId = productId;
}
public Long getTermId()
{
return termId;
}
public void setTermId(Long termId)
{
this.termId = termId;
}
public Integer getVersionNo()
{
return versionNo;
}
public void setVersionNo(Integer versionNo)
{
this.versionNo = versionNo;
}
public String getOperationType()
{
return operationType;
}
public void setOperationType(String operationType)
{
this.operationType = operationType;
}
public String getRuleKey()
{
return ruleKey;
}
public void setRuleKey(String ruleKey)
{
this.ruleKey = ruleKey;
}
public String getChangeContent()
{
return changeContent;
}
public void setChangeContent(String changeContent)
{
this.changeContent = changeContent;
}
public String getCreateBy()
{
return createBy;
}
public void setCreateBy(String createBy)
{
this.createBy = createBy;
}
public Date getCreateTime()
{
return createTime;
}
public void setCreateTime(Date createTime)
{
this.createTime = createTime;
}
}
@@ -0,0 +1,14 @@
package com.bms.price.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.bms.price.domain.PaySurchargeRuleLog;
public interface PaySurchargeRuleLogMapper
{
List<PaySurchargeRuleLog> selectByTerm(@Param("productId") Long productId, @Param("termId") Long termId);
List<PaySurchargeRuleLog> selectByProductIds(@Param("productIds") List<Long> productIds);
int insertBatch(@Param("list") List<PaySurchargeRuleLog> list);
}
@@ -4,15 +4,16 @@ import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.bms.price.domain.PaySurchargeRule;
/** 应付附加费计费条件。一个产品版本一份。 */
/** 应付附加费计费条件。一份跟产品走。 */
public interface PaySurchargeRuleMapper
{
List<PaySurchargeRule> selectByProduct(@Param("productId") Long productId, @Param("termId") Long termId);
List<PaySurchargeRule> selectByProductId(@Param("productId") Long productId);
int deleteByTerm(@Param("productId") Long productId, @Param("termId") Long termId);
int insertBatch(@Param("list") List<PaySurchargeRule> rules);
int deleteByProductIds(@Param("productIds") List<Long> productIds);
/** 同一产品字典下,比当前版本号小的最近一版 */
PaySurchargeRule selectPreviousVersion(@Param("productId") Long productId);
int insertBatch(@Param("list") List<PaySurchargeRule> rules);
}
@@ -101,6 +101,17 @@ public final class PayCostCalculator
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,
boolean postcodeOn, boolean zoneOn)
{
return calculate(product, weight, length, width, height, postcode, zoneName, bizDate, orderStatus, remoteHits,
postcodeOn, zoneOn, null, null);
}
/**
* 计算一票尾程应付。countryCode、countryName 用来核对配置页上写了国家的命中邮编和命中分区。
*/
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,
boolean postcodeOn, boolean zoneOn, String countryCode, String countryName)
{
PayOrderCostResult result = new PayOrderCostResult();
result.setFees(new ArrayList<>());
@@ -158,7 +169,7 @@ public final class PayCostCalculator
result.getFees().add(freightLine);
result.setCurrency(currency);
addSurcharges(product, weight, length, width, height, postcode, zoneName, bizDate, orderStatus, freight, currency,
band.getSectionName(), remoteHits, postcodeOn, zoneOn, result);
band.getSectionName(), remoteHits, postcodeOn, zoneOn, countryCode, countryName, result);
BigDecimal total = BigDecimal.ZERO;
for (PayCostLine fee : result.getFees())
{
@@ -278,7 +289,8 @@ public final class PayCostCalculator
*/
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, boolean postcodeOn, boolean zoneOn, PayOrderCostResult result)
String freightSection, List<PayRemoteBook.Hit> remoteHits, boolean postcodeOn, boolean zoneOn,
String countryCode, String countryName, PayOrderCostResult result)
{
if (product.getSurcharges() == null)
{
@@ -292,7 +304,9 @@ public final class PayCostCalculator
continue;
}
String rule = fee.getCalcRule() == null || fee.getCalcRule().isBlank() ? PaySurchargeRules.MANUAL : fee.getCalcRule();
if (remoteHits != null && remoteFee(fee))
List<Map<String, Object>> postcodeRules = prefixRules(product, PaySurchargeRules.POSTCODE);
List<Map<String, Object>> zoneRules = prefixRules(product, PaySurchargeRules.ZONE);
if (remoteHits != null && remoteFee(fee) && postcodeRules.isEmpty())
{
String name = blank(fee.getFeeName());
boolean added = false;
@@ -332,8 +346,9 @@ public final class PayCostCalculator
result.getSkipped().add(blank(fee.getFeeName()) + ":未打开命中分区");
continue;
}
boolean configuredZone = PaySurchargeRules.ZONE.equals(rule) && !zoneRules.isEmpty();
boolean postcodeDriven = PaySurchargeRules.POSTCODE.equals(rule) || !blank(fee.getPostcodeParam()).isEmpty();
if (!postcodeDriven && zoneName != null && !zoneName.isBlank() && fee.getZoneName() != null && !fee.getZoneName().isBlank()
if (!configuredZone && !postcodeDriven && zoneName != null && !zoneName.isBlank() && fee.getZoneName() != null && !fee.getZoneName().isBlank()
&& !PayZoneMatcher.sameZone(fee.getZoneName(), zoneName))
{
continue;
@@ -341,22 +356,45 @@ public final class PayCostCalculator
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())
if (!postcodeRules.isEmpty())
{
result.getSkipped().add(name + ":订单没有邮编");
addConfiguredZoneFee(result, fee, weight, currency, postcode, countryCode, countryName, postcodeRules, "邮编 ");
}
else if (PaySurchargeRules.matchesPostcode(param, postcode) && weightAtLeast(fee, weight))
else
{
addFlat(result.getFees(), fee, currency, "邮编 " + postcode.trim());
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))
if (!zoneRules.isEmpty())
{
addFlat(result.getFees(), fee, currency, "地区 " + zoneName.replace('\n', ' '));
String hit = PaySurchargeRules.matchedZone(zoneRules, postcode, countryCode, countryName);
boolean already = result.getFees().stream().anyMatch(line -> name.equals(line.getFeeName()));
if (postcode == null || postcode.isBlank())
{
result.getSkipped().add(name + ":订单没有邮编");
}
else if (!already && hit != null && PayZoneMatcher.sameZone(fee.getZoneName(), hit) && weightIn(fee, weight))
{
addFlat(result.getFees(), fee, currency, "地区 " + hit.replace('\n', ' '));
}
}
else
{
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))
@@ -566,6 +604,38 @@ public final class PayCostCalculator
return false;
}
/** 配置页写了邮编前缀的命中邮编或命中分区。命中后只收同一分区的那一档附加费。 */
private static void addConfiguredZoneFee(PayOrderCostResult result, PaySurcharge fee, BigDecimal weight, String currency, String postcode,
String countryCode, String countryName, List<Map<String, Object>> rules, String remark)
{
String name = blank(fee.getFeeName());
if (postcode == null || postcode.isBlank())
{
result.getSkipped().add(name + ":订单没有邮编");
return;
}
String hit = PaySurchargeRules.matchedZone(rules, postcode, countryCode, countryName);
if (hit != null && PayZoneMatcher.sameZone(fee.getZoneName(), hit) && weightAtLeast(fee, weight))
{
addFlat(result.getFees(), fee, currency, remark + postcode.trim());
}
}
/** 只留下写了邮编前缀的配置。没写前缀的旧开关仍走原来的邮编配置。 */
private static List<Map<String, Object>> prefixRules(PayProduct product, String calcRule)
{
List<Map<String, Object>> rules = new ArrayList<>();
for (Map<String, Object> detail : ruleDetails(product, calcRule))
{
Object postcodes = detail.get("postcodes");
if (postcodes != null && !String.valueOf(postcodes).isBlank())
{
rules.add(detail);
}
}
return rules;
}
private static List<Map<String, Object>> ruleDetails(PayProduct product, String calcRule)
{
List<Map<String, Object>> details = new ArrayList<>();
@@ -36,6 +36,8 @@ public final class PaySurchargeRules
private static final Pattern RANGE = Pattern.compile("([A-Z]{1,2})(\\d{1,2})\\s*-\\s*(\\d{1,2})");
private static final Pattern DIGIT_RANGE = Pattern.compile("(\\d{2,10})\\s*-\\s*(\\d{2,10})");
private static final Pattern DISTRICT = Pattern.compile("([A-Z]{1,2})(\\d{1,2})");
private static final Pattern AREA = Pattern.compile("^[A-Z]{1,2}$");
@@ -147,6 +149,132 @@ public final class PaySurchargeRules
return false;
}
/**
* 配置页上的邮编。逗号或换行分隔。短码按前缀,长码按完整邮编,数字区间和英国邮编区间都认。
* 返回命中的最长一段长度,没有命中时为 -1。
*/
public static int configuredMatchLength(String param, String postcode)
{
if (param == null || param.isBlank() || postcode == null || postcode.isBlank())
{
return -1;
}
int best = -1;
for (String token : param.split("[,,、;;\\n\\r]+"))
{
String value = token.trim();
if (value.isEmpty())
{
continue;
}
int length = tokenLength(value, postcode);
if (length > best)
{
best = length;
}
}
return best;
}
/**
* 在命中邮编或命中分区的配置里,选出和订单邮编最贴合的分区。同一长度对上多个分区时返回 null。
*/
public static String matchedZone(java.util.List<java.util.Map<String, Object>> details, String postcode,
String countryCode, String countryName)
{
if (details == null || details.isEmpty() || postcode == null || postcode.isBlank())
{
return null;
}
String bestZone = null;
int best = -1;
boolean tie = false;
for (java.util.Map<String, Object> detail : details)
{
if (detail == null || !countryOk(detail, countryCode, countryName))
{
continue;
}
int length = configuredMatchLength(text(detail.get("postcodes")), postcode);
if (length < 0)
{
continue;
}
String zone = text(detail.get("zoneName"));
if (length > best)
{
best = length;
bestZone = zone;
tie = false;
}
else if (length == best && !PayZoneMatcher.sameZone(zone, bestZone))
{
tie = true;
}
}
return tie ? null : bestZone;
}
private static int tokenLength(String token, String postcode)
{
Matcher range = DIGIT_RANGE.matcher(token);
if (range.matches())
{
String from = range.group(1);
String to = range.group(2);
String digits = postcode.replaceAll("\\D", "");
if (from.length() != to.length() || digits.length() != from.length())
{
return -1;
}
long low = Long.parseLong(from);
long high = Long.parseLong(to);
if (low > high)
{
long swap = low;
low = high;
high = swap;
}
long current = Long.parseLong(digits);
return current >= low && current <= high ? from.length() : -1;
}
if (!matchesPostcode(token, postcode))
{
return -1;
}
String compact = token.toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", "");
return compact.isEmpty() ? -1 : compact.length();
}
/** 配置没写国家时不限制。写了国家时,订单目的国二字码或名称要对上其中一项。 */
private static boolean countryOk(java.util.Map<String, Object> detail, String countryCode, String countryName)
{
String raw = text(detail.get("country"));
if (raw == null)
{
return true;
}
boolean listed = false;
for (String token : raw.split("[,,、;;\\s]+"))
{
String value = token.trim();
if (value.isEmpty())
{
continue;
}
listed = true;
if (countryCode != null && countryCode.equalsIgnoreCase(value))
{
return true;
}
if (countryName != null && countryName.trim().equals(value))
{
return true;
}
}
return !listed;
}
/** 从“07邮编开头”“35、38邮编开头”取出数字前缀。没有邮编字样时不猜。 */
public static String remotePrefixes(String text)
{
@@ -233,7 +361,7 @@ public final class PaySurchargeRules
return checks.contains(Boolean.TRUE);
}
/** 每票都收。写了费用名的规则只约束这一项;带生效月日的规则只在这段日期内收取。 */
/** 每票都收。配置了条件之后,只有写明费用项的规则才收取这一项;带生效月日的只在这段日期内收取。 */
public static boolean allowsAlways(List<java.util.Map<String, Object>> details, String feeName, java.util.Date bizDate)
{
if (details == null || details.isEmpty())
@@ -241,8 +369,11 @@ public final class PaySurchargeRules
return true;
}
String name = feeName == null ? "" : feeName.trim();
List<java.util.Map<String, Object>> named = new ArrayList<>();
List<java.util.Map<String, Object>> shared = new ArrayList<>();
if (name.isEmpty())
{
return false;
}
java.time.LocalDate day = bizDate == null ? null : bizDate.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate();
for (java.util.Map<String, Object> detail : details)
{
if (detail == null)
@@ -250,23 +381,10 @@ public final class PaySurchargeRules
continue;
}
String ruleName = detail.get("feeName") == null ? "" : String.valueOf(detail.get("feeName")).trim();
if (ruleName.isEmpty())
if (!ruleName.equals(name))
{
shared.add(detail);
continue;
}
else if (ruleName.equals(name))
{
named.add(detail);
}
}
List<java.util.Map<String, Object>> mine = named.isEmpty() ? shared : named;
if (mine.isEmpty())
{
return shared.isEmpty();
}
java.time.LocalDate day = bizDate == null ? null : bizDate.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate();
for (java.util.Map<String, Object> detail : mine)
{
if (!hasPeriod(detail))
{
return true;
@@ -46,6 +46,10 @@ public final class PayTexts
private static final Pattern MONEY = Pattern.compile(
"(?i)(\\d+(?:\\.\\d+)?)\\s*(EUR|GBP|USD|RMB|CNY|欧元|英镑|人民币)?");
/** GBP 23.00 这种币种写在数字前面 */
private static final Pattern MONEY_BEFORE = Pattern.compile(
"(?i)(EUR|GBP|USD|RMB|CNY|欧元|英镑|人民币)\\s*(\\d+(?:\\.\\d+)?)");
private static final Pattern PERCENT = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*%");
private PayTexts()
@@ -193,6 +197,11 @@ public final class PayTexts
return found;
}
}
Matcher ahead = MONEY_BEFORE.matcher(source);
if (ahead.find())
{
return new Money(decimal(ahead.group(2)), currency(ahead.group(1)), unit(source));
}
return found;
}
@@ -828,6 +828,20 @@ public class PayWorkbookParser
private static boolean mentionsHighRisk(PayProduct product)
{
if (product.getServiceNote() != null && product.getServiceNote().contains("高风险"))
{
return true;
}
if (product.getExtraLines() != null)
{
for (PayExtraLine line : product.getExtraLines())
{
if (line.getLineText() != null && line.getLineText().contains("高风险"))
{
return true;
}
}
}
if (product.getSurcharges() == null)
{
return false;
@@ -1172,7 +1186,12 @@ public class PayWorkbookParser
continue;
}
String line = joinRow(grid, row);
// 备注、说明段落不是表格,不写入附加费,也不收进服务说明
// 特别注意、体积重计算方式这类说明不是附加费,也不当作下一张表的标题
if (isNarrative(line))
{
row++;
continue;
}
if (countText(grid, row) <= 2 && line.length() <= 40 && !line.contains("特别申明") && !line.contains("。"))
{
pending = line;
@@ -1305,13 +1324,25 @@ public class PayWorkbookParser
String zone = zoneOf(sub[col], parent[col]).replace('\n', ' ').replaceAll("\\s+", " ").trim();
String standard = price.stripTrailingZeros().toPlainString() + " " + feeUnit(parent[col], weight);
String desc = country.isBlank() ? weight : country + ";" + weight;
addSurcharge(product, feeName, feeName, zone, standard.trim(), desc, "");
PaySurcharge fee = product.getSurcharges().get(product.getSurcharges().size() - 1);
applyFeeRateRule(fee, feeName, zone, weight, includedKg);
if (addSurcharge(product, feeName, feeName, zone, standard.trim(), desc, ""))
{
PaySurcharge fee = product.getSurcharges().get(product.getSurcharges().size() - 1);
applyFeeRateRule(fee, feeName, zone, weight, includedKg);
}
}
if (!priced && joinRow(grid, data).length() > 40)
if (!priced)
{
break;
String line = joinRow(grid, data);
if (line.contains("特别注意") || line.contains("体积重") || line.contains("计算方式"))
{
addConfig(product, line);
data++;
break;
}
if (line.length() > 40)
{
break;
}
}
data++;
}
@@ -1492,9 +1523,19 @@ public class PayWorkbookParser
band.setSortNo(product.getWeightBands().size() + 1);
product.getWeightBands().add(band);
}
if (!priced && joinRow(grid, data).length() > 40)
if (!priced)
{
break;
String line = joinRow(grid, data);
if (line.contains("特别注意") || line.contains("体积重") || line.contains("计算方式"))
{
addConfig(product, line);
data++;
break;
}
if (line.length() > 40)
{
break;
}
}
data++;
}
@@ -1867,9 +1908,8 @@ public class PayWorkbookParser
markManual(product);
}
}
else if (!sentence.isBlank())
else if (!sentence.isBlank() && addSurcharge(product, section, name, "", sentence, sentence, ""))
{
addSurcharge(product, section, name, "", sentence, sentence, "");
markManual(product);
keepSingleEuro(product.getSurcharges().get(product.getSurcharges().size() - 1), sentence);
}
@@ -2156,7 +2196,10 @@ public class PayWorkbookParser
}
desc.append(String.join("、", entry.getValue())).append(" ").append(entry.getKey());
}
addSurcharge(product, section, "Cubic Capacity", "", String.join("、", factors.keySet()), desc.toString(), "");
if (!addSurcharge(product, section, "Cubic Capacity", "", String.join("、", factors.keySet()), desc.toString(), ""))
{
return;
}
PaySurcharge fee = product.getSurcharges().get(product.getSurcharges().size() - 1);
fee.setChargeText(String.join("、", factors.keySet()));
fee.setAmount(null);
@@ -3277,11 +3320,21 @@ public class PayWorkbookParser
addSurcharge(product, cleanTitle(pending), blankTo(name, "附加费"), "", standard, desc, "");
}
private void addSurcharge(PayProduct product, String section, String name, String zone, String standard, String desc, String remark)
/** 没有价格、币种、单位的说明不记成附加费。报价里的「不含燃油」仍然保留。 */
private boolean addSurcharge(PayProduct product, String section, String name, String zone, String standard, String desc, String remark)
{
String combined = (standard + " " + desc + " " + remark).trim();
Period period = PayTexts.period(combined, 2026);
Money money = PayTexts.money(blankTo(standard, combined), period);
// 收费标准自己要能认出价格、币种或单位。说明里的 5000、10KG 不能拿来凑成一条附加费
Money money = PayTexts.money(standard, period);
if (!hasCharge(money, standard) && fuelMark(standard).isEmpty() && !"Cubic Capacity".equals(name))
{
if ((name != null && name.contains("高风险")) || (standard != null && standard.contains("高风险")))
{
addConfig(product, (name + " " + standard).trim());
}
return false;
}
PaySurcharge fee = new PaySurcharge();
fee.setSectionName(section);
fee.setFeeName(cut(new StringBuilder(name), 200));
@@ -3309,6 +3362,7 @@ public class PayWorkbookParser
fee.setSortNo(product.getSurcharges().size() + 1);
PaySurchargeRules.apply(fee);
product.getSurcharges().add(fee);
return true;
}
/** 附加费币种为空时取价卡币种,价卡也没有则默认 EUR。 */
@@ -4239,7 +4293,10 @@ public class PayWorkbookParser
{
String feeName = shortCharge(head);
String standard = price.stripTrailingZeros().toPlainString() + " " + feeUnit(head, weight);
addSurcharge(product, section, feeName, zone, standard.trim(), weight, "");
if (!addSurcharge(product, section, feeName, zone, standard.trim(), weight, ""))
{
return;
}
PaySurcharge fee = product.getSurcharges().get(product.getSurcharges().size() - 1);
BigDecimal[] bounds = PayTexts.weightBounds(weight);
if (bounds[0] != null && bounds[1] != null)
@@ -4341,7 +4398,7 @@ public class PayWorkbookParser
private static boolean isLooseSurcharge(ExcelGrid grid, int row)
{
String line = joinRow(grid, row);
if (!line.contains("费") || line.length() > 500)
if (!line.contains("费") || line.length() > 500 || isNarrative(line))
{
return false;
}
@@ -4820,7 +4877,56 @@ public class PayWorkbookParser
return "";
}
String value = title.trim();
return value.length() > 80 ? "" : value;
if (value.length() > 80 || isNarrative(value))
{
return "";
}
return value;
}
/**
* 收费标准要带币种或计价单位。纯说明里的 90cm、5000、10KG 不算价格。
* 单元格里单独一个数字仍然算价格。
*/
private static boolean hasCharge(PayTexts.Money money, String standard)
{
if (money == null)
{
return false;
}
if (money.currency != null && !money.currency.isBlank())
{
return true;
}
if (money.unit != null && !money.unit.isBlank())
{
return true;
}
String text = standard == null ? "" : standard.trim();
if (money.amount != null && text.matches("\\d+(?:\\.\\d+)?"))
{
return true;
}
// 按 31.5kg 收费。体积公式里的 kg/m3 不是计价单位
String upper = text.toUpperCase(Locale.ROOT);
return money.amount != null && text.length() <= 80 && upper.matches("(?s).*\\d(?:\\.\\d+)?\\s*KG(?!/).*")
&& !upper.contains("M3") && !text.contains("立方米");
}
/** 价卡里的说明句。提到费用或除数,也不当成附加费。 */
private static boolean isNarrative(String line)
{
if (line == null || line.isBlank())
{
return false;
}
String text = line.replace('\n', ' ').trim();
if (text.contains("特别注意") || text.contains("特别申明") || text.contains("体积重")
|| text.contains("计算方式") || text.contains("计费说明") || text.contains("计费重量"))
{
return true;
}
return text.length() >= 20 && (text.contains(";") || text.contains("。")) && !isMoneyText(text);
}
private static String blankTo(String value, String fallback)
@@ -1,17 +1,22 @@
package com.bms.price.service;
import java.util.List;
import com.bms.price.domain.PayProduct;
import com.bms.price.domain.PaySurchargeRule;
import com.bms.price.domain.PaySurchargeRuleLog;
import com.bms.price.domain.PaySurchargeRuleSave;
/** 应付附加费计费条件。一个产品版本只保留一份。 */
/** 应付附加费计费条件。一份跟产品走,计算时用当前生效版本上的附加费。 */
public interface IPaySurchargeRuleService
{
List<PaySurchargeRule> list(Long productId, Long termId);
/** 上一版的条件,只读出来供当前页编辑,不直接落库 */
List<PaySurchargeRule> previous(Long productId);
/** 计算时读取的条件。先取产品上的一份,没有再取当前生效版本上的。 */
List<PaySurchargeRule> listForCalc(PayProduct product);
/** 覆盖所选产品当前版本的配置。同一版本不会留下两份。 */
/** 覆盖所选产品的配置。不按价格版本各存一份。 */
void save(PaySurchargeRuleSave command);
/** 这个产品上的附加费配置操作记录。 */
List<PaySurchargeRuleLog> logs(Long productId, Long termId);
}
@@ -110,12 +110,12 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService
Integer orderStatus = bill.getOrderStatus() != null ? bill.getOrderStatus() : (ext == null ? null : ext.getOrderStatus());
List<PayPostcodeGroup> linked = linkedGroups(product);
String zoneName = resolveZone(channel, product, bill.getDestPostcode(), bill.getCountryCode(), bill.getCountryName(), linked);
List<PaySurchargeRule> billingRules = billingRules(product);
product.setBillingRules(billingRules);
applyCurrentSurcharge(channel.getId(), product);
List<PaySurchargeRule> billingRules = product.getBillingRules();
boolean postcodeOn = ruleOn(billingRules, PaySurchargeRules.POSTCODE);
boolean zoneOn = ruleOn(billingRules, PaySurchargeRules.ZONE);
List<PayRemoteBook.Hit> remoteHits = null;
if (postcodeOn)
if (postcodeOn && !hasPrefixRules(billingRules, PaySurchargeRules.POSTCODE))
{
remoteHits = remoteHits(linked, bill.getDestPostcode(), bill.getCountryCode(), bill.getCountryName());
if (remoteHits == null)
@@ -124,7 +124,8 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService
}
}
PayOrderCostResult result = PayCostCalculator.calculate(product, weight, bill.getPkgLength(), bill.getPkgWidth(),
bill.getPkgHeight(), bill.getDestPostcode(), zoneName, bizDate, orderStatus, remoteHits, postcodeOn, zoneOn);
bill.getPkgHeight(), bill.getDestPostcode(), zoneName, bizDate, orderStatus, remoteHits, postcodeOn, zoneOn,
bill.getCountryCode(), bill.getCountryName());
result.setBillId(bill.getBillId());
result.setChannelId(channel.getId());
result.setChannelName(channel.getChannelName());
@@ -138,7 +139,7 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService
saveFees(bill.getBillId(), payBill.getPayBillId(), result.getFees());
String channelName = channel.getChannelName() == null ? channel.getChannelCode() : channel.getChannelName();
String end = product.getTermEnd() == null ? "不限" : day(product.getTermEnd());
result.setMessage("渠道「" + channelName + "」产品编号 " + product.getOriginId() + " 第 " + product.getVersionNo()
result.setMessage("渠道「" + channelName + "」" + product.getProductName() + " 第 " + product.getVersionNo()
+ " 版(" + day(product.getTermStart()) + " 至 " + end + ")," + result.getMessage());
paySettleSupport.stampCalc(bill.getBillId(), product.getSupplierName());
return result;
@@ -176,14 +177,45 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService
* @param product 当前价卡版本
* @return 名称能对上的邮编组,没有时为空列表
*/
private List<PaySurchargeRule> billingRules(PayProduct product)
/** 运费仍按订单日期取版本。附加费条件和附加费明细改用产品当前生效版本。 */
private void applyCurrentSurcharge(String channelId, PayProduct priced)
{
if (product == null || product.getProductId() == null || product.getTermId() == null)
try
{
return new ArrayList<>();
PayProduct current = payProductService.selectPriceForDate(channelId, new Date());
if (current != null && current.getSurcharges() != null)
{
priced.setSurcharges(current.getSurcharges());
}
}
List<PaySurchargeRule> rules = paySurchargeRuleService.list(product.getProductId(), product.getTermId());
return rules == null ? new ArrayList<>() : rules;
catch (ServiceException ignored)
{
// 今天没有生效版本时,附加费明细仍用订单日期命中的那一版
}
List<PaySurchargeRule> rules = paySurchargeRuleService.listForCalc(priced);
priced.setBillingRules(rules == null ? new ArrayList<>() : rules);
}
/** 配置页已经写了邮编前缀时,命中邮编不再去应付邮编配置里查偏远。 */
private static boolean hasPrefixRules(List<PaySurchargeRule> rules, String calcRule)
{
if (rules == null)
{
return false;
}
for (PaySurchargeRule rule : rules)
{
if (rule == null || !calcRule.equals(rule.getCalcRule()) || "0".equals(rule.getEnabled()) || rule.getDetail() == null)
{
continue;
}
Object postcodes = rule.getDetail().get("postcodes");
if (postcodes != null && !String.valueOf(postcodes).isBlank())
{
return true;
}
}
return false;
}
private static boolean ruleOn(List<PaySurchargeRule> rules, String calcRule)
@@ -146,6 +146,14 @@ public class PayProductServiceImpl implements IPayProductService
try
{
PayPreview preview = parser.parseMatched(bytes, filename, dictValue, loadDictValues(), listener);
if (!labelMode)
{
preview.setEffectiveDate(null);
for (PayProduct product : preview.getProducts())
{
product.setEffectiveDate(null);
}
}
writeProgress(progressKey, 98, "正在核对已有产品", PayParseProgress.RUNNING);
String stored = storeSource(bytes, filename);
rememberSource(preview.getFileMd5(), stored);
@@ -271,6 +279,7 @@ public class PayProductServiceImpl implements IPayProductService
continue;
}
product.setProductType(PAY_TYPE_AP);
product.setEffectiveDate(null);
product.setFilePath(resolveSource(product));
prepareCounts(product);
fitColumns(product);
@@ -279,7 +288,7 @@ public class PayProductServiceImpl implements IPayProductService
product.setCreateTime(new Date());
if (existsId != null)
{
appendVersion(existsId, product, operator);
appendVersion(existsId, product, operator, true);
replaced++;
}
else
@@ -628,16 +637,11 @@ public class PayProductServiceImpl implements IPayProductService
return payProductTermMapper.updateStatus(term);
}
/** 给刚保存的这一版记一条生效记录。新版本从生效日开始,旧版本收到生效日前一天。 */
/** 给刚保存的这一版记一条生效记录。应付导入不写生效开始。覆盖旧版时,旧版收到新生效日前一天;没有新生效日则收到昨天。 */
private PayProductTerm openTerm(PayProduct product, boolean replacePrevious, Long priceSetId)
{
String key = termKey(product);
boolean labelMode = priceSetId != null;
// 应付:无生效日则默认今天;打单:创建不写默认生效日,由详情「启动」设置
if (!labelMode && product.getEffectiveDate() == null)
{
product.setEffectiveDate(today());
}
if (replacePrevious)
{
Date endDate;
@@ -1932,10 +1936,10 @@ public class PayProductServiceImpl implements IPayProductService
/** 在同一个产品编号下追加一版价格:复用稳定 product_id,明细挂新 term_id。 */
private PayProduct appendVersion(Long rootId, PayProduct incoming, String operator)
{
return appendVersion(rootId, incoming, operator, null);
return appendVersion(rootId, incoming, operator, false);
}
private PayProduct appendVersion(Long rootId, PayProduct incoming, String operator, Long priceSetId)
private PayProduct appendVersion(Long rootId, PayProduct incoming, String operator, boolean blankEffectiveStart)
{
Long root = resolveRoot(rootId);
PayProduct base = payProductMapper.selectPayProductById(root);
@@ -1944,13 +1948,17 @@ public class PayProductServiceImpl implements IPayProductService
copy.setOriginId(null);
copy.setChannelId(null);
copy.setDictValue(blankTo(copy.getDictValue(), base.getDictValue()));
Integer maxVersion = payProductTermMapper.selectMaxVersionByProduct(root, priceSetId);
Integer maxVersion = payProductTermMapper.selectMaxVersionByProduct(root, null);
if (maxVersion == null || maxVersion < 1)
{
maxVersion = payProductMapper.selectMaxVersion(root);
}
copy.setVersionNo((maxVersion == null ? 1 : maxVersion) + 1);
if (copy.getEffectiveDate() == null)
if (blankEffectiveStart)
{
copy.setEffectiveDate(null);
}
else if (copy.getEffectiveDate() == null)
{
copy.setEffectiveDate(today());
}
@@ -1960,7 +1968,7 @@ public class PayProductServiceImpl implements IPayProductService
prepareCounts(copy);
fitColumns(copy);
payProductMapper.updatePayProduct(copy);
openTerm(copy, true, priceSetId);
openTerm(copy, true, null);
insertBands(copy);
insertSurcharges(copy);
insertExtras(copy);
@@ -2,6 +2,8 @@ package com.bms.price.service.impl;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
@@ -17,9 +19,11 @@ import com.bms.common.utils.SecurityUtils;
import com.bms.price.domain.PayProduct;
import com.bms.price.domain.PayProductTerm;
import com.bms.price.domain.PaySurchargeRule;
import com.bms.price.domain.PaySurchargeRuleLog;
import com.bms.price.domain.PaySurchargeRuleSave;
import com.bms.price.mapper.PayProductMapper;
import com.bms.price.mapper.PayProductTermMapper;
import com.bms.price.mapper.PaySurchargeRuleLogMapper;
import com.bms.price.mapper.PaySurchargeRuleMapper;
import com.bms.price.service.IPaySurchargeRuleService;
@@ -33,9 +37,16 @@ public class PaySurchargeRuleServiceImpl implements IPaySurchargeRuleService
"boxLength", "boxWidth", "boxHeight", "volumeMin", "volumeMax", "cubicMax", "weightMin", "weightMax",
"exceedStepCm", "exceedRatio", "thresholdKg", "stepKg", "minCharge", "ratio");
private static final Map<String, String> RULE_LABELS = Map.of(
"ALWAYS", "每票都收", "POSTCODE", "命中邮编", "ZONE", "命中分区", "EXTRA_KG", "超重续收",
"WEIGHT", "超重", "SIZE", "超尺寸", "RETURN", "退件");
@Autowired
private PaySurchargeRuleMapper ruleMapper;
@Autowired
private PaySurchargeRuleLogMapper logMapper;
@Autowired
private PayProductMapper productMapper;
@@ -45,30 +56,22 @@ public class PaySurchargeRuleServiceImpl implements IPaySurchargeRuleService
@Override
public List<PaySurchargeRule> list(Long productId, Long termId)
{
if (productId == null || termId == null)
if (productId == null)
{
return List.of();
}
List<PaySurchargeRule> rows = ruleMapper.selectByProduct(productId, termId);
rows.forEach(PaySurchargeRuleServiceImpl::hydrate);
return rows;
return hydrateAll(rulesOf(stableId(productId)));
}
@Override
public List<PaySurchargeRule> previous(Long productId)
public List<PaySurchargeRule> listForCalc(PayProduct product)
{
if (productId == null)
{
throw new ServiceException("请选择产品版本");
}
PaySurchargeRule previous = ruleMapper.selectPreviousVersion(productId);
if (previous == null || previous.getProductId() == null || previous.getTermId() == null)
if (product == null || product.getProductId() == null)
{
return List.of();
}
List<PaySurchargeRule> rows = ruleMapper.selectByProduct(previous.getProductId(), previous.getTermId());
rows.forEach(PaySurchargeRuleServiceImpl::hydrate);
return rows;
Long stable = product.getOriginId() != null ? product.getOriginId() : stableId(product.getProductId());
return hydrateAll(rulesOf(stable));
}
@Override
@@ -81,28 +84,29 @@ public class PaySurchargeRuleServiceImpl implements IPaySurchargeRuleService
}
List<PaySurchargeRule> rules = normalize(command.getRules());
String operator = SecurityUtils.getUsername();
Map<String, PaySurchargeRuleSave.PaySurchargeRuleBind> versions = new LinkedHashMap<>();
Map<Long, PaySurchargeRuleSave.PaySurchargeRuleBind> products = new LinkedHashMap<>();
for (PaySurchargeRuleSave.PaySurchargeRuleBind bind : command.getProducts())
{
if (bind == null || bind.getProductId() == null || bind.getTermId() == null)
if (bind == null || bind.getProductId() == null)
{
throw new ServiceException("产品还没有可绑定的生效版本");
throw new ServiceException("请选择要绑定的产品");
}
versions.put(bind.getProductId() + ":" + bind.getTermId(), bind);
products.putIfAbsent(stableId(bind.getProductId()), bind);
}
for (PaySurchargeRuleSave.PaySurchargeRuleBind bind : versions.values())
for (Long stableId : products.keySet())
{
PayProduct product = productMapper.selectPayProductById(bind.getProductId());
if (product == null)
List<Long> family = productMapper.selectFamilyIds(stableId);
if (family == null || family.isEmpty())
{
throw new ServiceException("应付产品不存在");
family = List.of(stableId);
}
PayProductTerm term = termMapper.selectById(bind.getTermId());
if (term == null || term.getProductId() == null || !term.getProductId().equals(bind.getProductId()))
List<PaySurchargeRule> before = hydrateAll(rulesOf(stableId));
List<PaySurchargeRuleLog> logs = diffLogs(before, rules, stableId, 0L, null, operator);
ruleMapper.deleteByProductIds(family);
if (!logs.isEmpty())
{
throw new ServiceException("计费条件必须跟这一版有效期走");
logMapper.insertBatch(logs);
}
ruleMapper.deleteByTerm(bind.getProductId(), bind.getTermId());
if (rules.isEmpty())
{
continue;
@@ -118,9 +122,9 @@ public class PaySurchargeRuleServiceImpl implements IPaySurchargeRuleService
lastRule = rule.getCalcRule();
}
PaySurchargeRule row = copy(rule);
row.setProductId(bind.getProductId());
row.setTermId(bind.getTermId());
row.setVersionNo(term.getVersionNo() == null ? bind.getVersionNo() : term.getVersionNo());
row.setProductId(stableId);
row.setTermId(0L);
row.setVersionNo(0);
row.setSortNo(sort++);
row.setCreateBy(operator);
row.setUpdateBy(operator);
@@ -161,9 +165,79 @@ public class PaySurchargeRuleServiceImpl implements IPaySurchargeRuleService
row.setWeightMax(decimal(detail.get("weightMax"), "重量上限"));
rules.add(row);
}
Set<String> alwaysFees = new HashSet<>();
for (PaySurchargeRule row : rules)
{
if (!"ALWAYS".equals(row.getCalcRule()) || row.getDetail() == null)
{
continue;
}
String feeName = text(row.getDetail().get("feeName"));
if (feeName != null && !alwaysFees.add(feeName))
{
throw new ServiceException("每票都收的费用项不能重复:" + feeName);
}
}
return rules;
}
@Override
public List<PaySurchargeRuleLog> logs(Long productId, Long termId)
{
if (productId == null)
{
return List.of();
}
List<Long> family = productMapper.selectFamilyIds(stableId(productId));
if (family == null || family.isEmpty())
{
return List.of();
}
return logMapper.selectByProductIds(family);
}
/** 产品上的一份。还没迁到产品上时,用当前生效版本里原来那份。 */
private List<PaySurchargeRule> rulesOf(Long stableId)
{
List<PaySurchargeRule> rows = ruleMapper.selectByProductId(stableId);
if (rows != null && !rows.isEmpty())
{
return rows;
}
Long currentId = productMapper.selectEffectiveVersionId(stableId, new Date());
if (currentId == null)
{
return List.of();
}
PayProductTerm term = termMapper.selectByProductId(currentId);
if (term == null || term.getTermId() == null)
{
return List.of();
}
List<PaySurchargeRule> current = ruleMapper.selectByProduct(currentId, term.getTermId());
return current == null ? List.of() : current;
}
private Long stableId(Long productId)
{
PayProduct product = productMapper.selectPayProductById(productId);
if (product == null)
{
throw new ServiceException("应付产品不存在");
}
return product.getOriginId() == null ? product.getProductId() : product.getOriginId();
}
private static List<PaySurchargeRule> hydrateAll(List<PaySurchargeRule> rows)
{
if (rows == null || rows.isEmpty())
{
return List.of();
}
rows.forEach(PaySurchargeRuleServiceImpl::hydrate);
return rows;
}
private static void hydrate(PaySurchargeRule row)
{
Map<String, Object> detail = parse(row.getRuleDetail());
@@ -274,9 +348,14 @@ public class PaySurchargeRuleServiceImpl implements IPaySurchargeRuleService
{
require(detail.get("thresholdKg") != null, "超重续收要填写超过多少公斤后开始续收");
}
else if ("ALWAYS".equals(calcRule))
{
require(text(detail.get("feeName")) != null, "每票都收要选择费用项");
}
else if ("POSTCODE".equals(calcRule) || "ZONE".equals(calcRule))
{
detail.clear();
require(text(detail.get("zoneName")) != null, "命中邮编和命中分区要选择分区");
require(text(detail.get("postcodes")) != null, "命中邮编和命中分区要填写邮编前缀");
}
else if ("RETURN".equals(calcRule))
{
@@ -322,13 +401,13 @@ public class PaySurchargeRuleServiceImpl implements IPaySurchargeRuleService
private static String summary(String calcRule, Map<String, Object> detail)
{
if ("POSTCODE".equals(calcRule))
if ("POSTCODE".equals(calcRule) || "ZONE".equals(calcRule))
{
return "按应付邮编配置判断,邮编前缀和完整邮编都认";
}
if ("ZONE".equals(calcRule))
{
return "按应付邮编配置的分区判断";
List<String> located = new ArrayList<>();
add(located, text(detail.get("zoneName")));
add(located, text(detail.get("postcodes")));
add(located, text(detail.get("country")));
return located.isEmpty() ? null : String.join(";", located);
}
List<String> parts = new ArrayList<>();
add(parts, choice(detail.get("matchMode"), Map.of("ANY", "任一命中", "ALL", "全部命中")));
@@ -384,6 +463,11 @@ public class PaySurchargeRuleServiceImpl implements IPaySurchargeRuleService
{
add(parts, text(detail.get("remark")));
}
String feeName = text(detail.get("feeName"));
if (feeName != null)
{
parts.add(0, feeName);
}
return parts.isEmpty() ? null : String.join(";", parts);
}
@@ -560,4 +644,178 @@ public class PaySurchargeRuleServiceImpl implements IPaySurchargeRuleService
}
return text;
}
private static List<PaySurchargeRuleLog> diffLogs(List<PaySurchargeRule> before, List<PaySurchargeRule> after,
Long productId, Long termId, Integer versionNo, String operator)
{
Map<String, Map<String, String>> oldRows = keyed(before);
Map<String, Map<String, String>> newRows = keyed(after);
List<PaySurchargeRuleLog> logs = new ArrayList<>();
for (Map.Entry<String, Map<String, String>> entry : newRows.entrySet())
{
Map<String, String> previous = oldRows.remove(entry.getKey());
if (previous == null)
{
logs.add(log(productId, termId, versionNo, "1", entry.getKey(), null, entry.getValue(), operator));
continue;
}
Map<String, String> oldChanged = new LinkedHashMap<>();
Map<String, String> newChanged = new LinkedHashMap<>();
Set<String> labels = new java.util.LinkedHashSet<>();
labels.addAll(previous.keySet());
labels.addAll(entry.getValue().keySet());
for (String label : labels)
{
String oldValue = previous.get(label);
String newValue = entry.getValue().get(label);
if (java.util.Objects.equals(oldValue, newValue))
{
continue;
}
if (oldValue != null)
{
oldChanged.put(label, oldValue);
}
if (newValue != null)
{
newChanged.put(label, newValue);
}
}
if (!oldChanged.isEmpty() || !newChanged.isEmpty())
{
logs.add(log(productId, termId, versionNo, "2", entry.getKey(), oldChanged, newChanged, operator));
}
}
for (Map.Entry<String, Map<String, String>> entry : oldRows.entrySet())
{
logs.add(log(productId, termId, versionNo, "3", entry.getKey(), entry.getValue(), null, operator));
}
return logs;
}
private static Map<String, Map<String, String>> keyed(List<PaySurchargeRule> rules)
{
Map<String, Map<String, String>> rows = new LinkedHashMap<>();
Map<String, Integer> index = new LinkedHashMap<>();
if (rules == null)
{
return rows;
}
for (PaySurchargeRule rule : rules)
{
if (rule == null || rule.getCalcRule() == null)
{
continue;
}
Map<String, String> attrs = filled(rule);
if (attrs.isEmpty())
{
continue;
}
String feeName = attrs.get("费用项");
String key;
if ("ALWAYS".equals(rule.getCalcRule()) && feeName != null)
{
key = "ALWAYS|" + feeName;
}
else if ("POSTCODE".equals(rule.getCalcRule()) || "ZONE".equals(rule.getCalcRule()))
{
int next = index.getOrDefault(rule.getCalcRule(), 0);
index.put(rule.getCalcRule(), next + 1);
String zone = attrs.get("分区");
key = rule.getCalcRule() + "|" + (zone == null ? String.valueOf(next) : zone + "#" + next);
}
else
{
int next = index.getOrDefault(rule.getCalcRule(), 0);
index.put(rule.getCalcRule(), next + 1);
key = rule.getCalcRule() + "#" + next;
}
rows.put(key, attrs);
}
return rows;
}
private static Map<String, String> filled(PaySurchargeRule rule)
{
Map<String, Object> detail = rule.getDetail() == null ? Map.of() : rule.getDetail();
Map<String, String> attrs = new LinkedHashMap<>();
putAttr(attrs, "计费条件", RULE_LABELS.getOrDefault(rule.getCalcRule(), rule.getCalcRule()));
putAttr(attrs, "费用项", text(detail.get("feeName")));
putAttr(attrs, "计费方式", choice(detail.get("rateMode"), Map.of("FIXED", "固定金额", "RATIO", "按比例")));
putAttr(attrs, "比例", detail.get("ratio") == null ? null : plain(detail.get("ratio")) + "%");
putAttr(attrs, "计费基数", choice(detail.get("chargeBase"), Map.of("HANDLE", "处理费", "DELIVERY", "派送费", "FREIGHT_FEE", "运费加附加费")));
putAttr(attrs, "生效开始", text(detail.get("periodStart")));
putAttr(attrs, "生效结束", text(detail.get("periodEnd")));
putAttr(attrs, "说明", text(detail.get("remark")));
putAttr(attrs, "命中方式", choice(detail.get("matchMode"), Map.of("ANY", "任一命中", "ALL", "全部命中")));
putAttr(attrs, "邮编匹配", choice(detail.get("postcodeMode"), Map.of("PREFIX", "邮编开头", "EXACT", "完整邮编")));
putAttr(attrs, "最长边下限", unit(detail.get("longestMin"), "cm"));
putAttr(attrs, "最长边上限", unit(detail.get("longestMax"), "cm"));
putAttr(attrs, "次长边下限", unit(detail.get("secondMin"), "cm"));
putAttr(attrs, "次长边上限", unit(detail.get("secondMax"), "cm"));
putAttr(attrs, "三边之和下限", unit(detail.get("sumMin"), "cm"));
putAttr(attrs, "三边之和上限", unit(detail.get("sumMax"), "cm"));
putAttr(attrs, "围长下限", unit(detail.get("girthMin"), "cm"));
putAttr(attrs, "围长上限", unit(detail.get("girthMax"), "cm"));
putAttr(attrs, "箱规比较", choice(detail.get("boxMode"), Map.of("OVER", "超过", "UNDER", "小于")));
if (detail.get("boxLength") != null)
{
putAttr(attrs, "箱规", plain(detail.get("boxLength")) + "×" + plain(detail.get("boxWidth")) + "×" + plain(detail.get("boxHeight")) + "cm");
}
putAttr(attrs, "体积下限", unit(detail.get("volumeMin"), "m³"));
putAttr(attrs, "体积上限", unit(detail.get("volumeMax"), "m³"));
putAttr(attrs, "体积上限立方", unit(detail.get("cubicMax"), "cm³"));
putAttr(attrs, "超出步长", unit(detail.get("exceedStepCm"), "cm"));
putAttr(attrs, "加收比例", detail.get("exceedRatio") == null ? null : plain(detail.get("exceedRatio")) + "%");
putAttr(attrs, "重量下限", unit(detail.get("weightMin"), "kg"));
putAttr(attrs, "重量上限", unit(detail.get("weightMax"), "kg"));
putAttr(attrs, "重量口径", choice(detail.get("weightBasis"), Map.of("ACTUAL", "实重", "CHARGE", "计费重")));
putAttr(attrs, "超过重量", unit(detail.get("thresholdKg"), "kg"));
putAttr(attrs, "每超出", unit(detail.get("stepKg"), "kg"));
putAttr(attrs, "最低消费", plain(detail.get("minCharge")));
putAttr(attrs, "分区", text(detail.get("zoneName")));
putAttr(attrs, "邮编", text(detail.get("postcodes")));
putAttr(attrs, "国家", text(detail.get("country") != null ? detail.get("country") : detail.get("countries")));
putAttr(attrs, "退回方式", choice(detail.get("chargeMode"), Map.of("SAME", "同正向运费", "AIR", "空运退回", "ROAD", "陆运退回", "FIXED", "固定金额")));
return attrs;
}
private static void putAttr(Map<String, String> attrs, String label, String value)
{
if (value == null || value.isBlank())
{
return;
}
attrs.put(label, value);
}
private static String unit(Object value, String unit)
{
String text = plain(value);
return text == null ? null : text + unit;
}
private static PaySurchargeRuleLog log(Long productId, Long termId, Integer versionNo, String operation,
String ruleKey, Map<String, String> before, Map<String, String> after, String operator)
{
Map<String, Object> content = new LinkedHashMap<>();
if (before != null && !before.isEmpty())
{
content.put("before", before);
}
if (after != null && !after.isEmpty())
{
content.put("after", after);
}
PaySurchargeRuleLog row = new PaySurchargeRuleLog();
row.setProductId(productId);
row.setTermId(termId);
row.setVersionNo(versionNo);
row.setOperationType(operation);
row.setRuleKey(ruleKey.length() > 200 ? ruleKey.substring(0, 200) : ruleKey);
row.setChangeContent(JSON.toJSONString(content));
row.setCreateBy(operator);
return row;
}
}
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bms.price.mapper.PaySurchargeRuleLogMapper">
<select id="selectByTerm" resultType="com.bms.price.domain.PaySurchargeRuleLog">
select log_id as logId, product_id as productId, term_id as termId, version_no as versionNo,
operation_type as operationType, rule_key as ruleKey, change_content as changeContent,
create_by as createBy, create_time as createTime
from pay_surcharge_rule_log
where product_id = #{productId}
and term_id = #{termId}
order by log_id desc
</select>
<select id="selectByProductIds" resultType="com.bms.price.domain.PaySurchargeRuleLog">
select log_id as logId, product_id as productId, term_id as termId, version_no as versionNo,
operation_type as operationType, rule_key as ruleKey, change_content as changeContent,
create_by as createBy, create_time as createTime
from pay_surcharge_rule_log
where product_id in
<foreach collection="productIds" item="id" open="(" separator="," close=")">#{id}</foreach>
order by log_id desc
</select>
<insert id="insertBatch">
insert into pay_surcharge_rule_log (
product_id, term_id, version_no, operation_type, rule_key, change_content, create_by, create_time
) values
<foreach collection="list" item="row" separator=",">
(#{row.productId}, #{row.termId}, #{row.versionNo}, #{row.operationType}, #{row.ruleKey},
#{row.changeContent}, #{row.createBy}, sysdate())
</foreach>
</insert>
</mapper>
@@ -12,12 +12,27 @@
order by calc_rule, sort_no, rule_id
</select>
<select id="selectByProductId" resultType="com.bms.price.domain.PaySurchargeRule">
select rule_id as ruleId, product_id as productId, term_id as termId, version_no as versionNo,
calc_rule as calcRule, enabled, calc_param as calcParam, rule_detail as ruleDetail, postcode_param as postcodeParam,
zone_name as zoneName, weight_min as weightMin, weight_max as weightMax, sort_no as sortNo
from pay_surcharge_rule
where product_id = #{productId}
order by calc_rule, sort_no, rule_id
</select>
<delete id="deleteByTerm">
delete from pay_surcharge_rule
where product_id = #{productId}
and term_id = #{termId}
</delete>
<delete id="deleteByProductIds">
delete from pay_surcharge_rule
where product_id in
<foreach collection="productIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</delete>
<insert id="insertBatch">
insert into pay_surcharge_rule (
product_id, term_id, version_no, calc_rule, enabled, calc_param, rule_detail, postcode_param,
@@ -29,18 +44,4 @@
#{row.createBy}, sysdate(), #{row.updateBy}, sysdate())
</foreach>
</insert>
<select id="selectPreviousVersion" resultType="com.bms.price.domain.PaySurchargeRule">
select v.product_id as productId, t.term_id as termId, ifnull(v.version_no, 1) as versionNo
from pay_product cur
join pay_product v on v.dict_value = cur.dict_value
and v.dict_value is not null and v.dict_value &lt;&gt; ''
and v.product_id &lt;&gt; cur.product_id
and (v.channel_id is null or v.channel_id = '')
and ifnull(v.version_no, 1) &lt; ifnull(cur.version_no, 1)
join pay_product_term t on t.product_id = v.product_id and t.price_set_id is null
where cur.product_id = #{productId}
order by ifnull(v.version_no, 1) desc, t.term_id desc
limit 1
</select>
</mapper>
@@ -0,0 +1,14 @@
-- 新增应付附加费配置操作记录表,按产品版本保存,内容只含有数据的属性
CREATE TABLE IF NOT EXISTS `bms`.`pay_surcharge_rule_log` (
`log_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '记录ID',
`product_id` bigint(20) NOT NULL COMMENT '应付产品版本ID',
`term_id` bigint(20) NOT NULL COMMENT '价卡版本周期',
`version_no` int(11) DEFAULT NULL COMMENT '版本号',
`operation_type` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '操作:1新增 2修改 3删除',
`rule_key` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '条件标识,每票都收带费用项',
`change_content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '变更内容,只含有数据的属性',
`create_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT '操作人',
`create_time` datetime DEFAULT NULL COMMENT '操作时间',
PRIMARY KEY (`log_id`),
KEY `idx_pay_surcharge_rule_log_ver` (`product_id`, `term_id`, `log_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='应付附加费配置操作记录';
+7 -7
View File
@@ -172,21 +172,21 @@ export function delPay(productIds) {
})
}
/** 当前版本的附加费计费条件 */
export function listPaySurchargeRule(productId, termId) {
/** 产品上的附加费计费条件,不按价格版本拆开 */
export function listPaySurchargeRule(productId) {
return request({
url: '/price/pay/surchargeRule',
method: 'get',
params: { productId, termId }
params: { productId }
})
}
/** 上一版计费条件,复制到当前页编辑 */
export function previousPaySurchargeRule(productId) {
/** 这一版产品上的附加费配置操作记录 */
export function listPaySurchargeRuleLog(productId, termId) {
return request({
url: '/price/pay/surchargeRule/previous',
url: '/price/pay/surchargeRule/log',
method: 'get',
params: { productId }
params: { productId, termId }
})
}
@@ -74,6 +74,7 @@
:size="size"
:height="height"
:max-height="maxHeight"
:scrollbar-always-on="scrollbarAlwaysOn"
:default-sort="defaultSort"
:span-method="spanMethod"
@selection-change="handleSelectionChange"
@@ -291,6 +292,8 @@ const props = defineProps({
// 表格高度
height: { type: [String, Number], default: undefined },
maxHeight: { type: [String, Number], default: undefined },
// 横向、纵向滚动条一直显示
scrollbarAlwaysOn: { type: Boolean, default: false },
// 默认排序(透传 el-table default-sort,如 { prop: 'createTime', order: 'descending' })
defaultSort: {
type: Object,
@@ -78,6 +78,14 @@
<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-select v-model="pane.feeBill" clearable placeholder="计费条件" class="pay-bill-filter" @change="pane.feePageNo = 1">
<el-option label="需要计费" value="BILL">
<span class="bill-need-text">需要计费</span>
</el-option>
<el-option label="暂不计费" value="MANUAL">
<span class="bill-hold-text">暂不计费</span>
</el-option>
</el-select>
<el-button v-if="editable" size="small" type="primary" plain @click="openFeePicker(pane)">添加附加费</el-button>
<el-button
v-if="editable"
@@ -101,7 +109,7 @@
<el-checkbox :model-value="row._checked === true" @update:model-value="v => { row._checked = !!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 fixed="left">
<template #default="{ row }">
<el-select
v-if="isRowEditing(row)"
@@ -189,13 +197,25 @@
<el-table-column prop="feeDesc" label="费用解释" min-width="240" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.feeDesc" :edit="isRowEditing(row)" /></template>
</el-table-column>
<el-table-column prop="calcRule" label="计费条件" width="140">
<el-table-column prop="calcRule" label="计费条件" width="140" align="center">
<template #default="{ row }">
<el-select v-if="isRowEditing(row)" :model-value="feeBillFlag(row)" size="small" @change="value => setFeeBill(row, value)">
<el-option label="需要计费" value="BILL" />
<el-option label="暂不计费" value="MANUAL" />
<el-select
v-if="isRowEditing(row)"
:model-value="feeBillFlag(row)"
size="small"
:class="feeBillFlag(row) === 'BILL' ? 'bill-need' : 'bill-hold'"
@change="value => setFeeBill(row, value)"
>
<el-option label="需要计费" value="BILL">
<span class="bill-need-text">需要计费</span>
</el-option>
<el-option label="暂不计费" value="MANUAL">
<span class="bill-hold-text">暂不计费</span>
</el-option>
</el-select>
<span v-else>{{ feeBillFlag(row) === 'BILL' ? '需要计费' : '暂不计费' }}</span>
<el-tag v-else :type="feeBillFlag(row) === 'BILL' ? 'success' : 'danger'" size="small" effect="light">
{{ feeBillFlag(row) === 'BILL' ? '需要计费' : '暂不计费' }}
</el-tag>
</template>
</el-table-column>
<el-table-column v-if="editable" label="操作" width="120" align="center" fixed="right">
@@ -483,6 +503,7 @@ watch(source, (list) => {
tab: 'band',
bandKeyword: '',
feeKeyword: '',
feeBill: '',
postKeyword: '',
bandPageNo: 1,
feePageNo: 1,
@@ -495,7 +516,9 @@ function filteredBands(pane) {
}
function filteredFees(pane) {
return filterRows(pane.item.surcharges || [], pane.feeKeyword, ['feeName', 'zoneName', 'chargeText', 'amount', 'currency', 'priceUnit', 'feeDesc', 'calcRule', 'calcParam', 'postcodeParam'])
const rows = filterRows(pane.item.surcharges || [], pane.feeKeyword, ['feeName', 'zoneName', 'chargeText', 'amount', 'currency', 'priceUnit', 'feeDesc', 'calcRule', 'calcParam', 'postcodeParam'])
if (!pane.feeBill) return rows
return rows.filter(row => feeBillFlag(row) === pane.feeBill)
}
function feeTabLabel(pane) {
@@ -721,6 +744,9 @@ function statusText(status) {
.pay-filter {
width: 320px;
}
.pay-bill-filter {
width: 140px;
}
.pay-edit-bar {
display: flex;
align-items: center;
@@ -745,4 +771,16 @@ function statusText(status) {
.limit-tag {
margin-right: 6px;
}
.bill-need :deep(.el-select__selected-item) {
color: var(--el-color-success);
}
.bill-hold :deep(.el-select__selected-item) {
color: var(--el-color-danger);
}
.bill-need-text {
color: var(--el-color-success);
}
.bill-hold-text {
color: var(--el-color-danger);
}
</style>
@@ -5,7 +5,7 @@
<div class="rule-title">绑定产品</div>
<div class="rule-products">
<el-tag v-for="item in products" :key="item.key" closable class="rule-tag" @close="removeProduct(item)">
{{ item.productName }} · 第 {{ item.versionNo || 1 }} 版
{{ item.productName }}
</el-tag>
<span v-if="!products.length" class="rule-empty">还没有产品。勾选列表后打开,或在下面查询后加入。</span>
</div>
@@ -25,7 +25,7 @@
</template>
</el-table-column>
</el-table>
<p class="rule-hint">配置写到每个产品当前生效的版本。同一版本再次保存会覆盖原来的一份。</p>
<p class="rule-hint">配置跟产品走,不跟价格版本。计算时用这个产品当前生效版本上的附加费。</p>
</div>
<div class="rule-block">
@@ -36,8 +36,6 @@
<el-switch v-model="enabled[rule.value]" @change="value => toggleRule(rule.value, value)" />
</div>
</div>
<p v-if="enabled.POSTCODE" class="rule-hint">打开后,用订单目的国去应付邮编配置里对国家和邮编。邮编前缀和完整邮编都会判断,命中偏远或岛屿才加这笔附加费。</p>
<p v-if="enabled.ZONE" class="rule-hint">打开后,用订单目的国和邮编去应付邮编配置里对上分区,再加这一档附加费。</p>
</div>
<div v-for="rule in openedRules" :key="rule.value" class="rule-block">
@@ -55,209 +53,310 @@
<div v-if="rule.value === 'SIZE'" class="rule-grid">
<div class="field">
<span class="label">命中方式</span>
<el-select v-model="row.matchMode" size="small">
<el-option label="任一命中" value="ANY" />
<el-option label="全部命中" value="ALL" />
</el-select>
<div class="control">
<el-select v-model="row.matchMode" size="small">
<el-option label="任一命中" value="ANY" />
<el-option label="全部命中" value="ALL" />
</el-select>
</div>
</div>
<div class="field">
<span class="label">重量前提 kg</span>
<el-input v-model="row.weightMin" size="small" placeholder="下限" />
<span class="sep">至</span>
<el-input v-model="row.weightMax" size="small" placeholder="上限" />
<div class="control">
<el-input v-model="row.weightMin" size="small" placeholder="下限" />
<span class="sep">至</span>
<el-input v-model="row.weightMax" size="small" placeholder="上限" />
</div>
</div>
<div class="field">
<span class="label">最长边 cm</span>
<el-input v-model="row.longestMin" size="small" placeholder="下限" />
<span class="sep">至</span>
<el-input v-model="row.longestMax" size="small" placeholder="上限" />
<div class="control">
<el-input v-model="row.longestMin" size="small" placeholder="下限" />
<span class="sep">至</span>
<el-input v-model="row.longestMax" size="small" placeholder="上限" />
</div>
</div>
<div class="field">
<span class="label">次长边 cm</span>
<el-input v-model="row.secondMin" size="small" placeholder="下限" />
<span class="sep">至</span>
<el-input v-model="row.secondMax" size="small" placeholder="上限" />
<div class="control">
<el-input v-model="row.secondMin" size="small" placeholder="下限" />
<span class="sep">至</span>
<el-input v-model="row.secondMax" size="small" placeholder="上限" />
</div>
</div>
<div class="field">
<span class="label">三边之和 cm</span>
<el-input v-model="row.sumMin" size="small" placeholder="下限" />
<span class="sep">至</span>
<el-input v-model="row.sumMax" size="small" placeholder="上限" />
<div class="control">
<el-input v-model="row.sumMin" size="small" placeholder="下限" />
<span class="sep">至</span>
<el-input v-model="row.sumMax" size="small" placeholder="上限" />
</div>
</div>
<div class="field">
<span class="label">围长 cm</span>
<el-input v-model="row.girthMin" size="small" placeholder="下限" />
<span class="sep">至</span>
<el-input v-model="row.girthMax" size="small" placeholder="上限" />
<div class="control">
<el-input v-model="row.girthMin" size="small" placeholder="下限" />
<span class="sep">至</span>
<el-input v-model="row.girthMax" size="small" placeholder="上限" />
</div>
</div>
<div class="field">
<span class="label">箱规 cm</span>
<el-select v-model="row.boxMode" size="small" class="mode">
<el-option label="任一边超过" value="OVER" />
<el-option label="小于最小尺寸" value="UNDER" />
</el-select>
<el-input v-model="row.boxLength" size="small" placeholder="长" />
<el-input v-model="row.boxWidth" size="small" placeholder="宽" />
<el-input v-model="row.boxHeight" size="small" placeholder="高" />
<div class="control">
<el-select v-model="row.boxMode" size="small" class="mode">
<el-option label="任一边超过" value="OVER" />
<el-option label="小于最小尺寸" value="UNDER" />
</el-select>
<el-input v-model="row.boxLength" class="num" size="small" placeholder="长" />
<el-input v-model="row.boxWidth" class="num" size="small" placeholder="宽" />
<el-input v-model="row.boxHeight" class="num" size="small" placeholder="高" />
</div>
</div>
<div class="field">
<span class="label">体积 m³</span>
<el-input v-model="row.volumeMin" size="small" placeholder="下限" />
<span class="sep">至</span>
<el-input v-model="row.volumeMax" size="small" placeholder="上限" />
<div class="control">
<el-input v-model="row.volumeMin" size="small" placeholder="下限" />
<span class="sep">至</span>
<el-input v-model="row.volumeMax" size="small" placeholder="上限" />
</div>
</div>
<div class="field">
<span class="label">长×宽×高上限 cm³</span>
<el-input v-model="row.cubicMax" size="small" placeholder="例如 432000" />
<div class="control">
<el-input v-model="row.cubicMax" size="small" placeholder="例如 432000" />
</div>
</div>
<div class="field">
<span class="label">每超出 cm</span>
<el-input v-model="row.exceedStepCm" size="small" placeholder="例如 10" />
<div class="control">
<el-input v-model="row.exceedStepCm" size="small" placeholder="例如 10" />
</div>
</div>
<div class="field">
<span class="label">运费加收比例 %</span>
<el-input v-model="row.exceedRatio" size="small" placeholder="例如 35" />
<div class="control">
<el-input v-model="row.exceedRatio" size="small" placeholder="例如 35" />
</div>
</div>
<div class="field span-2">
<div class="field">
<span class="label">说明</span>
<el-input v-model="row.remark" size="small" placeholder="这一档的补充说明" />
<div class="control">
<el-input v-model="row.remark" size="small" placeholder="这一档的补充说明" />
</div>
</div>
</div>
<div v-else-if="rule.value === 'WEIGHT'" class="rule-grid">
<div class="field">
<span class="label">重量 kg</span>
<el-input v-model="row.weightMin" size="small" placeholder="下限" />
<span class="sep">至</span>
<el-input v-model="row.weightMax" size="small" placeholder="上限" />
<div class="control">
<el-input v-model="row.weightMin" size="small" placeholder="下限" />
<span class="sep">至</span>
<el-input v-model="row.weightMax" size="small" placeholder="上限" />
</div>
</div>
<div class="field">
<span class="label">重量口径</span>
<el-select v-model="row.weightBasis" size="small">
<el-option label="实重" value="ACTUAL" />
<el-option label="计费重" value="CHARGE" />
</el-select>
<div class="control">
<el-select v-model="row.weightBasis" size="small">
<el-option label="实重" value="ACTUAL" />
<el-option label="计费重" value="CHARGE" />
</el-select>
</div>
</div>
<div class="field">
<span class="label">每超出 kg</span>
<el-input v-model="row.stepKg" size="small" placeholder="超过上限后按每公斤" />
<div class="control">
<el-input v-model="row.stepKg" size="small" placeholder="超过上限后按每公斤" />
</div>
</div>
<div class="field">
<span class="label">最低消费</span>
<el-input v-model="row.minCharge" size="small" placeholder="没有就空着" />
<div class="control">
<el-input v-model="row.minCharge" size="small" placeholder="没有就空着" />
</div>
</div>
<div class="field">
<span class="label">运费加收比例 %</span>
<el-input v-model="row.exceedRatio" size="small" placeholder="按运费比例加收时填写" />
<div class="control">
<el-input v-model="row.exceedRatio" size="small" placeholder="按运费比例加收时填写" />
</div>
</div>
<div class="field">
<span class="label">说明</span>
<el-input v-model="row.remark" size="small" />
<div class="control">
<el-input v-model="row.remark" size="small" />
</div>
</div>
</div>
<div v-else-if="rule.value === 'EXTRA_KG'" class="rule-grid">
<div class="field">
<span class="label">超过 kg</span>
<el-input v-model="row.thresholdKg" size="small" placeholder="超过这个重量后开始续收" />
<div class="control">
<el-input v-model="row.thresholdKg" size="small" placeholder="超过这个重量后开始续收" />
</div>
</div>
<div class="field">
<span class="label">每超出 kg</span>
<el-input v-model="row.stepKg" size="small" placeholder="例如 1" />
<div class="control">
<el-input v-model="row.stepKg" size="small" placeholder="例如 1" />
</div>
</div>
<div class="field">
<span class="label">重量口径</span>
<el-select v-model="row.weightBasis" size="small">
<el-option label="实重" value="ACTUAL" />
<el-option label="计费重" value="CHARGE" />
</el-select>
<div class="control">
<el-select v-model="row.weightBasis" size="small">
<el-option label="实重" value="ACTUAL" />
<el-option label="计费重" value="CHARGE" />
</el-select>
</div>
</div>
<div class="field">
<span class="label">最低消费</span>
<el-input v-model="row.minCharge" size="small" placeholder="例如 17.5" />
<div class="control">
<el-input v-model="row.minCharge" size="small" placeholder="例如 17.5" />
</div>
</div>
<div class="field span-2">
<div class="field">
<span class="label">邮编</span>
<el-input v-model="row.postcodes" size="small" type="textarea" :rows="2" placeholder="续收还要限定邮编时填写,支持 KA27-28 这种区间" />
<div class="control">
<el-input v-model="row.postcodes" size="small" type="textarea" :rows="2" placeholder="续收还要限定邮编时填写,支持 KA27-28 这种区间" />
</div>
</div>
</div>
<div v-else-if="rule.value === 'POSTCODE' || rule.value === 'ZONE'" class="rule-grid">
<div class="field">
<span class="label">分区</span>
<div class="control">
<el-select v-model="row.zoneName" filterable size="small" placeholder="选择附加费里的高、中、低区">
<el-option v-for="zone in zoneOptions" :key="zone" :label="zone" :value="zone" />
</el-select>
</div>
</div>
<div class="field">
<span class="label">邮编前缀</span>
<div class="control">
<el-input v-model="row.postcodes" size="small" type="textarea" :rows="2" placeholder="多个用逗号或换行。前缀、完整邮编、1000-1099、KA27-28 都可以" />
</div>
</div>
<div class="field">
<span class="label">国家</span>
<div class="control">
<el-input v-model="row.country" size="small" placeholder="不填则不限,例如 DE 或 德国" />
</div>
</div>
</div>
<div v-else-if="rule.value === 'ALWAYS'" class="rule-grid">
<div class="field">
<span class="label">费用项</span>
<div class="control">
<el-select v-model="row.feeName" filterable size="small" placeholder="选择这一条要每票收取的费用" @change="(name) => pickFee(row, name)">
<el-option v-for="item in feeOptions" :key="item.feeName" :label="item.feeName" :value="item.feeName" />
</el-select>
</div>
</div>
<div class="field">
<span class="label">计费方式</span>
<el-select v-model="row.rateMode" size="small">
<el-option label="固定金额" value="FIXED" />
<el-option label="按比例" value="RATIO" />
</el-select>
<div class="control">
<el-select v-model="row.rateMode" size="small">
<el-option label="固定金额" value="FIXED" />
<el-option label="按比例" value="RATIO" />
</el-select>
</div>
</div>
<div class="field">
<span class="label">比例 %</span>
<el-input v-model="row.ratio" size="small" placeholder="燃油、战争附加费这类" />
<div class="control">
<el-input v-model="row.ratio" size="small" placeholder="燃油、战争附加费这类" />
</div>
</div>
<div class="field">
<span class="label">计费基数</span>
<el-select v-model="row.chargeBase" size="small" clearable placeholder="按比例时选择">
<el-option label="处理费" value="HANDLE" />
<el-option label="派送费" value="DELIVERY" />
<el-option label="运费加附加费" value="FREIGHT_FEE" />
</el-select>
<div class="control">
<el-select v-model="row.chargeBase" size="small" clearable placeholder="按比例时选择">
<el-option label="处理费" value="HANDLE" />
<el-option label="派送费" value="DELIVERY" />
<el-option label="运费加附加费" value="FREIGHT_FEE" />
</el-select>
</div>
</div>
<div class="field">
<span class="label">生效日期</span>
<el-input v-model="row.periodStart" size="small" placeholder="开始,如 11-01" />
<span class="sep">至</span>
<el-input v-model="row.periodEnd" size="small" placeholder="结束,如 12-23" />
<div class="control">
<el-date-picker v-model="row.periodStart" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" size="small" clearable />
<span class="sep">至</span>
<el-date-picker v-model="row.periodEnd" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" size="small" clearable />
</div>
</div>
<div class="field span-2">
<div class="field">
<span class="label">说明</span>
<el-input v-model="row.remark" size="small" placeholder="例如每年 11、12 月固定收取" />
<div class="control">
<el-input v-model="row.remark" size="small" placeholder="例如每年 11、12 月固定收取" />
</div>
</div>
</div>
<div v-else class="rule-grid">
<div class="field">
<span class="label">退回方式</span>
<el-select v-model="row.chargeMode" size="small">
<el-option label="同正向运费" value="SAME" />
<el-option label="空运退回" value="AIR" />
<el-option label="陆运退回" value="ROAD" />
<el-option label="固定金额" value="FIXED" />
</el-select>
<div class="control">
<el-select v-model="row.chargeMode" size="small">
<el-option label="同正向运费" value="SAME" />
<el-option label="空运退回" value="AIR" />
<el-option label="陆运退回" value="ROAD" />
<el-option label="固定金额" value="FIXED" />
</el-select>
</div>
</div>
<div class="field">
<span class="label">重量 kg</span>
<el-input v-model="row.weightMin" size="small" placeholder="下限" />
<span class="sep">至</span>
<el-input v-model="row.weightMax" size="small" placeholder="上限" />
<div class="control">
<el-input v-model="row.weightMin" size="small" placeholder="下限" />
<span class="sep">至</span>
<el-input v-model="row.weightMax" size="small" placeholder="上限" />
</div>
</div>
<div class="field">
<span class="label">尺寸命中</span>
<el-select v-model="row.matchMode" size="small" clearable placeholder="退件还看尺寸时选择">
<el-option label="任一命中" value="ANY" />
<el-option label="全部命中" value="ALL" />
</el-select>
<div class="control">
<el-select v-model="row.matchMode" size="small" clearable placeholder="退件还看尺寸时选择">
<el-option label="任一命中" value="ANY" />
<el-option label="全部命中" value="ALL" />
</el-select>
</div>
</div>
<div class="field">
<span class="label">最长边 cm</span>
<el-input v-model="row.longestMin" size="small" placeholder="下限" />
<span class="sep">至</span>
<el-input v-model="row.longestMax" size="small" placeholder="上限" />
<div class="control">
<el-input v-model="row.longestMin" size="small" placeholder="下限" />
<span class="sep">至</span>
<el-input v-model="row.longestMax" size="small" placeholder="上限" />
</div>
</div>
<div class="field">
<span class="label">围长 cm</span>
<el-input v-model="row.girthMin" size="small" placeholder="下限" />
<span class="sep">至</span>
<el-input v-model="row.girthMax" size="small" placeholder="上限" />
<div class="control">
<el-input v-model="row.girthMin" size="small" placeholder="下限" />
<span class="sep">至</span>
<el-input v-model="row.girthMax" size="small" placeholder="上限" />
</div>
</div>
<div class="field">
<span class="label">三边之和 cm</span>
<el-input v-model="row.sumMin" size="small" placeholder="下限" />
<span class="sep">至</span>
<el-input v-model="row.sumMax" size="small" placeholder="上限" />
<div class="control">
<el-input v-model="row.sumMin" size="small" placeholder="下限" />
<span class="sep">至</span>
<el-input v-model="row.sumMax" size="small" placeholder="上限" />
</div>
</div>
<div class="field span-2">
<div class="field">
<span class="label">说明</span>
<el-input v-model="row.remark" size="small" placeholder="例如拒收、地址错误退回" />
<div class="control">
<el-input v-model="row.remark" size="small" placeholder="例如拒收、地址错误退回" />
</div>
</div>
</div>
</div>
@@ -265,7 +364,6 @@
</div>
<template #footer>
<el-button :disabled="!products.length" :loading="copying" @click="copyPrevious">复制上一版本</el-button>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
@@ -273,7 +371,7 @@
</template>
<script setup>
import { listPay, listPaySurchargeRule, previousPaySurchargeRule, savePaySurchargeRule } from '@/api/price/pay'
import { getPay, listPay, listPaySurchargeRule, savePaySurchargeRule } from '@/api/price/pay'
const { proxy } = getCurrentInstance()
@@ -293,12 +391,20 @@ const ruleDefs = [
label: '超重续收',
hint: '超过某个重量后只收超出部分。偏远按公斤且有最低消费的,把邮编和最低消费写在这一条。'
},
{ value: 'POSTCODE', label: '命中邮编', switchOnly: true },
{ value: 'ZONE', label: '命中分区', switchOnly: true },
{
value: 'POSTCODE',
label: '命中邮编',
hint: '按附加费里的高、中、低区填写邮编前缀。订单邮编命中这一区,才加这一区的附加费。前缀、完整邮编和区间都认。国家不填则不限目的国。'
},
{
value: 'ZONE',
label: '命中分区',
hint: '写法和命中邮编一样,表示这些邮编属于哪个地区。命中后按该地区的附加费收取。运费用的地区仍按应付邮编配置。'
},
{
value: 'ALWAYS',
label: '每票都收',
hint: '燃油、去碳、道路通行、旺季这类。按比例的填写比例和基数,旺季填写生效日期。'
hint: '每一条都要选费用项,没被选中的费用不会按每票收取。燃油、去碳按比例填写比例和基数,旺季再填生效日期。'
},
{
value: 'RETURN',
@@ -309,11 +415,12 @@ const ruleDefs = [
const visible = ref(false)
const saving = ref(false)
const copying = ref(false)
const searching = ref(false)
const keyword = ref('')
const found = ref([])
const products = ref([])
const feeOptions = ref([])
const zoneOptions = ref([])
const enabled = reactive({})
const groups = reactive({})
@@ -327,8 +434,11 @@ function blank(rule) {
if (rule === 'EXTRA_KG') {
return { thresholdKg: '', stepKg: '1', weightBasis: 'ACTUAL', minCharge: '', postcodes: '' }
}
if (rule === 'POSTCODE' || rule === 'ZONE') {
return { zoneName: '', postcodes: '', country: '' }
}
if (rule === 'ALWAYS') {
return { rateMode: 'FIXED', ratio: '', chargeBase: '', periodStart: '', periodEnd: '', remark: '' }
return { feeName: '', scId: '', rateMode: 'FIXED', ratio: '', chargeBase: '', periodStart: '', periodEnd: '', remark: '' }
}
return { chargeMode: 'SAME', weightMin: '', weightMax: '', matchMode: '', longestMin: '', longestMax: '', girthMin: '', girthMax: '', sumMin: '', sumMax: '', remark: '' }
}
@@ -344,9 +454,10 @@ function reset() {
keyword.value = ''
found.value = []
products.value = []
feeOptions.value = []
zoneOptions.value = []
blankGroups()
saving.value = false
copying.value = false
searching.value = false
}
@@ -357,32 +468,82 @@ function open(rows) {
for (const row of rows || []) addProduct(row, skipped)
if (skipped.length) proxy.$modal.msgWarning('有产品还没有生效版本,未带入:' + skipped.join('、'))
const first = products.value[0]
if (first) loadRules(first.productId, first.termId)
if (first) loadRules(first.productId)
}
function versionOf(row) {
return {
key: String(row.versionProductId || row.productId) + ':' + String(row.termId || ''),
productId: row.versionProductId || row.productId,
termId: row.termId,
versionNo: row.versionNo,
key: String(row.productId),
productId: row.productId,
feeProductId: row.versionProductId || row.productId,
productName: row.productName || '未命名产品'
}
}
function addProduct(row, skipped) {
const item = versionOf(row)
if (!item.productId || !item.termId) {
if (!item.productId) {
if (skipped) skipped.push(item.productName)
else proxy.$modal.msgWarning(item.productName + '还没有生效版本,不能绑定')
else proxy.$modal.msgWarning(item.productName + '不能绑定')
return
}
if (products.value.some(product => product.key === item.key)) return
products.value.push(item)
loadFees()
}
function removeProduct(item) {
products.value = products.value.filter(product => product.key !== item.key)
loadFees()
}
let feeToken = 0
function loadFees() {
const token = ++feeToken
const bound = products.value.slice()
if (!bound.length) {
feeOptions.value = []
zoneOptions.value = keepZones([])
return
}
Promise.all(bound.map(item => getPay(item.feeProductId || item.productId).catch(() => null))).then(results => {
if (token !== feeToken) return
const map = new Map()
const zones = new Set()
results.forEach(res => {
const rows = res?.data?.surcharges || []
rows.forEach(fee => {
const name = String(fee.feeName || '').trim()
const zone = String(fee.zoneName || '').trim()
if (zone) zones.add(zone)
if (!name || map.has(name)) return
map.set(name, { feeName: name, scId: fee.scId || '' })
})
})
zoneOptions.value = keepZones([...zones])
for (const row of groups.ALWAYS || []) {
const name = String(row.feeName || '').trim()
if (name && !map.has(name)) map.set(name, { feeName: name, scId: row.scId || '' })
}
feeOptions.value = [...map.values()]
})
}
function keepZones(zones) {
const set = new Set(zones)
for (const rule of ['POSTCODE', 'ZONE']) {
for (const row of groups[rule] || []) {
const zone = String(row.zoneName || '').trim()
if (zone) set.add(zone)
}
}
return [...set]
}
function pickFee(row, name) {
const found = feeOptions.value.find(item => item.feeName === name)
row.scId = found?.scId || ''
}
function termText(row) {
@@ -422,7 +583,8 @@ function applyRules(list) {
const row = blank(rule)
const detail = item.detail || {}
Object.keys(row).forEach(key => {
if (detail[key] !== undefined && detail[key] !== null) row[key] = String(detail[key])
if (detail[key] === undefined || detail[key] === null) return
row[key] = key === 'periodStart' || key === 'periodEnd' ? asDate(detail[key]) : String(detail[key])
})
groups[rule].push(row)
}
@@ -430,10 +592,26 @@ function applyRules(list) {
if (rule.switchOnly) continue
if (enabled[rule.value] && !groups[rule.value].length) groups[rule.value].push(blank(rule.value))
}
const map = new Map(feeOptions.value.map(item => [item.feeName, item]))
for (const row of groups.ALWAYS || []) {
const name = String(row.feeName || '').trim()
if (name && !map.has(name)) map.set(name, { feeName: name, scId: row.scId || '' })
}
feeOptions.value = [...map.values()]
zoneOptions.value = keepZones(zoneOptions.value)
}
function loadRules(productId, termId) {
listPaySurchargeRule(productId, termId).then(res => applyRules(res.data || []))
function asDate(value) {
const text = String(value).trim()
if (/^\d{4}-\d{2}-\d{2}/.test(text)) return text.slice(0, 10)
const monthDay = text.match(/^(\d{1,2})-(\d{1,2})$/)
if (!monthDay) return ''
const year = new Date().getFullYear()
return `${year}-${monthDay[1].padStart(2, '0')}-${monthDay[2].padStart(2, '0')}`
}
function loadRules(productId) {
listPaySurchargeRule(productId).then(res => applyRules(res.data || []))
}
const openedRules = computed(() => ruleDefs.filter(rule => enabled[rule.value] && !rule.switchOnly))
@@ -465,39 +643,45 @@ function payloadRules() {
return rules
}
function copyPrevious() {
const first = products.value[0]
if (!first) return
copying.value = true
previousPaySurchargeRule(first.productId).then(res => {
const list = res.data || []
if (!list.length) {
proxy.$modal.msgWarning('上一版本没有计费条件')
return
}
applyRules(list)
proxy.$modal.msgSuccess('已带入上一版本,保存后写到当前版本')
}).finally(() => {
copying.value = false
})
}
function save() {
if (!products.value.length) {
proxy.$modal.msgError('请选择要绑定的产品')
return
}
for (const rule of ['POSTCODE', 'ZONE']) {
if (!enabled[rule]) continue
const label = rule === 'POSTCODE' ? '命中邮编' : '命中分区'
for (const row of groups[rule]) {
if (!String(row.zoneName || '').trim() || !String(row.postcodes || '').trim()) {
proxy.$modal.msgError(label + '要选择分区,并填写邮编前缀')
return
}
}
}
if (enabled.ALWAYS) {
const names = new Set()
for (const row of groups.ALWAYS) {
const name = String(row.feeName || '').trim()
if (!name) {
proxy.$modal.msgError('每票都收要选择费用项')
return
}
if (names.has(name)) {
proxy.$modal.msgError('每票都收的费用项不能重复:' + name)
return
}
names.add(name)
}
}
saving.value = true
savePaySurchargeRule({
products: products.value.map(item => ({
productId: item.productId,
termId: item.termId,
versionNo: item.versionNo,
productName: item.productName
})),
rules: payloadRules()
}).then(() => {
proxy.$modal.msgSuccess('已保存到当前版本')
proxy.$modal.msgSuccess('已保存,计算时按当前生效版本上的附加费执行')
visible.value = false
}).finally(() => {
saving.value = false
@@ -519,12 +703,16 @@ defineExpose({ open })
.rule-switches { display: flex; flex-wrap: wrap; gap: 12px 20px; margin-top: 8px; }
.rule-switch { display: flex; align-items: center; gap: 8px; }
.rule-bar, .rule-card-bar { display: flex; align-items: center; justify-content: space-between; }
.rule-card { border: 1px solid var(--el-border-color); border-radius: 6px; padding: 8px 12px 12px; margin-bottom: 10px; }
.rule-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 16px; }
.field { display: flex; align-items: center; gap: 6px; min-width: 0; }
.field .label { width: 118px; flex: none; font-size: 13px; color: var(--el-text-color-regular); }
.field .el-input, .field .el-select { flex: 1; min-width: 0; }
.field .sep { flex: none; color: var(--el-text-color-secondary); }
.field .mode { flex: 0 0 118px; }
.span-2 { grid-column: 1 / -1; }
.rule-card { border: 1px solid var(--el-border-color); border-radius: 6px; padding: 12px 20px 12px 28px; margin-bottom: 10px; overflow: visible; }
.rule-grid { display: grid; grid-template-columns: 1fr 1fr; column-gap: 48px; row-gap: 12px; align-items: center; }
.field { display: grid; grid-template-columns: 104px minmax(0, 1fr); column-gap: 8px; align-items: center; min-width: 0; }
.field .label { width: 104px; min-width: 0; justify-self: end; text-align: right; white-space: nowrap; overflow: visible; font-size: 13px; line-height: 24px; color: var(--el-text-color-regular); }
.control { display: flex; align-items: center; gap: 6px; min-width: 0; }
.control :deep(.el-input),
.control :deep(.el-select),
.control :deep(.el-textarea),
.control :deep(.el-date-editor) { flex: 1 1 0; width: 0 !important; min-width: 0; max-width: none; }
.control :deep(.mode) { flex: 1.7 1 0; }
.control :deep(.num) { flex: 0.75 1 0; }
.field .sep { flex: none; color: var(--el-text-color-secondary); line-height: 24px; }
</style>
+62 -21
View File
@@ -8,6 +8,7 @@
:search-per-row="4"
search-action-align="right"
row-key="productId"
scrollbar-always-on
>
<template #toolbar="{ selection, ids }">
<el-col :span="1.5">
@@ -87,31 +88,34 @@
<el-table-column label="生效结束" width="110" align="center">
<template #default="{ row }">{{ dayText(row.effectiveEnd) || '不限' }}</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-switch
v-if="row.termId"
:model-value="row.status === '1' ? '1' : '2'"
active-value="1"
inactive-value="2"
inline-prompt
active-text="生效"
inactive-text="失效"
@change="(val) => changeStatus(row, 'status', val)"
v-hasPermi="['price:pay:edit']"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="210" align="center">
<el-table-column label="操作" width="220" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="downloadSource(row)" v-hasPermi="['price:pay:query']">下载</el-button>
<el-button link type="primary" @click="openTermDialog(row)" v-hasPermi="['price:pay:edit']">修改有效期</el-button>
<el-button link type="primary" @click="openRuleLog(row)" v-hasPermi="['price:pay:query']">附加费记录</el-button>
<el-button link type="primary" @click="openDetail(row)" v-hasPermi="['price:pay:query']">查看</el-button>
</template>
</el-table-column>
</el-table>
</el-drawer>
<el-dialog v-model="ruleLogVisible" :title="ruleLogTitle" width="860px" append-to-body>
<el-table v-loading="ruleLogLoading" :data="ruleLogRows" border size="small" empty-text="这一版还没有附加费配置记录">
<el-table-column label="操作" width="70" align="center">
<template #default="{ row }">{{ ruleLogAction(row.operationType) }}</template>
</el-table-column>
<el-table-column label="更改前" min-width="220" show-overflow-tooltip>
<template #default="{ row }">{{ ruleLogSide(row, 'before') }}</template>
</el-table-column>
<el-table-column label="更改后" min-width="220" show-overflow-tooltip>
<template #default="{ row }">{{ ruleLogSide(row, 'after') }}</template>
</el-table-column>
<el-table-column prop="createBy" label="操作人" width="100" align="center" />
<el-table-column label="操作时间" width="160" align="center">
<template #default="{ row }">{{ row.createTime || '-' }}</template>
</el-table-column>
</el-table>
</el-dialog>
<el-dialog v-model="termVisible" title="修改有效期" width="460px" append-to-body>
<el-form label-width="90px">
<el-form-item label="生效开始" required>
@@ -188,7 +192,7 @@
</template>
<script setup name="PricePay">
import { listPay, getPay, delPay, updatePay, listPayTerm, updatePayTerm, changePayTermStatus, comparePay } from '@/api/price/pay'
import { listPay, getPay, delPay, updatePay, listPayTerm, updatePayTerm, changePayTermStatus, comparePay, listPaySurchargeRuleLog } from '@/api/price/pay'
import PayImportDialog from './components/PayImportDialog.vue'
import PayConfigDetail from './components/PayConfigDetail.vue'
import PaySurchargeRuleDialog from './components/PaySurchargeRuleDialog.vue'
@@ -207,6 +211,10 @@ const historyRows = ref([])
const historyTitle = ref('价格版本')
const historyProductId = ref(null)
const historyPicked = ref([])
const ruleLogVisible = ref(false)
const ruleLogLoading = ref(false)
const ruleLogRows = ref([])
const ruleLogTitle = ref('附加费配置记录')
const compareVisible = ref(false)
const compareLoading = ref(false)
const compareTitle = ref('版本比对')
@@ -233,8 +241,7 @@ const statusOptions = [
]
const columns = [
{ label: '产品编号', prop: 'productId', align: 'center', width: 90 },
{ label: '产品名称', prop: 'productName', align: 'center', minWidth: 150, showOverflowTooltip: true },
{ label: '产品名称', prop: 'productName', align: 'center', minWidth: 180, showOverflowTooltip: true, fixed: 'left' },
{ label: '价卡归属', prop: 'supplierName', align: 'center', width: 100 },
{ label: '国家/口岸', prop: 'countryScope', align: 'center', minWidth: 120, showOverflowTooltip: true },
{ label: '费用类型', prop: 'chargeType', align: 'center', width: 110 },
@@ -384,9 +391,43 @@ function termText(row) {
return `${start || '不限'} 至 ${end || '不限'}`
}
function openRuleLog(row) {
ruleLogTitle.value = `${row.productName || '产品'} 第 ${row.versionNo || '-'} 版的附加费配置记录`
ruleLogRows.value = []
ruleLogVisible.value = true
ruleLogLoading.value = true
listPaySurchargeRuleLog(row.productId, row.termId).then(res => {
ruleLogRows.value = res.data || []
}).finally(() => {
ruleLogLoading.value = false
})
}
function ruleLogAction(type) {
if (type === '1') return '新增'
if (type === '2') return '修改'
if (type === '3') return '删除'
return '-'
}
function ruleLogSide(row, side) {
let content = row.changeContent
if (typeof content === 'string') {
try {
content = JSON.parse(content)
} catch (error) {
return side === 'after' ? content : '-'
}
}
const values = content?.[side]
if (!values || typeof values !== 'object') return '-'
const text = Object.entries(values).map(([label, value]) => `${label}:${value}`).join(';')
return text || '-'
}
function openHistory(row) {
historyProductId.value = row.productId
historyTitle.value = `${row.productName || '产品'}(编号 ${row.productId})的价格版本`
historyTitle.value = `${row.productName || '产品'}的价格版本`
historyPicked.value = []
historyVisible.value = true
loadHistory()