diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java index 6d2ab648..bbe0bcf3 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java @@ -84,10 +84,9 @@ public class InvoiceApplicationController extends BladeController { public R>> 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") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java index aa9faf18..3277eef2 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java @@ -45,7 +45,7 @@ public interface IInvoiceApplicationService extends BaseService selectPage(IPage page, InvoiceApplicationVO query); InvoiceApplicationVO detail(Long id); IPage> settlementCandidates(IPage page, String keyword, String contractCategory, - String settlementType, String invoiceStatus); + String settlementType); List settlementDetails(String settlementIds); Map receiverInformation(String settlementIds); Long saveDraft(InvoiceApplicationSaveRequest request); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java index 4459618e..0c8dad1e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java @@ -231,9 +231,23 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpllambdaQuery() - .eq(InvoiceApplicationSettlement::getInvoiceApplicationId, id) - .orderByAsc(InvoiceApplicationSettlement::getCreateTime))); + // 剩余可开票金额按当前占用实时重算(排除本申请),避免编辑时展示保存时点的过期额度 + List relations = settlementRelationMapper.selectList( + Wrappers.lambdaQuery() + .eq(InvoiceApplicationSettlement::getInvoiceApplicationId, id) + .orderByAsc(InvoiceApplicationSettlement::getCreateTime)); + if (!relations.isEmpty()) { + Map 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 sheets = sheetMapper.selectList(Wrappers.lambdaQuery() .eq(InvoiceApplicationSheet::getInvoiceApplicationId, id).orderByAsc(InvoiceApplicationSheet::getSheetNo)); vo.setSheets(sheets.stream().map(sheet -> { @@ -255,9 +269,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl> 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 contractIds = Func.isEmpty(contractCategory) ? List.of() : contractManageMapper.selectList(Wrappers.lambdaQuery() .select(ContractManage::getId) @@ -269,7 +282,6 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl settlements = formalSettlementMapper.selectList(Wrappers.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 first, LinkedHashMap::new)).values().stream().toList(); List 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 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 invoiceInfos = activeInvoiceInfoVOs(customer.getId()); diff --git a/doc/docs/业务流程/2026-10-19-开票申请支持结算单重复选择与累计额度控制改造说明.md b/doc/docs/业务流程/2026-10-19-开票申请支持结算单重复选择与累计额度控制改造说明.md new file mode 100644 index 00000000..e3a19b2a --- /dev/null +++ b/doc/docs/业务流程/2026-10-19-开票申请支持结算单重复选择与累计额度控制改造说明.md @@ -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、状态回到"部分开票"; +- [ ] 多结算单合并开票:本次开票金额按顺序分摊到各单,结算信息表格展示各单分摊结果; +- [ ] 编辑申请时"可开票金额"为实时额度(扣除其他申请占用、含本申请原分摊回退); +- [ ] 原有链路回归:审批流转、金蝶同步、批量同步状态、留痕记录。