This commit is contained in:
2026-08-19 09:55:29 +08:00
parent a381d443da
commit 80527fa15f
10 changed files with 82 additions and 31 deletions
@@ -1,5 +1,6 @@
package com.baiyue.finance.service; package com.baiyue.finance.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Propagation;
@@ -22,6 +23,7 @@ public class FinanceDocumentNumberGenerator {
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
private final Clock clock; private final Clock clock;
@Autowired
public FinanceDocumentNumberGenerator(JdbcTemplate jdbc) { public FinanceDocumentNumberGenerator(JdbcTemplate jdbc) {
this(jdbc, Clock.system(BUSINESS_ZONE)); this(jdbc, Clock.system(BUSINESS_ZONE));
} }
@@ -41,6 +41,14 @@ CALL finance_fee_source_add_column('fin_fee_source', 'operation_version',
"INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用编辑、提交、审核、调整和作废操作版本号' AFTER review_reason"); "INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用编辑、提交、审核、调整和作废操作版本号' AFTER review_reason");
CALL finance_fee_source_add_column('fin_fee_source', 'updated_at', CALL finance_fee_source_add_column('fin_fee_source', 'updated_at',
"DATETIME(3) NULL COMMENT '来源状态最后更新时间,数据修改时自动刷新' AFTER created_at"); "DATETIME(3) NULL COMMENT '来源状态最后更新时间,数据修改时自动刷新' AFTER created_at");
CALL finance_fee_source_add_column('fin_bill', 'customer_confirmation_status',
"VARCHAR(24) NOT NULL DEFAULT 'NOT_REQUIRED' COMMENT '客户确认状态:NOT_REQUIRED无需确认、PENDING_CONFIRMATION待确认、CONFIRMED已确认' AFTER issued_by");
CALL finance_fee_source_add_column('fin_bill', 'customer_confirmed_by',
"BIGINT UNSIGNED NULL COMMENT '客户确认人用户ID' AFTER customer_confirmation_status");
CALL finance_fee_source_add_column('fin_bill', 'customer_confirmed_by_name',
"VARCHAR(128) NULL COMMENT '客户确认人姓名快照' AFTER customer_confirmed_by");
CALL finance_fee_source_add_column('fin_bill', 'customer_confirmed_at',
"DATETIME(3) NULL COMMENT '客户确认时间' AFTER customer_confirmed_by_name");
CALL finance_fee_source_add_column('fin_inbox_event', 'payload_hash', CALL finance_fee_source_add_column('fin_inbox_event', 'payload_hash',
"CHAR(64) NULL COMMENT '规范化事件内容SHA-256哈希,用于同事件ID冲突检测' AFTER payload"); "CHAR(64) NULL COMMENT '规范化事件内容SHA-256哈希,用于同事件ID冲突检测' AFTER payload");
CALL finance_fee_source_add_column('fin_inbox_event', 'result_resource_id', CALL finance_fee_source_add_column('fin_inbox_event', 'result_resource_id',
@@ -91,6 +99,12 @@ BEGIN
AND column_name IN ('payload_hash','result_resource_id','error_code')) <> 3 THEN AND column_name IN ('payload_hash','result_resource_id','error_code')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:Inbox幂等字段不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:Inbox幂等字段不完整';
END IF; END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fin_bill'
AND column_name IN ('customer_confirmation_status','customer_confirmed_by',
'customer_confirmed_by_name','customer_confirmed_at')) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance客户对账单确认字段不完整';
END IF;
END$$ END$$
DELIMITER ; DELIMITER ;
CALL finance_fee_source_assert_schema(); CALL finance_fee_source_assert_schema();
@@ -1,6 +1,7 @@
package com.baiyue.finance.service; package com.baiyue.finance.service;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Clock; import java.time.Clock;
@@ -18,6 +19,18 @@ class FinanceDocumentNumberGeneratorTest {
private static final Clock CLOCK = Clock.fixed( private static final Clock CLOCK = Clock.fixed(
Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai")); 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();
assertThat(context.getBean(FinanceDocumentNumberGenerator.class)).isNotNull();
}
}
@Test @Test
void formatsShortDailySequenceForAllThreeDocuments() { void formatsShortDailySequenceForAllThreeDocuments() {
JdbcTemplate jdbc = mock(JdbcTemplate.class); JdbcTemplate jdbc = mock(JdbcTemplate.class);
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Propagation;
@@ -24,6 +25,7 @@ public class FirstlegDocumentNumberGenerator {
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
private final Clock clock; private final Clock clock;
@Autowired
public FirstlegDocumentNumberGenerator(JdbcTemplate jdbc) { public FirstlegDocumentNumberGenerator(JdbcTemplate jdbc) {
this(jdbc, Clock.system(BUSINESS_ZONE)); this(jdbc, Clock.system(BUSINESS_ZONE));
} }
@@ -73,7 +73,7 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail (
direction VARCHAR(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付', direction VARCHAR(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付',
supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空', supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空',
supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空', supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空',
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别快照;应收为空', supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商',
business_date DATE NOT NULL COMMENT '费用业务日期', business_date DATE NOT NULL COMMENT '费用业务日期',
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额', amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY',
@@ -100,37 +100,43 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail (
) ENGINE=InnoDB COMMENT='柜次级人工应收应付费用来源;审核通过后自动同步Finance'; ) ENGINE=InnoDB COMMENT='柜次级人工应收应付费用来源;审核通过后自动同步Finance';
CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot ( CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot (
id BIGINT UNSIGNED NOT NULL, id BIGINT UNSIGNED NOT NULL COMMENT '柜次费用快照ID',
source_fee_id BIGINT UNSIGNED NOT NULL, source_fee_id BIGINT UNSIGNED NOT NULL COMMENT '柜次费用来源ID',
source_version INT UNSIGNED NOT NULL, source_version INT UNSIGNED NOT NULL COMMENT '费用来源版本',
tenant_id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
loading_plan_id BIGINT UNSIGNED NOT NULL, loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '柜次ID',
container_booking_id BIGINT UNSIGNED NULL, container_booking_id BIGINT UNSIGNED NULL COMMENT '订舱柜ID快照',
container_no VARCHAR(64) NULL, container_no VARCHAR(64) NULL COMMENT '柜号快照',
fee_item_id BIGINT UNSIGNED NOT NULL, fee_item_id BIGINT UNSIGNED NOT NULL COMMENT '费用项目ID快照',
fee_item_code VARCHAR(50) NOT NULL, fee_item_code VARCHAR(50) NOT NULL COMMENT '费用项目编码快照',
fee_item_name VARCHAR(128) NOT NULL, fee_item_name VARCHAR(128) NOT NULL COMMENT '费用项目名称快照',
direction VARCHAR(16) NOT NULL, direction VARCHAR(16) NOT NULL COMMENT '费用方向',
supplier_id BIGINT UNSIGNED NULL, supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse供应商ID快照',
supplier_name VARCHAR(200) NULL, supplier_name VARCHAR(200) NULL COMMENT 'Warehouse供应商名称快照',
supplier_category VARCHAR(16) NULL, supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商',
business_date DATE NOT NULL, business_date DATE NOT NULL COMMENT '费用业务日期',
amount DECIMAL(18,2) NOT NULL, amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
currency CHAR(3) NOT NULL DEFAULT 'CNY', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY',
review_status VARCHAR(16) NOT NULL, review_status VARCHAR(16) NOT NULL COMMENT '审核状态快照',
reviewed_at DATETIME(3) NULL, reviewed_at DATETIME(3) NULL COMMENT '审核时间快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '快照创建时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_container_fee_source_version (loading_plan_id, source_fee_id, source_version), UNIQUE KEY uk_container_fee_source_version (loading_plan_id, source_fee_id, source_version),
KEY idx_container_fee_snapshot_tenant (tenant_id, loading_plan_id) KEY idx_container_fee_snapshot_tenant (tenant_id, loading_plan_id)
) ENGINE=InnoDB COMMENT='柜次费用不可变版本快照'; ) ENGINE=InnoDB COMMENT='柜次费用不可变版本快照';
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id', "BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction"); CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id',
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_name', "VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id"); "BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_category', "VARCHAR(16) NULL COMMENT '费用项目供应商类别快照;应收为空' AFTER supplier_name"); CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_name',
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id', "BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction"); "VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name', "VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id"); CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_category',
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category', "VARCHAR(16) NULL COMMENT '费用项目供应商类别快照;应收为空' AFTER supplier_name"); "VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id',
"BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name',
"VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category',
"VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name");
DROP PROCEDURE IF EXISTS firstleg_finance_add_column; DROP PROCEDURE IF EXISTS firstleg_finance_add_column;
DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema; DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema;
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Clock; import java.time.Clock;
@@ -19,6 +20,18 @@ class FirstlegDocumentNumberGeneratorTest {
private static final Clock CLOCK = Clock.fixed( private static final Clock CLOCK = Clock.fixed(
Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai")); 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(FirstlegDocumentNumberGenerator.class);
context.refresh();
assertThat(context.getBean(FirstlegDocumentNumberGenerator.class)).isNotNull();
}
}
@Test @Test
void formatsAllOperationalDocumentPrefixes() { void formatsAllOperationalDocumentPrefixes() {
JdbcTemplate jdbc = mock(JdbcTemplate.class); JdbcTemplate jdbc = mock(JdbcTemplate.class);
@@ -51,7 +51,7 @@ class FinanceG3G4PermissionSqlContractTest {
assertThat(sql) assertThat(sql)
.contains("platform:finance:settlement-ar:list") .contains("platform:finance:settlement-ar:list")
.contains("platform:finance:settlement-ap:list") .contains("platform:finance:settlement-ap:list")
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 27") .contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32")
.contains("rp.role_id=4000001") .contains("rp.role_id=4000001")
.contains("LIKE 'platform:finance:%')) <> 28"); .contains("LIKE 'platform:finance:%')) <> 28");
} }
@@ -49,5 +49,6 @@ test('扩展财务验收输出可回传资源与柜次部署阻断', () => {
test('按柜 PAYABLE 费用携带 Warehouse 供应商', () => { test('按柜 PAYABLE 费用携带 Warehouse 供应商', () => {
assert.match(fixtureSource, /supplierId: id\(supplier\.id\)/) assert.match(fixtureSource, /supplierId: id\(supplier\.id\)/)
assert.doesNotMatch(fixtureSource, /partnerId|partnerName/) assert.doesNotMatch(fixtureSource, /partnerId|partnerName/)
assert.match(fixtureSource, /\/api\/platform\/v1\/suppliers\?page=1&size=200&status=ACTIVE&category=BOOKING/) assert.match(fixtureSource, /POST', '\/api\/platform\/v1\/suppliers\?tenantId=0'/)
assert.match(fixtureSource, /!category \|\| detail\?\.category === category/)
}) })
@@ -47,7 +47,7 @@ test('Finance PAYABLE fixture creates an isolated ACTIVE supplier through the fo
assert.equal(body.status, 'ACTIVE') assert.equal(body.status, 'ACTIVE')
assert.match(body.name, /^财务验收供应商-/) assert.match(body.name, /^财务验收供应商-/)
assert.match(source, /POST', '\/api\/platform\/v1\/suppliers\?tenantId=0'/) assert.match(source, /POST', '\/api\/platform\/v1\/suppliers\?tenantId=0'/)
assert.match(source, /detail\?\.status === 'ACTIVE' && detail\?\.category === category/) assert.match(source, /detail\?\.status === 'ACTIVE' && \(!category \|\| detail\?\.category === category\)/)
}) })
test('fee detail writes preserve 19-digit fee item IDs as strings', () => { test('fee detail writes preserve 19-digit fee item IDs as strings', () => {
@@ -253,7 +253,7 @@ test('run 发布一条日期链、三张独立已出账单、三张 AR 和一张
assert.equal(fixture.payableSettlements.length, 1) assert.equal(fixture.payableSettlements.length, 1)
assert.equal(fixture.payableSettlements[0].supplierId, '501') assert.equal(fixture.payableSettlements[0].supplierId, '501')
assert.equal(fixture.payableSettlements[0].supplierId, '501') assert.equal(fixture.payableSettlements[0].supplierId, '501')
assert.equal(fixture.payableSettlements[0].supplierName, 'Fixture Partner') assert.equal(fixture.payableSettlements[0].supplierName, 'Fixture Supplier')
assert.equal(context.coverageGaps.some((gap) => gap.category === 'incomplete-container-cost-attempt' assert.equal(context.coverageGaps.some((gap) => gap.category === 'incomplete-container-cost-attempt'
&& gap.reason.includes('containerBookingId=CB-FAILED') && gap.reason.includes('containerBookingId=CB-FAILED')
&& gap.reason.includes('containerBookingId=CB-COST-RESPONSE')), true) && gap.reason.includes('containerBookingId=CB-COST-RESPONSE')), true)