✨ 开票合计反算、配载承运商乙方交集与临时合同结算校验

开票按合计金额/税率反算不含税单价(6位小数);配载按子单客户合同甲方匹配后取承运商乙方交集;临时合同过期不可结算,并修正合同变更编号唯一校验。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-26 10:50:55 +08:00
parent 6a4b9cf012
commit 81648fc659
15 changed files with 264 additions and 33 deletions
@@ -76,7 +76,7 @@ public class InvoiceApplicationLine extends TenantEntity {
*/
private BigDecimal quantity;
/**
* 不含税单价
* 不含税单价(由合计金额反算,保留 6 位小数)
*/
private BigDecimal unitPriceNoTax;
/**
@@ -87,4 +87,8 @@ public class WaybillVO extends Waybill {
@Schema(description = "运单主键集合")
private List<Long> ids;
@TableField(exist = false)
@Schema(description = "承运商合同名称")
private String carrierContractName;
}
@@ -56,9 +56,11 @@ public class LoadingManageController extends BladeController {
@GetMapping("/carrier-contracts")
@ApiOperationSupport(order = 13)
@Operation(summary = "可选承运商合同", description = "查询已审核生效的承运商合同")
public R<List<LoadingCarrierContractVO>> carrierContracts(@RequestParam List<Long> projectIds) {
return R.data(loadingManageService.carrierContracts(projectIds));
@Operation(summary = "可选承运商合同", description = "查询已审核生效的承运商合同;传入 waybillIds 时按子运单客户合同甲方匹配后对乙方取交集")
public R<List<LoadingCarrierContractVO>> carrierContracts(
@RequestParam List<Long> projectIds,
@RequestParam(required = false) List<Long> waybillIds) {
return R.data(loadingManageService.carrierContracts(projectIds, waybillIds));
}
@GetMapping("/list")
@@ -72,4 +72,19 @@ public interface IContractManageService extends BaseService<ContractManage> {
*/
String nextContractNo(Long organizationId);
/**
* 合同是否可用于生成结算单:已审批、未终止,且临时合同仍在临时效力期内。
*
* @param contract 合同
* @return true=可用
*/
boolean isUsableForSettlement(ContractManage contract);
/**
* 校验合同可用于生成结算单,不满足时抛出业务异常。
*
* @param contract 合同
*/
void assertUsableForSettlement(ContractManage contract);
}
@@ -26,7 +26,12 @@ public interface ILoadingManageService extends BaseService<LoadingManage> {
LoadingManageVO detail(Long id);
List<LoadingCarrierContractVO> carrierContracts(List<Long> projectIds);
/**
* 查询项目下已审核生效的承运商合同。
* <p>
* 传入 waybillIds 时,按各子运单客户合同甲方匹配可用承运商合同,再对乙方名称取交集后返回。
*/
List<LoadingCarrierContractVO> carrierContracts(List<Long> projectIds, List<Long> waybillIds);
boolean saveDraft(LoadingManage loadingManage);
@@ -592,7 +592,8 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
}
/**
* 保存合同编号。新增未填写时按部门规则生成,已填写或编辑时保留手工编号并校验唯一。
* 保存合同编号。新增未填写时按部门规则生成,已填写或编辑/变更时保留手工编号并校验唯一。
* 编号相对当前记录未变化时跳过唯一校验,避免合同变更原编号提交误报「合同编号已存在」。
*/
private void applyContractNo(ContractManage contractManage, String contractNo, boolean generateWhenEmpty) {
String normalizedContractNo = TransportBusinessSupport.trimToNull(contractNo);
@@ -603,8 +604,12 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
normalizedContractNo = nextContractNo(contractManage.getOrganizationId());
}
TransportBusinessSupport.validateLength(normalizedContractNo, 100, "合同编号不能超过100字");
String currentContractNo = TransportBusinessSupport.trimToNull(contractManage.getContractNo());
if (Objects.equals(normalizedContractNo, currentContractNo) && Func.isNotEmpty(contractManage.getId())) {
contractManage.setContractNo(normalizedContractNo);
return;
}
Long duplicatedCount = count(Wrappers.<ContractManage>lambdaQuery()
.eq(ContractManage::getIsDeleted, 0)
.eq(ContractManage::getContractNo, normalizedContractNo)
.ne(Func.isNotEmpty(contractManage.getId()), ContractManage::getId, contractManage.getId()));
if (duplicatedCount != null && duplicatedCount > 0) {
@@ -613,6 +618,47 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
contractManage.setContractNo(normalizedContractNo);
}
@Override
public boolean isUsableForSettlement(ContractManage contract) {
if (contract == null || Objects.equals(contract.getIsDeleted(), 1)) {
return false;
}
if (!List.of(STATUS_APPROVED, STATUS_CHANGE_APPROVED).contains(contract.getApprovalStatus())) {
return false;
}
if (Objects.equals(contract.getContractStage(), STAGE_TERMINATED)) {
return false;
}
return !isTemporaryContractExpired(contract);
}
@Override
public void assertUsableForSettlement(ContractManage contract) {
if (contract == null || Objects.equals(contract.getIsDeleted(), 1)) {
throw new ServiceException("合同不存在");
}
if (!List.of(STATUS_APPROVED, STATUS_CHANGE_APPROVED).contains(contract.getApprovalStatus())) {
throw new ServiceException("合同未审核完成,不可生成结算单");
}
if (Objects.equals(contract.getContractStage(), STAGE_TERMINATED)) {
throw new ServiceException("合同已终止,不可生成结算单");
}
if (isTemporaryContractExpired(contract)) {
throw new ServiceException("临时合同已过期,不可生成结算单");
}
}
/**
* 临时合同是否已过临时效力止日(不含当天仍有效)。
*/
private boolean isTemporaryContractExpired(ContractManage contract) {
if (contract == null || !Objects.equals(contract.getContractStage(), STAGE_TEMPORARY)) {
return false;
}
LocalDate temporaryEndDate = contract.getTemporaryEndDate();
return temporaryEndDate != null && temporaryEndDate.isBefore(LocalDate.now());
}
@Override
public synchronized String nextContractNo(Long organizationId) {
if (Func.isEmpty(organizationId)) {
@@ -548,11 +548,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
}
PreSettlement first = sources.isEmpty() ? null : sources.get(0);
ContractManage contract = contractManageService.getById(contractId);
if (contract == null || Objects.equals(contract.getIsDeleted(), 1)) throw new ServiceException("合同不存在");
if (!List.of("approved", "change_approved").contains(contract.getApprovalStatus())
|| "terminated".equals(contract.getContractStage())) {
throw new ServiceException("合同未审核完成,不可转正式结算单");
}
contractManageService.assertUsableForSettlement(contract);
if (creating || restoring) {
if (Func.isEmpty(settlement.getFormalSettlementNo())) settlement.setFormalSettlementNo(nextNo(settlementType));
settlement.setApprovalStatus(TransportBusiConstant.DRAFT);
@@ -1423,14 +1423,30 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
required(line.getGoodsCategory(), "商品和服务分类");
required(line.getGoodsName(), "货物或服务简称");
BigDecimal quantity = nonNegative(line.getQuantity(), "数量");
BigDecimal unitPriceNoTax = nonNegative(line.getUnitPriceNoTax(), "不含税单价")
if (quantity.compareTo(BigDecimal.ZERO) <= 0) {
throw new ServiceException("数量必须大于0");
}
BigDecimal totalAmount = nonNegative(line.getTotalAmount(), "合计金额")
.setScale(2, RoundingMode.HALF_UP);
if (totalAmount.compareTo(BigDecimal.ZERO) <= 0) {
throw new ServiceException("合计金额必须大于0");
}
BigDecimal taxRate = nonNegative(line.getTaxRate(), "税率");
if (taxRate.compareTo(BigDecimal.valueOf(100)) > 0) {
throw new ServiceException("税率必须在0-100之间");
}
// 合计金额 = 数量 × 不含税单价 × (1 + 税率)
// 不含税单价 = 合计金额 / 数量 / (1 + 税率),保留 6 位小数
BigDecimal rateFactor = BigDecimal.ONE.add(
taxRate.divide(BigDecimal.valueOf(100), 8, RoundingMode.HALF_UP));
BigDecimal unitPriceNoTax = totalAmount
.divide(quantity, 10, RoundingMode.HALF_UP)
.divide(rateFactor, 6, RoundingMode.HALF_UP);
BigDecimal amountNoTax = quantity.multiply(unitPriceNoTax)
.setScale(2, RoundingMode.HALF_UP);
BigDecimal taxRate = nonNegative(line.getTaxRate(), "税率");
if (taxRate.compareTo(BigDecimal.valueOf(100)) > 0) throw new ServiceException("税率必须在0-100之间");
BigDecimal taxAmount = calculateTax(amountNoTax, taxRate);
BigDecimal totalAmount = amountNoTax.add(taxAmount).setScale(2, RoundingMode.HALF_UP);
BigDecimal taxAmount = totalAmount.subtract(amountNoTax)
.setScale(2, RoundingMode.HALF_UP);
line.setUnitPriceNoTax(unitPriceNoTax);
line.setAmountNoTax(amountNoTax);
line.setTaxAmount(taxAmount);
line.setTotalAmount(totalAmount);
@@ -107,11 +107,22 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
}
@Override
public List<LoadingCarrierContractVO> carrierContracts(List<Long> projectIds) {
public List<LoadingCarrierContractVO> carrierContracts(List<Long> projectIds, List<Long> waybillIds) {
if (Func.isEmpty(projectIds)) {
return List.of();
}
return availableCarrierContracts(projectIds).stream().map(contract -> {
List<ContractManage> contracts = availableCarrierContracts(projectIds);
if (Func.isNotEmpty(waybillIds)) {
List<Waybill> waybills = loadWaybills(waybillIds);
Set<String> allowedPartyBs = intersectCarrierPartyBs(waybills, contracts);
if (allowedPartyBs.isEmpty()) {
return List.of();
}
contracts = contracts.stream()
.filter(contract -> allowedPartyBs.contains(normalizePartyName(contract.getPartyB())))
.toList();
}
return contracts.stream().map(contract -> {
LoadingCarrierContractVO option = new LoadingCarrierContractVO();
option.setId(contract.getId());
option.setContractName(contract.getContractName());
@@ -862,6 +873,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
}
}
validateSameProject(waybills);
validateCompatibleCarrierPartyB(waybills);
return waybills;
}
@@ -894,6 +906,20 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
}
}
/**
* 同一配载单子运单除同项目外,还必须存在共同的承运商合同乙方(按客户合同甲方匹配后取交集)。
*/
private void validateCompatibleCarrierPartyB(List<Waybill> waybills) {
if (Func.isEmpty(waybills) || waybills.size() < 2) {
return;
}
List<ContractManage> contracts = availableCarrierContracts(projectIdsOf(waybills));
Set<String> allowedPartyBs = intersectCarrierPartyBs(waybills, contracts);
if (allowedPartyBs.isEmpty()) {
throw new ServiceException("所选运单在同一项目下没有共同的承运商合同乙方,不能配载到同一配载单");
}
}
private void validateCarrierContract(LoadingManage loadingManage, boolean required) {
validateCarrierContract(loadingManage, required, null);
}
@@ -931,6 +957,18 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
? "所选承运商合同不属于本项目"
: "所选承运商合同不属于运单" + waybillNos + "的项目");
}
if (Func.isNotEmpty(childWaybills)) {
List<ContractManage> projectContracts = availableCarrierContracts(projectIdsOf(childWaybills));
Set<String> allowedPartyBs = intersectCarrierPartyBs(childWaybills, projectContracts);
String partyB = normalizePartyName(carrierContract.getPartyB());
if (Func.isNotEmpty(allowedPartyBs) && !allowedPartyBs.contains(partyB)) {
throw new ServiceException("所选承运商合同乙方与子运单可共同承运的乙方不一致,可选:"
+ String.join("、", allowedPartyBs));
}
if (allowedPartyBs.isEmpty()) {
throw new ServiceException("所选运单在同一项目下没有共同的承运商合同乙方,不能配载到同一配载单");
}
}
loadingManage.setCarrierName(carrierContract.getPartyB());
}
@@ -969,6 +1007,65 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
.orderByDesc(ContractManage::getCreateTime));
}
/**
* 各子运单按「客户合同甲方 = 承运商合同甲方」匹配可用承运商合同,再对乙方名称取交集。
* 例:子单1可选 A/B/C,子单2可选 B,则交集为 B。
*/
private Set<String> intersectCarrierPartyBs(List<Waybill> waybills, List<ContractManage> projectContracts) {
Set<String> intersection = null;
Map<Long, String> customerPartyAMap = loadCustomerContractPartyAMap(waybills);
for (Waybill waybill : waybills) {
Set<String> available = availableCarrierPartyBs(waybill, projectContracts, customerPartyAMap);
if (intersection == null) {
intersection = new LinkedHashSet<>(available);
} else {
intersection.retainAll(available);
}
if (intersection.isEmpty()) {
return Set.of();
}
}
return intersection == null ? Set.of() : intersection;
}
private Set<String> availableCarrierPartyBs(Waybill waybill, List<ContractManage> projectContracts,
Map<Long, String> customerPartyAMap) {
if (waybill == null || Func.isEmpty(projectContracts)) {
return Set.of();
}
String partyA = customerPartyAMap.get(waybill.getContractId());
return projectContracts.stream()
.filter(contract -> Objects.equals(contract.getProjectId(), waybill.getProjectId()))
.filter(contract -> Func.isEmpty(partyA) || Objects.equals(normalizePartyName(contract.getPartyA()), partyA))
.map(ContractManage::getPartyB)
.map(this::normalizePartyName)
.filter(Func::isNotEmpty)
.collect(Collectors.toCollection(LinkedHashSet::new));
}
private Map<Long, String> loadCustomerContractPartyAMap(List<Waybill> waybills) {
List<Long> contractIds = waybills.stream()
.map(Waybill::getContractId)
.filter(Objects::nonNull)
.distinct()
.toList();
if (Func.isEmpty(contractIds)) {
return Map.of();
}
return contractManageService.list(Wrappers.<ContractManage>lambdaQuery()
.eq(ContractManage::getIsDeleted, 0)
.in(ContractManage::getId, contractIds))
.stream()
.collect(Collectors.toMap(ContractManage::getId,
contract -> normalizePartyName(contract.getPartyA()),
(left, right) -> left,
LinkedHashMap::new));
}
private String normalizePartyName(String value) {
return value == null ? "" : value.trim();
}
/**
* 按主键取一张可用的承运商合同。
* <p>
@@ -1318,14 +1318,15 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|| !List.of("客户合同", "承运商合同").contains(contract.getContractCategory())) {
throw new ServiceException("仅允许选择审批完成且未作废的合同");
}
if (!contractManageService.isUsableForSettlement(contract)) {
throw new ServiceException("临时合同已过期,不可生成结算单");
}
validateContractRelations(contract);
return contract;
}
private boolean isAvailableContract(ContractManage contract) {
if (contract == null || Objects.equals(contract.getIsDeleted(), 1)
|| !List.of("approved", "change_approved").contains(contract.getApprovalStatus())
|| "terminated".equals(contract.getContractStage()) || contract.getProjectId() == null
if (!contractManageService.isUsableForSettlement(contract) || contract.getProjectId() == null
|| !List.of("客户合同", "承运商合同").contains(contract.getContractCategory())) {
return false;
}
@@ -847,20 +847,20 @@ public class ReceivablePayableDetailServiceImpl
private List<Long> settlementContractIds() {
return contractManageService.list(Wrappers.<ContractManage>lambdaQuery()
.select(ContractManage::getId)
.select(ContractManage::getId, ContractManage::getContractStage, ContractManage::getTemporaryEndDate,
ContractManage::getApprovalStatus, ContractManage::getIsDeleted)
.eq(ContractManage::getIsDeleted, 0)
.in(ContractManage::getApprovalStatus, "approved", "change_approved")
.ne(ContractManage::getContractStage, "terminated"))
.stream().map(ContractManage::getId).toList();
.stream()
.filter(contractManageService::isUsableForSettlement)
.map(ContractManage::getId)
.toList();
}
private void validateSettlementContract(Long contractId) {
ContractManage contract = contractId == null ? null : contractManageService.getById(contractId);
if (contract == null || Objects.equals(contract.getIsDeleted(), 1)
|| !List.of("approved", "change_approved").contains(contract.getApprovalStatus())
|| "terminated".equals(contract.getContractStage())) {
throw new ServiceException("合同未审核完成,不可转预结算单或正式结算单");
}
contractManageService.assertUsableForSettlement(contract);
}
@Override
@@ -935,9 +935,7 @@ public class ReceivablePayableDetailServiceImpl
return List.of();
}
ContractManage contract = contractManageService.getById(contractId);
if (contract == null || Objects.equals(contract.getIsDeleted(), 1)) {
throw new ServiceException("合同不存在");
}
contractManageService.assertUsableForSettlement(contract);
String targetSettlementType = settlementType(settlementType);
Long currentUserId = AuthUtil.getUserId();
Date generateTime = new Date();
@@ -3253,8 +3251,10 @@ public class ReceivablePayableDetailServiceImpl
if (Func.isEmpty(request.getBillingPlanId())) {
throw new ServiceException("请选择计费方案");
}
ContractManage contract = contractManageService.getById(request.getContractId());
contractManageService.assertUsableForSettlement(contract);
String targetSettlementType = settlementType(request.getSettlementType());
validateContractSettlementType(contractManageService.getById(request.getContractId()), targetSettlementType);
validateContractSettlementType(contract, targetSettlementType);
if (requireWaybill && Func.isEmpty(request.getWaybillIds())) {
throw new ServiceException("请选择需要生成费用的运单");
}
@@ -1079,6 +1079,9 @@ public class SettlementImportBatchServiceImpl
|| "terminated".equals(contract.getContractStage())) {
throw new ServiceException(label + "未审核完成或已终止");
}
if (!contractManageService.isUsableForSettlement(contract)) {
throw new ServiceException(label + "为临时合同且已过期,不可生成结算单");
}
return contract;
}
@@ -128,6 +128,10 @@ public class SettlementRuleAutoGenerateServiceImpl implements ISettlementRuleAut
}
private int[] generateContract(ContractManage contract, LocalDate today) {
if (!contractManageService.isUsableForSettlement(contract)) {
log.warn("合同[{}]不可用于结算(未审批、已终止或临时合同已过期),跳过自动生成", contract.getContractNo());
return new int[]{0, 0};
}
String settlementType = contractSettlementType(contract);
if (settlementType == null || contract.getProjectId() == null || contract.getOrganizationId() == null) {
log.warn("合同[{}]缺少结算类型、项目或所属组织,跳过自动生成", contract.getContractNo());
@@ -141,6 +141,7 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
entityPage.getRecords().forEach(this::syncDriverAcceptState);
IPage<WaybillVO> result = WaybillWrapper.build().pageVO(entityPage);
fillMileageMaintainable(result.getRecords());
fillCarrierContractName(result.getRecords());
return result;
}
@@ -148,6 +149,7 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
public WaybillVO detail(Long id) {
WaybillVO result = WaybillWrapper.build().entityVO(syncDriverAcceptState(loadEditable(id, false)));
fillCustomerNameFromContract(result);
fillCarrierContractName(List.of(result));
fillMileageMaintainable(List.of(result));
return result;
}
@@ -1206,6 +1208,29 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
}
}
private void fillCarrierContractName(List<WaybillVO> waybills) {
if (Func.isEmpty(waybills)) {
return;
}
List<Long> contractIds = waybills.stream()
.map(Waybill::getCarrierContractId)
.filter(Objects::nonNull)
.distinct()
.toList();
if (Func.isEmpty(contractIds)) {
return;
}
Map<Long, String> nameMap = contractManageService.listByIds(contractIds).stream()
.filter(contract -> contract != null && Objects.equals(contract.getIsDeleted(), 0))
.collect(Collectors.toMap(ContractManage::getId, ContractManage::getContractName, (left, right) -> left));
waybills.forEach(waybill -> {
if (waybill.getCarrierContractId() == null) {
return;
}
waybill.setCarrierContractName(nameMap.get(waybill.getCarrierContractId()));
});
}
private boolean isCustomerContract(ContractManage contract) {
return contract != null && "客户合同".equals(contract.getContractCategory());
}
@@ -0,0 +1,17 @@
-- 开票申请明细:不含税单价按合计金额反算后保留 6 位小数
SET @schema_name = DATABASE();
SET @has_unit_price_column = (
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'blade_invoice_application_line'
AND column_name = 'unit_price_no_tax'
);
SET @alter_unit_price_sql = IF(
@has_unit_price_column > 0,
'ALTER TABLE `blade_invoice_application_line` MODIFY COLUMN `unit_price_no_tax` decimal(18,6) DEFAULT NULL COMMENT ''不含税单价''',
'SELECT 1'
);
PREPARE stmt FROM @alter_unit_price_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;