fix(finance): preserve voided receivables and return current revisions
This commit is contained in:
+5
-2
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest {
|
||||
}
|
||||
}
|
||||
assertThat(tableCount).isEqualTo(23);
|
||||
assertThat(columnCount).isEqualTo(352);
|
||||
assertThat(columnCount).isEqualTo(353);
|
||||
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user