计费方案运输方式精确匹配,并完善付款与运单费用生成

修复公路/铁路子串误匹配导致错生成应收应付;尾款付款支持关联预结算单;运单新建/复制为已完成时按合同计费生成应收应付。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-23 00:17:13 +08:00
parent 3e23507b3a
commit f91fc19745
4 changed files with 140 additions and 36 deletions
@@ -150,15 +150,26 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
public PaymentApplicationReferenceAmountVO referenceAmount(String paymentType, Long referenceId, Long excludeId) {
if (referenceId == null) throw new ServiceException("结算单不能为空");
BigDecimal settlementAmount;
boolean preSettlementReference = false;
if ("settlement_payment".equals(paymentType)) {
FormalSettlement settlement = formalSettlementMapper.selectById(referenceId);
if (settlement == null || Objects.equals(settlement.getIsDeleted(), 1)) {
throw new ServiceException("正式结算单不存在");
}
if (!APPROVED.equals(settlement.getApprovalStatus())) {
FormalSettlement formalSettlement = formalSettlementMapper.selectById(referenceId);
if (formalSettlement != null && !Objects.equals(formalSettlement.getIsDeleted(), 1)) {
if (!APPROVED.equals(formalSettlement.getApprovalStatus())) {
throw new ServiceException("只能选择审批通过、未作废的正式结算单");
}
settlementAmount = money(settlement.getSettlementAmount());
settlementAmount = money(formalSettlement.getSettlementAmount());
} else {
// 尾款付款也允许关联预结算单
PreSettlement preSettlement = preSettlementMapper.selectById(referenceId);
if (preSettlement == null || Objects.equals(preSettlement.getIsDeleted(), 1)) {
throw new ServiceException("结算单不存在");
}
if (!APPROVED.equals(preSettlement.getApprovalStatus())) {
throw new ServiceException("只能选择审批通过、未作废的预结算单");
}
settlementAmount = money(preSettlement.getSettlementAmount());
preSettlementReference = true;
}
} else if ("progress_advance".equals(paymentType)) {
PreSettlement settlement = preSettlementMapper.selectById(referenceId);
if (settlement == null || Objects.equals(settlement.getIsDeleted(), 1)) {
@@ -168,10 +179,13 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
throw new ServiceException("只能选择审批通过、未作废的预结算单");
}
settlementAmount = money(settlement.getSettlementAmount());
preSettlementReference = true;
} else {
throw new ServiceException("付款类型不支持关联结算单");
}
BigDecimal cumulativeAppliedAmount = cumulativeAppliedAmount(paymentType, referenceId, excludeId);
BigDecimal cumulativeAppliedAmount = preSettlementReference
? cumulativePreSettlementApplied(referenceId, excludeId)
: cumulativeFormalSettlementApplied(referenceId, excludeId);
PaymentApplicationReferenceAmountVO amount = new PaymentApplicationReferenceAmountVO();
amount.setSettlementAmount(settlementAmount);
amount.setCumulativeAppliedAmount(cumulativeAppliedAmount);
@@ -265,6 +279,9 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
if (!List.of(DRAFT, RETURNED).contains(entity.getApprovalStatus())) {
throw new ServiceException("当前状态不允许提交");
}
if (entity.getAppliedAmount() == null || entity.getAppliedAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new ServiceException("申请付款金额必须大于0");
}
validateNoTailPayment(entity);
validateProjectAdvanceContract(entity);
validateSelectedBill(entity, false);
@@ -350,7 +367,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
}
private void syncPreSettlementAdvance(PaymentApplication entity) {
if (!"progress_advance".equals(entity.getPaymentType()) || entity.getPreSettlementId() == null) return;
if (entity.getPreSettlementId() == null) return;
if (!List.of("progress_advance", "settlement_payment").contains(entity.getPaymentType())) return;
PreSettlementAdvance advance = preSettlementAdvanceMapper.selectList(
Wrappers.<PreSettlementAdvance>lambdaQuery()
.eq(PreSettlementAdvance::getPreSettlementId, entity.getPreSettlementId())
@@ -474,13 +492,15 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
if (Func.isEmpty(request.getPaymentMethod())) throw new ServiceException("付款方式不能为空");
if (isBillPayment(request.getPaymentMethod()) && request.getBillLedgerId() == null) throw new ServiceException("汇票付款必须选择汇票台账");
if (request.getPaymentRatio() != null && (request.getPaymentRatio().compareTo(BigDecimal.ZERO) < 0 || request.getPaymentRatio().compareTo(BigDecimal.valueOf(100)) > 0)) throw new ServiceException("付款比例必须在0-100之间");
if (request.getAppliedAmount() == null || request.getAppliedAmount().compareTo(BigDecimal.ZERO) < 0) throw new ServiceException("申请付款金额不能小于0");
if (request.getAppliedAmount() == null || request.getAppliedAmount().compareTo(BigDecimal.ZERO) <= 0) throw new ServiceException("申请付款金额必须大于0");
if (!"project_advance".equals(request.getPaymentType()) && request.getSettlementId() == null
&& request.getPreSettlementId() == null && Func.isEmpty(request.getSettlementIds())) throw new ServiceException("非项目预付必须关联结算单");
if ("project_advance".equals(request.getPaymentType()) && request.getProjectId() == null) throw new ServiceException("项目预付必须选择所属项目");
if ("progress_advance".equals(request.getPaymentType()) && request.getPreSettlementId() == null) throw new ServiceException("进度预付必须关联预结算单");
if ("settlement_payment".equals(request.getPaymentType()) && request.getSettlementId() == null
&& Func.isEmpty(request.getSettlementIds())) throw new ServiceException("结算付款必须关联正式结算单");
&& Func.isEmpty(request.getSettlementIds()) && request.getPreSettlementId() == null) {
throw new ServiceException("尾款付款必须关联正式结算单或预结算单");
}
}
private void validateInvoice(PaymentApplicationInvoiceRequest invoice) {
@@ -546,12 +566,12 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
if (source == null) throw new ServiceException("正式结算单不存在");
if (!APPROVED.equals(source.getApprovalStatus())) throw new ServiceException("只能选择审批通过、未作废的正式结算单");
entity.setSettlementId(source.getId()); entity.setSettlementNo(source.getFormalSettlementNo()); entity.setPreSettlementId(null); entity.setPreSettlementNo(null);
entity.setProjectId(source.getProjectId()); entity.setProjectName(source.getProjectName()); entity.setDeptId(source.getDeptId()); entity.setDeptName(source.getDeptName()); entity.setContractId(source.getContractId()); entity.setContractNo(source.getContractNo()); entity.setContractName(source.getContractName()); entity.setPayerName(source.getPayerName()); entity.setPayeeName(source.getPayeeName()); entity.setSettlementAmount(source.getSettlementAmount()); entity.setPayableAmount(money(source.getSettlementAmount()).subtract(cumulativeAppliedAmount(request.getPaymentType(), source.getId(), entity.getId()))); entity.setBillType("正式结算单");
entity.setProjectId(source.getProjectId()); entity.setProjectName(source.getProjectName()); entity.setDeptId(source.getDeptId()); entity.setDeptName(source.getDeptName()); entity.setContractId(source.getContractId()); entity.setContractNo(source.getContractNo()); entity.setContractName(source.getContractName()); entity.setPayerName(source.getPayerName()); entity.setPayeeName(source.getPayeeName()); entity.setSettlementAmount(source.getSettlementAmount()); entity.setPayableAmount(money(source.getSettlementAmount()).subtract(cumulativeFormalSettlementApplied(source.getId(), entity.getId()))); entity.setBillType("正式结算单");
} else if (request.getPreSettlementId() != null) {
PreSettlement source = preSettlementMapper.selectById(request.getPreSettlementId());
if (source == null) throw new ServiceException("预结算单不存在");
if (!APPROVED.equals(source.getApprovalStatus())) throw new ServiceException("只能选择审批通过、未作废的预结算单");
entity.setPreSettlementId(source.getId()); entity.setPreSettlementNo(source.getPreSettlementNo()); entity.setSettlementId(null); entity.setSettlementNo(null); entity.setProjectId(source.getProjectId()); entity.setProjectName(source.getProjectName()); entity.setDeptId(source.getDeptId()); entity.setDeptName(source.getDeptName()); entity.setContractId(source.getContractId()); entity.setContractNo(source.getContractNo()); entity.setContractName(source.getContractName()); entity.setPayerName(source.getPayerName()); entity.setPayeeName(source.getPayeeName()); entity.setSettlementAmount(source.getSettlementAmount()); entity.setPayableAmount(money(source.getSettlementAmount()).subtract(cumulativeAppliedAmount(request.getPaymentType(), source.getId(), entity.getId()))); entity.setBillType("预结算单");
entity.setPreSettlementId(source.getId()); entity.setPreSettlementNo(source.getPreSettlementNo()); entity.setSettlementId(null); entity.setSettlementNo(null); entity.setProjectId(source.getProjectId()); entity.setProjectName(source.getProjectName()); entity.setDeptId(source.getDeptId()); entity.setDeptName(source.getDeptName()); entity.setContractId(source.getContractId()); entity.setContractNo(source.getContractNo()); entity.setContractName(source.getContractName()); entity.setPayerName(source.getPayerName()); entity.setPayeeName(source.getPayeeName()); entity.setSettlementAmount(source.getSettlementAmount()); entity.setPayableAmount(money(source.getSettlementAmount()).subtract(cumulativePreSettlementApplied(source.getId(), entity.getId()))); entity.setBillType("预结算单");
} else {
entity.setProjectId(request.getProjectId()); entity.setProjectName(request.getProjectName()); entity.setDeptId(request.getDeptId()); entity.setDeptName(request.getDeptName()); entity.setContractId(request.getContractId()); entity.setContractNo(request.getContractNo()); entity.setContractName(request.getContractName()); entity.setPayerName(request.getPayerName()); entity.setPayeeName(request.getPayeeName()); entity.setSettlementAmount(request.getSettlementAmount()); entity.setPayableAmount(request.getPayableAmount()); entity.setBillType(request.getBillType());
}
@@ -565,6 +585,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
List<Long> ids = request.getSettlementIds() == null ? List.of() : request.getSettlementIds();
if (ids.isEmpty() && request.getSettlementId() != null) ids = List.of(request.getSettlementId());
ids = ids.stream().filter(Objects::nonNull).distinct().toList();
if (ids.isEmpty()) return List.of();
List<FormalSettlement> settlements = ids.stream().map(id -> formalSettlementMapper.selectById(id)).toList();
if (settlements.stream().anyMatch(item -> item == null || Objects.equals(item.getIsDeleted(), 1)
|| !APPROVED.equals(item.getApprovalStatus()) || !"payable".equals(item.getSettlementType()))) {
@@ -586,7 +607,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
BigDecimal settlementAmount = settlements.stream().map(FormalSettlement::getSettlementAmount)
.map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal payableAmount = settlements.stream()
.map(item -> money(item.getSettlementAmount()).subtract(cumulativeAppliedAmount("settlement_payment", item.getId(), entity.getId())).max(BigDecimal.ZERO))
.map(item -> money(item.getSettlementAmount()).subtract(cumulativeFormalSettlementApplied(item.getId(), entity.getId())).max(BigDecimal.ZERO))
.reduce(BigDecimal.ZERO, BigDecimal::add);
if (money(entity.getAppliedAmount()).compareTo(payableAmount) > 0) throw new ServiceException("申请付款金额不能超过所选结算单可付款金额合计");
FormalSettlement first = settlements.get(0);
@@ -601,14 +622,14 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
settlementRelationMapper.delete(Wrappers.<PaymentApplicationSettlement>lambdaQuery()
.eq(PaymentApplicationSettlement::getPaymentApplicationId, entity.getId()));
BigDecimal totalBase = settlements.stream().map(item -> money(item.getSettlementAmount()).subtract(
cumulativeAppliedAmount("settlement_payment", item.getId(), entity.getId())).max(BigDecimal.ZERO))
cumulativeFormalSettlementApplied(item.getId(), entity.getId())).max(BigDecimal.ZERO))
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal remainingApplied = money(entity.getAppliedAmount());
BigDecimal remainingPaid = money(entity.getPaidAmount());
for (int index = 0; index < settlements.size(); index++) {
FormalSettlement settlement = settlements.get(index);
BigDecimal base = money(settlement.getSettlementAmount()).subtract(
cumulativeAppliedAmount("settlement_payment", settlement.getId(), entity.getId())).max(BigDecimal.ZERO);
cumulativeFormalSettlementApplied(settlement.getId(), entity.getId())).max(BigDecimal.ZERO);
BigDecimal applied = index == settlements.size() - 1 ? remainingApplied
: money(entity.getAppliedAmount()).multiply(base).divide(totalBase, 2, RoundingMode.DOWN);
BigDecimal paid = index == settlements.size() - 1 ? remainingPaid
@@ -663,11 +684,26 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
}
private BigDecimal cumulativeAppliedAmount(String paymentType, Long referenceId, Long excludeId) {
LambdaQueryWrapper<PaymentApplication> wrapper = Wrappers.<PaymentApplication>lambdaQuery()
.eq("settlement_payment".equals(paymentType), PaymentApplication::getSettlementId, referenceId)
.eq("progress_advance".equals(paymentType), PaymentApplication::getPreSettlementId, referenceId)
.ne(excludeId != null, PaymentApplication::getId, excludeId);
return sumApplied(wrapper);
if ("progress_advance".equals(paymentType)) {
return cumulativePreSettlementApplied(referenceId, excludeId);
}
if ("settlement_payment".equals(paymentType)) {
return cumulativeFormalSettlementApplied(referenceId, excludeId);
}
return BigDecimal.ZERO;
}
private BigDecimal cumulativeFormalSettlementApplied(Long settlementId, Long excludeId) {
return sumApplied(Wrappers.<PaymentApplication>lambdaQuery()
.eq(PaymentApplication::getSettlementId, settlementId)
.ne(excludeId != null, PaymentApplication::getId, excludeId));
}
private BigDecimal cumulativePreSettlementApplied(Long preSettlementId, Long excludeId) {
// 进度预付、尾款付款关联同一预结算单时累计占用可付款额度
return sumApplied(Wrappers.<PaymentApplication>lambdaQuery()
.eq(PaymentApplication::getPreSettlementId, preSettlementId)
.ne(excludeId != null, PaymentApplication::getId, excludeId));
}
private void fillBillLedger(PaymentApplication entity, PaymentApplicationSaveRequest request) {
@@ -1817,22 +1817,68 @@ public class ReceivablePayableDetailServiceImpl
/**
* 解析合同生效计费方案。
* 合同存在勾选默认的计费方案时仅在默认方案内匹配计算,不再回退其他方案;
* 全部方案均未勾选默认时仅使用最新添加的计费方案。
* 仅使用与运单运输方式一致的计费方案;无匹配方案时返回 null,不生成应收应付。
* 匹配方案中存在默认方案时取最新默认方案;否则取最新匹配方案。
*/
private Map<String, Object> resolveDefaultBillingPlan(List<Map<String, Object>> plans, String transportType) {
if (plans.isEmpty()) return null;
List<Map<String, Object>> defaultPlans = plans.stream().filter(this::isDefaultPlan).toList();
if (defaultPlans.isEmpty()) return plans.get(plans.size() - 1);
Optional<Map<String, Object>> matchedDefaultPlan = defaultPlans.stream()
.filter(plan -> !isBlank(plan.get("transportMode")))
.filter(plan -> matchesCondition(plan.get("transportMode"), transportType))
.findFirst();
if (matchedDefaultPlan.isPresent()) return matchedDefaultPlan.get();
return defaultPlans.stream()
.filter(plan -> isBlank(plan.get("transportMode")))
.findFirst()
.orElseGet(() -> defaultPlans.get(defaultPlans.size() - 1));
List<Map<String, Object>> matchedPlans = plans.stream()
.filter(plan -> matchesTransportMode(plan.get("transportMode"), transportType))
.toList();
if (matchedPlans.isEmpty()) {
return null;
}
List<Map<String, Object>> defaultPlans = matchedPlans.stream().filter(this::isDefaultPlan).toList();
if (!defaultPlans.isEmpty()) {
return defaultPlans.get(defaultPlans.size() - 1);
}
return matchedPlans.get(matchedPlans.size() - 1);
}
/**
* 计费方案运输方式与运单运输方式比对。
* 统一归一化为字典键后精确比较,避免 railway 被子串 road 误匹配。
*/
private boolean matchesTransportMode(Object expected, String actual) {
if (isBlank(expected) || Func.isBlank(actual)) {
return false;
}
if (expected instanceof List<?> values) {
return values.stream().anyMatch(value -> matchesTransportMode(value, actual));
}
String expectedKey = normalizeTransportModeKey(String.valueOf(expected));
String actualKey = normalizeTransportModeKey(actual);
if (expectedKey.isEmpty() || actualKey.isEmpty()) {
return false;
}
return expectedKey.equals(actualKey);
}
/**
* 将运输方式字典键/中文名归一化为统一键:road / railway / river / air。
* 须先判断铁路再判断公路,避免 railway 被识别为 road。
*/
private String normalizeTransportModeKey(String value) {
if (value == null) {
return "";
}
String text = value.trim().toLowerCase();
if (text.isEmpty()) {
return "";
}
if ("railway".equals(text) || "rail".equals(text) || text.contains("铁路")) {
return "railway";
}
if ("road".equals(text) || "gl".equals(text) || text.contains("公路") || text.contains("道路")) {
return "road";
}
if ("river".equals(text) || "water".equals(text) || text.contains("水路") || text.contains("水运")) {
return "river";
}
if ("air".equals(text) || "aviation".equals(text) || text.contains("航空") || text.contains("空运")) {
return "air";
}
return text;
}
private BigDecimal calculateRule(Map<String, Object> rule, Waybill waybill) {
@@ -684,9 +684,15 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
@Override
@Transactional(rollbackFor = Exception.class)
public boolean submit(Waybill waybill) {
boolean created = Func.isEmpty(waybill.getId());
prepareForSave(waybill);
validate(waybill);
return saveOrUpdate(waybill);
boolean result = saveOrUpdate(waybill);
// 复制后确认提交为已完成时,按合同计费规则生成应收应付
if (result && created) {
generateReceivablePayableIfCompleted(waybill);
}
return result;
}
@Override
@@ -1000,9 +1006,25 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
}
validate(target);
save(target);
// 原运单已完成时,复制后按合同系统计费规则生成应收应付明细
generateReceivablePayableIfCompleted(target);
return detail(target.getId());
}
/**
* 新建/复制运单若业务状态为已完成,按合同系统计费模式生成应收、应付明细。
* <p>使用同事务入口,避免 REQUIRES_NEW 读不到尚未提交的新运单。</p>
*/
private void generateReceivablePayableIfCompleted(Waybill waybill) {
if (waybill == null || Func.isEmpty(waybill.getId())) {
return;
}
if (!"completed".equals(waybill.getBusinessStatus())) {
return;
}
receivablePayableDetailService.generateForImportedWaybills(List.of(waybill));
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean changeRoute(Waybill waybill) {
@@ -38,7 +38,7 @@ public class PaymentApplicationWrapper extends BaseEntityWrapper<PaymentApplicat
vo.setPaymentTypeName(switch (entity.getPaymentType() == null ? "" : entity.getPaymentType()) {
case "project_advance" -> "项目预付";
case "progress_advance" -> "进度预付";
case "settlement_payment" -> "结算付款";
case "settlement_payment" -> "尾款付款";
default -> entity.getPaymentType();
});
vo.setApprovalStatusName(switch (entity.getApprovalStatus() == null ? "" : entity.getApprovalStatus()) {