偏远地区邮编判断调整

This commit is contained in:
liuminghui
2026-11-09 15:58:27 +08:00
parent 9220696519
commit b8b943ef51
26 changed files with 1431 additions and 60 deletions
@@ -6,22 +6,30 @@ import lombok.Data;
@Data
public class PayBillCommand
{
/** ORDER、TIME、LADING */
/** 取数方式:ORDER 订单号,TIME 计费日,LADING 提单号 */
private String sourceType;
/** 订单号原文,可用逗号、空格或换行分隔 */
private String orderText;
/** 计费日起,yyyy-MM-dd。sourceType 为 TIME 时必填 */
private String beginDate;
/** 计费日止,yyyy-MM-dd,含当天 */
private String endDate;
/** 提单号原文,可用逗号、空格或换行分隔 */
private String ladingText;
/** 供应商ID。生成账单时必填,预览不用 */
private Long supplierId;
/** 账单备注 */
private String remark;
/** 作废原因。作废时必填 */
private String voidReason;
/** 要作废的应付账单ID */
private Long billId;
}
@@ -8,11 +8,15 @@ import lombok.Data;
@Data
public class PayBillPreview
{
/** 还没进未作废账单、可以生成的订单 */
private List<PayBillOrder> orders = new ArrayList<>();
/** 按订单号查询时没有找到的单号 */
private List<String> missingNos = new ArrayList<>();
/** 已经挂在未作废账单上的订单 */
private List<PayBillOrder> occupied = new ArrayList<>();
/** 给页面的提示,例如未找到订单或超过单次票数 */
private String message;
}
@@ -51,11 +51,20 @@ public class PaySurcharge
/** 固定收取、产生时收取、旺季期间 */
private String chargeTiming;
/** ALWAYS、POSTCODE、WEIGHT、SIZE、RETURN、EXTRA_KG、MANUAL */
/** ALWAYS、POSTCODE、ZONE、WEIGHT、SIZE、RETURN、EXTRA_KG、MANUAL */
private String calcRule;
/** 邮编列表、重量阈值或尺寸表达式 */
/** 邮编列表、重量阈值或尺寸表达式。续重时这里是公斤阈值 */
private String calcParam;
/** 偏远邮编规则。续重行的阈值在 calcParam,邮编放这里,避免互相覆盖 */
private String postcodeParam;
/** 适用重量下限,空表示不限。首重从 0 开始 */
private BigDecimal weightMin;
/** 适用重量上限,空表示不限。分区价按这个区间只收一档 */
private BigDecimal weightMax;
private Integer sortNo;
}
@@ -8,26 +8,90 @@ import com.bms.price.domain.PayBillLog;
import com.bms.price.domain.PayBillOrder;
import com.bms.price.domain.PrcPayBillFee;
/** 供应商应付账单 pay_bill 及其明细、日志。 */
public interface PayBillMapper
{
/**
* 按账单号、供应商、状态、币种查询账单头。
*
* @param query 查询条件
* @return 账单头列表
*/
List<PayBill> selectPayBillList(PayBill query);
/**
* 按主键读取一张账单头。
*
* @param billId 应付账单ID
* @return 账单头,不存在时为 null
*/
PayBill selectPayBillById(Long billId);
/**
* 读取一张账单的费用快照。
*
* @param billId 应付账单ID
* @return 费用行
*/
List<PayBillLine> selectLines(Long billId);
/**
* 读取一张账单的操作日志。
*
* @param billId 应付账单ID
* @return 按写入顺序排列的日志
*/
List<PayBillLog> selectLogs(Long billId);
/**
* 按订单号、计费日或提单号查找可出账订单,并标出已挂在未作废账单上的账单号。
*
* @param orderNos 订单号,空则不按订单号限制
* @param beginDate 计费日起,yyyy-MM-dd,空则不限制
* @param endDate 计费日止,含当天,空则不限制
* @param ladingNos 提单号,同时匹配订单上的航空主单号和应付提单
* @return 最多 2000 票订单
*/
List<PayBillOrder> selectCandidateOrders(@Param("orderNos") List<String> orderNos, @Param("beginDate") String beginDate,
@Param("endDate") String endDate, @Param("ladingNos") List<String> ladingNos);
/**
* 读取这些订单当前的应付费用,供生成账单时抄成快照。
*
* @param billIds 计费订单ID
* @return 订单应付费用
*/
List<PrcPayBillFee> selectOrderFees(@Param("billIds") List<String> billIds);
/**
* 插入一张草稿应付账单。
*
* @param bill 账单头,账单号和供应商由调用方填好
* @return 影响行数
*/
int insertPayBill(PayBill bill);
/**
* 更新账单状态。作废时同时写原因、作废人和作废时间。
*
* @param bill 至少包含账单ID、状态和更新人
* @return 影响行数
*/
int updatePayBillStatus(PayBill bill);
/**
* 批量写入账单费用快照。
*
* @param list 费用行,每行已带账单ID
* @return 影响行数
*/
int insertLines(@Param("list") List<PayBillLine> list);
/**
* 写入一条账单操作日志。
*
* @param log 操作类型、前后状态和说明
* @return 影响行数
*/
int insertLog(PayBillLog log);
}
@@ -24,6 +24,9 @@ public interface PayPostcodeMapper
List<PayPostcodeGroup> selectGroupsByTokens(@Param("tokens") List<String> tokens);
/** 报价表、销售产品或工作表与产品名称完全一致的邮编配置 */
List<PayPostcodeGroup> selectGroupsForProduct(@Param("names") List<String> names);
List<PayPostcodeLine> selectRegionKeys(@Param("groupIds") List<Long> groupIds);
List<PayPostcodeLine> selectLinesByGroups(@Param("groupIds") List<Long> groupIds);
@@ -137,4 +137,7 @@ public interface PrcOrderBillMapper
/** 只更新应付合计,不改应收。 */
int updatePayTotal(@Param("billId") String billId, @Param("payTotal") BigDecimal payTotal);
/** Count order bills that reference this sales package version. */
int countByPsvId(@Param("psvId") Long psvId);
}
@@ -4,14 +4,38 @@ import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.bms.price.domain.PrcPayBillFee;
/** 应付账单费用 mapper */
/** 订单应付费用 prc_pay_bill_fee。 */
public interface PrcPayBillFeeMapper
{
/**
* 读取一票订单的全部应付费用,运费在前,附加费在后。
*
* @param billId 计费订单ID
* @return 费用明细
*/
public List<PrcPayBillFee> selectByBillId(String billId);
/**
* 插入一条订单应付费用。
*
* @param fee 费用类型、名称、金额和币种
* @return 影响行数
*/
public int insertPrcPayBillFee(PrcPayBillFee fee);
/**
* 批量插入订单应付费用。
*
* @param fees 本次要保存的费用
* @return 影响行数
*/
public int insertBatch(@Param("list") List<PrcPayBillFee> fees);
/**
* 删除一票订单上来源为计算的费用。提单分摊和人工费用保留。
*
* @param billId 计费订单ID
* @return 影响行数
*/
public int deleteByBillId(String billId);
}
@@ -2,18 +2,54 @@ package com.bms.price.mapper;
import com.bms.price.domain.PrcPayBill;
/** prc_pay_bill mapper */
/** 订单应付主表 prc_pay_bill。一票计费订单对应一行。 */
public interface PrcPayBillMapper
{
/**
* 按计费订单读取应付主表。
*
* @param billId 计费订单ID
* @return 订单应付,没有时为 null
*/
PrcPayBill selectByBillId(String billId);
/**
* 按应付主表ID读取。
*
* @param payBillId 订单应付ID
* @return 订单应付,没有时为 null
*/
PrcPayBill selectByPayBillId(String payBillId);
/**
* 新增一票订单的应付主表。
*
* @param row 应付合计、币种和业务员
* @return 影响行数
*/
int insertPrcPayBill(PrcPayBill row);
/**
* 更新已有订单应付的合计和业务员。
*
* @param row 含应付主表ID的更新内容
* @return 影响行数
*/
int updatePrcPayBill(PrcPayBill row);
/**
* 按计费订单插入或更新应付主表。
*
* @param row 订单应付
* @return 影响行数
*/
int upsertPrcPayBill(PrcPayBill row);
/**
* 删除一票订单的应付主表。
*
* @param billId 计费订单ID
* @return 影响行数
*/
int deleteByBillId(String billId);
}
@@ -28,14 +28,62 @@ public final class PayCostCalculator
{
}
/**
* 按重量、尺寸和邮编计算一票尾程应付。不指定地区,也不传入偏远命中结果。
*
* @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<>());
@@ -52,7 +100,12 @@ public final class PayCostCalculator
result.setMessage("订单没有计费重");
return result;
}
List<PayWeightBand> bands = forZone(usableBands(product.getWeightBands()), zoneName);
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 + "」不在价卡重量段里");
@@ -88,7 +141,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(), result);
band.getSectionName(), remoteHits, result);
BigDecimal total = BigDecimal.ZERO;
for (PayCostLine fee : result.getFees())
{
@@ -99,6 +152,79 @@ public final class PayCostCalculator
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())
@@ -118,7 +244,7 @@ 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, PayOrderCostResult result)
String freightSection, List<PayRemoteBook.Hit> remoteHits, PayOrderCostResult result)
{
if (product.getSurcharges() == null)
{
@@ -131,24 +257,59 @@ public final class PayCostCalculator
{
continue;
}
if (zoneName != null && !zoneName.isBlank() && fee.getZoneName() != null && !fee.getZoneName().isBlank()
&& !PayZoneMatcher.sameZone(fee.getZoneName(), zoneName))
{
continue;
}
String rule = fee.getCalcRule() == null || fee.getCalcRule().isBlank() ? PaySurchargeRules.MANUAL : fee.getCalcRule();
String name = blank(fee.getFeeName());
if (PaySurchargeRules.POSTCODE.equals(rule))
if (remoteHits != null && remoteFee(fee))
{
String name = blank(fee.getFeeName());
if (postcode == null || postcode.isBlank())
{
result.getSkipped().add(name + ":订单没有邮编");
}
else if (PaySurchargeRules.matchesPostcode(fee.getCalcParam(), postcode))
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))
@@ -180,7 +341,18 @@ public final class PayCostCalculator
}
else if (PaySurchargeRules.EXTRA_KG.equals(rule))
{
if (sameSection(fee.getSectionName(), freightSection))
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);
}
@@ -278,7 +450,8 @@ public final class PayCostCalculator
}
for (PaySurcharge fee : product.getSurcharges())
{
if (fee == null || !PaySurchargeRules.EXTRA_KG.equals(fee.getCalcRule()) || fee.getAmount() == null)
if (fee == null || !PaySurchargeRules.EXTRA_KG.equals(fee.getCalcRule()) || fee.getAmount() == null
|| !blank(fee.getPostcodeParam()).isEmpty())
{
continue;
}
@@ -319,6 +492,90 @@ public final class PayCostCalculator
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);
@@ -0,0 +1,177 @@
package com.bms.price.pay;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.bms.price.domain.PayPostcodeGroup;
import com.bms.price.domain.PayPostcodeLine;
/**
* 产品在应付邮编配置里的偏远名单。报价表或销售产品与价卡一致时,这份名单决定是否加偏远附加费。
*/
public final class PayRemoteBook
{
private static final Pattern AMOUNT = Pattern.compile("(\\d+(?:\\.\\d+)?)");
private PayRemoteBook()
{
}
/** 命中的一条偏远邮编,带上这一组的费用名和金额。 */
public static final class Hit
{
private final String feeName;
private final BigDecimal amount;
private final String zoneName;
private final String regionName;
public Hit(String feeName, BigDecimal amount, String zoneName, String regionName)
{
this.feeName = feeName == null ? "" : feeName;
this.amount = amount;
this.zoneName = zoneName == null ? "" : zoneName;
this.regionName = regionName == null ? "" : regionName;
}
public String getFeeName()
{
return feeName;
}
public BigDecimal getAmount()
{
return amount;
}
public String getZoneName()
{
return zoneName;
}
public String getRegionName()
{
return regionName;
}
}
public static boolean remoteGroup(PayPostcodeGroup group)
{
if (group == null)
{
return false;
}
String coverage = group.getCoverage() == null ? "" : group.getCoverage().trim();
if ("REMOTE".equalsIgnoreCase(coverage))
{
return true;
}
if ("REACH".equalsIgnoreCase(coverage) || "UNREACH".equalsIgnoreCase(coverage))
{
return false;
}
String fee = group.getFeeName() == null ? "" : group.getFeeName();
return fee.contains("偏远") || fee.toLowerCase(Locale.ROOT).contains("remote");
}
/**
* 没有偏远配置时返回 null,沿用价卡上的邮编条件。
* 有配置但邮编没命中时返回空列表,不加偏远附加费。
*/
public static List<Hit> hits(List<PayPostcodeGroup> groups, List<PayPostcodeLine> lines, String postcode,
String countryCode, String countryName)
{
if (groups == null || groups.isEmpty())
{
return null;
}
boolean anyRemote = false;
Map<Long, PayPostcodeGroup> remote = new LinkedHashMap<>();
for (PayPostcodeGroup group : groups)
{
if (group == null || group.getGroupId() == null || !remoteGroup(group))
{
continue;
}
anyRemote = true;
if (sameCountry(group, countryCode, countryName))
{
remote.put(group.getGroupId(), group);
}
}
if (!anyRemote)
{
return null;
}
if (remote.isEmpty())
{
return new ArrayList<>();
}
if (postcode == null || postcode.isBlank() || lines == null || lines.isEmpty())
{
return new ArrayList<>();
}
List<Hit> hits = new ArrayList<>();
for (PayPostcodeLine line : lines)
{
if (line == null || !remote.containsKey(line.getGroupId()) || !PayZoneMatcher.matchesLine(line, postcode))
{
continue;
}
PayPostcodeGroup group = remote.get(line.getGroupId());
String region = first(line.getRegionName(), line.getRegionNameCn());
hits.add(new Hit(group.getFeeName(), amountOf(group.getFeeText()), line.getZoneName(), region));
}
return hits;
}
public static BigDecimal amountOf(String feeText)
{
if (feeText == null || feeText.isBlank())
{
return null;
}
Matcher matcher = AMOUNT.matcher(feeText);
if (!matcher.find())
{
return null;
}
return new BigDecimal(matcher.group(1));
}
private static boolean sameCountry(PayPostcodeGroup group, String countryCode, String countryName)
{
boolean orderBlank = blank(countryCode) && blank(countryName);
boolean groupBlank = blank(group.getCountryCode()) && blank(group.getCountryName());
if (orderBlank || groupBlank)
{
return true;
}
if (!blank(countryCode) && countryCode.equalsIgnoreCase(group.getCountryCode().trim()))
{
return true;
}
return !blank(countryName) && countryName.trim().equals(group.getCountryName() == null ? "" : group.getCountryName().trim());
}
private static String first(String left, String right)
{
if (left != null && !left.isBlank())
{
return left.trim();
}
return right == null ? "" : right.trim();
}
private static boolean blank(String value)
{
return value == null || value.isBlank();
}
}
@@ -29,6 +29,11 @@ public final class PaySurchargeRules
/** 超过重量段上限后,按超出的公斤数乘单价 */
public static final String EXTRA_KG = "EXTRA_KG";
/** 邮编已经落到这个分区,再按重量区间收取,例如英国高地 */
public static final String ZONE = "ZONE";
private static final Pattern REMOTE_PREFIX = Pattern.compile("(\\d{1,5}(?:\\s*[、,,]\\s*\\d{1,5})*)\\s*邮编");
private static final Pattern RANGE = Pattern.compile("([A-Z]{1,2})(\\d{1,2})\\s*-\\s*(\\d{1,2})");
private static final Pattern DISTRICT = Pattern.compile("([A-Z]{1,2})(\\d{1,2})");
@@ -107,14 +112,24 @@ public final class PaySurchargeRules
public static boolean matchesPostcode(String param, String postcode)
{
UkPostcode parsed = parseUk(postcode);
if (parsed == null || param == null || param.isBlank())
if (param == null || param.isBlank() || postcode == null || postcode.isBlank())
{
return false;
}
UkPostcode parsed = parseUk(postcode);
String digits = postcode.replaceAll("\\D", "");
for (String token : param.split(","))
{
if (tokenMatches(token.trim().toUpperCase(Locale.ROOT), parsed))
String value = token.trim().toUpperCase(Locale.ROOT);
if (value.isEmpty())
{
continue;
}
if (parsed != null && tokenMatches(value, parsed))
{
return true;
}
if (parsed == null && value.matches("\\d{1,5}") && !digits.isEmpty() && digits.startsWith(value))
{
return true;
}
@@ -122,6 +137,28 @@ public final class PaySurchargeRules
return false;
}
/** 从“07邮编开头”“35、38邮编开头”取出数字前缀。没有邮编字样时不猜。 */
public static String remotePrefixes(String text)
{
if (text == null || !text.contains("邮编"))
{
return "";
}
List<String> tokens = new ArrayList<>();
Matcher matcher = REMOTE_PREFIX.matcher(text);
while (matcher.find())
{
for (String part : matcher.group(1).split("[、,,\\s]+"))
{
if (part.matches("\\d{1,5}") && !tokens.contains(part))
{
tokens.add(part);
}
}
}
return String.join(",", tokens);
}
public static boolean matchesWeight(String param, BigDecimal weight)
{
if (param == null || param.isBlank() || weight == null)
@@ -212,7 +249,7 @@ public final class PaySurchargeRules
}
int cut = Math.max(desc.lastIndexOf(';'), desc.lastIndexOf(';'));
String source = cut >= 0 ? desc.substring(cut + 1) : desc;
String upper = source.toUpperCase(Locale.ROOT).replace(',', ',').replace('、', ',').replace('.', ',');
String upper = source.replace("邮编开头", " ").toUpperCase(Locale.ROOT).replace(',', ',').replace('、', ',').replace('.', ',');
List<String> tokens = new ArrayList<>();
for (String raw : upper.split("[,\\s]+"))
{
@@ -454,6 +454,9 @@ public class PayWorkbookParser
copy.setChargeTiming(fee.getChargeTiming());
copy.setCalcRule(fee.getCalcRule());
copy.setCalcParam(fee.getCalcParam());
copy.setPostcodeParam(fee.getPostcodeParam());
copy.setWeightMin(fee.getWeightMin());
copy.setWeightMax(fee.getWeightMax());
return copy;
}
@@ -1250,6 +1253,7 @@ public class PayWorkbookParser
kind[col] = columnKind(head, numericBelow(grid, col, data));
}
int added = product.getSurcharges().size();
Map<String, BigDecimal> includedKg = new LinkedHashMap<>();
while (data < grid.rows())
{
if (grid.blankRow(data) || isHardHeader(grid, data))
@@ -1296,8 +1300,7 @@ public class PayWorkbookParser
String desc = country.isBlank() ? weight : country + ";" + weight;
addSurcharge(product, feeName, feeName, zone, standard.trim(), desc, "");
PaySurcharge fee = product.getSurcharges().get(product.getSurcharges().size() - 1);
fee.setCalcRule(PaySurchargeRules.MANUAL);
fee.setCalcParam(weight);
applyFeeRateRule(fee, feeName, zone, weight, includedKg);
}
if (!priced && joinRow(grid, data).length() > 40)
{
@@ -1452,6 +1455,11 @@ public class PayWorkbookParser
}
}
priced = true;
if (isSurchargePriceHead(parent[col]))
{
addPricedSurcharge(product, blankTo(section, "价格"), parent[col], zoneOf(sub[col], parent[col]), weight, price);
continue;
}
boolean feeHead = isPriceHead(parent[col]);
PayWeightBand band = new PayWeightBand();
band.setSectionName(blankTo(section, "价格"));
@@ -4156,6 +4164,60 @@ public class PayWorkbookParser
|| lower.contains("line haul");
}
/**
* 偏远行按邮编前缀计费:首重整段收取,每增加只收超出部分。没有邮编列表的费用保持人工。
*/
private static void applyFeeRateRule(PaySurcharge fee, String feeName, String zone, String weight, Map<String, BigDecimal> includedKg)
{
boolean remote = feeName != null && feeName.contains("偏远");
boolean step = weight != null && (weight.contains("每增加") || weight.toLowerCase(Locale.ROOT).contains("additional"));
BigDecimal[] bounds = PayTexts.weightBounds(weight);
String prefixes = PaySurchargeRules.remotePrefixes(zone);
if (!step && bounds[1] != null)
{
includedKg.put(zone, bounds[1]);
}
BigDecimal included = includedKg.get(zone);
if (remote && step && !prefixes.isEmpty())
{
fee.setCalcRule(PaySurchargeRules.EXTRA_KG);
fee.setCalcParam(included == null ? "1" : included.stripTrailingZeros().toPlainString());
fee.setPostcodeParam(prefixes);
fee.setWeightMin(null);
fee.setWeightMax(null);
}
else if (remote && !prefixes.isEmpty())
{
fee.setCalcRule(PaySurchargeRules.POSTCODE);
fee.setCalcParam(prefixes);
fee.setPostcodeParam(prefixes);
fee.setWeightMin(bounds[0]);
fee.setWeightMax(bounds[1]);
}
else
{
fee.setCalcRule(PaySurchargeRules.MANUAL);
fee.setCalcParam(weight);
}
}
/** 价格表里和派送费并排的偏远列,按分区和重量区间记附加费,不进重量段。 */
private void addPricedSurcharge(PayProduct product, String section, String head, String zone, String weight, BigDecimal price)
{
String feeName = shortCharge(head);
String standard = price.stripTrailingZeros().toPlainString() + " " + feeUnit(head, weight);
addSurcharge(product, section, feeName, zone, standard.trim(), weight, "");
PaySurcharge fee = product.getSurcharges().get(product.getSurcharges().size() - 1);
BigDecimal[] bounds = PayTexts.weightBounds(weight);
if (bounds[0] != null && bounds[1] != null)
{
fee.setWeightMin(bounds[0]);
fee.setWeightMax(bounds[1]);
fee.setCalcRule(PaySurchargeRules.ZONE);
fee.setCalcParam(null);
}
}
private static String feeUnit(String head, String weight)
{
String unit = unitOf(head + " " + weight);
@@ -88,6 +88,11 @@ public final class PayZoneMatcher
}
}
public static boolean matchesLine(PayPostcodeLine line, String postcode)
{
return line != null && matchedLength(line, postcode) >= 0;
}
public static Decision resolve(List<String> zoneNames, List<PayPostcodeLine> lines, String postcode)
{
if (zoneNames == null || zoneNames.size() <= 1)
@@ -5,19 +5,65 @@ import com.bms.price.domain.PayBill;
import com.bms.price.domain.PayBillCommand;
import com.bms.price.domain.PayBillPreview;
/**
* 应付账单。查询、预览、生成,以及确认、付款、作废。
*/
public interface IPayBillService
{
/**
* 按条件查询应付账单列表。
*
* @param query 账单号、供应商、状态、币种,空着的条件不过滤
* @return 账单头列表
*/
List<PayBill> selectPayBillList(PayBill query);
/**
* 查询一张应付账单,带上费用明细和操作日志。
*
* @param billId 应付账单ID
* @return 账单详情
*/
PayBill selectPayBillDetail(Long billId);
/**
* 预览可生成的订单。不重算费用,也不写账单。
*
* @param command 查询方式,以及订单号、计费日起止或提单号
* @return 可生成订单、未找到的单号、已经入账的订单
*/
PayBillPreview preview(PayBillCommand command);
/**
* 生成应付账单。重算范围内订单的尾程应付,再把订单费用抄成账单明细。
* 已经挂在未作废账单上的订单不会进入本次账单。
*
* @param command 供应商和取数范围
* @return 草稿账单,含明细和生成日志
*/
PayBill generate(PayBillCommand command);
/**
* 把草稿确认为已确认。
*
* @param billId 应付账单ID
* @return 成功时为 1
*/
int confirm(Long billId);
/**
* 把已确认账单登记为已付款。
*
* @param billId 应付账单ID
* @return 成功时为 1
*/
int markPaid(Long billId);
/**
* 作废账单。已付款的不能作废,作废后数据仍保留。
*
* @param command 账单ID和作废原因
* @return 成功时为 1
*/
int voidBill(PayBillCommand command);
}
@@ -2,8 +2,16 @@ package com.bms.price.service;
import com.bms.price.domain.PayOrderCostResult;
/** 按订单生成应付:渠道服务商上的应付产品,用订单基础数据计算。 */
/**
* 按订单计算尾程应付。用渠道上的应付产品和订单重量、尺寸、邮编取价,并写入订单应付。
*/
public interface IPayOrderCostService
{
/**
* 重算一票订单的尾程应付,覆盖原来的计算费用。
*
* @param billId 计费订单ID,对应 prc_order_bill.bill_id
* @return 本次算出的产品、版本、币种和费用明细
*/
PayOrderCostResult generate(String billId);
}
@@ -48,12 +48,24 @@ public class PayBillServiceImpl implements IPayBillService
@Autowired
private BmsSupplierMapper supplierMapper;
/**
* 按账单号、供应商、状态、币种查询应付账单。
*
* @param query 查询条件,空字段不参与过滤
* @return 账单头列表
*/
@Override
public List<PayBill> selectPayBillList(PayBill query)
{
return payBillMapper.selectPayBillList(query);
}
/**
* 查询一张应付账单,并装上费用明细和操作日志。
*
* @param billId 应付账单ID
* @return 账单详情
*/
@Override
public PayBill selectPayBillDetail(Long billId)
{
@@ -67,12 +79,25 @@ public class PayBillServiceImpl implements IPayBillService
return bill;
}
/**
* 预览可生成的订单。不重算尾程,也不写账单。
*
* @param command 查询方式,以及订单号、计费日或提单号
* @return 可生成订单、未找到的单号、已经入账的订单
*/
@Override
public PayBillPreview preview(PayBillCommand command)
{
return collect(command, false);
}
/**
* 生成应付账单。校验供应商后,对未入账订单重算尾程,再把订单费用抄成账单明细。
* 所选订单币种必须一致。账单以草稿写入,并记一条生成日志。
*
* @param command 供应商和取数范围。sourceType 为 ORDER、TIME 或 LADING
* @return 刚生成的草稿账单,含明细和日志
*/
@Override
@Transactional(rollbackFor = Exception.class)
public PayBill generate(PayBillCommand command)
@@ -165,6 +190,12 @@ public class PayBillServiceImpl implements IPayBillService
return selectPayBillDetail(bill.getBillId());
}
/**
* 把草稿账单改为已确认。
*
* @param billId 应付账单ID
* @return 成功时为 1
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int confirm(Long billId)
@@ -182,6 +213,12 @@ public class PayBillServiceImpl implements IPayBillService
return 1;
}
/**
* 把已确认账单登记为已付款。
*
* @param billId 应付账单ID
* @return 成功时为 1
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int markPaid(Long billId)
@@ -199,6 +236,12 @@ public class PayBillServiceImpl implements IPayBillService
return 1;
}
/**
* 作废账单。必须填写作废原因。已作废的不能再作废,已付款的不能直接作废。
*
* @param command 账单ID和作废原因
* @return 成功时为 1
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int voidBill(PayBillCommand command)
@@ -232,6 +275,13 @@ public class PayBillServiceImpl implements IPayBillService
return 1;
}
/**
* 按订单号、计费日或提单号查出订单,并分成可生成和已入账两组。
*
* @param command 查询方式和对应的单号或日期
* @param forGenerate 为 true 时不把未找到的单号写进提示,留给生成时抛错
* @return 预览结果。可生成订单放在 orders,已入账订单放在 occupied
*/
private PayBillPreview collect(PayBillCommand command, boolean forGenerate)
{
String mode = mode(command);
@@ -301,11 +351,23 @@ public class PayBillServiceImpl implements IPayBillService
return preview;
}
/**
* 取出还没有挂在未作废账单上的订单。
*
* @param preview 预览结果
* @return 可以进入本次账单的订单
*/
private List<PayBillOrder> freeOrders(PayBillPreview preview)
{
return preview.getOrders();
}
/**
* 取这批订单里第一个非空币种。都没有时用 CNY,后面仍会按费用行再核对。
*
* @param orders 本次要出账的订单
* @return 币种代码
*/
private String currencyOf(List<PayBillOrder> orders)
{
String currency = "";
@@ -320,6 +382,13 @@ public class PayBillServiceImpl implements IPayBillService
return currency.isEmpty() ? "CNY" : currency;
}
/**
* 用计费订单ID找到对应订单,用来回填订单号和提单号。
*
* @param orders 本次订单
* @param billId 计费订单ID
* @return 订单;对不上时返回 null
*/
private PayBillOrder findOrder(List<PayBillOrder> orders, String billId)
{
for (PayBillOrder order : orders)
@@ -332,6 +401,12 @@ public class PayBillServiceImpl implements IPayBillService
return null;
}
/**
* 读取账单头。账单不存在时抛出业务异常。
*
* @param billId 应付账单ID
* @return 账单头
*/
private PayBill required(Long billId)
{
PayBill bill = payBillMapper.selectPayBillById(billId);
@@ -342,6 +417,16 @@ public class PayBillServiceImpl implements IPayBillService
return bill;
}
/**
* 写一条应付账单操作日志。
*
* @param billId 应付账单ID
* @param type 操作类型:CREATE 生成、CONFIRM 确认、PAY 付款、VOID 作废
* @param from 变更前状态,新建时为空
* @param to 变更后状态
* @param desc 操作说明
* @param operator 操作人账号
*/
private void writeLog(Long billId, String type, String from, String to, String desc, String operator)
{
PayBillLog log = new PayBillLog();
@@ -354,12 +439,23 @@ public class PayBillServiceImpl implements IPayBillService
payBillMapper.insertLog(log);
}
/**
* 生成应付账单号。格式为 AP、年月、8 位随机码。
*
* @return 账单号
*/
private String nextNo()
{
return "AP" + new SimpleDateFormat("yyyyMM").format(new Date())
+ IdUtils.fastSimpleUUID().substring(0, 8).toUpperCase();
}
/**
* 把 yyyy-MM-dd 转成日期。空文本返回 null。
*
* @param text 计费日起或止
* @return 日期
*/
private Date parseDay(String text)
{
if (text == null || text.isBlank())
@@ -370,6 +466,12 @@ public class PayBillServiceImpl implements IPayBillService
return java.sql.Date.valueOf(day);
}
/**
* 读取查询方式,并转成大写。
*
* @param command 预览或生成参数
* @return ORDER 订单号、TIME 计费日、LADING 提单号
*/
private String mode(PayBillCommand command)
{
if (command == null || command.getSourceType() == null || command.getSourceType().isBlank())
@@ -379,6 +481,12 @@ public class PayBillServiceImpl implements IPayBillService
return command.getSourceType().trim().toUpperCase(Locale.ROOT);
}
/**
* 把订单号或提单号按逗号、顿号、分号、空白拆开,并去掉重复和空白。
*
* @param text 页面上粘贴的单号原文
* @return 去重后的单号
*/
static List<String> tokens(String text)
{
LinkedHashSet<String> values = new LinkedHashSet<>();
@@ -1,11 +1,8 @@
package com.bms.price.service.impl;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -18,6 +15,7 @@ import com.bms.price.domain.PayOrderCostResult;
import com.bms.price.domain.PayPostcodeGroup;
import com.bms.price.domain.PayPostcodeLine;
import com.bms.price.domain.PayProduct;
import com.bms.price.domain.PaySurcharge;
import com.bms.price.domain.PayWeightBand;
import com.bms.price.domain.PrcOrderBill;
import com.bms.price.domain.PrcOrderBillExt;
@@ -29,6 +27,8 @@ import com.bms.price.mapper.PrcOrderBillMapper;
import com.bms.price.mapper.PrcPayBillFeeMapper;
import com.bms.price.mapper.PrcPayBillMapper;
import com.bms.price.pay.PayCostCalculator;
import com.bms.price.pay.PayRemoteBook;
import com.bms.price.pay.PaySurchargeRules;
import com.bms.price.pay.PayZoneMatcher;
import com.bms.price.service.IPayOrderCostService;
import com.bms.price.service.IPayProductService;
@@ -60,6 +60,13 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService
@Autowired
private PayPostcodeMapper payPostcodeMapper;
/**
* 重算一票订单的尾程应付并落库。
* 先找渠道和计费日对应的价卡版本,再用重量、尺寸、邮编算出费用,写回订单应付。
*
* @param billId 计费订单ID
* @return 本次使用的渠道、价卡版本和费用明细
*/
@Override
@Transactional(rollbackFor = Exception.class)
public PayOrderCostResult generate(String billId)
@@ -75,13 +82,16 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService
}
PrcOrderBillExt ext = orderBillExtMapper.selectByBillId(bill.getBillId());
ServiceChannel channel = resolveChannel(ext);
java.util.Date bizDate = bill.getBizDate() != null ? bill.getBizDate() : bill.getOrderTime();
Date bizDate = bill.getBizDate() != null ? bill.getBizDate() : bill.getOrderTime();
PayProduct product = payProductService.selectPriceForDate(channel.getId(), bizDate);
BigDecimal weight = firstPositive(bill.getChargeWeight(), bill.getWeight(), bill.getActualWeight(), bill.getReceiveWeight());
Integer orderStatus = bill.getOrderStatus() != null ? bill.getOrderStatus() : (ext == null ? null : ext.getOrderStatus());
String zoneName = resolveZone(channel, product, bill.getDestPostcode(), bill.getCountryCode(), bill.getCountryName());
List<PayPostcodeGroup> linked = linkedGroups(product);
String zoneName = resolveZone(channel, product, bill.getDestPostcode(), bill.getCountryCode(), bill.getCountryName(), linked);
// 根据邮编判断是否是偏远地区
List<PayRemoteBook.Hit> remoteHits = remoteHits(linked, bill.getDestPostcode(), bill.getCountryCode(), bill.getCountryName());
PayOrderCostResult result = PayCostCalculator.calculate(product, weight, bill.getPkgLength(), bill.getPkgWidth(),
bill.getPkgHeight(), bill.getDestPostcode(), zoneName, bizDate, orderStatus);
bill.getPkgHeight(), bill.getDestPostcode(), zoneName, bizDate, orderStatus, remoteHits);
result.setBillId(bill.getBillId());
result.setChannelId(channel.getId());
result.setChannelName(channel.getChannelName());
@@ -101,14 +111,109 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService
return result;
}
/** 价卡分成多个地区时,用邮编在对应分区表里选出价格列。 */
private String resolveZone(ServiceChannel channel, PayProduct product, String postcode, String countryCode, String countryName)
/**
* 用产品名称、报价工作表、字典值和关联分区表,对上这份产品的邮编配置。
*
* @param product 当前价卡版本
* @return 名称能对上的邮编组,没有时为空列表
*/
private List<PayPostcodeGroup> linkedGroups(PayProduct product)
{
List<String> names = new ArrayList<>();
addName(names, product.getProductName());
addName(names, product.getSheetName());
addName(names, product.getDictValue());
addName(names, product.getRelatedSheet());
if (names.isEmpty())
{
return new ArrayList<>();
}
List<PayPostcodeGroup> found = payPostcodeMapper.selectGroupsForProduct(names);
return found == null ? new ArrayList<>() : found;
}
/**
* 把非空名称放进匹配列表,已有的不重复加入。
*
* @param names 已经收集的名称
* @param value 产品名、工作表名或字典值
*/
private static void addName(List<String> names, String value)
{
if (value == null || value.isBlank())
{
return;
}
String name = value.trim();
if (!names.contains(name))
{
names.add(name);
}
}
/**
* 用产品自己的偏远邮编判断这票是否偏远。没有偏远配置时返回 null,计费改看价卡上的邮编条件。
*
* @param linked 已对上的邮编组
* @param postcode 收件邮编
* @param countryCode 目的国二字码
* @param countryName 目的国名称
* @return 命中的偏远分区;有配置但没命中时返回空列表
*/
private List<PayRemoteBook.Hit> remoteHits(List<PayPostcodeGroup> linked, String postcode, String countryCode, String countryName)
{
List<Long> remoteIds = new ArrayList<>();
boolean anyRemote = false;
for (PayPostcodeGroup group : linked)
{
if (group == null || group.getGroupId() == null || !PayRemoteBook.remoteGroup(group))
{
continue;
}
anyRemote = true;
if (sameCountry(group, countryCode, countryName))
{
remoteIds.add(group.getGroupId());
}
}
if (!anyRemote)
{
return null;
}
if (remoteIds.isEmpty())
{
return new ArrayList<>();
}
List<PayPostcodeLine> lines = postcode == null || postcode.isBlank()
? new ArrayList<>() : payPostcodeMapper.selectLinesByGroups(remoteIds);
return PayRemoteBook.hits(linked, lines, postcode, countryCode, countryName);
}
/**
* 价卡有多个地区时,选出这一票对应的价格列。优先用产品关联的邮编分区,对不上再按名称关键字找。
* 只有一个地区时返回 null,计费不再按分区过滤。
*
* @param channel 订单渠道,关键字匹配时备用
* @param product 当前价卡版本
* @param postcode 收件邮编
* @param countryCode 目的国二字码
* @param countryName 目的国名称
* @param linked 已对上的邮编组
* @return 价卡上的地区名
*/
private String resolveZone(ServiceChannel channel, PayProduct product, String postcode, String countryCode, String countryName,
List<PayPostcodeGroup> linked)
{
List<String> zoneNames = zoneNames(product);
if (zoneNames.size() <= 1)
{
return null;
}
List<Long> linkedZoneIds = zoneGroupIds(linked, countryCode, countryName);
if (!linkedZoneIds.isEmpty())
{
return decideZone(zoneNames, linkedZoneIds, postcode);
}
List<String> tokens = PayZoneMatcher.searchTokens(product.getProductName(), product.getSheetName());
if (tokens.isEmpty())
{
@@ -148,35 +253,105 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService
{
throw new ServiceException("产品「" + product.getProductName() + "」有多个地区,邮编分区对不上价卡上的地区");
}
PayZoneMatcher.Decision decision = PayZoneMatcher.resolve(zoneNames, payPostcodeMapper.selectLinesByGroups(new ArrayList<>(usable)), postcode);
return decideZone(zoneNames, new ArrayList<>(usable), postcode);
}
/**
* 在给定邮编组里,用收件邮编决定价卡地区。对不上时抛出业务异常。
*
* @param zoneNames 价卡上的地区名
* @param groupIds 参与匹配的邮编组
* @param postcode 收件邮编
* @return 命中的地区名
*/
private String decideZone(List<String> zoneNames, List<Long> groupIds, String postcode)
{
PayZoneMatcher.Decision decision = PayZoneMatcher.resolve(zoneNames, payPostcodeMapper.selectLinesByGroups(groupIds), postcode);
if (decision.getMessage() != null)
{
throw new ServiceException(decision.getMessage());
}
if (decision.getZoneName() == null)
{
throw new ServiceException("邮编没有对上产品「" + product.getProductName() + "」的地区");
throw new ServiceException("邮编没有对上价卡地区");
}
return decision.getZoneName();
}
/**
* 取出用来选派送费的普通分区。偏远、附加费邮编和不可达不参与选区。
*
* @param linked 已对上的邮编组
* @param countryCode 目的国二字码
* @param countryName 目的国名称
* @return 可参与选区的邮编组ID
*/
private static List<Long> zoneGroupIds(List<PayPostcodeGroup> linked, String countryCode, String countryName)
{
List<Long> ids = new ArrayList<>();
if (linked == null)
{
return ids;
}
for (PayPostcodeGroup group : linked)
{
if (group == null || group.getGroupId() == null || PayRemoteBook.remoteGroup(group) || "FEE".equals(group.getConfigKind()))
{
continue;
}
if ("UNREACH".equalsIgnoreCase(group.getCoverage() == null ? "" : group.getCoverage().trim()))
{
continue;
}
if (!sameCountry(group, countryCode, countryName))
{
continue;
}
ids.add(group.getGroupId());
}
return ids;
}
/**
* 收集价卡重量段和分区附加费上的地区名。
*
* @param product 当前价卡版本
* @return 去重后的地区名
*/
private static List<String> zoneNames(PayProduct product)
{
Set<String> names = new LinkedHashSet<>();
if (product.getWeightBands() == null)
if (product.getWeightBands() != null)
{
return new ArrayList<>();
}
for (PayWeightBand band : product.getWeightBands())
{
if (band != null && band.getZoneName() != null && !band.getZoneName().isBlank())
for (PayWeightBand band : product.getWeightBands())
{
names.add(band.getZoneName().trim());
if (band != null && band.getZoneName() != null && !band.getZoneName().isBlank())
{
names.add(band.getZoneName().trim());
}
}
}
if (product.getSurcharges() != null)
{
for (PaySurcharge fee : product.getSurcharges())
{
if (fee != null && PaySurchargeRules.ZONE.equals(fee.getCalcRule()) && fee.getZoneName() != null && !fee.getZoneName().isBlank())
{
names.add(fee.getZoneName().trim());
}
}
}
return new ArrayList<>(names);
}
/**
* 判断邮编组是否和订单目的国相同。两边都没写国家时视为相同。
*
* @param group 邮编组
* @param countryCode 订单目的国二字码
* @param countryName 订单目的国名称
* @return 国家能对上时为 true
*/
private static boolean sameCountry(PayPostcodeGroup group, String countryCode, String countryName)
{
if (blank(countryCode) && blank(countryName))
@@ -194,6 +369,12 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService
return !blank(countryName) && countryName.equals(group.getCountryName());
}
/**
* 从订单扩展找出渠道。先按渠道ID,再按渠道编码,最后按渠道名称。
*
* @param ext 订单计费扩展
* @return 服务渠道
*/
private ServiceChannel resolveChannel(PrcOrderBillExt ext)
{
if (ext == null)
@@ -221,6 +402,14 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService
return channel;
}
/**
* 保存订单应付主表。已有记录则更新合计,没有则新建。合计只写本次尾程结果。
*
* @param bill 计费订单
* @param result 本次尾程计算结果
* @param operator 操作人账号
* @return 保存后的订单应付
*/
private PrcPayBill saveBill(PrcOrderBill bill, PayOrderCostResult result, String operator)
{
PrcPayBill existed = payBillMapper.selectByBillId(bill.getBillId());
@@ -250,6 +439,13 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService
return row;
}
/**
* 用本次算出的尾程费用替换订单上的计算费用。来源不是计算的费用行会保留。
*
* @param billId 计费订单ID
* @param payBillId 订单应付主表ID
* @param fees 本次算出的费用
*/
private void saveFees(String billId, String payBillId, List<PayCostLine> fees)
{
payBillFeeMapper.deleteByBillId(billId);
@@ -281,6 +477,12 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService
}
}
/**
* 把日期格式化成 yyyy-MM-dd,供结果说明使用。空日期表示不限。
*
* @param date 有效期起或止
* @return 日期文本
*/
private static String day(java.util.Date date)
{
if (date == null)
@@ -294,6 +496,12 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService
return date.toInstant().atZone(java.time.ZoneId.of("GMT+8")).toLocalDate().toString();
}
/**
* 按传入顺序取第一个大于 0 的重量。
*
* @param values 计费重、重量、实重、收货重
* @return 第一个正数;都没有时返回 null
*/
private static BigDecimal firstPositive(BigDecimal... values)
{
if (values == null)
@@ -310,6 +518,12 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService
return null;
}
/**
* 判断文本是否为空。
*
* @param value 待判断文本
* @return 为 null 或空白时返回 true
*/
private static boolean blank(String value)
{
return value == null || value.isBlank();
@@ -100,6 +100,20 @@
select group_id from pay_postcode_group where file_md5 = #{fileMd5} and sheet_name = #{sheetName}
</select>
<select id="selectGroupsForProduct" resultType="com.bms.price.domain.PayPostcodeGroup">
select group_id as groupId, block_title as blockTitle, channel_name as channelName,
product_name as productName, quote_sheet as quoteSheet, sheet_name as sheetName,
config_kind as configKind, coverage, fee_name as feeName, fee_text as feeText,
country_name as countryName, country_code as countryCode
from pay_postcode_group
where
<foreach collection="names" item="name" separator=" or ">
lower(quote_sheet) = lower(#{name})
or lower(product_name) = lower(#{name})
or lower(sheet_name) = lower(#{name})
</foreach>
</select>
<select id="selectGroupsByTokens" resultType="com.bms.price.domain.PayPostcodeGroup">
select group_id as groupId, block_title as blockTitle, channel_name as channelName,
product_name as productName, quote_sheet as quoteSheet, sheet_name as sheetName,
@@ -250,12 +250,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<insert id="insertSurcharges">
insert into pay_surcharge (
product_id, version_no, section_name, fee_name, zone_name, charge_text, amount, currency, price_unit, fee_desc, period_text,
effective_start, effective_end, charge_timing, calc_rule, calc_param, sort_no
effective_start, effective_end, charge_timing, calc_rule, calc_param, weight_min, weight_max, postcode_param, sort_no
) values
<foreach collection="list" item="row" separator=",">
(#{row.productId}, ifnull(#{row.versionNo}, 1), #{row.sectionName}, #{row.feeName}, #{row.zoneName}, #{row.chargeText}, #{row.amount},
#{row.currency}, #{row.priceUnit}, #{row.feeDesc}, #{row.periodText}, #{row.effectiveStart}, #{row.effectiveEnd},
#{row.chargeTiming}, ifnull(#{row.calcRule}, 'MANUAL'), #{row.calcParam}, #{row.sortNo})
#{row.chargeTiming}, ifnull(#{row.calcRule}, 'MANUAL'), #{row.calcParam}, #{row.weightMin}, #{row.weightMax},
#{row.postcodeParam}, #{row.sortNo})
</foreach>
</insert>
@@ -280,7 +281,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
select surcharge_id as surchargeId, product_id as productId, version_no as versionNo, section_name as sectionName, fee_name as feeName,
zone_name as zoneName, charge_text as chargeText, amount, currency, price_unit as priceUnit, fee_desc as feeDesc,
period_text as periodText, effective_start as effectiveStart, effective_end as effectiveEnd,
charge_timing as chargeTiming, calc_rule as calcRule, calc_param as calcParam, sort_no as sortNo
charge_timing as chargeTiming, calc_rule as calcRule, calc_param as calcParam,
weight_min as weightMin, weight_max as weightMax, postcode_param as postcodeParam, sort_no as sortNo
from pay_surcharge
where product_id in
<foreach collection="productIds" item="id" open="(" separator="," close=")">#{id}</foreach>