diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClient.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClient.java index c1ebf5b5..b55af34f 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClient.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClient.java @@ -309,12 +309,13 @@ public interface ISysClient { R getRegion(@RequestParam("code") String code); /** - * 获取启用的费用项 + * 获取费用项。默认仅返回启用档案;includeDisabled 为 true 时同时返回停用档案。 * + * @param includeDisabled 是否包含停用费用项 * @return 费用项集合 */ @GetMapping(FEE_ITEMS) - R> getFeeItems(); + R> getFeeItems(@RequestParam(value = "includeDisabled", required = false) Boolean includeDisabled); @GetMapping(CARGO_TYPES) R> getCargoTypes(); diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClientFallback.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClientFallback.java index b7704b68..bfb7d123 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClientFallback.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClientFallback.java @@ -165,7 +165,7 @@ public class ISysClientFallback implements ISysClient { } @Override - public R> getFeeItems() { + public R> getFeeItems(Boolean includeDisabled) { return R.fail("获取数据失败"); } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSaveRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSaveRequest.java index bb453e14..df7740a0 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSaveRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSaveRequest.java @@ -35,6 +35,7 @@ public class PaymentApplicationSaveRequest implements Serializable { private Long settlementId; private List settlementIds; private Long preSettlementId; + private List preSettlementIds; private Long projectId; private String projectName; private Long deptId; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplicationSettlement.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplicationSettlement.java index 0f4dd8ad..08f967b8 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplicationSettlement.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplicationSettlement.java @@ -1,5 +1,6 @@ package org.springblade.transport.pojo.entity; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import lombok.EqualsAndHashCode; @@ -17,7 +18,12 @@ public class PaymentApplicationSettlement extends TenantEntity { private Long paymentApplicationId; private Long formalSettlementId; private String formalSettlementNo; + private Long preSettlementId; + private String preSettlementNo; private BigDecimal settlementAmount; private BigDecimal appliedAmount; private BigDecimal paidAmount; + /** 查看时回填的可付款金额,不落库 */ + @TableField(exist = false) + private BigDecimal payableAmount; } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/feign/SysClient.java b/blade-service/blade-system/src/main/java/org/springblade/system/feign/SysClient.java index edacf506..a4cf9718 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/feign/SysClient.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/feign/SysClient.java @@ -32,6 +32,7 @@ import org.springblade.core.tool.api.R; import org.springblade.system.pojo.entity.*; import org.springblade.system.service.*; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import io.swagger.v3.oas.annotations.Hidden; @@ -218,9 +219,9 @@ public class SysClient implements ISysClient { @Override @GetMapping(FEE_ITEMS) - public R> getFeeItems() { + public R> getFeeItems(@RequestParam(value = "includeDisabled", required = false) Boolean includeDisabled) { return R.data(feeItemService.list(Wrappers.lambdaQuery() - .eq(FeeItem::getStatus, 1) + .eq(!Boolean.TRUE.equals(includeDisabled), FeeItem::getStatus, 1) .eq(FeeItem::getIsDeleted, 0) .orderByAsc(FeeItem::getFeeCategory, FeeItem::getName))); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementRuleAutoGenerateService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementRuleAutoGenerateService.java new file mode 100644 index 00000000..4542b599 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementRuleAutoGenerateService.java @@ -0,0 +1,42 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service; + +/** + * 按合同结算规则自动生成预结算单、正式结算单 + * + * @author Chill + */ +public interface ISettlementRuleAutoGenerateService { + + /** + * 扫描已开启自动生成的合同,为截至昨日已关闭的账期生成结算草稿。 + * + * @return 本次生成结果摘要 + */ + String generateDueSettlements(); + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java index 38057c1a..f9f55a2b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java @@ -116,10 +116,12 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -257,10 +259,20 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery() .eq(org.springblade.transport.pojo.entity.PaymentApplicationRecord::getPaymentApplicationId, id) .orderByDesc(org.springblade.transport.pojo.entity.PaymentApplicationRecord::getCreateTime))); - vo.setSettlements(settlementRelationMapper.selectList(Wrappers.lambdaQuery() + List settlements = settlementRelationMapper.selectList(Wrappers.lambdaQuery() .eq(PaymentApplicationSettlement::getPaymentApplicationId, id) .eq(PaymentApplicationSettlement::getIsDeleted, 0) - .orderByAsc(PaymentApplicationSettlement::getCreateTime))); + .orderByAsc(PaymentApplicationSettlement::getCreateTime)); + for (PaymentApplicationSettlement relation : settlements) { + if (relation.getPreSettlementId() != null) { + relation.setPayableAmount(payableOfPreSettlement(relation.getPreSettlementId(), relation.getSettlementAmount(), id) + .setScale(2, RoundingMode.HALF_UP)); + } else if (relation.getFormalSettlementId() != null) { + relation.setPayableAmount(payableOfFormalSettlement(relation.getFormalSettlementId(), relation.getSettlementAmount(), id) + .setScale(2, RoundingMode.HALF_UP)); + } + } + vo.setSettlements(settlements); return vo; } @@ -359,9 +371,15 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl preSettlementIds = resolvePreSettlementIds(request); + if (request.getPreSettlementId() == null && !preSettlementIds.isEmpty()) { + request.setPreSettlementId(preSettlementIds.get(0)); + } fillReference(entity, request); List formalSettlements = resolveFormalSettlements(request, entity); if (formalSettlements.size() > 1) fillFormalAggregateReference(entity, formalSettlements); + List preSettlements = resolvePreSettlements(preSettlementIds); + if (preSettlements.size() > 1) fillPreAggregateReference(entity, preSettlements); fillBillLedger(entity, request); validateQuota(entity); saveOrUpdate(entity); @@ -396,6 +414,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl relations = settlementRelationMapper.selectList( + Wrappers.lambdaQuery() + .eq(PaymentApplicationSettlement::getPaymentApplicationId, entity.getId()) + .eq(PaymentApplicationSettlement::getIsDeleted, 0) + .isNotNull(PaymentApplicationSettlement::getPreSettlementId)); + if (relations.isEmpty()) { + if (entity.getPreSettlementId() == null) return; + upsertPreSettlementAdvance(entity.getPreSettlementId(), entity.getPaymentNo(), money(entity.getAppliedAmount()), entity); + refreshPreSettlementPaidLinks(entity.getPreSettlementId()); + return; + } + for (PaymentApplicationSettlement relation : relations) { + String advanceNo = relations.size() == 1 ? entity.getPaymentNo() + : trimText(entity.getPaymentNo() + "-" + relation.getPreSettlementNo(), 100); + upsertPreSettlementAdvance(relation.getPreSettlementId(), advanceNo, money(relation.getAppliedAmount()), entity); + refreshPreSettlementPaidLinks(relation.getPreSettlementId()); + } + } + + private void upsertPreSettlementAdvance(Long preSettlementId, String advanceNo, BigDecimal appliedAmount, + PaymentApplication entity) { PreSettlementAdvance advance = preSettlementAdvanceMapper.selectList( Wrappers.lambdaQuery() - .eq(PreSettlementAdvance::getPreSettlementId, entity.getPreSettlementId()) + .eq(PreSettlementAdvance::getPreSettlementId, preSettlementId) .eq(PreSettlementAdvance::getIsDeleted, 0) .ne(PreSettlementAdvance::getBillStatus, TransportBusiConstant.VOIDED) - .eq(PreSettlementAdvance::getAdvanceNo, entity.getPaymentNo()) + .eq(PreSettlementAdvance::getAdvanceNo, advanceNo) .last("limit 1")).stream().findFirst().orElse(null); - if (advance == null) { + if (advance == null && Objects.equals(advanceNo, entity.getPaymentNo())) { advance = preSettlementAdvanceMapper.selectList(Wrappers.lambdaQuery() - .eq(PreSettlementAdvance::getPreSettlementId, entity.getPreSettlementId()) + .eq(PreSettlementAdvance::getPreSettlementId, preSettlementId) .eq(PreSettlementAdvance::getIsDeleted, 0) .ne(PreSettlementAdvance::getBillStatus, TransportBusiConstant.VOIDED) .eq(PreSettlementAdvance::getPaidAmount, BigDecimal.ZERO) @@ -1576,24 +1615,34 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery() - .eq(FormalSettlementSource::getPreSettlementId, entity.getPreSettlementId()) + .eq(FormalSettlementSource::getPreSettlementId, preSettlementId) .eq(FormalSettlementSource::getIsDeleted, 0)).forEach(source -> { - source.setAdvanceAppliedAmount(findPreSettlementApplied(entity.getPreSettlementId())); - source.setAdvancePaidAmount(findPreSettlementPaid(entity.getPreSettlementId())); + source.setAdvanceAppliedAmount(findPreSettlementApplied(preSettlementId)); + source.setAdvancePaidAmount(findPreSettlementPaid(preSettlementId)); formalSettlementSourceMapper.updateById(source); }); - formalSettlementService.refreshPaymentSummariesForPreSettlement(entity.getPreSettlementId()); + formalSettlementService.refreshPaymentSummariesForPreSettlement(preSettlementId); + } + + private String trimText(String value, int length) { + if (value == null || value.length() <= length) { + return value; + } + return value.substring(0, length); } private BigDecimal findPreSettlementPaid(Long preSettlementId) { @@ -1683,11 +1732,14 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl 0)) throw new ServiceException("付款比例必须在0-100之间"); 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("非项目预付必须关联结算单"); + && request.getPreSettlementId() == null && Func.isEmpty(request.getSettlementIds()) + && Func.isEmpty(request.getPreSettlementIds())) 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 ("progress_advance".equals(request.getPaymentType()) && request.getPreSettlementId() == null + && Func.isEmpty(request.getPreSettlementIds())) throw new ServiceException("进度预付必须关联预结算单"); if (TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(request.getPaymentType()) && request.getSettlementId() == null - && Func.isEmpty(request.getSettlementIds()) && request.getPreSettlementId() == null) { + && Func.isEmpty(request.getSettlementIds()) && request.getPreSettlementId() == null + && Func.isEmpty(request.getPreSettlementIds())) { throw new ServiceException("尾款付款必须关联正式结算单或预结算单"); } } @@ -1764,7 +1816,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl 0 && money(entity.getPayableAmount()).compareTo(BigDecimal.ZERO) > 0) throw new ServiceException("申请付款金额不能超过可付款金额"); } @@ -1830,6 +1882,98 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl resolvePreSettlementIds(PaymentApplicationSaveRequest request) { + if (!List.of("progress_advance", TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT).contains(request.getPaymentType())) { + return List.of(); + } + boolean formalSelected = TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(request.getPaymentType()) + && ((request.getSettlementIds() != null && request.getSettlementIds().stream().anyMatch(Objects::nonNull)) + || request.getSettlementId() != null) + && Func.isEmpty(request.getPreSettlementIds()); + if (formalSelected) { + return List.of(); + } + List ids = request.getPreSettlementIds() == null ? List.of() : request.getPreSettlementIds(); + ids = ids.stream().filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty() && request.getPreSettlementId() != null) { + return List.of(request.getPreSettlementId()); + } + return ids; + } + + private boolean hasMultiplePreReferences(PaymentApplicationSaveRequest request) { + return request.getPreSettlementIds() != null + && request.getPreSettlementIds().stream().filter(Objects::nonNull).distinct().count() > 1; + } + + private List resolvePreSettlements(List ids) { + if (ids.size() <= 1) { + return List.of(); + } + List settlements = ids.stream().map(preSettlementMapper::selectById).toList(); + if (settlements.stream().anyMatch(item -> item == null || Objects.equals(item.getIsDeleted(), 1) + || !TransportBusiConstant.APPROVED.equals(item.getApprovalStatus()))) { + throw new ServiceException("只能选择审批通过、未作废的预结算单"); + } + Long contractId = settlements.get(0).getContractId(); + if (contractId == null || settlements.stream().anyMatch(item -> !Objects.equals(contractId, item.getContractId()))) { + throw new ServiceException("多选预结算单必须属于同一合同"); + } + return settlements; + } + + private void fillPreAggregateReference(PaymentApplication entity, List settlements) { + BigDecimal settlementAmount = settlements.stream().map(PreSettlement::getSettlementAmount).map(this::money) + .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(2, RoundingMode.HALF_UP); + BigDecimal payableAmount = settlements.stream() + .map(item -> payableOfPreSettlement(item.getId(), item.getSettlementAmount(), entity.getId())) + .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(2, RoundingMode.HALF_UP); + if (money(entity.getAppliedAmount()).compareTo(payableAmount) > 0) { + throw new ServiceException("申请付款金额不能超过所选预结算单可付款金额合计"); + } + PreSettlement first = settlements.get(0); + String preSettlementNo = settlements.stream().map(PreSettlement::getPreSettlementNo).filter(Objects::nonNull) + .collect(Collectors.joining("、")); + entity.setPreSettlementId(first.getId()); + entity.setPreSettlementNo(preSettlementNo.length() > 500 ? preSettlementNo.substring(0, 500) : preSettlementNo); + entity.setSettlementId(null); + entity.setSettlementNo(null); + entity.setSettlementAmount(settlementAmount); + entity.setPayableAmount(payableAmount); + entity.setBillType("预结算单"); + } + + private void savePreSettlementRelations(PaymentApplication entity, List settlements) { + if (settlements.size() > 1) { + settlementRelationMapper.delete(Wrappers.lambdaQuery() + .eq(PaymentApplicationSettlement::getPaymentApplicationId, entity.getId())); + } else { + settlementRelationMapper.delete(Wrappers.lambdaQuery() + .eq(PaymentApplicationSettlement::getPaymentApplicationId, entity.getId()) + .isNotNull(PaymentApplicationSettlement::getPreSettlementId)); + return; + } + BigDecimal totalBase = settlements.stream() + .map(item -> payableOfPreSettlement(item.getId(), item.getSettlementAmount(), entity.getId())) + .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal remainingApplied = money(entity.getAppliedAmount()); + for (int index = 0; index < settlements.size(); index++) { + PreSettlement settlement = settlements.get(index); + BigDecimal base = payableOfPreSettlement(settlement.getId(), settlement.getSettlementAmount(), entity.getId()); + BigDecimal applied = index == settlements.size() - 1 || totalBase.compareTo(BigDecimal.ZERO) == 0 + ? remainingApplied : money(entity.getAppliedAmount()).multiply(base).divide(totalBase, 2, RoundingMode.DOWN); + PaymentApplicationSettlement relation = new PaymentApplicationSettlement(); + relation.setPaymentApplicationId(entity.getId()); + relation.setPreSettlementId(settlement.getId()); + relation.setPreSettlementNo(settlement.getPreSettlementNo()); + relation.setSettlementAmount(money(settlement.getSettlementAmount()).setScale(2, RoundingMode.HALF_UP)); + relation.setAppliedAmount(applied.setScale(2, RoundingMode.HALF_UP)); + relation.setPaidAmount(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)); + settlementRelationMapper.insert(relation); + remainingApplied = remainingApplied.subtract(applied); + } + } + private void validateQuota(PaymentApplication entity) { if (!"project_advance".equals(entity.getPaymentType())) return; BigDecimal appliedAmount = money(entity.getAppliedAmount()); @@ -1888,10 +2032,36 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery() + // 进度预付、尾款付款关联同一预结算单时累计占用可付款额度;多选时按关联行的申请金额累计,避免把整单金额算到第一张预结算上 + List relations = settlementRelationMapper.selectList( + Wrappers.lambdaQuery() + .eq(PaymentApplicationSettlement::getPreSettlementId, preSettlementId) + .eq(PaymentApplicationSettlement::getIsDeleted, 0)); + Set coveredPaymentIds = new HashSet<>(); + BigDecimal relationAmount = BigDecimal.ZERO; + if (!relations.isEmpty()) { + Map payments = listByIds(relations.stream() + .map(PaymentApplicationSettlement::getPaymentApplicationId).filter(Objects::nonNull).distinct().toList()) + .stream().collect(Collectors.toMap(PaymentApplication::getId, item -> item, (left, right) -> left)); + for (PaymentApplicationSettlement relation : relations) { + PaymentApplication payment = payments.get(relation.getPaymentApplicationId()); + if (payment == null || Objects.equals(payment.getIsDeleted(), 1)) { + continue; + } + coveredPaymentIds.add(payment.getId()); + if (Objects.equals(payment.getId(), excludeId) || TransportBusiConstant.VOIDED.equals(payment.getApprovalStatus())) { + continue; + } + relationAmount = relationAmount.add(money(relation.getAppliedAmount())); + } + } + BigDecimal legacyAmount = list(Wrappers.lambdaQuery() .eq(PaymentApplication::getPreSettlementId, preSettlementId) - .ne(excludeId != null, PaymentApplication::getId, excludeId)); + .ne(excludeId != null, PaymentApplication::getId, excludeId) + .notIn(!coveredPaymentIds.isEmpty(), PaymentApplication::getId, coveredPaymentIds) + .ne(PaymentApplication::getApprovalStatus, TransportBusiConstant.VOIDED)).stream() + .map(PaymentApplication::getAppliedAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); + return relationAmount.add(legacyAmount); } /** 正式结算可付款 = 结算金额 − 已申请付款 − 项目预付核销 */ diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java index de0b160e..cdf17b02 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java @@ -166,7 +166,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl categoryNames = DictBizCache.getList("fee_category").stream() .collect(Collectors.toMap(DictBiz::getDictKey, DictBiz::getDictValue, (first, second) -> first, LinkedHashMap::new)); - org.springblade.core.tool.api.R> response = sysClient.getFeeItems(); + org.springblade.core.tool.api.R> response = sysClient.getFeeItems(false); if (response == null || !response.isSuccess() || response.getData() == null) { throw new ServiceException("费用项基础档案读取失败,请稍后重试"); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementImportBatchServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementImportBatchServiceImpl.java index 34ae5a9b..634814ab 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementImportBatchServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementImportBatchServiceImpl.java @@ -703,7 +703,9 @@ public class SettlementImportBatchServiceImpl private Map validateSettlementExtras(SettlementImportBatchRequest request) { Map errorMap = new LinkedHashMap<>(); - Set feeItemCatalog = loadFeeItemNames(); + Set enabledFeeItemNames = new HashSet<>(); + Set disabledFeeItemNames = new HashSet<>(); + loadFeeItemNames(enabledFeeItemNames, disabledFeeItemNames); List feeItemNames = resolveFeeItemNames(request); List> rows = request.getRows(); for (int index = 0; index < rows.size(); index++) { @@ -716,15 +718,21 @@ public class SettlementImportBatchServiceImpl errors.add("*运输费必须大于等于0"); } Map feeItems = extractFeeItems(row, feeItemNames); - for (String name : feeItems.keySet()) { - if (!feeItemCatalog.contains(name)) { - errors.add("费用项「" + name + "」未在费用项主数据中匹配"); + for (Map.Entry entry : feeItems.entrySet()) { + String name = normalizeFeeName(entry.getKey()); + BigDecimal amount = entry.getValue() == null ? BigDecimal.ZERO : entry.getValue(); + // 模板自带的装卸费、过路费等列未填写时金额为 0,不要求费用项主数据存在同名档案 + if (Func.isEmpty(name) || amount.compareTo(BigDecimal.ZERO) == 0) { + continue; } - } - for (String name : feeItemNames) { - if (Func.isNotEmpty(name) && !feeItemCatalog.contains(name)) { - errors.add("费用项「" + name + "」未在费用项主数据中匹配"); + if (enabledFeeItemNames.contains(name)) { + continue; } + if (disabledFeeItemNames.contains(name)) { + errors.add("费用项「" + name + "」已停用"); + continue; + } + errors.add("费用项「" + name + "」未在费用项主数据中匹配"); } if (!errors.isEmpty()) { errorMap.put(index, String.join("; ", errors)); @@ -733,15 +741,27 @@ public class SettlementImportBatchServiceImpl return errorMap; } - private Set loadFeeItemNames() { - R> response = sysClient.getFeeItems(); + private void loadFeeItemNames(Set enabledFeeItemNames, Set disabledFeeItemNames) { + R> response = sysClient.getFeeItems(true); if (response == null || !response.isSuccess() || response.getData() == null) { throw new ServiceException("费用项基础档案读取失败,请稍后重试"); } - return response.getData().stream() - .map(FeeItem::getName) - .filter(Func::isNotEmpty) - .collect(Collectors.toCollection(HashSet::new)); + for (FeeItem feeItem : response.getData()) { + String name = normalizeFeeName(feeItem.getName()); + if (Func.isEmpty(name)) { + continue; + } + if (feeItem.getStatus() == null || Objects.equals(feeItem.getStatus(), 1)) { + enabledFeeItemNames.add(name); + } else { + disabledFeeItemNames.add(name); + } + } + disabledFeeItemNames.removeAll(enabledFeeItemNames); + } + + private String normalizeFeeName(String name) { + return name == null ? "" : name.trim(); } private List resolveFeeItemNames(SettlementImportBatchRequest request) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementRuleAutoGenerateServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementRuleAutoGenerateServiceImpl.java new file mode 100644 index 00000000..67818e9e --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementRuleAutoGenerateServiceImpl.java @@ -0,0 +1,622 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.tenant.TenantUtil; +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.constant.TransportBusiConstant; +import org.springblade.transport.mapper.PreSettlementDetailMapper; +import org.springblade.transport.mapper.ReceivablePayableDetailMapper; +import org.springblade.transport.pojo.dto.FormalSettlementSaveRequest; +import org.springblade.transport.pojo.dto.PreSettlementSaveRequest; +import org.springblade.transport.pojo.entity.ContractManage; +import org.springblade.transport.pojo.entity.PreSettlement; +import org.springblade.transport.pojo.entity.PreSettlementDetail; +import org.springblade.transport.pojo.entity.ReceivablePayableDetail; +import org.springblade.transport.service.IContractManageService; +import org.springblade.transport.service.IFormalSettlementService; +import org.springblade.transport.service.IPreSettlementService; +import org.springblade.transport.service.ISettlementRuleAutoGenerateService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.YearMonth; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * 按合同结算规则自动生成结算草稿。 + *

+ * 账期在次日零点视为关闭:日结/现结取前一日,周结和固定天数从账单开始日期顺延, + * 月结按自然月、固定截单日或自定义多周期截取。没有可归集明细时不生成空单。 + * 同一明细只进入一张结算单,预结算先于正式结算执行。正式结算同时归集费用日期完整落在账期内、且尚未转正式结算的已审批预结算单。 + * + * @author Chill + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SettlementRuleAutoGenerateServiceImpl implements ISettlementRuleAutoGenerateService { + + private static final int QUERY_BATCH_SIZE = 500; + + private static final String LOCAL_CURRENCY = "RMB"; + + private final IContractManageService contractManageService; + + private final IPreSettlementService preSettlementService; + + private final IFormalSettlementService formalSettlementService; + + private final ReceivablePayableDetailMapper receivablePayableDetailMapper; + + private final PreSettlementDetailMapper preSettlementDetailMapper; + + private final PlatformTransactionManager transactionManager; + + @Override + public synchronized String generateDueSettlements() { + LocalDate today = LocalDate.now(); + List contracts = TenantUtil.ignore(this::listAutoContracts); + int preCount = 0; + int formalCount = 0; + int failedCount = 0; + for (ContractManage contract : contracts) { + if (Func.isBlank(contract.getTenantId())) { + log.warn("合同[{}]缺少租户,跳过自动生成结算单", contract.getContractNo()); + continue; + } + try { + int[] created = TenantUtil.use(contract.getTenantId(), () -> generateContract(contract, today)); + preCount += created[0]; + formalCount += created[1]; + } catch (Exception exception) { + failedCount++; + log.error("合同[{}]自动生成结算单失败", contract.getContractNo(), exception); + } + } + return "扫描合同" + contracts.size() + "份,生成预结算单" + preCount + "张,正式结算单" + formalCount + + "张,失败" + failedCount + "份"; + } + + private List listAutoContracts() { + return contractManageService.list(Wrappers.lambdaQuery() + .in(ContractManage::getApprovalStatus, "approved", "change_approved") + .in(ContractManage::getContractCategory, "客户合同", "承运商合同") + .and(query -> query.isNull(ContractManage::getContractStage) + .or().ne(ContractManage::getContractStage, "terminated")) + .and(query -> query.like(ContractManage::getPreSettlementConfigJson, "autoGenerate") + .or().like(ContractManage::getFormalSettlementConfigJson, "autoGenerate") + .or().like(ContractManage::getSettlementRuleJson, "autoGenerate"))); + } + + private int[] generateContract(ContractManage contract, LocalDate today) { + String settlementType = contractSettlementType(contract); + if (settlementType == null || contract.getProjectId() == null || contract.getOrganizationId() == null) { + log.warn("合同[{}]缺少结算类型、项目或所属组织,跳过自动生成", contract.getContractNo()); + return new int[]{0, 0}; + } + int preCount = generateBills(contract, settlementType, resolveRule(contract, true), today, true); + int formalCount = generateBills(contract, settlementType, resolveRule(contract, false), today, false); + return new int[]{preCount, formalCount}; + } + + private int generateBills(ContractManage contract, String settlementType, Map rule, + LocalDate today, boolean preBill) { + if (!enabled(rule.get("autoGenerate"))) { + return 0; + } + String cycleName = Func.toStr(rule.get("settlementType")).trim(); + if (!knownCycle(cycleName)) { + log.warn("合同[{}]{}结算周期[{}]无法识别,跳过自动生成", contract.getContractNo(), + preBill ? "预" : "正式", cycleName); + return 0; + } + List ranges = closedPeriods(rule, today); + if (ranges.isEmpty()) { + return 0; + } + if (preBill) { + return createPreBills(contract, ranges); + } + return createFormalBills(contract, settlementType, ranges); + } + + private int createPreBills(ContractManage contract, List ranges) { + List details = loadPendingDetails(contract, ranges); + Map> grouped = groupDetails(ranges, details); + int created = 0; + for (Map.Entry> entry : grouped.entrySet()) { + Map> byCurrency = new LinkedHashMap<>(); + for (ReceivablePayableDetail detail : entry.getValue()) { + String currency = Func.isEmpty(detail.getCurrency()) ? LOCAL_CURRENCY : detail.getCurrency(); + byCurrency.computeIfAbsent(currency, key -> new ArrayList<>()).add(detail.getId()); + } + for (List detailIds : byCurrency.values()) { + try { + inTransaction(() -> { + PreSettlementSaveRequest request = new PreSettlementSaveRequest(); + request.setContractId(contract.getId()); + request.setSourceDetailIds(detailIds); + request.setRemark(periodRemark(entry.getKey())); + preSettlementService.saveDraft(request); + }); + created++; + log.info("合同[{}]自动生成预结算单,账期{}至{},明细{}条", contract.getContractNo(), + entry.getKey().start(), entry.getKey().end(), detailIds.size()); + } catch (Exception exception) { + log.error("合同[{}]自动生成预结算单失败,账期{}至{}", contract.getContractNo(), + entry.getKey().start(), entry.getKey().end(), exception); + } + } + } + return created; + } + + private int createFormalBills(ContractManage contract, String settlementType, List ranges) { + Map> detailGroups = groupDetails(ranges, loadPendingDetails(contract, ranges)); + Map> preGroups = groupApprovedPres(contract, settlementType, ranges); + int created = 0; + for (DateRange range : ranges) { + Map> detailIds = new LinkedHashMap<>(); + for (ReceivablePayableDetail detail : detailGroups.getOrDefault(range, List.of())) { + String currency = detail.getCurrency() == null ? "" : detail.getCurrency(); + detailIds.computeIfAbsent(currency, key -> new ArrayList<>()).add(detail.getId()); + } + Map> preIds = new LinkedHashMap<>(); + for (PreSettlement preSettlement : preGroups.getOrDefault(range, List.of())) { + String currency = preSettlement.getCurrency() == null ? "" : preSettlement.getCurrency(); + preIds.computeIfAbsent(currency, key -> new ArrayList<>()).add(preSettlement.getId()); + } + Set currencies = new LinkedHashSet<>(); + currencies.addAll(detailIds.keySet()); + currencies.addAll(preIds.keySet()); + for (String currency : currencies) { + List sourcePreSettlementIds = preIds.getOrDefault(currency, List.of()); + List sourceDetailIds = detailIds.getOrDefault(currency, List.of()); + if (sourcePreSettlementIds.isEmpty() && sourceDetailIds.isEmpty()) { + continue; + } + try { + inTransaction(() -> { + FormalSettlementSaveRequest request = new FormalSettlementSaveRequest(); + request.setContractId(contract.getId()); + request.setSettlementType(settlementType); + request.setSourcePreSettlementIds(sourcePreSettlementIds.isEmpty() ? null : sourcePreSettlementIds); + request.setSourceDetailIds(sourceDetailIds.isEmpty() ? null : sourceDetailIds); + request.setRemark(periodRemark(range)); + formalSettlementService.saveDraft(request); + }); + created++; + log.info("合同[{}]自动生成正式结算单,账期{}至{},预结算{}张,明细{}条", contract.getContractNo(), + range.start(), range.end(), sourcePreSettlementIds.size(), sourceDetailIds.size()); + } catch (Exception exception) { + log.error("合同[{}]自动生成正式结算单失败,账期{}至{}", contract.getContractNo(), + range.start(), range.end(), exception); + } + } + } + return created; + } + + private List loadPendingDetails(ContractManage contract, List ranges) { + LocalDate minDate = ranges.get(0).start(); + LocalDate maxDate = ranges.get(0).end(); + for (DateRange range : ranges) { + if (range.start().isBefore(minDate)) { + minDate = range.start(); + } + if (range.end().isAfter(maxDate)) { + maxDate = range.end(); + } + } + LocalDate periodStart = minDate; + LocalDate periodEnd = maxDate; + return receivablePayableDetailMapper.selectList(Wrappers.lambdaQuery() + .eq(ReceivablePayableDetail::getIsDeleted, 0) + .eq(ReceivablePayableDetail::getContractId, contract.getId()) + .eq(ReceivablePayableDetail::getProjectId, contract.getProjectId()) + .eq(ReceivablePayableDetail::getDeptId, contract.getOrganizationId()) + .eq(ReceivablePayableDetail::getSettlementType, contractSettlementType(contract)) + .eq(ReceivablePayableDetail::getSettlementStatus, "pending") + .and(query -> query.isNull(ReceivablePayableDetail::getPreSettlementNo) + .or().eq(ReceivablePayableDetail::getPreSettlementNo, "")) + .and(query -> query.isNull(ReceivablePayableDetail::getFormalSettlementNo) + .or().eq(ReceivablePayableDetail::getFormalSettlementNo, "")) + .and(query -> query.eq(ReceivablePayableDetail::getCustomerName, contract.getPartyA()) + .or().eq(ReceivablePayableDetail::getCustomerName, contract.getPartyB())) + .and(query -> query.between(ReceivablePayableDetail::getFeeDate, periodStart, periodEnd) + .or(nested -> nested.isNull(ReceivablePayableDetail::getFeeDate) + .ge(ReceivablePayableDetail::getCreateTime, periodStart.atStartOfDay()) + .lt(ReceivablePayableDetail::getCreateTime, periodEnd.plusDays(1).atStartOfDay())))); + } + + private Map> groupApprovedPres(ContractManage contract, String settlementType, + List ranges) { + Map> grouped = new LinkedHashMap<>(); + ranges.forEach(range -> grouped.put(range, new ArrayList<>())); + List preSettlements = preSettlementService.list(Wrappers.lambdaQuery() + .eq(PreSettlement::getContractId, contract.getId()) + .eq(PreSettlement::getSettlementType, settlementType) + .eq(PreSettlement::getApprovalStatus, TransportBusiConstant.APPROVED) + .and(query -> query.isNull(PreSettlement::getFormalSettlementNo) + .or().eq(PreSettlement::getFormalSettlementNo, ""))); + if (preSettlements.isEmpty()) { + return grouped; + } + List preSettlementIds = preSettlements.stream().map(PreSettlement::getId).toList(); + Map> sourceIdsByPre = new LinkedHashMap<>(); + for (List batch : partition(preSettlementIds)) { + List lines = preSettlementDetailMapper.selectList(Wrappers.lambdaQuery() + .in(PreSettlementDetail::getPreSettlementId, batch) + .eq(PreSettlementDetail::getIsDeleted, 0)); + for (PreSettlementDetail line : lines) { + if (line.getSourceDetailId() == null) { + continue; + } + sourceIdsByPre.computeIfAbsent(line.getPreSettlementId(), key -> new ArrayList<>()).add(line.getSourceDetailId()); + } + } + Set sourceIds = new LinkedHashSet<>(); + sourceIdsByPre.values().forEach(sourceIds::addAll); + Map sourceMap = new LinkedHashMap<>(); + for (List batch : partition(sourceIds)) { + receivablePayableDetailMapper.selectBatchIds(batch) + .forEach(detail -> sourceMap.put(detail.getId(), detail)); + } + for (PreSettlement preSettlement : preSettlements) { + List sourceDetailIds = sourceIdsByPre.getOrDefault(preSettlement.getId(), List.of()); + LocalDate minDate = null; + LocalDate maxDate = null; + boolean invalid = sourceDetailIds.isEmpty(); + for (Long sourceDetailId : sourceDetailIds) { + ReceivablePayableDetail source = sourceMap.get(sourceDetailId); + LocalDate businessDate = source == null ? null : businessDate(source); + if (businessDate == null) { + invalid = true; + break; + } + minDate = minDate == null || businessDate.isBefore(minDate) ? businessDate : minDate; + maxDate = maxDate == null || businessDate.isAfter(maxDate) ? businessDate : maxDate; + } + if (invalid || minDate == null || maxDate == null) { + log.warn("预结算单[{}]缺少可归集的费用日期,跳过自动转正式结算", preSettlement.getPreSettlementNo()); + continue; + } + boolean matched = false; + for (DateRange range : ranges) { + if (!minDate.isBefore(range.start()) && !maxDate.isAfter(range.end())) { + grouped.get(range).add(preSettlement); + matched = true; + break; + } + } + if (!matched && !maxDate.isAfter(LocalDate.now().minusDays(1))) { + log.info("预结算单[{}]费用日期{}至{}未完整落入已关闭账期,暂不自动归集正式结算", + preSettlement.getPreSettlementNo(), minDate, maxDate); + } + } + return grouped; + } + + private Map> groupDetails(List ranges, + List details) { + Map> grouped = new LinkedHashMap<>(); + ranges.forEach(range -> grouped.put(range, new ArrayList<>())); + for (ReceivablePayableDetail detail : details) { + LocalDate businessDate = businessDate(detail); + if (businessDate == null) { + continue; + } + for (DateRange range : ranges) { + if (!businessDate.isBefore(range.start()) && !businessDate.isAfter(range.end())) { + grouped.get(range).add(detail); + break; + } + } + } + return grouped; + } + + private List closedPeriods(Map rule, LocalDate today) { + LocalDate billStart = dateValue(rule.get("billStartDate")); + LocalDate yesterday = today.minusDays(1); + if (billStart == null || yesterday.isBefore(billStart)) { + return List.of(); + } + String settlementType = Func.toStr(rule.get("settlementType")).trim(); + return switch (settlementType) { + case "现结", "日结" -> cyclePeriods(billStart, yesterday, 1); + case "周结" -> cyclePeriods(billStart, yesterday, 7); + case "按固定天数周期", "固定天数周期结算" -> { + Integer cycleDays = intValue(rule.get("cycleDays")); + if (cycleDays == null || cycleDays < 1 || cycleDays > 365) { + yield List.of(); + } + yield cyclePeriods(billStart, yesterday, cycleDays); + } + case "月结" -> monthlyPeriods(rule, billStart, yesterday); + default -> List.of(); + }; + } + + private List monthlyPeriods(Map rule, LocalDate billStart, LocalDate yesterday) { + String cycleType = Func.toStr(rule.get("billCycleType")).trim(); + if (Func.isBlank(cycleType) || "自然月".equals(cycleType)) { + return naturalMonthPeriods(billStart, yesterday); + } + if ("固定截单日".equals(cycleType)) { + Integer cutoffDay = intValue(rule.get("billCutoffDay")); + if (cutoffDay == null || cutoffDay < 1 || cutoffDay > 31) { + return List.of(); + } + return fixedCutoffPeriods(billStart, yesterday, cutoffDay); + } + if ("自定义多周期".equals(cycleType)) { + return customMonthPeriods(rule.get("customPeriods"), billStart, yesterday); + } + return List.of(); + } + + private List cyclePeriods(LocalDate billStart, LocalDate yesterday, int days) { + List ranges = new ArrayList<>(); + LocalDate cursor = billStart; + while (true) { + LocalDate end = cursor.plusDays(days - 1L); + if (end.isAfter(yesterday)) { + break; + } + ranges.add(new DateRange(cursor, end)); + cursor = end.plusDays(1); + } + return ranges; + } + + private List naturalMonthPeriods(LocalDate billStart, LocalDate yesterday) { + List ranges = new ArrayList<>(); + YearMonth cursor = YearMonth.from(billStart); + YearMonth lastMonth = YearMonth.from(yesterday); + while (!cursor.isAfter(lastMonth)) { + LocalDate end = cursor.atEndOfMonth(); + if (!end.isAfter(yesterday) && !end.isBefore(billStart)) { + LocalDate start = cursor.atDay(1).isBefore(billStart) ? billStart : cursor.atDay(1); + ranges.add(new DateRange(start, end)); + } + cursor = cursor.plusMonths(1); + } + return ranges; + } + + private List fixedCutoffPeriods(LocalDate billStart, LocalDate yesterday, int cutoffDay) { + List ranges = new ArrayList<>(); + YearMonth cursor = YearMonth.from(billStart).minusMonths(1); + YearMonth limit = YearMonth.from(yesterday).plusMonths(1); + LocalDate previousEnd = null; + while (!cursor.isAfter(limit)) { + LocalDate end = cursor.atDay(Math.min(cutoffDay, cursor.lengthOfMonth())); + if (!end.isBefore(billStart) && !end.isAfter(yesterday)) { + LocalDate start = previousEnd == null ? billStart : previousEnd.plusDays(1); + if (start.isBefore(billStart)) { + start = billStart; + } + if (!start.isAfter(end)) { + ranges.add(new DateRange(start, end)); + } + } + if (!end.isBefore(billStart.minusMonths(1))) { + previousEnd = end; + } + cursor = cursor.plusMonths(1); + } + return ranges; + } + + private List customMonthPeriods(Object periodsValue, LocalDate billStart, LocalDate yesterday) { + List periods = customPeriods(periodsValue); + if (periods.isEmpty()) { + return List.of(); + } + List ranges = new ArrayList<>(); + YearMonth cursor = YearMonth.from(billStart); + YearMonth lastMonth = YearMonth.from(yesterday); + while (!cursor.isAfter(lastMonth)) { + for (int[] period : periods) { + int startDay = Math.min(period[0], cursor.lengthOfMonth()); + int endDay = Math.min(period[1], cursor.lengthOfMonth()); + if (startDay > endDay) { + continue; + } + LocalDate end = cursor.atDay(endDay); + if (end.isAfter(yesterday) || end.isBefore(billStart)) { + continue; + } + LocalDate start = cursor.atDay(startDay); + if (start.isBefore(billStart)) { + start = billStart; + } + if (!start.isAfter(end)) { + ranges.add(new DateRange(start, end)); + } + } + cursor = cursor.plusMonths(1); + } + return ranges; + } + + private List customPeriods(Object value) { + if (!(value instanceof List periodList)) { + return List.of(); + } + List periods = new ArrayList<>(); + for (Object item : periodList) { + if (!(item instanceof Map period)) { + continue; + } + Integer startDay = intValue(period.get("startDay")); + Integer endDay = intValue(period.get("endDay")); + if (startDay == null || endDay == null || startDay < 1 || endDay > 31 || endDay < startDay) { + continue; + } + periods.add(new int[]{startDay, endDay}); + } + return periods; + } + + private Map resolveRule(ContractManage contract, boolean preRule) { + Map direct = parseObject(preRule ? contract.getPreSettlementConfigJson() : contract.getFormalSettlementConfigJson()); + if (!direct.isEmpty()) { + return direct; + } + Map payload = parseObject(contract.getSettlementRuleJson()); + Object nested = payload.get(preRule ? "preSettlementConfig" : "formalSettlementConfig"); + if (nested instanceof Map nestedMap) { + Map result = new LinkedHashMap<>(); + nestedMap.forEach((key, value) -> result.put(String.valueOf(key), value)); + return result; + } + if (!payload.containsKey("preSettlementConfig") && !payload.containsKey("formalSettlementConfig")) { + return payload; + } + return Map.of(); + } + + private Map parseObject(String json) { + if (Func.isBlank(json)) { + return Map.of(); + } + try { + Object parsed = JsonUtil.parse(json, Map.class); + if (!(parsed instanceof Map raw) || raw.isEmpty()) { + return Map.of(); + } + Map result = new LinkedHashMap<>(); + raw.forEach((key, value) -> result.put(String.valueOf(key), value)); + return result; + } catch (Exception exception) { + log.warn("结算规则JSON解析失败:{}", exception.getMessage()); + return Map.of(); + } + } + + private void inTransaction(Runnable runnable) { + new TransactionTemplate(transactionManager).executeWithoutResult(status -> runnable.run()); + } + + private List> partition(Collection ids) { + List source = ids instanceof List list ? list : new ArrayList<>(ids); + List> batches = new ArrayList<>(); + for (int index = 0; index < source.size(); index += QUERY_BATCH_SIZE) { + batches.add(source.subList(index, Math.min(index + QUERY_BATCH_SIZE, source.size()))); + } + return batches; + } + + private String contractSettlementType(ContractManage contract) { + if ("客户合同".equals(contract.getContractCategory())) { + return "receivable"; + } + if ("承运商合同".equals(contract.getContractCategory())) { + return "payable"; + } + return null; + } + + private LocalDate businessDate(ReceivablePayableDetail detail) { + if (detail.getFeeDate() != null) { + return detail.getFeeDate(); + } + if (detail.getCreateTime() == null) { + return null; + } + return detail.getCreateTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); + } + + private String periodRemark(DateRange range) { + return "系统按结算规则自动生成(" + range.start() + "至" + range.end() + ")"; + } + + private boolean enabled(Object value) { + if (value instanceof Boolean enabled) { + return enabled; + } + if (value instanceof Number number) { + return number.intValue() == 1; + } + String text = value == null ? "" : String.valueOf(value).trim(); + return "1".equals(text) || "true".equalsIgnoreCase(text); + } + + private boolean knownCycle(String settlementType) { + return List.of("现结", "日结", "周结", "月结", "按固定天数周期", "固定天数周期结算").contains(settlementType); + } + + private Integer intValue(Object value) { + if (value instanceof Number number) { + return number.intValue(); + } + if (value == null || Func.isBlank(String.valueOf(value))) { + return null; + } + try { + return Integer.parseInt(String.valueOf(value).trim()); + } catch (NumberFormatException exception) { + return null; + } + } + + private LocalDate dateValue(Object value) { + if (value == null || Func.isBlank(String.valueOf(value))) { + return null; + } + String text = String.valueOf(value).trim(); + if (text.length() >= 10) { + text = text.substring(0, 10); + } + try { + return LocalDate.parse(text); + } catch (DateTimeParseException exception) { + return null; + } + } + + private record DateRange(LocalDate start, LocalDate end) { + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java index f091b00f..07a38041 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java @@ -828,7 +828,7 @@ public class KingdeeApBillSyncSupport { } Map categoryNames = DictBizCache.getList(FEE_CATEGORY_DICT).stream() .collect(Collectors.toMap(DictBiz::getDictKey, DictBiz::getDictValue, (first, second) -> first)); - R> response = sysClient.getFeeItems(); + R> response = sysClient.getFeeItems(false); if (response == null || !response.isSuccess() || Func.isEmpty(response.getData())) { throw new ServiceException("推送金蝶失败:费用项基础档案读取失败,请稍后重试"); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/SettlementRuleAutoGenerateTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/SettlementRuleAutoGenerateTask.java new file mode 100644 index 00000000..80cc5d8b --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/SettlementRuleAutoGenerateTask.java @@ -0,0 +1,59 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.task; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.transport.service.ISettlementRuleAutoGenerateService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 合同结算规则定时生成结算单。 + *

+ * 每天零点执行:预结算或正式结算配置开启自动生成时,按账单开始日期和结算周期归集已关闭账期的运单明细。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SettlementRuleAutoGenerateTask { + + private final ISettlementRuleAutoGenerateService settlementRuleAutoGenerateService; + + /** 每天 00:00 生成到期的预结算单、正式结算单 */ + @Scheduled(cron = "${blade.settlement.auto-generate-cron:0 0 0 * * ?}") + public void generateDueSettlements() { + try { + String summary = settlementRuleAutoGenerateService.generateDueSettlements(); + log.info("定时自动生成结算单:{}", summary); + } catch (Exception exception) { + log.error("定时自动生成结算单异常", exception); + } + } + +} diff --git a/doc/sql/transport/blade_payment_application_settlement_pre_20261016.sql b/doc/sql/transport/blade_payment_application_settlement_pre_20261016.sql new file mode 100644 index 00000000..1cc6e9e9 --- /dev/null +++ b/doc/sql/transport/blade_payment_application_settlement_pre_20261016.sql @@ -0,0 +1,82 @@ +-- 付款申请可关联多张预结算单:关联表补充预结算字段,正式结算字段改为可空 + +SET @ddl_sql = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE `blade_payment_application_settlement` MODIFY COLUMN `formal_settlement_id` bigint(20) NULL COMMENT ''正式结算单ID''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_payment_application_settlement' + AND COLUMN_NAME = 'formal_settlement_id' + AND IS_NULLABLE = 'NO' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE `blade_payment_application_settlement` MODIFY COLUMN `formal_settlement_no` varchar(100) COLLATE utf8mb4_general_ci NULL COMMENT ''正式结算单号''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_payment_application_settlement' + AND COLUMN_NAME = 'formal_settlement_no' + AND IS_NULLABLE = 'NO' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_payment_application_settlement` ADD COLUMN `pre_settlement_id` bigint(20) DEFAULT NULL COMMENT ''预结算单ID'' AFTER `formal_settlement_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_payment_application_settlement' + AND COLUMN_NAME = 'pre_settlement_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_payment_application_settlement` ADD COLUMN `pre_settlement_no` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''预结算单号'' AFTER `pre_settlement_id`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_payment_application_settlement' + AND COLUMN_NAME = 'pre_settlement_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_payment_application_settlement` ADD KEY `idx_payment_settlement_pre` (`pre_settlement_id`)', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_payment_application_settlement' + AND INDEX_NAME = 'idx_payment_settlement_pre' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE `blade_payment_application` MODIFY COLUMN `pre_settlement_no` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''预结算单号''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_payment_application' + AND COLUMN_NAME = 'pre_settlement_no' + AND CHARACTER_MAXIMUM_LENGTH < 500 +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt;