!5 金蝶测试问题修复
Merge pull request !5 from mindless/feature/20261019-bfi-all
This commit is contained in:
+2
-3
@@ -84,10 +84,9 @@ public class InvoiceApplicationController extends BladeController {
|
||||
public R<IPage<Map<String, Object>>> settlementCandidates(Query query,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String contractCategory,
|
||||
@RequestParam(defaultValue = "receivable") String settlementType,
|
||||
@RequestParam(defaultValue = "unreceived") String invoiceStatus) {
|
||||
@RequestParam(defaultValue = "receivable") String settlementType) {
|
||||
return R.data(invoiceApplicationService.settlementCandidates(
|
||||
Condition.getPage(query), keyword, contractCategory, settlementType, invoiceStatus));
|
||||
Condition.getPage(query), keyword, contractCategory, settlementType));
|
||||
}
|
||||
|
||||
@GetMapping("/settlement-details")
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ public interface IInvoiceApplicationService extends BaseService<InvoiceApplicati
|
||||
IPage<InvoiceApplicationVO> selectPage(IPage<InvoiceApplication> page, InvoiceApplicationVO query);
|
||||
InvoiceApplicationVO detail(Long id);
|
||||
IPage<Map<String, Object>> settlementCandidates(IPage<?> page, String keyword, String contractCategory,
|
||||
String settlementType, String invoiceStatus);
|
||||
String settlementType);
|
||||
List<FormalSettlementDetail> settlementDetails(String settlementIds);
|
||||
Map<String, Object> receiverInformation(String settlementIds);
|
||||
Long saveDraft(InvoiceApplicationSaveRequest request);
|
||||
|
||||
+34
-5
@@ -274,7 +274,8 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
boolean created = Func.isEmpty(customer.getId());
|
||||
CustomerArchive before = created ? null : ensureCustomerAccessible(customer.getId());
|
||||
if (created) {
|
||||
customer.setCustomerCode(nextCustomerCode());
|
||||
// 新增时金蝶已有客商,前端回填金蝶客商编码,建档直接沿用;未回填则自动生成
|
||||
customer.setCustomerCode(Func.isEmpty(customer.getCustomerCode()) ? this.nextCustomerCode() : customer.getCustomerCode());
|
||||
}
|
||||
CustomerArchive entity = Objects.requireNonNull(BeanUtil.copyProperties(customer, CustomerArchive.class));
|
||||
// 曾审核通过后的评分替换:保存/再提交时仍沿用原生效等级与额度,待再次审核通过后再切换
|
||||
@@ -516,10 +517,10 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
after.setKingdeeSyncTime(LocalDateTime.now());
|
||||
BfiCustomerVO kingdeeCustomer = queryKingdeeCustomer(customer.getUnifiedCreditCode());
|
||||
if (kingdeeCustomer != null && BfiConstant.BILL_STATUS_AUDITED.equals(kingdeeCustomer.getStatus())) {
|
||||
// 金蝶已有已审核档案:跳过推送
|
||||
// 金蝶已有已审核档案:跳过推送,客商编号采纳金蝶客商编码
|
||||
after.setKingdeeStatus(KINGDEE_STATUS_EXIST);
|
||||
after.setKingdeeFailReason(null);
|
||||
addChangeRecord(id, "金蝶已存在该客商档案,跳过推送", customer, after);
|
||||
addChangeRecord(id, "金蝶已存在该客商档案,跳过推送" + this.adoptKingdeeCode(customer, after, kingdeeCustomer.getNumber()), customer, after);
|
||||
updateById(after);
|
||||
return after;
|
||||
}
|
||||
@@ -618,6 +619,31 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
return rows == null || rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 金蝶已存在档案时,客商编号(customer_code)采纳金蝶客商编码,后续业务接口沿用客商编号即拿到金蝶编码;
|
||||
* 金蝶编码为空或与当前编号一致时无需处理;同租户下已被其他客商占用时跳过回填,避免违反 uk_customer_archive_code 唯一约束
|
||||
*
|
||||
* @param customer 变更前档案
|
||||
* @param after 变更后档案(编码采纳结果回填其上)
|
||||
* @param kingdeeNumber A902-1 查询返回的金蝶客商编码
|
||||
* @return 变更说明后缀,无需处理返回空串
|
||||
*/
|
||||
private String adoptKingdeeCode(CustomerArchive customer, CustomerArchive after, String kingdeeNumber) {
|
||||
if (Func.isEmpty(kingdeeNumber) || kingdeeNumber.equals(customer.getCustomerCode())) {
|
||||
return "";
|
||||
}
|
||||
long occupied = count(Wrappers.<CustomerArchive>lambdaQuery()
|
||||
.eq(CustomerArchive::getCustomerCode, kingdeeNumber)
|
||||
.ne(CustomerArchive::getId, customer.getId()));
|
||||
if (occupied > 0) {
|
||||
log.warn("金蝶客商编码 {} 已被其他客商占用,跳过采纳客商编号,id={},customerCode={}",
|
||||
kingdeeNumber, customer.getId(), customer.getCustomerCode());
|
||||
return ",金蝶客商编码 " + kingdeeNumber + " 已被占用未同步";
|
||||
}
|
||||
after.setCustomerCode(kingdeeNumber);
|
||||
return ",客商编号已同步为金蝶客商编码:" + kingdeeNumber;
|
||||
}
|
||||
|
||||
private BfiBillSaveResultItemVO firstSaveItem(BfiBillSaveResultVO result) {
|
||||
if (result == null || result.getResult() == null || result.getResult().isEmpty()) {
|
||||
return null;
|
||||
@@ -740,11 +766,11 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
CustomerArchive after = copyCustomer(customer);
|
||||
after.setKingdeeSyncTime(LocalDateTime.now());
|
||||
if (BfiConstant.BILL_STATUS_AUDITED.equals(kingdeeCustomer.getStatus())) {
|
||||
// 金蝶已存在已审核档案:跳过推送,直接建档
|
||||
// 金蝶已存在已审核档案:跳过推送直接建档,客商编号采纳金蝶客商编码
|
||||
after.setKingdeeStatus(KINGDEE_STATUS_EXIST);
|
||||
after.setKingdeeFailReason(null);
|
||||
existed++;
|
||||
collectChangeRecord(changeRecords, customer.getId(), "金蝶已存在该客商档案,跳过推送", customer, after);
|
||||
collectChangeRecord(changeRecords, customer.getId(), "金蝶已存在该客商档案,跳过推送" + this.adoptKingdeeCode(customer, after, kingdeeCustomer.getNumber()), customer, after);
|
||||
} else {
|
||||
// 金蝶侧暂存/已提交:审核中,置失败待重推
|
||||
after.setKingdeeStatus(KINGDEE_STATUS_FAILED);
|
||||
@@ -822,6 +848,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
CustomerArchive after = copyCustomer(customer);
|
||||
after.setKingdeeStatus(KINGDEE_STATUS_EXIST);
|
||||
after.setKingdeeFailReason(null);
|
||||
this.adoptKingdeeCode(customer, after, kingdeeCustomer.getNumber());
|
||||
updateList.add(after);
|
||||
collectChangeRecord(changeRecords, customer.getId(), "金蝶客商已审核,推送最终成功", customer, after);
|
||||
log.info("金蝶客商已审核,推送最终成功并升级为金蝶已有档案,id={},unifiedCreditCode={}",
|
||||
@@ -955,6 +982,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
}
|
||||
|
||||
private void prepare(CustomerArchiveVO customer) {
|
||||
customer.setCustomerCode(trimToNull(customer.getCustomerCode()));
|
||||
customer.setShortName(trimToNull(customer.getShortName()));
|
||||
customer.setFullName(trimToEmpty(customer.getFullName()));
|
||||
customer.setCustomerKind(normalizeCustomerKind(customer.getCustomerKind()));
|
||||
@@ -1046,6 +1074,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
throw new ServiceException("固定营业期限必须填写营业期限截止日");
|
||||
}
|
||||
validateLength(customer.getShortName(), 20, "客商简称最多20个汉字");
|
||||
validateLength(customer.getCustomerCode(), 20, "客商编号最多20个字符");
|
||||
validateLength(customer.getFullName(), 100, "客商全称最多100个字符");
|
||||
validateLength(customer.getLegalPerson(), 10, "法人/负责人最多10个汉字");
|
||||
validateLength(customer.getContactPhone(), 11, "联系电话必须为11位手机号");
|
||||
|
||||
+52
-9
@@ -85,6 +85,7 @@ import org.springblade.transport.pojo.entity.SettlementAdjustment;
|
||||
import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.FormalSettlementVO;
|
||||
import org.springblade.transport.pojo.vo.PaymentApplicationVO;
|
||||
import org.springblade.transport.pojo.vo.PreSettlementVO;
|
||||
import org.springblade.transport.pojo.vo.SettlementAdvancePaymentVO;
|
||||
import org.springblade.transport.pojo.vo.SettlementAdjustmentVO;
|
||||
@@ -327,13 +328,14 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
vo.setProjectAdvanceWriteOffs(projectAdvanceWriteOffService.listBySettlement(
|
||||
TransportBusiConstant.SETTLEMENT_BILL_FORMAL, id));
|
||||
vo.setAdvancePayments(this.listAdvancePayments(sources, vo.getProjectAdvanceWriteOffs()));
|
||||
Set<Long> relationApplicationIds = paymentApplicationSettlementMapper.selectList(
|
||||
Map<Long, PaymentApplicationSettlement> relationByApplicationId = paymentApplicationSettlementMapper.selectList(
|
||||
Wrappers.<PaymentApplicationSettlement>lambdaQuery()
|
||||
.select(PaymentApplicationSettlement::getPaymentApplicationId)
|
||||
.eq(PaymentApplicationSettlement::getFormalSettlementId, id)
|
||||
.eq(PaymentApplicationSettlement::getIsDeleted, 0)).stream()
|
||||
.map(PaymentApplicationSettlement::getPaymentApplicationId).filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
.filter(relation -> relation.getPaymentApplicationId() != null)
|
||||
.collect(Collectors.toMap(PaymentApplicationSettlement::getPaymentApplicationId, relation -> relation,
|
||||
(first, second) -> first));
|
||||
Set<Long> relationApplicationIds = relationByApplicationId.keySet();
|
||||
LambdaQueryWrapper<PaymentApplication> paymentApplicationQuery = Wrappers.<PaymentApplication>lambdaQuery();
|
||||
paymentApplicationQuery.and(query -> {
|
||||
query.eq(PaymentApplication::getSettlementId, id);
|
||||
@@ -345,7 +347,16 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
.eq(PaymentApplication::getIsDeleted, 0);
|
||||
vo.setPaymentApplications(paymentApplicationMapper.selectList(paymentApplicationQuery
|
||||
.orderByDesc(PaymentApplication::getCreateTime)).stream()
|
||||
.map(item -> PaymentApplicationWrapper.build().entityVO(item)).toList());
|
||||
.map(item -> {
|
||||
PaymentApplicationVO application = PaymentApplicationWrapper.build().entityVO(item);
|
||||
// 多结算单合并付款时,付款申请金额为整单合计;详情按当前结算单关联行展示分摊金额
|
||||
PaymentApplicationSettlement relation = relationByApplicationId.get(item.getId());
|
||||
if (relation != null) {
|
||||
application.setAppliedAmount(money(relation.getAppliedAmount()));
|
||||
application.setPaidAmount(money(relation.getPaidAmount()));
|
||||
}
|
||||
return application;
|
||||
}).toList());
|
||||
vo.setAdjustments(settlementAdjustmentMapper.selectList(Wrappers.<SettlementAdjustment>lambdaQuery()
|
||||
.eq(SettlementAdjustment::getFormalSettlementId, id)
|
||||
.orderByDesc(SettlementAdjustment::getCreateTime)).stream()
|
||||
@@ -1374,6 +1385,9 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
List<FormalSettlementSummaryFee> existingManualRows = existingRows.stream()
|
||||
.filter(row -> Integer.valueOf(1).equals(row.getManualFlag())).toList();
|
||||
Map<String, BigDecimal> aggregates = new LinkedHashMap<>();
|
||||
// 预结算手工费用行不并入自动汇总:金蝶分录只送手工行(manualFlag=1),并入自动汇总
|
||||
// 会被当作明细再汇总跳过,推送金蝶时金额缺失(如预付单手工水费转正式后丢失)
|
||||
Map<String, BigDecimal> sourceManualAmounts = new LinkedHashMap<>();
|
||||
for (FormalSettlementSource source : sourceMapper.selectList(Wrappers.<FormalSettlementSource>lambdaQuery()
|
||||
.eq(FormalSettlementSource::getFormalSettlementId, settlementId))) {
|
||||
List<PreSettlementSummaryFee> sourceSummary = preSummaryFeeMapper.selectList(
|
||||
@@ -1381,9 +1395,12 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
.eq(PreSettlementSummaryFee::getPreSettlementId, source.getPreSettlementId())
|
||||
.eq(PreSettlementSummaryFee::getIsDeleted, 0));
|
||||
sourceSummary.forEach(row -> {
|
||||
BigDecimal additionalAmount = Integer.valueOf(1).equals(row.getManualFlag())
|
||||
? money(row.getSettlementAmount()) : money(row.getAdjustAmount());
|
||||
aggregates.merge(summaryKey(row.getFeeType(), row.getFeeItem()), additionalAmount, BigDecimal::add);
|
||||
if (Integer.valueOf(1).equals(row.getManualFlag())) {
|
||||
sourceManualAmounts.merge(summaryKey(row.getFeeType(), row.getFeeItem()),
|
||||
money(row.getSettlementAmount()), BigDecimal::add);
|
||||
} else {
|
||||
aggregates.merge(summaryKey(row.getFeeType(), row.getFeeItem()), money(row.getAdjustAmount()), BigDecimal::add);
|
||||
}
|
||||
});
|
||||
}
|
||||
Map<String, String> feeTypeMap = feeTypeMap();
|
||||
@@ -1418,6 +1435,25 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
manualRow.setLineNo(lineNo++);
|
||||
summaryFeeMapper.updateById(manualRow);
|
||||
}
|
||||
// 预结算手工费用行结转为正式手工行(保留 manualFlag=1 供金蝶分录取数);已存在同键手工行时沿用页面维护值不重复插入
|
||||
for (Map.Entry<String, BigDecimal> entry : sourceManualAmounts.entrySet()) {
|
||||
boolean existed = existingManualRows.stream().anyMatch(row -> summaryKey(row).equals(entry.getKey()));
|
||||
if (existed) {
|
||||
continue;
|
||||
}
|
||||
String[] keyParts = entry.getKey().split("@@@", 2);
|
||||
FormalSettlementSummaryFee manualRow = new FormalSettlementSummaryFee();
|
||||
manualRow.setFormalSettlementId(settlementId);
|
||||
manualRow.setFeeType(keyParts[0]);
|
||||
manualRow.setFeeItem(keyParts.length > 1 ? keyParts[1] : "");
|
||||
manualRow.setOriginalAmount(BigDecimal.ZERO.setScale(2));
|
||||
manualRow.setAdjustAmount(money(entry.getValue()));
|
||||
manualRow.setSettlementAmount(money(entry.getValue()));
|
||||
manualRow.setRemark("");
|
||||
manualRow.setManualFlag(1);
|
||||
manualRow.setLineNo(lineNo++);
|
||||
summaryFeeMapper.insert(manualRow);
|
||||
}
|
||||
renumberSummaryFees(settlementId);
|
||||
}
|
||||
|
||||
@@ -1514,7 +1550,14 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
}
|
||||
|
||||
private void applySummaryRequest(Long settlementId, List<FormalSettlementSaveRequest.SummaryFee> requestRows) {
|
||||
if (requestRows == null) return;
|
||||
if (requestRows == null) {
|
||||
return;
|
||||
}
|
||||
// 空列表表示沿用 rebuildSummaryFees 结果(如预结算转正式结转的手工合计行),不得按空请求删光手工行
|
||||
if (requestRows.isEmpty()) {
|
||||
renumberSummaryFees(settlementId);
|
||||
return;
|
||||
}
|
||||
Map<String, Set<String>> allowedManualFees = new LinkedHashMap<>();
|
||||
for (Map<String, Object> option : preSettlementService.feeOptions()) {
|
||||
Set<String> feeItems = new LinkedHashSet<>();
|
||||
|
||||
+35
-19
@@ -231,9 +231,23 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
InvoiceApplication entity = existing(id);
|
||||
InvoiceApplicationVO vo = toListVO(entity);
|
||||
fillKingdeeLockStatus(List.of(vo));
|
||||
vo.setSettlements(settlementRelationMapper.selectList(Wrappers.<InvoiceApplicationSettlement>lambdaQuery()
|
||||
.eq(InvoiceApplicationSettlement::getInvoiceApplicationId, id)
|
||||
.orderByAsc(InvoiceApplicationSettlement::getCreateTime)));
|
||||
// 剩余可开票金额按当前占用实时重算(排除本申请),避免编辑时展示保存时点的过期额度
|
||||
List<InvoiceApplicationSettlement> relations = settlementRelationMapper.selectList(
|
||||
Wrappers.<InvoiceApplicationSettlement>lambdaQuery()
|
||||
.eq(InvoiceApplicationSettlement::getInvoiceApplicationId, id)
|
||||
.orderByAsc(InvoiceApplicationSettlement::getCreateTime));
|
||||
if (!relations.isEmpty()) {
|
||||
Map<Long, FormalSettlement> settlementMap = formalSettlementMapper.selectBatchIds(
|
||||
relations.stream().map(InvoiceApplicationSettlement::getFormalSettlementId).distinct().toList())
|
||||
.stream().collect(Collectors.toMap(FormalSettlement::getId, Function.identity(), (first, duplicate) -> first));
|
||||
relations.forEach(relation -> {
|
||||
FormalSettlement settlement = settlementMap.get(relation.getFormalSettlementId());
|
||||
if (settlement != null) {
|
||||
relation.setAvailableInvoiceAmount(availableAmount(settlement, id));
|
||||
}
|
||||
});
|
||||
}
|
||||
vo.setSettlements(relations);
|
||||
List<InvoiceApplicationSheet> sheets = sheetMapper.selectList(Wrappers.<InvoiceApplicationSheet>lambdaQuery()
|
||||
.eq(InvoiceApplicationSheet::getInvoiceApplicationId, id).orderByAsc(InvoiceApplicationSheet::getSheetNo));
|
||||
vo.setSheets(sheets.stream().map(sheet -> {
|
||||
@@ -255,9 +269,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
|
||||
@Override
|
||||
public IPage<Map<String, Object>> settlementCandidates(IPage<?> page, String keyword,
|
||||
String contractCategory, String settlementType, String invoiceStatus) {
|
||||
String contractCategory, String settlementType) {
|
||||
String candidateSettlementType = Func.isEmpty(settlementType) ? "receivable" : settlementType;
|
||||
String candidateInvoiceStatus = Func.isEmpty(invoiceStatus) ? "unreceived" : invoiceStatus;
|
||||
List<Long> contractIds = Func.isEmpty(contractCategory) ? List.of()
|
||||
: contractManageMapper.selectList(Wrappers.<ContractManage>lambdaQuery()
|
||||
.select(ContractManage::getId)
|
||||
@@ -269,7 +282,6 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
}
|
||||
List<FormalSettlement> settlements = formalSettlementMapper.selectList(Wrappers.<FormalSettlement>lambdaQuery()
|
||||
.eq(FormalSettlement::getSettlementType, candidateSettlementType)
|
||||
.eq(FormalSettlement::getInvoiceStatus, candidateInvoiceStatus)
|
||||
.eq(FormalSettlement::getApprovalStatus, TransportBusiConstant.APPROVED)
|
||||
.eq(FormalSettlement::getStatus, 1)
|
||||
.in(Func.isNotEmpty(contractCategory), FormalSettlement::getContractId, contractIds)
|
||||
@@ -337,6 +349,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
row.put("receiverName", settlement.getPayerName());
|
||||
row.put("settlementAmount", money(settlement.getSettlementAmount()));
|
||||
row.put("availableInvoiceAmount", available);
|
||||
row.put("invoicedAmount", money(settlement.getSettlementAmount()).subtract(available));
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -402,28 +415,31 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
.collect(Collectors.toMap(InvoiceApplicationSaveRequest.SettlementRow::getSettlementId,
|
||||
Function.identity(), (first, duplicate) -> first, LinkedHashMap::new)).values().stream().toList();
|
||||
List<FormalSettlement> settlements = requestedRows.stream().map(row -> availableSettlement(row.getSettlementId())).toList();
|
||||
if (settlements.stream().anyMatch(settlement -> !"receivable".equals(settlement.getSettlementType())
|
||||
|| (creating && !"unreceived".equals(settlement.getInvoiceStatus())))) {
|
||||
throw new ServiceException("只能选择审批通过、未作废、未收票的应收正式结算单");
|
||||
if (settlements.stream().anyMatch(settlement -> !"receivable".equals(settlement.getSettlementType()))) {
|
||||
throw new ServiceException("只能选择审批通过、未作废的应收正式结算单");
|
||||
}
|
||||
assertCompatible(settlements);
|
||||
FormalSettlement first = settlements.get(0);
|
||||
Map<Long, FormalSettlement> settlementMap = settlements.stream()
|
||||
.collect(Collectors.toMap(FormalSettlement::getId, Function.identity()));
|
||||
BigDecimal totalAvailable = BigDecimal.ZERO;
|
||||
for (InvoiceApplicationSaveRequest.SettlementRow row : requestedRows) {
|
||||
FormalSettlement settlement = settlementMap.get(row.getSettlementId());
|
||||
BigDecimal available = availableAmount(settlement, entity.getId());
|
||||
BigDecimal allocated = nonNegative(row.getAllocatedInvoiceAmount(), "分摊发票金额");
|
||||
if (allocated.compareTo(available) > 0) {
|
||||
throw new ServiceException("结算单" + settlement.getFormalSettlementNo() + "的分摊金额超过剩余可开票金额");
|
||||
}
|
||||
totalAvailable = totalAvailable.add(available);
|
||||
}
|
||||
BigDecimal lineTotal = validateSheets(request.getSheets());
|
||||
if (lineTotal.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new ServiceException("本次开票金额必须大于0");
|
||||
}
|
||||
// 本次开票金额按选择顺序自动分摊到各结算单并登记累计占用,摊不完即超过可开票金额
|
||||
BigDecimal totalAvailable = BigDecimal.ZERO;
|
||||
BigDecimal remaining = lineTotal;
|
||||
for (InvoiceApplicationSaveRequest.SettlementRow row : requestedRows) {
|
||||
FormalSettlement settlement = settlementMap.get(row.getSettlementId());
|
||||
BigDecimal available = availableAmount(settlement, entity.getId());
|
||||
totalAvailable = totalAvailable.add(available);
|
||||
BigDecimal allocated = available.min(remaining);
|
||||
row.setAllocatedInvoiceAmount(allocated);
|
||||
remaining = remaining.subtract(allocated);
|
||||
}
|
||||
if (remaining.compareTo(BigDecimal.ZERO) > 0) {
|
||||
throw new ServiceException("本次开票金额不能超过可开票金额");
|
||||
}
|
||||
CustomerArchive customer = findCustomer(first.getPayerName());
|
||||
if (customer == null) throw new ServiceException("未找到正式结算单受票方对应的客商档案");
|
||||
List<CustomerInvoiceInfoVO> invoiceInfos = activeInvoiceInfoVOs(customer.getId());
|
||||
|
||||
+28
-27
@@ -32,11 +32,9 @@ import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.DictBizCache;
|
||||
import org.springblade.system.cache.SysCache;
|
||||
import org.springblade.system.feign.ISysClient;
|
||||
import org.springblade.system.pojo.entity.Dept;
|
||||
import org.springblade.system.pojo.entity.DictBiz;
|
||||
import org.springblade.system.pojo.entity.FeeItem;
|
||||
import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillQueryDTO;
|
||||
import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveDTO;
|
||||
@@ -102,8 +100,10 @@ public class KingdeeApBillSyncSupport {
|
||||
private static final int PAY_RESULT_PAGE_SIZE = 200;
|
||||
/** 结算明细行(运费)计费方案匹配费用项(运输类无税率残差组标识用) */
|
||||
private static final String FREIGHT_FEE_ITEM = "运输费";
|
||||
/** 业务字典:费用类型(fee_category),费用项档案分类编码 -> 名称 */
|
||||
private static final String FEE_CATEGORY_DICT = "fee_category";
|
||||
/** 费用大类编码(业务字典 fee_category 键值):运输 */
|
||||
public static final String FEE_CATEGORY_FREIGHT = "A01";
|
||||
/** 费用大类编码(业务字典 fee_category 键值):装卸 */
|
||||
public static final String FEE_CATEGORY_LOADING = "A10";
|
||||
|
||||
private final IBfiService bfiService;
|
||||
private final ContractManageMapper contractManageMapper;
|
||||
@@ -424,7 +424,7 @@ public class KingdeeApBillSyncSupport {
|
||||
ContractManage contract = requireContract(settlement.getContractId(), context);
|
||||
// 合同计费方案规则:费用类型/费用项 -> 税率(拆行分组用)
|
||||
Map<String, BigDecimal> taxRateByRule = taxRateByRule(contract);
|
||||
// 费用项档案:费用项名称 -> 费用类型名称(费用大类判定,字典 fee_category 翻译)
|
||||
// 费用项档案:费用项名称 -> 费用大类编码(费用大类判定)
|
||||
Map<String, String> feeCategoryByName = feeCategoryByName(context);
|
||||
EntryResolveContext resolveContext = new EntryResolveContext(settlement, project, taxRateByRule, feeCategoryByName);
|
||||
// 结算明细费用行:一车多货按货物拆分的费用快照行
|
||||
@@ -540,7 +540,7 @@ public class KingdeeApBillSyncSupport {
|
||||
String feeType = Func.toStr(summaryFee.getFeeType()).trim();
|
||||
String feeItem = Func.toStr(summaryFee.getFeeItem()).trim();
|
||||
String sourceDesc = "结算合计[" + feeType + "-" + feeItem + "]";
|
||||
// 非运费类(费用项档案分类/费用类型/费用项名称均不含“运费/运输费”)统一映射装卸物料
|
||||
// 非运费类(费用项档案分类编码非 A01 且名称无运费字样)统一映射装卸物料
|
||||
boolean loadingFee = !isFreightCategory(feeType, feeItem, context.feeCategoryByName());
|
||||
String materialNumber = resolveMaterialNumber(loadingFee, context.project(), fallbackTransportType, sourceDesc);
|
||||
// 结算合计费用项行无运输量,数量默认 1
|
||||
@@ -667,12 +667,12 @@ public class KingdeeApBillSyncSupport {
|
||||
|
||||
/**
|
||||
* 费用项按费用大类/税率拆分(纯函数):
|
||||
* 费用项经费用项档案/业务字典判为装卸大类(名称含“装卸”的费用类型)、其余归运输大类;
|
||||
* 费用项经费用项档案判为装卸大类(费用大类编码 A10)、其余归运输大类;
|
||||
* 各大类内按计费方案规则税率分组(未配置税率的费用项归入无税率组),
|
||||
* 费用项金额合计与结算金额的残差并入运输类无税率组,保证拆分后各行金额合计与结算金额勾稽
|
||||
*
|
||||
* @param feeItems 费用项名称 -> 金额(费用项JSON解析结果,保持行序)
|
||||
* @param feeCategoryByName 费用项档案:费用项名称 -> 费用类型名称(空 Map 时回退名称关键字判定)
|
||||
* @param feeCategoryByName 费用项档案:费用项名称 -> 费用大类编码(空 Map 时回退名称关键字判定)
|
||||
* @param taxRateByRule 合同计费方案:费用类型/费用项 -> 税率(百分数)
|
||||
* @param rowAmount 货物费用行结算金额(含税),残差勾稽基准
|
||||
* @return 拆分行列表(保持费用项出现顺序)
|
||||
@@ -720,29 +720,33 @@ public class KingdeeApBillSyncSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* 装卸大类判定:费用项在费用项档案(blade_fee_item)中的费用类型名称(业务字典 fee_category 翻译)
|
||||
* 含“装卸”归装卸大类;档案未维护该费用项时回退费用项名称关键字判定
|
||||
* 装卸大类判定:费用项在费用项档案(blade_fee_item)中的费用大类编码为 A10 归装卸大类;
|
||||
* 档案未维护该费用项时回退费用项名称关键字判定
|
||||
*/
|
||||
static boolean isLoadingCategory(String feeItemName, Map<String, String> feeCategoryByName) {
|
||||
String categoryName = feeItemName == null || feeCategoryByName == null ? null : feeCategoryByName.get(feeItemName);
|
||||
if (Func.isNotBlank(categoryName)) {
|
||||
return categoryName.contains("装卸");
|
||||
String categoryCode = feeItemName == null || feeCategoryByName == null ? null : feeCategoryByName.get(feeItemName);
|
||||
if (Func.isNotBlank(categoryCode)) {
|
||||
return FEE_CATEGORY_LOADING.equals(categoryCode);
|
||||
}
|
||||
return isLoadingFeeItem(feeItemName);
|
||||
}
|
||||
|
||||
/** 运费类费用判定(关键字兜底):名称含“运费”或“运输费” */
|
||||
/** 运费类费用判定(关键字兜底):名称含“运费”或“运输”(业务字典 fee_category 运输类的字典值为“运输”) */
|
||||
static boolean isFreightFeeText(String name) {
|
||||
return name != null && (name.contains("运费") || name.contains("运输费"));
|
||||
return name != null && (name.contains("运费") || name.contains("运输"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 运费类判定(手动添加费用行物料映射用):费用项档案分类名称、费用类型、费用项名称任一
|
||||
* 含“运费/运输费”即归运费类映射运输物料;档案未维护时退化为名称关键字判定(与原口径一致)
|
||||
* 运费类判定(手动添加费用行物料映射用):费用项档案维护的费用大类编码为 A01 归运费类映射运输物料;
|
||||
* 档案未维护时回退行费用类型编码与费用项名称关键字判定
|
||||
*/
|
||||
static boolean isFreightCategory(String feeTypeName, String feeItemName, Map<String, String> feeCategoryByName) {
|
||||
String categoryName = feeItemName == null || feeCategoryByName == null ? null : feeCategoryByName.get(feeItemName);
|
||||
return isFreightFeeText(categoryName) || isFreightFeeText(feeTypeName) || isFreightFeeText(feeItemName);
|
||||
public static boolean isFreightCategory(String feeTypeCode, String feeItemName, Map<String, String> feeCategoryByName) {
|
||||
String categoryCode = feeItemName == null || feeCategoryByName == null ? null : feeCategoryByName.get(feeItemName);
|
||||
if (Func.isNotBlank(categoryCode)) {
|
||||
// 档案维护的费用项以档案分类编码为准
|
||||
return FEE_CATEGORY_FREIGHT.equals(categoryCode);
|
||||
}
|
||||
return FEE_CATEGORY_FREIGHT.equals(feeTypeCode) || isFreightFeeText(feeItemName);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -844,8 +848,8 @@ public class KingdeeApBillSyncSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* 费用项档案分类:费用项名称 -> 费用类型名称(blade_fee_item.fee_category 编码经业务字典
|
||||
* {@link #FEE_CATEGORY_DICT} 翻译;批量同步时全批共享一次加载结果),费用大类判定用;
|
||||
* 费用项档案分类:费用项名称 -> 费用大类编码(blade_fee_item.fee_category,业务字典 fee_category 键值,
|
||||
* 运输 A01 / 装卸 A10;批量同步时全批共享一次加载结果),费用大类判定用;
|
||||
* 档案读取失败直接拦截
|
||||
*/
|
||||
public Map<String, String> feeCategoryByName(KingdeeSyncContext context) {
|
||||
@@ -853,8 +857,6 @@ public class KingdeeApBillSyncSupport {
|
||||
if (!cache.isEmpty()) {
|
||||
return cache;
|
||||
}
|
||||
Map<String, String> categoryNames = DictBizCache.getList(FEE_CATEGORY_DICT).stream()
|
||||
.collect(Collectors.toMap(DictBiz::getDictKey, DictBiz::getDictValue, (first, second) -> first));
|
||||
R<List<FeeItem>> response = sysClient.getFeeItems(false);
|
||||
if (response == null || !response.isSuccess() || Func.isEmpty(response.getData())) {
|
||||
throw new ServiceException("推送金蝶失败:费用项基础档案读取失败,请稍后重试");
|
||||
@@ -864,10 +866,9 @@ public class KingdeeApBillSyncSupport {
|
||||
if (feeItem == null || Func.isBlank(feeItem.getName()) || Func.isBlank(feeItem.getFeeCategory())) {
|
||||
continue;
|
||||
}
|
||||
loaded.putIfAbsent(feeItem.getName().trim(),
|
||||
categoryNames.getOrDefault(feeItem.getFeeCategory().trim(), feeItem.getFeeCategory().trim()));
|
||||
loaded.putIfAbsent(feeItem.getName().trim(), feeItem.getFeeCategory().trim());
|
||||
}
|
||||
log.info("费用项档案加载:{}项(费用项名称 -> 费用类型名称)", loaded.size());
|
||||
log.info("费用项档案加载:{}项(费用项名称 -> 费用大类编码)", loaded.size());
|
||||
cache.putAll(loaded);
|
||||
return cache;
|
||||
}
|
||||
|
||||
+21
-2
@@ -86,11 +86,11 @@ class KingdeeEntrySplitTest {
|
||||
assertEquals(new BigDecimal("800"), splits.get(1).getAmount());
|
||||
}
|
||||
|
||||
/** 费用项档案分类判定:名称不含“装卸”的费用项,档案费用类型名称含“装卸”即归装卸大类 */
|
||||
/** 费用项档案分类判定:名称不含“装卸”的费用项,档案费用大类编码 A10 即归装卸大类 */
|
||||
@Test
|
||||
void loadingCategoryFromFeeItemArchive() {
|
||||
Map<String, BigDecimal> feeItems = Map.of("吊装作业", new BigDecimal("500"));
|
||||
Map<String, String> feeCategoryByName = Map.of("吊装作业", "装卸");
|
||||
Map<String, String> feeCategoryByName = Map.of("吊装作业", "A10");
|
||||
List<FeeLineSplit> splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, feeCategoryByName, Map.of(), new BigDecimal("500"));
|
||||
assertEquals(1, splits.size());
|
||||
assertTrue(splits.get(0).isLoading());
|
||||
@@ -166,4 +166,23 @@ class KingdeeEntrySplitTest {
|
||||
List<FeeLineSplit> splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, Map.of(), Map.of(), BigDecimal.ZERO);
|
||||
assertTrue(splits.isEmpty());
|
||||
}
|
||||
|
||||
/** 手工合计行运费类判定:费用项档案分类编码 A01 归运费类映射运输物料(SJS2026101900004 车辆维修费误映射装卸物料回归用例) */
|
||||
@Test
|
||||
void freightCategoryMatchesArchiveCode() {
|
||||
Map<String, String> feeCategoryByName = Map.of(
|
||||
"车辆维修费", "A01",
|
||||
"人工装卸费用", "A10");
|
||||
assertTrue(KingdeeApBillSyncSupport.isFreightCategory("A01", "车辆维修费", feeCategoryByName));
|
||||
assertFalse(KingdeeApBillSyncSupport.isFreightCategory("A10", "人工装卸费用", feeCategoryByName));
|
||||
// 档案编码优先于名称关键字:A02 档案的“装卸搬运作业费”不归装卸大类
|
||||
Map<String, BigDecimal> feeItems = Map.of("装卸搬运作业费", new BigDecimal("100"));
|
||||
List<FeeLineSplit> splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems,
|
||||
Map.of("装卸搬运作业费", "A02"), Map.of(), new BigDecimal("100"));
|
||||
assertFalse(splits.get(0).isLoading());
|
||||
// 档案未维护:回退行费用类型编码与名称关键字判定
|
||||
assertTrue(KingdeeApBillSyncSupport.isFreightCategory("A01", "车辆维修费", Map.of()));
|
||||
assertFalse(KingdeeApBillSyncSupport.isFreightCategory("A10", "人工装卸费用", Map.of()));
|
||||
assertTrue(KingdeeApBillSyncSupport.isFreightCategory("", "运输费", Map.of()));
|
||||
}
|
||||
}
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.thirdparty.bfi.config;
|
||||
|
||||
import org.springblade.thirdparty.bfi.feign.IBfiClient;
|
||||
import org.springblade.thirdparty.bfi.mock.BfiClientMock;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
|
||||
/**
|
||||
* BFI 客户端 Mock 配置
|
||||
* <p>
|
||||
* thirdParty.bfi.mock-enabled=true(Nacos 单入口总开关)时注册 @Primary 的 mock 实现,
|
||||
* 覆盖同类型 Feign 客户端 bean,{@link org.springblade.thirdparty.bfi.service.impl.BfiServiceImpl} 注入的 {@link IBfiClient}
|
||||
* 无感知切换为"有桩用桩、没桩走接口":{mockDir}/{方法名}.json 有桩走本地 JSON 桩,
|
||||
* 无桩透传真实 Feign 接口({@link BfiClientMock},目录经 thirdParty.bfi.mockDir 配置),调用侧零改动。
|
||||
* 仅本地自测/演示环境开启,生产必须保持 false。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Configuration
|
||||
public class BfiMockConfig {
|
||||
|
||||
/**
|
||||
* mock 版 BFI 客户端(@Primary 优先于 Feign 客户端注入)
|
||||
*
|
||||
* @param properties BFI 配置(取桩文件目录 mockDir)
|
||||
* @param realClientProvider 真实客户端候选
|
||||
* @return mock 客户端
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
// @ConditionalOnProperty 不支持宽松绑定,key 必须与 Nacos 实际键完全一致(本仓库 Nacos 为 thirdParty.bfi.* 驼峰根)
|
||||
@ConditionalOnProperty(name = "thirdParty.bfi.mock-enabled", havingValue = "true")
|
||||
public IBfiClient bfiClientMock(BfiProperties properties, ObjectProvider<IBfiClient> realClientProvider) {
|
||||
return BfiClientMock.create(properties.getMockDir(), realClientProvider);
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -65,7 +65,12 @@ public class BfiProperties {
|
||||
*/
|
||||
private String language = "zh_CN";
|
||||
/**
|
||||
* 是否启用 mock 模式(开发调试用)
|
||||
* 是否启用 mock 模式(开发调试用):开启后 IBfiClient 走本地桩目录(有桩用桩、无桩透传真实接口,见 BfiClientMock)
|
||||
*/
|
||||
private boolean mockEnabled = false;
|
||||
/**
|
||||
* mock 客户端桩文件目录,文件名为接口方法名:{mockDir}/{方法名}.json,如 payApplyQuery.json;
|
||||
* 仅 mock-enabled=true 生效,文件每次调用现读,改文件即生效
|
||||
*/
|
||||
private String mockDir = "./mock/bfi";
|
||||
}
|
||||
|
||||
+2
-1
@@ -85,7 +85,8 @@ import java.util.List;
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@FeignClient(name = "BFI", url = "${thirdParty.bfi.baseUrl}", configuration = BfiFeignClientConfig.class)
|
||||
// primary = false:Feign 客户端默认 primary=true,开启 mock(BfiMockConfig 注册 @Primary 实现)会双 primary 启动失败
|
||||
@FeignClient(name = "BFI", url = "${thirdParty.bfi.baseUrl}", configuration = BfiFeignClientConfig.class, primary = false)
|
||||
public interface IBfiClient {
|
||||
|
||||
String GET_TOKEN_URL = "${thirdParty.bfi.getTokenUrl:/ierp/kapi/oauth2/getToken}";
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.thirdparty.bfi.mock;
|
||||
|
||||
import com.fasterxml.jackson.databind.JavaType;
|
||||
import com.fasterxml.jackson.databind.type.TypeFactory;
|
||||
import feign.Response;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.thirdparty.bfi.exception.BfiException;
|
||||
import org.springblade.thirdparty.bfi.feign.IBfiClient;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiResultVO;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* BFI 客户端本地 Mock(thirdParty.bfi.mock-enabled=true 时经 BfiMockConfig 注册为 @Primary,包装真实 Feign 客户端)
|
||||
* <p>
|
||||
* 单入口总开关,方法级混合:每个 IBfiClient 方法对应一个外部目录桩文件 {mockDir}/{方法名}.json(thirdParty.bfi.mockDir 配置),
|
||||
* 有桩即走 mock(内容即金蝶真实响应报文,含 BfiResultVO/BfiOpenApiResultVO 包装,按方法返回泛型反序列化,调用侧无感知),
|
||||
* 无桩自动透传真实 Feign 接口,实现"有桩用桩、没桩走接口"。
|
||||
* 桩文件每次调用现读,改文件即生效无需重启;桩解析失败返回失败的 BfiResultVO,不静默吞错。
|
||||
* 文件下载接口 invoiceDownload 返回 feign.Response,无法按 JSON 桩反序列化,始终透传真实接口。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
public class BfiClientMock implements InvocationHandler {
|
||||
|
||||
/**
|
||||
* 桩文件后缀(文件名即接口方法名)
|
||||
*/
|
||||
private static final String MOCK_FILE_SUFFIX = ".json";
|
||||
|
||||
/**
|
||||
* 桩文件目录
|
||||
*/
|
||||
private final Path mockDir;
|
||||
|
||||
/**
|
||||
* 真实客户端候选(排除 mock 自身,运行期首次透传时解析并缓存)
|
||||
*/
|
||||
private final ObjectProvider<IBfiClient> realClientProvider;
|
||||
|
||||
/**
|
||||
* 真实 Feign 客户端缓存(double-checked locking)
|
||||
*/
|
||||
private volatile IBfiClient realClient;
|
||||
|
||||
/**
|
||||
* 构建 mock 版 IBfiClient
|
||||
*
|
||||
* @param mockDir 桩文件目录(third-party.bfi.mock-dir)
|
||||
* @param realClientProvider 真实客户端候选(容器内 IBfiClient 全量,含 mock 自身,解析时排除)
|
||||
* @return mock 客户端
|
||||
*/
|
||||
public static IBfiClient create(String mockDir, ObjectProvider<IBfiClient> realClientProvider) {
|
||||
return (IBfiClient) Proxy.newProxyInstance(IBfiClient.class.getClassLoader(),
|
||||
new Class<?>[]{IBfiClient.class}, new BfiClientMock(mockDir, realClientProvider));
|
||||
}
|
||||
|
||||
private BfiClientMock(String mockDir, ObjectProvider<IBfiClient> realClientProvider) {
|
||||
this.mockDir = Path.of(mockDir);
|
||||
this.realClientProvider = realClientProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
|
||||
// Object 基础方法(toString/hashCode/equals)直接执行
|
||||
if (method.getDeclaringClass() != IBfiClient.class) {
|
||||
return method.invoke(this, args);
|
||||
}
|
||||
// 文件下载返回 feign.Response,无法按 JSON 桩反序列化,始终透传
|
||||
if (method.getReturnType() == Response.class) {
|
||||
return this.invokeReal(method, args, proxy);
|
||||
}
|
||||
return this.loadMock(method, args, proxy);
|
||||
}
|
||||
|
||||
/**
|
||||
* 有桩读桩,无桩透传真实接口
|
||||
*
|
||||
* @param method 接口方法
|
||||
* @param args 入参
|
||||
* @param proxy mock 代理自身(解析真实客户端时排除)
|
||||
* @return 桩数据或真实接口响应
|
||||
*/
|
||||
private Object loadMock(Method method, Object[] args, Object proxy) throws Throwable {
|
||||
Path mockFile = this.mockDir.resolve(method.getName() + MOCK_FILE_SUFFIX);
|
||||
if (!Files.exists(mockFile)) {
|
||||
log.debug("BFI mock 桩文件缺失,透传真实接口:{}", method.getName());
|
||||
return this.invokeReal(method, args, proxy);
|
||||
}
|
||||
// 外部桩每次调用现读,改文件即生效
|
||||
try (InputStream inputStream = Files.newInputStream(mockFile)) {
|
||||
JavaType javaType = TypeFactory.defaultInstance().constructType(method.getGenericReturnType());
|
||||
return JsonUtil.getInstance().readValue(inputStream, javaType);
|
||||
} catch (Exception exception) {
|
||||
log.error("BFI mock 桩文件解析失败:{}", mockFile.toAbsolutePath(), exception);
|
||||
return this.failResult("BFI mock 桩文件解析失败:" + mockFile.toAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 透传真实 Feign 客户端(首次调用时懒解析,排除 mock 代理自身,异常原样抛出)
|
||||
*
|
||||
* @param method 接口方法
|
||||
* @param args 入参
|
||||
* @param proxy mock 代理自身
|
||||
* @return 真实接口响应
|
||||
* @throws Throwable 真实调用异常(Feign 降级/业务异常原样透传)
|
||||
*/
|
||||
private Object invokeReal(Method method, Object[] args, Object proxy) throws Throwable {
|
||||
IBfiClient real = this.realClient;
|
||||
if (real == null) {
|
||||
synchronized (this) {
|
||||
if (this.realClient == null) {
|
||||
this.realClient = this.realClientProvider.stream()
|
||||
.filter(client -> client != proxy)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new BfiException("BFI mock 桩缺失且未找到真实 BFI 客户端:" + method.getName()));
|
||||
}
|
||||
real = this.realClient;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return method.invoke(real, args);
|
||||
} catch (InvocationTargetException exception) {
|
||||
throw exception.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造失败的 BfiResultVO(桩解析异常时兜底,避免调用侧拿到 null)
|
||||
*
|
||||
* @param msg 失败原因
|
||||
* @return 失败结果
|
||||
*/
|
||||
private BfiResultVO<?> failResult(String msg) {
|
||||
BfiResultVO<?> result = new BfiResultVO<>();
|
||||
result.setStatus(false);
|
||||
result.setErrorCode("errors.mock");
|
||||
result.setMessage(msg);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
# 开票申请支持结算单重复选择与累计额度控制改造说明
|
||||
|
||||
> 日期:2026-10-19
|
||||
> 模块:收款付账管理 / 开票管理(开票申请)
|
||||
> 涉及工程:`tms-api`(blade-transport)、`tms-erp-web`
|
||||
> 关联功能说明:支持多张结算单合并开票、一张结算单拆分多张发票;审批通过后同步金蝶;单据留痕。
|
||||
|
||||
---
|
||||
|
||||
## 一、背景与现状问题
|
||||
|
||||
开票申请原实现中,选单弹窗与保存校验都要求结算单处于"未收票"(`unreceived`)状态:
|
||||
|
||||
1. 前端选单请求写死 `invoiceStatus: 'unreceived'`;
|
||||
2. 后端候选接口按 `invoice_status = 'unreceived'` 过滤;
|
||||
3. 保存校验:新建申请时若结算单状态不是 `unreceived` 直接报"只能选择审批通过、未作废、未收票的应收正式结算单";
|
||||
4. 开票申请**保存草稿**时即回写结算单发票状态(`refreshSettlementInvoiceStatus`),状态变为"部分开票 / 全额开票"。
|
||||
|
||||
**结果**:结算单 001、002 在第一张开票申请保存后状态不再是"未收票",开第二张票时弹窗查不到、后端也拦截,**一张结算单无法拆分多次开票**,与功能说明中"一张结算单拆分多张发票"不符。
|
||||
|
||||
同时原"分摊发票金额"依赖用户手填,且存在口径不一致:单选结算单时自动填满剩余额度(即使本次只开一部分),多选时全部填 0(不占用),累计额度控制实际失效。
|
||||
|
||||
## 二、改造方案(自动分摊口径)
|
||||
|
||||
核心思路:**放开"未收票"过滤让结算单可重复选;"累计开票金额不超结算单金额"由后端按本次开票金额自动分摊登记占用**,复用既有的剩余额度计算与状态回写链路。
|
||||
|
||||
### 2.1 核心规则
|
||||
|
||||
| 规则 | 说明 |
|
||||
| --- | --- |
|
||||
| 可开票金额(剩余) | `结算单总金额 − Σ分摊发票金额`,其中分摊金额只统计**未作废、未删除**的开票申请(`availableAmount`,编辑时排除本申请自身) |
|
||||
| 可选结算单范围 | 审批通过 + 未作废 + 启用 + **剩余可开票金额 > 0** 的应收正式结算单;不再限制"未收票"。开满的(剩余额度为 0)自动从弹窗消失 |
|
||||
| 自动分摊 | 保存申请(含草稿)时,后端将**本次开票金额**(开票内容商品行含税金额合计)按结算单选择顺序**逐单填满**:每单分摊 `min(该单剩余可开票金额, 尚未摊完的金额)`,分摊结果写入关联表 `allocated_invoice_amount` |
|
||||
| 整体兜底校验 | 摊不完(本次开票金额 > Σ剩余可开票金额)时保存报错:"本次开票金额不能超过可开票金额",草稿与提交均生效 |
|
||||
| 结算单发票状态 | 保存/删除/作废申请时自动刷新:占用 ≤ 0 → 未收票;0 < 占用 < 总金额 → 部分开票;占用 = 总金额 → 全额开票(`refreshSettlementInvoiceStatus`,本次未改动) |
|
||||
| 额度释放 | 删除草稿、作废申请时自动释放占用(既有逻辑,本次未改动) |
|
||||
|
||||
### 2.2 分摊示例
|
||||
|
||||
结算单 001 总金额 1000:
|
||||
|
||||
| 步骤 | 操作 | 分摊结果 | 001 状态 | 剩余可开票 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 第 1 张申请 | 选 001,本次开票 600 | 001 分摊 600 | 部分开票 | 400 |
|
||||
| 第 2 张申请 | 弹窗仍可选 001(显示已申请 600 / 可开票 400),本次开票 400 | 001 分摊 400 | 全额开票 | 0 |
|
||||
| 第 3 张申请 | 弹窗不再出现 001(剩余额度 0) | — | — | — |
|
||||
| 第 2 张申请被作废 | 释放 400 | 001 分摊余 600 | 部分开票 | 400 |
|
||||
|
||||
多张结算单(001 剩 1000、002 剩 500)本次开票 1200:001 分摊 1000、002 分摊 200;本次开票 1600 时摊不完,保存报错。
|
||||
|
||||
## 三、改动明细
|
||||
|
||||
### 3.1 后端(tms-api / blade-transport)
|
||||
|
||||
| 文件 | 改动 |
|
||||
| --- | --- |
|
||||
| `IInvoiceApplicationService.java` | `settlementCandidates` 签名删除 `invoiceStatus` 参数 |
|
||||
| `InvoiceApplicationController.java` | `/settlement-candidates` 删除 `invoiceStatus` 请求参数(原默认 `unreceived`) |
|
||||
| `InvoiceApplicationServiceImpl.java` `settlementCandidates` | 删除 `invoice_status = 'unreceived'` 过滤;"剩余可开票金额 > 0"过滤保留,兜住开满的结算单 |
|
||||
| `InvoiceApplicationServiceImpl.java` `settlementCandidateMap` | 候选行新增返回 `invoicedAmount`(已申请开票金额 = 结算总金额 − 剩余可开票) |
|
||||
| `InvoiceApplicationServiceImpl.java` `saveDraft` | ① 删除新建时 `unreceived` 状态硬校验,文案改为"只能选择审批通过、未作废的应收正式结算单";② 先计算本次开票金额(`validateSheets`),再按 2.1 规则自动分摊并回写 `SettlementRow.allocatedInvoiceAmount`,摊不完报错。关联表保存(`saveRelations`)直接读取分摊结果,无需感知 |
|
||||
| `InvoiceApplicationServiceImpl.java` `detail` | 对已选结算单实时重算剩余可开票金额(`availableAmount` 排除本申请),避免编辑时展示保存时点的过期额度;批量查询结算单避免 N+1 |
|
||||
|
||||
### 3.2 前端(tms-erp-web / `src/views/payment/invoice-application-form.vue`)
|
||||
|
||||
| 位置 | 改动 |
|
||||
| --- | --- |
|
||||
| `loadSettlementCandidates` | 删除 `invoiceStatus: 'unreceived'` 参数 |
|
||||
| 选单弹窗 | 在"结算金额"与"可开票金额"之间新增"已申请开票金额"列(`invoicedAmount`);"可开票金额"列原本已有 |
|
||||
| `applySettlements` | 选单后分摊金额统一默认 0(删除"单选自动填满剩余额度"逻辑),分摊由后端保存时自动计算 |
|
||||
| 结算信息表格 | "分摊发票金额"列改为只读展示(保存/详情返回的自动分摊结果),不再允许手填 |
|
||||
|
||||
提交前的整体校验"本次开票金额不能超过可开票金额"(`validateAvailableInvoiceAmount`)保留,用于提交时提前拦截;后端保存兜底,草稿同样受限。
|
||||
|
||||
## 四、操作说明
|
||||
|
||||
### 4.1 制单人:首张开票(含草稿)
|
||||
|
||||
1. 开票管理 → 开票申请 → 新增;
|
||||
2. 结算单"选择":弹窗展示审批通过的应收正式结算单,可见**结算金额 / 已申请开票金额 / 可开票金额**三列,可多选合并开票(需同一合同、项目、组织及收付款方);
|
||||
3. 勾选开票明细、维护开票内容商品行,本次开票金额由商品行含税金额自动求和;
|
||||
4. 保存/提交:系统自动把本次开票金额按选择顺序分摊到各结算单并登记占用,结算单状态刷新为"部分开票"或"全额开票"。
|
||||
|
||||
### 4.2 制单人:同一结算单拆分开第二张票(本次新增能力)
|
||||
|
||||
1. 再次新增开票申请,选单弹窗中**已开过票的结算单仍会出现**(只要还有剩余额度);
|
||||
2. "可开票金额"列显示的即为剩余额度(总金额 − 已申请);
|
||||
3. 本次开票金额超过剩余额度时,保存/提交均提示"本次开票金额不能超过可开票金额",调低商品行金额后可正常保存;
|
||||
4. 开满后该结算单自动从弹窗消失。
|
||||
|
||||
### 4.3 编辑与释放
|
||||
|
||||
- 编辑已有申请:结算信息中"可开票金额"为实时剩余额度(已排除本申请自身占用),"分摊发票金额"为上次保存的自动分摊结果;再次保存时按新的本次开票金额重新分摊;
|
||||
- 删除草稿 / 作废申请:占用自动释放,对应结算单恢复可开额度并回刷发票状态。
|
||||
|
||||
## 五、兼容性与注意事项
|
||||
|
||||
| 项 | 说明 |
|
||||
| --- | --- |
|
||||
| 存量数据 | 历史"多选分摊=0"的申请未登记占用,其结算单会以"全额可开"出现在弹窗。如需严格口径,上线前可做数据订正:按未作废申请的 `invoice_amount` 回填关联表 `allocated_invoice_amount` 并刷新结算单状态;或明确存量不追溯 |
|
||||
| 草稿即占用 | 保存草稿即登记占用(与原行为一致),长期挂着的草稿会占住额度,需作废/删除释放 |
|
||||
| 明细不去重 | 同一结算单的运单明细可能被多张申请勾选,本次按金额总额控制,不按明细去重(需求未要求) |
|
||||
| 手填分摊失效 | `allocatedInvoiceAmount` 请求字段后端不再采信(自动覆盖),前端已改只读;如有第三方调用直接传该字段,行为变化需知悉 |
|
||||
| 金蝶同步 | 同步链路读取申请主表/商品行,未读取分摊字段,理论无影响;上线后建议回归一次"审批通过 → 同步金蝶"链路 |
|
||||
| 接口变更 | `/invoice-application/settlement-candidates` 删除 `invoiceStatus` 参数、响应新增 `invoicedAmount`,仅开票申请选单使用 |
|
||||
|
||||
## 六、验证清单
|
||||
|
||||
- [ ] 新增申请选择 001 开票 600,保存后 001 状态"部分开票"、已申请 600;
|
||||
- [ ] 第二张申请弹窗可选 001,可开票金额显示 400,已申请开票金额显示 600;
|
||||
- [ ] 第二张本次开票 500,保存报"本次开票金额不能超过可开票金额";调为 400 保存成功,001 变"全额开票";
|
||||
- [ ] 第三张申请弹窗不再出现 001;
|
||||
- [ ] 作废第二张申请后,001 恢复可开票 400、状态回到"部分开票";
|
||||
- [ ] 多结算单合并开票:本次开票金额按顺序分摊到各单,结算信息表格展示各单分摊结果;
|
||||
- [ ] 编辑申请时"可开票金额"为实时额度(扣除其他申请占用、含本申请原分摊回退);
|
||||
- [ ] 原有链路回归:审批流转、金蝶同步、批量同步状态、留痕记录。
|
||||
@@ -47,6 +47,11 @@ thirdParty:
|
||||
accountId: ${BFI_ACCOUNT_ID:}
|
||||
# 语言字串,默认 zh_CN
|
||||
language: ${BFI_LANGUAGE:zh_CN}
|
||||
# BFI 客户端本地 Mock 开关(联调自测用,生产必须 false):
|
||||
# true 时 IBfiClient 有桩用桩({mockDir}/{接口方法名}.json)、无桩透传真实 Feign 接口,调用侧零改动
|
||||
mock-enabled: ${BFI_MOCK_ENABLED:false}
|
||||
# Mock 桩文件目录:文件名为接口方法名,如 getToken.json、payApplyQuery.json;文件每次调用现读,改文件即生效
|
||||
mockDir: ${BFI_MOCK_DIR:./mock/bfi}
|
||||
# 数据来源系统编码(推送金蝶公共参数:客商、付款申请等统一使用),默认 WLTMS(运输TMS)
|
||||
srcSys: ${BFI_SRC_SYS:WLTMS}
|
||||
# 应付/应收/收票单分录物料映射(A102-2/A302-2/A303-2 共用,未配置使用代码默认值):
|
||||
|
||||
Reference in New Issue
Block a user