优化
This commit is contained in:
+1
-1
@@ -50,7 +50,7 @@
|
||||
- 平台写操作必须校验权限、目标客户、幂等键、请求哈希、乐观锁版本和审计;客户接口只能从登录态取得客户范围,不接受客户端指定客户 ID。
|
||||
- 出账后的业务来源和账单不得原地覆盖或物理删除;调整、作废、收款分配和修订必须保留可追溯记录。金额使用定点小数,当前业务币种固定为 CNY,长整型 ID 跨前端按字符串传输。
|
||||
- 对账单出账后将客户确认状态置为 `PENDING_CONFIRMATION`;已出账账单调整后必须清除原确认快照并重新待确认,作废或重开时为 `NOT_REQUIRED`。客户只能确认本企业且状态为 `ISSUED`、`PARTIALLY_PAID` 或 `PAID` 的待确认账单,必须校验登录成员、乐观锁版本和幂等键,并记录确认人姓名快照与确认时间。
|
||||
- 对账单、应收结算单和应付结算单编号统一按北京时间生成 `REC/AR/AP-yyMMdd-####`,各前缀每日独立递增且单日最多 9999;不得回退为数据库 ID 拼接、随机数或跨时区日期。
|
||||
- 财务单据编号遵循 `../../docs/业务单据编号契约.md`,由本服务 Repository/Mapper 按前缀与北京时间日期原子分配;不得回退为数据库 ID 拼接、随机数或跨时区日期。
|
||||
- 客户授信必须带晚于当天的到期日且不限制最远日期;到期日当天仍有效。有效期内消费先用实际资金,不足部分累计为已用授信;次日失效时已用授信自动并入实际余额并允许为负,充值先归还已用授信或负余额。Order 在客户提交新订单时必须拒绝负数实际余额,已存在的资金冻结仍按原业务单结算或释放。
|
||||
- 应收款统计只按正式应收结算单的生成月份汇总 CNY 应收、已收和未收,支持可选客户筛选和分页;导出必须通过 Finance Provider + File `FINANCE_RECEIVABLE_STATISTICS` 异步任务,不在 Controller 或 Admin 直接生成文件。
|
||||
- 费用入池只接受满足上游发布与结算条件的不可变来源版本;来源 ID、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。
|
||||
|
||||
@@ -66,11 +66,7 @@ public class FinanceSettlementService {
|
||||
private final FinanceSupplierValidator supplierValidator;
|
||||
private final FinanceDocumentNumberGenerator documentNumbers;
|
||||
|
||||
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
|
||||
FinanceSupplierValidator supplierValidator) {
|
||||
this(jdbc, objectMapper, voucherValidator, supplierValidator, new FinanceDocumentNumberGenerator(jdbc));
|
||||
}
|
||||
|
||||
/** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */
|
||||
@Autowired
|
||||
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
|
||||
FinanceSupplierValidator supplierValidator,
|
||||
@@ -82,6 +78,7 @@ public class FinanceSettlementService {
|
||||
this.documentNumbers = documentNumbers;
|
||||
}
|
||||
|
||||
/** 创建应收结算单,幂等请求恢复原单,首次建单分配 AR 日流水。 */
|
||||
@Transactional
|
||||
public SettlementDetail createReceivableSettlement(long operatorId, String key,
|
||||
CreateReceivableSettlementRequest request) {
|
||||
|
||||
+2
-4
@@ -46,10 +46,7 @@ public class ReconciliationStatementService {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final FinanceDocumentNumberGenerator documentNumbers;
|
||||
|
||||
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
||||
this(jdbc, objectMapper, new FinanceDocumentNumberGenerator(jdbc));
|
||||
}
|
||||
|
||||
/** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */
|
||||
@Autowired
|
||||
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
||||
FinanceDocumentNumberGenerator documentNumbers) {
|
||||
@@ -58,6 +55,7 @@ public class ReconciliationStatementService {
|
||||
this.documentNumbers = documentNumbers;
|
||||
}
|
||||
|
||||
/** 创建对账单并分配 REC 日流水,重复请求返回已创建记录。 */
|
||||
@Transactional
|
||||
public ReconciliationDetail create(long operatorId, String key, CreateReconciliationRequest request) {
|
||||
validateCreate(request);
|
||||
|
||||
@@ -398,7 +398,7 @@ CREATE TABLE IF NOT EXISTS fin_document_sequence (
|
||||
current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号',
|
||||
PRIMARY KEY (sequence_key),
|
||||
CONSTRAINT chk_fin_document_sequence_value CHECK (current_value > 0)
|
||||
) ENGINE=InnoDB COMMENT='Finance业务单号日序列;生成YYMMDD加四位当日序号';
|
||||
) ENGINE=InnoDB COMMENT='财务业务单号日序列;生成前缀加yyyyMMdd加四位当日序号';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fin_audit_log (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
@@ -524,7 +524,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account (
|
||||
CREATE TABLE IF NOT EXISTS fin_recharge_request (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
|
||||
request_no VARCHAR(32) NOT NULL COMMENT '充值申请业务编号RC-yyMMdd-####',
|
||||
request_no VARCHAR(32) NOT NULL COMMENT '充值申请业务编号RCyyyyMMdd####',
|
||||
amount DECIMAL(20,2) NOT NULL COMMENT '申请充值金额',
|
||||
voucher_file_id BIGINT UNSIGNED NOT NULL COMMENT '付款截图File ID',
|
||||
voucher_file_name VARCHAR(255) NOT NULL COMMENT '付款截图文件名快照',
|
||||
@@ -549,7 +549,7 @@ CREATE TABLE IF NOT EXISTS fin_recharge_request (
|
||||
CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
|
||||
business_no VARCHAR(32) NOT NULL COMMENT '账户变动业务编号AC-yyMMdd-####',
|
||||
business_no VARCHAR(32) NOT NULL COMMENT '账户变动业务编号ACyyyyMMdd####',
|
||||
change_type VARCHAR(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整',
|
||||
amount DECIMAL(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负',
|
||||
actual_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后实际余额',
|
||||
|
||||
@@ -620,8 +620,9 @@ class FinanceSettlementBehaviorTest {
|
||||
org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT JSON_UNQUOTE"), anyLong(), anyLong()))
|
||||
.thenReturn(List.of(new java.util.HashMap<>(Map.of("supplier_category", "BOOKING"))));
|
||||
FinanceDocumentNumberGenerator documentNumbers = org.mockito.Mockito.mock(FinanceDocumentNumberGenerator.class);
|
||||
org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR-260818-0001");
|
||||
org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP-260818-0001");
|
||||
org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR202608180001");
|
||||
org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP202608180001");
|
||||
org.mockito.Mockito.lenient().when(documentNumbers.next("PAY")).thenReturn("PAY202608180001");
|
||||
return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(),
|
||||
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
@Test
|
||||
void selectedModeRejectsDuplicateFeeSourcesBeforeWriting() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L, 88L),
|
||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null);
|
||||
|
||||
@@ -105,7 +105,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
var result = target.create(71L, "create-key", request);
|
||||
|
||||
assertThat(result.statementId()).isEqualTo(501L);
|
||||
assertThat(result.statementNo()).isEqualTo("REC-260818-0001");
|
||||
assertThat(result.statementNo()).isEqualTo("REC202608180001");
|
||||
assertThat(result.currency()).isEqualTo("CNY");
|
||||
assertThat(result.createdByName()).isEqualTo("platform_admin");
|
||||
assertThat(result.updatedByName()).isEqualTo("platform_admin");
|
||||
@@ -132,7 +132,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
});
|
||||
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT id,customer_id"), eq(88L)))
|
||||
.thenReturn(List.of(Map.of("id", 88L, "customer_id", 10L)));
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||
|
||||
assertThatThrownBy(() -> target.create(71L, "create-key", new CreateReconciliationRequest(9L, "CNY",
|
||||
"SELECTED", List.of(88L), LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null)))
|
||||
@@ -144,7 +144,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
@Test
|
||||
void submitUsesExpectedVersionAndPersistsRevisionBeforeStateChange() throws Exception {
|
||||
JdbcTemplate jdbc = actionJdbc("DRAFT", "PENDING_REVIEW", "submit-key");
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||
|
||||
var result = target.action(71L, 501L, "submit-key",
|
||||
new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of()));
|
||||
@@ -164,7 +164,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
@Test
|
||||
void approveFreezesInternalBillAndReturnsContractStatus() throws Exception {
|
||||
JdbcTemplate jdbc = actionJdbc("PENDING_REVIEW", "APPROVED", "approve-key");
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||
|
||||
var result = target.action(71L, 501L, "approve-key",
|
||||
new ReconciliationActionRequest("APPROVE", "审核通过", 1, List.of()));
|
||||
@@ -177,7 +177,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
@Test
|
||||
void issueFreezesSourcesOnlyAfterApproval() throws Exception {
|
||||
JdbcTemplate jdbc = actionJdbc("APPROVED", "ISSUED", "issue-key");
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||
|
||||
var result = target.action(71L, 501L, "issue-key",
|
||||
new ReconciliationActionRequest("ISSUE", null, 2, List.of()));
|
||||
@@ -202,7 +202,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
});
|
||||
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
||||
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("confirm-key"))).thenReturn(List.of());
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||
|
||||
var result = target.confirmForCustomer(9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3));
|
||||
|
||||
@@ -236,7 +236,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
JdbcTemplate crossCustomer = mock(JdbcTemplate.class);
|
||||
when(crossCustomer.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L)))
|
||||
.thenReturn(List.of());
|
||||
assertThatThrownBy(() -> new ReconciliationStatementService(crossCustomer, new ObjectMapper()).confirmForCustomer(
|
||||
assertThatThrownBy(() -> serviceWithNumber(crossCustomer).confirmForCustomer(
|
||||
9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3)))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_404");
|
||||
@@ -251,7 +251,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
});
|
||||
when(draft.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
||||
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("draft-key"))).thenReturn(List.of());
|
||||
assertThatThrownBy(() -> new ReconciliationStatementService(draft, new ObjectMapper()).confirmForCustomer(
|
||||
assertThatThrownBy(() -> serviceWithNumber(draft).confirmForCustomer(
|
||||
9L, 71L, 501L, "draft-key", new CustomerConfirmationRequest(0)))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||
@@ -271,7 +271,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
});
|
||||
when(stale.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
||||
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("stale-key"))).thenReturn(List.of());
|
||||
assertThatThrownBy(() -> new ReconciliationStatementService(stale, new ObjectMapper()).confirmForCustomer(
|
||||
assertThatThrownBy(() -> serviceWithNumber(stale).confirmForCustomer(
|
||||
9L, 71L, 501L, "stale-key", new CustomerConfirmationRequest(2)))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||
@@ -288,7 +288,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("used-key"))).thenReturn(List.of(Map.of(
|
||||
"request_hash", "different-request", "status", "SUCCEEDED", "result_resource_id", 501L,
|
||||
"updated_at", LocalDateTime.now())));
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||
|
||||
assertThatThrownBy(() -> target.action(71L, 501L, "used-key",
|
||||
new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of())))
|
||||
@@ -300,7 +300,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
@Test
|
||||
void invalidActionAndVersionConflictAreRejectedBeforeMutation() throws Exception {
|
||||
JdbcTemplate jdbc = actionJdbc("DRAFT", "DRAFT", "invalid-key");
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||
|
||||
assertThatThrownBy(() -> target.action(71L, 501L, "invalid-key",
|
||||
new ReconciliationActionRequest("NOT_AN_ACTION", null, 0, List.of())))
|
||||
@@ -318,7 +318,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
JdbcTemplate jdbc = actionJdbc("ISSUED", "ISSUED", "adjust-key");
|
||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COALESCE(SUM(amount)"), eq(BigDecimal.class), eq(9L), eq(501L)))
|
||||
.thenReturn(new BigDecimal("280.00"));
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||
|
||||
target.action(71L, 501L, "adjust-key", new ReconciliationActionRequest("ADJUST", "运费更正", 3,
|
||||
List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("280.00"), "运费更正"))));
|
||||
@@ -342,7 +342,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
return List.of(mapper.mapRow(rs, 0));
|
||||
});
|
||||
|
||||
assertThatThrownBy(() -> new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()).action(71L, 501L,
|
||||
assertThatThrownBy(() -> serviceWithNumber(jdbc).action(71L, 501L,
|
||||
"low-adjust-key", new ReconciliationActionRequest("ADJUST", "金额错误", 3,
|
||||
List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("80.00"), "金额错误")))))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
@@ -355,7 +355,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
JdbcTemplate jdbc = actionJdbc("ISSUED", "VOIDED", "void-key");
|
||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_receipt_bill"), eq(Long.class), eq(9L), eq(501L))).thenReturn(0L);
|
||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Long.class), eq(9L), eq(501L))).thenReturn(0L);
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||
|
||||
var result = target.action(71L, 501L, "void-key",
|
||||
new ReconciliationActionRequest("VOID", "客户重开账期", 3, List.of()));
|
||||
@@ -372,7 +372,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
.thenReturn("receipt".equals(query) ? 1L : 0L);
|
||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Long.class), eq(9L), eq(501L)))
|
||||
.thenReturn("settlement".equals(query) ? 1L : 0L);
|
||||
assertThatThrownBy(() -> new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()).action(71L, 501L,
|
||||
assertThatThrownBy(() -> serviceWithNumber(jdbc).action(71L, 501L,
|
||||
"void-" + query, new ReconciliationActionRequest("VOID", "作废原因", 3, List.of())))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||
@@ -384,7 +384,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
void reopenVoidedStatementReacquiresEveryReleasedSource() throws Exception {
|
||||
JdbcTemplate jdbc = actionJdbc("VOIDED", "DRAFT", "reopen-key");
|
||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), eq(9L), eq(501L))).thenReturn(1);
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||
|
||||
var result = target.action(71L, 501L, "reopen-key",
|
||||
new ReconciliationActionRequest("REOPEN", "重新核对", 4, List.of()));
|
||||
@@ -399,7 +399,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
JdbcTemplate firstJdbc = mock(JdbcTemplate.class, invocation ->
|
||||
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
|
||||
when(firstJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("first-key"))).thenReturn(List.of());
|
||||
ReconciliationStatementService firstService = new ReconciliationStatementService(firstJdbc, new ObjectMapper().findAndRegisterModules());
|
||||
ReconciliationStatementService firstService = serviceWithNumber(firstJdbc);
|
||||
|
||||
Long first = ReflectionTestUtils.invokeMethod(firstService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "first-key", "POST", "/statements/501/actions", body);
|
||||
ArgumentCaptor<String> hash = ArgumentCaptor.forClass(String.class);
|
||||
@@ -412,7 +412,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "SUCCEEDED",
|
||||
"result_resource_id", 501L, "updated_at", LocalDateTime.now())));
|
||||
assertThat((Long) ReflectionTestUtils.invokeMethod(
|
||||
new ReconciliationStatementService(replayJdbc, new ObjectMapper().findAndRegisterModules()),
|
||||
serviceWithNumber(replayJdbc),
|
||||
"reserve", 9L, 71L, "RECONCILIATION_ACTION", "replay-key", "POST", "/statements/501/actions", body))
|
||||
.isEqualTo(501L);
|
||||
|
||||
@@ -420,7 +420,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
when(processingJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("processing-key")))
|
||||
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now())));
|
||||
assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod(
|
||||
new ReconciliationStatementService(processingJdbc, new ObjectMapper().findAndRegisterModules()),
|
||||
serviceWithNumber(processingJdbc),
|
||||
"reserve", 9L, 71L, "RECONCILIATION_ACTION", "processing-key", "POST", "/statements/501/actions", body))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||
@@ -431,7 +431,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "FAILED", "updated_at", LocalDateTime.now())));
|
||||
when(recoveryJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("stale-key")))
|
||||
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now().minusMinutes(10))));
|
||||
ReconciliationStatementService recoveryService = new ReconciliationStatementService(recoveryJdbc, new ObjectMapper().findAndRegisterModules());
|
||||
ReconciliationStatementService recoveryService = serviceWithNumber(recoveryJdbc);
|
||||
|
||||
assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "failed-key", "POST", "/statements/501/actions", body)).isNull();
|
||||
assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "stale-key", "POST", "/statements/501/actions", body)).isNull();
|
||||
@@ -556,7 +556,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
ResultSet rs = mock(ResultSet.class);
|
||||
when(rs.getLong("id")).thenReturn(501L);
|
||||
when(rs.getLong("tenant_id")).thenReturn(9L);
|
||||
when(rs.getString("bill_no")).thenReturn("REC-260818-0001");
|
||||
when(rs.getString("bill_no")).thenReturn("REC202608180001");
|
||||
when(rs.getString("customer_name_snapshot")).thenReturn("客户9");
|
||||
when(rs.getString("status")).thenReturn(status);
|
||||
when(rs.getBigDecimal("total_amount")).thenReturn(new BigDecimal("300.00"));
|
||||
@@ -591,7 +591,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
|
||||
private static ReconciliationStatementService serviceWithNumber(JdbcTemplate jdbc) {
|
||||
FinanceDocumentNumberGenerator documentNumbers = mock(FinanceDocumentNumberGenerator.class);
|
||||
org.mockito.Mockito.lenient().when(documentNumbers.next("REC")).thenReturn("REC-260818-0001");
|
||||
org.mockito.Mockito.lenient().when(documentNumbers.next("REC")).thenReturn("REC202608180001");
|
||||
return new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules(), documentNumbers);
|
||||
}
|
||||
}
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.baiyue.finance.repository;
|
||||
|
||||
import com.baiyue.finance.mapper.DocumentSequenceMapper;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.*;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** 实际 Mapper 和隔离 H2 MySQL 模式验证原子序列,不访问部署环境。 */
|
||||
class DocumentSequenceRepositoryTest {
|
||||
private static class Fixture {
|
||||
final JdbcTemplate jdbc;
|
||||
final DataSourceTransactionManager transactions;
|
||||
final DocumentSequenceMapper mapper;
|
||||
final DocumentSequenceRepository repository;
|
||||
/** 仅测试造表允许 JDBC;被测路径全部使用 Mapper。 */
|
||||
Fixture() throws Exception {
|
||||
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa","");
|
||||
jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data);
|
||||
jdbc.execute("CREATE TABLE fin_document_sequence(sequence_key VARCHAR(32) PRIMARY KEY,current_value BIGINT NOT NULL)");
|
||||
var configuration=new MybatisConfiguration();configuration.addMapper(DocumentSequenceMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet();
|
||||
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DocumentSequenceMapper.class);
|
||||
var proxy=new ProxyFactory(new DocumentSequenceRepository(mapper));proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource()));
|
||||
repository=(DocumentSequenceRepository)proxy.getProxy();
|
||||
}
|
||||
}
|
||||
/** 日期和前缀各自独立,重复分配持续递增。 */
|
||||
@Test void dateAndPrefixHaveIndependentSequences() throws Exception {
|
||||
var f=new Fixture();
|
||||
assertThat(f.repository.next("AA:20261020")).isEqualTo(1);
|
||||
assertThat(f.repository.next("AA:20261020")).isEqualTo(2);
|
||||
assertThat(f.repository.next("BB:20261020")).isEqualTo(1);
|
||||
assertThat(f.repository.next("AA:20261021")).isEqualTo(1);
|
||||
}
|
||||
/** 两个线程使用真实事务连接并发争用,不能产生重号。 */
|
||||
@Test void concurrentConnectionsAllocateUniqueNumbers() throws Exception {
|
||||
var f=new Fixture();var pool=Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var work=new ArrayList<Callable<Long>>();
|
||||
for(int i=0;i<64;i++)work.add(()->f.repository.next("AA:20261020"));
|
||||
var values=new TreeSet<Long>();
|
||||
for(var future:pool.invokeAll(work))values.add(future.get());
|
||||
assertThat(values).hasSize(64);assertThat(values.first()).isEqualTo(1L);assertThat(values.last()).isEqualTo(64L);
|
||||
} finally {pool.shutdownNow();}
|
||||
}
|
||||
/** 外层建单失败后不能把已经分配的单号再交给其他单据。 */
|
||||
@Test void businessRollbackDoesNotReuseAllocatedNumber() throws Exception {
|
||||
var f=new Fixture();
|
||||
assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{
|
||||
assertThat(f.repository.next("AA:20261020")).isEqualTo(1);
|
||||
throw new IllegalStateException("业务回滚");
|
||||
})).isInstanceOf(IllegalStateException.class);
|
||||
assertThat(f.repository.next("AA:20261020")).isEqualTo(2);
|
||||
}
|
||||
/** 同一会话中连接值变化后,读取必须绕过 MyBatis 缓存。 */
|
||||
@Test void connectionResultIsNeverCached() throws Exception {
|
||||
var f=new Fixture();
|
||||
new TransactionTemplate(f.transactions).executeWithoutResult(status->{
|
||||
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class);
|
||||
assertThat(f.mapper.allocatedValue()).isEqualTo(7L);
|
||||
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class);
|
||||
assertThat(f.mapper.allocatedValue()).isEqualTo(37L);
|
||||
});
|
||||
}
|
||||
}
|
||||
+28
-57
@@ -1,70 +1,41 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.finance.repository.DocumentSequenceRepository;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import java.time.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FinanceDocumentNumberGeneratorTest {
|
||||
private static final Clock CLOCK = Clock.fixed(
|
||||
Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai"));
|
||||
|
||||
@Test
|
||||
void startsAsASpringBeanWhenTheTestClockConstructorAlsoExists() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||
context.registerBean(JdbcTemplate.class, () -> jdbc);
|
||||
context.register(FinanceDocumentNumberGenerator.class);
|
||||
context.refresh();
|
||||
private static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC);
|
||||
|
||||
/** 多构造器情况下正式依赖可以被 Spring 正确选择。 */
|
||||
@Test void springCreatesGeneratorWithRepository() {
|
||||
try(var context=new AnnotationConfigApplicationContext()) {
|
||||
context.registerBean(DocumentSequenceRepository.class,()->mock(DocumentSequenceRepository.class));
|
||||
context.register(FinanceDocumentNumberGenerator.class);context.refresh();
|
||||
assertThat(context.getBean(FinanceDocumentNumberGenerator.class)).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void formatsShortDailySequenceForAllThreeDocuments() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(1L, 2L, 38L);
|
||||
FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK);
|
||||
|
||||
assertThat(generator.next("REC")).isEqualTo("REC-260818-0001");
|
||||
assertThat(generator.next("AR")).isEqualTo("AR-260818-0002");
|
||||
assertThat(generator.next("AP")).isEqualTo("AP-260818-0038");
|
||||
|
||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"),
|
||||
eq("REC:20260818"));
|
||||
/** 主机使用 UTC 时仍按北京时间跨午夜,且四位流水不截断。 */
|
||||
@Test void usesShanghaiDateAndIndependentSequenceKey() {
|
||||
var sequences=mock(DocumentSequenceRepository.class);
|
||||
when(sequences.next("REC:20261020")).thenReturn(1L,2L,9999L);
|
||||
var numbers=new FinanceDocumentNumberGenerator(sequences,CLOCK);
|
||||
assertThat(numbers.next("REC")).isEqualTo("REC202610200001");
|
||||
assertThat(numbers.next("REC")).isEqualTo("REC202610200002");
|
||||
assertThat(numbers.next("REC")).isEqualTo("REC202610209999");
|
||||
verify(sequences,times(3)).next("REC:20261020");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnsupportedTypeAndOverflow() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK);
|
||||
assertThatThrownBy(() -> generator.next("PAY")).isInstanceOf(IllegalArgumentException.class);
|
||||
|
||||
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L);
|
||||
assertThatThrownBy(() -> generator.next("REC"))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||
}
|
||||
|
||||
@Test
|
||||
void vehicleCostsAndPaymentsUseIndependentSequenceKeys() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(1L);
|
||||
FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK);
|
||||
assertThat(generator.next("VC")).isEqualTo("VC-260818-0001");
|
||||
assertThat(generator.next("VP")).isEqualTo("VP-260818-0001");
|
||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), eq("VC:20260818"));
|
||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), eq("VP:20260818"));
|
||||
/** 未登记类型不能消耗流水,分配异常或溢出时拒绝出号。 */
|
||||
@Test void rejectsUnknownTypeAndOverflow() {
|
||||
var sequences=mock(DocumentSequenceRepository.class);
|
||||
var numbers=new FinanceDocumentNumberGenerator(sequences,CLOCK);
|
||||
assertThatThrownBy(()->numbers.next("ZZZZ")).isInstanceOf(IllegalArgumentException.class);
|
||||
verifyNoInteractions(sequences);
|
||||
when(sequences.next("REC:20261020")).thenReturn(10000L);
|
||||
assertThatThrownBy(()->numbers.next("REC")).isInstanceOf(BizException.class);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user