🐛 修复应付承运商、汇票占用、一单多货结算与运输对账完成校验

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-17 08:31:33 +08:00
parent c168bf276a
commit 6f73e3fbf9
6 changed files with 465 additions and 43 deletions
@@ -6,6 +6,8 @@
*/ */
package org.springblade.transport.pojo.entity; package org.springblade.transport.pojo.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
@@ -41,6 +43,7 @@ public class TransportReconciliationExternal extends TenantEntity {
private BigDecimal freightAmount; private BigDecimal freightAmount;
private String feeItemsJson; private String feeItemsJson;
private BigDecimal settlementAmount; private BigDecimal settlementAmount;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Boolean suspectedDuplicate; private Boolean suspectedDuplicate;
private String matchStatus; private String matchStatus;
private Long matchedInternalId; private Long matchedInternalId;
@@ -403,10 +403,19 @@ public final class MkProcessBizHandlers {
if (entity == null || "returned".equals(entity.getApprovalStatus())) { if (entity == null || "returned".equals(entity.getApprovalStatus())) {
return entity != null; return entity != null;
} }
entity.setApprovalStatus("returned"); if ("reviewing".equals(entity.getApprovalStatus())) {
entity.setCurrentNode("审核不通过"); PaymentApplicationStatusRequest request = new PaymentApplicationStatusRequest();
entity.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统")); request.setId(id);
return service.updateById(entity); service.returnBill(request);
}
PaymentApplication updated = service.getById(id);
if (updated == null) {
return false;
}
updated.setApprovalStatus("returned");
updated.setCurrentNode("审核不通过");
updated.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统"));
return service.updateById(updated);
} }
} }
@@ -314,6 +314,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
List<FormalSettlementDetail> details = detailMapper.selectList(Wrappers.<FormalSettlementDetail>lambdaQuery() List<FormalSettlementDetail> details = detailMapper.selectList(Wrappers.<FormalSettlementDetail>lambdaQuery()
.eq(FormalSettlementDetail::getFormalSettlementId, id).orderByAsc(FormalSettlementDetail::getLineNo)); .eq(FormalSettlementDetail::getFormalSettlementId, id).orderByAsc(FormalSettlementDetail::getLineNo));
fillDetailWaybillAddresses(details); fillDetailWaybillAddresses(details);
fillDetailCargoNames(details);
vo.setDetails(details); vo.setDetails(details);
vo.setSummaryFees(listSummaryFees(id)); vo.setSummaryFees(listSummaryFees(id));
vo.setPayments(paymentMapper.selectList(Wrappers.<FormalSettlementPayment>lambdaQuery() vo.setPayments(paymentMapper.selectList(Wrappers.<FormalSettlementPayment>lambdaQuery()
@@ -1663,6 +1664,117 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
.set(ReceivablePayableDetail::getSettlementStatus, "formal_settled")); .set(ReceivablePayableDetail::getSettlementStatus, "formal_settled"));
if (affected != 1) throw new ServiceException("单据" + source.getDocumentNo() + "已被其他结算单选择"); if (affected != 1) throw new ServiceException("单据" + source.getDocumentNo() + "已被其他结算单选择");
} }
persistDetailCargoNames(settlement.getId());
}
/**
* 一单多货时运单主表只保留首行货物。结算明细按运单货物清单回填全部货物名称、货物类型。
*/
private void fillDetailCargoNames(List<FormalSettlementDetail> details) {
if (details == null || details.isEmpty()) {
return;
}
List<Long> waybillIds = details.stream().map(FormalSettlementDetail::getWaybillId)
.filter(Objects::nonNull).distinct().toList();
Map<Long, Waybill> waybillMap = waybillIds.isEmpty() ? Map.of() : waybillService.listByIds(waybillIds).stream()
.collect(Collectors.toMap(Waybill::getId, Function.identity(), (left, right) -> left));
List<Long> detailIds = details.stream().map(FormalSettlementDetail::getId).filter(Objects::nonNull).toList();
Map<Long, List<FormalSettlementDetailFee>> feesByDetail = detailIds.isEmpty() ? Map.of()
: detailFeeMapper.selectList(Wrappers.<FormalSettlementDetailFee>lambdaQuery()
.in(FormalSettlementDetailFee::getFormalSettlementDetailId, detailIds)
.orderByAsc(FormalSettlementDetailFee::getLineNo)).stream()
.collect(Collectors.groupingBy(FormalSettlementDetailFee::getFormalSettlementDetailId, LinkedHashMap::new, Collectors.toList()));
for (FormalSettlementDetail detail : details) {
Waybill waybill = waybillMap.get(detail.getWaybillId());
LinkedHashSet<String> goodsNames = new LinkedHashSet<>();
LinkedHashSet<String> goodsTypes = new LinkedHashSet<>();
if (waybill != null) {
collectGoodsCargo(waybill.getGoodsJson(), goodsNames, goodsTypes);
}
LinkedHashSet<String> feeNames = new LinkedHashSet<>();
LinkedHashSet<String> feeTypes = new LinkedHashSet<>();
for (FormalSettlementDetailFee fee : feesByDetail.getOrDefault(detail.getId(), List.of())) {
addCargoToken(feeNames, fee.getCargoName(), true);
addCargoToken(feeTypes, fee.getCargoType(), true);
}
LinkedHashSet<String> names = goodsNames.size() > 1 ? goodsNames : feeNames.size() > 1 ? feeNames : goodsNames;
LinkedHashSet<String> types = goodsTypes.size() > 1 ? goodsTypes : feeTypes.size() > 1 ? feeTypes : goodsTypes;
if (names.isEmpty()) {
addCargoToken(names, detail.getCargoName(), true);
}
if (types.isEmpty()) {
addCargoToken(types, detail.getCargoType(), true);
}
if (names.size() > 1) {
detail.setCargoName(joinCargo(names));
}
if (types.size() > 1) {
detail.setCargoType(joinCargo(types));
}
}
}
private void persistDetailCargoNames(Long settlementId) {
List<FormalSettlementDetail> details = detailMapper.selectList(Wrappers.<FormalSettlementDetail>lambdaQuery()
.eq(FormalSettlementDetail::getFormalSettlementId, settlementId)
.orderByAsc(FormalSettlementDetail::getLineNo));
Map<Long, String> cargoNames = new LinkedHashMap<>();
Map<Long, String> cargoTypes = new LinkedHashMap<>();
for (FormalSettlementDetail detail : details) {
cargoNames.put(detail.getId(), detail.getCargoName());
cargoTypes.put(detail.getId(), detail.getCargoType());
}
fillDetailCargoNames(details);
for (FormalSettlementDetail detail : details) {
if (Objects.equals(cargoNames.get(detail.getId()), detail.getCargoName())
&& Objects.equals(cargoTypes.get(detail.getId()), detail.getCargoType())) {
continue;
}
detailMapper.update(null, Wrappers.<FormalSettlementDetail>lambdaUpdate()
.eq(FormalSettlementDetail::getId, detail.getId())
.set(FormalSettlementDetail::getCargoName, detail.getCargoName())
.set(FormalSettlementDetail::getCargoType, detail.getCargoType()));
}
}
private void collectGoodsCargo(String goodsJson, Set<String> names, Set<String> types) {
if (Func.isEmpty(goodsJson)) {
return;
}
try {
Object parsed = JsonUtil.parse(goodsJson, List.class);
if (!(parsed instanceof List<?> rows)) {
return;
}
for (Object row : rows) {
if (!(row instanceof Map<?, ?> goods)) {
continue;
}
addCargoToken(names, goods.get("cargoName") == null ? null : String.valueOf(goods.get("cargoName")), false);
addCargoToken(types, goods.get("cargoType") == null ? null : String.valueOf(goods.get("cargoType")), false);
}
} catch (RuntimeException ignored) {
// 货物清单异常时保留明细上已有的货物名称。
}
}
private void addCargoToken(Set<String> target, String raw, boolean splitJoined) {
if (Func.isEmpty(raw)) {
return;
}
String[] parts = splitJoined ? raw.split("[,,]") : new String[]{raw};
for (String part : parts) {
String token = part.trim();
if (token.isEmpty() || "手工调差".equals(token)) {
continue;
}
target.add(token);
}
}
private String joinCargo(Set<String> values) {
String joined = String.join(",", values);
return joined.length() > 500 ? joined.substring(0, 500) : joined;
} }
private void copyPreDetailFees(PreSettlementDetail source, FormalSettlementDetail target) { private void copyPreDetailFees(PreSettlementDetail source, FormalSettlementDetail target) {
@@ -383,6 +383,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
fillBillLedger(entity, request); fillBillLedger(entity, request);
validateQuota(entity); validateQuota(entity);
saveOrUpdate(entity); saveOrUpdate(entity);
// 汇票付款在生成申请时即占用可用余额;编辑时按差额调整,避免重复扣减
syncBillOccupation(entity);
invoiceMapper.delete(Wrappers.<PaymentApplicationInvoice>lambdaQuery() invoiceMapper.delete(Wrappers.<PaymentApplicationInvoice>lambdaQuery()
.eq(PaymentApplicationInvoice::getPaymentApplicationId, entity.getId())); .eq(PaymentApplicationInvoice::getPaymentApplicationId, entity.getId()));
int lineNo = 1; int lineNo = 1;
@@ -419,7 +421,16 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
} }
@Override @Transactional(rollbackFor = Exception.class) @Override @Transactional(rollbackFor = Exception.class)
public void removeDraft(Long id) { PaymentApplication entity = editable(id); invoiceMapper.delete(Wrappers.<PaymentApplicationInvoice>lambdaQuery().eq(PaymentApplicationInvoice::getPaymentApplicationId, id)); recordMapper.delete(Wrappers.<PaymentApplicationRecord>lambdaQuery().eq(PaymentApplicationRecord::getPaymentApplicationId, id)); settlementRelationMapper.delete(Wrappers.<PaymentApplicationSettlement>lambdaQuery().eq(PaymentApplicationSettlement::getPaymentApplicationId, id)); removeById(entity); } public void removeDraft(Long id) {
PaymentApplication entity = editable(id);
if (isBillPayment(entity.getPaymentMethod())) {
releaseBillBalanceIfOccupied(entity);
}
invoiceMapper.delete(Wrappers.<PaymentApplicationInvoice>lambdaQuery().eq(PaymentApplicationInvoice::getPaymentApplicationId, id));
recordMapper.delete(Wrappers.<PaymentApplicationRecord>lambdaQuery().eq(PaymentApplicationRecord::getPaymentApplicationId, id));
settlementRelationMapper.delete(Wrappers.<PaymentApplicationSettlement>lambdaQuery().eq(PaymentApplicationSettlement::getPaymentApplicationId, id));
removeById(entity);
}
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void submit(PaymentApplicationStatusRequest request) { public void submit(PaymentApplicationStatusRequest request) {
@@ -434,13 +445,33 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
validateProjectAdvanceContract(entity); validateProjectAdvanceContract(entity);
validateSelectedBill(entity, false); validateSelectedBill(entity, false);
validateQuota(entity); validateQuota(entity);
// 驳回后可用余额已退回,再次提交时重新占用
if (isBillPayment(entity.getPaymentMethod()) && activeBillUsage(entity.getId()) == null) {
syncBillOccupation(entity);
}
entity.setApprovalStatus(TransportBusiConstant.REVIEWING); entity.setApprovalStatus(TransportBusiConstant.REVIEWING);
entity.setCurrentNode("财务审核"); entity.setCurrentNode("财务审核");
entity.setCurrentProcessor(AuthUtil.getUserName()); entity.setCurrentProcessor(AuthUtil.getUserName());
updateById(entity); updateById(entity);
refreshFormalSettlement(entity); refreshFormalSettlement(entity);
} }
@Override public void returnBill(PaymentApplicationStatusRequest request) { change(request.getId(), TransportBusiConstant.REVIEWING, TransportBusiConstant.RETURNED, "已驳回", request.getReason()); } @Override
@Transactional(rollbackFor = Exception.class)
public void returnBill(PaymentApplicationStatusRequest request) {
PaymentApplication entity = lockedPayment(request.getId());
if (!TransportBusiConstant.REVIEWING.equals(entity.getApprovalStatus())) {
throw new ServiceException("当前状态不允许此操作");
}
if (isBillPayment(entity.getPaymentMethod())) {
releaseBillBalanceIfOccupied(entity);
}
entity.setApprovalStatus(TransportBusiConstant.RETURNED);
entity.setCurrentNode("已驳回");
entity.setCurrentProcessor(AuthUtil.getUserName());
entity.setRemark(request.getReason() == null ? entity.getRemark() : limit(request.getReason(), 200));
updateById(entity);
refreshFormalSettlement(entity);
}
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void voidBill(PaymentApplicationStatusRequest request) { public void voidBill(PaymentApplicationStatusRequest request) {
@@ -464,7 +495,10 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
public void approve(PaymentApplicationStatusRequest request) { public void approve(PaymentApplicationStatusRequest request) {
PaymentApplication entity = lockedPayment(request.getId()); PaymentApplication entity = lockedPayment(request.getId());
if (!TransportBusiConstant.REVIEWING.equals(entity.getApprovalStatus())) throw new ServiceException("仅审批中的付款申请允许审核"); if (!TransportBusiConstant.REVIEWING.equals(entity.getApprovalStatus())) throw new ServiceException("仅审批中的付款申请允许审核");
if (isBillPayment(entity.getPaymentMethod())) useBillBalance(entity); // 生成申请时已扣减;仅历史未占用单据在审批通过时补扣,避免重复扣减
if (isBillPayment(entity.getPaymentMethod()) && activeBillUsage(entity.getId()) == null) {
useBillBalance(entity);
}
entity.setApprovalStatus(TransportBusiConstant.APPROVED); entity.setCurrentNode("审批通过"); entity.setCurrentProcessor(AuthUtil.getUserName()); entity.setApprovalStatus(TransportBusiConstant.APPROVED); entity.setCurrentNode("审批通过"); entity.setCurrentProcessor(AuthUtil.getUserName());
updateById(entity); updateById(entity);
refreshFormalSettlement(entity); refreshFormalSettlement(entity);
@@ -2016,9 +2050,17 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
private BigDecimal sumApplied(LambdaQueryWrapper<PaymentApplication> wrapper) { private BigDecimal sumApplied(LambdaQueryWrapper<PaymentApplication> wrapper) {
return list(wrapper.ne(PaymentApplication::getApprovalStatus, TransportBusiConstant.VOIDED)).stream() return list(wrapper.ne(PaymentApplication::getApprovalStatus, TransportBusiConstant.VOIDED)).stream()
.filter(item -> !releasedBillPayment(item))
.map(PaymentApplication::getAppliedAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); .map(PaymentApplication::getAppliedAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add);
} }
/** 汇票付款被驳回后不再占用结算单可付款金额,删除草稿由逻辑删除自然释放。 */
private boolean releasedBillPayment(PaymentApplication payment) {
return payment != null
&& isBillPayment(payment.getPaymentMethod())
&& TransportBusiConstant.RETURNED.equals(payment.getApprovalStatus());
}
private BigDecimal cumulativeAppliedAmount(String paymentType, Long referenceId, Long excludeId) { private BigDecimal cumulativeAppliedAmount(String paymentType, Long referenceId, Long excludeId) {
if ("progress_advance".equals(paymentType)) { if ("progress_advance".equals(paymentType)) {
return cumulativePreSettlementApplied(referenceId, excludeId); return cumulativePreSettlementApplied(referenceId, excludeId);
@@ -2053,7 +2095,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
continue; continue;
} }
coveredPaymentIds.add(payment.getId()); coveredPaymentIds.add(payment.getId());
if (Objects.equals(payment.getId(), excludeId) || TransportBusiConstant.VOIDED.equals(payment.getApprovalStatus())) { if (Objects.equals(payment.getId(), excludeId) || TransportBusiConstant.VOIDED.equals(payment.getApprovalStatus())
|| releasedBillPayment(payment)) {
continue; continue;
} }
relationAmount = relationAmount.add(money(relation.getAppliedAmount())); relationAmount = relationAmount.add(money(relation.getAppliedAmount()));
@@ -2064,6 +2107,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
.ne(excludeId != null, PaymentApplication::getId, excludeId) .ne(excludeId != null, PaymentApplication::getId, excludeId)
.notIn(!coveredPaymentIds.isEmpty(), PaymentApplication::getId, coveredPaymentIds) .notIn(!coveredPaymentIds.isEmpty(), PaymentApplication::getId, coveredPaymentIds)
.ne(PaymentApplication::getApprovalStatus, TransportBusiConstant.VOIDED)).stream() .ne(PaymentApplication::getApprovalStatus, TransportBusiConstant.VOIDED)).stream()
.filter(item -> !releasedBillPayment(item))
.map(PaymentApplication::getAppliedAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); .map(PaymentApplication::getAppliedAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add);
return relationAmount.add(legacyAmount); return relationAmount.add(legacyAmount);
} }
@@ -2093,7 +2137,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
return; return;
} }
BillLedger ledger = billLedgerMapper.selectById(request.getBillLedgerId()); BillLedger ledger = billLedgerMapper.selectById(request.getBillLedgerId());
validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true); validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true, entity.getId());
entity.setBillLedgerId(ledger.getId()); entity.setBillLedgerId(ledger.getId());
entity.setBillNo(ledger.getBillNo()); entity.setBillNo(ledger.getBillNo());
} }
@@ -2101,37 +2145,127 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
private void validateSelectedBill(PaymentApplication entity, boolean locked) { private void validateSelectedBill(PaymentApplication entity, boolean locked) {
if (!isBillPayment(entity.getPaymentMethod())) return; if (!isBillPayment(entity.getPaymentMethod())) return;
BillLedger ledger = locked ? lockedBill(entity.getBillLedgerId()) : billLedgerMapper.selectById(entity.getBillLedgerId()); BillLedger ledger = locked ? lockedBill(entity.getBillLedgerId()) : billLedgerMapper.selectById(entity.getBillLedgerId());
validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true); validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true, entity.getId());
} }
private void validateBill(BillLedger ledger, Long deptId, BigDecimal amount, boolean checkMaturity) { private void validateBill(BillLedger ledger, Long deptId, BigDecimal amount, boolean checkMaturity, Long paymentApplicationId) {
if (ledger == null || Objects.equals(ledger.getIsDeleted(), 1)) throw new ServiceException("所选汇票台账不存在"); if (ledger == null || Objects.equals(ledger.getIsDeleted(), 1)) throw new ServiceException("所选汇票台账不存在");
if (checkMaturity && (ledger.getMaturityDate() == null || ledger.getMaturityDate().isBefore(LocalDate.now()))) { if (checkMaturity && (ledger.getMaturityDate() == null || ledger.getMaturityDate().isBefore(LocalDate.now()))) {
throw new ServiceException("所选汇票已到期"); throw new ServiceException("所选汇票已到期");
} }
if (money(amount).compareTo(money(ledger.getAvailableBalance())) > 0) throw new ServiceException("申请付款金额超过汇票可用余额"); BigDecimal available = money(ledger.getAvailableBalance()).add(occupiedAmount(paymentApplicationId, ledger.getId()));
if (money(amount).compareTo(available) > 0) throw new ServiceException("申请付款金额超过汇票可用余额");
if (!departmentAvailable(ledger, deptId)) throw new ServiceException("当前使用部门不在汇票可用部门范围内"); if (!departmentAvailable(ledger, deptId)) throw new ServiceException("当前使用部门不在汇票可用部门范围内");
} }
private void useBillBalance(PaymentApplication entity) { private void useBillBalance(PaymentApplication entity) {
BillLedger ledger = lockedBill(entity.getBillLedgerId()); BillLedger ledger = lockedBill(entity.getBillLedgerId());
validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true); validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true, null);
BillLedgerUsage exists = billLedgerUsageMapper.selectOne(Wrappers.<BillLedgerUsage>lambdaQuery() BillLedgerUsage exists = lockedBillUsage(entity.getId());
.eq(BillLedgerUsage::getPaymentApplicationId, entity.getId()).last("FOR UPDATE")); if (exists != null && TransportBusiConstant.APPROVED.equals(exists.getUsageStatus())) {
if (exists != null) throw new ServiceException("该付款申请已生成汇票使用记录"); throw new ServiceException("该付款申请已生成汇票使用记录");
}
BigDecimal amount = money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP); BigDecimal amount = money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP);
ledger.setAvailableBalance(money(ledger.getAvailableBalance()).subtract(amount)); ledger.setAvailableBalance(money(ledger.getAvailableBalance()).subtract(amount).setScale(2, RoundingMode.HALF_UP));
billLedgerMapper.updateById(ledger);
if (exists == null) {
BillLedgerUsage usage = new BillLedgerUsage();
usage.setBillLedgerId(ledger.getId());
usage.setPaymentApplicationId(entity.getId());
usage.setApplicationNo(entity.getPaymentNo());
usage.setUsedAmount(amount);
usage.setUseDeptId(entity.getDeptId());
usage.setUseDeptName(entity.getDeptName());
usage.setUsageStatus(TransportBusiConstant.APPROVED);
usage.setStatus(1);
billLedgerUsageMapper.insert(usage);
return;
}
exists.setBillLedgerId(ledger.getId());
exists.setApplicationNo(entity.getPaymentNo());
exists.setUsedAmount(amount);
exists.setUseDeptId(entity.getDeptId());
exists.setUseDeptName(entity.getDeptName());
exists.setUsageStatus(TransportBusiConstant.APPROVED);
billLedgerUsageMapper.updateById(exists);
}
/**
* 汇票付款申请生成后占用汇票可用余额。同一申请重复保存时只调整差额;
* 更换汇票或改为非汇票付款时,先退回原占用。
*/
private void syncBillOccupation(PaymentApplication entity) {
BillLedgerUsage usage = lockedBillUsage(entity.getId());
boolean occupy = isBillPayment(entity.getPaymentMethod()) && entity.getBillLedgerId() != null;
if (!occupy) {
if (usage != null && TransportBusiConstant.APPROVED.equals(usage.getUsageStatus())) {
releaseUsage(usage);
}
return;
}
BigDecimal amount = money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP);
if (usage != null && TransportBusiConstant.APPROVED.equals(usage.getUsageStatus())
&& Objects.equals(usage.getBillLedgerId(), entity.getBillLedgerId())) {
adjustOccupiedAmount(usage, entity, amount);
return;
}
if (usage != null && TransportBusiConstant.APPROVED.equals(usage.getUsageStatus())) {
releaseUsage(usage);
}
useBillBalance(entity);
}
private void adjustOccupiedAmount(BillLedgerUsage usage, PaymentApplication entity, BigDecimal amount) {
BigDecimal delta = amount.subtract(money(usage.getUsedAmount())).setScale(2, RoundingMode.HALF_UP);
BillLedger ledger = lockedBill(usage.getBillLedgerId());
if (delta.compareTo(BigDecimal.ZERO) > 0 && delta.compareTo(money(ledger.getAvailableBalance())) > 0) {
throw new ServiceException("申请付款金额超过汇票可用余额");
}
BigDecimal available = money(ledger.getAvailableBalance()).subtract(delta).setScale(2, RoundingMode.HALF_UP);
if (available.compareTo(BigDecimal.ZERO) < 0) {
throw new ServiceException("申请付款金额超过汇票可用余额");
}
BigDecimal faceAmount = money(ledger.getFaceAmount());
if (available.compareTo(faceAmount) > 0) {
available = faceAmount;
}
ledger.setAvailableBalance(available);
billLedgerMapper.updateById(ledger); billLedgerMapper.updateById(ledger);
BillLedgerUsage usage = new BillLedgerUsage();
usage.setBillLedgerId(ledger.getId());
usage.setPaymentApplicationId(entity.getId());
usage.setApplicationNo(entity.getPaymentNo()); usage.setApplicationNo(entity.getPaymentNo());
usage.setUsedAmount(amount); usage.setUsedAmount(amount);
usage.setUseDeptId(entity.getDeptId()); usage.setUseDeptId(entity.getDeptId());
usage.setUseDeptName(entity.getDeptName()); usage.setUseDeptName(entity.getDeptName());
usage.setUsageStatus(TransportBusiConstant.APPROVED); billLedgerUsageMapper.updateById(usage);
usage.setStatus(1); }
billLedgerUsageMapper.insert(usage);
private BigDecimal occupiedAmount(Long paymentApplicationId, Long billLedgerId) {
if (paymentApplicationId == null || billLedgerId == null) {
return BigDecimal.ZERO;
}
BillLedgerUsage usage = activeBillUsage(paymentApplicationId);
if (usage == null || !Objects.equals(usage.getBillLedgerId(), billLedgerId)) {
return BigDecimal.ZERO;
}
return money(usage.getUsedAmount());
}
private BillLedgerUsage activeBillUsage(Long paymentApplicationId) {
if (paymentApplicationId == null) {
return null;
}
return billLedgerUsageMapper.selectOne(Wrappers.<BillLedgerUsage>lambdaQuery()
.eq(BillLedgerUsage::getPaymentApplicationId, paymentApplicationId)
.eq(BillLedgerUsage::getUsageStatus, TransportBusiConstant.APPROVED)
.last("limit 1"));
}
private BillLedgerUsage lockedBillUsage(Long paymentApplicationId) {
if (paymentApplicationId == null) {
return null;
}
return billLedgerUsageMapper.selectOne(Wrappers.<BillLedgerUsage>lambdaQuery()
.eq(BillLedgerUsage::getPaymentApplicationId, paymentApplicationId)
.last("limit 1 FOR UPDATE"));
} }
private void releaseBillBalance(PaymentApplication entity) { private void releaseBillBalance(PaymentApplication entity) {
@@ -2139,9 +2273,23 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
.eq(BillLedgerUsage::getPaymentApplicationId, entity.getId()) .eq(BillLedgerUsage::getPaymentApplicationId, entity.getId())
.eq(BillLedgerUsage::getUsageStatus, TransportBusiConstant.APPROVED).last("FOR UPDATE")); .eq(BillLedgerUsage::getUsageStatus, TransportBusiConstant.APPROVED).last("FOR UPDATE"));
if (usage == null) throw new ServiceException("未找到对应的汇票使用记录,无法作废"); if (usage == null) throw new ServiceException("未找到对应的汇票使用记录,无法作废");
releaseUsage(usage);
}
private void releaseBillBalanceIfOccupied(PaymentApplication entity) {
BillLedgerUsage usage = billLedgerUsageMapper.selectOne(Wrappers.<BillLedgerUsage>lambdaQuery()
.eq(BillLedgerUsage::getPaymentApplicationId, entity.getId())
.eq(BillLedgerUsage::getUsageStatus, TransportBusiConstant.APPROVED).last("FOR UPDATE"));
if (usage == null) {
return;
}
releaseUsage(usage);
}
private void releaseUsage(BillLedgerUsage usage) {
BillLedger ledger = lockedBill(usage.getBillLedgerId()); BillLedger ledger = lockedBill(usage.getBillLedgerId());
ledger.setAvailableBalance(money(ledger.getAvailableBalance()).add(money(usage.getUsedAmount())) ledger.setAvailableBalance(money(ledger.getAvailableBalance()).add(money(usage.getUsedAmount()))
.min(money(ledger.getFaceAmount()))); .min(money(ledger.getFaceAmount())).setScale(2, RoundingMode.HALF_UP));
billLedgerMapper.updateById(ledger); billLedgerMapper.updateById(ledger);
usage.setUsageStatus("released"); usage.setUsageStatus("released");
billLedgerUsageMapper.updateById(usage); billLedgerUsageMapper.updateById(usage);
@@ -836,6 +836,12 @@ public class ReceivablePayableDetailServiceImpl
if (existsByWaybill(waybill.getId(), targetSettlementType)) { if (existsByWaybill(waybill.getId(), targetSettlementType)) {
continue; continue;
} }
if ("payable".equals(targetSettlementType)
&& trimToNull(waybill.getCarrierName()) != null
&& !matchesWaybillCarrier(contract, waybill)) {
throw new ServiceException("运单【" + Func.toStr(waybill.getWaybillNo(), String.valueOf(waybill.getId()))
+ "】的承运商不是所选承运商合同的乙方,不能使用项目内其他承运商合同生成应付");
}
ReceivablePayableDetail detail = buildDetail(waybill, contract, request.getBillingPlanId(), targetSettlementType); ReceivablePayableDetail detail = buildDetail(waybill, contract, request.getBillingPlanId(), targetSettlementType);
detail.setSourceType(SOURCE_MANUAL_GENERATION); detail.setSourceType(SOURCE_MANUAL_GENERATION);
save(detail); save(detail);
@@ -1064,11 +1070,20 @@ public class ReceivablePayableDetailServiceImpl
List<Waybill> payableWaybills = waybills.stream() List<Waybill> payableWaybills = waybills.stream()
.filter(waybill -> !isSelfTransport(waybill)).toList(); .filter(waybill -> !isSelfTransport(waybill)).toList();
if (payableWaybills.isEmpty()) return; if (payableWaybills.isEmpty()) return;
List<Waybill> carrierMatchedWaybills = payableWaybills.stream()
.filter(waybill -> matchesWaybillCarrier(carrierContract, waybill)).toList();
List<Waybill> otherCarrierWaybills = payableWaybills.stream()
.filter(waybill -> !matchesWaybillCarrier(carrierContract, waybill)).toList();
if (!otherCarrierWaybills.isEmpty()) {
generateAutomaticWaybillDetails(otherCarrierWaybills, false,
this::resolveCarrierContract, currentUserId, generateTime);
}
if (carrierMatchedWaybills.isEmpty()) return;
generateAutomaticDetail(carrierContract, "payable", generateAutomaticDetail(carrierContract, "payable",
() -> existsByLoading(loadingNo, "payable"), payableWaybills, () -> existsByLoading(loadingNo, "payable"), carrierMatchedWaybills,
(fees, unitPrice) -> { (fees, unitPrice) -> {
normalizeWaybillFeeLines(fees); normalizeWaybillFeeLines(fees);
return buildLoadingDetail(loadingNo, carrierContract, payableWaybills, fees, unitPrice); return buildLoadingDetail(loadingNo, carrierContract, carrierMatchedWaybills, fees, unitPrice);
}, },
false, currentUserId, generateTime); false, currentUserId, generateTime);
} }
@@ -1118,22 +1133,54 @@ public class ReceivablePayableDetailServiceImpl
private ContractManage resolveWaybillCarrierContract(Waybill waybill) { private ContractManage resolveWaybillCarrierContract(Waybill waybill) {
if (Func.isNotEmpty(waybill.getLoadingNo())) return null; if (Func.isNotEmpty(waybill.getLoadingNo())) return null;
return resolveCarrierContract(waybill);
}
/**
* 应付只认运单选择的承运商。
* 运单已记录承运商合同时,乙方必须就是该承运商;否则按承运商名称回查同一项目下的承运商合同。
* 不允许落到项目里其他承运商合同的客商。
*/
private ContractManage resolveCarrierContract(Waybill waybill) {
if (waybill == null || isSelfTransport(waybill)) {
return null;
}
String carrierName = trimToNull(waybill.getCarrierName());
if (Func.isNotEmpty(waybill.getCarrierContractId())) { if (Func.isNotEmpty(waybill.getCarrierContractId())) {
ContractManage contract = contractManageService.getById(waybill.getCarrierContractId()); ContractManage recorded = contractManageService.getById(waybill.getCarrierContractId());
if (contract == null || !"承运商合同".equals(contract.getContractCategory())) { if (recorded == null || !"承运商合同".equals(recorded.getContractCategory())) {
throw new ServiceException("运单记录的承运商合同不存在或合同类别不正确"); throw new ServiceException("运单记录的承运商合同不存在或合同类别不正确");
} }
return contract; if (carrierName == null || matchesWaybillCarrier(recorded, waybill)) {
return recorded;
}
} }
return findCarrierContract(waybill); ContractManage matched = findCarrierContract(waybill);
if (matched != null && matchesWaybillCarrier(matched, waybill)) {
return matched;
}
return null;
}
private boolean matchesWaybillCarrier(ContractManage contract, Waybill waybill) {
if (contract == null || waybill == null || !"承运商合同".equals(contract.getContractCategory())) {
return false;
}
if (waybill.getProjectId() != null && contract.getProjectId() != null
&& !Objects.equals(waybill.getProjectId(), contract.getProjectId())) {
return false;
}
String carrierName = trimToNull(waybill.getCarrierName());
String partyB = trimToNull(contract.getPartyB());
return carrierName != null && carrierName.equals(partyB);
} }
private ContractManage findCarrierContract(Waybill waybill) { private ContractManage findCarrierContract(Waybill waybill) {
if (waybill.getProjectId() == null || Func.isEmpty(waybill.getContractId()) String carrierName = trimToNull(waybill.getCarrierName());
|| Func.isEmpty(waybill.getCarrierName())) { if (waybill.getProjectId() == null || Func.isEmpty(waybill.getContractId()) || carrierName == null) {
return null; return null;
} }
// 承运商合同甲方应与运单绑定的客户合同甲方一致,不能直接使用运单的客户名称拼接字段。 // 承运商合同甲方应与运单绑定的客户合同甲方一致,乙方必须是运单选择的承运商。
ContractManage customerContract = contractManageService.getById(waybill.getContractId()); ContractManage customerContract = contractManageService.getById(waybill.getContractId());
if (customerContract == null || Func.isEmpty(customerContract.getPartyA())) { if (customerContract == null || Func.isEmpty(customerContract.getPartyA())) {
return null; return null;
@@ -1142,13 +1189,38 @@ public class ReceivablePayableDetailServiceImpl
.eq(ContractManage::getIsDeleted, 0) .eq(ContractManage::getIsDeleted, 0)
.eq(ContractManage::getProjectId, waybill.getProjectId()) .eq(ContractManage::getProjectId, waybill.getProjectId())
.eq(ContractManage::getPartyA, customerContract.getPartyA()) .eq(ContractManage::getPartyA, customerContract.getPartyA())
.eq(ContractManage::getPartyB, waybill.getCarrierName()) .eq(ContractManage::getPartyB, carrierName)
.eq(ContractManage::getContractCategory, "承运商合同") .eq(ContractManage::getContractCategory, "承运商合同")
.and(wrapper -> wrapper.isNull(ContractManage::getContractStage) .and(wrapper -> wrapper.isNull(ContractManage::getContractStage)
.or().ne(ContractManage::getContractStage, "terminated")) .or().ne(ContractManage::getContractStage, "terminated"))
.orderByDesc(ContractManage::getCreateTime), false); .orderByDesc(ContractManage::getCreateTime), false);
} }
private String trimToNull(String value) {
if (value == null) {
return null;
}
String text = value.trim();
return text.isEmpty() ? null : text;
}
/**
* 应付客商取运单选择的承运商,避免写成项目内其他承运商合同的乙方。
*/
private String resolveCounterpartyName(Waybill waybill, ContractManage contract, String settlementType) {
if ("payable".equals(settlementType)) {
String carrierName = trimToNull(waybill == null ? null : waybill.getCarrierName());
if (carrierName != null) {
return carrierName;
}
return contract == null ? null : contract.getPartyB();
}
if (contract != null && Func.isNotEmpty(contract.getPartyA())) {
return contract.getPartyA();
}
return waybill == null ? null : waybill.getCustomerName();
}
@Override @Override
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW, @Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW,
rollbackFor = Exception.class) rollbackFor = Exception.class)
@@ -1201,12 +1273,24 @@ public class ReceivablePayableDetailServiceImpl
.collect(Collectors.toMap(ContractManage::getId, contract -> contract)); .collect(Collectors.toMap(ContractManage::getId, contract -> contract));
Map<Long, List<Waybill>> carrierWaybills = waybills.stream().collect(Collectors.groupingBy( Map<Long, List<Waybill>> carrierWaybills = waybills.stream().collect(Collectors.groupingBy(
Waybill::getCarrierContractId, LinkedHashMap::new, Collectors.toList())); Waybill::getCarrierContractId, LinkedHashMap::new, Collectors.toList()));
List<Waybill> mismatchedCarrierWaybills = waybills.stream()
.filter(waybill -> !matchesWaybillCarrier(carrierContracts.get(waybill.getCarrierContractId()), waybill))
.toList();
if (!mismatchedCarrierWaybills.isEmpty()) {
generateAutomaticWaybillDetails(mismatchedCarrierWaybills, false,
this::resolveCarrierContract, currentUserId, generateTime);
}
for (Map.Entry<Long, List<Waybill>> entry : carrierWaybills.entrySet()) { for (Map.Entry<Long, List<Waybill>> entry : carrierWaybills.entrySet()) {
ContractManage carrierContract = carrierContracts.get(entry.getKey()); ContractManage carrierContract = carrierContracts.get(entry.getKey());
if (carrierContract == null || !"承运商合同".equals(carrierContract.getContractCategory())) { if (carrierContract == null || !"承运商合同".equals(carrierContract.getContractCategory())) {
throw new ServiceException("总单【" + masterOrder.getMasterNo() + "】记录的承运商合同不存在或合同类别不正确"); throw new ServiceException("总单【" + masterOrder.getMasterNo() + "】记录的承运商合同不存在或合同类别不正确");
} }
List<Waybill> currentCarrierWaybills = entry.getValue(); List<Waybill> currentCarrierWaybills = entry.getValue().stream()
.filter(waybill -> matchesWaybillCarrier(carrierContract, waybill))
.toList();
if (currentCarrierWaybills.isEmpty()) {
continue;
}
generateAutomaticDetail(carrierContract, "payable", generateAutomaticDetail(carrierContract, "payable",
() -> existsByMasterOrderContract(masterOrder.getMasterNo(), "payable", carrierContract.getId()), () -> existsByMasterOrderContract(masterOrder.getMasterNo(), "payable", carrierContract.getId()),
currentCarrierWaybills, currentCarrierWaybills,
@@ -1635,9 +1719,7 @@ public class ReceivablePayableDetailServiceImpl
detail.setDeptId(contract == null ? waybill.getDeptId() : contract.getOrganizationId()); detail.setDeptId(contract == null ? waybill.getDeptId() : contract.getOrganizationId());
detail.setDeptName(contract == null ? waybill.getDeptName() : contract.getOrganizationName()); detail.setDeptName(contract == null ? waybill.getDeptName() : contract.getOrganizationName());
detail.setFeeDate(waybill.getEndDate() == null ? LocalDate.now() : waybill.getEndDate()); detail.setFeeDate(waybill.getEndDate() == null ? LocalDate.now() : waybill.getEndDate());
detail.setCustomerName(contract == null detail.setCustomerName(resolveCounterpartyName(waybill, contract, settlementType));
? ("payable".equals(settlementType) ? waybill.getCarrierName() : waybill.getCustomerName())
: ("payable".equals(settlementType) ? contract.getPartyB() : contract.getPartyA()));
detail.setContractId(contract == null ? waybill.getContractId() : contract.getId()); detail.setContractId(contract == null ? waybill.getContractId() : contract.getId());
detail.setContractNo(contract == null ? null : contract.getContractNo()); detail.setContractNo(contract == null ? null : contract.getContractNo());
detail.setContractName(contract == null ? waybill.getContractName() : contract.getContractName()); detail.setContractName(contract == null ? waybill.getContractName() : contract.getContractName());
@@ -1652,8 +1734,8 @@ public class ReceivablePayableDetailServiceImpl
detail.setArrivalContact(waybill.getArrivalContact()); detail.setArrivalContact(waybill.getArrivalContact());
detail.setArrivalPhone(waybill.getArrivalPhone()); detail.setArrivalPhone(waybill.getArrivalPhone());
detail.setTransportType(waybill.getTransportType()); detail.setTransportType(waybill.getTransportType());
detail.setCargoName(waybill.getCargoName()); detail.setCargoName(joinDistinctGoods(waybill, "cargoName", waybill.getCargoName()));
detail.setCargoType(waybill.getCargoType()); detail.setCargoType(joinDistinctGoods(waybill, "cargoType", waybill.getCargoType()));
detail.setTransportQuantity(resolveWaybillTransportQuantity(waybill)); detail.setTransportQuantity(resolveWaybillTransportQuantity(waybill));
detail.setQuantityUnit(waybill.getQuantityUnit()); detail.setQuantityUnit(waybill.getQuantityUnit());
detail.setMileage(normalizeGeneratedMileage(waybill.getMileage())); detail.setMileage(normalizeGeneratedMileage(waybill.getMileage()));
@@ -1668,6 +1750,21 @@ public class ReceivablePayableDetailServiceImpl
return detail; return detail;
} }
/** 一单多货时主表只保留首行货物,应付明细改记全部货物名称、货物类型。 */
private String joinDistinctGoods(Waybill waybill, String field, String fallback) {
LinkedHashSet<String> values = new LinkedHashSet<>();
for (Map<String, Object> goods : parseList(waybill.getGoodsJson())) {
String value = stringValue(goods, field, "");
if (Func.isNotEmpty(value)) {
values.add(value.trim());
}
}
if (values.isEmpty()) {
return fallback;
}
return String.join(",", values);
}
/** /**
* 多货物运单优先按 goodsJson 数量合计作为运输总量,避免主表 quantity 只落了首行。 * 多货物运单优先按 goodsJson 数量合计作为运输总量,避免主表 quantity 只落了首行。
*/ */
@@ -1233,14 +1233,67 @@ public class TransportReconciliationServiceImpl
private List<TransportReconciliationInternal> assertAllMatched(TransportReconciliation bill) { private List<TransportReconciliationInternal> assertAllMatched(TransportReconciliation bill) {
List<TransportReconciliationInternal> internals = internalRows(bill.getId()); List<TransportReconciliationInternal> internals = internalRows(bill.getId());
List<TransportReconciliationExternal> externals = externalRows(bill.getId()); List<TransportReconciliationExternal> externals = externalRows(bill.getId());
if (internals.isEmpty() || externals.isEmpty() || internals.size() != externals.size() // 调整账单后旧的疑似重复标记可能已失效,按当前内容重算,避免已一一匹配仍被拦截
|| internals.stream().anyMatch(item -> !MATCHED.equals(item.getMatchResult())) syncSuspectedDuplicates(externals);
|| externals.stream().anyMatch(item -> !MATCHED.equals(item.getMatchStatus()) || Boolean.TRUE.equals(item.getSuspectedDuplicate()))) { if (!oneToOneMatched(internals, externals)) {
throw new ServiceException("所有内外部账单明细必须一一匹配且不存在疑似重复"); throw new ServiceException("所有内外部账单明细必须一一匹配且不存在疑似重复");
} }
return internals; return internals;
} }
private void syncSuspectedDuplicates(List<TransportReconciliationExternal> externals) {
Map<String, List<TransportReconciliationExternal>> groups = externals.stream()
.collect(Collectors.groupingBy(this::duplicateKey));
Set<Long> duplicateIds = new HashSet<>();
for (List<TransportReconciliationExternal> group : groups.values()) {
if (group.size() > 1) {
group.stream().map(TransportReconciliationExternal::getId).forEach(duplicateIds::add);
}
}
for (TransportReconciliationExternal external : externals) {
boolean duplicate = duplicateIds.contains(external.getId());
String matchStatus = external.getMatchStatus();
if (!duplicate && DUPLICATE.equals(matchStatus) && external.getMatchedInternalId() != null) {
matchStatus = MATCHED;
}
if (Boolean.TRUE.equals(external.getSuspectedDuplicate()) == duplicate
&& Objects.equals(matchStatus, external.getMatchStatus())) {
continue;
}
external.setSuspectedDuplicate(duplicate);
external.setMatchStatus(matchStatus);
externalMapper.update(null, Wrappers.<TransportReconciliationExternal>lambdaUpdate()
.eq(TransportReconciliationExternal::getId, external.getId())
.set(TransportReconciliationExternal::getSuspectedDuplicate, duplicate)
.set(TransportReconciliationExternal::getMatchStatus, matchStatus));
}
}
private boolean oneToOneMatched(List<TransportReconciliationInternal> internals,
List<TransportReconciliationExternal> externals) {
if (internals.isEmpty() || externals.isEmpty() || internals.size() != externals.size()) {
return false;
}
if (externals.stream().anyMatch(item -> Boolean.TRUE.equals(item.getSuspectedDuplicate()))) {
return false;
}
Map<Long, TransportReconciliationExternal> externalMap = externals.stream()
.collect(Collectors.toMap(TransportReconciliationExternal::getId, Function.identity(), (left, right) -> left));
Set<Long> usedExternalIds = new HashSet<>();
for (TransportReconciliationInternal internal : internals) {
if (!MATCHED.equals(internal.getMatchResult()) || internal.getMatchedExternalId() == null
|| !usedExternalIds.add(internal.getMatchedExternalId())) {
return false;
}
TransportReconciliationExternal external = externalMap.get(internal.getMatchedExternalId());
if (external == null || !MATCHED.equals(external.getMatchStatus())
|| !Objects.equals(external.getMatchedInternalId(), internal.getId())) {
return false;
}
}
return usedExternalIds.size() == externals.size();
}
private void refreshStats(Long id) { private void refreshStats(Long id) {
TransportReconciliation bill = existing(id); TransportReconciliation bill = existing(id);
List<TransportReconciliationInternal> internals = internalRows(id); List<TransportReconciliationInternal> internals = internalRows(id);