From ed6a173b8678a43d260b89b8d10a4d2e0e0f99af Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 27 Oct 2026 15:30:28 +0800 Subject: [PATCH] fix(finance): preserve voided receivables and return current revisions --- deploy/sql/README.md | 2 + ...ce-receivable-active-uniqueness-repair.sql | 11 ++++ deploy/sql/头程数据库统一增量-MySQL8.sql | 3 +- .../ReconciliationStatementService.java | 7 ++- finance/src/main/resources/db/finance.sql | 3 +- .../FinanceSettlementSchemaContractTest.java | 53 +++++++++++++++++++ .../FinanceSqlCommentContractTest.java | 2 +- .../ReconciliationStatementBehaviorTest.java | 25 +++++++++ 8 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 deploy/sql/finance-receivable-active-uniqueness-repair.sql diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 7e3657d50..0908be9c7 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,7 @@ # 单测试环境数据库空库初始化 +`finance-receivable-active-uniqueness-repair.sql` 为 TEST 应收作废后重新建单的定向约束修复,仅以一条原子 ALTER 将 `baiyue_finance.fin_settlement` 的全历史唯一键替换为有效应收唯一键;作废历史仍保留原对账单关联,同一客户、同一对账单最多一张未作废应收。须先具备 Finance 重开校验版本 `fe89b8a2b875`,再单独授权执行 SQL;不修改业务金额、不清理或回填数据、不重放空库基线。执行前后核对表结构与记录总数,遇错停止,结果不明确先回查;脚本存在不表示已执行。 + `overseas-fees-storage-rent-structure-add.sql` 为指定 TEST 海外费用/仓租的定向结构补齐,只增加缺失的 `ll_storage_rent_inbound` 和 `ll_finance_fee_source.reviewer_name`。全部前置检查在首个业务 DDL 前完成;已有结构匹配时跳过,不匹配则停止,不覆盖或回填。随后按单独授权执行已有 `overseas-storage-rent-zero-fee-repair.sql`,补齐自动免费仓租的金额与汇率约束。使用独立连接、遇错停止,不重放空库初始化;DDL 隐式提交,失败先回查,不删除已完成项。两步范围与发布清单见[海外费用数据库定义汇总](20261022-海外费用仓租数据库变更汇总.md)及[发布配套](../../lastleg-service/docs/overseas-fees-storage-rent-release.md#发布配套)。文件准备不代表已执行或部署。 车辆与司机档案照片复用:先执行 `lastleg-fleet-resource-photos.sql` 新增照片版本表,再配套发布 File、Lastleg 和 Admin。车辆详情/司机详情维护照片,排车保存时复制缺失照片为当趟证据;已有照片不被后续档案替换覆盖。驾驶证统一接受 PDF/JPG/PNG。源基线与海外空库基线已同步;本次未执行环境 SQL 或部署。 diff --git a/deploy/sql/finance-receivable-active-uniqueness-repair.sql b/deploy/sql/finance-receivable-active-uniqueness-repair.sql new file mode 100644 index 000000000..0395e5b97 --- /dev/null +++ b/deploy/sql/finance-receivable-active-uniqueness-repair.sql @@ -0,0 +1,11 @@ +-- 仅用于单独授权的 TEST:修正作废应收仍占用原对账单的唯一约束。 +-- 不更新、删除或回填业务记录;作废单的原对账单关联与修订历史保持不变。 +-- 一条原子 ALTER 同时替换约束;有效应收重复、原索引缺失或目标列已存在均应报错停止。 +-- 使用独立连接并开启遇错停止;若结果不明确,先核对结构,不盲目重放。 +-- 前置:已部署 Finance 结算重开校验(fe89b8a2b875),目标为 MySQL 8 / InnoDB。 +ALTER TABLE baiyue_finance.fin_settlement + ADD COLUMN active_receivable_statement_id BIGINT UNSIGNED + GENERATED ALWAYS AS (CASE WHEN direction='RECEIVABLE' AND status<>'VOIDED' THEN reconciliation_statement_id ELSE NULL END) + VIRTUAL COMMENT '有效应收占用的对账单;作废单不占用,历史关联仍保留', + DROP INDEX uk_fin_settlement_receivable_statement, + ADD UNIQUE KEY uk_fin_settlement_active_receivable (tenant_id, active_receivable_statement_id); diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index cadcdd050..244e6525d 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -5044,6 +5044,7 @@ CREATE TABLE fin_settlement ( due_date date NOT NULL COMMENT '结算到期日期', payment_status varchar(24) NOT NULL DEFAULT 'UNPAID' COMMENT '收付款状态:UNPAID未收付、PARTIALLY_PAID部分收付、PAID已结清', status varchar(24) NOT NULL DEFAULT 'ISSUED' COMMENT '结算状态:ISSUED已生效、PARTIALLY_PAID部分收付、PAID已结清、VOIDED已作废', + active_receivable_statement_id bigint unsigned GENERATED ALWAYS AS (CASE WHEN direction='RECEIVABLE' AND status<>'VOIDED' THEN reconciliation_statement_id ELSE NULL END) VIRTUAL COMMENT '有效应收占用的对账单;作废单不占用,历史关联仍保留', version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', created_by bigint unsigned NOT NULL COMMENT '创建操作人ID', updated_by bigint unsigned NOT NULL COMMENT '最后修改操作人ID', @@ -5051,7 +5052,7 @@ CREATE TABLE fin_settlement ( updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', PRIMARY KEY (id), UNIQUE KEY uk_fin_settlement_no (tenant_id,settlement_no), - UNIQUE KEY uk_fin_settlement_receivable_statement (tenant_id,reconciliation_statement_id,direction), + UNIQUE KEY uk_fin_settlement_active_receivable (tenant_id,active_receivable_statement_id), KEY idx_fin_settlement_customer_direction_status (tenant_id,direction,status), KEY idx_fin_settlement_supplier_status (supplier_id,direction,status), KEY idx_fin_settlement_receivable_statistics (direction,currency_code,generated_date,status,tenant_id), diff --git a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java index e4ef8262f..ee4fc7f2e 100644 --- a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java @@ -114,6 +114,7 @@ public class ReconciliationStatementService { return detail(request.customerId(), statementId); } + /** 推进对账单状态并保存修订,响应包含本次操作刚写入的审计记录。 */ @Transactional public ReconciliationDetail action(long operatorId, long statementId, String key, ReconciliationActionRequest request) { validateAction(statementId, request); @@ -152,9 +153,11 @@ public class ReconciliationStatementService { ReconciliationDetail after = detail(before.customerId(), statementId); audit(before.customerId(), statementId, "RECONCILIATION_" + action, request.reason(), before, after, operatorId); complete(before.customerId(), operatorId, "RECONCILIATION_ACTION", key, statementId); - return after; + // 审计在金额/状态快照之后写入,返回前重新读取,避免首个响应缺少最新修订。 + return detail(before.customerId(), statementId); } + /** 调整已出账明细并保留前后金额,响应与随后查询的修订历史一致。 */ private ReconciliationDetail adjust(long operatorId, long statementId, String key, ReconciliationActionRequest request, Bill before) { if (!"ISSUED".equals(before.status())) throw error("FIN_409", "仅已出账对账单允许调整"); @@ -176,7 +179,7 @@ public class ReconciliationStatementService { ReconciliationDetail after = detail(before.customerId(), statementId); audit(before.customerId(), statementId, "RECONCILIATION_ADJUST", request.reason(), before, after, operatorId); complete(before.customerId(), operatorId, "RECONCILIATION_ACTION", key, statementId); - return after; + return detail(before.customerId(), statementId); } public ReconciliationDetail detail(long customerId, long statementId) { diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql index de37e73e1..7fa15681a 100644 --- a/finance/src/main/resources/db/finance.sql +++ b/finance/src/main/resources/db/finance.sql @@ -437,6 +437,7 @@ CREATE TABLE IF NOT EXISTS fin_settlement ( due_date DATE NOT NULL COMMENT '结算到期日期', payment_status VARCHAR(24) NOT NULL DEFAULT 'UNPAID' COMMENT '收付款状态:UNPAID未收付、PARTIALLY_PAID部分收付、PAID已结清', status VARCHAR(24) NOT NULL DEFAULT 'ISSUED' COMMENT '结算状态:ISSUED已生效、PARTIALLY_PAID部分收付、PAID已结清、VOIDED已作废', + active_receivable_statement_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN direction='RECEIVABLE' AND status<>'VOIDED' THEN reconciliation_statement_id ELSE NULL END) VIRTUAL COMMENT '有效应收占用的对账单;作废单不占用,历史关联仍保留', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID', @@ -444,7 +445,7 @@ CREATE TABLE IF NOT EXISTS fin_settlement ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', PRIMARY KEY (id), UNIQUE KEY uk_fin_settlement_no (tenant_id, settlement_no), - UNIQUE KEY uk_fin_settlement_receivable_statement (tenant_id, reconciliation_statement_id, direction), + UNIQUE KEY uk_fin_settlement_active_receivable (tenant_id, active_receivable_statement_id), KEY idx_fin_settlement_customer_direction_status (tenant_id, direction, status), KEY idx_fin_settlement_supplier_status (supplier_id, direction, status), KEY idx_fin_settlement_receivable_statistics (direction, currency_code, generated_date, status, tenant_id), diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementSchemaContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementSchemaContractTest.java index 13f0ae1e8..6c38247c4 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSettlementSchemaContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementSchemaContractTest.java @@ -1,12 +1,20 @@ package com.baiyue.finance; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.UUID; +import java.util.regex.Pattern; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class FinanceSettlementSchemaContractTest { @@ -31,6 +39,51 @@ class FinanceSettlementSchemaContractTest { assertThat(unified).doesNotContain("ALTER TABLE fin_settlement", "assert_finance"); } + /** 执行两份真实基线的结算表定义,作废历史不占位,有效应收仍由数据库兜底唯一。 */ + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void voidedReceivableCanBeReplacedWithoutAllowingDuplicateActiveSettlement(boolean unified) throws Exception { + Path source = unified ? resolveUnifiedSql() : Path.of("src/main/resources/db/finance.sql"); + String sql = Files.readString(source, StandardCharsets.UTF_8); + var table = Pattern.compile("CREATE TABLE (?:IF NOT EXISTS )?fin_settlement \\(.*?\\) ENGINE=[^;]*;", Pattern.DOTALL).matcher(sql); + assertThat(table.find()).isTrue(); + // H2 仅去除 MySQL 的存储、注释和普通索引语法,保留生成列表达式及所有唯一约束。 + String ddl = table.group().replaceFirst("\\) ENGINE=[^;]*;", ");") + .replaceAll("(?i) COMMENT '(?:[^']|'')*'", "") + .replaceAll("(?i)\\bUNSIGNED\\b|\\bVIRTUAL\\b|\\bSTORED\\b|_utf8mb4", "") + .replaceAll("(?m)^\\s*KEY [^\\r\\n]+\\r?\\n", "") + .replaceAll("UNIQUE KEY (\\w+)", "CONSTRAINT $1 UNIQUE"); + var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1")); + jdbc.execute(ddl); + insertSettlement(jdbc, 1, 10, "AR-OLD", "RECEIVABLE", 501L, "VOIDED"); + insertSettlement(jdbc, 2, 10, "AR-REPLACEMENT", "RECEIVABLE", 501L, "ISSUED"); + insertSettlement(jdbc, 3, 10, "AR-HISTORY", "RECEIVABLE", 501L, "VOIDED"); + for (String status : new String[]{"ISSUED", "PARTIALLY_PAID", "PAID"}) { + jdbc.update("UPDATE fin_settlement SET status=? WHERE id=2", status); + assertThatThrownBy(() -> insertSettlement(jdbc, 4, 10, "AR-DUP", "RECEIVABLE", 501L, "ISSUED")) + .isInstanceOf(DuplicateKeyException.class); + assertThatThrownBy(() -> jdbc.update("UPDATE fin_settlement SET status='ISSUED' WHERE id=1")) + .isInstanceOf(DuplicateKeyException.class); + } + jdbc.update("UPDATE fin_settlement SET status='VOIDED' WHERE id=2"); + assertThat(jdbc.update("UPDATE fin_settlement SET status='ISSUED' WHERE id=1")).isEqualTo(1); + insertSettlement(jdbc, 5, 11, "AR-OTHER-CUSTOMER", "RECEIVABLE", 501L, "ISSUED"); + insertSettlement(jdbc, 6, 10, "AP-ONE", "PAYABLE", null, "ISSUED"); + insertSettlement(jdbc, 7, 10, "AP-TWO", "PAYABLE", null, "ISSUED"); + assertThatThrownBy(() -> insertSettlement(jdbc, 8, 10, "AR-OLD", "RECEIVABLE", 502L, "VOIDED")) + .isInstanceOf(DuplicateKeyException.class); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_settlement", Integer.class)).isEqualTo(6); + } + + /** 构造必填字段完整的结算行,仅改变本测试关注的客户、来源与状态。 */ + private static void insertSettlement(JdbcTemplate jdbc, long id, long customerId, String no, + String direction, Long billId, String status) { + jdbc.update("INSERT INTO fin_settlement(id,tenant_id,settlement_no,direction,customer_name_snapshot," + + "reconciliation_statement_id,total_amount,paid_amount,outstanding_amount,generated_date,due_date,status,created_by,updated_by) " + + "VALUES (?,?,?,?,'合成测试客户',?,0.03,0,0.03,CURRENT_DATE,CURRENT_DATE,?,7,7)", + id, customerId, no, direction, billId, status); + } + private static Path resolveUnifiedSql() { Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); return Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java index 20609d980..30795d6ec 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java @@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest { } } assertThat(tableCount).isEqualTo(23); - assertThat(columnCount).isEqualTo(352); + assertThat(columnCount).isEqualTo(353); assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty(); } diff --git a/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java index 2620a8a44..e86ba0e0d 100644 --- a/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java @@ -11,6 +11,8 @@ import com.baiyue.finance.service.FinanceDocumentNumberGenerator; import com.baiyue.finance.service.ReconciliationStatementService; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.ArgumentCaptor; @@ -360,6 +362,29 @@ class ReconciliationStatementBehaviorTest { verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET total_amount="), (Object[]) any()); } + /** 首次操作响应必须带上本次审计修订,不能要求页面刷新后才看到最新操作。 */ + @ParameterizedTest + @ValueSource(strings = {"SUBMIT", "ADJUST"}) + void actionResponseIncludesRevisionWrittenByThatAction(String action) throws Exception { + boolean adjust = "ADJUST".equals(action); + JdbcTemplate jdbc = actionJdbc(adjust ? "ISSUED" : "DRAFT", adjust ? "ISSUED" : "PENDING_REVIEW", "revision-key"); + var auditWritten = new java.util.concurrent.atomic.AtomicBoolean(); + when(jdbc.update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_audit_log"), any(Object[].class))) + .thenAnswer(invocation -> { auditWritten.set(true); return 1; }); + var revision = new com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision( + 1, action, new BigDecimal("240.00"), new BigDecimal(adjust ? "280.00" : "240.00"), + "核对即时修订", 71L, "测试财务", LocalDateTime.of(2026, 10, 27, 12, 0)); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT action,reason"), any(RowMapper.class), eq(9L), eq(501L))) + .thenAnswer(invocation -> auditWritten.get() ? List.of(revision) : List.of()); + if (adjust) when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COALESCE(SUM(amount)"), eq(BigDecimal.class), eq(9L), eq(501L))) + .thenReturn(new BigDecimal("280.00")); + var request = new ReconciliationActionRequest(action, "核对即时修订", adjust ? 3 : 0, + adjust ? List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("280.00"), "调整")) : List.of()); + var result = serviceWithNumber(jdbc).action(71L, 501L, "revision-key", request); + assertThat(auditWritten.get()).isTrue(); + assertThat(result.revisions()).containsExactly(revision); + } + @Test void voidIssuedStatementReleasesSourcesWhenNoReceiptOrSettlementExists() throws Exception { JdbcTemplate jdbc = actionJdbc("ISSUED", "VOIDED", "void-key");