feat: 开票申请支持结算单重复选择并自动分摊控制累计额度
- 结算单候选接口放开未收票过滤,仅保留剩余可开票金额>0,响应新增已申请开票金额 - 保存申请时本次开票金额按选择顺序自动分摊到各结算单登记占用,摊不完报错,删除新建时未收票硬校验 - 详情实时重算已选结算单剩余可开票金额(排除本申请) - 附改造说明与操作文档
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);
|
||||
|
||||
+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());
|
||||
|
||||
@@ -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、状态回到"部分开票";
|
||||
- [ ] 多结算单合并开票:本次开票金额按顺序分摊到各单,结算信息表格展示各单分摊结果;
|
||||
- [ ] 编辑申请时"可开票金额"为实时额度(扣除其他申请占用、含本申请原分摊回退);
|
||||
- [ ] 原有链路回归:审批流转、金蝶同步、批量同步状态、留痕记录。
|
||||
Reference in New Issue
Block a user