修复费用审核前调整和运输取消及对账明细约束
This commit is contained in:
@@ -225,7 +225,8 @@ CREATE TABLE IF NOT EXISTS fin_bill_line (
|
||||
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_fin_bill_line_source (source_type, source_id, source_version),
|
||||
UNIQUE KEY uk_fin_bill_line_bill_source (bill_id, source_type, source_id, source_version),
|
||||
KEY idx_fin_bill_line_source (source_type, source_id, source_version),
|
||||
KEY idx_fin_bill_line_bill (tenant_id, bill_id),
|
||||
CONSTRAINT chk_fin_bill_line_tenant CHECK (tenant_id > 0),
|
||||
CONSTRAINT chk_fin_bill_line_currency CHECK (currency_code = 'CNY'),
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
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.math.BigDecimal;
|
||||
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 FinanceReconciliationSchemaContractTest {
|
||||
|
||||
/** 执行真实明细表约束,复现两笔费用作废后重新出单,保留原单快照且单内不能重复。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void voidedStatementKeepsHistoryWhileItsTwoSourcesCanEnterANewStatement(boolean unified) throws Exception {
|
||||
JdbcTemplate jdbc = createSchema(unified);
|
||||
jdbc.execute("CREATE TABLE fin_bill(id BIGINT PRIMARY KEY,tenant_id BIGINT NOT NULL,status VARCHAR(32) NOT NULL)");
|
||||
jdbc.update("INSERT INTO fin_bill VALUES(501,9,'ISSUED'),(502,9,'DRAFT')");
|
||||
insertLine(jdbc, 1, 501, 88);
|
||||
insertLine(jdbc, 2, 501, 89);
|
||||
jdbc.update("UPDATE fin_bill SET status='VOIDED' WHERE id=501");
|
||||
|
||||
insertLine(jdbc, 3, 502, 88);
|
||||
insertLine(jdbc, 4, 502, 89);
|
||||
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE bill_id=501", Integer.class)).isEqualTo(2);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE bill_id=502", Integer.class)).isEqualTo(2);
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM fin_bill WHERE id=501", String.class)).isEqualTo("VOIDED");
|
||||
assertThat(jdbc.queryForObject("SELECT SUM(l.amount) FROM fin_bill_line l JOIN fin_bill b ON b.id=l.bill_id AND b.tenant_id=l.tenant_id WHERE b.status<>'VOIDED'", BigDecimal.class))
|
||||
.isEqualByComparingTo("200.00");
|
||||
assertThatThrownBy(() -> insertLine(jdbc, 5, 502, 88)).isInstanceOf(DuplicateKeyException.class);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line", Integer.class)).isEqualTo(4);
|
||||
}
|
||||
|
||||
/** 从模块或统一空库入口读取真实建表语句,仅移除 H2 不支持的 MySQL 存储语法。 */
|
||||
private static JdbcTemplate createSchema(boolean unified) throws Exception {
|
||||
Path unifiedPath = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||
if (!Files.exists(unifiedPath)) unifiedPath = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||
String sql = Files.readString(unified ? unifiedPath : Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
|
||||
var table = Pattern.compile("CREATE TABLE (?:IF NOT EXISTS )?fin_bill_line \\(.*?\\) ENGINE=[^;]*;", Pattern.DOTALL).matcher(sql);
|
||||
assertThat(table.find()).isTrue();
|
||||
String ddl = table.group().replaceFirst("\\) ENGINE=[^;]*;", ");")
|
||||
.replaceAll("(?i) COMMENT '(?:[^']|'')*'", "")
|
||||
.replaceAll("(?i)\\bUNSIGNED\\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);
|
||||
return jdbc;
|
||||
}
|
||||
|
||||
/** 写入完整合成明细,仅改变本测试关注的单据和费用来源身份。 */
|
||||
private static void insertLine(JdbcTemplate jdbc, long id, long billId, long sourceId) {
|
||||
jdbc.update("INSERT INTO fin_bill_line(id,tenant_id,bill_id,source_type,source_id,source_version,fee_item_code,fee_item_name,amount,currency_code) VALUES(?,9,?,'FIRSTLEG_FEE',?,1,'SYNTHETIC','合成测试费用',100,'CNY')",
|
||||
id, billId, sourceId);
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,22 @@ class ReconciliationStatementBehaviorTest {
|
||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source SET status='LOCKED'"), eq(88L), eq(9L));
|
||||
}
|
||||
|
||||
/** 已被占用的费用不能完成第二张有效对账单的生成,事务必须拒绝提交。 */
|
||||
@Test
|
||||
void createRejectsSourceWhoseAvailableStatusCannotBeLocked() throws Exception {
|
||||
JdbcTemplate jdbc = createJdbc();
|
||||
when(jdbc.update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source SET status='LOCKED'"), eq(88L), eq(9L)))
|
||||
.thenReturn(0);
|
||||
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L),
|
||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null);
|
||||
|
||||
assertThatThrownBy(() -> serviceWithNumber(jdbc).create(71L, "create-key", request))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.hasMessage("费用来源已被其他对账单占用")
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"), (Object[]) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectedCrossCustomerSourceIsHiddenAsNotFoundBeforeBillInsert() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> {
|
||||
@@ -428,6 +444,24 @@ class ReconciliationStatementBehaviorTest {
|
||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L));
|
||||
}
|
||||
|
||||
/** 作废单的两笔原费用有任一已进入新单时,旧单不得重开为另一张有效单。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {0, 1})
|
||||
void reopenRejectsVoidedStatementWhenReplacementOccupiesAnySource(int reacquired) throws Exception {
|
||||
JdbcTemplate jdbc = actionJdbc("VOIDED", "DRAFT", "reopen-conflict-key");
|
||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), eq(9L), eq(501L)))
|
||||
.thenReturn(2);
|
||||
when(jdbc.update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L)))
|
||||
.thenReturn(reacquired);
|
||||
|
||||
assertThatThrownBy(() -> serviceWithNumber(jdbc).action(71L, 501L, "reopen-conflict-key",
|
||||
new ReconciliationActionRequest("REOPEN", "重新核对", 4, List.of())))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.hasMessage("原费用来源已被占用,无法重开对账单")
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET status="), (Object[]) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reconciliationIdempotencyRecoversFailedAndStaleProcessingRecords() {
|
||||
ReconciliationActionRequest body = new ReconciliationActionRequest("SUBMIT", null, 0, List.of());
|
||||
|
||||
Reference in New Issue
Block a user