From 2ddee7c8951a117e839c10d88dbb0a43d222a5e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Fri, 16 Oct 2026 00:15:40 +0800 Subject: [PATCH 01/47] =?UTF-8?q?feat:=20=E4=BB=98=E6=AC=BE=E7=94=B3?= =?UTF-8?q?=E8=AF=B7=E9=87=91=E8=9D=B6=E7=94=9F=E6=88=90=E5=8D=B3=E5=9B=9E?= =?UTF-8?q?=E6=98=BE=E9=A2=84=E7=BB=93=E7=AE=97=E9=A2=84=E4=BB=98=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=EF=BC=8C=E4=BD=9C=E5=BA=9F/=E8=A7=A3=E9=94=81?= =?UTF-8?q?=E8=81=94=E5=8A=A8=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/PaymentApplicationServiceImpl.java | 43 ++++++++++++++++--- 1 file changed, 38 insertions(+), 5 deletions(-) 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 742b5931..eb8e1d83 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 @@ -421,6 +421,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaUpdate() + .eq(PreSettlementAdvance::getPreSettlementId, entity.getPreSettlementId()) + .eq(PreSettlementAdvance::getAdvanceNo, entity.getPaymentNo()) + .eq(PreSettlementAdvance::getIsDeleted, 0) + .ne(PreSettlementAdvance::getBillStatus, "paid") + .set(PreSettlementAdvance::getBillStatus, TransportBusiConstant.VOIDED)); + refreshPreSettlementAdvanceSummary(entity.getPreSettlementId()); + } + private BigDecimal findPreSettlementPaid(Long preSettlementId) { return preSettlementAdvanceMapper.selectList(Wrappers.lambdaQuery() .eq(PreSettlementAdvance::getPreSettlementId, preSettlementId) From 591b93f1573442d63db4ea508f37636364acf8d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Fri, 16 Oct 2026 00:15:49 +0800 Subject: [PATCH 02/47] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=E4=BB=98?= =?UTF-8?q?=E6=AC=BE=E7=94=B3=E8=AF=B7=E7=94=9F=E6=88=90=E5=8D=B3=E5=9B=9E?= =?UTF-8?q?=E6=98=BE=E9=A2=84=E4=BB=98=E4=BF=A1=E6=81=AF=E6=94=B9=E9=80=A0?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...即回显预结算预付信息(synced提前建档-作废联动清理).md | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 doc/docs/业务流程/2026-10-16-付款申请金蝶生成即回显预结算预付信息(synced提前建档-作废联动清理).md diff --git a/doc/docs/业务流程/2026-10-16-付款申请金蝶生成即回显预结算预付信息(synced提前建档-作废联动清理).md b/doc/docs/业务流程/2026-10-16-付款申请金蝶生成即回显预结算预付信息(synced提前建档-作废联动清理).md new file mode 100644 index 00000000..1cd0cae7 --- /dev/null +++ b/doc/docs/业务流程/2026-10-16-付款申请金蝶生成即回显预结算预付信息(synced提前建档-作废联动清理).md @@ -0,0 +1,53 @@ +# 付款申请金蝶生成即回显预结算预付信息:synced 提前建档 + 作废联动清理 + +> 日期:2026-10-16 +> 关联文档: +> - `doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md`(A103-3 推送与 A103-2 消失解锁) +> - `doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md`(回写任务与 `applyPayApplyResult`) +> - `doc/docs/业务流程/2026-09-20-应付申请与付款申请同步处理全链路业务流程(A102-2下推-A102-4查询-A103-1生成-A103-2回写).md` + +## 1. 背景与问题 + +排查预结算单 YJ2026101500001(已完成预结算审批、付款申请 FKD2026101500001 审批、金蝶付款申请单生成 FKSQ-00000229):预结算单详情页"预付信息"卡片始终空白。 + +根因:`blade_pre_settlement_advance`(预付记录表,详情接口 `advances` 字段的数据源)此前**只在金蝶回写"付款完成"(`kingdeeStatus=paid`)时才写入**(`applyPayApplyResult` → `syncPreSettlementAdvance`)。付款申请推进到"已生成"(synced)阶段不落记录,页面预付信息必须等付款完成才可见;且基本信息区的预付申请/已付金额来自付款申请实时汇总(`fillPaymentApplicationAmounts`),金额有值、明细卡片空白,容易误判。 + +## 2. 改造内容(`PaymentApplicationServiceImpl`) + +| # | 变更点 | 变更前 | 变更后 | +|---|---|---|---| +| 1 | `syncPreSettlementAdvance` | 无参方法,写死 `billStatus="paid"`、已付=申请全额 | 参数化 `(entity, billStatus, paidAmount)`,建档与付款完成共用;按付款申请单号查重幂等 | +| 2 | `doSyncKingdee` synced 落库处 | 仅更新付款申请状态 | **生成成功即建档**:`approved` / 已付 0,同事务落库,预付信息提前回显 | +| 3 | `applyPayApplyResult` paid 分支 | 调原方法 | `paid` / 申请全额(语义不变),已建档时更新为已付款 | +| 4 | `applyPayApplyResult` 勾稽分支(新增) | failed 单据经 A103-2 勾稽确认 synced/paying 时不落预付记录 | 与推送成功同语义,`approved` / 已付 0 建档 | +| 5 | 新增 `voidPreSettlementAdvance` | 无 | 按付款申请单号将未付预付记录置 `voided`(`paid` 记录不联动,付款完成事实不回退),并刷新预结算汇总 | +| 6 | `voidBill`(付款申请作废) | 不清理预付记录 | 联动作废,避免残留记录阻断预结算作废(`PreSettlementServiceImpl#voidBill` 的"存在关联预付申请"拦截) | +| 7 | `unlockBill`(金蝶反审核解锁,含 A103-2 连续两轮消失判定) | 不清理预付记录 | 联动作废,重推生成新单后重新建档 | + +建档/作废后的联动刷新(`refreshPreSettlementAdvanceSummary` 预结算 advance_no/预付金额列、`formal_settlement_source` 预付金额、正式结算付款汇总)复用原有尾部逻辑,无需额外改动。转正式结算时来源表预付金额不再恒为 0。 + +## 3. 状态与联动时序 + +| 付款申请阶段 | 预付记录(blade_pre_settlement_advance) | 页面预付信息 | +|---|---|---| +| 审批通过、未推送 | 不存在 | 空白 | +| 金蝶付款申请单生成成功(synced) | `approved` / 已付 0(提前建档) | **回显**(状态"审批通过") | +| 付款中(paying)/ 勾稽确认已生成 | `approved`(补建或维持) | 回显 | +| 付款完成(paid 回写) | `paid` / 已付全额 | 回显(状态"已付款") | +| 付款申请作废 / 金蝶反审核解锁 | 未付记录置 `voided`(`paid` 不动) | 消失(汇总同步扣减) | + +前端零改动:`pre-settlement-editor.vue` 的 `advanceStatusName` 已含 `approved→审批通过` 映射。 + +## 4. 定时任务影响 + +- **付款结果回写任务(`syncKingdeePaymentResult`)**:零改动。轮询条件基于付款申请自身状态,不依赖预付记录表;paid 回写时命中已有记录更新,不产生重复。 +- **失败重推(`retryKingdeeFailed`)**:failed 阶段未建档,重推成功经 synced 建档一次,幂等查重兜底。 +- **A103-4 付款记录回填、金蝶单据锁补偿**:不涉及预付记录表,无影响。 + +## 5. 行为变化与存量数据 + +| 项 | 说明 | +|---|---| +| 预结算作废拦截提前 | "存在关联预付申请,请先作废预付申请"从付款完成后提前到金蝶生成即生效——作废预结算前需先作废付款申请(预期收紧) | +| 存量单据不自动补 | 改造上线前已 synced 的付款申请(如 FKD2026101500001)不会重推(状态门禁跳过 synced),预付记录要等 paid 回写补建;需立即回显的,手工补一条 `blade_pre_settlement_advance`(`advance_no=付款申请单号`、`bill_status='approved'`、已付 0) | +| 金额模糊匹配触发提前 | synced 建档时同额未付记录的兜底匹配与 paid 阶段同一逻辑,仅触发时点提前;如出现误绑定,可将模糊匹配收敛到 paid 分支 | From a76bd01482fbf712ab060aed32d0c6a94a9b9e99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Fri, 16 Oct 2026 00:33:19 +0800 Subject: [PATCH 03/47] =?UTF-8?q?:feat:=20=E9=87=91=E8=9D=B6=E7=A5=A8?= =?UTF-8?q?=E6=B1=A0=E6=96=B0=E5=A2=9E=E5=AE=A2=E5=95=86=E5=8D=95=E4=BD=8D?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=E5=BC=80=E5=85=B3=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit thirdParty.bfi.invoiceReceipt.customerUnitsOnly(默认 false)控制是否只同步开票单位与受票单位税号均已登记在客商发票信息中的发票,前端票池展示同用此开关 --- .../org/springblade/transport/config/BfiProperties.java | 6 ++++++ doc/nacos/third-party-api.yaml | 2 ++ 2 files changed, 8 insertions(+) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/config/BfiProperties.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/BfiProperties.java index f2e38a9d..760d80ec 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/config/BfiProperties.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/BfiProperties.java @@ -307,6 +307,12 @@ public class BfiProperties { */ private String invoiceTypes = ""; + /** + * 客商单位过滤开关:开启后只同步开票单位(销方)与受票单位(购方)税号均已登记在 + * 客商发票信息(blade_customer_invoice_info)中的发票,前端票池展示查询同用此开关过滤,默认关闭 + */ + private boolean customerUnitsOnly = false; + /** * 同步 single-flight 锁租约秒数,须大于最坏一轮耗时;进程异常退出时锁在租约到期后自动释放 */ diff --git a/doc/nacos/third-party-api.yaml b/doc/nacos/third-party-api.yaml index caa5f604..bdd092fd 100644 --- a/doc/nacos/third-party-api.yaml +++ b/doc/nacos/third-party-api.yaml @@ -118,6 +118,8 @@ thirdParty: incrementDays: ${BFI_INVOICE_RECEIPT_INCREMENT_DAYS:30} # 同步发票种类白名单(逗号分隔票易通编码,如 26,27 数电普票/专票),为空不限制 invoiceTypes: ${BFI_INVOICE_RECEIPT_INVOICE_TYPES:26,27} + # 客商单位过滤开关:开启后只同步开票单位(销方)与受票单位(购方)税号均已登记在客商发票信息中的发票,前端票池展示查询同用此开关过滤 + customerUnitsOnly: ${BFI_INVOICE_RECEIPT_CUSTOMER_UNITS_ONLY:false} # 补偿绑定单轮限量(未绑定登记按发票号码精确补查条数上限) compensateLimit: ${BFI_INVOICE_RECEIPT_COMPENSATE_LIMIT:200} # single-flight 分布式锁租约秒数,须大于最坏一轮耗时;进程异常退出时锁在租约到期后自动释放 From e8a933cf9ebe21b706bfd3b60e11fb25aeb0b037 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Fri, 16 Oct 2026 00:33:28 +0800 Subject: [PATCH 04/47] =?UTF-8?q?:feat:=20=E9=87=91=E8=9D=B6=E7=A5=A8?= =?UTF-8?q?=E6=B1=A0=E5=90=8C=E6=AD=A5=E4=B8=8E=E5=B1=95=E7=A4=BA=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E6=8C=89=E5=AE=A2=E5=95=86=E5=8D=95=E4=BD=8D=E8=BF=87?= =?UTF-8?q?=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 定时同步拉取后按客商发票信息税号集过滤,销/购方税号未同时登记的发票不入镜像(短页判定按原始条数,过滤不影响切片续扫);票池统一分页查询增加双 EXISTS 过滤与同步同开关同口径,收票登记选票与结算单认领两处展示自动生效 --- .../mapper/KingdeeInvoicePoolMapper.java | 10 +-- .../mapper/KingdeeInvoicePoolMapper.xml | 12 ++++ .../impl/InvoiceReceiptServiceImpl.java | 71 +++++++++++++++---- 3 files changed, 74 insertions(+), 19 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.java index 29ddf75d..abdb5c3f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.java @@ -44,11 +44,13 @@ public interface KingdeeInvoicePoolMapper extends BaseMapper /** * 金蝶票据池镜像统一分页查询:仅出可用票(剩余可用金额>0 且未使用),认领/登记标记由 EXISTS 子查询直出 * - * @param page 分页参数 - * @param query 统一查询条件(已在上层完成 trim 与日期格式校验) - * @param tenantId 当前租户 + * @param page 分页参数 + * @param query 统一查询条件(已在上层完成 trim 与日期格式校验) + * @param tenantId 当前租户 + * @param customerUnitsOnly 客商单位过滤开关:开启时只出销/购方税号均登记在客商发票信息中的发票(与定时同步同开关) * @return 统一视图分页 */ IPage selectInvoicePoolPage(IPage page, - @Param("query") KingdeeInvoicePoolQueryDTO query, @Param("tenantId") String tenantId); + @Param("query") KingdeeInvoicePoolQueryDTO query, @Param("tenantId") String tenantId, + @Param("customerUnitsOnly") boolean customerUnitsOnly); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.xml index b25b0ddc..0c1e7969 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.xml +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.xml @@ -39,12 +39,24 @@ AND p.available_amount > 0 AND p.amount_use_status = '0' + + + AND EXISTS (SELECT 1 FROM blade_customer_invoice_info ci + WHERE ci.is_deleted = 0 AND ci.status = 1 AND ci.tenant_id = p.tenant_id AND ci.tax_no = p.saler_tax_no) + AND EXISTS (SELECT 1 FROM blade_customer_invoice_info cj + WHERE cj.is_deleted = 0 AND cj.status = 1 AND cj.tenant_id = p.tenant_id AND cj.tax_no = p.buyer_tax_no) + + + + diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementProjectAdvanceWriteOffService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementProjectAdvanceWriteOffService.java index 9c1d7368..3c8886d8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementProjectAdvanceWriteOffService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementProjectAdvanceWriteOffService.java @@ -14,7 +14,9 @@ import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff; import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO; import java.math.BigDecimal; +import java.util.Collection; import java.util.List; +import java.util.Map; /** * 结算单项目预付核销服务 @@ -24,7 +26,7 @@ import java.util.List; public interface ISettlementProjectAdvanceWriteOffService { /** - * 按合同查询可选项目预付(审批通过且仍有可核销余额)。 + * 按合同查询可选项目预付(审批通过、金蝶已付款且仍有可核销余额)。 */ List listOptions(Long contractId, String excludeSettlementType, Long excludeSettlementId); @@ -39,6 +41,11 @@ public interface ISettlementProjectAdvanceWriteOffService { */ BigDecimal sumBySettlement(String settlementType, Long settlementId); + /** + * 按结算单集合分组统计核销合计(一次 group-by,供批量刷新与列表页使用)。 + */ + Map sumBySettlementIds(String settlementType, Collection settlementIds); + /** * 覆盖保存核销明细,并校验不超过可核销金额与结算单金额。 */ @@ -50,4 +57,14 @@ public interface ISettlementProjectAdvanceWriteOffService { */ void deleteBySettlement(String settlementType, Long settlementId); + /** + * 删除指定结算单集合上的核销明细(核销行转移/结算单作废联动),返回删除行数。 + */ + int deleteBySettlementIds(String settlementType, Collection settlementIds); + + /** + * 按付款申请删除其核销明细(付款申请作废联动),返回受影响的正式结算单id。 + */ + List deleteByPaymentApplicationIds(Collection paymentApplicationIds); + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java index 7e37cd0d..ba94adfd 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java @@ -33,12 +33,14 @@ import org.springblade.transport.mapper.InvoiceApplicationMapper; import org.springblade.transport.mapper.InvoiceApplicationSettlementMapper; import org.springblade.transport.mapper.PreSettlementDetailMapper; import org.springblade.transport.mapper.PreSettlementDetailFeeMapper; +import org.springblade.transport.mapper.PreSettlementAdvanceMapper; import org.springblade.transport.mapper.PreSettlementMapper; import org.springblade.transport.mapper.PreSettlementSummaryFeeMapper; import org.springblade.transport.mapper.PaymentApplicationMapper; import org.springblade.transport.mapper.PaymentApplicationInvoiceMapper; import org.springblade.transport.mapper.PaymentApplicationSettlementMapper; import org.springblade.transport.mapper.ReceiptClaimSettlementMapper; +import org.springblade.transport.mapper.SettlementProjectAdvanceWriteOffMapper; import org.springblade.transport.constant.KingdeeAttachmentConstant; import org.springblade.transport.config.BfiProperties; import org.springblade.transport.constant.KingdeeBfiConstant; @@ -69,6 +71,7 @@ import org.springblade.transport.pojo.entity.InvoiceApplicationInvoice; import org.springblade.transport.pojo.entity.InvoiceApplicationSettlement; import org.springblade.transport.pojo.entity.ContractManage; import org.springblade.transport.pojo.entity.PreSettlement; +import org.springblade.transport.pojo.entity.PreSettlementAdvance; import org.springblade.transport.pojo.entity.PreSettlementDetail; import org.springblade.transport.pojo.entity.PreSettlementDetailFee; import org.springblade.transport.pojo.entity.PreSettlementSummaryFee; @@ -79,9 +82,11 @@ import org.springblade.transport.pojo.entity.ReceiptClaimSettlement; import org.springblade.transport.pojo.entity.ReceivablePayableDetail; import org.springblade.transport.pojo.entity.ReceivablePayableCargoFee; 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.PreSettlementVO; +import org.springblade.transport.pojo.vo.SettlementAdvancePaymentVO; import org.springblade.transport.pojo.vo.SettlementAdjustmentVO; import org.springblade.transport.service.IFormalSettlementService; import org.springblade.transport.service.IInvoiceReceiptService; @@ -113,6 +118,8 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collection; +import java.util.Comparator; +import java.util.Date; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -162,6 +169,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl result = page(page, wrapper.orderByDesc(FormalSettlement::getCreateTime)); - IPage voPage = result.convert(this::toVO); + // 本页应付结算单的项目预付核销合计一次查询,避免逐行 toVO 重算汇总时的 N+1 + Map writeOffAmounts = this.projectAdvanceWriteOffService.sumBySettlementIds( + TransportBusiConstant.SETTLEMENT_BILL_FORMAL, + result.getRecords().stream() + .filter(item -> "payable".equals(item.getSettlementType())) + .map(FormalSettlement::getId).toList()); + IPage voPage = result.convert(item -> this.toVO(item, writeOffAmounts)); fillAttachmentSyncStatus(voPage.getRecords()); fillKingdeeLockStatus(voPage.getRecords()); return voPage; @@ -322,6 +337,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl relationApplicationIds = paymentApplicationSettlementMapper.selectList( Wrappers.lambdaQuery() .select(PaymentApplicationSettlement::getPaymentApplicationId) @@ -545,12 +561,17 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl 新单[{}](明细{}条)", - source.getFormalSettlementNo(), clone.getFormalSettlementNo(), details.size()); + // 核销行迁移:克隆到新单并删除原单行,避免预付可核销额度双占(原单为调整存档,收付口径以新单为准) + List writeOffRows = this.projectAdvanceWriteOffMapper.selectList( + Wrappers.lambdaQuery() + .eq(SettlementProjectAdvanceWriteOff::getSettlementType, TransportBusiConstant.SETTLEMENT_BILL_FORMAL) + .eq(SettlementProjectAdvanceWriteOff::getSettlementId, sourceSettlementId) + .eq(SettlementProjectAdvanceWriteOff::getIsDeleted, 0)); + for (SettlementProjectAdvanceWriteOff writeOffRow : writeOffRows) { + SettlementProjectAdvanceWriteOff cloneWriteOff = Objects.requireNonNull( + BeanUtil.copyProperties(writeOffRow, SettlementProjectAdvanceWriteOff.class)); + resetAuditFields(cloneWriteOff); + cloneWriteOff.setSettlementId(clone.getId()); + this.projectAdvanceWriteOffMapper.insert(cloneWriteOff); + } + this.projectAdvanceWriteOffService.deleteBySettlementIds( + TransportBusiConstant.SETTLEMENT_BILL_FORMAL, List.of(sourceSettlementId)); + this.refreshPaymentSummaries(List.of(sourceSettlementId, clone.getId())); + log.info("结算调整重推克隆正式结算单:原单[{}] -> 新单[{}](明细{}条,核销迁移{}条)", + source.getFormalSettlementNo(), clone.getFormalSettlementNo(), details.size(), writeOffRows.size()); return new CloneResult(clone.getId(), detailIdMap, feeIdMap); } @@ -1601,6 +1644,11 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl money(item.getAllocatedReceiptAmount()), BigDecimal::add))) : Map.of(); + // 项目预付核销合计(仅应付结算单需要,一次 group-by 避免逐单查询) + Map writeOffAmountsBySettlement = hasPayable + ? this.projectAdvanceWriteOffService.sumBySettlementIds( + TransportBusiConstant.SETTLEMENT_BILL_FORMAL, settledIds) + : Map.of(); for (FormalSettlement settlement : settlements) { List relations = relationsBySettlement @@ -1616,7 +1664,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl writeOffAmounts) { FormalSettlementVO vo = FormalSettlementWrapper.build().entityVO(entity); vo.setInvoiceStatus(invoiceStatus(entity.getInvoiceAmount(), entity.getSettlementAmount())); - PaymentSummary summary = calculatePaymentSummary(entity); + PaymentSummary summary = this.calculatePaymentSummary(entity, writeOffAmounts); vo.setAppliedPaymentAmount(summary.appliedAmount()); vo.setPaidAmount(summary.paidAmount()); vo.setRemainingPayableAmount(money(entity.getSettlementAmount()).subtract(summary.paidAmount()) @@ -1837,6 +1893,10 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl) null); + } + + private PaymentSummary calculatePaymentSummary(FormalSettlement settlement, Map writeOffAmounts) { List relations = paymentApplicationSettlementMapper.selectList( Wrappers.lambdaQuery() .eq(PaymentApplicationSettlement::getFormalSettlementId, settlement.getId()) @@ -1854,7 +1914,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl relations, List directApplications, Map relationStatuses, - List sources, BigDecimal receiptClaimPaid) { + List sources, BigDecimal receiptClaimPaid, + Map writeOffAmounts) { } private SettlementAdjustmentVO toAdjustmentVO(SettlementAdjustment entity) { @@ -1937,6 +2006,74 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl listAdvancePayments(List sources, + List writeOffs) { + List records = new ArrayList<>(); + List preSettlementIds = sources.stream().map(FormalSettlementSource::getPreSettlementId) + .filter(Objects::nonNull).distinct().toList(); + if (!preSettlementIds.isEmpty()) { + List advances = this.preSettlementAdvanceMapper.selectList( + Wrappers.lambdaQuery() + .eq(PreSettlementAdvance::getIsDeleted, 0) + .ne(PreSettlementAdvance::getBillStatus, TransportBusiConstant.VOIDED) + .in(PreSettlementAdvance::getPreSettlementId, preSettlementIds)); + // 进度预付行的展示时间取底层付款申请创建时间(advanceNo 即付款申请单号) + List advanceNos = advances.stream().map(PreSettlementAdvance::getAdvanceNo) + .filter(Func::isNotEmpty).distinct().toList(); + Map applicationTimes = advanceNos.isEmpty() ? Map.of() + : this.paymentApplicationMapper.selectList(Wrappers.lambdaQuery() + .select(PaymentApplication::getPaymentNo, PaymentApplication::getCreateTime) + .eq(PaymentApplication::getIsDeleted, 0) + .in(PaymentApplication::getPaymentNo, advanceNos)) + .stream().filter(item -> item.getCreateTime() != null) + .collect(Collectors.toMap(PaymentApplication::getPaymentNo, + PaymentApplication::getCreateTime, (first, second) -> first)); + for (PreSettlementAdvance advance : advances) { + SettlementAdvancePaymentVO record = new SettlementAdvancePaymentVO(); + record.setRecordType(TransportBusiConstant.PAYMENT_TYPE_PROGRESS_ADVANCE); + record.setPaymentNo(advance.getAdvanceNo()); + record.setAppliedAmount(advance.getAppliedAmount()); + record.setPaidAmount(advance.getPaidAmount()); + record.setBillStatus(advance.getBillStatus()); + record.setKingdeeBillNo(advance.getKingdeeAdvanceNo()); + record.setCreateUserName(UserCache.getUserRealName(advance.getCreateUser())); + record.setCreateTime(applicationTimes.getOrDefault(advance.getAdvanceNo(), advance.getCreateTime())); + records.add(record); + } + } + if (Func.isNotEmpty(writeOffs)) { + List applicationIds = writeOffs.stream() + .map(SettlementProjectAdvanceWriteOff::getPaymentApplicationId) + .filter(Objects::nonNull).distinct().toList(); + Map applications = applicationIds.isEmpty() ? Map.of() + : this.paymentApplicationMapper.selectBatchIds(applicationIds).stream() + .collect(Collectors.toMap(PaymentApplication::getId, Function.identity(), + (first, second) -> first)); + for (SettlementProjectAdvanceWriteOff writeOff : writeOffs) { + PaymentApplication application = applications.get(writeOff.getPaymentApplicationId()); + SettlementAdvancePaymentVO record = new SettlementAdvancePaymentVO(); + record.setRecordType(TransportBusiConstant.PAYMENT_TYPE_PROJECT_ADVANCE); + record.setPaymentNo(writeOff.getPaymentNo()); + record.setAppliedAmount(writeOff.getWriteOffAmount()); + record.setPaidAmount(application == null ? null : application.getPaidAmount()); + record.setBillStatus(application == null ? null : application.getApprovalStatus()); + record.setKingdeeBillNo(writeOff.getKingdeeBillNo()); + record.setCreateUserName(UserCache.getUserRealName(application == null + ? writeOff.getCreateUser() : application.getCreateUser())); + record.setCreateTime(writeOff.getPaymentCreateTime() != null + ? writeOff.getPaymentCreateTime() : writeOff.getCreateTime()); + records.add(record); + } + } + records.sort(Comparator.comparing(SettlementAdvancePaymentVO::getCreateTime, + Comparator.nullsLast(Comparator.reverseOrder()))); + return records; + } + private String approvalStatusName(String value) { if (value == null) { return ""; 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 6650afd0..f8860776 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 @@ -456,6 +456,11 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl writeOffSettlementIds = this.projectAdvanceWriteOffService.deleteByPaymentApplicationIds(List.of(entity.getId())); + if (!writeOffSettlementIds.isEmpty()) { + formalSettlementService.refreshPaymentSummaries(writeOffSettlementIds); + } refreshFormalSettlement(entity); } 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 cdf17b02..d832ea60 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 @@ -461,6 +461,9 @@ public class PreSettlementServiceImpl extends BaseServiceImpl 0) { + throw new ServiceException("调整后结算金额不能低于项目预付核销合计" + money(writeOffTotal) + ",请先调减项目预付核销"); + } formal.setSettlementAmount(amount); formal.setLocalSettlementAmount(amount.multiply(formal.getExchangeRate() == null ? BigDecimal.ONE : formal.getExchangeRate())); formal.setRemainingPayableAmount(amount.subtract(money(formal.getPaidAmount())).max(BigDecimal.ZERO)); formalMapper.updateById(formal); + // 重算收付汇总(含核销计入),保持付款状态与剩余可付准确 + formalSettlementService.refreshPaymentSummary(formal.getId()); } /** 涉事事务的自代理:跨方法调用确保 {@link #cloneAndApplyForRepush} 的事务注解生效 */ diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementProjectAdvanceWriteOffServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementProjectAdvanceWriteOffServiceImpl.java index 52e7dbc4..473ee3d3 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementProjectAdvanceWriteOffServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementProjectAdvanceWriteOffServiceImpl.java @@ -14,6 +14,7 @@ import lombok.RequiredArgsConstructor; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.tool.utils.Func; import org.springblade.system.cache.UserCache; +import org.springblade.transport.constant.KingdeeBfiConstant; import org.springblade.transport.constant.TransportBusiConstant; import org.springblade.transport.mapper.PaymentApplicationMapper; import org.springblade.transport.mapper.SettlementProjectAdvanceWriteOffMapper; @@ -28,8 +29,11 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; @@ -56,6 +60,7 @@ public class SettlementProjectAdvanceWriteOffServiceImpl implements ISettlementP .eq(PaymentApplication::getContractId, contractId) .eq(PaymentApplication::getPaymentType, TransportBusiConstant.PAYMENT_TYPE_PROJECT_ADVANCE) .eq(PaymentApplication::getApprovalStatus, TransportBusiConstant.APPROVED) + .eq(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_PAID) .eq(PaymentApplication::getIsDeleted, 0) .orderByDesc(PaymentApplication::getCreateTime)); List options = new ArrayList<>(); @@ -121,6 +126,20 @@ public class SettlementProjectAdvanceWriteOffServiceImpl implements ISettlementP return money(writeOffMapper.sumWriteOffBySettlement(settlementType, settlementId)); } + @Override + public Map sumBySettlementIds(String settlementType, Collection settlementIds) { + if (Func.isEmpty(settlementType) || Func.isEmpty(settlementIds)) { + return Map.of(); + } + Map result = new HashMap<>(); + for (Map row : writeOffMapper.sumWriteOffBySettlementIds(settlementType, settlementIds)) { + if (row.get("settlementId") instanceof Number settlementId && row.get("totalAmount") instanceof BigDecimal totalAmount) { + result.put(settlementId.longValue(), money(totalAmount)); + } + } + return result; + } + @Override @Transactional(rollbackFor = Exception.class) public void replaceWriteOffs(String settlementType, Long settlementId, Long contractId, @@ -157,6 +176,9 @@ public class SettlementProjectAdvanceWriteOffServiceImpl implements ISettlementP if (!TransportBusiConstant.APPROVED.equals(application.getApprovalStatus())) { throw new ServiceException("仅可选择审核通过的项目预付单"); } + if (!KingdeeBfiConstant.KINGDEE_STATUS_PAID.equals(application.getKingdeeStatus())) { + throw new ServiceException("预付单" + Func.toStr(application.getPaymentNo(), "") + "尚未付款,项目预付须金蝶付款完成后方可核销"); + } if (!Objects.equals(contractId, application.getContractId())) { throw new ServiceException("项目预付单与结算单合同不一致"); } @@ -203,6 +225,40 @@ public class SettlementProjectAdvanceWriteOffServiceImpl implements ISettlementP .eq(SettlementProjectAdvanceWriteOff::getSettlementId, settlementId)); } + @Override + @Transactional(rollbackFor = Exception.class) + public int deleteBySettlementIds(String settlementType, Collection settlementIds) { + if (Func.isEmpty(settlementType) || Func.isEmpty(settlementIds)) { + return 0; + } + return writeOffMapper.delete(Wrappers.lambdaQuery() + .eq(SettlementProjectAdvanceWriteOff::getSettlementType, settlementType) + .in(SettlementProjectAdvanceWriteOff::getSettlementId, settlementIds)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public List deleteByPaymentApplicationIds(Collection paymentApplicationIds) { + if (Func.isEmpty(paymentApplicationIds)) { + return List.of(); + } + List rows = writeOffMapper.selectList( + Wrappers.lambdaQuery() + .eq(SettlementProjectAdvanceWriteOff::getIsDeleted, 0) + .in(SettlementProjectAdvanceWriteOff::getPaymentApplicationId, paymentApplicationIds)); + if (rows.isEmpty()) { + return List.of(); + } + writeOffMapper.delete(Wrappers.lambdaQuery() + .in(SettlementProjectAdvanceWriteOff::getPaymentApplicationId, paymentApplicationIds)); + return rows.stream() + .filter(row -> TransportBusiConstant.SETTLEMENT_BILL_FORMAL.equals(row.getSettlementType())) + .map(SettlementProjectAdvanceWriteOff::getSettlementId) + .filter(Objects::nonNull) + .distinct() + .toList(); + } + private BigDecimal money(BigDecimal value) { return value == null ? BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP) : value.setScale(2, RoundingMode.HALF_UP); From 1c9471df8902efd9304f37ede4fc9d0353ac7f50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Sun, 18 Oct 2026 14:31:46 +0800 Subject: [PATCH 31/47] =?UTF-8?q?fix:=20=E4=BB=98=E6=AC=BE=E7=94=B3?= =?UTF-8?q?=E8=AF=B7=E5=8F=91=E5=8F=B7=E5=90=AB=E9=80=BB=E8=BE=91=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E5=8D=A0=E4=BD=8D=E5=B9=B6=E5=8B=BE=E7=A8=BD=E9=87=91?= =?UTF-8?q?=E9=A2=9D=E6=A0=A1=E9=AA=8C=EF=BC=8C=E9=98=B2=E5=8D=95=E5=8F=B7?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=E6=92=9E=E9=87=91=E8=9D=B6=E9=98=B2=E9=87=8D?= =?UTF-8?q?=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nextNo 改为含逻辑删除单据取当天最大序号+1,删除后单号不再复用(此前复用同号推送金蝶时按防重键匹配到旧单转更新,报已提交状态下的单据不允许修改);A103-2 勾稽回填前校验金蝶同号单金额与本单一致,不一致拦截并提示财务核实 --- .../mapper/PaymentApplicationMapper.java | 14 ++++++++++++++ .../impl/PaymentApplicationServiceImpl.java | 19 ++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java index 770dc202..2f8af3b1 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java @@ -21,9 +21,23 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.PaymentApplication; +import java.util.List; + /** 付款申请 Mapper。 @author Chill */ @Mapper public interface PaymentApplicationMapper extends BaseMapper { + + /** + * 按前缀查询单号(含逻辑删除单据):发号占位用,删除后的单号不复用, + * 避免 A103-3/A103-1 推送时防重键(awrp_src_code)撞金蝶已有单据(已提交单据不允许修改)。 + * + * @param pattern 单号匹配模式(如 'FKD20261018%') + */ + @Select("SELECT payment_no FROM blade_payment_application WHERE payment_no LIKE #{pattern}") + List selectPaymentNosIncludingDeleted(@Param("pattern") String pattern); + } 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 f8860776..4b387c48 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 @@ -1040,6 +1040,13 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery().likeRight(PaymentApplication::getPaymentNo, prefix)) + 1); } + private String nextNo() { + String prefix = "FKD" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + // 单号占用含逻辑删除单据:删除后不复用单号,避免推送时防重键撞金蝶已有单据(已提交单据不允许修改) + int max = this.baseMapper.selectPaymentNosIncludingDeleted(prefix + "%").stream() + .map(no -> no.length() > prefix.length() ? no.substring(prefix.length()) : "") + .filter(no -> no.matches("\\d+")) + .map(Integer::valueOf) + .max(Integer::compareTo) + .orElse(0); + return prefix + String.format("%05d", max + 1); + } private BigDecimal nonNegative(BigDecimal value, String name) { if (value == null || value.compareTo(BigDecimal.ZERO) < 0) throw new ServiceException(name + "不能小于0"); return value; } private BigDecimal money(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } private String limit(String value, int length) { if (value != null && value.length() > length) throw new ServiceException("备注不能超过" + length + "个字符"); return value; } From d9379150431941d7e56362d9b7546614b25f1476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Sun, 18 Oct 2026 14:32:01 +0800 Subject: [PATCH 32/47] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=E9=A2=84?= =?UTF-8?q?=E4=BB=98=E5=AE=8C=E6=95=B4=E6=B5=81=E7=A8=8B=E6=A2=B3=E7=90=86?= =?UTF-8?q?-=E6=AD=A3=E5=BC=8F=E7=BB=93=E7=AE=97=E5=8D=95=E9=A2=84?= =?UTF-8?q?=E4=BB=98=E6=95=B4=E5=90=88=E8=AE=BE=E8=AE=A1=E4=B8=8E=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=E5=90=8E=E5=85=A8=E9=93=BE=E8=B7=AF=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...´合设计(进度预付+项目预付核销·金额口径与记录流转).md | 284 ++++++++++++++++++ ...¾路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md | 129 ++++++++ ...¢³理(进度预付-项目预付-正式结算单-付款-金蝶回写).md | 160 ++++++++++ 3 files changed, 573 insertions(+) create mode 100644 doc/docs/业务流程/2026-10-18-正式结算单预付整合设计(进度预付+项目预付核销·金额口径与记录流转).md create mode 100644 doc/docs/业务流程/2026-10-18-预付与正式结算单全链路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md create mode 100644 doc/docs/业务流程/2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md diff --git a/doc/docs/业务流程/2026-10-18-正式结算单预付整合设计(进度预付+项目预付核销·金额口径与记录流转).md b/doc/docs/业务流程/2026-10-18-正式结算单预付整合设计(进度预付+项目预付核销·金额口径与记录流转).md new file mode 100644 index 00000000..6c3805c7 --- /dev/null +++ b/doc/docs/业务流程/2026-10-18-正式结算单预付整合设计(进度预付+项目预付核销·金额口径与记录流转).md @@ -0,0 +1,284 @@ +# 正式结算单预付整合设计(进度预付 + 项目预付核销) + +> 关联文档:同目录《2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md》(改造前现状梳理) +> 设计日期:2026-10-18。范围:正式结算单(FormalSettlement)对两类预付的金额整合与付款记录关联显示。 +> **落地后全链路逻辑见同目录《2026-10-18-预付与正式结算单全链路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md》。** +> **v2 修订**:纳入评审意见——核销计入口径改为"仅已付款预付可核销"(待业务确认)、新增核销行迁移设计(预结算转正式 / 调整重推)、作废联动删行补全(含结算单作废/金蝶删单回调/预结算作废)、调整单校验与刷新、列表页 N+1 规避、对账与调整完成判定影响面、细节修正。见文末修订记录。 + +## 一、背景与目标 + +两条预付线的"单据流"已经打通(进度预付经预结算单继承、项目预付经核销组件落库),但**金额与记录**没有整合进正式结算单: + +| # | 差距 | 现状锚点 | +|---|---|---| +| 1 | 结算合计区域看不到预付抵扣:原金额、已付金额(含预付/核销)、剩余未付金额 | `formal-settlement-editor.vue` 结算合计区只渲染费用明细合计(L99-180) | +| 2 | 项目预付核销金额不计入结算单 `appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus` | `calculatePaymentSummary` payable 分支只加来源预结算的预付快照(`FormalSettlementServiceImpl` L1881-1890) | +| 3 | 付款记录只显示尾款(`settlement_payment`)申请,进度预付/项目预付的已付款记录不关联显示 | `detail()` 查询付款申请固定 `paymentType=结算`(L339) | +| 4 | 核销行没有"流转"设计:预结算转正式、调整重推时核销行丢失或额度双占 | `cloneFormalSettlement` L641-708 不克隆核销行;编辑器新建固定重置 `[]`(L1410) | +| 5 | 各类作废不清理核销行,详情残留、批量刷新会把作废单核销计入已付 | `FormalSettlementServiceImpl.voidBill` L600-609、`voidByKingdee` L613-637、`PreSettlementServiceImpl.voidBill` L443 均无删行 | + +**零表结构变更**:所需字段均已存在(`FormalSettlementSource` 预付快照、`SettlementProjectAdvanceWriteOff` 核销行、`PreSettlementAdvance` 预付台账),本设计只做计算口径、记录流转与展示整合。 + +## 二、已确认决策 + +| 决策 | 结论 | 状态 | +|---|---|---| +| D1 结算合计"扣除"的呈现 | `settlementAmount` **保持全额**(费用合计,开票匹配上限、金蝶 A102-2 应付单金额、结算调整单基线都不变)。结算合计卡片下增加汇总栏:**结算合计(原金额)/ 已付金额(含预付、核销)/ 剩余未付金额**,编辑态与只读态都显示。 | ✅ 已确认 | +| D2 核销计入口径(修订) | **仅已付款的项目预付可核销**:`listOptions` 选单过滤与 `replaceWriteOffs` 保存校验都增加 `kingdeeStatus='paid'` 条件(当前只要求 `approved`,L157)。核销时点钱已付出,核销金额同时计入 `appliedPaymentAmount` 与 `paidAmount` 属实付口径,不存在"审批未付却推成 paid"的失真,与付款记录行展示的底层实付一致。 | ✅ 已确认(方案②) | +| D3 付款记录关联显示 | 后端 detail 新增统一预付付款记录列表 `advancePayments`;前端在只读"付款信息"表格中**合并成一张表**,以"付款类型"列区分:进度预付 / 项目预付核销 / 结算尾款。 | ✅ 已确认 | +| D4 范围 | 只做正式结算单(FORMAL)侧汇总;金蝶预付抵扣字段(`y_billtype/y_billid/y_settleamt`)维持本期不传;应收(receivable)侧不动。**但 PRE→FORMAL 的核销行搬运在范围内**(见 D5)。 | ✅ 已确认 | +| D5 核销行流转原则 | **同一笔预付的核销在链路上只存在一份**:预结算转正式时来源 PRE 核销行转移为本单 FORMAL 行;调整重推时原单核销行迁移到新单。转移 = 删源行 + 落新行,同一事务。 | ✅ 已确认 | +| D6 作废联动删行 | 付款申请作废删其核销行(既有"作废联动"模式);正式结算单作废(`voidBill`/`voidByKingdee`)与预结算单作废(`voidBill`)补 `deleteBySettlement`,消除详情残留与汇总污染。 | ✅ 已确认 | +| D7 结算调整单兜底 | 调整落库(`SettlementAdjustmentServiceImpl` L384-388)后:①校验**核销合计 ≤ 新结算金额**,超出则拒绝并提示先调减核销;②调用 `refreshPaymentSummary` 重算汇总(含核销)。 | ✅ 已确认 | +| D8 影响面确认点 | ① 对账单 `copyHeader` 复制 `paidAmount`(`TransportReconciliationServiceImpl` L1325)→ 对账单已付口径将含核销,**按可接受设计**;② `isSettlementCompleted`(`SettlementAdjustmentServiceImpl` L468-471)判定 → 全额核销的结算单 `paymentStatus=paid`,将**不能再发起结算调整**,按"全额抵扣即结清"设计。历史数据问题明确不考虑。 | ✅ 已确认 | + +## 三、金额口径 + +前提(D2 修订):核销行的底层付款申请必为 `kingdeeStatus='paid'`。 + +``` +结算金额 settlementAmount = Σ 结算合计费用项 settlementAmount(不变) +已付金额 paidAmount = 尾款已付(直挂+关系表, approved) + + Σ 来源预结算 advancePaidAmount(进度预付,已有) + + Σ 本单核销行 writeOffAmount(项目预付,本次新增;行必对应已付款预付) +已申请付款 appliedPaymentAmount = 尾款申请中(直挂+关系表, reviewing) + + Σ 来源预结算 advanceAppliedAmount(已有) + + Σ 本单核销行 writeOffAmount(本次新增) +剩余可付 remainingPayableAmount = max(settlementAmount − paidAmount, 0)(已有公式,输入变了) +付款状态 paymentStatus = unpaid / partial / paid(已有公式,输入变了) +``` + +> 与进度预付口径的准确表述:进度预付在结算单上是"申请(advanceAppliedAmount)/ 实付(advancePaidAmount)"两条快照分开跟踪;项目预付在核销时点已完成实付(D2),故核销额计入实付。付款记录行的"已付款金额"(底层实付)与汇总栏"已付金额"必然一致,不存在同屏矛盾。 + +## 四、后端改动(tms-api / blade-transport) + +### 4.1 汇总计入核销金额 + +**`FormalSettlementServiceImpl.calculatePaymentSummary(settlement, data)`**(L1864-1901): + +`payable` 分支(L1881-1890)在累加来源预付快照之后追加: + +```java +// 项目预付核销计入已付/已申请(D2:核销行底层预付必已付款,核销额即实付抵扣) +BigDecimal writeOffAmount = data.writeOffAmounts() != null + ? data.writeOffAmounts().getOrDefault(settlement.getId(), BigDecimal.ZERO) + : this.projectAdvanceWriteOffService.sumBySettlement( + TransportBusiConstant.SETTLEMENT_BILL_FORMAL, settlement.getId()); +appliedAmount = appliedAmount.add(this.money(writeOffAmount)); +paidAmount = paidAmount.add(this.money(writeOffAmount)); +``` + +**`PaymentSummaryData` record**(L1926-1929)新增组件: + +```java +@param writeOffAmounts 结算单id → 核销金额合计(仅应付结算单;null 表示由纯计算按需查询) +..., BigDecimal receiptClaimPaid, Map writeOffAmounts +``` + +同步修改两处构造调用: +- 批量版 `refreshPaymentSummaries`(L1544-1627):`hasPayable` 时按 `settledIds` **一次**查询核销行分组求和(4.5 新增方法),按结算单分发,避免 N+1; +- 单笔版包装方法(L1839-1858):传 `null` 走按需查询。 + +> 说明:`saveDraft` 保存核销后由 `refreshSettlementAmount`(内部 L1524 刷新)+ 新增的一次 `refreshPaymentSummary` **连续两次完整刷新是有意为之**——核销校验依赖 `refreshSettlementAmount` 重算后的最终结算金额,故 `replaceWriteOffs` 只能排在其后,二次刷新不能合并省略。后续优化前先读此注释。 + +### 4.2 保存核销后刷新汇总 + +**`FormalSettlementServiceImpl.saveDraft`**(L546-553):`replaceWriteOffs` 保持在 `refreshSettlementAmount`(L546)之后(校验用最终结算金额),其后追加: + +```java +projectAdvanceWriteOffService.replaceWriteOffs(...); // L548-553 原位置不动 +this.refreshPaymentSummary(settlement.getId()); // 新增:核销行计入汇总 +``` + +### 4.3 核销行流转(D5) + +**通用删行方法**(`ISettlementProjectAdvanceWriteOffService` 新增,事务内逻辑删除): + +```java +/** 删除指定结算单上的核销行(配合转移/作废场景),返回被删行数 */ +int deleteBySettlementIds(String settlementType, Collection settlementIds); +/** 按付款申请删除核销行,返回受影响的正式结算单id(settlementType=formal,供汇总刷新) */ +List deleteByPaymentApplicationIds(Collection paymentApplicationIds); +``` + +**预结算转正式(`saveDraft` 完整建单路径)**: +1. 前端:编辑器 `confirmCandidates` 选定来源预结算后,对每个新选 `preSettlementId` 复用**既有** `preSettlement.getDetail(id)` 取 `projectAdvanceWriteOffs` 预填进本单核销组件(去重、金额 Number 化),用户可在弹窗中增删改; +2. 后端 `saveDraft`:在 `replaceWriteOffs`(L548)**之前**执行 `deleteBySettlementIds("pre", request.getSourcePreSettlementIds())`——先释放来源占用,`replaceWriteOffs` 的"不超可核销余额"校验才能通过预填金额;随后 `replaceWriteOffs` 将预填行落为本单 FORMAL 行。同一事务,失败整体回滚。 +3. 轻量标记路径(`PreSettlementServiceImpl.formalSettlement(id)`,L550):不产生正式单实体,PRE 核销行原地保留(预结算实体仍是载体),待后续真正建正式单时按上述规则转移。 + +**调整重推(`cloneFormalSettlement`,L641-708)**:与既有"新单付款口径归零"哲学(L658 注释)冲突时以**额度正确性优先**—— +1. 在来源克隆循环后,克隆原单活跃核销行到新单(`settlementId` 换新、`resetAuditFields`); +2. `deleteBySettlementIds("formal", List.of(sourceSettlementId))` 删原单核销行(原单 `adjustStatus=adjusted` 为历史存档,已付归零以新单为准,文档明示); +3. `refreshPaymentSummaries(List.of(原单id, 新单id))` 一次批量刷新两单(新单头 L662-664 置零的 applied/paid 由重算纠正为核销额)。 + +> 新单由克隆直接 `approved`(L647),无草稿编辑机会,故必须迁移而非"让用户重新核销"。 + +### 4.4 作废联动删行(D6) + +| 触发点 | 改动 | +|---|---| +| `PaymentApplicationServiceImpl.voidBill`(L446-460,L458 `voidPreSettlementAdvance` 后) | `deleteByPaymentApplicationIds(List.of(entity.getId()))` → 返回的 formal ids 非空则 `formalSettlementService.refreshPaymentSummaries(ids)` | +| `FormalSettlementServiceImpl.voidBill`(L600-609) | `deleteBySettlementIds("formal", List.of(id))` | +| `FormalSettlementServiceImpl.voidByKingdee`(L613-637) | 同上 | +| `PreSettlementServiceImpl.voidBill`(L443) | `deleteBySettlementIds("pre", List.of(id))`(预结算作废前置"先作废预付"校验不受影响,核销行不阻断) | + +金蝶反审核自动解锁置 `failed`(未作废)的场景**不删行**:预付申请仍是 `approved`,已核销行继续计入;人工作废后经上表路径回落。 + +存量数据提示:v2 之前保存的核销行可能对应"approved 未 paid"的预付,本次不做数据修正,如需口径清洗另出脚本(`replaceWriteOffs` 校验只约束新保存)。 + +### 4.5 核销服务与 Mapper + +**`ISettlementProjectAdvanceWriteOffService` / `SettlementProjectAdvanceWriteOffServiceImpl`**: +- 4.3 两个删除方法(见上); +- `Map sumBySettlementIds(String settlementType, Collection settlementIds)`——一次 group-by,供批量刷新与列表页(4.7)使用。 + +**`SettlementProjectAdvanceWriteOffMapper.java`(接口)+ `SettlementProjectAdvanceWriteOffMapper.xml`**:新增 `sumWriteOffBySettlementIds`(`is_deleted=0` + `settlement_type` + `settlement_id in (...)`,`GROUP BY settlement_id`),与既有 `sumWriteOffBySettlement`(XML L27-33)口径一致。 + +**选单与校验加 paid 条件(D2)**: +- `listOptions`(ServiceImpl L48-81):查询条件追加 `kingdeeStatus='paid'`; +- `replaceWriteOffs`(L124-193):在 `approvalStatus=approved` 校验(L157-159)后追加 `kingdeeStatus='paid'` 校验,提示文案"项目预付已付款后方可核销"。 + +### 4.6 detail 返回统一预付付款记录 + +**新 VO `SettlementAdvancePaymentVO`**(`blade-transport-api/pojo/vo/`,仿 `ProjectAdvanceWriteOffOptionVO` 风格,含 BladeX 许可头): + +| 字段 | 说明 | +|---|---| +| `recordType` | `progress_advance` 进度预付 / `project_advance` 项目预付核销 | +| `paymentNo` | 进度预付 = `PreSettlementAdvance.advanceNo`(FKD 单号);项目预付 = 核销行 `paymentNo` | +| `appliedAmount` | 进度预付 = `appliedAmount`;项目预付 = 核销行 `writeOffAmount`(**本次核销金额**,非付款申请金额) | +| `paidAmount` | 进度预付 = `paidAmount`;项目预付 = 底层付款申请 `paidAmount`(D2 下 = 核销额,同屏一致) | +| `billStatus` | 进度预付 = 台账 `billStatus`;项目预付 = 付款申请 `approvalStatus` | +| `kingdeeBillNo` | 进度预付 = `kingdeeAdvanceNo`;项目预付 = 核销行 `kingdeeBillNo` | +| `createUserName` / `createTime` | `UserCache.getUserRealName(createUser)`(AGENTS.md 6.9);**createTime 统一取底层付款申请创建时间**:项目行用核销行已回填的 `paymentCreateTime`(`listBySettlement` 现成),进度行按 `advanceNo in (...)` 批量反查 `PaymentApplication.createTime`(一次 in 查询)。列表排序与展示均用该时间 | + +**`FormalSettlementVO`** 新增 `@TableField(exist = false) private List advancePayments;`(L74 `paymentApplications` 旁)。 + +**`FormalSettlementServiceImpl.detail`**(L307-352)在加载 `projectAdvanceWriteOffs`(L323-324)后组装: + +```java +vo.setAdvancePayments(this.listAdvancePayments(sources, vo.getProjectAdvanceWriteOffs())); +``` + +`listAdvancePayments` 私有方法: +1. **进度预付**:`PreSettlementAdvance` 按 `preSettlementId in 来源预结算ids`、`billStatus != 'voided'` 查询(注入 `PreSettlementAdvanceMapper`,与同类 `sourceMapper` 直连模式一致); +2. **项目预付**:遍历核销行,`paymentApplicationMapper.selectBatchIds` 批量取底层付款申请回填已付金额/状态; +3. 合并后按"付款申请创建时间"降序(列表默认排序规范)。 + +尾款记录(`paymentApplications`)查询不动。 + +### 4.7 列表页 N+1 规避(D8/评审#5) + +`selectPage` L209-210 逐行 `toVO`,而 `toVO`(L1827-1837)内部 `calculatePaymentSummary` 已是每行 3-4 次查询,**不能再叠加每行一次核销求和**: + +- 新增重载 `toVO(FormalSettlement entity, Map writeOffAmounts)`,把 map 置入 `PaymentSummaryData`;单参版委托 `toVO(entity, null)`(按需查询,`detail` 等单笔场景不受影响); +- `selectPage`:本页 `payable` 结算单 ids 一次 `sumBySettlementIds` 得 map,`result.convert(item -> this.toVO(item, writeOffMap))`。 + +### 4.8 结算调整单兜底(D7) + +**`SettlementAdjustmentServiceImpl`**(L384-388 直改金额处): +1. 落库前校验:`projectAdvanceWriteOffService.sumBySettlement("formal", formal.getId())` ≤ 新 `settlementAmount`,超出抛"核销合计超过调整后结算金额,请先调减项目预付核销"; +2. 落库后:`formalSettlementService.refreshPaymentSummary(formal.getId())`(注入 `IFormalSettlementService`,当前仅有 `formalMapper`)。 + +### 4.9 后端改动文件清单 + +| 文件 | 改动 | +|---|---| +| `service/impl/FormalSettlementServiceImpl.java` | `calculatePaymentSummary` 计入核销;`PaymentSummaryData` 加组件;`saveDraft` 补刷新 + 转移来源 PRE 行;`cloneFormalSettlement` 克隆/迁移核销行;`voidBill`/`voidByKingdee` 删行;`detail` 补 `advancePayments` + 新私有 `listAdvancePayments`;`toVO` 加 map 重载;`selectPage` 批量供数;注入 `PreSettlementAdvanceMapper` | +| `service/ISettlementProjectAdvanceWriteOffService.java` + `impl` | 新增 `deleteBySettlementIds`、`deleteByPaymentApplicationIds`、`sumBySettlementIds`;`listOptions`/`replaceWriteOffs` 加 `kingdeeStatus='paid'` 条件 | +| `mapper/SettlementProjectAdvanceWriteOffMapper.java` + `.xml` | 新增 `sumWriteOffBySettlementIds`(接口 + XML 成对) | +| `service/impl/PaymentApplicationServiceImpl.java` | `voidBill` 联动删核销行 + 刷新汇总 | +| `service/impl/PreSettlementServiceImpl.java` | `voidBill` 删 PRE 核销行 | +| `service/impl/SettlementAdjustmentServiceImpl.java` | 调整落库前核销合计校验 + 落库后刷新汇总;注入 `IFormalSettlementService` | +| `pojo/vo/SettlementAdvancePaymentVO.java`(新增) | 统一预付付款记录 VO | +| `pojo/vo/FormalSettlementVO.java` | 加 `advancePayments` | + +## 五、前端改动(tms-erp-web) + +### 5.1 结算合计汇总栏 + +**`formal-settlement-editor.vue`**:结算合计 section-card(L99-180)表格下方加汇总条(仅 `form.settlementType === 'payable'` 显示): + +``` +结算合计(原金额):form.settlementAmount +已付金额(含预付、核销):见下 +剩余未付金额:max(结算合计 − 已付, 0) +``` + +- 编辑态:`Σ sources.advancePaidAmount + Σ projectAdvanceWriteOffs.writeOffAmount`(选预结算 `confirmCandidates` 与勾选核销行时实时联动;草稿态无尾款申请,公式完备;D2 下核销行必对应已付预付,计入实付口径成立); +- 只读态:`form.paidAmount`(已含尾款 + 进度预付 + 核销的落库值)。 + +### 5.2 付款记录合并表 + +只读"付款信息"应付表格(L580-612)数据源改为合并计算属性: + +```js +paymentRecords() { + const advance = (this.advancePayments || []).map(r => ({ ...r, typeName: this.recordTypeName(r.recordType) })); + const tail = (this.paymentApplications || []).map(r => ({ ...r, typeName: r.paymentTypeName })); + return [...advance, ...tail]; +} +// recordTypeName: progress_advance→'进度预付',project_advance→'项目预付核销' +``` + +- 列:付款类型(`typeName`,尾款行兼容原 `paymentTypeName`)/ 单据号 / **申请/核销金额**(预付行的"申请金额"实为核销金额,列名合并以免误导)/ 已付款金额 / 单据状态 / 金蝶单据号 / 创建人 / 创建时间(= 付款申请创建时间,见 4.6)。原"付款方式"列预付行无值,显示 `-`; +- 单据状态名映射**提取公共工具**(与 `pre-settlement-editor.vue` L2879 `advanceStatusName` 去重):新建 `src/utils/settlementStatus.js` 导出 `settlementStatusName(value)`(reviewing 审核中、approved 已审批、paid 已付款、returned 已驳回、voided 已作废、draft 草稿),两个编辑器共用,避免状态名不一致; +- `loadFormalDetail`(L1360-1379)回填 `this.advancePayments = data.advancePayments || []`,`readonly` 重置时清空。 + +### 5.3 转正式预填核销行 + +`confirmCandidates`(L1729-1752)选定来源预结算后:对每个新选 `preSettlementId` 调用既有 `preSettlement` detail 接口,取 `projectAdvanceWriteOffs` 预填进本单核销组件(与已有行按 `paymentApplicationId` 去重、金额 Number 化);保存时随 payload 提交,后端按 4.3 转移来源行。 + +### 5.4 前端不动的部分 + +- `project-advance-write-off.vue`:选单过滤、本次核销金额校验链路已完整(L198-288),"已付款"过滤条件由后端 `listOptions` 落实,组件不改; +- 列表页 `formal-settlement.vue`:"申请付款金额(含预付)/已收已付合计(含预付)/剩余可付金额"列直接受益于后端口径修正,不改; +- 尾款申请默认可付金额 `payableOfFormalSettlement` 已扣核销额(`PaymentApplicationServiceImpl` L2115-2122),不改。 + +## 六、边界场景 + +| 场景 | 行为 | +|---|---| +| 草稿保存核销 | 已付/已申请/剩余可付/付款状态立即生效(D2,行必对应已付预付),列表可见 | +| 底层项目预付申请作废 | 联动删核销行 + 批量刷新 → 已付回落(4.4) | +| 底层预付金蝶推送 `failed` / 反审核解锁 | 不删行,核销继续计入(预付申请仍 approved);人工作废后回落 | +| 金蝶付款完成回写(进度预付) | `syncOnePreSettlementAdvance` 刷新来源快照 → `refreshPaymentSummariesForPreSettlement` → 批量版自动计入核销(4.1 供数) | +| 正式结算单作废 / 金蝶删单回调 | 删除本单核销行(4.4),额度释放、详情无残留 | +| 预结算单作废 | 删除 PRE 核销行(4.4) | +| 预结算转正式(尾款结算) | 前端预填 + 后端先删来源 PRE 行再落 FORMAL 行(4.3),额度单份占用 | +| 轻量标记转正式(无在途预付) | PRE 核销行原地保留,待建正式单时转移(4.3) | +| 结算调整重推 | 核销行迁移到新单,原单删行并以新单为准(4.3) | +| 结算调整单调低金额致核销超限 | 落库前拦截,提示先调减核销(4.8) | +| 全额核销后发起调整 | `isSettlementCompleted` 拦截(现状逻辑),业务含义=已结清(D8 待确认) | +| 对账单生成 | 复制的 `paidAmount` 含核销口径(D8 待确认) | +| 核销合计 + 尾款申请超结算金额 | 双向封顶:核销合计 ≤ 结算金额(`replaceWriteOffs` L184-186);尾款默认额已扣核销额;`remainingPayableAmount` min 0 | +| 开票 | `settlementAmount` 全额不变,发票匹配上限(≤结算金额)不受影响(D1) | +| 应收(receivable)结算单 | 走收款认领分支,payable 分支的新增逻辑不触达 | + +## 七、测试要点 + +1. `saveDraft` 带核销行 → `appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus` 正确;编辑重复保存不重复累计(全删重插)。 +2. D2:`approved` 未 `paid` 的项目预付不出现在选单(`listOptions`),手工提交被 `replaceWriteOffs` 拒绝。 +3. 底层预付作废 → 核销行删除、汇总回落、列表金额一致;作废的正式结算单不再出现在批量刷新的已付统计中。 +4. 预结算转正式:预填行保存成功(先删 PRE 行再校验余额的顺序);同一预付在 PRE/FORMAL 侧无双份核销。 +5. 调整重推:新单含核销行且汇总正确(头字段从置零被重算纠正),原单行删除、汇总归零。 +6. 结算调整单:调低金额低于核销合计被拒;合法调整后 `refreshPaymentSummary` 生效。 +7. `detail` 返回 `advancePayments`:进度预付行(排除 voided)+ 项目预付行(回填底层实付),按付款申请创建时间倒序。 +8. 列表页 SQL 次数:本页核销求和恒定 1 次(不随行数增长)。 +9. 应收结算单、无预付结算单回归:汇总与现状一致。 +10. 前端:编辑态汇总栏随选预结算/核销行实时变化;只读态合并付款记录表三类型展示、状态名与预结算页一致;应收侧页面不出现汇总栏。 + +## 八、范围外(明确不做) + +- 预结算(PRE)侧自身收付汇总计入核销(本次仅 FORMAL); +- 金蝶预付抵扣字段 `y_billtype / y_billid / y_settleamt`(文档 TODO,本期不传); +- 应收侧预收/收款认领汇总; +- 存量"approved 未 paid 核销行"的数据修正脚本(如需另出); +- 遗留接口 `/pre-settlement/apply-advance` 等下线评估(见《2026-10-18-预付完整流程梳理》第五节)。 + +## 九、修订记录 + +| 版本 | 日期 | 内容 | +|---|---|---| +| v1 | 2026-10-18 | 初版:汇总计入、作废联动(仅付款申请侧)、detail 预付记录、前后端展示。 | +| v2 | 2026-10-18 | 评审修订:D2 改为"仅已付款可核销"(待确认);新增 D5 核销行流转(预结算转正式预填+转移、调整重推迁移)、D6 作废删行补全(结算单作废/金蝶回调/预结算作废)、D7 调整单校验+刷新、D8 对账与调整完成判定影响面(待确认);列表页 N+1 规避;文件清单补 Mapper 接口;合并表列名"申请/核销金额";状态名公共工具;createTime 统一为付款申请创建时间;点明 saveDraft 双刷新为有意设计。 | diff --git a/doc/docs/业务流程/2026-10-18-预付与正式结算单全链路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md b/doc/docs/业务流程/2026-10-18-预付与正式结算单全链路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md new file mode 100644 index 00000000..25cf41f8 --- /dev/null +++ b/doc/docs/业务流程/2026-10-18-预付与正式结算单全链路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md @@ -0,0 +1,129 @@ +# 改造后全链路逻辑梳理(预付 × 正式结算单) + +> 关联文档:同目录《2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md》(改造前现状梳理)、同目录《2026-10-18-正式结算单预付整合设计(进度预付+项目预付核销·金额口径与记录流转).md》(v2 设计与决策) +> 梳理日期:2026-10-18。本文描述**改造落地后**的完整逻辑,前端到后端各环节。 + +## 一、全景 + +``` +【线A 进度预付】付款申请(progress_advance) ─A103-3→ 金蝶付款 ─回写→ 预结算预付汇总 + └→ 尾款结算建正式单:继承预付快照 → 结算合计(全额)+汇总栏(已付含预付) → 审批 ─A102-2→ 财务应付单 + └→ 尾款付款(默认额已扣核销) ─A103-1→ 回写paid → 付款记录合并表 + +【线B 项目预付】付款申请(project_advance) ─A103-3→ 金蝶付款 ─回写→ kingdeeStatus=paid + └→ 正式结算单「核销」(仅paid可选) → 核销计入已付 → 审批 ─A102-2→ 财务应付单(全额) + └→ 尾款付剩余 → 后续与正式结算单流程完全一致 +``` + +**核心口径**(`FormalSettlementServiceImpl.calculatePaymentSummary` payable 分支,全系统唯一): + +``` +结算金额 settlementAmount = Σ 结算合计费用项(全额,不扣预付/核销——开票与金蝶应付基线) +已申请 appliedPaymentAmount = 尾款reviewing(直挂+关系表) + Σ来源预付快照 + Σ核销 +已付 paidAmount = 尾款approved实付 + Σ来源预付快照 + Σ核销 +剩余可付 remainingPayableAmount = max(结算金额 − 已付, 0) +付款状态 paymentStatus = unpaid / partial / paid +``` + +D2 决策:仅**金蝶已付款**(`kingdeeStatus='paid'`)的项目预付可核销,核销额计入已付属实付口径。 + +--- + +## 二、线A:进度预付(挂预结算单) + +### A1 预付与付款(改造前已有) + +- 前端 `pre-settlement.vue`「预付申请」→ `payment-application-form?paymentType=progress_advance` +- 后端 `PaymentApplicationServiceImpl`:审批 → AFTER_COMMIT `KingdeeApprovalSyncListener` → A103-3 直推金蝶 → 提前建档 `PreSettlementAdvance`(advanceNo=FKD 单号) +- 金蝶付款后 `PaymentKingdeeResultTask`(A103-2/A103-4)回写 paid +- 联动:`syncOnePreSettlementAdvance` 刷新预结算预付汇总(`advanceAppliedAmount/advancePaidAmount`)→ 同步刷新引用它的 `FormalSettlementSource` 快照 → `refreshPaymentSummariesForPreSettlement`(正式单已付跟着涨) + +### A2 尾款结算 → 正式结算单表单(前端) + +- `applyInitialSources`(列表跳转带入)/ `confirmCandidates`(弹窗追加):拉每张预结算 detail,合并明细与费用项 → `summaryTotal` → `form.settlementAmount`(**全额费用合计,不扣预付**) +- **汇总栏(新增)**:仅应付显示;编辑态实时 `已付 = Σ来源预结算.advancePaidAmount + Σ核销行`,`剩余 = 原金额 − 已付`;只读态 `已付 = form.paidAmount`(落库值,含尾款) + +### A3 保存 `saveDraft`(后端,同一事务) + +1. 来源合并(同合同同币种校验)→ `FormalSettlementSource` 快照(含预付汇总) +2. `refreshSettlementAmount`:settlementAmount=Σ费用项 → 内部刷一次汇总 +3. **新增**:`deleteBySettlementIds(PRE, 来源预结算ids)`——先转移来源预结算的项目预付核销行(释放额度) +4. `replaceWriteOffs`(服务端复校验:paid、同合同、≤可核销余额、合计≤结算金额)→ **新增** `refreshPaymentSummary`(核销落库后必须再刷一次,第 2 步的刷新覆盖不到核销行) + +### A4 审批 → 同步金蝶 + +- `approve` → `KingdeeApprovalSyncListener` → **A102-2 财务应付单,金额=全额结算金额** → lockBusiData 锁单 + A902 附件 + +### A5 只读详情(付款记录关联显示,本次核心新增) + +- 后端 `detail` 新增 `advancePayments`:进度预付行 = 来源预结算的 `PreSettlementAdvance` 台账(排除 voided;appliedAmount=申请预付、paidAmount=实付;createTime 统一取**付款申请创建时间**,按 advanceNo 反查) +- 前端 `paymentRecords` 合并表:**进度预付 / 项目预付核销 / 结算尾款** 三类型一张表;状态名走公共 `src/utils/settlementStatus.js` + +### A6 尾款付款 + +- 前端 `formal-settlement.vue`「付款申请」→ `referenceAmount` 默认可付 = **结算金额 − 累计尾款申请 − 核销** +- `settlement_payment` → 前置校验已有财务应付单 → A103-1 应付下推 → 回写 paid → `FormalSettlementPayment(final)` + 汇总刷新 → `paymentStatus=paid` + +--- + +## 三、线B:项目预付(挂项目+承运商合同) + +| 环节 | 逻辑 | +|---|---| +| B1 申请 | 付款申请 `project_advance`:选项目+承运商合同、不挂结算单;`validateQuota` 校验项目额度(fundLimit+临时额度)与客户额度(maxCreditLimit) | +| B2 付款 | 审批 → A103-3 直推 → 金蝶付款 → A103-2 回写 **`kingdeeStatus='paid'`**(核销前置条件) | +| B3 核销·选单(编辑器) | 全新单:核销组件「选择预付单」→ `project-advance-write-off-options`(后端过滤:同合同 + `approved` + **`paid`** + 有余额);**预结算转正式入口(新增)**:`confirmCandidates`/`applyInitialSources` 选定来源后自动预填来源预结算已保存的核销行,可增删改 | +| B3 核销·录额(前端) | 手录本次核销金额:>0、≤可核销余额、合计≤结算金额(`project-advance-write-off.vue` 既有校验链) | +| B4 保存 | 同 A3 第 3-4 步:先转移源 PRE 行 → `replaceWriteOffs` 复校验落 FORMAL 行 → 刷新汇总,核销计入已付 | +| B5 审批 | A102-2 按全额结算金额生成财务应付单(金蝶预付抵扣字段 `y_billtype/y_billid/y_settleamt` 本期不传) | +| B6 付款记录 | 合并表显示项目预付核销行:`申请/核销金额`=本次核销额,`已付款金额`=底层付款申请实付(paid 前置下两者相等,同屏一致) | +| B7 尾款 | 默认可付已扣核销 → 已核销部分不会被重复支付;剩余走正常尾款流程 | +| B8 差异 | 后续金额差异按既有规则走**结算调整单**(见横切 5) | + +--- + +## 四、横切环节 + +| # | 环节 | 逻辑 | +|---|---|---| +| 1 | 列表页 | `selectPage` 本页 payable 一次 `sumWriteOffBySettlementIds`(group-by)→ `toVO(map)` 重算 → 列「申请付款金额(含预付)/已收已付合计(含预付)/剩余可付/收付款状态」直接反映核销;SQL 不随行数增长 | +| 2 | 汇总刷新触发点 | 保存、审批、金蝶付款回写、预结算预付快照联动、作废、调整——全部收敛到 `calculatePaymentSummary`,口径唯一 | +| 3 | 作废联动(新增) | 付款申请作废 → 删其核销行 + 刷新受影响正式单汇总(已付回落);正式单作废(`voidBill`)与金蝶删单回调(`voidByKingdee`)→ 删本单核销行(额度释放);预结算作废 → 删 PRE 核销行 | +| 4 | 核销行流转(新增) | 预结算转正式 = 先删源 PRE 行再落 FORMAL 行(同事务,同一预付的核销全链路只存在一份);调整重推 = 核销行克隆到新单 + 删原单行 + `refreshPaymentSummaries(两单)`(原单为调整存档,收付口径以新单为准) | +| 5 | 结算调整单(新增兜底) | 调整落库前校验**核销合计 ≤ 新结算金额**(超出拒绝并提示先调减核销);落库后 `refreshPaymentSummary` 重算 | +| 6 | 对账单 | `copyHeader` 复制 `paidAmount` → 对账单已付口径含核销(已确认接受) | +| 7 | 全额核销=结清 | `paymentStatus=paid` → `isSettlementCompleted` 拦截,不允许再发起结算调整(已确认接受) | +| 8 | 开票 | 始终按**全额 settlementAmount** 匹配(Σ匹配 ≤ 结算金额),预付/核销不影响发票 | +| 9 | 金蝶预付抵扣 | `y_billtype / y_billid / y_settleamt` 本期不传(见《2026-10-18-预付完整流程梳理》TODO) | +| 10 | 历史数据 | 本次不考虑存量"approved 未 paid"核销行的清洗 | + +--- + +## 五、改动文件索引 + +**后端(tms-api)** + +| 文件 | 内容 | +|---|---| +| `FormalSettlementServiceImpl` | 汇总计入核销(单笔/批量/toVO 重载)、saveDraft 转移+刷新、cloneFormalSettlement 迁移、voidBill/voidByKingdee 删行、detail.advancePayments、selectPage 批量供数 | +| `SettlementProjectAdvanceWriteOffServiceImpl` | listOptions/replaceWriteOffs 加 paid 条件;sumBySettlementIds / deleteBySettlementIds / deleteByPaymentApplicationIds | +| `SettlementProjectAdvanceWriteOffMapper.java/.xml` | sumWriteOffBySettlementIds | +| `PaymentApplicationServiceImpl` | voidBill 联动删核销行+刷新 | +| `PreSettlementServiceImpl` | voidBill 删 PRE 核销行 | +| `SettlementAdjustmentServiceImpl` | 调整前核销合计校验 + 调整后刷新汇总 | +| `SettlementAdvancePaymentVO`(新)/ `FormalSettlementVO` | 统一预付付款记录 VO / advancePayments 字段 | + +**前端(tms-erp-web)** + +| 文件 | 内容 | +|---|---| +| `formal-settlement-editor.vue` | 结算合计汇总栏;付款记录合并表;confirmCandidates/applyInitialSources 预填核销行 | +| `src/utils/settlementStatus.js`(新) | 状态名/记录类型名公共映射(预结算编辑器同步使用) | + +--- + +## 六、已知口径与风险 + +1. 尾款默认可付额(`payableOfFormalSettlement`)= 结算金额 − 累计尾款申请 − 核销,**不扣进度预付已付部分**(改造前既有口径,本次未动);进度预付占大头时默认额可能偏大,提交后由审批环节把关。 +2. 正式结算单 `paidAmount` 现含核销,下游消费该字段的报表/对账口径随之变化(对账已确认接受)。 +3. 金蝶侧预付抵扣字段未传,金蝶报表与本系统"已付含核销"存在时间差。 diff --git a/doc/docs/业务流程/2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md b/doc/docs/业务流程/2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md new file mode 100644 index 00000000..63ac715b --- /dev/null +++ b/doc/docs/业务流程/2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md @@ -0,0 +1,160 @@ +# 预付完整流程梳理 + +> 范围:预付(进度预付 / 项目预付)→ 正式结算单 → 付款 → 金蝶回写等后续流程,前端到后端全链路。 +> 依据代码现状梳理(tms-api / tms-erp-web),梳理日期:2026-10-18。 + +## 一、总览 + +预付有**两条线**,最终在金蝶(星瀚 BFI)汇合: + +``` +【进度预付线】 【项目预付线】 +预结算单(审批通过,应付) 付款申请-项目预付(挂项目/合同,不挂结算单) + └─「预付申请」→ 付款申请(progress_advance) └─ 校验项目额度/客户额度 → A103-3 → 金蝶付款 + └─ 审批 → A103-3 直推金蝶付款申请单 └─ 事后在预结算/正式结算单上「项目预付核销」 + └─ 提前建档 PreSettlementAdvance(approved) + └─ 定时回写 A103-2/A103-4 → paid + └─ 预付记录置 paid + 预结算单预付汇总刷新 + └─「尾款结算」→ 正式结算单(继承预付汇总) → 审批 → A102-2 财务应付单 + └─「申请付款」→ 付款申请(settlement_payment) → A103-1 应付下推 + └─ 回写 paid → FormalSettlementPayment + 结算单付款汇总(unpaid→partial→paid) +``` + +### 金蝶接口速查 + +| 接口 | 用途 | 触发方 | +|---|---|---| +| A103-3 | 预付款直接保存金蝶付款申请单(不绑应付单) | 付款申请(progress_advance / project_advance)同步金蝶 | +| A103-1 | 应付下推生成金蝶付款申请单 | 付款申请(settlement_payment)同步金蝶 | +| A103-2 | 付款申请单查询(幂等预检 + 结果回写) | 推送前预检、定时回写任务 | +| A103-4 | 付款处理查询(回填实付款记录) | 定时回写任务 | +| A102-2 | 财务应付单保存 | 正式结算单「同步金蝶」 | +| A102-4 | 财务应付单查询(防重预检 / 反查) | 推送前预检、下推反查付款计划分录 | +| A902 | 附件上传 | 主单推送成功后联带上传 | +| lockBusiData | 金蝶单据锁定 | 推送成功事务提交后 | + +## 二、涉及的主要代码与数据表 + +### 后端(tms-api / blade-transport) + +| 内容 | 位置 | +|---|---| +| 预结算单 | `controller/PreSettlementController.java`、`service/impl/PreSettlementServiceImpl.java` | +| 付款申请 | `controller/PaymentApplicationController.java`、`service/impl/PaymentApplicationServiceImpl.java` | +| 正式结算单 | `controller/FormalSettlementController.java`、`service/impl/FormalSettlementServiceImpl.java` | +| 项目预付核销 | `service/impl/SettlementProjectAdvanceWriteOffServiceImpl.java` | +| 金蝶同步支撑 | `support/KingdeeApBillSyncSupport.java`(A102-2/A102-4)、`service/impl/KingdeeBillLockServiceImpl.java`(锁单)、`KingdeeAttachmentSyncServiceImpl.java`(附件) | +| 审批后自动推送 | `listener/KingdeeApprovalSyncListener.java`(AFTER_COMMIT)+ `event/KingdeeBillApprovedEvent.java` | +| 定时任务 | `task/PaymentKingdeeResultTask.java`(付款结果回写,每 10 分钟)、`task/KingdeeAutoSyncRetryTask.java`(失败重推,每 10 分钟)、`task/KingdeeBillLockRetryTask.java`(补锁) | + +### 前端(tms-erp-web) + +| 页面 | 位置 | +|---|---| +| 预结算单列表/表单 | `src/views/settlement/pre-settlement.vue`、`pre-settlement-form.vue`、`components/pre-settlement-editor.vue` | +| 正式结算单列表/表单 | `src/views/settlement/formal-settlement.vue`、`formal-settlement-form.vue`、`components/formal-settlement-editor.vue` | +| 项目预付核销组件 | `src/views/settlement/components/project-advance-write-off.vue` | +| 付款申请列表/表单 | `src/views/payment/payment-application.vue`、`payment-application-form.vue` | +| API 层 | `src/api/settlement/preSettlement.js`、`src/api/settlement/formalSettlement.js`、`src/api/payment/paymentApplication.js` | + +### 核心数据表(实体) + +| 表/实体 | 作用 | +|---|---| +| `PreSettlement`(预结算单) | 含预付汇总字段 `advanceNo / advanceAppliedAmount / advancePaidAmount`、`formalSettlementNo` | +| `PreSettlementAdvance`(预结算预付记录) | 预付台账:`advanceNo`(=付款申请单号)、`appliedAmount / paidAmount / billStatus(reviewing/approved/paid/voided) / kingdeeAdvanceNo` | +| `PaymentApplication`(付款申请) | `paymentType`:`progress_advance` 进度预付 / `project_advance` 项目预付 / `settlement_payment` 尾款付款;`kingdeeStatus`:`unsynced/synced/paying/paid/closed/failed` | +| `PaymentApplicationSettlement` | 付款申请 ↔ 结算单(正式/预结算)多选关系及分摊金额 | +| `PaymentApplicationRecord` | 付款记录(金蝶 A103-4 回填,`voucherJson` 留档凭证号/银行流水/电子回单) | +| `FormalSettlement`(正式结算单) | 含 `appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus / kingdeeSyncStatus` | +| `FormalSettlementSource` | 正式结算单来源预结算单/明细快照,冗余预付汇总 | +| `FormalSettlementPayment` | 正式结算单付款记录(尾款 `paymentType=final`) | +| `SettlementProjectAdvanceWriteOff` | 项目预付核销记录(`settlementType`:PRE/FORMAL) | + +## 三、阶段详解 + +### 阶段一:前置 —— 预结算单审批通过 + +- 前端:预结算列表新建/编辑 → 保存草稿 → 提交 → 审批通过(`pre-settlement.vue`,API `/blade-transport/pre-settlement/save|submit|approve`)。 +- 后端:`PreSettlementController` → `PreSettlementServiceImpl.saveDraft / submit / approve`。 +- 审批通过(`approved`)后,**应付(payable)**预结算单即可发起预付。 + +### 阶段二:发起预付(进度预付) + +1. **前端入口**:预结算列表勾选 →「预付申请」按钮。前端校验:同一合同、`approved`、`payable`、未转正式结算(`pre-settlement.vue` `handleAdvanceApplication`)。 +2. **跳转付款申请表单** `/payment/payment-application/form?paymentType=progress_advance&preSettlementIds=...`(并非直接建预付单)。 +3. **表单**(`payment-application-form.vue`): + - 进度预付仅可关联预结算单;不展示发票信息与付款记录; + - 可付金额实时调 `/payment-application/reference-amount` = 结算金额 − 累计已申请 − 项目预付核销额。 +4. **后端**(`PaymentApplicationServiceImpl`): + - `saveDraft`:生成 `FKD+日期+序号` 单号;`fillReference` 回填预结算关联;保存 `PaymentApplicationSettlement` 关系表; + - `submit`:`draft/returned → reviewing`(财务审核); + - `approve`:`→ approved`,刷新关联结算单付款汇总,**发布 `KingdeeBillApprovedEvent`**。 + +> 说明:`PreSettlementController` 上的 `/apply-advance`、`/update-advance-paid`、`/void-advance` 为遗留接口,前端已不再调用;`PreSettlementAdvance` 台账现由付款申请流程联动维护(见阶段三/四)。 + +### 阶段三:同步金蝶(A103-3 预付款直推) + +- 审批事务 **AFTER_COMMIT** → `KingdeeApprovalSyncListener.pushPayment`(Nacos 开关 `thirdParty.bfi.autoSync.payment`)→ `paymentApplicationService.syncKingdee`。 +- `doSyncKingdee`:预付款走 **A103-3 直接保存金蝶付款申请单(不绑应付单)**;推送前 A103-2 幂等预检防重复;成功后落 `kingdeeStatus=synced` + `kingdeeBillId` → **lockBusiData 锁单** → A902 附件上传。 +- **提前建档**:`syncPreSettlementAdvance(entity, approved)` —— 按付款申请号在 `PreSettlementAdvance` 生成预付记录(`advanceNo = FKD 单号`),刷新预结算单预付汇总(`advanceNo / advanceAppliedAmount / advancePaidAmount`),并联动刷新关联正式结算单付款汇总(预付信息两边页面即时可见)。 +- **失败兜底**:`markKingdeeSyncFailed` 落 `failed`;`KingdeeAutoSyncRetryTask` 每 10 分钟自动重推;页面「同步金蝶」按钮手动兜底。审批接口通过 `pollSyncFailure()` 将推送失败原因以警告文案带回前端(审批结果不受推送影响)。 + +### 阶段四:金蝶付款 + 结果回写(付款之后) + +定时任务 `PaymentKingdeeResultTask`(每 10 分钟,分布式锁 single-flight)→ `syncKingdeePaymentResult`: + +1. **A103-2 付款申请单查询**:按 `paymentNo`(防重键)分批查询,回填金蝶单号、状态(`synced → paying → paid/closed`)、已付金额(分录 `e_paidamt` 汇总)。 +2. **付款完成(paid)联动**:`syncPreSettlementAdvance(paid, 全额)` 置预付记录为已付。 +3. **A103-4 付款处理查询**:回填付款记录 `PaymentApplicationRecord`——实付金额、付款日期、金蝶付款单号;凭证号/银行流水/电子回单整体存 `voucherJson` 留档追溯。 +4. **异常自愈**:A103-2 连续两轮查不到已推送单据(Redis 计数)→ 判定金蝶侧已反审核删除 → `unlockBill` 置 `failed`、清空金蝶单号/内码、**联动作废未付的预付记录**(已付事实不回退);重推后重新建档。 +5. 前端可手动触发:付款申请页「回写金蝶付款结果」按钮 `/sync-kingdee-result`。 + +### 阶段五:预结算 → 正式结算单 + +- **前端**:预结算列表「尾款结算」→ 校验同项目、`approved` → 跳 `/settlement/formal-settlement/form` 携带预结算数据(`handleFormalSettlement`)。 +- **后端两条路**: + 1. **完整建单**:`FormalSettlementServiceImpl.saveDraft` —— 合并多张预结算单(`FormalSettlementSource`)或应收应付明细;**继承预付汇总**(`advanceAppliedAmount / advancePaidAmount`);支持**项目预付核销** `replaceWriteOffs`(校验:同合同、审批通过、不超可核销余额、合计不超结算单金额);单号 `ZJS...`。 + 2. **轻量标记**:`PreSettlementController /formal-settlement` → `formalSettlement(id)` —— 预结算单回写 `formalSettlementNo`、源明细置 `formal_settled`;**前提:无在途预付申请**(存在 `reviewing/approved` 预付则拦截)。 +- **正式结算单审批**:草稿 → 提交 → `approve` → 事件 → `syncKingdee` → **A102-2 财务应付单**(A102-4 预检/反查防重,一张结算单一张应付单)→ 回写 `kingdeeSyncStatus` → 锁单 + 附件。 +- 列表展示「申请付款金额(含预付)/ 已收已付合计(含预付)」。 + +### 阶段六:尾款付款(正式结算单之后) + +1. **前端**:正式结算单列表勾选(`approved`、`payable`、同合同)→「申请付款」→ 付款申请表单 `paymentType=settlement_payment`,默认取剩余可付金额(`openPaymentDialog`)。 +2. **后端**:`saveDraft` 支持一单挂多张正式结算单(同合同,按可付金额比例分摊);`submit` 时 `validateNoTailPayment` 防重复尾款申请。 +3. **同步金蝶路由不同**:结算付款走 **A103-1 应付下推**——前置校验每张正式结算单已在金蝶生成财务应付单(A102-4 预检,未生成则提示"请先在正式付款单页面同步金蝶"),再下推生成金蝶付款申请单;之后锁单、附件同前。 +4. **回写后**:paid 时 `syncFormalSettlementPayments` 按关系表分摊生成 `FormalSettlementPayment(paymentType=final)` → `refreshPaymentSummary` 重算结算单 `appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus`(unpaid → partial → paid)。金额后续差异走**结算调整单**。 + +### 项目预付线(project_advance) + +- 付款申请页「预付申请」按钮 → `project_advance`:选**项目 + 承运商合同**(仅承运商合同),不挂结算单、无发票/付款记录。 +- `validateQuota` 校验:项目额度(`fundLimit` + 有效期内审批通过的临时额度)、客户额度(`maxCreditLimit`)。 +- 审批 → A103-3 直推 → 金蝶付款 → 回写 paid。 +- **后续核销**:预结算单/正式结算单表单中「项目预付核销」组件 → 保存时 `replaceWriteOffs` 写 `SettlementProjectAdvanceWriteOff`;可付金额计算统一扣减核销额(`referenceAmount`)。 +- 金蝶侧预付抵扣字段(`y_billtype / y_billid / y_settleamt`)**TODO 本期不传**(`KingdeeApBillSyncSupport`,待业务确认后迭代)。 + +## 四、逆向流程 + +| 操作 | 规则 | +|---|---| +| 付款申请作废(仅 approved) | 金蝶锁单中禁止作废(`checkEditable`);汇票付款释放票据余额;**联动作废未付的预付记录**(已付事实不回退);刷新结算单汇总 | +| 预结算单作废 | 存在关联预付申请需先作废预付 | +| 预付记录作废 | 已付款的不能作废 | +| 金蝶反审核(解锁) | A103-2 连续两轮消失自动解锁置 `failed`,人工核实后重推;解锁时原金蝶单号/内码留档清空,重推生成新单后重新关联附件与预付记录 | + +## 五、遗留接口说明 + +`PreSettlementController` 的以下接口前端已不再调用(前端统一走付款申请单流程),`PreSettlementAdvance` 表改由付款申请联动维护,保留兼容: + +- `POST /pre-settlement/apply-advance`(发起预付申请) +- `POST /pre-settlement/update-advance-paid`(回写预付付款金额) +- `POST /pre-settlement/void-advance`(作废预付申请) + +如需下线,需另行评估权限与存量数据兼容性。 + +## 六、风险与未覆盖范围 + +- 应收侧预收/收款认领(ReceiptClaim)流程未在本篇展开。 +- 汇票付款(BillPayment)仅提及票据余额占用/释放联动。 +- 金蝶 A102/A103 接口报文字段细节未逐项列出,参见 `KingdeeApBillSyncSupport`、`PaymentApplicationServiceImpl.pushBfiPayApply / pushBfiPayApplyPush` 源码注释。 From cc2564c6ee4a6421f3652d2f65818cd2d8b1650d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Sun, 18 Oct 2026 14:38:07 +0800 Subject: [PATCH 33/47] =?UTF-8?q?Revert=20"fix:=20=E4=BB=98=E6=AC=BE?= =?UTF-8?q?=E7=94=B3=E8=AF=B7=E5=8F=91=E5=8F=B7=E5=90=AB=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=8D=A0=E4=BD=8D=E5=B9=B6=E5=8B=BE=E7=A8=BD?= =?UTF-8?q?=E9=87=91=E9=A2=9D=E6=A0=A1=E9=AA=8C=EF=BC=8C=E9=98=B2=E5=8D=95?= =?UTF-8?q?=E5=8F=B7=E5=A4=8D=E7=94=A8=E6=92=9E=E9=87=91=E8=9D=B6=E9=98=B2?= =?UTF-8?q?=E9=87=8D=E9=94=AE"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 1c9471df8902efd9304f37ede4fc9d0353ac7f50. --- .../mapper/PaymentApplicationMapper.java | 14 -------------- .../impl/PaymentApplicationServiceImpl.java | 19 +------------------ 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java index 2f8af3b1..770dc202 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java @@ -21,23 +21,9 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.PaymentApplication; -import java.util.List; - /** 付款申请 Mapper。 @author Chill */ @Mapper public interface PaymentApplicationMapper extends BaseMapper { - - /** - * 按前缀查询单号(含逻辑删除单据):发号占位用,删除后的单号不复用, - * 避免 A103-3/A103-1 推送时防重键(awrp_src_code)撞金蝶已有单据(已提交单据不允许修改)。 - * - * @param pattern 单号匹配模式(如 'FKD20261018%') - */ - @Select("SELECT payment_no FROM blade_payment_application WHERE payment_no LIKE #{pattern}") - List selectPaymentNosIncludingDeleted(@Param("pattern") String pattern); - } 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 4b387c48..f8860776 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 @@ -1040,13 +1040,6 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl no.length() > prefix.length() ? no.substring(prefix.length()) : "") - .filter(no -> no.matches("\\d+")) - .map(Integer::valueOf) - .max(Integer::compareTo) - .orElse(0); - return prefix + String.format("%05d", max + 1); - } + private String nextNo() { String prefix = "FKD" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); return prefix + String.format("%05d", count(Wrappers.lambdaQuery().likeRight(PaymentApplication::getPaymentNo, prefix)) + 1); } private BigDecimal nonNegative(BigDecimal value, String name) { if (value == null || value.compareTo(BigDecimal.ZERO) < 0) throw new ServiceException(name + "不能小于0"); return value; } private BigDecimal money(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } private String limit(String value, int length) { if (value != null && value.length() > length) throw new ServiceException("备注不能超过" + length + "个字符"); return value; } From 6e685d3440dccb1ed238dbc4e44c3921470480c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Sun, 18 Oct 2026 15:23:12 +0800 Subject: [PATCH 34/47] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=E9=A2=84?= =?UTF-8?q?=E4=BB=98=E8=BD=AC=E6=AD=A3=E5=BC=8F=E7=BB=93=E7=AE=97=E5=8D=95?= =?UTF-8?q?=E4=B8=8E=E9=A1=B9=E7=9B=AE=E9=A2=84=E4=BB=98=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E8=AF=B4=E6=98=8E=EF=BC=88=E5=88=86=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=C2=B7=E5=89=8D=E7=AB=AF=E5=90=8E=E7=AB=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...�结算单与项目预付操作流程说明(分场景·前端后端).md | 363 ++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作流程说明(分场景·前端后端).md diff --git a/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作流程说明(分场景·前端后端).md b/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作流程说明(分场景·前端后端).md new file mode 100644 index 00000000..a61724d0 --- /dev/null +++ b/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作流程说明(分场景·前端后端).md @@ -0,0 +1,363 @@ +# 预付转正式结算单与项目预付操作流程说明(分场景·前端后端) + +> 整理日期:2026-10-18 +> 定位:面向操作者与实施/联调人员的**分场景操作手册 + 逻辑说明**。讲清「谁在哪个页面点什么 → 后端发生什么 → 数据表/金蝶变成什么 → 怎么校验成功」。 +> 依据代码现状梳理(`tms-api` / `tms-erp-web`)。关联文档(同目录): +> - 《2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md》——改造前现状 +> - 《2026-10-18-正式结算单预付整合设计(进度预付+项目预付核销·金额口径与记录流转).md》——v2 设计与决策 +> - 《2026-10-18-预付与正式结算单全链路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md》——改造后全链路 +> - 《2026-09-21-应收结算与开票申请同步金蝶全链路业务流程(A302-2保存-A302-4勾稽-A303-1新增-A303-4预检-A303-5回写).md》——开票链 +> - 《2026-09-29-预结算开票(预开票)金蝶推送方案.md》——预开票(**方案,尚未落地**) + +--- + +## 〇、先看全局:两条预付线 + 一条开票线 + +### 0.1 术语 + +| 术语 | 含义 | +|---|---| +| 预结算单(PreSettlement) | 结算前的费用单据,审批通过后作为预付/尾款结算的依据。有 **应付(payable)/应收(receivable)** 两种。 | +| 进度预付(progress_advance) | 挂在**预结算单**上的预付,属**应付**侧(我方付出去)。 | +| 项目预付(project_advance) | 挂在**项目 + 承运商合同**上的预付,不挂结算单,属**应付**侧。 | +| 核销 | 项目预付金在后续结算单上"抵扣",落 `SettlementProjectAdvanceWriteOff` 行。**仅金蝶已付款(paid)的项目预付可核销**。 | +| 容纳 / 纳入 | 预结算单转正式结算单时,其**预付台账快照**(进度预付)与**核销行**(项目预付)被正式结算单继承,计入正式单的"已付/已申请/剩余可付"。 | +| 尾款 | 正式结算单扣除已付(含预付、核销)后的剩余部分,走单独付款申请(settlement_payment)。 | +| 开票 | **应收**侧动作:正式结算单 → 开票申请 → 金蝶开票单。**应付**侧对应的是**收票**,两者共用 `formal_settlement.invoice_status` 字段,名称按结算方向区分。 | + +### 0.2 全景图 + +``` +【应付线 · 预付】 【应收线 · 开票】 + +预结算单(应付) approved 预结算单(应收) approved + │ │ + ├─ 预付申请(进度预付 progress_advance) └─ 尾款结算 + │ └ 审批 → A103-3 直推金蝶付款 → 回写 paid └→ 应收正式结算单 + │ └ 提前建档 PreSettlementAdvance ├ 审批 → 同步金蝶 A302-2 财务应收单 + │ └ 开票申请(选应收正式结算单) +项目预付(project_advance) ← 付款申请页「预付申请」 └ 审批 → A303-1 开票申请单 + ├ 选 项目 + 承运商合同(校验项目额度/客户额度) └ A303-5 回写发票 → 开票完成 + ├ 审批 → A103-3 → 金蝶付款 → 回写 paid + └ 后续在结算单上「项目预付核销」 + + ▼ 双线在「正式结算单」汇合 + 预结算单(应付) ──「尾款结算」──→ 应付正式结算单(容纳预付快照 + 核销行) + ├ 审批 → 同步金蝶 A102-2 财务应付单(全额 settlementAmount) + └「付款申请」→ 尾款付款 → A103-1 应付下推 +``` + +### 0.3 金蝶接口速查 + +| 接口 | 用途 | 触发方 | +|---|---|---| +| A103-3 | 预付款直接保存金蝶付款申请单(不绑应付单) | 付款申请(progress_advance / project_advance)同步金蝶 | +| A103-1 | 应付下推生成金蝶付款申请单 | 付款申请(settlement_payment)同步金蝶 | +| A103-2 / A103-4 | 付款申请单查询 / 付款处理查询 | 推送前幂等预检、定时回写(每 10 分钟) | +| A102-2 / A102-4 | 财务应付单保存 / 查询(防重预检) | 应付正式结算单「同步金蝶」 | +| A302-2 / A302-4 | 财务应收单保存 / 查询(勾稽) | 应收正式结算单「同步金蝶」 | +| A303-1 / A303-4 / A303-5 | 开票申请单保存 / 幂等预检 / 开票结果查询 | 开票申请「同步金蝶」与定时回写 | +| lockBusiData | 金蝶单据锁定 | 推送成功事务提交后 | +| A902 | 附件上传 | 主单推送成功后联带 | + +--- + +## 一、应付侧:预付转正式结算单(被"容纳") + +### 场景 A1:发起进度预付(挂预结算单) + +**前提**:预结算单 `approvalStatus=approved`、`settlementType=payable`、未转正式结算(`formalSettlementNo` 为空)。 + +**前端操作** +1. 菜单进「预结算单」列表 → 勾选 1 条或多条预结算单(**必须同一合同**)。 +2. 点工具栏「**预付申请**」。 + - 校验不通过时的提示:缺合同 ID / 多合同 / 非 payable / 非 approved / 已转正式。 +3. 跳转「付款申请」表单,页面类型已锁定为**进度预付**:结算单只读、结算金额与可付款金额自动带出。 +4. 填「申请付款金额」「付款方式」→「保存」→ 详情页「提交」→ 列表「通过」。 + +**后端动作** + +| 步骤 | 接口/方法 | 结果 | +|---|---|---| +| 可付金额 | `GET /payment-application/reference-amount` | `可付 = 预结算金额 − 累计已申请 − 项目预付核销额` | +| 保存 | `POST /payment-application/save` → `saveDraft` | 生成 `FKD+日期+序号` 单号;写 `PaymentApplicationSettlement` 关系表 | +| 提交 | `POST /payment-application/submit` | `draft/returned → reviewing` | +| 审批 | `POST /payment-application/approve` | `→ approved`,刷新预结算付款汇总,发布 `KingdeeBillApprovedEvent` | +| 自动推送 | `KingdeeApprovalSyncListener`(AFTER_COMMIT,开关 `thirdParty.bfi.autoSync.payment`) | A103-3 直推金蝶付款申请单;**提前建档** `PreSettlementAdvance`(`advanceNo = FKD 单号`);刷新预结算预付汇总并联动正式结算单 | + +> 说明:`PreSettlementController` 的 `/apply-advance`、`/update-advance-paid`、`/void-advance` 为**遗留接口**,前端已不再调用,台账改由付款申请流程联动维护。 + +**结果校验**:预结算单列表「申请预付金额 / 已预付金额」变化;付款申请 `kingdeeStatus` 从 `unsynced → synced`。 + +### 场景 A2:发起项目预付(挂项目 + 承运商合同) + +**前提**:项目已建、承运商合同已建;不超过项目额度与客户额度。 + +**前端操作** +1. 菜单进「付款申请」列表 → 点工具栏「**预付申请**」(注意:与 A1 的入口不同,本入口从付款申请页进)。 +2. 表单页类型为**项目预付**,填写: + - **所属项目**(必选,下拉) + - **合同名称**(必选,**仅承运商合同**,选项目后才可选) + - 申请付款金额、付款方式、付款方/收款方等 + - 不挂结算单,不展示发票信息与付款记录 +3. 「保存」→「提交」→「通过」。 + +**后端动作** + +| 步骤 | 接口/方法 | 结果 | +|---|---|---| +| 保存 | `saveDraft` → `validateQuota` | 项目额度、客户额度双重校验(见下) | +| 提交/审批 | `submit` / `approve` | 同上,审批后发事件推 A103-3 | +| 推送 | A103-3 | 金蝶生成付款申请单,`kingdeeStatus=synced` | +| 回写 | `PaymentKingdeeResultTask`(A103-2/A103-4,每 10 分钟) | `synced → paying → paid`;回填实付金额与付款记录 | + +**额度校验口径**(`PaymentApplicationServiceImpl.validateQuota`,悲观锁 `FOR UPDATE`) + +``` +项目额度 = (项目 fundLimit + Σ有效期内 approved 临时额度) × 10000 +客户额度 = 客商 maxCreditLimit × 10000 +申请付款金额 > 额度 → 分别报"申请付款金额不能超过项目额度 / 客户额度" +``` + +**结果校验**:付款申请列表 `kingdeeStatus=已付款(paid)`,金蝶单据号已回填。**只有 paid 才能进入后续核销**。 + +### 场景 A3:项目预付核销(在结算单上选单 + 录额) + +**前提**:存在 `approvalStatus=approved` 且 `kingdeeStatus=paid` 的项目预付,与结算单**同一合同**,且有剩余可核销余额。 + +**前端操作** +1. 打开**预结算单编辑器**(或后续的**正式结算单编辑器**)→「**项目预付核销**」分区 → 点「**选择**」。 +2. 弹窗「选择项目预付单」列出可核销单(同合同 + 已审批 + **已付款** + 有余额),已选行不可重复勾选。 +3. 勾选「确定」→ 默认按"可核销金额"预填「本次核销金额」,可手工改小。 +4. 「保存」(或保存草稿)。 + +**校验链(前端 → 后端一致)** + +| 层级 | 规则 | 提示 | +|---|---|---| +| 前端 | 金额 > 0 | 预付单 X 本次核销金额必须大于 0 | +| 前端 | 金额 ≤ 可核销金额 | 本次核销金额不能超过可核销金额 | +| 前端 | 核销合计 ≤ 结算单金额 | 项目预付核销合计不能超过结算单金额 | +| 后端 | 合同一致 | 项目预付单与结算单合同不一致 | +| 后端 | 仅项目预付 | 仅可核销项目预付单 | +| 后端 | approved | 仅可选择审核通过的项目预付单 | +| 后端 | **paid**(本次新增) | 预付单 X 尚未付款,项目预付须金蝶付款完成后方可核销 | +| 后端 | 同一预付单不重复 | 同一项目预付单不可重复核销 | +| 后端 | 有余额 / 合计 ≤ 结算金额 | 超出可核销金额 / 核销合计不能超过结算单金额 | + +**结果校验**:正式/预结算单「已付金额」立即包含核销额(核销行必对应已付款预付,计入实付口径),列表「申请付款金额(含预付)/已收已付合计(含预付)/剩余可付」同步变化。 + +### 场景 A4:预结算转正式结算单(容纳预付) + +**前提**:预结算单 `approved`、**同一项目**;此时其预付(进度预付台账、项目预付核销行)将被正式单继承。 + +**前端操作(两条路径)** +1. 「预结算单」列表勾选 → 工具栏「**尾款结算**」→ 跳「正式结算单」表单,来源预结算明细与费用项自动合并。 +2. 或在正式结算单表单里通过弹窗「追加来源预结算」。 + +**表单上的三处关键呈现(应付侧)** +- **结算合计(原金额)**:Σ 费用项 = `settlementAmount`,**始终全额,不扣预付/核销**(开票匹配上限、金蝶应付单金额基线)。 +- **汇总栏**「已付金额(含预付、核销)/ 剩余未付金额」: + - 编辑态实时算:`已付 = Σ来源预结算.advancePaidAmount + Σ核销行.writeOffAmount`; + - 只读态取落库值 `form.paidAmount`(已含尾款 + 进度预付 + 核销)。 +- **项目预付核销**:从预结算来源带入的核销行会**自动预填**,可增删改。 + +**后端动作(`FormalSettlementServiceImpl.saveDraft`,同一事务)** +1. 来源合并校验(同合同、同币种)→ 写 `FormalSettlementSource` 快照(含预付汇总 `advanceAppliedAmount / advancePaidAmount`)。 +2. `refreshSettlementAmount`:以费用项重算 `settlementAmount`,并刷一次汇总。 +3. **转移来源核销行**:`deleteBySettlementIds(PRE, 来源预结算ids)` —— 先释放来源占用,后续"不超可核销余额"校验才能通过预填金额。 +4. `replaceWriteOffs`:服务端复校验后落 **FORMAL** 核销行。 +5. `refreshPaymentSummary` 再刷一次(核销落库后必须再算,第 2 步覆盖不到核销行)。 + +> 原则:**同一笔预付的核销在链路上只存在一份** —— 预结算转正式 = 删源 PRE 行 + 落 FORMAL 行。 +> 轻量标记路径(`PreSettlementController /formal-settlement`):仅回写正式结算单号,无正式单实体,PRE 核销行原地保留,待真正建单时再转移。**前提:无在途预付申请**(存在 reviewing/approved 预付会拦截)。 + +**结果校验**:正式结算单「申请付款金额(含预付)」「已收已付合计(含预付)」「剩余可付」「收付款状态」四列直接反映预付与核销;同一预付在 PRE / FORMAL 侧无双份核销。 + +### 场景 A5:正式结算单同步金蝶(A102-2 财务应付单) + +**前端操作**:正式结算单列表 → **应付** tab → 勾选 1 条(按钮要求单选)→ 工具栏「**同步金蝶**」。 + +**后端动作**:`POST /formal-settlement/sync-kingdee` → 按结算方向路由: +- 应付 → **A102-2 财务应付单**(A102-4 幂等预检 / 反查,一张结算单一张应付单),金额 = **全额 settlementAmount**; +- 成功后回写 `kingdeeSyncStatus=synced` + 金蝶单据号/内码 → `lockBusiData` 锁单 → A902 附件上传。 + +**结果校验**:列表「金蝶同步状态」= 已同步、「金蝶锁定状态」= 已锁定 / 锁定中;附件未全部上传时页面追加提示"可重推补传"。 + +**审批时自动推送**:正式结算单「通过」后也会 AFTER_COMMIT 自动推;若推送失败,审批结果为 `false` + 具体原因以 warning 提示,审批本身不失败,走「同步金蝶」手动兜底或定时重推。 + +### 场景 A6:尾款付款(正式结算单之后) + +**前提**:正式结算单 `approved`、`payable`、同一合同、有剩余可付金额。 + +**前端操作**:正式结算单(应付)列表勾选 1 条或多条 → 工具栏「**付款申请**」→ 弹窗确认各单申请金额(默认 = 剩余可付,已扣核销)→「提交」。 + +**后端动作** +1. `saveDraft`:可挂多张同合同正式结算单,按可付金额比例分摊;`submit` 时 `validateNoTailPayment` 防重复尾款申请。 +2. **推送路由不同**:尾款走 **A103-1 应付下推**,前置对每张正式结算单做 A102-4 预检——**未在金蝶生成财务应付单则拦截**(提示先在正式结算单页同步金蝶);下推生成金蝶付款申请单,随后锁单 + 附件。 +3. 回写 `paid` → 生成 `FormalSettlementPayment(paymentType=final)` → `refreshPaymentSummary` 重算 `appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus`(unpaid → partial → paid)。 + +**金额口径(全系统唯一定义)** + +``` +settlementAmount = Σ 结算合计费用项(全额,不扣预付/核销) +appliedPaymentAmount = 尾款申请中(reviewing) + Σ来源预结算 advanceAppliedAmount + Σ核销行 +paidAmount = 尾款已付(approved 实付) + Σ来源预结算 advancePaidAmount + Σ核销行 +remainingPayableAmount = max(settlementAmount − paidAmount, 0) +paymentStatus = unpaid / partial / paid +``` + +--- + +## 二、应收侧:正式结算单推送开票 + +> 开票是**应收**方向:给客户开票 → 金蝶开票单。与应付侧的"收票"共用 `invoice_status` 字段,文案按方向区分(未开票 / 部分开票 / 已开票,未收票 / 部分收票 / 已收票)。 + +### 场景 B1:应收正式结算单同步金蝶(A302-2 财务应收单) + +**前端操作**:正式结算单列表切到「**应收**」tab → 勾选 1 条 → 工具栏「**同步金蝶**」。 + +**后端动作**:`POST /formal-settlement/sync-kingdee` → **A302-2 财务应收单**(防重键 `正式结算单号_结算明细单据号`)→ 回写单据号/内码 + `synced` → 锁单 + A902 附件。 +另有定时勾稽 `syncKingdeeArResult`(A302-4):对已同步/生成失败的应收结算单批量反查金蝶应收单并纠偏回填,可手动触发 `POST /formal-settlement/sync-kingdee-ar-result`。 + +**结果校验**:应收正式结算单 `kingdeeSyncStatus=已同步`,列表可见金蝶单据号。 + +> **开票前置**:正式结算开票要求**先有财务应收单**。开票申请推送时按 A302-4 逐结算单预检,未生成则拦截并提示"请先在正式结算单页面同步金蝶"。 + +### 场景 B2:开票申请创建与推送(A303-1 / A303-5) + +**前提**:存在 `approved`、未作废、**未收票/未开票(invoiceStatus=unreceived)** 的**应收**正式结算单。 + +**前端操作** +1. 菜单进「**开票申请**」列表 → 点「**新增**」。 +2. 「开票基本信息」区点「结算单」右侧「**选择**」→ 弹窗「选择应收正式结算单」(可按结算单号、项目、合同搜索)→ 勾选 → 「确定」。 + - 项目、开票单位(= 结算单收款方)自动带出;「**可开票金额**」= Σ 各结算单剩余可开票额;受票方开票信息按受票方客商档案带出。 +3. 填「发票类型」(电子专票时纳税人识别号/开户行/开户账号/注册地址必填)、受票方、邮箱、「开票内容」(发票张次与商品行)→ 系统自动算「本次开票金额」= 行合计。 +4. 「结算信息」表中填各结算单的**分摊开票金额**(≤ 该单剩余可开票金额)。 +5. 「开票明细」勾选明细 → 「保存」(草稿)→ 「提交」→ 列表「通过」。 + +**后端动作** + +| 步骤 | 接口/方法 | 规则 | +|---|---|---| +| 保存 | `POST /invoice-application/save` → `saveDraft` | ① 仅可选择**应收、审批通过、未作废、未收票**的正式结算单;② 结算单需兼容(同项目/开票方等);③ 分摊金额 ≤ 剩余可开票金额;④ 行合计 > 0;⑤ 受票方须有客商档案与有效开票信息;⑥ 电子专票四要素必填;⑦ 保存后刷新来源结算单开票状态,写变更记录 | +| 提交 | `/invoice-application/submit` | `draft/returned → reviewing`,写申请日期 | +| 审批 | `/invoice-application/approve` | `→ approved`,发布事件 → AFTER_COMMIT 推金蝶 | +| 推送 | `syncKingdee` → `doSyncKingdee` | ① **必须关联正式结算单**;② 逐结算单 A302-4 预检应收单已生成;③ A303-4 幂等预检(已生成的张次跳过并回填);④ 按发票张次拆分 `billno = 单号-张次` 逐张推 A303-1;⑤ 成功后锁单 + A902 附件 | +| 回写 | 定时 `syncKingdeeInvoiceResult`(A303-5,按 `serialNo=billno` 分批 ≤50)或手动 `POST /invoice-application/sync-kingdee-invoice-result` | 查到发票 → 落 `InvoiceApplicationInvoice` 发票记录并推进 `invoiced` 终态;同时刷新来源结算单 `invoice_status`(部分开票 → 已开票) | + +**结果校验**:开票申请 `kingdeeStatus=已同步 / 开票完成`,详情页可见发票结果(发票号、开票日期、价税合计、文件);来源正式结算单「发票状态」从未开票 → 部分开票 → 已开票。 + +**边界与限制(现状)** +- 已推送(`synced/invoiced`)**锁单不可改**;仅审批通过且未锁定时可「作废」。 +- 数电票已开出只能红冲(不走本系统作废)。 +- **预付/核销不影响开票**:开票按**全额 `settlementAmount`** 匹配,预付与核销只影响应付侧的已付/剩余可付。 + +### 场景 B3:预开票(预结算单直接开票)——**方案,尚未落地** + +按《2026-09-29-预结算开票(预开票)金蝶推送方案》,预结算(应收)可**只推 A303-1 开票申请单**、不校验应收单,新增 `invoice_source=pre` 与预结算关联表区分。 + +**当前代码状态**:`InvoiceApplicationServiceImpl.doSyncKingdee` 仍硬校验"必须关联正式结算单",创建入口也只允许选应收正式结算单 ⇒ **预开票目前推不出去**,需按上述方案改造后才能操作。本文不列为可执行场景。 + +--- + +## 三、状态机与字段口径 + +### 3.1 付款申请(PaymentApplication) + +| 字段 | 取值 | 说明 | +|---|---|---| +| `paymentType` | `progress_advance` / `project_advance` / `settlement_payment` | 进度预付 / 项目预付 / 尾款付款 | +| `approvalStatus` | `draft / reviewing / approved / returned / voided` | 提交后 reviewing;仅 approved 可作废 | +| `kingdeeStatus` | `unsynced / synced / paying / paid / closed / failed` | **paid 是项目预付可核销的前置** | +| `kingdeeLockStatus` | `locked / …` | 已锁定的单据禁止编辑/作废 | + +### 3.2 正式结算单(FormalSettlement) + +| 字段 | 取值 | +|---|---| +| `approvalStatus` | `draft / reviewing / approved / returned / voided` | +| `paymentStatus` | `unpaid / partial / paid` | +| `kingdeeSyncStatus` | `unsynced / synced / failed` | +| `invoiceStatus` | 应付=收票状态,应收=开票状态:`unreceived / partial / completed` | + +### 3.3 预付记录单据状态名(`src/utils/settlementStatus.js`,两个编辑器共用) + +| 值 | 名称 | +|---|---| +| `reviewing` | 审批中 | +| `approved` | 审批通过 | +| `paid` | 已付款 | +| `returned` | 已驳回 | +| `voided` | 已作废 | +| `draft` | 草稿 | + +### 3.4 正式结算单付款记录合并表(只读) + +一张表三类型,按「付款类型」列区分:**进度预付**(来源预结算 `PreSettlementAdvance` 台账)/ **项目预付核销**(核销行,金额=本次核销额)/ **结算尾款**。列名统一为「**申请/核销金额**」;创建时间统一取**底层付款申请创建时间**。 + +--- + +## 四、逆向与异常场景 + +| 场景 | 触发 | 系统行为 | +|---|---|---| +| 付款申请作废(仅 approved) | 付款申请列表「作废」(非金蝶锁定) | 金蝶锁单中禁止作废;汇票付款释放票据余额;**联动作废未付预付记录 + 删除其核销行并刷新受影响正式结算单汇总**(已付事实不回退) | +| 正式结算单作废 | 结算单列表「作废」 | 删本单 FORMAL 核销行,额度释放、详情无残留 | +| 金蝶删单回调(`voidByKingdee`) | 金蝶侧反审核删除 | 同上,删本单核销行 | +| 预结算单作废 | 预结算列表「作废」 | 存在关联预付申请需先作废预付;删 PRE 核销行 | +| 预付记录作废 | 预付台账 | 已付款的不能作废 | +| 金蝶反审核(解锁) | A103-2 连续两轮查不到已推单据 | 判定金蝶侧已删 → `unlockBill` 置 `failed`、清空金蝶单号/内码、**联动作废未付预付记录**(已付不回退);重推后重新建档。**此场景不删核销行**(预付申请仍 approved,核销继续计入),人工作废后才回落 | +| 推送失败 | A103-2/A102-2/A302-2/A303-1 报错 | 落 `failed` → `KingdeeAutoSyncRetryTask` 每 10 分钟自动重推;页面「同步金蝶」/「批量同步」手动兜底;审批接口把失败原因带回前端 warning,审批结果不受影响 | +| 结算调整(金额变更) | 「结算调整单」 | 调整落库前校验**核销合计 ≤ 新结算金额**,超出拒绝并提示先调减核销;落库后 `refreshPaymentSummary` 重算。调整重推:核销行**迁移到新单**(克隆 + 删原单行 + 批量刷新两单),原单作历史存档,收付口径以新单为准 | +| 全额核销 / 已结清 | `paymentStatus=paid` | `isSettlementCompleted` 拦截,不允许再发起结算调整(已确认"全额抵扣即结清") | + +--- + +## 五、接口与数据表索引 + +### 5.1 后端接口 + +| 模块 | 主要接口(`/blade-transport` 网关前缀) | +|---|---| +| 预结算单 | `GET /pre-settlement/list|detail`;`POST /pre-settlement/save|submit|approve|return|void`;`POST /pre-settlement/formal-settlement`(轻量标记转正式) | +| 付款申请 | `GET /payment-application/list|detail|reference-amount|project-advance-write-off-options`;`POST /payment-application/save|submit|approve|return|void|sync-kingdee|sync-kingdee-batch|sync-kingdee-result` | +| 正式结算单 | `GET /formal-settlement/list|detail|candidate-pre-settlements|contract-options`;`POST /formal-settlement/save|submit|approve|return|void|sync-kingdee|sync-kingdee-ar-result|retry-kingdee-bill-lock|apply-payment|apply-payments|claim-invoices` | +| 开票申请 | `GET /invoice-application/list|detail|settlement-candidates|settlement-details|receiver-information`;`POST /invoice-application/save|remove|submit|approve|return|void|sync-kingdee|sync-kingdee-invoice-result` | + +### 5.2 核心数据表 + +| 表/实体 | 作用 | +|---|---| +| `PreSettlement` | 预结算单;预付汇总 `advanceNo / advanceAppliedAmount / advancePaidAmount`、`formalSettlementNo` | +| `PreSettlementAdvance` | 预结算预付台账;`advanceNo`(=FKD 单号)、`billStatus`、`kingdeeAdvanceNo` | +| `PaymentApplication` | 付款申请(三种 paymentType) | +| `PaymentApplicationSettlement` | 付款申请 ↔ 结算单关系及分摊 | +| `PaymentApplicationRecord` | 付款记录(A103-4 回填,`voucherJson` 留档) | +| `FormalSettlement` | 正式结算单;`settlementAmount / appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus / kingdeeSyncStatus / invoiceStatus` | +| `FormalSettlementSource` | 来源预结算/明细快照,冗余预付汇总 | +| `FormalSettlementPayment` | 正式结算单尾款付款记录(`paymentType=final`) | +| `SettlementProjectAdvanceWriteOff` | 项目预付核销行(`settlementType`:PRE / FORMAL) | +| `InvoiceApplication` / `InvoiceApplicationSettlement` / `InvoiceApplicationSheet` / `InvoiceApplicationLine` / `InvoiceApplicationInvoice` | 开票申请主单 / 结算单关联与分摊 / 发票张次 / 商品行 / 金蝶回写发票 | + +### 5.3 前端页面 + +| 页面 | 路径 | +|---|---| +| 预结算单列表/表单 | `src/views/settlement/pre-settlement.vue`、`pre-settlement-form.vue`、`components/pre-settlement-editor.vue` | +| 正式结算单列表/表单 | `src/views/settlement/formal-settlement.vue`、`formal-settlement-form.vue`、`components/formal-settlement-editor.vue` | +| 项目预付核销组件 | `src/views/settlement/components/project-advance-write-off.vue` | +| 付款申请列表/表单 | `src/views/payment/payment-application.vue`、`payment-application-form.vue` | +| 开票申请列表/表单 | `src/views/payment/invoice-application.vue`、`invoice-application-form.vue` | +| 状态名公共工具 | `src/utils/settlementStatus.js`、`src/option/settlement/formalSettlementSearch.js` | + +--- + +## 六、已知口径与风险 + +1. **尾款默认可付额** = 结算金额 − 累计尾款申请 − 核销,**不扣进度预付已付部分**(既有口径)。进度预付占大头时默认额可能偏大,由审批环节把关。 +2. **正式结算单 `paidAmount` 现含核销**,下游消费该字段的报表/对账单口径随之变化(对账已确认接受)。 +3. **金蝶侧预付抵扣字段**(`y_billtype / y_billid / y_settleamt`)本期**不传**,金蝶报表与本系统"已付含核销"存在时间差(待业务确认后迭代)。 +4. **预开票未落地**:预结算直接开票需先按方案改造 `doSyncKingdee` 门禁与数据模型。 +5. 存量"approved 未 paid"的核销行未做数据修正(`replaceWriteOffs` 只约束新保存),如需清洗另出脚本。 +6. 应收侧预收/收款认领(ReceiptClaim)与收票流程未在本文展开。 From ed6346e7a10aca99f114ab2da6d248b2bdabd387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Sun, 18 Oct 2026 15:50:07 +0800 Subject: [PATCH 35/47] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=E9=A2=84?= =?UTF-8?q?=E4=BB=98=E8=BD=AC=E6=AD=A3=E5=BC=8F=E7=BB=93=E7=AE=97=E5=8D=95?= =?UTF-8?q?=E4=B8=8E=E9=A1=B9=E7=9B=AE=E9=A2=84=E4=BB=98=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E6=89=8B=E5=86=8C=EF=BC=88=E7=BA=AF=E6=93=8D=E4=BD=9C=C2=B7?= =?UTF-8?q?=E5=B8=A6=E7=8A=B6=E6=80=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...¬正式结算单与项目预付操作手册(纯操作·带状态).md | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作手册(纯操作·带状态).md diff --git a/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作手册(纯操作·带状态).md b/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作手册(纯操作·带状态).md new file mode 100644 index 00000000..2dbb4807 --- /dev/null +++ b/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作手册(纯操作·带状态).md @@ -0,0 +1,279 @@ +# 预付转正式结算单与项目预付操作手册(纯操作·带状态) + +> 整理日期:2026-10-18 +> 定位:**给操作者和实施/培训用的纯操作手册**。只讲"在哪个页面、点哪个按钮、填什么、会看到什么状态",不含接口、表名、代码。 +> 配套逻辑说明见同目录《2026-10-18-预付转正式结算单与项目预付操作流程说明(分场景·前端后端).md》。 + +--- + +## 一、先认状态(每个场景都对着这几张表看) + +### 1.1 单据审批状态(所有单据通用) + +``` +新建/保存 ──→ 草稿 ──提交──→ 审批中 ──通过──→ 审批通过 + ↑ │ + │ 驳回 + └── 已驳回 ←────┘ (改完再提交) + +草稿 / 审批中 / 审批通过 ──(可作废时)──→ 已作废(终态) +``` + +| 状态 | 能做什么 | +|---|---| +| 草稿 | 编辑 / 删除 / 提交 | +| 已驳回 | 编辑(保存后回到草稿)/ 提交 | +| 审批中 | 通过 / 驳回(审批人操作) | +| 审批通过 | 看后续:同步金蝶、付款申请、开票申请、作废(未锁定时) | +| 已作废 | 终态,只读 | + +### 1.2 付款申请的金蝶状态 + +``` +未同步 ──推送──→ 已同步 ──→ 付款中 ──→ 已付款 + │ │ + └────────── 生成失败(可重推) └──→ 已关闭 +``` + +- **已付款**:钱已付出。**项目预付只有到"已付款"才能在结算单上核销**。 +- **生成失败**:系统每 10 分钟自动重推;也可在列表勾选后点「批量同步」手动重推。 + +### 1.3 正式结算单上的四个状态 + +| 状态位 | 取值 | 看什么 | +|---|---|---| +| 审批状态 | 草稿 / 审批中 / 审批通过 / 已驳回 / 已作废 | 单据走到哪一步 | +| 金蝶同步状态 | 未同步 / 已同步 / 同步失败 | 有没有在金蝶生成应付单(应付)/应收单(应收) | +| 收付款状态 | 未收/付款 / 部分收/付款 / 已收/付款 | 收付进度(**已含预付、核销**) | +| 发票状态 | 应付=未收票/部分收票/已收票;应收=未开票/部分开票/已开票 | 票开/收到哪一步 | + +### 1.4 金蝶锁定状态 + +| 显示 | 含义 | 影响 | +|---|---|---| +| 锁定中 | 正在向金蝶加锁 | 稍等 | +| 已锁定 | 金蝶已锁 | **不可编辑、不可作废** | +| 锁定失败·补偿中 / 已达上限 | 加锁未成功 | 定时自动补偿;必要时联系管理员 | + +### 1.5 开票申请的金蝶状态 + +``` +未同步 ──推送──→ 已同步 ──金蝶回写──→ 开票完成 + │ + └── 生成失败(可重推) +``` + +--- + +## 二、应付侧操作(钱付出去) + +### 场景 1:预结算单直接发起「进度预付」 + +**前提**:预结算单是**应付**、状态**审批通过**、还没转正式结算。 + +1. 进「**预结算单**」列表,勾选预结算单(**同一合同**可多选)。 +2. 点工具栏「**预付申请**」。 +3. 自动跳到「付款申请」页面(类型已固定为进度预付):结算单只读,"结算金额/可付款金额"自动带出。 +4. 填「申请付款金额」(不超过可付款金额)、「付款方式」→「保存」。 +5. 回到「付款申请」列表 → 找到该单 →「**提交**」。 +6. 审批人点「**通过**」。 + +**状态变化**:付款申请 `草稿 → 审批中 → 审批通过`;随后系统自动推金蝶,金蝶状态 `未同步 → 已同步 → 已付款`。 +**同时发生**:预结算单上出现「申请预付金额 / 已预付金额」;预结算单仍然可以继续「尾款结算」。 + +> 若提交/通过后看到黄色警告写着金蝶推送失败:审批结果**不受影响**,稍后自动重推,或到付款申请列表点「批量同步」。 + +--- + +### 场景 2:发起「项目预付」(不挂结算单) + +**前提**:项目已建、**承运商合同**已建;金额不超过项目额度与客户额度。 + +1. 进「**付款申请**」列表 → 点工具栏「**预付申请**」(注意:这个入口是从付款申请页进的,和场景 1 从预结算单进不同)。 +2. 页面类型已固定为项目预付,填写: + - **所属项目**(必选) + - **合同名称**(必选,**只能选承运商合同**,选了项目后才能选) + - 申请付款金额、付款方式、付款方/收款方等 + - 本类型不挂结算单,不显示发票信息与付款记录 +3. 「保存」→ 列表「提交」→ 审批人「通过」。 + +**会被拦截的情况**: +- "申请付款金额不能超过项目额度"(项目额度 = 项目额度 + 有效期内的临时额度) +- "申请付款金额不能超过客户额度" + +**状态变化**:同场景 1。**务必等到金蝶状态「已付款」**,否则下一步核销做不了。 + +--- + +### 场景 3:结算单上「项目预付核销」 + +**前提**:有一笔**已付款**的项目预付(金蝶状态=已付款),且与本次结算单**同一合同**,还有剩余可核销金额。 + +1. 打开结算单(**预结算单**或**正式结算单**都行)的编辑页。 +2. 找到「**项目预付核销**」分区 → 点「**选择**」。 +3. 弹窗「选择项目预付单」里勾选要抵扣的预付单(已选的会灰掉,不能重复选)→「确定」。 +4. 在「本次核销金额」列:默认已按"可核销金额"填满,可以改小。 +5. 「保存」。 + +**会被拦截的情况(提示文案)**: +- "预付单 XXX 本次核销金额必须大于 0" +- "本次核销金额不能超过可核销金额" +- "项目预付核销合计不能超过结算单金额" +- "仅可选择审核通过的项目预付单" +- "预付单 XXX 尚未付款,项目预付须金蝶付款完成后方可核销" +- "同一项目预付单不可重复核销" +- "项目预付单与结算单合同不一致" + +**状态变化**:结算单「**已付金额(含预付、核销)**」立刻增大,「剩余未付金额」减小;列表上"申请付款金额(含预付)/已收已付合计(含预付)/剩余可付/收付款状态"同步变化。 + +--- + +### 场景 4:预结算单转「正式结算单」(容纳预付) + +**前提**:预结算单**审批通过**,且**同一项目**。 + +1. 进「**预结算单**」列表,勾选(可多选,须同项目)→ 点工具栏「**尾款结算**」。 +2. 系统跳到「正式结算单」新增页,把所选预结算单的明细、费用项合并进来。 +3. 检查三处金额(**应付结算单才有**): + - **结算合计(原金额)**:费用合计,**始终全额**,不会因为预付/核销而变小; + - **已付金额(含预付、核销)**:把来源预结算的已付预付 + 本次核销都算进去; + - **剩余未付金额** = 结算合计 − 已付。 +4. 「项目预付核销」区会**自动带出来源预结算上已核销的行**,可增/删/改金额。 +5. 「保存」(草稿)→「提交」→ 审批人「通过」。 + +**状态变化**:正式结算单 `草稿 → 审批中 → 审批通过`;预结算单会被标记为已转正式结算(来源被锁定)。 +**注意**:如果预结算单上还有**审批中或已审批但没结束的预付申请**,走"轻量转正式"会被拦住,需要先把预付处理完。 + +--- + +### 场景 5:正式结算单「同步金蝶」 + +**前提**:结算单**审批通过**。 + +1. 进「**正式结算单**」列表,切到「**应付**」页签。 +2. 勾选 **1 条**(按钮只允许选一条)→ 点工具栏「**同步金蝶**」。 +3. 成功后会提示"同步成功,金蝶单据号:XXX";列表「金蝶同步状态」变为**已同步**,「金蝶锁定状态」变为**已锁定/锁定中**。 + +**生成失败怎么办**:列表金蝶状态显示"同步失败",重新点「同步金蝶」即可。 +**提示"该单附件未全部上传,可重推补传"**:单据已同步成功,附件稍后重推会自动补传。 + +> 审批通过时系统也会自动推一次;若失败,只弹黄色警告,审批照样通过,按上面手动补推。 + +--- + +### 场景 6:正式结算单「付款申请」(付尾款) + +**前提**:正式结算单是**应付**、**审批通过**、**同一合同**、还有剩余可付金额。 + +1. 「正式结算单」列表(应付)→ 勾选 1 条或多条(同合同)→ 工具栏「**付款申请**」。 +2. 弹窗里确认各单的「申请付款金额」(默认已填=剩余可申请金额,**已扣掉核销**)→「提交」。 +3. 到「付款申请」列表「提交」→ 审批人「通过」。 + +**会被拦截的情况**: +- "仅审批通过的应付正式结算单允许发起付款申请" +- "付款申请必须选择同一个合同的正式结算单" +- 提示需要先在正式结算单页面同步金蝶(说明该单在金蝶还没有应付单,尾款是从应付单下推的,**必须先做场景 5**) + +**状态变化**:正式结算单「收付款状态」`未付款 → 部分付款 → 已付款`,金额按付款结果自动重算。 +**金额错了怎么办**:正式结算单**审批通过后不能直接改金额**,走「**结算调整单**」(见《2026-10-17-结算调整单操作流程说明(分场景).md》)。 + +--- + +## 三、应收侧操作(票开出去) + +### 场景 7:应收正式结算单「同步金蝶」 + +1. 「正式结算单」列表切到「**应收**」页签 → 勾选 1 条 → 工具栏「**同步金蝶**」。 +2. 列表「金蝶同步状态」变**已同步**,可见金蝶单据号。 + +**必须做这一步**:正式结算单开票要求先有金蝶应收单,没做这步,开票申请推不出去。 + +--- + +### 场景 8:创建并推送「开票申请」 + +**前提**:有一张**应收**正式结算单,**审批通过**、未作废、发票状态还是「**未开票**」,且已完成场景 7。 + +1. 进「**开票申请**」列表 → 点「**新增**」。 +2. 「开票基本信息」里点「结算单」右侧的「**选择**」→ 弹窗「选择应收正式结算单」→ 搜索并勾选 →「确定」。 + - 「项目」「开票单位」自动带出;「**可开票金额**」= 所选结算单剩余可开票额之和。 + - 受票方开票信息按客商档案自动带出。 +3. 填「发票类型」等;选**电子专票**时,纳税人识别号、开户行、开户账号、注册地址**必填**。 +4. 填「开票内容」(发票张次 + 商品行),系统自动算「**本次开票金额**」。 +5. 在「结算信息」表里给每张结算单填「**分摊开票金额**」(不超过该单剩余可开票金额)。 +6. 「开票明细」里勾选明细 →「保存」→「提交」→ 审批人「通过」。 + +**会被拦截的情况**: +- "只能选择审批通过、未作废、未收票的应收正式结算单" +- "结算单 XXX 的分摊金额超过剩余可开票金额" +- "本次开票金额必须大于 0" +- "请选择受票方有效的开票信息" +- "未找到正式结算单受票方对应的客商档案" + +**状态变化** +| 对象 | 状态变化 | +|---|---| +| 开票申请 | 审批 `草稿 → 审批中 → 审批通过`;金蝶 `未同步 → 已同步 → 开票完成` | +| 来源正式结算单 | 发票状态 `未开票 → 部分开票 → 已开票`(按分摊与回写自动算) | + +**看发票结果**:开票申请详情页的发票信息里能看到发票号、开票日期、价税合计、发票文件。金蝶回写是定时的,没出来可以先等,或在开票申请列表点「**批量同步**」推一把。 + +**限制**:开票申请一旦推到金蝶(已同步/开票完成)就**锁定不能改**;数电票已经开出来的只能走红冲,不能在本系统作废。**预付和核销不影响开票金额**,开票始终按结算单的**全额**来匹配。 + +--- + +## 四、作废与撤销:能做什么、状态怎么变 + +| 我想…… | 前提 | 操作位置 | 状态变化 | +|---|---|---|---| +| 作废付款申请 | 状态=审批通过,且**未被金蝶锁定** | 付款申请列表「作废」 | 单据→已作废;**未付的预付记录联动作废,相关核销行被删除**,受影响结算单的已付金额回落(已付出去的钱不会回退) | +| 作废正式结算单 | 未审批通过,或审批通过但**未锁定** | 正式结算单列表「作废」 | 单据→已作废;本单的核销行被删除,可核销额度释放 | +| 作废预结算单 | 无关联预付申请(有则先作废预付) | 预结算单列表「作废」 | 单据→已作废;预结算上的核销行被删除 | +| 作废开票申请 | 状态=审批通过,且**未锁定**(即还没推金蝶/还没开票) | 开票申请列表「作废」 | 单据→已作废 | +| 删除 | 状态=草稿 | 各列表「删除」 | 草稿被删除 | +| 金蝶侧反审核删单 | — | 金蝶操作后由系统处理 | 付款申请金蝶状态→生成失败,金蝶单号被清空;**未付的预付记录联动作废**;人工核实后重推,重推成功会重新建档;**已核销的行不会自动删**,需要人工作废该预付款才回落 | + +> 一句话记住:**预付一旦真付出去(已付款),作废不会把钱退回**,只会把台账和核销额度清掉。 + +--- + +## 五、操作者常见疑问 + +| 问题 | 答案 | +|---|---| +| 为什么选不到某张预结算单发起预付? | 需要**应付**、**审批通过**、**未转正式结算**、且所选单据**同一合同**。 | +| 为什么核销弹窗里找不到那笔项目预付? | 它必须已**金蝶付款完成(已付款)**,且与结算单**同一合同**、还有剩余可核销额。 | +| 为什么新增开票申请时选不到某张应收结算单? | 需要**审批通过、未作废、发票状态=未开票**;已被别的开票申请占用的会扣掉可开票额。 | +| 开票提示要先去同步金蝶? | 正式结算开票前,必须先在该应收结算单上点「同步金蝶」生成金蝶应收单。 | +| 付款申请审批通过了,但金蝶那边没有单? | 看金蝶状态:若"生成失败",点「批量同步」重推或等自动重推。 | +| 结算单已付金额比我算的多/少? | 已付金额**含**进度预付、项目预付核销;核销金额同时计入"已付"和"剩余可付"的计算,属正常口径。 | +| 结算单金额写错了怎么改? | 审批通过后不能直接改,走「结算调整单」。 | +| 单据上的「作废」是灰的? | 可能已被金蝶锁定,或状态不满足(只有审批通过且未锁定才能作废)。 | + +--- + +## 六、一眼看懂整个流程(操作版) + +``` +【应付·进度预付】 +预结算单(审批通过) →「预付申请」→ 付款申请(提交→通过) → 金蝶付款(已付款) + └ 预结算单出现"已预付金额" + +【应付·项目预付】 +付款申请页「预付申请」→ 选项目+承运商合同 → 提交→通过 → 金蝶付款(已付款) + +【预结算 → 正式结算(容纳预付)】 +预结算单(审批通过·同项目) →「尾款结算」→ 正式结算单 + 结算合计(全额) / 已付(含预付、核销) / 剩余未付 + 项目预付核销自动带入 → 保存 → 提交 → 通过 + └ 「同步金蝶」(应付单已同步) + +【应付·尾款】 +正式结算单(应付·审批通过) →「付款申请」→ 付款申请 → 金蝶应付下推 → 已付款 + └ 结算单收付款状态:未付款 → 部分付款 → 已付款 + +【应收·开票】 +应收正式结算单(审批通过·未开票) →「同步金蝶」(应收单已同步) + →「开票申请」新增 → 选结算单 → 填开票内容与分摊 → 提交 → 通过 + → 金蝶开票(已同步 → 开票完成) → 结算单发票状态:未开票 → 部分开票 → 已开票 +``` From ddaa6f2c45ac8fa1b16cb9533a2466e3d6d13a73 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Sun, 18 Oct 2026 20:35:28 +0800 Subject: [PATCH 36/47] =?UTF-8?q?:sparkles:=20=E5=AE=8C=E5=96=84=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E7=BB=93=E7=AE=97=E5=8D=95=E6=A8=A1=E6=9D=BF=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E3=80=81=E8=8D=89=E7=A8=BF=E6=98=8E=E7=BB=86=E4=B8=8E?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E8=BF=90=E5=8D=95=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../pojo/entity/SettlementImportBatch.java | 3 + .../pojo/vo/SettlementImportBatchVO.java | 4 + .../SettlementImportController.java | 15 ++ .../excel/SettlementImportFeeReader.java | 100 +++++++- .../SettlementImportTemplateExporter.java | 211 ++++++++++++++++ .../ISettlementImportBatchService.java | 8 + .../SettlementImportBatchServiceImpl.java | 239 ++++++++++++++++-- ...blade_settlement_import_batch_20261015.sql | 10 +- ...settlement_import_batch_alter_20261018.sql | 7 + 9 files changed, 573 insertions(+), 24 deletions(-) create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java create mode 100644 doc/sql/transport/blade_settlement_import_batch_alter_20261018.sql diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementImportBatch.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementImportBatch.java index 88a3bcd4..9cecf4af 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementImportBatch.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementImportBatch.java @@ -107,6 +107,9 @@ public class SettlementImportBatch extends TenantEntity { @Schema(description = "动态费用列名JSON") private String feeItemNamesJson; + @Schema(description = "导入明细行JSON") + private String rowsJson; + @Schema(description = "备注") private String remark; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementImportBatchVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementImportBatchVO.java index 3f856a62..6887da79 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementImportBatchVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementImportBatchVO.java @@ -32,6 +32,7 @@ import org.springblade.transport.pojo.entity.SettlementImportBatch; import java.io.Serial; import java.util.List; +import java.util.Map; /** * 导入结算单批次视图 @@ -66,4 +67,7 @@ public class SettlementImportBatchVO extends SettlementImportBatch { @Schema(description = "动态费用列名") private List feeItemNames; + + @Schema(description = "导入明细行") + private List> rows; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/SettlementImportController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/SettlementImportController.java index cfc6fe69..3fac3e24 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/SettlementImportController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/SettlementImportController.java @@ -144,4 +144,19 @@ public class SettlementImportController extends BladeController { settlementImportBatchService.voidBatch(request); return R.success("作废成功"); } + + @PostMapping("/remove") + @ApiOperationSupport(order = 12) + @Operation(summary = "删除草稿批次") + public R remove(@RequestParam Long id) { + settlementImportBatchService.removeDraft(id); + return R.success("删除成功"); + } + + @GetMapping("/history-waybills") + @ApiOperationSupport(order = 13) + @Operation(summary = "历史运单可选列表") + public R>> historyWaybills(SettlementImportBatchRequest request) { + return R.data(settlementImportBatchService.historyWaybills(request)); + } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportFeeReader.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportFeeReader.java index be771e3c..992db31c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportFeeReader.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportFeeReader.java @@ -26,7 +26,9 @@ package org.springblade.transport.excel; import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellType; import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.DateUtil; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Workbook; @@ -38,11 +40,16 @@ import org.springframework.web.multipart.MultipartFile; import java.io.InputStream; import java.math.BigDecimal; import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; /** * 导入结算单 Excel 读取器(Apache POI)。 @@ -54,6 +61,11 @@ import java.util.Map; public final class SettlementImportFeeReader { private static final DataFormatter DATA_FORMATTER = new DataFormatter(Locale.CHINA); + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final Set DATE_FIELDS = Set.of( + "actualStartDate", "actualEndDate", "planStartDate", "planEndDate" + ); + private static final Set OPTIONAL_DECIMAL_FIELDS = Set.of("mileage", "unitPrice"); private SettlementImportFeeReader() { } @@ -193,23 +205,26 @@ public final class SettlementImportFeeReader { for (Map.Entry entry : headers.entrySet()) { int column = entry.getKey(); String trimmedHeader = entry.getValue(); - String cell = cellString(row.getCell(column)); + Cell poiCell = row.getCell(column); if (feeColumns.contains(column)) { - feeItems.put(trimmedHeader, decimal(cell, trimmedHeader, excelRowIndex)); + feeItems.put(trimmedHeader, decimal(cellString(poiCell), trimmedHeader, excelRowIndex)); continue; } String field = FIXED_HEADER_MAP.get(trimmedHeader); if (field == null) { continue; } - if ("transportFee".equals(field) || "unitPrice".equals(field) - || "quantity".equals(field) || "mileage".equals(field) + if (DATE_FIELDS.contains(field)) { + mapped.put(field, dateCellString(poiCell)); + } else if (OPTIONAL_DECIMAL_FIELDS.contains(field)) { + mapped.put(field, optionalDecimal(cellString(poiCell), trimmedHeader, excelRowIndex)); + } else if ("transportFee".equals(field) || "quantity".equals(field) || "settlementAmount".equals(field)) { - mapped.put(field, decimal(cell, trimmedHeader, excelRowIndex)); + mapped.put(field, decimal(cellString(poiCell), trimmedHeader, excelRowIndex)); } else if ("serialNumber".equals(field)) { - mapped.put(field, intValue(cell)); + mapped.put(field, intValue(cellString(poiCell))); } else { - mapped.put(field, blankToNull(cell)); + mapped.put(field, blankToNull(cellString(poiCell))); } mapped.put(trimmedHeader, mapped.get(field)); } @@ -278,6 +293,69 @@ public final class SettlementImportFeeReader { return text.isEmpty() ? null : text; } + /** + * 读取日期单元格:Excel 日期序列优先格式化为 yyyy-MM-dd; + * 文本短日期(如 1/1/26、10/10/2026)归一为四位年,供后续 LenientDateParser 识别。 + */ + private static String dateCellString(Cell cell) { + if (cell == null) { + return null; + } + CellType cellType = cell.getCellType(); + if (cellType == CellType.FORMULA) { + cellType = cell.getCachedFormulaResultType(); + } + if (cellType == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) { + Date date = cell.getDateCellValue(); + if (date == null) { + return null; + } + LocalDate localDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); + return DATE_FORMATTER.format(localDate); + } + String text = cellString(cell); + return normalizeExcelShortDate(text); + } + + /** 将 Excel 常见短日期文本归一为 yyyy-MM-dd;无法识别时原样返回交由校验层报错。 */ + private static String normalizeExcelShortDate(String text) { + if (text == null || text.isBlank()) { + return null; + } + String normalized = text.trim().replace('.', '/').replace('-', '/'); + String[] parts = normalized.split("[/\\s]+"); + if (parts.length < 3) { + return text.trim(); + } + try { + int first = Integer.parseInt(parts[0]); + int second = Integer.parseInt(parts[1]); + int third = Integer.parseInt(parts[2].replaceAll("[^0-9].*$", "")); + int year; + int month; + int day; + if (parts[0].length() == 4) { + year = first; + month = second; + day = third; + } else if (parts[2].length() == 4 || parts[2].length() == 2) { + // M/d/yy 或 M/d/yyyy(Excel 中文本/短日期常见) + month = first; + day = second; + year = parts[2].length() == 2 ? 2000 + third : third; + if (parts[2].length() == 2 && third >= 100) { + return text.trim(); + } + } else { + return text.trim(); + } + LocalDate localDate = LocalDate.of(year, month, day); + return DATE_FORMATTER.format(localDate); + } catch (Exception exception) { + return text.trim(); + } + } + private static BigDecimal decimal(String cell, String columnName, Integer excelRowIndex) { if (cell == null || cell.isBlank()) { return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); @@ -290,6 +368,14 @@ public final class SettlementImportFeeReader { } } + /** 可选数值:空白返回 null,避免空里程被当成 0 触发「必须为正数」。 */ + private static BigDecimal optionalDecimal(String cell, String columnName, Integer excelRowIndex) { + if (cell == null || cell.isBlank()) { + return null; + } + return decimal(cell, columnName, excelRowIndex); + } + private static Integer intValue(String cell) { if (cell == null || cell.isBlank()) { return null; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java new file mode 100644 index 00000000..a33dbb6c --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java @@ -0,0 +1,211 @@ +/** + * 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.excel; + +import jakarta.servlet.http.HttpServletResponse; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.DataValidation; +import org.apache.poi.ss.usermodel.DataValidationConstraint; +import org.apache.poi.ss.usermodel.DataValidationHelper; +import org.apache.poi.ss.usermodel.FillPatternType; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.HorizontalAlignment; +import org.apache.poi.ss.usermodel.IndexedColors; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.VerticalAlignment; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.util.CellRangeAddressList; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springblade.core.log.exception.ServiceException; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * 导入结算单模板导出:填写说明 + 数据表头 + 运输方式下拉 + 系统费用项列。 + * + * @author Chill + */ +public final class SettlementImportTemplateExporter { + + private static final List FIXED_HEADERS_BEFORE_FEE = List.of( + "序号", + "原始单号", + "配载标识号", + "*车牌号/航班号/船号/班列号", + "*运输方式", + "司机/船长姓名", + "司机/船长手机号", + "*发货地址", + "发货联系人", + "发货联系人电话", + "*到货地址", + "收货联系人", + "收货联系人电话", + "*货物名称", + "*货物类型", + "包装", + "*数量", + "*数量单位", + "规格", + "型号", + "里程(km)", + "运输单价", + "*运输费" + ); + + private static final List FIXED_HEADERS_AFTER_FEE = List.of( + "结算金额", + "*实际发货时间", + "*实际完成时间", + "预计发货时间", + "预计完成时间", + "备注", + "同一运单标识号" + ); + + private static final List DEFAULT_FEE_ITEMS = List.of("装卸费", "过路费"); + + private SettlementImportTemplateExporter() { + } + + public static void export(HttpServletResponse response, List transportTypeOptions, + List feeItemNames) { + List headers = buildHeaders(feeItemNames); + List transportOptions = defaultTransportOptions(transportTypeOptions); + try (Workbook workbook = new XSSFWorkbook()) { + writeInstructionSheet(workbook, transportOptions, feeItemNames); + Sheet dataSheet = workbook.createSheet("导入结算单模板"); + CellStyle headerStyle = createHeaderStyle(workbook); + Row headerRow = dataSheet.createRow(0); + for (int index = 0; index < headers.size(); index++) { + Cell cell = headerRow.createCell(index); + cell.setCellValue(headers.get(index)); + cell.setCellStyle(headerStyle); + dataSheet.setColumnWidth(index, Math.min(28, Math.max(12, headers.get(index).length() + 4)) * 256); + } + int transportTypeColumn = headers.indexOf("*运输方式"); + if (transportTypeColumn >= 0 && !transportOptions.isEmpty()) { + addDropdown(dataSheet, transportTypeColumn, transportOptions); + } + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setHeader("Content-disposition", + "attachment;filename=" + URLEncoder.encode("导入结算单模板", StandardCharsets.UTF_8) + ".xlsx"); + workbook.write(response.getOutputStream()); + } catch (IOException exception) { + throw new ServiceException("导出导入结算单模板失败:" + exception.getMessage()); + } + } + + private static List buildHeaders(List feeItemNames) { + List headers = new ArrayList<>(FIXED_HEADERS_BEFORE_FEE); + Set feeNames = new LinkedHashSet<>(); + if (feeItemNames != null) { + for (String name : feeItemNames) { + if (name != null && !name.isBlank()) { + feeNames.add(name.trim()); + } + } + } + if (feeNames.isEmpty()) { + feeNames.addAll(DEFAULT_FEE_ITEMS); + } + headers.addAll(feeNames); + headers.addAll(FIXED_HEADERS_AFTER_FEE); + return headers; + } + + private static void writeInstructionSheet(Workbook workbook, List transportOptions, + List feeItemNames) { + Sheet sheet = workbook.createSheet("填写说明"); + CellStyle titleStyle = workbook.createCellStyle(); + Font titleFont = workbook.createFont(); + titleFont.setBold(true); + titleStyle.setFont(titleFont); + List lines = new ArrayList<>(); + lines.add("导入结算单填写说明"); + lines.add("1. 请不要修改「导入结算单模板」工作表的表头;带 * 号为必填项。"); + lines.add("2. 运输方式请从下拉项中选择,须与系统字典一致。"); + lines.add("3. 日期请填写 yyyy-MM-dd(如 2026-01-01);Excel 日期单元格也可直接选择。"); + lines.add("4. 里程(km) 可为空;填写时须为正数。"); + lines.add("5. 「*运输费」之后、「结算金额」之前的列为费用项,列名须与系统费用项主数据名称完全一致;金额不为 0 时若不存在或已停用则无法导入。"); + lines.add("6. 结算金额 = 运输费 + 各费用项合计,系统会按此重算。"); + lines.add("7. 当前可选运输方式:" + String.join("、", transportOptions) + "。"); + if (feeItemNames != null && !feeItemNames.isEmpty()) { + lines.add("8. 当前启用费用项示例:" + String.join("、", feeItemNames.stream().limit(30).toList()) + + (feeItemNames.size() > 30 ? " 等" : "") + "。"); + } else { + lines.add("8. 模板默认包含装卸费、过路费列,可按系统费用项主数据增删费用列。"); + } + for (int index = 0; index < lines.size(); index++) { + Row row = sheet.createRow(index); + Cell cell = row.createCell(0); + cell.setCellValue(lines.get(index)); + if (index == 0) { + cell.setCellStyle(titleStyle); + } + } + sheet.setColumnWidth(0, 120 * 256); + } + + private static void addDropdown(Sheet sheet, int columnIndex, List options) { + DataValidationHelper helper = sheet.getDataValidationHelper(); + String[] values = options.toArray(new String[0]); + DataValidationConstraint constraint = helper.createExplicitListConstraint(values); + CellRangeAddressList addressList = new CellRangeAddressList(1, 2000, columnIndex, columnIndex); + DataValidation validation = helper.createValidation(constraint, addressList); + validation.setEmptyCellAllowed(true); + validation.setShowErrorBox(true); + sheet.addValidationData(validation); + } + + private static CellStyle createHeaderStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + style.setAlignment(HorizontalAlignment.CENTER); + style.setVerticalAlignment(VerticalAlignment.CENTER); + Font font = workbook.createFont(); + font.setBold(true); + style.setFont(font); + return style; + } + + private static List defaultTransportOptions(List transportTypeOptions) { + if (transportTypeOptions != null && !transportTypeOptions.isEmpty()) { + return transportTypeOptions; + } + return List.of("公路运输", "铁路运输", "水路运输", "航空运输"); + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementImportBatchService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementImportBatchService.java index f045a015..5d81be02 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementImportBatchService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementImportBatchService.java @@ -66,4 +66,12 @@ public interface ISettlementImportBatchService extends BaseService> historyWaybills(SettlementImportBatchRequest request); } 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 634814ab..9e21cddc 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 @@ -32,7 +32,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springblade.common.excel.ImportFailureExcelUtil; -import org.springblade.core.excel.util.ExcelUtil; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.secure.utils.AuthUtil; @@ -41,12 +40,16 @@ import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.Func; +import org.springblade.system.cache.DictBizCache; +import org.springblade.system.cache.DictCache; import org.springblade.system.cache.UserCache; import org.springblade.system.feign.ISysClient; +import org.springblade.system.pojo.entity.DictBiz; import org.springblade.system.pojo.entity.FeeItem; import org.springblade.transport.constant.TransportBusiConstant; import org.springblade.transport.excel.SettlementImportFeeReader; import org.springblade.transport.excel.SettlementImportTemplateExcel; +import org.springblade.transport.excel.SettlementImportTemplateExporter; import org.springblade.transport.mapper.SettlementImportBatchMapper; import org.springblade.transport.pojo.dto.FormalSettlementSaveRequest; import org.springblade.transport.pojo.dto.FormalSettlementStatusRequest; @@ -186,6 +189,7 @@ public class SettlementImportBatchServiceImpl .eq(SettlementImportBatch::getIsDeleted, 0) .like(Func.isNotEmpty(request.getBatchNo()), SettlementImportBatch::getBatchNo, request.getBatchNo()) .eq(Func.isNotEmpty(request.getSettlementType()), SettlementImportBatch::getSettlementType, request.getSettlementType()) + .eq(Func.isNotEmpty(request.getImportStatus()), SettlementImportBatch::getImportStatus, request.getImportStatus()) .like(Func.isNotEmpty(request.getProjectName()), SettlementImportBatch::getProjectName, request.getProjectName()) .like(Func.isNotEmpty(request.getCustomerName()), SettlementImportBatch::getCustomerName, request.getCustomerName()) .like(Func.isNotEmpty(request.getCarrierName()), SettlementImportBatch::getCarrierName, request.getCarrierName()) @@ -204,13 +208,18 @@ public class SettlementImportBatchServiceImpl if (batch == null || Objects.equals(batch.getIsDeleted(), 1)) { throw new ServiceException("导入结算单批次不存在"); } - return toVO(batch); + SettlementImportBatchVO vo = toVO(batch); + List> rows = parseRowsJson(batch.getRowsJson()); + if (Func.isEmpty(rows) && !STATUS_DRAFT.equals(batch.getImportStatus())) { + rows = buildRowsFromWaybills(listBatchWaybills(batch)); + } + vo.setRows(rows); + return vo; } @Override public void exportTemplate(HttpServletResponse response) { - List sample = new ArrayList<>(); - ExcelUtil.export(response, "导入结算单模板", "导入结算单模板", sample, SettlementImportTemplateExcel.class); + SettlementImportTemplateExporter.export(response, loadTransportTypeLabels(), loadEnabledFeeItemNames()); } @Override @@ -270,6 +279,7 @@ public class SettlementImportBatchServiceImpl @Override @Transactional(rollbackFor = Exception.class) public SettlementImportBatchVO saveDraft(SettlementImportBatchRequest request) { + prepareSettlementRows(request); SettlementImportBatch batch = buildDraftBatch(request); if (Func.isNotEmpty(request.getId())) { SettlementImportBatch oldBatch = getById(request.getId()); @@ -282,10 +292,14 @@ public class SettlementImportBatchServiceImpl batch.setId(oldBatch.getId()); batch.setBatchNo(oldBatch.getBatchNo()); } - batch.setWaybillCount(0); - batch.setDetailCount(0); + List> draftRows = sanitizeRowsForStorage(request.getRows()); + batch.setRowsJson(JsonUtil.toJson(draftRows)); + batch.setWaybillCount(primaryRows(draftRows).size()); + batch.setDetailCount(draftRows.size()); saveOrUpdate(batch); - return toVO(batch); + SettlementImportBatchVO vo = toVO(batch); + vo.setRows(draftRows); + return vo; } @Override @@ -399,8 +413,73 @@ public class SettlementImportBatchServiceImpl } batch.setImportStatus(STATUS_COMPLETED); batch.setFeeItemNamesJson(JsonUtil.toJson(resolveFeeItemNames(request))); + batch.setRowsJson(JsonUtil.toJson(sanitizeRowsForStorage(request.getRows()))); updateById(batch); - return toVO(batch); + SettlementImportBatchVO vo = toVO(batch); + vo.setRows(parseRowsJson(batch.getRowsJson())); + return vo; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void removeDraft(Long id) { + if (id == null) { + throw new ServiceException("请选择需要删除的批次"); + } + SettlementImportBatch batch = getById(id); + if (batch == null || Objects.equals(batch.getIsDeleted(), 1)) { + throw new ServiceException("导入结算单批次不存在"); + } + if (!STATUS_DRAFT.equals(batch.getImportStatus())) { + throw new ServiceException("仅草稿状态的批次允许删除"); + } + deleteLogic(List.of(id)); + } + + @Override + public List> historyWaybills(SettlementImportBatchRequest request) { + if (request == null || Func.isEmpty(request.getProjectId()) + || Func.isEmpty(request.getCustomerContractId()) + || Func.isEmpty(request.getCarrierContractId())) { + throw new ServiceException("请先选择项目、客户合同和承运商合同"); + } + String settlementType = Func.isEmpty(request.getSettlementType()) + ? TYPE_RECEIVABLE : request.getSettlementType(); + if (!TYPE_RECEIVABLE.equals(settlementType) && !TYPE_PAYABLE.equals(settlementType)) { + throw new ServiceException("结算类型不正确"); + } + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() + .eq(Waybill::getIsDeleted, 0) + .eq(Waybill::getProjectId, request.getProjectId()) + .eq(Waybill::getContractId, request.getCustomerContractId()) + .eq(Waybill::getCarrierContractId, request.getCarrierContractId()) + .apply("not exists (select 1 from blade_receivable_payable_detail d" + + " where d.waybill_id = blade_waybill.id and d.settlement_type = {0}" + + " and d.is_deleted = 0)", settlementType) + .orderByDesc(Waybill::getCreateTime) + .last("limit 500"); + List waybills = waybillService.list(wrapper); + List> result = new ArrayList<>(); + for (Waybill waybill : waybills) { + Map item = new LinkedHashMap<>(); + item.put("id", waybill.getId()); + item.put("waybillNo", waybill.getWaybillNo()); + item.put("vehicleNo", waybill.getVehicleNo()); + item.put("transportType", waybill.getTransportType()); + item.put("departureAddress", waybill.getDepartureAddress()); + item.put("arrivalAddress", waybill.getArrivalAddress()); + item.put("cargoName", waybill.getCargoName()); + item.put("cargoType", waybill.getCargoType()); + item.put("quantity", waybill.getQuantity()); + item.put("quantityUnit", waybill.getQuantityUnit()); + item.put("model", waybill.getModel()); + item.put("mileage", waybill.getMileage()); + item.put("actualStartDate", waybill.getStartDate()); + item.put("actualEndDate", waybill.getEndDate()); + item.put("driverName", waybill.getDriverName()); + result.add(item); + } + return result; } @Override @@ -608,9 +687,9 @@ public class SettlementImportBatchServiceImpl .eq(Waybill::getEndDate, endDate) .eq(Waybill::getDepartureAddress, departureAddress) .eq(Waybill::getArrivalAddress, arrivalAddress) - .eq(Waybill::getCargoName, cargoName) - .orderByDesc(Waybill::getCreateTime) - .last("limit 3"); + .eq(Waybill::getCargoName, cargoName); + applyUnsettledWaybillFilter(wrapper, request.getSettlementType()); + wrapper.orderByDesc(Waybill::getCreateTime).last("limit 3"); List matches = waybillService.list(wrapper); if (Func.isEmpty(matches)) { throw new ServiceException("未匹配到系统已有运单(项目+车号+运输完成日期+客户+承运商+起运地+目的地+货物名称)"); @@ -623,16 +702,17 @@ public class SettlementImportBatchServiceImpl /** 手工指定运单号时,在当前项目/客户/承运商范围内精确匹配。 */ private Waybill matchHistoryWaybillByNo(SettlementImportBatchRequest request, String waybillNo) { - List matches = waybillService.list(Wrappers.lambdaQuery() + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() .eq(Waybill::getIsDeleted, 0) .eq(Waybill::getProjectId, request.getProjectId()) .eq(Waybill::getContractId, request.getCustomerContractId()) .eq(Waybill::getCarrierContractId, request.getCarrierContractId()) - .eq(Waybill::getWaybillNo, waybillNo) - .orderByDesc(Waybill::getCreateTime) - .last("limit 2")); + .eq(Waybill::getWaybillNo, waybillNo); + applyUnsettledWaybillFilter(wrapper, request.getSettlementType()); + wrapper.orderByDesc(Waybill::getCreateTime).last("limit 2"); + List matches = waybillService.list(wrapper); if (Func.isEmpty(matches)) { - throw new ServiceException("未匹配到运单号「" + waybillNo + "」对应的系统已有运单"); + throw new ServiceException("未匹配到运单号「" + waybillNo + "」对应的未结算运单"); } if (matches.size() > 1) { throw new ServiceException("运单号「" + waybillNo + "」匹配到多条运单,请核对后重试"); @@ -640,6 +720,14 @@ public class SettlementImportBatchServiceImpl return matches.get(0); } + /** 仅保留尚未生成对应结算类型应收/应付明细的运单。 */ + private void applyUnsettledWaybillFilter(LambdaQueryWrapper wrapper, String settlementType) { + String type = Func.isEmpty(settlementType) ? TYPE_RECEIVABLE : settlementType; + wrapper.apply("not exists (select 1 from blade_receivable_payable_detail d" + + " where d.waybill_id = blade_waybill.id and d.settlement_type = {0}" + + " and d.is_deleted = 0)", type); + } + private void prepareSettlementRows(SettlementImportBatchRequest request) { if (request == null || Func.isEmpty(request.getRows())) { return; @@ -1161,4 +1249,123 @@ public class SettlementImportBatchServiceImpl String merged = Func.isEmpty(original) ? "作废原因:" + reason : original + ";作废原因:" + reason; return limit(merged, 200); } + + @SuppressWarnings("unchecked") + private List> parseRowsJson(String rowsJson) { + if (Func.isEmpty(rowsJson)) { + return List.of(); + } + List rawList = JsonUtil.parseArray(rowsJson, Map.class); + if (rawList == null || rawList.isEmpty()) { + return List.of(); + } + List> rows = new ArrayList<>(); + for (Map raw : rawList) { + if (raw == null) { + continue; + } + rows.add(new LinkedHashMap<>(raw)); + } + return rows; + } + + private List> sanitizeRowsForStorage(List> rows) { + if (Func.isEmpty(rows)) { + return List.of(); + } + List> sanitized = new ArrayList<>(); + for (Map row : rows) { + if (row == null) { + continue; + } + Map copy = new LinkedHashMap<>(); + for (Map.Entry entry : row.entrySet()) { + String key = entry.getKey(); + if (key == null || key.startsWith("_")) { + continue; + } + copy.put(key, entry.getValue()); + } + sanitized.add(copy); + } + return sanitized; + } + + private List loadTransportTypeLabels() { + LinkedHashSet labels = new LinkedHashSet<>(); + try { + List dictBizList = DictBizCache.getList("transport_type"); + if (Func.isNotEmpty(dictBizList)) { + dictBizList.stream() + .map(DictBiz::getDictValue) + .filter(Func::isNotEmpty) + .map(String::trim) + .forEach(labels::add); + } + } catch (Exception ignored) { + // 业务字典不可用时继续读取系统字典 + } + try { + List dictList = DictCache.getList("transport_type"); + if (Func.isNotEmpty(dictList)) { + dictList.stream() + .map(org.springblade.system.pojo.entity.Dict::getDictValue) + .filter(Func::isNotEmpty) + .map(String::trim) + .forEach(labels::add); + } + } catch (Exception ignored) { + // 忽略 + } + if (labels.isEmpty()) { + return List.of("公路运输", "铁路运输", "水路运输", "航空运输"); + } + return new ArrayList<>(labels); + } + + private List loadEnabledFeeItemNames() { + R> response = sysClient.getFeeItems(false); + if (response == null || !response.isSuccess() || response.getData() == null) { + return List.of(); + } + return response.getData().stream() + .filter(item -> item != null && (item.getStatus() == null || Objects.equals(item.getStatus(), 1))) + .map(FeeItem::getName) + .filter(Func::isNotEmpty) + .map(String::trim) + .distinct() + .toList(); + } + + /** 历史已完成批次无 rows_json 时,按关联运单回填查看明细。 */ + private List> buildRowsFromWaybills(List waybills) { + if (Func.isEmpty(waybills)) { + return List.of(); + } + List> rows = new ArrayList<>(); + int serial = 1; + for (Waybill waybill : waybills) { + Map row = new LinkedHashMap<>(); + row.put("serialNumber", serial++); + row.put("waybillNo", waybill.getWaybillNo()); + row.put("vehicleNo", waybill.getVehicleNo()); + row.put("transportType", waybill.getTransportType()); + row.put("driverName", waybill.getDriverName()); + row.put("departureAddress", waybill.getDepartureAddress()); + row.put("arrivalAddress", waybill.getArrivalAddress()); + row.put("cargoName", waybill.getCargoName()); + row.put("cargoType", waybill.getCargoType()); + row.put("quantity", waybill.getQuantity()); + row.put("quantityUnit", waybill.getQuantityUnit()); + row.put("specification", waybill.getSpecification()); + row.put("model", waybill.getModel()); + row.put("mileage", waybill.getMileage()); + row.put("unitPrice", waybill.getUnitPrice()); + row.put("actualStartDate", waybill.getStartDate()); + row.put("actualEndDate", waybill.getEndDate()); + row.put("remark", waybill.getRemark()); + rows.add(row); + } + return rows; + } } diff --git a/doc/sql/transport/blade_settlement_import_batch_20261015.sql b/doc/sql/transport/blade_settlement_import_batch_20261015.sql index 6578be14..89c7b872 100644 --- a/doc/sql/transport/blade_settlement_import_batch_20261015.sql +++ b/doc/sql/transport/blade_settlement_import_batch_20261015.sql @@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS `blade_settlement_import_batch` ( `pre_settlement_id` bigint DEFAULT NULL COMMENT '预结算单ID', `pre_settlement_no` varchar(100) DEFAULT NULL COMMENT '预结算单号', `fee_item_names_json` longtext COMMENT '动态费用列名JSON数组', + `rows_json` longtext COMMENT '导入明细行JSON数组', `remark` varchar(200) DEFAULT NULL COMMENT '备注', `waybill_import_batch_id` bigint DEFAULT NULL COMMENT '关联底层运单导入批次,可空', `create_user` bigint DEFAULT NULL COMMENT '创建人', @@ -45,6 +46,7 @@ ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `settlement_bill_type` va ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `pre_settlement_id` bigint DEFAULT NULL COMMENT '预结算单ID' AFTER `formal_settlement_no`; ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `pre_settlement_no` varchar(100) DEFAULT NULL COMMENT '预结算单号' AFTER `pre_settlement_id`; ALTER TABLE `blade_settlement_import_batch` ADD KEY `idx_settlement_import_pre` (`pre_settlement_id`); +ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `rows_json` longtext COMMENT '导入明细行JSON数组' AFTER `fee_item_names_json`; -- 运单关联导入结算批次(可选,若列已存在可忽略报错) ALTER TABLE `blade_waybill` ADD COLUMN `settlement_import_batch_id` bigint DEFAULT NULL COMMENT '导入结算单批次ID'; @@ -56,5 +58,11 @@ INSERT INTO `blade_menu` (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source` (2090000000000002082,2090000000000002080,'settlement_import_add','新增','settlement_import_add','','',2,2,0,1,NULL,'',0), (2090000000000002083,2090000000000002080,'settlement_import_detail','详情','settlement_import_detail','','',3,2,0,1,NULL,'',0), (2090000000000002084,2090000000000002080,'settlement_import_void','作废','settlement_import_void','','',4,2,0,1,NULL,'',0), -(2090000000000002085,2090000000000002080,'settlement_import_template','下载模板','settlement_import_template','','',5,2,0,1,NULL,'',0) +(2090000000000002085,2090000000000002080,'settlement_import_template','下载模板','settlement_import_template','','',5,2,0,1,NULL,'',0), +(2090000000000002086,2090000000000002080,'settlement_import_delete','删除','settlement_import_delete','','',6,2,0,1,NULL,'',0) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`path`=VALUES(`path`),`is_deleted`=0; + +-- 已建表环境增量菜单(已存在可忽略) +INSERT INTO `blade_menu` (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) VALUES +(2090000000000002086,2090000000000002080,'settlement_import_delete','删除','settlement_import_delete','','',6,2,0,1,NULL,'',0) ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`path`=VALUES(`path`),`is_deleted`=0; diff --git a/doc/sql/transport/blade_settlement_import_batch_alter_20261018.sql b/doc/sql/transport/blade_settlement_import_batch_alter_20261018.sql new file mode 100644 index 00000000..218ed51e --- /dev/null +++ b/doc/sql/transport/blade_settlement_import_batch_alter_20261018.sql @@ -0,0 +1,7 @@ +-- 导入结算单:草稿明细落库 + 删除菜单 +ALTER TABLE `blade_settlement_import_batch` + ADD COLUMN `rows_json` longtext COMMENT '导入明细行JSON数组' AFTER `fee_item_names_json`; + +INSERT INTO `blade_menu` (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) VALUES +(2090000000000002086,2090000000000002080,'settlement_import_delete','删除','settlement_import_delete','','',6,2,0,1,NULL,'',0) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`path`=VALUES(`path`),`is_deleted`=0; From 320f4720435e7155abad7146bb6c7229827b2b4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Sun, 18 Oct 2026 21:30:07 +0800 Subject: [PATCH 37/47] =?UTF-8?q?fix:=20=E5=BC=80=E7=A5=A8=E7=94=B3?= =?UTF-8?q?=E8=AF=B7=E4=B8=8E=E6=AD=A3=E5=BC=8F=E7=BB=93=E7=AE=97=E5=8D=95?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E9=87=91=E8=9D=B6=E5=85=A5=E5=8F=A3=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E8=A1=8C=E9=94=81=E4=BA=92=E6=96=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - doSyncKingdee 首行改为 lockedExisting 行锁读(FOR UPDATE),审批自动推/手动按钮/定时重推三入口在同一单据上串行化 - 后到请求等锁后读到已提交状态,命中已同步幂等分支直接返回金蝶单号 - 修复并发推送触发金蝶「已提交状态下的单据不允许修改」的假失败与重复建单风险 --- .../service/impl/FormalSettlementServiceImpl.java | 10 +++++++++- .../service/impl/InvoiceApplicationServiceImpl.java | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java index 4e58963f..f3b6ac7e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java @@ -981,7 +981,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaQuery() + .eq(FormalSettlement::getId, id).last("FOR UPDATE")); + if (entity == null || Objects.equals(entity.getIsDeleted(), 1)) throw new ServiceException("正式结算单不存在"); + return entity; + } + private FormalSettlement editable(Long id) { FormalSettlement entity = existing(id); if (!TransportBusiConstant.DRAFT.equals(entity.getApprovalStatus()) && !TransportBusiConstant.RETURNED.equals(entity.getApprovalStatus())) throw new ServiceException("仅草稿或已驳回的正式结算单允许编辑"); 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 10b197a8..ab2d6bad 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 @@ -701,7 +701,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpllambdaQuery() + .eq(InvoiceApplication::getId, id).last("FOR UPDATE")); + if (entity == null || Objects.equals(entity.getIsDeleted(), 1)) throw new ServiceException("开票申请不存在"); + return entity; + } + private InvoiceApplication editable(Long id) { InvoiceApplication entity = existing(id); if (!List.of(TransportBusiConstant.DRAFT, TransportBusiConstant.RETURNED).contains(entity.getApprovalStatus())) throw new ServiceException("当前状态不可编辑"); From d1be16755f078dea9f0fc63fba75edaa59b9e62f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Sun, 18 Oct 2026 21:30:37 +0800 Subject: [PATCH 38/47] =?UTF-8?q?fix:=20=E6=94=B6=E7=A5=A8=E7=99=BB?= =?UTF-8?q?=E8=AE=B0=E6=8E=A8=E9=80=81=E9=87=91=E8=9D=B6=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E9=80=90=E5=8D=95=E4=BA=8B=E5=8A=A1=E8=A1=8C=E9=94=81=E4=B8=B2?= =?UTF-8?q?=E8=A1=8C=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 批量编排逐单经 TransactionTemplate 开单据级短事务,推送首行 FOR UPDATE 行锁读并重查推送状态,已推送幂等跳过 - 同一登记的审批自动推/手动批量/定时重推并发时串行化,不再重复调金蝶保存 - 单笔失败只回滚自身不中断整批,失败状态由事务外独立补落,纳入定时重推 --- .../impl/InvoiceReceiptServiceImpl.java | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java index 9a1580fd..bb136e21 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java @@ -90,6 +90,7 @@ import org.springblade.transport.wrapper.InvoiceReceiptWrapper; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import java.io.IOException; import java.io.InputStream; @@ -151,6 +152,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl failures = new ArrayList<>(); for (InvoiceReceipt receipt : receipts) { try { - pushOneReceivedInvoice(planByReceipt.get(receipt.getId()), kingdeeRowByInvoiceNo, apBillNoBySrcCode, context, autoSync); + // 逐单单据级事务:推送方法首行 FOR UPDATE 串行化同一登记的并发推送,单笔失败只回滚自身不中断整批 + transactionTemplate.executeWithoutResult(status -> pushOneReceivedInvoice( + planByReceipt.get(receipt.getId()), kingdeeRowByInvoiceNo, apBillNoBySrcCode, context, autoSync)); success++; } catch (RuntimeException pushException) { failures.add("发票" + Func.toStrWithEmpty(receipt.getInvoiceNo(), String.valueOf(receipt.getId())) + ":" + pushException.getMessage()); log.warn("推送金蝶收票单失败,收票登记id={}", receipt.getId(), pushException); + // 单笔事务已回滚,事务外独立补落"推送失败"状态,纳入定时重推(与付款/开票失败口径统一) + markReceivedInvoicePushFailed(receipt, planByReceipt.get(receipt.getId()), pushException, autoSync); } } String summary = "推送金蝶收票单完成:本批 " + receipts.size() + " 笔,成功 " + success + " 笔"; @@ -674,7 +681,21 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl kingdeeRows, Map apBillNoBySrcCode, KingdeeSyncContext context, boolean autoSync) { - InvoiceReceipt receipt = plan.receipt(); + // 行锁读:同一登记并发推送串行化(锁随本单事务提交释放,事务由批量编排的 TransactionTemplate 开启) + InvoiceReceipt receipt = baseMapper.selectOne(Wrappers.lambdaQuery() + .eq(InvoiceReceipt::getId, plan.receipt().getId()).last("FOR UPDATE")); + if (receipt == null || Objects.equals(receipt.getIsDeleted(), 1)) { + throw new ServiceException("收票登记不存在"); + } + // 锁后重查推送状态:并发场景另一事务已完成推送则幂等跳过,不再重复调金蝶 + if (KingdeeBfiConstant.RECEIVED_PUSH_PUSHED.equals(receipt.getKingdeePushStatus()) + || KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED.equals(receipt.getKingdeePushStatus())) { + log.info("收票登记[{}]金蝶收票单已推送(状态[{}]),幂等跳过", receipt.getInvoiceNo(), receipt.getKingdeePushStatus()); + return "发票" + Func.toStrWithEmpty(receipt.getInvoiceNo(), String.valueOf(receipt.getId())) + "已推送金蝶,幂等跳过"; + } + // 计划内登记实体以行锁后最新数据替换(并发下推送状态可能已被其他事务改写) + plan = new ReceivedInvoicePushPlan(receipt, plan.mirror(), plan.settlement(), plan.details(), + plan.apBillSrcCode(), plan.blockedReason()); log.info("收票登记[{}]推送金蝶收票单开始:id={},推送状态={},应付单防重键={}", receipt.getInvoiceNo(), receipt.getId(), receipt.getKingdeePushStatus(), plan.apBillSrcCode()); if (Func.isNotBlank(plan.blockedReason())) { @@ -765,6 +786,25 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl Date: Sun, 18 Oct 2026 21:31:10 +0800 Subject: [PATCH 39/47] =?UTF-8?q?feat:=20=E9=87=91=E8=9D=B6=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E5=A4=B1=E8=B4=A5=E5=BC=82=E5=B8=B8=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E9=87=91=E8=9D=B6=E5=8D=95=E6=8D=AE=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BfiBillSaveResultItemVO 新增 getFailMark 定位信息(金蝶单据号,@JsonIgnore 不影响序列化) - commonCall 失败分支按失败分录拼入金蝶单据号,「已提交状态下的单据不允许修改」等场景可直接定位星瀚侧单据 - requireBfiSaveSuccess 单据级失败提示同步拼入 --- .../support/KingdeeApBillSyncSupport.java | 2 +- .../bfi/pojo/vo/BfiBillSaveResultItemVO.java | 13 ++++++++++ .../bfi/service/impl/BfiServiceImpl.java | 24 ++++++++++++++++++- 3 files changed, 37 insertions(+), 2 deletions(-) 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 d120ff59..38225ca5 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 @@ -887,7 +887,7 @@ public class KingdeeApBillSyncSupport { BfiBillSaveResultItemVO item = saveResult.getResult().get(0); if (!Boolean.TRUE.equals(item.getBillStatus())) { String errorText = Func.isBlank(item.getErrorText()) ? "未知错误" : item.getErrorText(); - throw new ServiceException(action + "失败:" + errorText); + throw new ServiceException(action + "失败:" + errorText + item.getFailMark()); } } diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillSaveResultItemVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillSaveResultItemVO.java index 341cdca8..11424966 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillSaveResultItemVO.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillSaveResultItemVO.java @@ -25,6 +25,7 @@ */ package org.springblade.thirdparty.bfi.pojo.vo; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; @@ -91,4 +92,16 @@ public class BfiBillSaveResultItemVO implements Serializable { .filter(text -> !text.isEmpty()) .collect(Collectors.joining(";")); } + + /** + * 失败定位信息:金蝶单据号,供异常提示展示, + * 「已提交状态下的单据不允许修改」等场景可凭金蝶单据号直接定位星瀚侧单据 + * + * @return 定位信息文本,形如(金蝶单据:xxx),无可用信息返回空串 + */ + @JsonIgnore + public String getFailMark() { + String billNo = keys == null ? null : keys.get("billno"); + return billNo == null || billNo.isBlank() ? "" : "(金蝶单据:" + billNo + ")"; + } } diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java index 0cc4b18c..de5f8b77 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java @@ -79,6 +79,7 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiApBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiArBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiAttachmentUploadVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillLockResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBlacklistVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiClaimAnnounceVO; @@ -112,6 +113,7 @@ import java.util.List; import java.util.Optional; import java.util.UUID; import java.util.function.Supplier; +import java.util.stream.Collectors; /** * 业财融合(BFI)服务实现 @@ -655,7 +657,7 @@ public class BfiServiceImpl implements IBfiService { String message = Optional.ofNullable(result).map(BfiResultVO::getMessage).filter(StringUtil::isNotBlank) .orElseGet(() -> Optional.ofNullable(result).map(BfiResultVO::getErrorCode) .map(errorCode -> "错误码 " + errorCode).orElse("响应为空或解析失败")); - throw new BfiException("调用BFI接口" + type + "异常 " + message); + throw new BfiException("调用BFI接口" + type + "异常 " + message + billSaveFailDetail(result)); } catch (BfiException e) { log.error("调用BFI接口 {},BfiException异常", type, e); throw e; @@ -665,6 +667,26 @@ public class BfiServiceImpl implements IBfiService { } } + /** + * 单据保存失败定位后缀:取失败分录的金蝶单据号,非单据保存类调用返回空串 + * + * @param result BFI 响应 + * @param 响应数据类型 + * @return 定位后缀,形如 (金蝶单据:xxx) + */ + private String billSaveFailDetail(BfiResultVO result) { + if (result == null || !(result.getData() instanceof BfiBillSaveResultVO billSave) + || billSave.getResult() == null || billSave.getResult().isEmpty()) { + return ""; + } + String detail = billSave.getResult().stream() + .filter(item -> !Boolean.TRUE.equals(item.getBillStatus())) + .map(BfiBillSaveResultItemVO::getFailMark) + .filter(mark -> !mark.isEmpty()) + .collect(Collectors.joining(";")); + return detail.isEmpty() ? "" : " " + detail; + } + /** * 收票 rim/message 契约公共调用方法,成功错误码兼容 0 与 0000 * From 74bc8c844d02d219fe6f11e903b9fdbcaea1f227 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Sun, 18 Oct 2026 22:20:17 +0800 Subject: [PATCH 40/47] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=E5=8D=95?= =?UTF-8?q?=E6=8D=AE=E5=8F=B7=E5=8F=96=E5=8F=B7=E5=BF=BD=E7=95=A5=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E5=88=A0=E9=99=A4=EF=BC=8C=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?OA=E4=BA=BA=E5=91=98=E7=BB=84=E7=BB=87=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 人员同步改为绑定已有组织并归并旁路存量;付款/开票/结算/对账等取号按最大流水含已删数据;运单导入数量单位改读计量单位主数据。 Co-authored-by: Cursor --- .../service/impl/OASyncServiceImpl.java | 4 +- .../service/impl/OaUserListSyncHelper.java | 518 +++++++++++------- .../transport/mapper/BillPaymentMapper.java | 11 + .../mapper/FormalSettlementMapper.java | 9 + .../mapper/FormalSettlementPaymentMapper.java | 11 + .../mapper/InvoiceApplicationMapper.java | 11 + .../mapper/PaymentApplicationMapper.java | 11 + .../mapper/PreSettlementAdvanceMapper.java | 10 + .../transport/mapper/PreSettlementMapper.java | 18 + .../mapper/SettlementAdjustmentMapper.java | 14 +- .../mapper/TransportReconciliationMapper.java | 9 + .../service/impl/BillPaymentServiceImpl.java | 5 +- .../impl/FormalSettlementServiceImpl.java | 23 +- .../impl/InvoiceApplicationServiceImpl.java | 7 +- .../impl/PaymentApplicationServiceImpl.java | 9 +- .../impl/PreSettlementServiceImpl.java | 42 +- .../impl/SettlementAdjustmentServiceImpl.java | 7 +- .../TransportReconciliationServiceImpl.java | 18 +- .../impl/WaybillImportBatchServiceImpl.java | 33 +- 19 files changed, 499 insertions(+), 271 deletions(-) diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java index 6f3143a4..e133b6c5 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java @@ -263,7 +263,7 @@ public class OASyncServiceImpl implements IOASyncService { } /** - * 从 OA 人员列表同步组织与人员 + * 从 OA 人员列表同步人员并绑定已有组织(不维护组织树,须先同步组织架构) * * @param startTime 增量查询开始时间,为空则全量 * @return 处理的人员数量 @@ -284,7 +284,7 @@ public class OASyncServiceImpl implements IOASyncService { } /** - * 按页从 OA 人员列表同步组织与人员 + * 按页从 OA 人员列表同步人员并绑定已有组织(不维护组织树,须先同步组织架构) * * @param current 当前页 * @param size 每页条数 diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java index 77ba09d0..87e3cd50 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java @@ -30,7 +30,6 @@ import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; -import org.springblade.common.constant.DataStatusEnum; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.constant.BladeConstant; import org.springblade.core.tool.utils.DigestUtil; @@ -53,7 +52,6 @@ import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.Collection; -import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; @@ -66,10 +64,9 @@ import java.util.stream.Collectors; import static org.springblade.common.constant.CommonConstant.DEFAULT_PARAM_PASSWORD; import static org.springblade.common.constant.CommonConstant.DEFAULT_PARAM_ROLE; import static org.springblade.common.constant.CommonConstant.DEFAULT_ROLE; -import static org.springblade.common.constant.CommonConstant.YES; /** - * 从 OA 人员列表提取组织并同步人员 + * OA 人员同步:绑定已有组织机构,不维护组织树 * * @author Chill */ @@ -86,104 +83,312 @@ public class OaUserListSyncHelper { private final IRoleService roleService; /** - * 从人员数据提取二级公司、三级部门,并挂到集团顶级(id=1/GWWL) 下 + * 加载已有组织索引,并将人员旁路产生的 OAC/OAD 存量归并到正式组织。 + *

人员同步不再新建/更新组织树,须先执行组织架构同步。

* * @param oaPersons OA人员 - * @return 组织索引 + * @return 组织索引(仅含已有组织) */ public OaOrgIndex syncOrgsFromPersons(List oaPersons) { OaOrgIndex orgIndex = new OaOrgIndex(); if (CollectionUtil.isEmpty(oaPersons)) { return orgIndex; } - Date orgSyncStart = new Date(); - Dept rootCompany = this.getOrCreateRootCompany(); - String tenantId = resolveTenantId(); - Map companyPersonMap = new LinkedHashMap<>(); - Map departmentPersonMap = new LinkedHashMap<>(); - for (OAPersonResponse oaPerson : oaPersons) { - String companyKey = resolveCompanyKey(oaPerson); - if (StringUtils.isNotBlank(companyKey) && StringUtils.isNotBlank(oaPerson.getSubcompanyname())) { - companyPersonMap.putIfAbsent(companyKey, oaPerson); - } - String departmentKey = resolveDepartmentKey(oaPerson); - if (StringUtils.isNotBlank(departmentKey) && StringUtils.isNotBlank(oaPerson.getDepartmentname()) - && StringUtils.isNotBlank(oaPerson.getSubcompanyname())) { - departmentPersonMap.putIfAbsent(departmentKey, oaPerson); - } - } - - Map existingCompanyByCode = new HashMap<>(); - Map existingCompanyByName = new HashMap<>(); - deptService.list(Wrappers.lambdaQuery() - .eq(Dept::getParentId, rootCompany.getId()) - .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) - ).forEach(dept -> { - if (StringUtils.isNotBlank(dept.getDeptCode())) { - existingCompanyByCode.put(dept.getDeptCode(), dept); - } - if (StringUtils.isNotBlank(dept.getDeptName())) { - existingCompanyByName.put(dept.getDeptName(), dept); - } - }); - List addCompanies = new ArrayList<>(); - List updateCompanies = new ArrayList<>(); - companyPersonMap.forEach((companyKey, oaPerson) -> { - String oaCode = buildCompanyCode(oaPerson, companyKey); - Dept existing = existingCompanyByCode.get(oaCode); - if (existing == null) { - existing = existingCompanyByName.get(oaPerson.getSubcompanyname()); - } - Dept company = this.upsertOrg(oaPerson.getSubcompanyname(), oaCode, existing, rootCompany, tenantId, - DeptCategory.COMPANY, addCompanies, updateCompanies); - orgIndex.companyByOaId.put(companyKey, company); - }); - this.saveOrgs(addCompanies, updateCompanies, DeptCategory.COMPANY); - - Map existingDeptByCode = new HashMap<>(); - Map existingDeptByParentAndName = new HashMap<>(); - List companyIds = orgIndex.companyByOaId.values().stream() - .map(Dept::getId) - .filter(Objects::nonNull) - .distinct() - .toList(); - if (CollectionUtil.isNotEmpty(companyIds)) { - deptService.list(Wrappers.lambdaQuery() - .in(Dept::getParentId, companyIds) - .eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode()) - ).forEach(dept -> { - if (StringUtils.isNotBlank(dept.getDeptCode())) { - existingDeptByCode.put(dept.getDeptCode(), dept); - } - if (dept.getParentId() != null && StringUtils.isNotBlank(dept.getDeptName())) { - existingDeptByParentAndName.put(dept.getParentId() + "#" + dept.getDeptName(), dept); - } - }); - } - List addDepartments = new ArrayList<>(); - List updateDepartments = new ArrayList<>(); - departmentPersonMap.forEach((departmentKey, oaPerson) -> { - Dept parentCompany = orgIndex.findCompany(oaPerson); - if (parentCompany == null || parentCompany.getId() == null) { - return; - } - String oaCode = buildDepartmentCode(oaPerson, departmentKey); - Dept existing = existingDeptByCode.get(oaCode); - if (existing == null) { - existing = existingDeptByParentAndName.get(parentCompany.getId() + "#" + oaPerson.getDepartmentname()); - } - Dept department = this.upsertOrg(oaPerson.getDepartmentname(), oaCode, existing, parentCompany, tenantId, - DeptCategory.DEPT, addDepartments, updateDepartments); - orgIndex.deptByOaId.put(departmentKey, department); - orgIndex.deptByCompanyAndName.put(resolveCompanyKey(oaPerson) + "#" + oaPerson.getDepartmentname(), department); - }); - this.saveOrgs(addDepartments, updateDepartments, DeptCategory.DEPT); - deptService.updateAncestors(orgSyncStart); - ComposeLogUtil.getLastLog().info("同步人员提取组织完成,二级公司{}个,三级部门{}个", + this.repairPersonBypassOrgs(); + this.loadExistingOrgIndex(orgIndex); + ComposeLogUtil.getLastLog().info("人员同步绑定已有组织,二级公司{}个,三级部门{}个", orgIndex.companyByOaId.size(), orgIndex.deptByOaId.size()); return orgIndex; } + /** + * 将 OAC/OAD 旁路节点上的人员关系迁移到同 oa_id 的正式组织,并逻辑删除旁路节点 + */ + private void repairPersonBypassOrgs() { + List companyList = deptService.list(Wrappers.lambdaQuery() + .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode())); + List departmentList = deptService.list(Wrappers.lambdaQuery() + .eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode())); + Map officialCompanyByOaId = new HashMap<>(); + Map officialDeptByOaId = new HashMap<>(); + Map companyById = new HashMap<>(); + for (Dept company : companyList) { + if (company.getId() != null) { + companyById.put(company.getId(), company); + } + if (!isPersonBypassCode(company.getDeptCode(), DeptCategory.COMPANY)) { + this.putPreferredByOaId(officialCompanyByOaId, company, DeptCategory.COMPANY); + } + } + for (Dept department : departmentList) { + if (!isPersonBypassCode(department.getDeptCode(), DeptCategory.DEPT)) { + this.putPreferredByOaId(officialDeptByOaId, department, DeptCategory.DEPT); + } + } + + Set softDeleteIds = new HashSet<>(); + Set affectedUserIds = new HashSet<>(); + int migratedDeptBindingCount = 0; + int migratedCompanyBindingCount = 0; + + for (Dept bypass : departmentList) { + if (!isPersonBypassCode(bypass.getDeptCode(), DeptCategory.DEPT) || bypass.getId() == null) { + continue; + } + String oaId = StringUtils.trimToNull(bypass.getOaId()); + if (oaId == null) { + oaId = extractOaIdFromBypassCode(bypass.getDeptCode(), DeptCategory.DEPT); + } + if (oaId == null) { + continue; + } + Dept official = officialDeptByOaId.get(oaId); + if (official == null || official.getId() == null || Objects.equals(official.getId(), bypass.getId()) + || isPersonBypassCode(official.getDeptCode(), DeptCategory.DEPT)) { + continue; + } + Dept officialCompany = resolveBelongCompany(official, companyById); + migratedDeptBindingCount += this.migrateUserDeptBindings(bypass, official, officialCompany, affectedUserIds); + softDeleteIds.add(bypass.getId()); + } + + for (Dept bypass : companyList) { + if (!isPersonBypassCode(bypass.getDeptCode(), DeptCategory.COMPANY) || bypass.getId() == null) { + continue; + } + String oaId = StringUtils.trimToNull(bypass.getOaId()); + if (oaId == null) { + oaId = extractOaIdFromBypassCode(bypass.getDeptCode(), DeptCategory.COMPANY); + } + if (oaId == null) { + continue; + } + Dept official = officialCompanyByOaId.get(oaId); + if (official == null || official.getId() == null || Objects.equals(official.getId(), bypass.getId()) + || isPersonBypassCode(official.getDeptCode(), DeptCategory.COMPANY)) { + continue; + } + migratedCompanyBindingCount += this.migrateUserCompanyBindings(bypass, official, affectedUserIds); + long childCount = deptService.count(Wrappers.lambdaQuery().eq(Dept::getParentId, bypass.getId())); + if (childCount == 0) { + softDeleteIds.add(bypass.getId()); + } else { + ComposeLogUtil.getLastLog().warn("旁路公司仍有子节点,暂不删除 companyId={} deptCode={} childCount={}", + bypass.getId(), bypass.getDeptCode(), childCount); + } + } + + if (CollectionUtil.isNotEmpty(softDeleteIds)) { + deptService.removeByIds(softDeleteIds); + ComposeLogUtil.getLastLog().info("人员同步归并旁路组织完成,迁移部门关系{}条,迁移公司关系{}条,逻辑删除旁路节点{}个", + migratedDeptBindingCount, migratedCompanyBindingCount, softDeleteIds.size()); + } else if (migratedDeptBindingCount > 0 || migratedCompanyBindingCount > 0) { + ComposeLogUtil.getLastLog().info("人员同步归并旁路组织完成,迁移部门关系{}条,迁移公司关系{}条", + migratedDeptBindingCount, migratedCompanyBindingCount); + } + this.refreshUserDeptFields(affectedUserIds); + } + + /** + * 仅加载已有组织,按 oa_id / 名称建立索引供人员绑定 + */ + private void loadExistingOrgIndex(OaOrgIndex orgIndex) { + Map companyByOaId = new HashMap<>(); + Map companyByName = new HashMap<>(); + Map companyById = new HashMap<>(); + deptService.list(Wrappers.lambdaQuery() + .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) + ).forEach(dept -> { + if (dept.getId() != null) { + companyById.put(dept.getId(), dept); + } + this.putPreferredByOaKey(companyByOaId, resolveOrgOaKey(dept, DeptCategory.COMPANY), dept, DeptCategory.COMPANY); + if (StringUtils.isNotBlank(dept.getDeptName())) { + Dept previous = companyByName.get(dept.getDeptName()); + if (previous == null + || (isPersonBypassCode(previous.getDeptCode(), DeptCategory.COMPANY) + && !isPersonBypassCode(dept.getDeptCode(), DeptCategory.COMPANY))) { + companyByName.put(dept.getDeptName(), dept); + } + } + }); + companyByOaId.forEach((oaId, dept) -> orgIndex.companyByOaId.put(oaId, dept)); + companyByName.forEach((name, dept) -> orgIndex.companyByOaId.putIfAbsent("NAME:" + name, dept)); + + Map deptByOaId = new HashMap<>(); + deptService.list(Wrappers.lambdaQuery() + .eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode()) + ).forEach(dept -> { + this.putPreferredByOaKey(deptByOaId, resolveOrgOaKey(dept, DeptCategory.DEPT), dept, DeptCategory.DEPT); + Dept parentCompany = resolveBelongCompany(dept, companyById); + if (parentCompany == null || StringUtils.isBlank(dept.getDeptName())) { + return; + } + String companyKey = resolveOrgOaKey(parentCompany, DeptCategory.COMPANY); + if (companyKey == null && StringUtils.isNotBlank(parentCompany.getDeptName())) { + companyKey = "NAME:" + parentCompany.getDeptName(); + } + if (companyKey == null) { + return; + } + String nameKey = companyKey + "#" + dept.getDeptName(); + Dept previous = orgIndex.deptByCompanyAndName.get(nameKey); + if (previous == null + || (isPersonBypassCode(previous.getDeptCode(), DeptCategory.DEPT) + && !isPersonBypassCode(dept.getDeptCode(), DeptCategory.DEPT))) { + orgIndex.deptByCompanyAndName.put(nameKey, dept); + } + }); + orgIndex.deptByOaId.putAll(deptByOaId); + } + + /** 优先取 oa_id;旁路空 oa_id 时从 OAC/OAD 编码后缀解析 */ + private String resolveOrgOaKey(Dept dept, DeptCategory deptCategory) { + if (dept == null) { + return null; + } + String oaId = StringUtils.trimToNull(dept.getOaId()); + if (oaId != null) { + return oaId; + } + return extractOaIdFromBypassCode(dept.getDeptCode(), deptCategory); + } + + private int migrateUserDeptBindings(Dept bypass, Dept official, Dept officialCompany, Set affectedUserIds) { + List bindingList = userDeptService.list(Wrappers.lambdaQuery() + .and(wrapper -> wrapper.eq(UserDept::getDeptId, bypass.getId()) + .or().eq(UserDept::getDeptCode, bypass.getDeptCode()))); + if (CollectionUtil.isEmpty(bindingList)) { + return 0; + } + Set userIds = bindingList.stream() + .map(UserDept::getUserId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map existingByKey = userDeptService.list(Wrappers.lambdaQuery() + .in(UserDept::getUserId, userIds) + ).stream() + .collect(Collectors.toMap(this::getUserDeptKey, userDept -> userDept, (first, second) -> first)); + List updateList = new ArrayList<>(); + List removeIds = new ArrayList<>(); + for (UserDept binding : bindingList) { + if (binding.getUserId() == null) { + continue; + } + affectedUserIds.add(binding.getUserId()); + String companyCode = officialCompany != null ? officialCompany.getDeptCode() : binding.getCompanyCode(); + String companyName = officialCompany != null ? officialCompany.getDeptName() : binding.getCompanyName(); + String newKey = binding.getUserId() + StringUtils.defaultString(companyCode) + + StringUtils.defaultString(official.getDeptCode()); + UserDept alreadyOfficial = existingByKey.get(newKey); + if (alreadyOfficial != null && !Objects.equals(alreadyOfficial.getId(), binding.getId())) { + removeIds.add(binding.getId()); + continue; + } + binding.setDeptId(official.getId()); + binding.setDeptCode(official.getDeptCode()); + binding.setDeptName(official.getDeptName()); + if (officialCompany != null) { + binding.setCompanyCode(companyCode); + binding.setCompanyName(companyName); + } + updateList.add(binding); + existingByKey.put(newKey, binding); + } + if (CollectionUtil.isNotEmpty(updateList)) { + userDeptService.updateBatchById(updateList); + } + if (CollectionUtil.isNotEmpty(removeIds)) { + userDeptService.removeByIds(removeIds); + } + return updateList.size() + removeIds.size(); + } + + private int migrateUserCompanyBindings(Dept bypass, Dept official, Set affectedUserIds) { + if (StringUtils.isBlank(bypass.getDeptCode())) { + return 0; + } + List bindingList = userDeptService.list(Wrappers.lambdaQuery() + .eq(UserDept::getCompanyCode, bypass.getDeptCode())); + if (CollectionUtil.isEmpty(bindingList)) { + return 0; + } + List updateList = new ArrayList<>(); + for (UserDept binding : bindingList) { + if (binding.getUserId() != null) { + affectedUserIds.add(binding.getUserId()); + } + binding.setCompanyCode(official.getDeptCode()); + binding.setCompanyName(official.getDeptName()); + updateList.add(binding); + } + userDeptService.updateBatchById(updateList); + return updateList.size(); + } + + private void refreshUserDeptFields(Set userIds) { + if (CollectionUtil.isEmpty(userIds)) { + return; + } + List userDeptIdsList = userDeptService.queryUserDeptIds(userIds); + List updateUserParams = userDeptIdsList.stream() + .filter(userDeptIds -> StringUtils.isNotBlank(userDeptIds.getDeptIds())) + .filter(userDeptIds -> { + int deptIdLength = userDeptIds.getDeptIds().length(); + int deptCodesLength = StringUtils.length(userDeptIds.getDeptCodes()); + return deptIdLength <= MAX_DEPT_ID_LENGTH && deptCodesLength <= MAX_DEPT_ID_LENGTH; + }) + .map(userDeptIds -> { + User user = new User(); + user.setId(userDeptIds.getUserId()); + user.setDeptId(userDeptIds.getDeptIds()); + user.setDeptCodes(userDeptIds.getDeptCodes()); + return user; + }).toList(); + if (CollectionUtil.isNotEmpty(updateUserParams)) { + userService.updateBatchById(updateUserParams); + } + } + + private Dept resolveBelongCompany(Dept department, Map companyById) { + if (department == null) { + return null; + } + if (department.getParentId() != null) { + Dept parent = companyById.get(department.getParentId()); + if (parent != null) { + return parent; + } + } + if (StringUtils.isNotBlank(department.getBelongCompanyCode())) { + return companyById.values().stream() + .filter(company -> department.getBelongCompanyCode().equals(company.getDeptCode())) + .findFirst() + .orElse(null); + } + return null; + } + + /** + * 从 OAC37 / OAD37 旁路编码解析 OA 主键;哈希旁路(OACNxxx)返回 null + */ + private String extractOaIdFromBypassCode(String deptCode, DeptCategory deptCategory) { + if (StringUtils.isBlank(deptCode) || !isPersonBypassCode(deptCode, deptCategory)) { + return null; + } + String prefix = DeptCategory.COMPANY.equals(deptCategory) + ? OAConvertConstant.COMPANY_OA_PREFIX + : OAConvertConstant.DEPARTMENT_OA_PREFIX; + if (deptCode.length() <= prefix.length()) { + return null; + } + String suffix = deptCode.substring(prefix.length()); + if (suffix.isEmpty() || suffix.startsWith("N")) { + return null; + } + return suffix; + } + /** * 同步人员并绑定到三级部门 * @@ -395,73 +600,47 @@ public class OaUserListSyncHelper { return localModified.equals(remoteModified); } - private Dept getOrCreateRootCompany() { - return deptService.ensureOaSyncRootDept(); + private boolean isPersonBypassCode(String deptCode, DeptCategory deptCategory) { + if (StringUtils.isBlank(deptCode)) { + return true; + } + if (DeptCategory.COMPANY.equals(deptCategory)) { + return deptCode.startsWith(OAConvertConstant.COMPANY_OA_PREFIX); + } + return deptCode.startsWith(OAConvertConstant.DEPARTMENT_OA_PREFIX); } - private Dept upsertOrg(String name, String oaCode, Dept existing, Dept parent, String tenantId, - DeptCategory deptCategory, List addList, List updateList) { - if (existing != null) { - Dept updateParam = new Dept(); - updateParam.setId(existing.getId()); - updateParam.setDeptName(name); - updateParam.setFullName(name); - updateParam.setShortName(name); - updateParam.setParentId(parent.getId()); - updateParam.setParentCode(parent.getDeptCode()); - updateParam.setAncestors(buildAncestors(parent)); - updateParam.setIsOa(YES); - updateParam.setSyncTime(new Date()); - updateList.add(updateParam); - existing.setDeptName(name); - existing.setFullName(name); - existing.setShortName(name); - existing.setParentId(parent.getId()); - existing.setParentCode(parent.getDeptCode()); - existing.setAncestors(updateParam.getAncestors()); - return existing; + /** 同一 oa_id 多条时优先正式编码,避免人员挂到 OAC/OAD 旁路重复节点。 */ + private void putPreferredByOaId(Map byOaId, Dept dept, DeptCategory deptCategory) { + if (dept == null || StringUtils.isBlank(dept.getOaId())) { + return; } - Dept dept = this.buildOrgDept(name, oaCode, parent, tenantId, deptCategory); - dept.setId(DefaultIdentifierGenerator.getInstance().nextId(null)); - addList.add(dept); - return dept; + this.putPreferredByOaKey(byOaId, dept.getOaId().trim(), dept, deptCategory); } - private void saveOrgs(List addList, List updateList, DeptCategory deptCategory) { - if (CollectionUtil.isNotEmpty(addList)) { - ComposeLogUtil.getLastLog().info("批量新增{}:{}", deptCategory.getName(), addList.size()); - deptService.saveBatch(addList); + private void putPreferredByOaKey(Map byOaId, String oaKey, Dept dept, DeptCategory deptCategory) { + if (dept == null || StringUtils.isBlank(oaKey)) { + return; } - if (CollectionUtil.isNotEmpty(updateList)) { - ComposeLogUtil.getLastLog().info("批量修改{}:{}", deptCategory.getName(), updateList.size()); - deptService.updateBatchById(updateList); + Dept previous = byOaId.get(oaKey); + if (previous == null) { + byOaId.put(oaKey, dept); + return; + } + boolean previousBypass = isPersonBypassCode(previous.getDeptCode(), deptCategory); + boolean currentBypass = isPersonBypassCode(dept.getDeptCode(), deptCategory); + if (previousBypass && !currentBypass) { + byOaId.put(oaKey, dept); } - } - - private Dept buildOrgDept(String name, String deptCode, Dept parent, String tenantId, DeptCategory deptCategory) { - Dept dept = new Dept(); - dept.setTenantId(tenantId); - dept.setParentId(parent.getId()); - dept.setParentCode(parent.getDeptCode()); - dept.setAncestors(this.buildAncestors(parent)); - dept.setDeptName(name); - dept.setFullName(name); - dept.setShortName(name); - dept.setDeptCode(deptCode); - dept.setBelongCompanyCode(DeptCategory.COMPANY.equals(deptCategory) ? deptCode : parent.getBelongCompanyCode()); - dept.setDeptCategory(deptCategory.getCode()); - dept.setSort(0); - dept.setStatus(DataStatusEnum.ENABLE.getCode()); - dept.setIsDeleted(BladeConstant.DB_NOT_DELETED); - dept.setIsOa(YES); - dept.setIsPlatformCompany(0); - dept.setSyncTime(new Date()); - return dept; } private UserDept buildUserDept(OAPersonResponse oaPerson, Map userMap, OaOrgIndex orgIndex) { Dept department = orgIndex.findDept(oaPerson); if (department == null || department.getId() == null) { + ComposeLogUtil.getLastLog().warn( + "人员未匹配到已有部门,请先同步组织架构 account={} departmentid={} departmentname={} subcompanyid1={}", + userConvert.resolveAccount(oaPerson), oaPerson.getDepartmentid(), oaPerson.getDepartmentname(), + oaPerson.getSubcompanyid1()); return null; } UserDept userDept = userConvert.person2userDept(oaPerson, userMap); @@ -493,55 +672,6 @@ public class OaUserListSyncHelper { return roleList.get(0).getId().toString(); } - private String resolveCompanyKey(OAPersonResponse oaPerson) { - if (oaPerson == null) { - return null; - } - if (StringUtils.isNotBlank(oaPerson.getSubcompanyid1())) { - return oaPerson.getSubcompanyid1().trim(); - } - if (StringUtils.isNotBlank(oaPerson.getSubcompanyname())) { - return "NAME:" + oaPerson.getSubcompanyname().trim(); - } - return null; - } - - private String resolveDepartmentKey(OAPersonResponse oaPerson) { - if (oaPerson == null) { - return null; - } - if (StringUtils.isNotBlank(oaPerson.getDepartmentid())) { - return oaPerson.getDepartmentid().trim(); - } - String companyKey = resolveCompanyKey(oaPerson); - if (StringUtils.isNotBlank(companyKey) && StringUtils.isNotBlank(oaPerson.getDepartmentname())) { - return companyKey + ":" + oaPerson.getDepartmentname().trim(); - } - return null; - } - - private String buildCompanyCode(OAPersonResponse oaPerson, String companyKey) { - if (StringUtils.isNotBlank(oaPerson.getSubcompanyid1())) { - return OAConvertConstant.COMPANY_OA_PREFIX + oaPerson.getSubcompanyid1().trim(); - } - return OAConvertConstant.COMPANY_OA_PREFIX + "N" + Math.abs(companyKey.hashCode()); - } - - private String buildDepartmentCode(OAPersonResponse oaPerson, String departmentKey) { - if (StringUtils.isNotBlank(oaPerson.getDepartmentid())) { - return OAConvertConstant.DEPARTMENT_OA_PREFIX + oaPerson.getDepartmentid().trim(); - } - return OAConvertConstant.DEPARTMENT_OA_PREFIX + "N" + Math.abs(departmentKey.hashCode()); - } - - private String buildAncestors(Dept parent) { - String ancestors = parent.getAncestors(); - if (StringUtils.isBlank(ancestors)) { - ancestors = String.valueOf(BladeConstant.TOP_PARENT_ID); - } - return ancestors + "," + parent.getId(); - } - private String resolveTenantId() { String tenantId = AuthUtil.getTenantId(); if (StringUtils.isBlank(tenantId)) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/BillPaymentMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/BillPaymentMapper.java index 2b2db978..ca20187c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/BillPaymentMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/BillPaymentMapper.java @@ -27,9 +27,20 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.BillPayment; /** 汇票付款 Mapper。 @author Chill */ @Mapper public interface BillPaymentMapper extends BaseMapper { + + /** + * 查询指定租户、指定日期前缀下已使用的最大汇票付款流水号。 + * 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(payment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_bill_payment WHERE tenant_id = #{tenantId} " + + "AND payment_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/FormalSettlementMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/FormalSettlementMapper.java index 36f22485..2c01c226 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/FormalSettlementMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/FormalSettlementMapper.java @@ -29,4 +29,13 @@ public interface FormalSettlementMapper extends BaseMapper { /** 恢复逻辑删除的正式结算单主记录。 */ @Update("UPDATE blade_formal_settlement SET is_deleted = 0 WHERE tenant_id = #{tenantId} AND id = #{id} AND is_deleted = 1") int restoreByIdIncludingDeleted(@Param("tenantId") String tenantId, @Param("id") Long id); + + /** + * 查询指定租户、指定日期前缀下已使用的最大正式结算单流水号。 + * 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(formal_settlement_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_formal_settlement WHERE tenant_id = #{tenantId} " + + "AND formal_settlement_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/FormalSettlementPaymentMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/FormalSettlementPaymentMapper.java index fe41e31b..2e0e61a1 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/FormalSettlementPaymentMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/FormalSettlementPaymentMapper.java @@ -10,9 +10,20 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.FormalSettlementPayment; /** 正式结算付款申请 Mapper。 @author Chill */ @Mapper public interface FormalSettlementPaymentMapper extends BaseMapper { + + /** + * 查询指定租户、指定日期前缀下已使用的最大付款申请流水号。 + * 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(payment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_formal_settlement_payment WHERE tenant_id = #{tenantId} " + + "AND payment_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/InvoiceApplicationMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/InvoiceApplicationMapper.java index 9e9db78c..4bcca990 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/InvoiceApplicationMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/InvoiceApplicationMapper.java @@ -27,6 +27,8 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.InvoiceApplication; /** @@ -36,4 +38,13 @@ import org.springblade.transport.pojo.entity.InvoiceApplication; */ @Mapper public interface InvoiceApplicationMapper extends BaseMapper { + + /** + * 查询指定租户、指定日期前缀下已使用的最大开票申请流水号。 + * 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(application_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_invoice_application WHERE tenant_id = #{tenantId} " + + "AND application_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java index 770dc202..f1507f68 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java @@ -21,9 +21,20 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.PaymentApplication; /** 付款申请 Mapper。 @author Chill */ @Mapper public interface PaymentApplicationMapper extends BaseMapper { + + /** + * 查询指定租户、指定日期前缀下已使用的最大付款申请流水号。 + * 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(payment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_payment_application WHERE tenant_id = #{tenantId} " + + "AND payment_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementAdvanceMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementAdvanceMapper.java index c8bb1558..734364da 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementAdvanceMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementAdvanceMapper.java @@ -10,6 +10,8 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.PreSettlementAdvance; /** @@ -19,4 +21,12 @@ import org.springblade.transport.pojo.entity.PreSettlementAdvance; */ @Mapper public interface PreSettlementAdvanceMapper extends BaseMapper { + + /** + * 查询预付单号最大流水(含逻辑删除)。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(advance_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_pre_settlement_advance WHERE tenant_id = #{tenantId} " + + "AND advance_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementMapper.java index c2b86484..2fbe0db4 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementMapper.java @@ -10,6 +10,8 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.PreSettlement; /** @@ -19,4 +21,20 @@ import org.springblade.transport.pojo.entity.PreSettlement; */ @Mapper public interface PreSettlementMapper extends BaseMapper { + + /** + * 查询预结算单号最大流水(含逻辑删除)。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(pre_settlement_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_pre_settlement WHERE tenant_id = #{tenantId} " + + "AND pre_settlement_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxPreSettlementDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); + + /** + * 查询预结算转正式结算单号最大流水(含逻辑删除)。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(formal_settlement_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_pre_settlement WHERE tenant_id = #{tenantId} " + + "AND formal_settlement_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxFormalSettlementDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/SettlementAdjustmentMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/SettlementAdjustmentMapper.java index 1a2b5629..9a7b7e64 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/SettlementAdjustmentMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/SettlementAdjustmentMapper.java @@ -2,7 +2,19 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.SettlementAdjustment; @Mapper -public interface SettlementAdjustmentMapper extends BaseMapper {} +public interface SettlementAdjustmentMapper extends BaseMapper { + + /** + * 查询指定租户、指定日期前缀下已使用的最大结算调整单流水号。 + * 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(adjustment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_settlement_adjustment WHERE tenant_id = #{tenantId} " + + "AND adjustment_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportReconciliationMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportReconciliationMapper.java index e808e682..db46b800 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportReconciliationMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportReconciliationMapper.java @@ -22,4 +22,13 @@ public interface TransportReconciliationMapper extends BaseMapperlambdaQuery() - .likeRight(BillPayment::getPaymentNo, prefix)) + 1); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1); } private BigDecimal positive(BigDecimal value, String name) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java index cbfdb8e0..83462702 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java @@ -286,21 +286,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl throw new ServiceException("结算类型不正确"); }; String prefix = code + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); - List numbers = list(Wrappers.lambdaQuery() - .select(FormalSettlement::getFormalSettlementNo) - .likeRight(FormalSettlement::getFormalSettlementNo, prefix)) - .stream().map(FormalSettlement::getFormalSettlementNo).toList(); - int sequence = 1; - if (!numbers.isEmpty()) { - String latest = numbers.stream().max(String::compareTo).orElse(prefix); - try { - sequence = Integer.parseInt(latest.substring(prefix.length())) + 1; - } catch (RuntimeException ignored) { - sequence = Math.toIntExact(count(Wrappers.lambdaQuery() - .likeRight(FormalSettlement::getFormalSettlementNo, prefix))) + 1; - } - } - return prefix + String.format("%05d", sequence); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1); } @Override @@ -2190,11 +2177,11 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaQuery() - .likeRight(FormalSettlementPayment::getPaymentNo, prefix)); - return prefix + String.format("%04d", count + 1); + Long maxSequence = paymentMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%04d", (maxSequence == null ? 0L : maxSequence) + 1); } private BigDecimal money(BigDecimal value) { return (value == null ? BigDecimal.ZERO : value).setScale(2, RoundingMode.HALF_UP); } 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 10b197a8..6ca7eaef 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 @@ -1679,10 +1679,11 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpllambdaQuery() - .likeRight(InvoiceApplication::getApplicationNo, prefix)) + 1); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1); } private String normalizeDepartmentEmails(String value, List invoiceInfos) { 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 6b827dd5..9a3af520 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 @@ -2455,7 +2455,14 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery().likeRight(PaymentApplication::getPaymentNo, prefix)) + 1); } + /** + * 生成当日付款申请单号;按最大流水号 +1,且计入逻辑删除数据,避免撞 uk_payment_application_no + */ + private synchronized String nextNo() { + String prefix = "FKD" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1); + } private BigDecimal nonNegative(BigDecimal value, String name) { if (value == null || value.compareTo(BigDecimal.ZERO) < 0) throw new ServiceException(name + "不能小于0"); return value; } private BigDecimal money(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } private String limit(String value, int length) { if (value != null && value.length() > length) throw new ServiceException("备注不能超过" + length + "个字符"); return value; } 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 354ec65f..ff913107 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 @@ -85,7 +85,6 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; -import java.util.Comparator; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -1780,44 +1779,25 @@ public class PreSettlementServiceImpl extends BaseServiceImpllambdaQuery() - .likeRight(PreSettlementAdvance::getAdvanceNo, prefix) - .orderByDesc(PreSettlementAdvance::getAdvanceNo) - .last("limit 1")); - return prefix + String.format("%05d", nextSequence(latest == null ? null : latest.getAdvanceNo(), prefix)); - } - - private String nextDailyCode(String code, Function getter) { - String prefix = code + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); - PreSettlement latest = list(Wrappers.lambdaQuery() - .and(query -> query.likeRight(PreSettlement::getPreSettlementNo, prefix) - .or().likeRight(PreSettlement::getFormalSettlementNo, prefix)) - .orderByDesc(PreSettlement::getCreateTime) - .last("limit 1")).stream().max(Comparator.comparing(item -> { - String value = getter.apply(item); - return value == null ? "" : value; - })).orElse(null); - String latestCode = latest == null ? null : getter.apply(latest); - return prefix + String.format("%05d", nextSequence(latestCode, prefix)); - } - - private int nextSequence(String latestCode, String prefix) { - if (latestCode == null || !latestCode.startsWith(prefix)) return 1; - try { - return Integer.parseInt(latestCode.substring(prefix.length())) + 1; - } catch (NumberFormatException exception) { - return 1; - } + Long maxSequence = advanceMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java index 8dda1c1c..d6dd4a95 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java @@ -392,7 +392,12 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpllambdaQuery().likeRight(SettlementAdjustment::getAdjustmentNo, prefix)); return prefix + String.format("%04d", count + 1); } + /** 生成当日结算调整单号;按最大流水号 +1,且计入逻辑删除数据 */ + private synchronized String nextNo() { + String prefix = "TZ" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%04d", (maxSequence == null ? 0L : maxSequence) + 1); + } private BigDecimal money(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } private String requiredText(String value, String field, int max) { if (Func.isEmpty(value)) throw new ServiceException(field + "不能为空"); return limit(value.trim(), max); } private String limit(String value, int max) { if (value != null && value.length() > max) throw new ServiceException("内容不能超过" + max + "个字"); return value; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportReconciliationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportReconciliationServiceImpl.java index dff1f39b..065ec3b9 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportReconciliationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportReconciliationServiceImpl.java @@ -1512,20 +1512,14 @@ public class TransportReconciliationServiceImpl private BigDecimal sumExternalQuantity(List rows) { return rows.stream().map(TransportReconciliationExternal::getTransportQuantity).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); } private BigDecimal sumInternalAmount(List rows) { return rows.stream().map(TransportReconciliationInternal::getSettlementAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); } private BigDecimal sumExternalAmount(List rows) { return rows.stream().map(TransportReconciliationExternal::getSettlementAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); } + /** 生成当日运输对账单号;按最大流水号 +1,且计入逻辑删除数据 */ private synchronized String nextNo() { String prefix = "DZD" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); - int sequence = list(Wrappers.lambdaQuery() - .select(TransportReconciliation::getReconciliationNo) - .likeRight(TransportReconciliation::getReconciliationNo, prefix)) - .stream() - .map(TransportReconciliation::getReconciliationNo) - .filter(number -> number != null && number.length() == prefix.length() + 5) - .map(number -> number.substring(prefix.length())) - .filter(suffix -> suffix.chars().allMatch(Character::isDigit)) - .mapToInt(Integer::parseInt) - .max() - .orElse(0) + 1; - if (sequence > 99999) throw new ServiceException("当日运输对账单号流水已用完"); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + long sequence = (maxSequence == null ? 0L : maxSequence) + 1; + if (sequence > 99999) { + throw new ServiceException("当日运输对账单号流水已用完"); + } return prefix + String.format("%05d", sequence); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java index 0f92b9b4..2d52773c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java @@ -25,6 +25,7 @@ import org.springblade.system.cache.UserCache; import org.springblade.system.feign.ISysClient; import org.springblade.system.pojo.entity.DictBiz; import org.springblade.system.pojo.entity.CargoType; +import org.springblade.system.pojo.entity.MeasurementUnit; import org.springblade.transport.excel.WaybillImportBatchExcel; import org.springblade.transport.mapper.WaybillImportBatchMapper; import org.springblade.transport.pojo.dto.WaybillImportBatchRequest; @@ -919,13 +920,14 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl row, List quantityUnitOptions, List errors) { - String quantityUnit = stringValue(row, "quantityUnit", "数量单位"); + String quantityUnit = stringValue(row, "quantityUnit", "数量单位", "*数量单位"); if (Func.isEmpty(quantityUnit)) { return; } - - if (!quantityUnitOptions.contains(quantityUnit)) { - errors.add("数量单位必须为系统枚举值之一"); + String normalized = quantityUnit.trim(); + boolean matched = quantityUnitOptions.stream().anyMatch(option -> option.equalsIgnoreCase(normalized)); + if (!matched) { + errors.add("数量单位「" + normalized + "」未在计量单位主数据中匹配"); } } @@ -1186,9 +1188,28 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl loadQuantityUnitOptions() { - // 常见数量单位 - return List.of("吨", "千克", "立方米", "件", "箱", "台", "个", "升", "米", "平方米"); + R> response = sysClient.getMeasurementUnits(); + if (response == null || !response.isSuccess() || response.getData() == null) { + throw new ServiceException("计量单位基础数据读取失败,请稍后重试"); + } + Set options = new HashSet<>(); + for (MeasurementUnit unit : response.getData()) { + if (unit == null || (unit.getStatus() != null && !Objects.equals(unit.getStatus(), 1))) { + continue; + } + if (Func.isNotEmpty(unit.getUnitName())) { + options.add(unit.getUnitName().trim()); + } + if (Func.isNotEmpty(unit.getUnitCode())) { + options.add(unit.getUnitCode().trim()); + } + } + if (options.isEmpty()) { + throw new ServiceException("计量单位主数据为空,请先在基础数据中配置计量单位"); + } + return new ArrayList<>(options); } /** From 6d3c0bfef49f536263fc995cba008663eefeaf7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Mon, 19 Oct 2026 02:08:02 +0800 Subject: [PATCH 41/47] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E9=87=91?= =?UTF-8?q?=E8=9D=B6BFI=E6=93=8D=E4=BD=9C=E6=89=8B=E5=86=8C=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E4=BB=BB=E5=8A=A1=E8=BF=90=E8=BD=AC=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E4=B8=8E=E7=8A=B6=E6=80=81=E4=B8=AD=E6=96=87=E9=87=8A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md | 63 +++++++++++++++---- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md index d5097a95..270be3fa 100644 --- a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md +++ b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md @@ -248,21 +248,62 @@ ### 4.1 任务清单(Nacos `thirdparty.bfi.*` 配置) -| 任务 | 干什么 | 开关键 | cron 键(默认频率) | 手动替代按钮 | +| 任务 | 干什么 | 开关键 | cron 键(默认值) | 手动替代按钮 | |---|---|---|---|---| -| 付款结果回写 | 付款状态(paid/closed)+ 付款记录/凭证回写;顺带应收勾稽纠偏、开票结果回写 | `thirdParty.bfi.payResult.syncEnabled`(样例已开) | `…payResult.cron`(10 分钟) | 付款「同步付款结果」、开票结果手动端点、结算勾稽手动端点 | -| 收票结果回写 | 收票单状态回写、pushed 断点补发指定应付 | `…receivedInvoiceResult.syncEnabled` | 每 10 分钟 | 收票「同步金蝶结果」 | -| 客商同步 | 金蝶审核状态复查(synced→exist)、停用联动、failed 重推 | `…customer.syncEnabled` | 每天 02:00 | 客商「同步金蝶」 | -| 收款认领推送 | 自动推送认领完成的流水到金蝶 | `…receiptClaimPush.syncEnabled` | 每 10 分钟 | 流水行内「推送金蝶」 | -| 全票池同步 | 拉金蝶全票池发票到本地镜像、补绑定 | `…invoiceReceipt.syncEnabled` | 每 30 分钟 | 手动端点 `POST /invoice-receipt/sync-invoice-pool` | -| failed 重推 | 自动重推所有推送失败的结算/付款/开票/收票单 | `…autoSync.retryEnabled` | 每 10 分钟 | 各页面「同步金蝶」 | -| 锁单补偿 | 推送成功但金蝶锁定失败的自动补锁(≤5 次) | `…billLock.retryEnabled` | 每 5 分钟 | 手动端点 `POST /formal-settlement/retry-kingdee-bill-lock` | +| 付款结果回写 | 付款状态(paid/closed)+ 付款记录/凭证回写;顺带应收勾稽纠偏、开票结果回写 | `thirdParty.bfi.payResult.syncEnabled`(样例已开) | `…payResult.cron`(默认 `0 1/10 * * * ?`) | 付款「同步付款结果」、开票结果手动端点、结算勾稽手动端点 | +| 收票结果回写 | 收票单状态回写、pushed 断点补发指定应付 | `…receivedInvoiceResult.syncEnabled` | `…receivedInvoiceResult.cron`(默认 `0 8/10 * * * ?`) | 收票「同步金蝶结果」 | +| 客商同步 | 金蝶审核状态复查(synced→exist)、停用联动、failed 重推 | `…customer.syncEnabled` | `…customer.cron`(默认 `0 0 2 * * ?`) | 客商「同步金蝶」 | +| 收款认领推送 | 自动推送认领完成的流水到金蝶 | `…receiptClaimPush.syncEnabled` | `…receiptClaimPush.cron`(默认 `0 4/10 * * * ?`) | 流水行内「推送金蝶」 | +| 全票池同步 | 拉金蝶全票池发票到本地镜像、补绑定 | `…invoiceReceipt.syncEnabled` | `…invoiceReceipt.cron`(默认 `0 16/30 * * * ?`) | 手动端点 `POST /invoice-receipt/sync-invoice-pool` | +| failed 重推 | 自动重推所有推送失败的结算/付款/开票/收票单 | `…autoSync.retryEnabled` | `…autoSync.retryCron`(默认 `0 5/10 * * * ?`) | 各页面「同步金蝶」 | +| 锁单补偿 | 推送成功但金蝶锁定失败的自动补锁(≤5 次) | `…billLock.retryEnabled` | `…billLock.retryCron`(默认 `0 2/5 * * * ?`) | 手动端点 `POST /formal-settlement/retry-kingdee-bill-lock` | -### 4.2 审批自动同步开关(按业务域独立) +### 4.2 各任务的运转逻辑 + +**共性规则(先读这个)** + +- **终态不轮询**:付款 paid(已付款)/closed(已关闭)、开票 invoiced(已开票)、收票 assigned(已指定应付)、客商 exist(金蝶已审核)、认领流水 pushed(已推送金蝶)后自动退出扫描范围——任务只处理「还没走完」的单据,越跑越轻,不会重复处理。 +- **定时与手动同一套逻辑**:每个任务与它的「手动替代按钮」调用同一个服务方法,效果完全一致;区别只是触发方式。 +- **单条失败不拖批次**:一轮里某条单据失败只记录原因,同批其余照常处理;失败的下轮自动再试(兜底见 ⑥ failed 重推)。 +- **默认执行时间错峰**(付款 :01、认领 :04、failed 重推 :05、收票 :08、锁补偿每 5 分钟的 :02、票池每 30 分钟的 :16),互不挤占;自行改 cron 时建议保留错峰。 + +**① 付款结果回写(10 分钟)** +扫「审批通过且已推送 / 付款中 / 推送失败」的付款申请 → 到金蝶查付款申请单和付款处理单 → 推进 synced(已推送受理)→ paying(金蝶付款中)→ **paid(已付款)/ closed(已关闭)**,paid 时回写付款记录/凭证,并联动刷新预结算垫资、结算付款记录(3.4 第 3 步的「正常等定时回写」就是它)。 +- 自愈一(应收勾稽):已推送但失败、或缺金蝶单号的**应收结算单**,查金蝶有则自动补单号、纠回 synced——「实际推成功但本地显示失败」的场景自动痊愈; +- 自愈二(开票结果):已受理的开票申请自动落发票信息并推进 **invoiced(已开票,终态)**(3.5 第 3 步的定时回写就是它); +- 防呆(金蝶删单):synced(已推送受理)/paying(金蝶付款中)的单据**连续 2 轮(约 20 分钟)在金蝶查不到** → 判定金蝶侧已删,自动解锁置 failed(推送失败)并清空金蝶单号,等人工核实后重推;重推带幂等预检,金蝶单据其实还在时会自动回填,不会重复生成。 + +**② 收票结果回写(10 分钟)** +扫 pushed(金蝶收票单已生成)/ assigned(已指定应付)的收票登记,按近 **30 天**回看窗口查金蝶收票单,一轮处理三种情况: +- 票在金蝶被作废/红冲 → 置 failed(推送失败)并提示人工处理(**不要重推**,见 3.6); +- 卡在 pushed → 自动补发「指定应付」推进 **assigned(已指定应付,终态)**(3.6 说的「卡在 pushed 不用管」就是它); +- assigned 但缺金蝶收票单号 → 自动补登单号。 +窗口内查不到的保持现状不动(不判删除),由人工重推兜底。 + +**③ 客商同步(每天 02:00)** +一次扫全部审批通过客商,干三件事: +- **未推送/推送失败** → 先复查金蝶:已有且已审核的直接置 **exist(金蝶已有该客商且审核通过,终态)**(不重复建档);金蝶侧审核中则置 failed(推送失败)提示稍后;确实没有才推送; +- **已推送(synced,已推送受理)** → 金蝶审核通过自动升级 **exist(金蝶已审核,终态)**(3.1 第 5 步的定时升级就是它); +- **金蝶侧停用(enable=0)** → TMS 档案联动停用;金蝶恢复可用**不会**自动启用(单向联动,需人工启用)。 + +**④ 收款认领推送(10 分钟)** +扫「认领完成(claimed)且未推送/推送失败」的收款流水 → 按认领人逐人拉最新认领公告**对账**(本地认领金额与公告未认领金额一致才推)→ 推送成功回写金蝶收款单号、流水置 **pushed(已推送金蝶)**(3.7 第 3 步的「自动」就是它)。失败的下一轮自动重试,防重码按分摊结算单生成、重试不变,不会在金蝶重复生成收款单。 + +**⑤ 全票池同步(30 分钟)** +按近 **30 天**增量窗口分页拉取金蝶全票池发票到本地镜像表,收票选票、票池查询全部走本地镜像——所以**刚在金蝶开的票要等同步后才能选到**;急用可让运维手动触发同步端点,或用票号精确搜索(会自动去金蝶现查,见 3.6)。 + +**⑥ failed 重推(10 分钟)** +只重推 **failed(推送失败)** 单据(结算 → 付款 → 开票 → 收票四个域依次处理);**unsynced(审批通过但从未推送)不会自动推**——历史存量单不会被上线后批量补推,仍走手动按钮。前置没就绪(如上游应付单还没生成)会再次 failed、下轮继续;前置就绪后自动续推成功。 +⚠️ 重推**没有次数上限**:根因不修(联行号缺失、组织编码缺失等)会每 10 分钟失败一次刷日志,修复根因是唯一正解(呼应 4.4)。 + +**⑦ 锁单补偿(5 分钟)** +扫「推送成功但金蝶锁定失败」的锁记录,重试加锁,**最多 5 次**;超过 5 次放弃,需运维调手动补偿端点 `POST /formal-settlement/retry-kingdee-bill-lock`(对应 FAQ 的「锁定失败」标签)。 + +### 4.3 审批自动同步开关(按业务域独立) `thirdParty.bfi.autoSync.settlement`(结算单)/ `…payment`(付款)/ `…invoiceApply`(开票)/ `…receipt`(收票),默认全关。**代码默认全部关闭,必须在 Nacos 显式开启**;客商档案审批推送不受此开关控制(始终自动)。 -### 4.3 运维注意 +### 4.4 运维注意 - 所有定时任务带**分布式锁**:多实例部署只跑一份;手动按钮和定时共用锁,定时运行中手动触发会直接跳过(属正常)。 - 开了「failed 重推」后,持续失败的单据每 10 分钟会重试一次,**修复根因**(配置/数据)比反复观察更重要。 @@ -324,4 +365,4 @@ --- -> 本文基于 2026-10-14 代码现状整理;系统升级后按钮与提示如有出入,以页面实际为准,并核对全链路总纲文档的更新说明。 +> 本文基于 2026-10-14 代码现状整理;2026-10-19 依据当前代码核对并补充 4.2 定时任务运转逻辑(cron 默认值、错峰、终态不轮询、防呆与自愈行为)。系统升级后按钮与提示如有出入,以页面实际为准,并核对全链路总纲文档的更新说明。 From d9d405cfe4ac115ff2d197d49ecf5e6344a98c2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Mon, 19 Oct 2026 11:10:56 +0800 Subject: [PATCH 42/47] =?UTF-8?q?fix:=20=E9=87=91=E8=9D=B6=E4=BB=98?= =?UTF-8?q?=E6=AC=BE=E7=94=B3=E8=AF=B7=E9=83=A8=E9=97=A8=E7=BC=96=E7=A0=81?= =?UTF-8?q?=E6=94=B9=E4=BC=A0=E9=A1=B9=E7=9B=AE=E4=B8=9A=E5=8A=A1=E9=83=A8?= =?UTF-8?q?=E9=97=A8=E8=87=AA=E8=BA=AB=E9=87=91=E8=9D=B6=E7=BC=96=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A103-3 付款申请保存 awrp_department_number 原与结算/申请/付款组织同传上溯平台公司编码,现拆分:组织三字段仍走 kingdeeOrgNumber(平台公司),部门编码新增 kingdeeDeptNumber 取业务部门自身 blade_dept.kingdee_dept_code(不上溯,未维护拦截) --- .../impl/PaymentApplicationServiceImpl.java | 4 +- .../support/KingdeeApBillSyncSupport.java | 43 ++++++++++++++----- 2 files changed, 35 insertions(+), 12 deletions(-) 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 c63ac3eb..8789361f 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 @@ -1223,8 +1223,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl Date: Mon, 19 Oct 2026 11:11:19 +0800 Subject: [PATCH 43/47] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0BFI=E6=8E=A8?= =?UTF-8?q?=E9=80=81=E6=98=A0=E5=B0=84=E8=A1=A8=E7=BB=84=E7=BB=87/?= =?UTF-8?q?=E9=83=A8=E9=97=A8=E7=BC=96=E7=A0=81=E5=8F=A3=E5=BE=84=E5=8F=8A?= =?UTF-8?q?=E4=BB=98=E6=AC=BE=E7=94=B3=E8=AF=B7=E6=8A=80=E6=9C=AF=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 映射表:组织类字段统一为公司级平台公司编码口径,A103-3 拆分 awrp_department_number(部门级 kingdeeDeptNumber)与结算/申请/付款组织(kingdeeOrgNumber),补充组织编码层级说明;付款申请技术文档:组织字段来源改为 kingdeeOrgNumber/kingdeeDeptNumber,未传字段清单同步 --- ...€š)BFI推送入参取值映射表(页面字段-表字段-配置对照).md | 14 +++++++++----- .../业务流程/付款申请同步金蝶(星瀚)技术文档.md | 5 +++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/doc/docs/业务流程/2026-10-15-金蝶(星瀚)BFI推送入参取值映射表(页面字段-表字段-配置对照).md b/doc/docs/业务流程/2026-10-15-金蝶(星瀚)BFI推送入参取值映射表(页面字段-表字段-配置对照).md index 80c1b141..21f883f3 100644 --- a/doc/docs/业务流程/2026-10-15-金蝶(星瀚)BFI推送入参取值映射表(页面字段-表字段-配置对照).md +++ b/doc/docs/业务流程/2026-10-15-金蝶(星瀚)BFI推送入参取值映射表(页面字段-表字段-配置对照).md @@ -41,7 +41,8 @@ | 项 | 规则 | 代码 | |---|---|---| | awrp_src_sys / systemsource / sourceSystem | Nacos `thirdparty.bfi.srcSys`,默认常量 `WLTMS` | BfiProperties(srcSys) | -| 组织 org/结算/付款/收款/申请组织 | 合同/申请单所属组织 → `SysCache.getDept()` → **部门金蝶组织编码**,空拦截 | `blade_contract_manage.organization_id` / 申请单 dept_id → `blade_dept.kingdee_dept_code`(KingdeeApBillSyncSupport.bfiOrgNumber L933-944) | +| 组织 org/结算/付款/收款组织(核算/资金类) | **统一取公司级编码**:项目业务部门沿部门树向上反查平台公司(`is_platform_company=1`)→ 平台公司 `blade_dept.kingdee_dept_code`;未归属平台公司/未维护编码直接拦截 | `kingdeeOrgNumber`(KingdeeApBillSyncSupport L976-999),项目经 `blade_project_apply.business_dept_id` | +| 申请部门类字段(A303-1 awrp_orgfield) | 申请部门自身 `kingdee_dept_code`(部门级业务单元编码,星瀚允许) | 申请单 dept_id → `blade_dept.kingdee_dept_code` | | 币种 | 合同结算币种,`RMB→CNY` | `blade_contract_manage.settlement_currency`(bfiCurrency L952-956) | | 往来户 | 名称 → `blade_customer_archive` 按 `full_name/short_name` 匹配(limit 1)取 `customer_code`,查不到报错 | queryCustomerCode L966-984 | | 日期文本 | 统一 `yyyy-MM-dd HH:mm:ss` | bfiDateTimeText L922-925 | @@ -133,7 +134,7 @@ | awrp_src_sys【必】 | 外部系统编号 | 配置 | `thirdparty.bfi.srcSys`(WLTMS) | | awrp_src_type【必】 | 外部单据类型 | — | 常量 `TMS财务应付单` | | billtypeid_number【必】 | 单据类型.编码 | — | 常量字典「服务采购应付」→ `ApFin_service_BT_S` | -| org_number / payorg_number【必】 | 结算/付款组织.编码 | 合同所属组织 | blade_contract_manage.organization_id → blade_dept.kingdee_dept_code | +| org_number / payorg_number【必】 | 结算/付款组织.编码 | 合同关联项目业务部门上溯平台公司 | kingdeeOrgNumber → 平台公司 `blade_dept.kingdee_dept_code`(公司级核算组织编码) | | bizdate【必】 | 单据日期 | 同步当日 | LocalDate.now() | | asstacttype【必】 | 往来类型 | — | 常量 `bd_customer`(固定,供应商分支已注释) | | asstact_number【必】 | 往来户.编码 | 合同乙方 | blade_contract_manage.party_b → blade_customer_archive.customer_code | @@ -205,7 +206,8 @@ | 金蝶入参 | 含义 | 来源 | 表.字段 / 配置 / 常量 | |---|---|---|---| | billtype_number【必】 | 单据类型 | 配置 | `thirdparty.bfi.payApply.billtype`(默认 `ap_payapply_oth_BT_S`) | -| settleorg/awrp_department/applyorg/payorg_number【必】 | 组织/部门 | 合同所属组织 | blade_contract_manage.organization_id → blade_dept.kingdee_dept_code | +| settleorg/applyorg/payorg_number【必】 | 结算/申请/付款组织.编码 | 合同关联项目业务部门上溯平台公司 | kingdeeOrgNumber → 平台公司 `blade_dept.kingdee_dept_code`(公司级核算组织编码) | +| awrp_department_number【必】 | 部门.编码 | 项目业务部门自身 | kingdeeDeptNumber → 业务部门 `blade_dept.kingdee_dept_code`(部门级编码,不上溯平台公司;未维护拦截) | | paycurrency/settlecurrency_number【必】 | 币种 | 合同结算币种(RMB→CNY) | blade_contract_manage.settlement_currency | | applycause | 请款事由 | 按付款类型映射文案:项目垫资→「项目预付款申请」、进度垫资→「进度预付款申请」、结算付款→「结算付款申请」 | blade_payment_application.payment_type | | exratetable_number【必】 | 汇率表 | 配置(**此接口实际送**) | `thirdparty.bfi.payApply.exratetable`(默认 `01`) | @@ -228,7 +230,8 @@ | 金蝶入参 | 含义 | 页面字段 | 表.字段 / 配置 / 常量 | |---|---|---|---| | billno【必·候选键】 | 单据编号 | 开票申请单号(多张加张次序号) | blade_invoice_application.application_no(多张 + "-" + blade_invoice_application_sheet.sheet_no) | -| orgid_number / awrp_orgfield_number【必】 | 组织/申请部门 | 申请部门 | blade_invoice_application.dept_id → blade_dept.kingdee_dept_code | +| orgid_number【必】 | 组织.编码 | 申请关联项目业务部门上溯平台公司 | kingdeeOrgNumber → 平台公司金蝶组织编码(绑定税号的核算组织) | +| awrp_orgfield_number【必】 | 申请部门.编码 | 申请部门 | blade_invoice_application.dept_id → blade_dept.kingdee_dept_code(部门级业务单元编码) | | billdate【必】 | 单据日期 | 申请日期(空取当天) | blade_invoice_application.application_date | | billproperties / taxationstyle / buyerproperty | 单据性质/征税方式/购方类型 | 固定 | `"1"` / `"0"` / `"0"` | | invoicetype【必】 | 发票种类 | 发票类型:数电专票→`08xdp`,其余→`10xdp` | blade_invoice_application.invoice_type | @@ -293,7 +296,7 @@ | invoicetypef7_number【必】 | 发票类型.编码 | 票易通原始编码(缺失拦截并按票号回源 A801-1 补填) | blade_kingdee_invoice_pool.invoice_type_code | | paymenttype【必】 | 付款方式 | 固定现购 | `CASH` | | asstacttype【必】 | 往来类型 | 统一固定客户(与应付单/付款申请同口径) | `bd_customer` | -| org_number / currency_number【必】 | 组织/币种 | 分摊应付结算单关联合同 | blade_contract_manage.organization_id / settlement_currency | +| org_number / currency_number【必】 | 组织/币种 | 分摊应付结算单关联合同 → 项目业务部门上溯平台公司 | kingdeeOrgNumber → 平台公司编码 / settlement_currency | | receivablessupp_number【必】 | 往来户.编码 | 合同乙方→客商编码 | blade_contract_manage.party_b → customer_code | | exratetable_number【必】 | 汇率表 | 固定 | `"01"` | | asstactname / buyertin / sellertin【必】 | 销方名称/购销方税号 | 票池镜像票面 | blade_kingdee_invoice_pool.issuer_name / buyer_tax_no / saler_tax_no | @@ -401,5 +404,6 @@ multipart 上传:`file`=文件流,`args`=JSON `{entityNumber, billPkId, cont | 4 | A301-2 claimusers | 只读 Nacos 配置,方法入参(当前登录人姓名)已不参与取值;billstatus 字段存在但不传 | | 5 | A801-1 expenseStatus | 同步**不下推**(常量已定义未使用),「在用/已用票随窗口刷新」,可选票过滤在本地 SQL | | 6 | 应付 asstact_number | 取**合同乙方 party_b**(payer_name 仅应收侧使用) | +| 7 | 组织编码层级 | 核算/结算/付款/收款/开票**组织类字段已统一改为公司级编码**:`kingdeeOrgNumber`(L976-999)按项目业务部门沿部门树上溯 `is_platform_company=1` 平台公司取其 `kingdee_dept_code`;仅 A303-1 申请部门(awrp_orgfield)保留部门级编码。部门级编码(如工业物流中心 01.02.05.06.01.015)不再直接作为结算/付款组织送出,自动替换为其平台公司编码(如国安运输 01.02.05.06.01);前置数据要求见《2026-10-16-金蝶组织编码部门数据维护清单》 | > 校验提醒:以上配置键的**环境实际值**以 Nacos 为准(本文只列键名与代码默认值);blade_* 表列名按 Entity 驼峰↔下划线约定标注,个别快照列以建表 SQL 为准。 diff --git a/doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md b/doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md index 4e922bfd..13b52fd4 100644 --- a/doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md +++ b/doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md @@ -131,7 +131,8 @@ TMS 付款申请审核通过 | --- | --- | --- | --- | | awrp_src_code | 来源单据号(防重键) | `paymentNo` | 付款申请单号 | | awrp_src_sys | 外部系统编号(防重键) | 配置 `payApply.srcSys` | 默认 `WLTMS` | -| settleorg_number / applyorg_number / payorg_number / purorg_number | 结算/申请/付款/采购组织编码 | 合同所属组织 | `organizationId` → 组织编码 `deptCode`,为空回退组织名称;缺失报错 | +| settleorg_number / applyorg_number / payorg_number | 结算/申请/付款组织编码 | 合同关联项目业务部门上溯平台公司 | `kingdeeOrgNumber` → 平台公司 `blade_dept.kingdee_dept_code`;缺失报错 | +| awrp_department_number | 部门编码 | 项目业务部门自身 | `kingdeeDeptNumber` → 业务部门 `blade_dept.kingdee_dept_code`(部门级编码,不上溯平台公司);未维护报错 | | billtype_number | 单据类型编码 | 配置 `payApply.billtype` | 默认 `ap_payapply_oth_BT_S` 其他付款申请 | | applydate | 申请日期 | `applyDate` | yyyy-MM-dd,空取当天 | | applycause | 请款事由 | paymentType 映射 | 见 5.1 | @@ -158,7 +159,7 @@ TMS 付款申请审核通过 | awrp_entry_code | 对方唯一行 id(防重) | `paymentNo + "-1"` | 不得重复 | | e_remark | 备注 | `remark` | 可选 | -未传的可选字段:purdept_number、awrp_department_number、e_duedate、e_settlementtype_number、e_costcenter_number、awrp_src_con_eno、awrp_splb_number。 +未传的可选字段:purorg_number、purdept_number、e_duedate、e_settlementtype_number、e_costcenter_number、awrp_src_con_eno、awrp_splb_number。 > 前置校验:推送前按 `contractId` 加载合同,未关联合同、合同不存在、所属组织编码为空、结算币种为空时,同步失败并标记 `kingdeeStatus = failed`。 From 7858ba30aca9d02e2fff7e2418b8b170adcdc181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Mon, 19 Oct 2026 11:19:03 +0800 Subject: [PATCH 44/47] =?UTF-8?q?docs:=20=E4=BB=98=E6=AC=BE=E7=94=B3?= =?UTF-8?q?=E8=AF=B7=E5=90=8C=E6=AD=A5=E9=87=91=E8=9D=B6=E6=8A=80=E6=9C=AF?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E6=8C=89=E7=9B=AE=E5=BD=95=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=97=A5=E6=9C=9F=E5=89=8D=E7=BC=80=EF=BC=88?= =?UTF-8?q?2026-09-16=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...(星瀚)技术文档.md => 2026-09-16-付款申请同步金蝶(星瀚)技术文档.md} | 0 ...付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md | 4 ++-- ...˜款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md | 2 +- ...3-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename doc/docs/业务流程/{付款申请同步金蝶(星瀚)技术文档.md => 2026-09-16-付款申请同步金蝶(星瀚)技术文档.md} (100%) diff --git a/doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md b/doc/docs/业务流程/2026-09-16-付款申请同步金蝶(星瀚)技术文档.md similarity index 100% rename from doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md rename to doc/docs/业务流程/2026-09-16-付款申请同步金蝶(星瀚)技术文档.md diff --git a/doc/docs/业务流程/2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md b/doc/docs/业务流程/2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md index fc8fd06a..a7e9df35 100644 --- a/doc/docs/业务流程/2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md +++ b/doc/docs/业务流程/2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md @@ -2,7 +2,7 @@ > 适用系统:TMS(BladeX 微服务平台,服务:blade-transport) > 对接系统:金蝶云·星瀚(业财融合 BFI,经 iPaaS 网关) -> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`、`doc/docs/业财融合系统接口文档.xlsx`、`doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md`(A103-3 预付款路径) +> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`、`doc/docs/业财融合系统接口文档.xlsx`、`doc/docs/业务流程/2026-09-16-付款申请同步金蝶(星瀚)技术文档.md`(A103-3 预付款路径) > 最后更新:2026-09-18(字段映射与联调默认值按当前代码重新梳理) --- @@ -15,7 +15,7 @@ - 对接接口: - **A102-2 财务应付单保存** `POST /ierp/kapi/v2/awrp/ap/ap/ap_finapbill/save` - **A103-1 付款申请保存(应付下推)** `POST /ierp/kapi/v2/awrp/ap/ap/ap_finapbill/push` -- 路径划分:**正式付款**(settlement_payment)走本文档 A102-2 → A103-1 两步链路;**预付款**(project_advance / progress_advance)维持原 A103-3 直推付款申请单,见《付款申请同步金蝶(星瀚)技术文档》 +- 路径划分:**正式付款**(settlement_payment)走本文档 A102-2 → A103-1 两步链路;**预付款**(project_advance / progress_advance)维持原 A103-3 直推付款申请单,见《2026-09-16-付款申请同步金蝶(星瀚)技术文档》 - 调用方式:前端 → TMS 后端转发 → 星瀚(OAuth2 凭证仅存后端,前端不持有) - 落库结果:A103-1 返回的付款申请单 id 写入 `kingdee_bill_no`(既有字段);**A102-2 返回的应付单 id 不落库**,仅校验保存成功,后续如需可经 A102-4 按防重键(`awrp_src_sys` + `awrp_src_code`)查询(`BfiServiceImpl.queryApBill` 已封装),本次无表结构变更 diff --git a/doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md b/doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md index 46d7f4d1..4f80f684 100644 --- a/doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md +++ b/doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md @@ -2,7 +2,7 @@ > 适用系统:TMS(BladeX 微服务平台,服务:blade-transport) > 对接系统:金蝶云·星瀚(业财融合 BFI,经 iPaaS 网关) -> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`(4.3 节步骤 11)、`付款申请同步金蝶(星瀚)技术文档.md`、`2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md`、`2026-09-17-BFI分页查询通用翻页工具(BfiUtil-queryAllPages)技术文档.md` +> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`(4.3 节步骤 11)、`2026-09-16-付款申请同步金蝶(星瀚)技术文档.md`、`2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md`、`2026-09-17-BFI分页查询通用翻页工具(BfiUtil-queryAllPages)技术文档.md` > 最后更新:2026-09-17 --- diff --git a/doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md b/doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md index 130d952c..c72838ed 100644 --- a/doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md +++ b/doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md @@ -4,7 +4,7 @@ > 关联文档: > - `doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`(图03 财务应付及付款,步骤4 推送金蝶 / 步骤13 异常回收) > - `doc/adr/0005-B901事件驱动双表锁单模型.md`(UNLOCK 置失败态不自动重推;A103-2 连续两轮查询消失兜底复用 `unlockBill()`) -> - `doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md`(A103-3 接口定义) +> - `doc/docs/业务流程/2026-09-16-付款申请同步金蝶(星瀚)技术文档.md`(A103-3 接口定义) > - `doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md` > - `doc/docs/业务流程/2026-09-18-付款管理金蝶同步全链路逻辑梳理(推送链路与回写链路连贯性).md` From c5d5687999a7689e2818963ce9fc15a36cc19b76 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Mon, 19 Oct 2026 12:06:49 +0800 Subject: [PATCH 45/47] =?UTF-8?q?:sparkles:=20=E5=AE=8C=E5=96=84=E4=BB=98?= =?UTF-8?q?=E6=AC=BE=E7=94=B3=E8=AF=B7=E5=A4=9A=E7=BB=93=E7=AE=97=E8=A1=8C?= =?UTF-8?q?=E3=80=81=E7=BB=93=E7=AE=97=E5=AF=BC=E5=85=A5=E4=B8=8E=E8=AE=A1?= =?UTF-8?q?=E8=B4=B9=E6=96=B9=E5=BC=8F=E5=9B=9E=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../dto/PaymentApplicationSaveRequest.java | 2 + ...ymentApplicationSettlementLineRequest.java | 43 ++++++ .../ReceivablePayableFeeCalculateRequest.java | 6 + .../pojo/entity/PreSettlementDetail.java | 5 + .../pojo/vo/ReceivablePayableDetailVO.java | 4 + .../springblade/system/mapper/UserMapper.java | 12 +- .../springblade/system/mapper/UserMapper.xml | 23 +-- .../system/service/impl/DeptServiceImpl.java | 4 +- .../system/service/impl/UserServiceImpl.java | 4 +- .../excel/SettlementImportFeeReader.java | 21 ++- .../impl/FormalSettlementServiceImpl.java | 2 +- .../impl/PaymentApplicationServiceImpl.java | 131 +++++++++++++++- .../impl/PreSettlementServiceImpl.java | 31 +++- .../ReceivablePayableDetailServiceImpl.java | 145 +++++++++++++++--- .../SettlementImportBatchServiceImpl.java | 50 +++++- .../impl/WaybillImportBatchServiceImpl.java | 22 ++- doc/sql/bladex/bladex.mysql.all.create.sql | 4 +- .../blade_user_dept_index_20261019.sql | 6 + doc/sql/transport/transport.sql | 4 +- 19 files changed, 449 insertions(+), 70 deletions(-) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSettlementLineRequest.java create mode 100644 doc/sql/transport/blade_user_dept_index_20261019.sql 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 df7740a0..d608d7cb 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 @@ -61,4 +61,6 @@ public class PaymentApplicationSaveRequest implements Serializable { private String remark; private List invoices; private List paymentRecords; + /** 多张结算单时按行提交的付款金额 */ + private List settlementLines; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSettlementLineRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSettlementLineRequest.java new file mode 100644 index 00000000..78f28a11 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSettlementLineRequest.java @@ -0,0 +1,43 @@ +/** + * 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.pojo.dto; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** 付款申请多结算单付款明细。 @author Chill */ +@Data +public class PaymentApplicationSettlementLineRequest implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + private Long formalSettlementId; + private Long preSettlementId; + private BigDecimal paymentRatio; + private BigDecimal appliedAmount; +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableFeeCalculateRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableFeeCalculateRequest.java index 640ac839..04f1cd42 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableFeeCalculateRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableFeeCalculateRequest.java @@ -63,4 +63,10 @@ public class ReceivablePayableFeeCalculateRequest implements Serializable { @Schema(description = "动态费用项目") private Map feeItems; + @Schema(description = "运输单价") + private BigDecimal unitPrice; + + @Schema(description = "本次调整字段:transportQuantity、mileage、unitPrice 或费用项名称") + private String changedField; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PreSettlementDetail.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PreSettlementDetail.java index 2539ca07..76c5653c 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PreSettlementDetail.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PreSettlementDetail.java @@ -9,6 +9,7 @@ */ package org.springblade.transport.pojo.entity; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -99,6 +100,10 @@ public class PreSettlementDetail extends TenantEntity { @Schema(description = "批次号") private String batchNo; + @TableField(exist = false) + @Schema(description = "计费方式") + private String billingType; + @Schema(description = "运输单价") private BigDecimal unitPrice; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceivablePayableDetailVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceivablePayableDetailVO.java index 44857626..5fe0ab5d 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceivablePayableDetailVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceivablePayableDetailVO.java @@ -73,6 +73,10 @@ public class ReceivablePayableDetailVO extends ReceivablePayableDetail { @Schema(description = "状态名称") private String settlementStatusName; + @TableField(exist = false) + @Schema(description = "计费方式") + private String billingType; + @TableField(exist = false) @Schema(description = "费用项目") private Map feeItems; diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.java b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.java index 76a4be9f..a252c387 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.java @@ -45,13 +45,13 @@ public interface UserMapper extends BaseMapper { /** * 自定义分页 * - * @param page - * @param user - * @param deptIdList - * @param tenantId - * @return + * @param page 分页 + * @param user 查询条件 + * @param deptId 部门 ID(含自身及子孙,由 SQL FIND_IN_SET 过滤;为空则不过滤部门) + * @param tenantId 租户 ID + * @return 用户列表 */ - List selectUserPage(IPage page, @Param("user") User user, @Param("deptIdList") List deptIdList, @Param("tenantId") String tenantId); + List selectUserPage(IPage page, @Param("user") User user, @Param("deptId") Long deptId, @Param("tenantId") String tenantId); /** * 获取用户 diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml index 23fff0b7..1cdec59f 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml @@ -61,20 +61,21 @@ and bu.status = #{user.status} - + and bu.id in ( - SELECT - user_id - FROM - blade_user_dept - WHERE - status = 1 and is_deleted = 0 and dept_id IN - - #{item} - + select distinct bud.user_id + from blade_user_dept bud + where bud.status = 1 + and bud.is_deleted = 0 + and bud.dept_id in ( + select bd.id + from blade_dept bd + where bd.is_deleted = 0 + and (bd.id = #{deptId} or find_in_set(#{deptId}, bd.ancestors) > 0) + ) ) - ORDER BY bu.id + ORDER BY bu.create_time DESC, bu.id DESC