From 6cd477a037d00ba59419e008fd07f5f07ce7b730 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 2 Sep 2026 16:48:05 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=9B=9B=E6=9C=9F?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E9=AA=8C=E6=94=B6=E5=A4=B9=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/admin-test-env-acceptance.mjs | 15 ++++++++++- ...phase4-overseas-business-contract.test.mjs | 2 ++ .../phase4-overseas-business.mjs | 2 +- .../service-center-flow.mjs | 12 ++++++++- ...verseas-acceptance-entry-contract.test.mjs | 27 +++++++++++++++++++ 5 files changed, 55 insertions(+), 3 deletions(-) diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 60dd43ca8..3ac58dc79 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -317,7 +317,7 @@ export function parseArguments(argv) { const modules = normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean)) const merchantUsername = values.get('merchant-username') || process.env.MERCHANT_TEST_USERNAME || '' const merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || '' - if (merchantUsername && merchantPassword) { + if (merchantUsername && merchantPassword && !modules.includes('phase4-overseas-business')) { const fixtureIndex = modules.indexOf('merchant-qualification-fixture') if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1) } @@ -1582,6 +1582,19 @@ export class AcceptanceRun { if (this.options.waitSplitCount > 1 || this.options.waitSplitPackageCount > 0 || this.options.orderProfileMode === 'randomized' || this.options.routeMode === 'random') { this.applyWaitSplitVariant(this.options.waitSplitSequence || 1) + } else if (this.options.originWarehouseName || this.options.channelName || this.options.channelCode) { + this.context.waitSplitRoute = { + originWarehouseName: this.options.originWarehouseName || '', + channelName: this.options.channelName || '', + channelCode: this.options.channelCode || '', + randomize: false, + randomIndex: 0, + destinationIndex: 0, + taxModeIndex: 0, + } + // A fixed non-default origin may not carry the Yiwu-only TP-001/YW-TP-002 fixtures. + // Reuse an existing pallet from the selected warehouse while keeping order data deterministic. + this.context.allowAnyExistingPallet = true } const loginResponse = await this.request('POST', '/api/auth/login', { username: this.options.username, password: this.options.password }, '') const login = this.apiSuccess('authentication', '平台管理员登录', loginResponse) diff --git a/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs index 82436c24a..63a70ecf9 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs @@ -210,6 +210,8 @@ test('module is context injected, blocks missing prerequisites and excludes forb assert.doesNotMatch(source, /https?:\/\/|process\.env|fetch\(/) assert.doesNotMatch(source, /addCoverageGap|coverageGap\(/) assert.match(source, /deploymentBlocker: true/) + assert.match(source, /contactPhone: '\d+'/) + assert.doesNotMatch(source, /contactPhone: '\+[^']+'/) for (const forbidden of ['/outbounds', '/receive-pallets', '/outbound', '/delivery', '/dispatch', '/export']) { assert.equal(source.includes(forbidden), false, forbidden) } diff --git a/scripts/admin-test-env-modules/phase4-overseas-business.mjs b/scripts/admin-test-env-modules/phase4-overseas-business.mjs index 0fc381806..41fd98413 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business.mjs @@ -56,7 +56,7 @@ function partnerWarehousePayload(context) { return { tenantId: 0, warehouseCode: `P4B-${suffix}`.slice(0, 50), warehouseName: `${context.marker} 采购合作仓`, warehouseType: 'OVERSEAS_PARTNER', countryCode: 'US', timezone: 'UTC-8', contactName: 'Phase4 QA', - contactPhone: '+1-213-555-0177', contactEmail: null, status: 'ACTIVE', remark: `${context.marker}-procurement-fixture`, + contactPhone: '12135550177', contactEmail: null, status: 'ACTIVE', remark: `${context.marker}-procurement-fixture`, address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles', district: 'Commerce', postalCode: '90040', addressLine1: '601 Procurement Partner Avenue', addressLine2: null, latitude: null, longitude: null }, diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index 1702f0d5f..eba1206e9 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -6,6 +6,16 @@ const TRUCK = '/api/platform/v1/truck-appointments' function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +async function requestWithTransientRetry(context, method, path, body) { + let response = null + for (let attempt = 1; attempt <= 3; attempt += 1) { + response = await context.request(method, path, body) + if (response?.http !== 0) return response + if (attempt < 3) await new Promise((resolve) => setTimeout(resolve, 500 * attempt)) + } + return response +} + async function actionRequest(context, path, endpoint, version) { try { const response = await fetch(`${context.options.gateway}${path}/${endpoint}`, { @@ -64,7 +74,7 @@ export async function run(context) { context.assert(MODULE, '已放舱订舱可直接创建订舱级拖车预约', oceanResponse, id(booking?.id) === id(ocean.id) && booking?.status === 'RELEASED', `oceanBookingId=${id(booking?.id) || 'missing'}, status=${booking?.status || 'missing'}`, OPTIONS) - const carrierResponse = await context.request('GET', + const carrierResponse = await requestWithTransientRetry(context, 'GET', '/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&page=1&size=20') const carrierPage = context.apiSuccess(MODULE, '读取 ACTIVE TRUCKING 承运商事实源', carrierResponse, '', OPTIONS) const carrier = carrierPage?.records?.find?.((item) => item?.id && item?.name) diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index 11b3dfb67..b4b3256a9 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -59,6 +59,33 @@ test('entry accepts a separate overseas Gateway without exposing token state', ( assert.match(source, /forbiddenValues = \[this\.options\.password, this\.options\.merchantPassword,[\s\S]*this\.token, this\.refreshToken, this\.overseasToken\]/) }) +test('overseas business keeps its marker-scoped customer fixture with reusable credentials configured', () => { + const previousUsername = process.env.MERCHANT_TEST_USERNAME + const previousPassword = process.env.MERCHANT_TEST_PASSWORD + process.env.MERCHANT_TEST_USERNAME = 'reusable-client' + process.env.MERCHANT_TEST_PASSWORD = 'reusable-password' + try { + const modules = parseArguments(['--modules', 'phase4-overseas-business']).modules + assert.ok(modules.includes('merchant-qualification-fixture')) + assert.ok(modules.indexOf('merchant-qualification-fixture') < modules.indexOf('phase4-overseas-business')) + } finally { + if (previousUsername === undefined) delete process.env.MERCHANT_TEST_USERNAME + else process.env.MERCHANT_TEST_USERNAME = previousUsername + if (previousPassword === undefined) delete process.env.MERCHANT_TEST_PASSWORD + else process.env.MERCHANT_TEST_PASSWORD = previousPassword + } +}) + +test('a fixed origin route does not require randomized order fixtures', () => { + const options = parseArguments(['--origin-warehouse-name', '东莞仓', '--modules', 'phase4-overseas-pda']) + assert.equal(options.originWarehouseName, '东莞仓') + assert.equal(options.routeMode, 'fixed') + assert.equal(options.orderProfileMode, 'fixed') + assert.match(source, /else if \(this\.options\.originWarehouseName \|\| this\.options\.channelName \|\| this\.options\.channelCode\)/) + assert.match(source, /this\.context\.waitSplitRoute = \{[\s\S]*randomize: false/) + assert.match(source, /this\.context\.allowAnyExistingPallet = true/) +}) + test('dual-token refresh replaces the whole session only after a complete response', async () => { const run = new AcceptanceRun({ gateway: 'http://localhost:9000', overseasGateway: 'http://localhost:9300', username: 'contract-user', password: 'contract-password', modules: [], output: '' }) From 3bdaba2c60d7c29f3c9b8283b446922680302f99 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 2 Sep 2026 17:07:03 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=B4=A2=E5=8A=A1?= =?UTF-8?q?=E8=B4=B9=E7=94=A8=E6=9D=A5=E6=BA=90=E4=B8=8E=E6=9F=9C=E7=BA=A7?= =?UTF-8?q?=E5=88=86=E6=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 86 +++++++++++++++++-- deploy/sql/海外区域统一增量-MySQL8.sql | 39 +++++++++ .../service/OverseasEventDispatcherTest.java | 18 ++++ finance/AGENTS.md | 2 +- .../BusinessFeeSourceEventService.java | 13 +-- .../FinanceCustomerAccountService.java | 4 +- .../FinancePhase1OperationService.java | 14 +-- .../finance/service/FinanceQueryService.java | 4 +- .../db/fee-source-event-migration.sql | 50 +++++++++-- finance/src/main/resources/db/finance.sql | 21 +++-- .../BusinessFeeSourceEventServiceTest.java | 83 ++++++++++++++++++ .../CustomerAccountSummaryBehaviorTest.java | 2 +- .../FinanceCustomerAccountServiceTest.java | 2 +- .../FinanceFeeSourceEventServiceTest.java | 14 +-- ...FinanceFeeSourceMigrationContractTest.java | 2 +- .../FinancePhase1OperationBehaviorTest.java | 8 +- .../FinanceSqlCommentContractTest.java | 4 +- firstleg/AGENTS.md | 2 +- .../PlatformContainerFeeDetailController.java | 5 ++ .../firstleg/dto/ContainerFeeDetailDtos.java | 6 +- .../entity/ContainerFeeDetailEntity.java | 2 + .../ContainerFeeSourceSnapshotEntity.java | 2 + .../ContainerFeeSourceSnapshotRepository.java | 2 + .../service/ContainerFeeDetailService.java | 29 +++++-- .../FinanceFeeSourceSnapshotService.java | 5 +- .../db/finance-fee-source-migration.sql | 16 +++- .../resources/openapi/container-fees-v1.yaml | 45 ++++++++++ .../ContainerFeeDetailServiceTest.java | 58 +++++++++++++ lastleg-service/AGENTS.md | 1 + .../service/OverseasFeeBusinessGuard.java | 4 +- .../service/OverseasFeeOutboxService.java | 4 +- .../openapi/overseas-finance-fees-v1.yaml | 50 +++++++++++ .../service/OverseasFeeOutboxServiceTest.java | 56 ++++++++++++ scripts/db/incremental.sql | 43 ++++++++++ 34 files changed, 625 insertions(+), 71 deletions(-) create mode 100644 finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java create mode 100644 firstleg/src/main/resources/openapi/container-fees-v1.yaml create mode 100644 lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxServiceTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index b3ad4cdd3..62b4336a8 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -11408,6 +11408,12 @@ DROP PROCEDURE IF EXISTS firstleg_finance_add_index; CREATE TABLE IF NOT EXISTS fl_container_fee_detail ( id BIGINT UNSIGNED NOT NULL COMMENT '柜次费用来源ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', + allocation_group_id BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID;单入仓直接费用为空', + allocation_order_id BIGINT UNSIGNED NULL COMMENT '本分摊行业务订单ID', + allocation_order_no VARCHAR(64) NULL COMMENT '本分摊行业务订单号', + allocation_inbound_id BIGINT UNSIGNED NULL COMMENT '本分摊行对应入仓单ID', + allocation_inbound_code VARCHAR(64) NULL COMMENT '本分摊行对应入仓码', + allocation_group_amount DECIMAL(18,2) NULL COMMENT '柜级原始费用总额快照', loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '柜次ID,对应fl_loading_plan.id', container_booking_id BIGINT UNSIGNED NULL COMMENT '订舱柜ID快照', container_no VARCHAR(64) NULL COMMENT '柜号快照', @@ -11462,6 +11468,12 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot ( source_fee_id BIGINT UNSIGNED NOT NULL COMMENT '柜次费用来源ID', source_version INT UNSIGNED NOT NULL COMMENT '费用来源版本', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', + allocation_group_id BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID', + allocation_order_id BIGINT UNSIGNED NULL COMMENT '分摊业务订单ID快照', + allocation_order_no VARCHAR(64) NULL COMMENT '分摊业务订单号快照', + allocation_inbound_id BIGINT UNSIGNED NULL COMMENT '分摊入仓单ID快照', + allocation_inbound_code VARCHAR(64) NULL COMMENT '分摊入仓码快照', + allocation_group_amount DECIMAL(18,2) NULL COMMENT '柜级原始费用总额快照', loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '柜次ID', container_booking_id BIGINT UNSIGNED NULL COMMENT '订舱柜ID快照', container_no VARCHAR(64) NULL COMMENT '柜号快照', @@ -11530,6 +11542,18 @@ CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_reference_n "VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method"); CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_voucher_file_ids', "JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_group_id', + "BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID;单入仓直接费用为空' AFTER tenant_id"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_order_id', + "BIGINT UNSIGNED NULL COMMENT '本分摊行业务订单ID' AFTER allocation_group_id"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_order_no', + "VARCHAR(64) NULL COMMENT '本分摊行业务订单号' AFTER allocation_order_id"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_inbound_id', + "BIGINT UNSIGNED NULL COMMENT '本分摊行对应入仓单ID' AFTER allocation_order_no"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_inbound_code', + "VARCHAR(64) NULL COMMENT '本分摊行对应入仓码' AFTER allocation_inbound_id"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_group_amount', + "DECIMAL(18,2) NULL COMMENT '柜级原始费用总额快照' AFTER allocation_inbound_code"); CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'settlement_mode', "VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID' AFTER supplier_category"); CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'paid_at', @@ -11540,6 +11564,18 @@ CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_re "VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method"); CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_voucher_file_ids', "JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_group_id', + "BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID' AFTER tenant_id"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_order_id', + "BIGINT UNSIGNED NULL COMMENT '分摊业务订单ID快照' AFTER allocation_group_id"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_order_no', + "VARCHAR(64) NULL COMMENT '分摊业务订单号快照' AFTER allocation_order_id"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_inbound_id', + "BIGINT UNSIGNED NULL COMMENT '分摊入仓单ID快照' AFTER allocation_order_no"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_inbound_code', + "VARCHAR(64) NULL COMMENT '分摊入仓码快照' AFTER allocation_inbound_id"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_group_amount', + "DECIMAL(18,2) NULL COMMENT '柜级原始费用总额快照' AFTER allocation_inbound_code"); 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', @@ -11627,10 +11663,24 @@ CALL finance_fee_source_add_column('fin_fee_source', 'source_type', "VARCHAR(32) NULL COMMENT '来源费用类型:INBOUND_FEE入库单费用、CONTAINER_FEE柜子费用' AFTER customer_id"); CALL finance_fee_source_add_column('fin_fee_source', 'source_no', "VARCHAR(64) NULL COMMENT '来源入库单号或柜号快照,可为空' AFTER source_type"); +CALL finance_fee_source_add_column('fin_fee_source', 'business_type', + "VARCHAR(32) NULL COMMENT '来源业务类型,如海外入仓、采购、卡车或柜级分摊' AFTER source_no"); +CALL finance_fee_source_add_column('fin_fee_source', 'business_id', + "BIGINT UNSIGNED NULL COMMENT '来源业务记录ID,可为空' AFTER business_type"); +CALL finance_fee_source_add_column('fin_fee_source', 'business_no', + "VARCHAR(128) NULL COMMENT '来源业务编号快照,可为空' AFTER business_id"); +CALL finance_fee_source_add_column('fin_fee_source', 'inbound_code', + "VARCHAR(64) NULL COMMENT '来源入仓码快照;并非所有费用必填' AFTER business_no"); +CALL finance_fee_source_add_column('fin_fee_source', 'warehouse_id', + "BIGINT UNSIGNED NULL COMMENT '来源海外仓或业务仓ID,可为空' AFTER inbound_code"); CALL finance_fee_source_add_column('fin_fee_source', 'direction', "VARCHAR(16) NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付' AFTER source_no"); CALL finance_fee_source_add_column('fin_fee_source', 'container_id', "BIGINT UNSIGNED NULL COMMENT '来源Firstleg装柜计划ID,可为空' AFTER inbound_id"); +CALL finance_fee_source_add_column('fin_fee_source', 'container_booking_id', + "BIGINT UNSIGNED NULL COMMENT '来源订舱柜ID,可为空' AFTER container_id"); +CALL finance_fee_source_add_column('fin_fee_source', 'container_no', + "VARCHAR(64) NULL COMMENT '真实集装箱号快照,可为空' AFTER container_booking_id"); CALL finance_fee_source_add_column('fin_fee_source', 'fee_item_code', "VARCHAR(64) NULL COMMENT '来源费用项目编码快照' AFTER container_id"); CALL finance_fee_source_add_column('fin_fee_source', 'source_hash', @@ -11681,6 +11731,8 @@ CALL finance_fee_source_add_column('fin_cost', 'source_fee_version', "INT UNSIGNED NULL COMMENT '来源费用版本' AFTER source_fee_id"); CALL finance_fee_source_add_column('fin_cost', 'source_hash', "CHAR(64) NULL COMMENT '来源不可变快照SHA-256' AFTER source_fee_version"); +CALL finance_fee_source_add_column('fin_cost', 'warehouse_id', + "BIGINT UNSIGNED NULL COMMENT '业务直接已付成本关联仓库ID,可为空' AFTER container_id"); CALL finance_fee_source_add_column('fin_cost', 'paid_at', "DATETIME(3) NULL COMMENT '直接已付时间' AFTER voucher_file_ids"); CALL finance_fee_source_add_column('fin_cost', 'payment_method', @@ -11690,6 +11742,22 @@ CALL finance_fee_source_add_column('fin_cost', 'payment_reference_no', DROP PROCEDURE IF EXISTS finance_fee_source_add_column; +SET @finance_fee_source_index_ddl = ( + SELECT IF( + (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='fin_fee_source' + AND index_name='uk_fin_fee_source')=4, + 'SELECT 1', + IF(EXISTS(SELECT 1 FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='fin_fee_source' AND index_name='uk_fin_fee_source'), + 'ALTER TABLE fin_fee_source DROP INDEX uk_fin_fee_source, ADD UNIQUE KEY uk_fin_fee_source(source_system,source_type,source_fee_id,source_fee_version)', + 'ALTER TABLE fin_fee_source ADD UNIQUE KEY uk_fin_fee_source(source_system,source_type,source_fee_id,source_fee_version)') + ) +); +PREPARE finance_fee_source_index_stmt FROM @finance_fee_source_index_ddl; +EXECUTE finance_fee_source_index_stmt; +DEALLOCATE PREPARE finance_fee_source_index_stmt; + DROP PROCEDURE IF EXISTS finance_fee_source_add_index; DELIMITER $$ CREATE PROCEDURE finance_fee_source_add_index() @@ -11708,7 +11776,9 @@ CALL finance_fee_source_add_index(); DROP PROCEDURE IF EXISTS finance_fee_source_add_index; ALTER TABLE fin_cost - MODIFY COLUMN cost_type VARCHAR(24) NOT NULL COMMENT '成本归属类型:SINGLE_ORDER单票、CONTAINER_SHARED整柜公共、SOURCE_DIRECT_PAID来源直接已付'; + MODIFY COLUMN cost_type VARCHAR(24) NOT NULL COMMENT '成本归属类型:SINGLE_ORDER单票、CONTAINER_SHARED整柜公共、SOURCE_DIRECT_PAID来源直接已付', + MODIFY COLUMN cost_origin VARCHAR(24) NOT NULL DEFAULT 'MANUAL' COMMENT '成本来源:MANUAL人工补充、FIRSTLEG_DIRECT_PAID头程直接已付、BUSINESS_DIRECT_PAID其他业务直接已付', + MODIFY COLUMN source_hash VARCHAR(71) NULL COMMENT '来源不可变快照SHA-256,兼容sha256前缀格式'; DROP PROCEDURE IF EXISTS finance_fee_source_replace_cost_checks; DELIMITER $$ CREATE PROCEDURE finance_fee_source_replace_cost_checks() @@ -11725,7 +11795,7 @@ BEGIN ADD CONSTRAINT chk_fin_cost_target CHECK ( (cost_type='SINGLE_ORDER' AND order_id IS NOT NULL AND container_id IS NULL) OR (cost_type='CONTAINER_SHARED' AND container_id IS NOT NULL) - OR (cost_type='SOURCE_DIRECT_PAID' AND (order_id IS NOT NULL OR inbound_id IS NOT NULL OR container_id IS NOT NULL)) + OR (cost_type='SOURCE_DIRECT_PAID' AND (order_id IS NOT NULL OR inbound_id IS NOT NULL OR container_id IS NOT NULL OR warehouse_id IS NOT NULL)) ), ADD CONSTRAINT chk_fin_cost_origin CHECK ( (cost_origin='MANUAL' AND source_system IS NULL AND source_type IS NULL AND source_fee_id IS NULL AND source_fee_version IS NULL AND source_hash IS NULL) @@ -11733,6 +11803,10 @@ BEGIN AND source_fee_id IS NOT NULL AND source_fee_version IS NOT NULL AND source_hash IS NOT NULL AND status IN ('CONFIRMED','VOIDED') AND paid_at IS NOT NULL AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER')) + OR (cost_origin='BUSINESS_DIRECT_PAID' AND source_system NOT IN ('', 'FIRSTLEG') AND source_type IS NOT NULL + AND source_fee_id IS NOT NULL AND source_fee_version IS NOT NULL AND source_hash IS NOT NULL + AND status IN ('CONFIRMED','VOIDED') AND paid_at IS NOT NULL + AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER')) ); END$$ DELIMITER ; @@ -11756,12 +11830,12 @@ ALTER TABLE fin_fee_source MODIFY COLUMN source_type VARCHAR(32) NOT NULL COMMENT '来源费用类型:INBOUND_FEE入库单费用、CONTAINER_FEE柜子费用', MODIFY COLUMN direction VARCHAR(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付', MODIFY COLUMN fee_item_code VARCHAR(64) NOT NULL COMMENT '来源费用项目编码快照', - MODIFY COLUMN source_hash CHAR(64) NOT NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希', - MODIFY COLUMN source_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照', + MODIFY COLUMN source_hash VARCHAR(71) NOT NULL COMMENT '业务费用来源完整快照SHA-256哈希,兼容sha256前缀格式', + MODIFY COLUMN source_snapshot JSON NOT NULL COMMENT '业务费用来源完整不可变JSON快照', MODIFY COLUMN status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废', MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '来源状态最后更新时间,数据修改时自动刷新'; ALTER TABLE fin_inbox_event - MODIFY COLUMN payload_hash CHAR(64) NOT NULL COMMENT '规范化事件内容SHA-256哈希,用于同事件ID冲突检测', + MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件内容SHA-256哈希,兼容sha256前缀格式', MODIFY COLUMN status VARCHAR(16) NOT NULL DEFAULT 'RECEIVED' COMMENT '事件处理状态:RECEIVED已接收、PROCESSED已完成、FAILED处理失败'; DROP PROCEDURE IF EXISTS finance_fee_source_assert_schema; @@ -11791,7 +11865,7 @@ BEGIN WHERE table_schema = DATABASE() AND table_name = 'fin_cost' AND column_name IN ('cost_origin','source_system','source_type','source_fee_id', 'source_fee_version','source_hash','paid_at','payment_method', - 'payment_reference_no')) <> 9 THEN + 'payment_reference_no','warehouse_id')) <> 10 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance直接已付成本字段不完整'; END IF; IF (SELECT COUNT(*) FROM information_schema.statistics diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 5f9812428..bd7e1d132 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1116,4 +1116,43 @@ END$$ DELIMITER ; CALL assert_lastleg_security_projection_ready(); DROP PROCEDURE assert_lastleg_security_projection_ready; + +CREATE TABLE IF NOT EXISTS ll_finance_fee_source ( + id BIGINT NOT NULL COMMENT '费用版本记录ID', source_fee_id BIGINT NOT NULL COMMENT '跨版本稳定费用来源ID', + source_version INT NOT NULL COMMENT '费用来源版本,从1递增', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', + customer_name VARCHAR(200) NULL COMMENT '客户名称快照', source_type VARCHAR(64) NOT NULL COMMENT '海外费用来源类型', + source_no VARCHAR(128) NOT NULL COMMENT '来源业务编号或入仓码快照', business_type VARCHAR(32) NOT NULL COMMENT '来源业务类型', + business_id BIGINT NULL COMMENT '来源业务记录ID', business_no VARCHAR(128) NOT NULL COMMENT '来源业务编号', + order_id BIGINT NULL COMMENT '关联订单ID', inbound_id BIGINT NULL COMMENT '关联海外入仓计划或入仓记录ID', + inbound_code VARCHAR(64) NULL COMMENT '关联入仓码;并非所有费用必填', container_id BIGINT NULL COMMENT '关联柜次ID', + container_booking_id BIGINT NULL COMMENT '来源订舱柜ID', container_no VARCHAR(64) NULL COMMENT '真实集装箱号快照', + warehouse_id BIGINT NULL COMMENT '关联海外仓ID', fee_item_code VARCHAR(64) NOT NULL COMMENT '费用项目编码快照', + fee_item_name VARCHAR(200) NOT NULL COMMENT '费用项目名称快照', direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付', + supplier_id BIGINT NULL COMMENT '应付供应商ID', supplier_name VARCHAR(200) NULL COMMENT '应付供应商名称快照', + supplier_category VARCHAR(32) NULL COMMENT '应付供应商类别约束', settlement_mode VARCHAR(16) NULL COMMENT 'ON_ACCOUNT账期应付或DIRECT_PAID业务已付', + paid_at DATETIME(3) NULL COMMENT '直接已付时间', payment_method VARCHAR(32) NULL COMMENT '直接已付方式', + payment_reference_no VARCHAR(128) NULL COMMENT '直接已付流水号', payment_voucher_file_ids JSON NULL COMMENT '直接已付凭证文件ID快照', + business_date DATE NOT NULL COMMENT '费用业务日期', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,当前固定CNY', + amount DECIMAL(20,6) NOT NULL COMMENT '费用金额', review_status VARCHAR(16) NOT NULL COMMENT 'DRAFT、PENDING、APPROVED或REJECTED', + publication_status VARCHAR(16) NOT NULL COMMENT 'UNPUBLISHED或PUBLISHED', source_status VARCHAR(16) NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED', + review_reason VARCHAR(500) NULL COMMENT '审核或作废原因', reviewer_id VARCHAR(128) NULL COMMENT '审核人ID', reviewed_at DATETIME(3) NULL COMMENT '审核时间', + created_by VARCHAR(128) NOT NULL COMMENT '创建人ID', updated_by VARCHAR(128) NOT NULL COMMENT '最后更新人ID', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_version(source_fee_id,source_version), + KEY idx_ll_finance_fee_customer(customer_id,source_status,created_at), KEY idx_ll_finance_fee_business(business_type,business_no,created_at), + CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_finance_fee_currency CHECK(currency='CNY'), + CONSTRAINT chk_ll_finance_fee_direction CHECK(direction IN('RECEIVABLE','PAYABLE')), + CONSTRAINT chk_ll_finance_fee_review CHECK(review_status IN('DRAFT','PENDING','APPROVED','REJECTED')), + CONSTRAINT chk_ll_finance_fee_publication CHECK(publication_status IN('UNPUBLISHED','PUBLISHED')), + CONSTRAINT chk_ll_finance_fee_status CHECK(source_status IN('ACTIVE','SUPERSEDED','VOIDED')) +) ENGINE=InnoDB COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance'; + +CREATE TABLE IF NOT EXISTS ll_finance_fee_command ( + id BIGINT NOT NULL COMMENT '幂等命令ID', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', + action VARCHAR(16) NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID', idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', source_fee_id BIGINT NOT NULL COMMENT '稳定费用来源ID', + source_version INT NOT NULL COMMENT '命令结果版本', created_at DATETIME(3) NOT NULL COMMENT '命令创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_command(operator_id,action,idempotency_key), + KEY idx_ll_finance_fee_command_source(source_fee_id,source_version) +) ENGINE=InnoDB COMMENT='海外费用来源写操作幂等审计'; SELECT 'PASS' AS migration_status, '海外仓业务、海外仓资源与IAM安全投影结构已就绪' AS message; diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java index 5224a11e5..4262a7bc5 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java @@ -31,6 +31,7 @@ class OverseasEventDispatcherTest { mock(com.baiyue.eventbridge.service.LastlegEventClient.class); private final com.baiyue.eventbridge.service.FirstlegEventClient firstlegEventClient = mock(com.baiyue.eventbridge.service.FirstlegEventClient.class); + private final FinanceEventClient financeEventClient = mock(FinanceEventClient.class); private final SecurityProjectionEventClient securityProjectionEventClient = mock(SecurityProjectionEventClient.class); private OverseasEventDispatcher dispatcher; private BridgeInbox event; @@ -48,6 +49,7 @@ class OverseasEventDispatcherTest { ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper); ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient); ReflectionTestUtils.setField(dispatcher, "firstlegEventClient", firstlegEventClient); + ReflectionTestUtils.setField(dispatcher, "financeEventClient", financeEventClient); ReflectionTestUtils.setField(dispatcher, "securityProjectionEventClient", securityProjectionEventClient); ReflectionTestUtils.setField(dispatcher, "properties", properties); ReflectionTestUtils.setField(dispatcher, "metrics", new EventBridgeMetrics(registry)); @@ -150,6 +152,22 @@ class OverseasEventDispatcherTest { assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); } + @Test + void overseasFeeSourceIsDeliveredToFinanceAndNeverFirstleg() throws Exception { + event.setEventType(EventTypes.BUSINESS_FEE_SOURCE_CHANGED); + event.setSourceRegion("OVERSEAS"); + event.setTargetRegion("CN"); + when(financeEventClient.deliver(event)).thenReturn("200"); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verify(financeEventClient).deliver(event); + verifyNoInteractions(firstlegEventClient); + verifyNoInteractions(eventClient); + assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); + } + @Test void iamSecurityEventsAreDeliveredToSecurityProjectionAndNeverLastleg() throws Exception { for (String eventType : List.of(EventTypes.IAM_USER_STATUS_CHANGED, diff --git a/finance/AGENTS.md b/finance/AGENTS.md index eb5fbbf79..9127eb182 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -22,7 +22,7 @@ | 财务单据编号 | `service/FinanceDocumentNumberGenerator.java`;对账单、应收结算单、应付结算单分别使用 `REC`、`AR`、`AP` 前缀 | | Finance 导出数据 Provider | `controller/FinanceExportProviderController.java`、`service/FinanceExportProviderService.java`;由 File `ExportProviderClient` 按已登记 `exportType` 分批拉取,不从 Admin 直接生成文件 | | 五页产品 OpenAPI | `../../docs/财务管理/openapi/finance-phase1-five-pages-v1.yaml`;人工财务补充契约 `finance-f1-manual-v1.yaml` | -| Firstleg 费用来源事件入口 | `controller/InternalFinanceFeeSourceController.java`、`service/FinanceFeeSourceEventService.java` | +| 业务费用来源事件入口 | Firstleg:`controller/InternalFinanceFeeSourceController.java`、`service/FinanceFeeSourceEventService.java`;海外完整快照:`controller/InternalBusinessFeeSourceController.java`、`service/BusinessFeeSourceEventService.java` | | 查询模型与客户可见性 | `service/FinanceQueryService.java`、`dto/FinanceReadModels.java` | | 账期、账单、收款、成本及幂等写模型 | `service/FinanceService.java` | | Firstleg 费用来源与柜次成本快照 | `client/FirstlegFeeSourceClient.java`、`client/FirstlegCostSnapshotClient.java`;生产端见 `../firstleg/AGENTS.md` | diff --git a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java index ea2e32880..1b38916da 100644 --- a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java @@ -94,7 +94,7 @@ public class BusinessFeeSourceEventService { number(source.containerBookingId()), trim(source.containerNo()), source.feeItemCode().trim(), source.feeItemName().trim(), source.totalAmount(), source.currency(), trim(source.settlementMode()), source.paidAt(), trim(source.paymentMethod()), trim(source.paymentReferenceNo()), - json(source.paymentVoucherFileIds()), source.sourceHash(), json(source)); + voucherJson(source.paymentVoucherFileIds()), source.sourceHash(), json(source)); long id = jdbc.queryForObject("SELECT id FROM fin_fee_source WHERE source_system=? AND source_type=? AND source_fee_id=? AND source_fee_version=?", Long.class, system, source.sourceType(), feeId, source.sourceVersion()); if ("PAYABLE".equals(source.direction()) && "DIRECT_PAID".equals(source.settlementMode())) @@ -131,14 +131,14 @@ public class BusinessFeeSourceEventService { || (value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty())) { throw error("FIN-EVT-012", "应收来源不得携带供应商或付款事实"); } - BusinessFeeSourceChangedV1 unsigned = new BusinessFeeSourceChangedV1(value.action(), value.sourceSystem(), + BusinessFeeSourceChangedV1 unsigned = new BusinessFeeSourceChangedV1(null, value.sourceSystem(), value.sourceType(), value.sourceFeeId(), value.sourceVersion(), value.customerId(), value.customerName(), value.sourceNo(), value.businessType(), value.businessId(), value.businessNo(), value.orderId(), value.inboundId(), value.inboundCode(), value.containerId(), value.containerBookingId(), value.containerNo(), value.warehouseId(), value.feeItemCode(), value.feeItemName(), value.direction(), value.supplierId(), value.supplierName(), value.supplierCategory(), value.settlementMode(), value.paidAt(), value.paymentMethod(), value.paymentReferenceNo(), value.paymentVoucherFileIds(), value.businessDate(), value.currency(), - value.totalAmount(), value.reviewStatus(), value.publicationStatus(), value.sourceStatus(), null); + value.totalAmount(), value.reviewStatus(), "PUBLISHED", "ACTIVE", null); String expected = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned))); if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致"); } @@ -166,10 +166,10 @@ public class BusinessFeeSourceEventService { } private void materializeDirectPaidCost(String system, long feeId, BusinessFeeSourceChangedV1 value) { - jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,cost_origin,source_system,source_type,source_fee_id,source_fee_version,source_hash,order_id,inbound_id,container_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,currency_code,cost_amount,voucher_file_ids,paid_at,payment_method,payment_reference_no,status,confirmed_at,created_by,created_by_name,updated_by) VALUES(?,?,'SOURCE_DIRECT_PAID','BUSINESS_DIRECT_PAID',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)", + jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,cost_origin,source_system,source_type,source_fee_id,source_fee_version,source_hash,order_id,inbound_id,container_id,warehouse_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,currency_code,cost_amount,voucher_file_ids,paid_at,payment_method,payment_reference_no,status,confirmed_at,created_by,created_by_name,updated_by) VALUES(?,?,'SOURCE_DIRECT_PAID','BUSINESS_DIRECT_PAID',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)", positive(value.customerId(), "customerId"), "DIRECT-" + system + '-' + value.sourceType() + '-' + feeId + "-V" + value.sourceVersion(), system, value.sourceType(), feeId, value.sourceVersion(), value.sourceHash(), number(value.orderId()), - number(value.inboundId()), number(value.containerId()), positive(value.supplierId(), "supplierId"), + number(value.inboundId()), number(value.containerId()), number(value.warehouseId()), positive(value.supplierId(), "supplierId"), value.supplierName(), value.feeItemCode(), value.feeItemName(), value.currency(), value.totalAmount(), json(value.paymentVoucherFileIds()), value.paidAt(), value.paymentMethod(), value.paymentReferenceNo()); } @@ -187,6 +187,9 @@ public class BusinessFeeSourceEventService { try { return objectMapper.writeValueAsString(value == null ? List.of() : value); } catch (JsonProcessingException exception) { throw error("FIN-EVT-001", "费用事件序列化失败"); } } + private String voucherJson(List values) { + return values == null || values.isEmpty() ? null : json(values); + } private static Long number(String value) { return blank(value) ? null : positive(value, "business identifier"); } private static long positive(String value, String name) { try { long parsed = Long.parseLong(value); if (parsed > 0) return parsed; } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java index b13ed5196..da40b1969 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java @@ -68,9 +68,9 @@ public class FinanceCustomerAccountService { .forEach(row -> metric(result, row, "pendingBilling", "amount")); jdbc.queryForList("SELECT tenant_id customer_id,SUM(received_amount) amount FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") AND status='CONFIRMED' GROUP BY tenant_id", customerIds.toArray()) .forEach(row -> metric(result, row, "receivedInPeriod", "amount")); - jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT customer_id,amount FROM fin_fee_source WHERE customer_id IN (" + placeholders + ") AND direction='PAYABLE' AND settlement_mode='ON_ACCOUNT' AND review_status='APPROVED' AND currency_code='CNY' AND amount>0 AND status NOT IN ('VOIDED','SUPERSEDED') UNION ALL SELECT tenant_id customer_id,cost_amount amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND cost_origin='FIRSTLEG_DIRECT_PAID' AND status='CONFIRMED') costs GROUP BY customer_id", concat(customerIds, 2)) + jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT customer_id,amount FROM fin_fee_source WHERE customer_id IN (" + placeholders + ") AND direction='PAYABLE' AND settlement_mode='ON_ACCOUNT' AND review_status='APPROVED' AND currency_code='CNY' AND amount>0 AND status NOT IN ('VOIDED','SUPERSEDED') UNION ALL SELECT tenant_id customer_id,cost_amount amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND status='CONFIRMED') costs GROUP BY customer_id", concat(customerIds, 2)) .forEach(row -> metric(result, row, "totalCost", "amount")); - jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,cost_amount amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND cost_origin='FIRSTLEG_DIRECT_PAID' AND status='CONFIRMED' UNION ALL SELECT tenant_id customer_id,paid_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED') paid_costs GROUP BY customer_id", concat(customerIds, 2)) + jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,cost_amount amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND status='CONFIRMED' UNION ALL SELECT tenant_id customer_id,paid_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED') paid_costs GROUP BY customer_id", concat(customerIds, 2)) .forEach(row -> metric(result, row, "paidCost", "amount")); jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,outstanding_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED' UNION ALL SELECT f.customer_id,f.amount FROM fin_fee_source f WHERE f.customer_id IN (" + placeholders + ") AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') AND NOT EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status<>'VOIDED')) payable_obligations GROUP BY customer_id", concat(customerIds, 2)) .forEach(row -> metric(result, row, "outstandingPayable", "amount")); diff --git a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java index 59a1c544c..da7761d6f 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java @@ -34,6 +34,7 @@ import java.util.Comparator; import java.util.HexFormat; import java.util.List; import java.util.Map; +import java.util.Set; @Service public class FinancePhase1OperationService { @@ -69,8 +70,8 @@ public class FinancePhase1OperationService { "(SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_settlement ar WHERE ar.tenant_id=c.customer_id AND ar.direction='RECEIVABLE' AND ar.status<>'VOIDED') outstanding_receivable," + "(SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='RECEIVABLE' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status='AVAILABLE' AND NOT EXISTS(SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED')) pending_billing," + "(SELECT COALESCE(SUM(received_amount),0) FROM fin_receipt r WHERE r.tenant_id=c.customer_id AND r.status='CONFIRMED') received_period," + - "((SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED'))+(SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.cost_origin='FIRSTLEG_DIRECT_PAID' AND x.status='CONFIRMED')) total_cost," + - "((SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.cost_origin='FIRSTLEG_DIRECT_PAID' AND x.status='CONFIRMED')+(SELECT COALESCE(SUM(paid_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED')) paid_cost," + + "((SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED'))+(SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND x.status='CONFIRMED')) total_cost," + + "((SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND x.status='CONFIRMED')+(SELECT COALESCE(SUM(paid_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED')) paid_cost," + "((SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED')+(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') AND NOT EXISTS(SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status<>'VOIDED'))) outstanding_payable," + "GREATEST(COALESCE((SELECT MAX(updated_at) FROM fin_bill b WHERE b.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_receipt r WHERE r.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_cost x WHERE x.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_fee_source f WHERE f.customer_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_settlement x WHERE x.tenant_id=c.customer_id),'1970-01-01')) last_changed_at" + base + " ORDER BY last_changed_at DESC,c.customer_id DESC LIMIT ? OFFSET ?"; Object[] args = java.util.Arrays.copyOf(filterArgs, filterArgs.length + 2); @@ -89,7 +90,7 @@ public class FinancePhase1OperationService { List rows = new ArrayList<>(); rows.addAll(jdbc.query("SELECT id,bill_no,created_at,total_amount,created_by,created_by_name,remark FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "SETTLEMENT_ISSUED", "BILL", "bill_no", "total_amount", null), customerId)); rows.addAll(jdbc.query("SELECT id,receipt_no,received_at received_time,received_amount,created_by,created_by_name,remark FROM fin_receipt WHERE tenant_id=? AND status='CONFIRMED'", (rs, n) -> raw(rs, "RECEIPT", "RECEIPT", "receipt_no", null, "received_amount"), customerId)); - rows.addAll(jdbc.query("SELECT id,cost_no,created_at,cost_amount,created_by,created_by_name,remark FROM fin_cost WHERE tenant_id=? AND cost_origin='FIRSTLEG_DIRECT_PAID' AND status='CONFIRMED'", (rs, n) -> raw(rs, "SUPPLIER_DIRECT_PAID", "DIRECT_PAID_COST", "cost_no", null, null), customerId)); + rows.addAll(jdbc.query("SELECT id,cost_no,created_at,cost_amount,created_by,created_by_name,remark FROM fin_cost WHERE tenant_id=? AND cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND status='CONFIRMED'", (rs, n) -> raw(rs, "SUPPLIER_DIRECT_PAID", "DIRECT_PAID_COST", "cost_no", null, null), customerId)); rows.addAll(jdbc.query("SELECT p.id,CONCAT(s.settlement_no,'-',p.id) payment_no,p.created_at,p.amount cost_amount,p.created_by,p.created_by_name,p.remark FROM fin_settlement_payment p JOIN fin_settlement s ON s.id=p.settlement_id AND s.tenant_id=p.tenant_id WHERE p.tenant_id=? AND p.transaction_type='PAYMENT' AND s.direction='PAYABLE' AND s.status<>'VOIDED'", (rs, n) -> raw(rs, "SUPPLIER_ON_ACCOUNT_PAYMENT", "AP_PAYMENT", "payment_no", null, null), customerId)); rows.sort(Comparator.comparing(RawLedger::occurredAt).thenComparing(RawLedger::id)); BigDecimal outstanding = BigDecimal.ZERO; @@ -185,8 +186,8 @@ public class FinancePhase1OperationService { throw error("FIN-REQ-400", adjustment ? "费用调整请求无效" : "费用编辑请求无效"); } FeeSourceDetail before = getFinanceFeeSource(feeSourceId); - if ("FIRSTLEG".equals(before.sourceSystem())) { - throw error("FIN-FEE-409", "Firstleg费用必须在入库或柜次费用明细中调整,Finance不可直接修改业务金额"); + if (Set.of("FIRSTLEG", "LASTLEG").contains(before.sourceSystem())) { + throw error("FIN-FEE-409", "业务费用必须回到对应业务费用明细调整,Finance不可直接修改来源金额"); } String operation = adjustment ? "FEE_SOURCE_ADJUST" : "FEE_SOURCE_EDIT"; String path = "/api/platform/finance/v1/fee-sources/" + feeSourceId + (adjustment ? "/adjust" : ""); @@ -222,6 +223,9 @@ public class FinancePhase1OperationService { throw error("FIN-REQ-400", "费用操作请求无效"); } FeeSourceDetail before = getFinanceFeeSource(feeSourceId); + if ("LASTLEG".equals(before.sourceSystem())) { + throw error("FIN-FEE-409", "海外业务费用必须在海外费用来源中作废,Finance只消费作废事件"); + } if (!"FIRSTLEG".equals(before.sourceSystem())) { return transitionFeeSource(feeSourceId, operatorId, key, request, "FEE_SOURCE_VOID", "VOIDED", List.of("DRAFT", "PENDING", "REJECTED", "APPROVED"), true); diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java index 1ad94c228..fbf7c4278 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java @@ -74,8 +74,8 @@ public class FinanceQueryService { "AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id " + "WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND b.status<>'VOIDED')) pending_billing," + "((SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED'))+" + - "(SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.cost_origin='FIRSTLEG_DIRECT_PAID' AND c.status='CONFIRMED')) total_cost," + - "((SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.cost_origin='FIRSTLEG_DIRECT_PAID' AND c.status='CONFIRMED')+" + + "(SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND c.status='CONFIRMED')) total_cost," + + "((SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND c.status='CONFIRMED')+" + "(SELECT COALESCE(SUM(ps.paid_amount),0) FROM fin_settlement ps WHERE ps.tenant_id=? AND ps.direction='PAYABLE' AND ps.status<>'VOIDED')) paid_cost," + "((SELECT COALESCE(SUM(os.outstanding_amount),0) FROM fin_settlement os WHERE os.tenant_id=? AND os.direction='PAYABLE' AND os.status<>'VOIDED')+" + "(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') " + diff --git a/finance/src/main/resources/db/fee-source-event-migration.sql b/finance/src/main/resources/db/fee-source-event-migration.sql index d922551f8..580e757d7 100644 --- a/finance/src/main/resources/db/fee-source-event-migration.sql +++ b/finance/src/main/resources/db/fee-source-event-migration.sql @@ -23,10 +23,24 @@ CALL finance_fee_source_add_column('fin_fee_source', 'source_type', "VARCHAR(32) NULL COMMENT '来源费用类型:INBOUND_FEE入库单费用、CONTAINER_FEE柜子费用' AFTER customer_id"); CALL finance_fee_source_add_column('fin_fee_source', 'source_no', "VARCHAR(64) NULL COMMENT '来源入库单号或柜号快照,可为空' AFTER source_type"); +CALL finance_fee_source_add_column('fin_fee_source', 'business_type', + "VARCHAR(32) NULL COMMENT '来源业务类型,如海外入仓、采购、卡车或柜级分摊' AFTER source_no"); +CALL finance_fee_source_add_column('fin_fee_source', 'business_id', + "BIGINT UNSIGNED NULL COMMENT '来源业务记录ID,可为空' AFTER business_type"); +CALL finance_fee_source_add_column('fin_fee_source', 'business_no', + "VARCHAR(128) NULL COMMENT '来源业务编号快照,可为空' AFTER business_id"); +CALL finance_fee_source_add_column('fin_fee_source', 'inbound_code', + "VARCHAR(64) NULL COMMENT '来源入仓码快照;并非所有费用必填' AFTER business_no"); +CALL finance_fee_source_add_column('fin_fee_source', 'warehouse_id', + "BIGINT UNSIGNED NULL COMMENT '来源海外仓或业务仓ID,可为空' AFTER inbound_code"); CALL finance_fee_source_add_column('fin_fee_source', 'direction', "VARCHAR(16) NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付' AFTER source_no"); CALL finance_fee_source_add_column('fin_fee_source', 'container_id', "BIGINT UNSIGNED NULL COMMENT '来源Firstleg装柜计划ID,可为空' AFTER inbound_id"); +CALL finance_fee_source_add_column('fin_fee_source', 'container_booking_id', + "BIGINT UNSIGNED NULL COMMENT '来源订舱柜ID,可为空' AFTER container_id"); +CALL finance_fee_source_add_column('fin_fee_source', 'container_no', + "VARCHAR(64) NULL COMMENT '真实集装箱号快照,可为空' AFTER container_booking_id"); CALL finance_fee_source_add_column('fin_fee_source', 'fee_item_code', "VARCHAR(64) NULL COMMENT '来源费用项目编码快照' AFTER container_id"); CALL finance_fee_source_add_column('fin_fee_source', 'source_hash', @@ -77,6 +91,8 @@ CALL finance_fee_source_add_column('fin_cost', 'source_fee_version', "INT UNSIGNED NULL COMMENT '来源费用版本' AFTER source_fee_id"); CALL finance_fee_source_add_column('fin_cost', 'source_hash', "CHAR(64) NULL COMMENT '来源不可变快照SHA-256' AFTER source_fee_version"); +CALL finance_fee_source_add_column('fin_cost', 'warehouse_id', + "BIGINT UNSIGNED NULL COMMENT '业务直接已付成本关联仓库ID,可为空' AFTER container_id"); CALL finance_fee_source_add_column('fin_cost', 'paid_at', "DATETIME(3) NULL COMMENT '直接已付时间' AFTER voucher_file_ids"); CALL finance_fee_source_add_column('fin_cost', 'payment_method', @@ -86,6 +102,22 @@ CALL finance_fee_source_add_column('fin_cost', 'payment_reference_no', DROP PROCEDURE IF EXISTS finance_fee_source_add_column; +SET @finance_fee_source_index_ddl = ( + SELECT IF( + (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='fin_fee_source' + AND index_name='uk_fin_fee_source')=4, + 'SELECT 1', + IF(EXISTS(SELECT 1 FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='fin_fee_source' AND index_name='uk_fin_fee_source'), + 'ALTER TABLE fin_fee_source DROP INDEX uk_fin_fee_source, ADD UNIQUE KEY uk_fin_fee_source(source_system,source_type,source_fee_id,source_fee_version)', + 'ALTER TABLE fin_fee_source ADD UNIQUE KEY uk_fin_fee_source(source_system,source_type,source_fee_id,source_fee_version)') + ) +); +PREPARE finance_fee_source_index_stmt FROM @finance_fee_source_index_ddl; +EXECUTE finance_fee_source_index_stmt; +DEALLOCATE PREPARE finance_fee_source_index_stmt; + DROP PROCEDURE IF EXISTS finance_fee_source_add_index; DELIMITER $$ CREATE PROCEDURE finance_fee_source_add_index() @@ -104,7 +136,9 @@ CALL finance_fee_source_add_index(); DROP PROCEDURE IF EXISTS finance_fee_source_add_index; ALTER TABLE fin_cost - MODIFY COLUMN cost_type VARCHAR(24) NOT NULL COMMENT '成本归属类型:SINGLE_ORDER单票、CONTAINER_SHARED整柜公共、SOURCE_DIRECT_PAID来源直接已付'; + MODIFY COLUMN cost_type VARCHAR(24) NOT NULL COMMENT '成本归属类型:SINGLE_ORDER单票、CONTAINER_SHARED整柜公共、SOURCE_DIRECT_PAID来源直接已付', + MODIFY COLUMN cost_origin VARCHAR(24) NOT NULL DEFAULT 'MANUAL' COMMENT '成本来源:MANUAL人工补充、FIRSTLEG_DIRECT_PAID头程直接已付、BUSINESS_DIRECT_PAID其他业务直接已付', + MODIFY COLUMN source_hash VARCHAR(71) NULL COMMENT '来源不可变快照SHA-256,兼容sha256前缀格式'; DROP PROCEDURE IF EXISTS finance_fee_source_replace_cost_checks; DELIMITER $$ CREATE PROCEDURE finance_fee_source_replace_cost_checks() @@ -121,7 +155,7 @@ BEGIN ADD CONSTRAINT chk_fin_cost_target CHECK ( (cost_type='SINGLE_ORDER' AND order_id IS NOT NULL AND container_id IS NULL) OR (cost_type='CONTAINER_SHARED' AND container_id IS NOT NULL) - OR (cost_type='SOURCE_DIRECT_PAID' AND (order_id IS NOT NULL OR inbound_id IS NOT NULL OR container_id IS NOT NULL)) + OR (cost_type='SOURCE_DIRECT_PAID' AND (order_id IS NOT NULL OR inbound_id IS NOT NULL OR container_id IS NOT NULL OR warehouse_id IS NOT NULL)) ), ADD CONSTRAINT chk_fin_cost_origin CHECK ( (cost_origin='MANUAL' AND source_system IS NULL AND source_type IS NULL AND source_fee_id IS NULL AND source_fee_version IS NULL AND source_hash IS NULL) @@ -129,6 +163,10 @@ BEGIN AND source_fee_id IS NOT NULL AND source_fee_version IS NOT NULL AND source_hash IS NOT NULL AND status IN ('CONFIRMED','VOIDED') AND paid_at IS NOT NULL AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER')) + OR (cost_origin='BUSINESS_DIRECT_PAID' AND source_system NOT IN ('', 'FIRSTLEG') AND source_type IS NOT NULL + AND source_fee_id IS NOT NULL AND source_fee_version IS NOT NULL AND source_hash IS NOT NULL + AND status IN ('CONFIRMED','VOIDED') AND paid_at IS NOT NULL + AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER')) ); END$$ DELIMITER ; @@ -152,12 +190,12 @@ ALTER TABLE fin_fee_source MODIFY COLUMN source_type VARCHAR(32) NOT NULL COMMENT '来源费用类型:INBOUND_FEE入库单费用、CONTAINER_FEE柜子费用', MODIFY COLUMN direction VARCHAR(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付', MODIFY COLUMN fee_item_code VARCHAR(64) NOT NULL COMMENT '来源费用项目编码快照', - MODIFY COLUMN source_hash CHAR(64) NOT NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希', - MODIFY COLUMN source_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照', + MODIFY COLUMN source_hash VARCHAR(71) NOT NULL COMMENT '业务费用来源完整快照SHA-256哈希,兼容sha256前缀格式', + MODIFY COLUMN source_snapshot JSON NOT NULL COMMENT '业务费用来源完整不可变JSON快照', MODIFY COLUMN status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废', MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '来源状态最后更新时间,数据修改时自动刷新'; ALTER TABLE fin_inbox_event - MODIFY COLUMN payload_hash CHAR(64) NOT NULL COMMENT '规范化事件内容SHA-256哈希,用于同事件ID冲突检测', + MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件内容SHA-256哈希,兼容sha256前缀格式', MODIFY COLUMN status VARCHAR(16) NOT NULL DEFAULT 'RECEIVED' COMMENT '事件处理状态:RECEIVED已接收、PROCESSED已完成、FAILED处理失败'; DROP PROCEDURE IF EXISTS finance_fee_source_assert_schema; @@ -187,7 +225,7 @@ BEGIN WHERE table_schema = DATABASE() AND table_name = 'fin_cost' AND column_name IN ('cost_origin','source_system','source_type','source_fee_id', 'source_fee_version','source_hash','paid_at','payment_method', - 'payment_reference_no')) <> 9 THEN + 'payment_reference_no','warehouse_id')) <> 10 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance直接已付成本字段不完整'; END IF; IF (SELECT COUNT(*) FROM information_schema.statistics diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql index bb4a41591..ea9b2d17a 100644 --- a/finance/src/main/resources/db/finance.sql +++ b/finance/src/main/resources/db/finance.sql @@ -30,7 +30,7 @@ CREATE TABLE IF NOT EXISTS fin_fee_source ( payment_method VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式:ONLINE、BANK_TRANSFER、CASH、OTHER', payment_reference_no VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号', payment_voucher_file_ids JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组', - source_hash CHAR(64) NOT NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希', + source_hash VARCHAR(71) NOT NULL COMMENT '业务费用来源完整快照SHA-256哈希,兼容sha256前缀格式', source_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照', review_status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'Finance费用状态:DRAFT草稿、PENDING待审核、APPROVED已通过、REJECTED已驳回', review_reason VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因', @@ -61,7 +61,7 @@ CREATE TABLE IF NOT EXISTS fin_inbox_event ( source_system VARCHAR(32) NOT NULL COMMENT '事件来源系统标识', event_type VARCHAR(64) NOT NULL COMMENT '来源事件类型', payload JSON NOT NULL COMMENT '来源事件原始JSON载荷快照', - payload_hash CHAR(64) NOT NULL COMMENT '规范化事件内容SHA-256哈希,用于同事件ID冲突检测', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件内容SHA-256哈希,兼容sha256前缀格式', result_resource_id BIGINT UNSIGNED NULL COMMENT '成功处理生成或定位的费用来源快照ID', error_code VARCHAR(64) NULL COMMENT '处理失败时的稳定FIN错误码', status VARCHAR(16) NOT NULL DEFAULT 'RECEIVED' COMMENT '事件处理状态:RECEIVED已接收、PROCESSED已完成、FAILED处理失败', @@ -221,21 +221,22 @@ CREATE TABLE IF NOT EXISTS fin_cost ( tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', cost_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一成本编号', cost_type VARCHAR(24) NOT NULL COMMENT '成本归属类型:SINGLE_ORDER单票、CONTAINER_SHARED整柜公共、SOURCE_DIRECT_PAID来源直接已付', - cost_origin VARCHAR(24) NOT NULL DEFAULT 'MANUAL' COMMENT '成本来源:MANUAL人工补充、FIRSTLEG_DIRECT_PAID头程直接已付', - source_system VARCHAR(32) NULL COMMENT '来源系统,头程直接已付固定FIRSTLEG', + cost_origin VARCHAR(24) NOT NULL DEFAULT 'MANUAL' COMMENT '成本来源:MANUAL人工补充、FIRSTLEG_DIRECT_PAID头程直接已付、BUSINESS_DIRECT_PAID其他业务直接已付', + source_system VARCHAR(32) NULL COMMENT '直接已付来源系统,如FIRSTLEG或LASTLEG', source_type VARCHAR(32) NULL COMMENT '来源费用类型快照', source_fee_id BIGINT UNSIGNED NULL COMMENT '来源费用ID', source_fee_version INT UNSIGNED NULL COMMENT '来源费用版本', - source_hash CHAR(64) NULL COMMENT '来源不可变快照SHA-256', + source_hash VARCHAR(71) NULL COMMENT '来源不可变快照SHA-256,兼容sha256前缀格式', order_id BIGINT UNSIGNED NULL COMMENT '成本归属的头程订单ID,单票成本必填', inbound_id BIGINT UNSIGNED NULL COMMENT '成本关联的入库单ID,可为空', container_id BIGINT UNSIGNED NULL COMMENT 'Firstleg装柜计划ID,整柜公共成本必填', + warehouse_id BIGINT UNSIGNED NULL COMMENT '业务直接已付成本关联仓库ID,可为空', supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse公共往来单位或供应商引用ID', supplier_name_snapshot VARCHAR(200) NULL COMMENT '登记成本时供应商或成本对象名称快照', fee_item_code VARCHAR(64) NOT NULL COMMENT '成本费用项目编码快照', fee_item_name VARCHAR(200) NOT NULL COMMENT '成本费用项目名称快照', currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', - cost_amount DECIMAL(20,6) NOT NULL COMMENT '成本总金额;仅FIRSTLEG_DIRECT_PAID且CONFIRMED计入已线下支付供应商款', + cost_amount DECIMAL(20,6) NOT NULL COMMENT '成本总金额;来源直接已付且CONFIRMED计入已线下支付供应商款', voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组JSON', paid_at DATETIME(3) NULL COMMENT '直接已付时间;人工补充成本可为空', payment_method VARCHAR(32) NULL COMMENT '直接已付方式:ONLINE、BANK_TRANSFER、CASH、OTHER', @@ -265,7 +266,7 @@ CREATE TABLE IF NOT EXISTS fin_cost ( CONSTRAINT chk_fin_cost_target CHECK ( (cost_type = 'SINGLE_ORDER' AND order_id IS NOT NULL AND container_id IS NULL) OR (cost_type = 'CONTAINER_SHARED' AND container_id IS NOT NULL) - OR (cost_type = 'SOURCE_DIRECT_PAID' AND (order_id IS NOT NULL OR inbound_id IS NOT NULL OR container_id IS NOT NULL)) + OR (cost_type = 'SOURCE_DIRECT_PAID' AND (order_id IS NOT NULL OR inbound_id IS NOT NULL OR container_id IS NOT NULL OR warehouse_id IS NOT NULL)) ), CONSTRAINT chk_fin_cost_origin CHECK ( (cost_origin='MANUAL' AND source_system IS NULL AND source_type IS NULL AND source_fee_id IS NULL AND source_fee_version IS NULL AND source_hash IS NULL) @@ -273,8 +274,12 @@ CREATE TABLE IF NOT EXISTS fin_cost ( AND source_fee_id IS NOT NULL AND source_fee_version IS NOT NULL AND source_hash IS NOT NULL AND status IN ('CONFIRMED','VOIDED') AND paid_at IS NOT NULL AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER')) + OR (cost_origin='BUSINESS_DIRECT_PAID' AND source_system NOT IN ('', 'FIRSTLEG') AND source_type IS NOT NULL + AND source_fee_id IS NOT NULL AND source_fee_version IS NOT NULL AND source_hash IS NOT NULL + AND status IN ('CONFIRMED','VOIDED') AND paid_at IS NOT NULL + AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER')) ) -) ENGINE=InnoDB COMMENT='成本事实;人工补充成本与头程直接已付来源成本统一留痕'; +) ENGINE=InnoDB COMMENT='成本事实;人工补充成本与业务直接已付来源成本统一留痕'; CREATE TABLE IF NOT EXISTS fin_cost_allocation ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', diff --git a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java new file mode 100644 index 000000000..817e6b48e --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java @@ -0,0 +1,83 @@ +package com.baiyue.finance; + +import com.baiyue.common.event.BusinessFeeSourceChangedV1; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.RegionCode; +import com.baiyue.finance.service.BusinessFeeSourceEventService; +import com.baiyue.finance.service.FinanceSupplierValidator; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class BusinessFeeSourceEventServiceTest { + private final JdbcTemplate jdbc = mock(JdbcTemplate.class); + private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); + private final FinanceSupplierValidator suppliers = mock(FinanceSupplierValidator.class); + private final BusinessFeeSourceEventService service = new BusinessFeeSourceEventService(jdbc, json, suppliers); + + @Test + void importsOverseasFeeWithoutInventingInboundReference() { + EventEnvelope event = eventWithoutInbound(); + when(jdbc.queryForList(startsWith("SELECT payload_hash,status,result_resource_id"), + eq("LASTLEG"), eq(event.eventId()))).thenReturn(List.of()); + when(jdbc.queryForList(startsWith("SELECT id,source_hash,status FROM fin_fee_source"), + eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(List.of()); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system=?"), + eq(Integer.class), eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L))).thenReturn(0); + when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), + eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(901L); + + assertThat(service.consume(event)).isEqualTo(901L); + + ArgumentCaptor values = ArgumentCaptor.forClass(Object[].class); + verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture()); + assertThat(values.getValue()[3]).isEqualTo(10001L); + assertThat(values.getValue()[5]).isEqualTo("PO-US-001"); + assertThat(values.getValue()[9]).isNull(); + assertThat(values.getValue()[13]).isNull(); + } + + private EventEnvelope eventWithoutInbound() { + BusinessFeeSourceChangedV1 unsigned = new BusinessFeeSourceChangedV1(null, "LASTLEG", + "OVERSEAS_PROCUREMENT_FEE", "701", 1, "10001", "客户A", "PO-US-001", + "OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null, + null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null, + null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY", + new BigDecimal("120.00"), "APPROVED", "PUBLISHED", "ACTIVE", null); + String sourceHash = EventPayloadHash.sha256( + EventPayloadCanonicalizer.canonicalize(json.valueToTree(unsigned))); + BusinessFeeSourceChangedV1 payload = new BusinessFeeSourceChangedV1("PUBLISHED", "LASTLEG", + "OVERSEAS_PROCUREMENT_FEE", "701", 1, "10001", "客户A", "PO-US-001", + "OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null, + null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null, + null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY", + new BigDecimal("120.00"), "APPROVED", "PUBLISHED", "ACTIVE", sourceHash); + JsonNode node = json.valueToTree(payload); + String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); + return new EventEnvelope("123e4567-e89b-12d3-a456-426614174701", + EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.parse("2026-09-02T08:00:00Z"), + RegionCode.OVERSEAS, RegionCode.CN, "10001", "BUSINESS_FEE", "701", 1, + null, "123e4567-e89b-12d3-a456-426614174702", "LASTLEG:FEE:701:1", null, + node, payloadHash); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java index 109765346..7e4737ea7 100644 --- a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java @@ -85,7 +85,7 @@ class CustomerAccountSummaryBehaviorTest { .contains("FROM fin_settlement s WHERE s.tenant_id=?") .contains("s.direction='RECEIVABLE' AND s.status<>'VOIDED'") .contains("os.direction='PAYABLE' AND os.status<>'VOIDED'") - .contains("f.settlement_mode='ON_ACCOUNT'", "c.cost_origin='FIRSTLEG_DIRECT_PAID'", "ps.paid_amount") + .contains("f.settlement_mode='ON_ACCOUNT'", "c.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID')", "ps.paid_amount") .contains("f.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.amount>0") .contains("b.status<>'VOIDED'", "FROM fin_settlement_line sl", "sl.fee_source_id=f.id", "aps.status<>'VOIDED'") .doesNotContain("JSON_EXTRACT") diff --git a/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java b/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java index 62b556735..4628bb385 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java @@ -59,7 +59,7 @@ class FinanceCustomerAccountServiceTest { String all = String.join("\n", sql.getAllValues()); assertThat(all) .contains("SUM(outstanding_amount) amount FROM fin_settlement", "direction='RECEIVABLE'") - .contains("cost_origin='FIRSTLEG_DIRECT_PAID' AND status='CONFIRMED'") + .contains("cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND status='CONFIRMED'") .contains("direction='PAYABLE' AND status<>'VOIDED'") .contains("NOT EXISTS (SELECT 1 FROM fin_settlement_line") .contains("sl.fee_source_id=f.id", "s.status<>'VOIDED'") diff --git a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java index 72c3be7ef..3f44aba12 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java @@ -61,7 +61,7 @@ class FinanceFeeSourceEventServiceTest { assertThat(service.consume(event)).isEqualTo(101L); verify(firstleg).container("test-token", 41, 31, 1); - verify(jdbc).update(contains("status='SUPERSEDED'"), eq(31L)); + verify(jdbc).update(contains("status='SUPERSEDED'"), eq("CONTAINER_FEE"), eq(31L)); } @Test @@ -143,7 +143,7 @@ class FinanceFeeSourceEventServiceTest { when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null))); assertThat(service.consume(event)).isEqualTo(101L); - verify(jdbc).update(contains("status='SUPERSEDED'"), eq(31L)); + verify(jdbc).update(contains("status='SUPERSEDED'"), eq("INBOUND_FEE"), eq(31L)); verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class)); } @@ -154,7 +154,7 @@ class FinanceFeeSourceEventServiceTest { when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2))) .thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null))); when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system='FIRSTLEG'"), - eq(Integer.class), eq(31L))).thenReturn(1); + eq(Integer.class), anyString(), eq(31L))).thenReturn(1); assertThatThrownBy(() -> service.consume(event)) .isInstanceOf(FinanceEventException.class) @@ -168,7 +168,7 @@ class FinanceFeeSourceEventServiceTest { FinanceFeeSourceEvent event = inboundEvent("voided-2", "FIRSTLEG_FEE_VOIDED", 2, 7); when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("voided-2"))).thenReturn(List.of()); when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null))); - when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), eq(31L), eq(2))) + when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), anyString(), eq(31L), eq(2))) .thenReturn(List.of(Map.of("id", 88L, "source_hash", "source-hash", "status", "AVAILABLE"))); assertThat(service.consume(event)).isEqualTo(88L); @@ -183,7 +183,7 @@ class FinanceFeeSourceEventServiceTest { .thenReturn(List.of()); when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2))) .thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null))); - when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), eq(31L), eq(2))) + when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), anyString(), eq(31L), eq(2))) .thenReturn(List.of(Map.of("id", 88L, "source_hash", "source-hash", "status", "AVAILABLE"))); when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.id=?"), eq(Integer.class), eq(88L))).thenReturn(1); @@ -253,8 +253,8 @@ class FinanceFeeSourceEventServiceTest { private void givenNewEvent() { when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), anyString())).thenReturn(List.of()); - when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), eq(31L), anyInt())).thenReturn(List.of()); - when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), eq(31L), anyInt())).thenReturn(101L); + when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), anyString(), eq(31L), anyInt())).thenReturn(List.of()); + when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), anyString(), eq(31L), anyInt())).thenReturn(101L); } private FinanceFeeSourceEvent inboundEvent(String id, String type, int version, long tenant) { diff --git a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceMigrationContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceMigrationContractTest.java index 8329eae7d..5eb6a2f2b 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceMigrationContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceMigrationContractTest.java @@ -16,7 +16,7 @@ class FinanceFeeSourceMigrationContractTest { "source_hash", "source_snapshot", "payload_hash", "result_resource_id", "error_code"); assertThat(sql).contains("settlement_mode", "paid_at", "payment_method", "payment_reference_no", "payment_voucher_file_ids"); - assertThat(sql).contains("cost_origin", "FIRSTLEG_DIRECT_PAID", "source_fee_version", + assertThat(sql).contains("cost_origin", "FIRSTLEG_DIRECT_PAID", "BUSINESS_DIRECT_PAID", "warehouse_id", "source_fee_version", "uk_fin_cost_source_version", "Finance直接已付成本字段不完整"); assertThat(sql).contains("AVAILABLE可结算", "SUPERSEDED已被新版本替代", "VOIDED已作废"); assertThat(sql).contains("finance_fee_source_assert_schema"); diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java index 3f422a86a..d78234a15 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java @@ -107,7 +107,7 @@ class FinancePhase1OperationBehaviorTest { assertThat(result.traceId()).isNotBlank(); assertThat(jdbc.accountListSql).contains("p.customer_name_snapshot"); assertThat(jdbc.accountListSql) - .contains("ar.direction='RECEIVABLE'", "cost_origin='FIRSTLEG_DIRECT_PAID'", "FROM fin_settlement_line sl") + .contains("ar.direction='RECEIVABLE'", "cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID')", "FROM fin_settlement_line sl") .contains("sl.fee_source_id=f.id", "s.status<>'VOIDED'"); assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)"); } @@ -123,7 +123,7 @@ class FinancePhase1OperationBehaviorTest { assertThat(result.data()).isEmpty(); assertThat(jdbc.ledgerCustomerIds).containsExactly(9L, 9L, 9L, 9L); assertThat(jdbc.ledgerSql).anySatisfy(sql -> assertThat(sql) - .contains("cost_origin='FIRSTLEG_DIRECT_PAID'", "status='CONFIRMED'")); + .contains("cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID')", "status='CONFIRMED'")); assertThat(jdbc.ledgerSql).anySatisfy(sql -> assertThat(sql) .contains("fin_settlement_payment", "transaction_type='PAYMENT'", "s.direction='PAYABLE'")); } @@ -240,9 +240,9 @@ class FinancePhase1OperationBehaviorTest { var request = new FinancePhase1Dtos.FeeSourceEditRequest( new BigDecimal("120.00"), "更正金额", 1); assertThatThrownBy(() -> service.editFinanceFeeSource(88L, 71L, "edit-key", request, false)) - .isInstanceOf(FinanceEventException.class).hasMessageContaining("Firstleg费用必须"); + .isInstanceOf(FinanceEventException.class).hasMessageContaining("业务费用必须回到对应业务费用明细调整"); assertThatThrownBy(() -> service.editFinanceFeeSource(88L, 71L, "adjust-key", request, true)) - .isInstanceOf(FinanceEventException.class).hasMessageContaining("Firstleg费用必须"); + .isInstanceOf(FinanceEventException.class).hasMessageContaining("业务费用必须回到对应业务费用明细调整"); verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET amount="), (Object[]) any()); } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java index 45e7d486b..952f007de 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(17); - assertThat(columnCount).isEqualTo(257); + assertThat(columnCount).isEqualTo(265); assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty(); } @@ -56,7 +56,7 @@ class FinanceSqlCommentContractTest { assertThat(sql).contains("RECEIVED已接收、PROCESSED已完成、FAILED处理失败"); assertThat(sql).contains("Firstleg费用来源完整不可变JSON快照"); assertThat(sql).contains("ON_ACCOUNT账期应付、DIRECT_PAID业务已付"); - assertThat(sql).contains("仅FIRSTLEG_DIRECT_PAID且CONFIRMED计入已线下支付供应商款"); + assertThat(sql).contains("来源直接已付且CONFIRMED计入已线下支付供应商款"); assertThat(sql).contains("RECEIVABLE计待收,PAYABLE与未制单ON_ACCOUNT来源共同计待付"); assertThat(sql).contains("规范化完整请求的SHA-256十六进制哈希"); } diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 86bc3622a..9e94fa7a8 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -27,7 +27,7 @@ | 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` | | 价格簿查询内部切片 | `pricebookquery/application/port/in/PriceBookQueryUseCase.java`、`application/PriceBookQueryService.java`、`application/port/out/PriceBookQueryReader.java`、`adapter/out/persistence/PriceBookQueryMapper.java`;查询行模型位于 `application/model/PriceBookQueryRows.java` | | 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 | -| 柜次费用明细 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` | +| 柜次费用明细 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;跨客户公共柜费使用 `allocation-groups` 按真实客户和订单原子分摊,入仓ID/入仓码同时填写或同时留空;契约 `src/main/resources/openapi/container-fees-v1.yaml`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` | | 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` | | 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 | | 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerFeeDetailController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerFeeDetailController.java index 7fb433976..47181a39e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerFeeDetailController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerFeeDetailController.java @@ -51,6 +51,7 @@ public class PlatformContainerFeeDetailController { public ApiResponse> createAllocationGroup( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId, @Valid @RequestBody AllocationGroupRequest request) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.createAllocationGroup(loadingPlanId, user.userId(), request)); } @@ -59,6 +60,7 @@ public class PlatformContainerFeeDetailController { public ApiResponse> approveAllocationGroup( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId, @PathVariable long groupId) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.approveAllocationGroup(loadingPlanId, groupId, user.userId(), user.username())); } @@ -67,6 +69,7 @@ public class PlatformContainerFeeDetailController { public ApiResponse> rejectAllocationGroup( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId, @PathVariable long groupId, @Valid @RequestBody RejectRequest request) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.rejectAllocationGroup(loadingPlanId, groupId, user.userId(), user.username(), request.reason())); } @@ -75,6 +78,7 @@ public class PlatformContainerFeeDetailController { public ApiResponse> adjustAllocationGroup( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId, @PathVariable long groupId, @Valid @RequestBody AllocationGroupAdjustRequest request) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.adjustAllocationGroup(loadingPlanId, groupId, user.userId(), request)); } @@ -83,6 +87,7 @@ public class PlatformContainerFeeDetailController { public ApiResponse voidAllocationGroup( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId, @PathVariable long groupId, @Valid @RequestBody VersionRequest request) { + FirstlegScopeGuard.platform(user); service.voidAllocationGroup(loadingPlanId, groupId, request.expectedSourceVersion(), user.userId()); return ApiResponse.success(null); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerFeeDetailDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerFeeDetailDtos.java index 1906e10d8..dabc1a8d4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerFeeDetailDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerFeeDetailDtos.java @@ -47,8 +47,10 @@ public final class ContainerFeeDetailDtos { public record Allocation( @NotNull @Min(1) Long customerId, - @NotNull @Min(1) Long inboundId, - @NotBlank @Size(max = 64) String inboundCode, + @NotNull @Min(1) Long orderId, + @NotBlank @Size(max = 64) String orderNo, + @Min(1) Long inboundId, + @Size(max = 64) String inboundCode, @NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal amount ) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeDetailEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeDetailEntity.java index 2ee0e9e3e..ca2a1e128 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeDetailEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeDetailEntity.java @@ -22,6 +22,8 @@ public class ContainerFeeDetailEntity { private Long id; private Long tenantId; private Long allocationGroupId; + private Long allocationOrderId; + private String allocationOrderNo; private Long allocationInboundId; private String allocationInboundCode; private BigDecimal allocationGroupAmount; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeSourceSnapshotEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeSourceSnapshotEntity.java index f249fd369..3d43f3d1a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeSourceSnapshotEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeSourceSnapshotEntity.java @@ -24,6 +24,8 @@ public class ContainerFeeSourceSnapshotEntity { private Integer sourceVersion; private Long tenantId; private Long allocationGroupId; + private Long allocationOrderId; + private String allocationOrderNo; private Long allocationInboundId; private String allocationInboundCode; private BigDecimal allocationGroupAmount; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeSourceSnapshotRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeSourceSnapshotRepository.java index 051dccc75..62aa82d97 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeSourceSnapshotRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeSourceSnapshotRepository.java @@ -20,6 +20,8 @@ public class ContainerFeeSourceSnapshotRepository { value.setSourceVersion(source.getSourceVersion()); value.setTenantId(source.getTenantId()); value.setAllocationGroupId(source.getAllocationGroupId()); + value.setAllocationOrderId(source.getAllocationOrderId()); + value.setAllocationOrderNo(source.getAllocationOrderNo()); value.setAllocationInboundId(source.getAllocationInboundId()); value.setAllocationInboundCode(source.getAllocationInboundCode()); value.setAllocationGroupAmount(source.getAllocationGroupAmount()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java index 2e597df2e..f03077b3f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java @@ -52,12 +52,15 @@ public class ContainerFeeDetailService { PlanContext plan = requireOwnedPlan(tenantId, loadingPlanId); List owned = loading.items(loadingPlanId).stream() .filter(item -> Long.valueOf(tenantId).equals(item.getAccountId())) - .map(item -> new InboundAllocationKey(item.getInboundId(), item.getInboundCode())) + .map(item -> new InboundAllocationKey(item.getOrderId(), text(item.getOrderNo()), + item.getInboundId(), text(item.getInboundCode()))) .distinct().toList(); - if (owned.size() != 1) throw conflict("柜次包含多个入仓码,必须使用柜级费用分摊录入"); + if (owned.size() != 1) throw conflict("柜次包含多个业务归属,必须使用柜级费用分摊录入"); FeeItemEntity item = requireItem(request.feeItemId(), request.direction()); ContainerFeeDetailEntity value = new ContainerFeeDetailEntity(); value.setTenantId(tenantId); + value.setAllocationOrderId(owned.get(0).orderId()); + value.setAllocationOrderNo(owned.get(0).orderNo()); value.setAllocationInboundId(owned.get(0).inboundId()); value.setAllocationInboundCode(owned.get(0).inboundCode()); value.setAllocationGroupAmount(request.amount().setScale(2, RoundingMode.HALF_UP)); @@ -86,19 +89,25 @@ public class ContainerFeeDetailService { if (allocated.compareTo(total) != 0) throw conflict("柜级费用分摊合计必须等于费用总额"); Map ownership = new HashMap<>(); loading.items(loadingPlanId).forEach(value -> ownership.put( - new InboundAllocationKey(value.getInboundId(), value.getInboundCode()), value.getAccountId())); + new InboundAllocationKey(value.getOrderId(), text(value.getOrderNo()), value.getInboundId(), + text(value.getInboundCode())), value.getAccountId())); Set unique = new HashSet<>(); long groupId = IdWorker.getId(); List rows = new java.util.ArrayList<>(); for (Allocation allocation : request.allocations()) { - InboundAllocationKey key = new InboundAllocationKey(allocation.inboundId(), allocation.inboundCode().trim()); - if (!unique.add(key)) throw conflict("同一入仓码不得重复分摊"); + String inboundCode = text(allocation.inboundCode()); + if ((allocation.inboundId() == null) != (inboundCode == null)) + throw new BizException(ResultCode.VALIDATION_FAILED, "入仓ID与入仓码必须同时填写或同时留空"); + InboundAllocationKey key = new InboundAllocationKey(allocation.orderId(), allocation.orderNo().trim(), + allocation.inboundId(), inboundCode); + if (!unique.add(key)) throw conflict("同一业务归属不得重复分摊"); if (!allocation.customerId().equals(ownership.get(key))) - throw conflict("分摊客户、入仓码与柜内真实归属不一致"); + throw conflict("分摊客户、订单及入仓信息与柜内真实归属不一致"); ContainerFeeDetailEntity value = new ContainerFeeDetailEntity(); value.setId(IdWorker.getId()); value.setTenantId(allocation.customerId()); - value.setAllocationGroupId(groupId); value.setAllocationInboundId(allocation.inboundId()); - value.setAllocationInboundCode(allocation.inboundCode().trim()); value.setAllocationGroupAmount(total); + value.setAllocationGroupId(groupId); value.setAllocationOrderId(allocation.orderId()); + value.setAllocationOrderNo(allocation.orderNo().trim()); value.setAllocationInboundId(allocation.inboundId()); + value.setAllocationInboundCode(inboundCode); value.setAllocationGroupAmount(total); value.setLoadingPlanId(loadingPlanId); value.setContainerBookingId(plan.plan().getContainerBookingId()); value.setContainerNo(plan.booking() == null ? null : plan.booking().getContainerNo()); value.setSourceVersion(1); value.setSourceStatus("ACTIVE"); value.setReviewStatus("PENDING"); @@ -359,5 +368,7 @@ public class ContainerFeeDetailService { private record PlanContext(LoadingPlanEntity plan, ContainerBookingEntity booking) { } - private record InboundAllocationKey(Long inboundId, String inboundCode) { } + private static String text(String value) { return value == null || value.isBlank() ? null : value.trim(); } + + private record InboundAllocationKey(Long orderId, String orderNo, Long inboundId, String inboundCode) { } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotService.java index c08c08cea..1ee012cb0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotService.java @@ -70,12 +70,13 @@ public class FinanceFeeSourceSnapshotService { nullSafe(detail.getSettlementMode()), nullSafe(detail.getPaidAt()), nullSafe(detail.getPaymentMethod()), nullSafe(detail.getPaymentReferenceNo()), nullSafe(detail.getPaymentVoucherFileIds()), - nullSafe(detail.getAllocationGroupId()), nullSafe(detail.getAllocationInboundId()), + nullSafe(detail.getAllocationGroupId()), nullSafe(detail.getAllocationOrderId()), + nullSafe(detail.getAllocationOrderNo()), nullSafe(detail.getAllocationInboundId()), nullSafe(detail.getAllocationInboundCode()), nullSafe(detail.getAllocationGroupAmount()), detail.getAmount().toPlainString(), nullSafe(detail.getCurrency()), nullSafe(detail.getReviewStatus())); return new FinanceFeeSourceSnapshot(detail.getTenantId(), "CONTAINER_FEE", feeId, - version, detail.getAllocationInboundId(), detail.getAllocationInboundCode(), null, containerId, detail.getContainerBookingId(), + version, detail.getAllocationInboundId(), detail.getAllocationInboundCode(), detail.getAllocationOrderId(), containerId, detail.getContainerBookingId(), detail.getContainerNo(), detail.getTenantId(), null, detail.getSupplierId(), detail.getSupplierName(), detail.getSupplierCategory(), detail.getSettlementMode(), detail.getPaidAt(), detail.getPaymentMethod(), detail.getPaymentReferenceNo(), diff --git a/firstleg/src/main/resources/db/finance-fee-source-migration.sql b/firstleg/src/main/resources/db/finance-fee-source-migration.sql index 9340eee23..d61769950 100644 --- a/firstleg/src/main/resources/db/finance-fee-source-migration.sql +++ b/firstleg/src/main/resources/db/finance-fee-source-migration.sql @@ -65,6 +65,8 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail ( id BIGINT UNSIGNED NOT NULL COMMENT '柜次费用来源ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', allocation_group_id BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID;单入仓直接费用为空', + allocation_order_id BIGINT UNSIGNED NULL COMMENT '本分摊行业务订单ID', + allocation_order_no VARCHAR(64) NULL COMMENT '本分摊行业务订单号', allocation_inbound_id BIGINT UNSIGNED NULL COMMENT '本分摊行对应入仓单ID', allocation_inbound_code VARCHAR(64) NULL COMMENT '本分摊行对应入仓码', allocation_group_amount DECIMAL(18,2) NULL COMMENT '柜级原始费用总额快照', @@ -123,6 +125,8 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot ( source_version INT UNSIGNED NOT NULL COMMENT '费用来源版本', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', allocation_group_id BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID', + allocation_order_id BIGINT UNSIGNED NULL COMMENT '分摊业务订单ID快照', + allocation_order_no VARCHAR(64) NULL COMMENT '分摊业务订单号快照', allocation_inbound_id BIGINT UNSIGNED NULL COMMENT '分摊入仓单ID快照', allocation_inbound_code VARCHAR(64) NULL COMMENT '分摊入仓码快照', allocation_group_amount DECIMAL(18,2) NULL COMMENT '柜级原始费用总额快照', @@ -196,8 +200,12 @@ CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_voucher_fil "JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no"); CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_group_id', "BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID;单入仓直接费用为空' AFTER tenant_id"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_order_id', + "BIGINT UNSIGNED NULL COMMENT '本分摊行业务订单ID' AFTER allocation_group_id"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_order_no', + "VARCHAR(64) NULL COMMENT '本分摊行业务订单号' AFTER allocation_order_id"); CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_inbound_id', - "BIGINT UNSIGNED NULL COMMENT '本分摊行对应入仓单ID' AFTER allocation_group_id"); + "BIGINT UNSIGNED NULL COMMENT '本分摊行对应入仓单ID' AFTER allocation_order_no"); CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_inbound_code', "VARCHAR(64) NULL COMMENT '本分摊行对应入仓码' AFTER allocation_inbound_id"); CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_group_amount', @@ -214,8 +222,12 @@ CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_vo "JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no"); CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_group_id', "BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID' AFTER tenant_id"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_order_id', + "BIGINT UNSIGNED NULL COMMENT '分摊业务订单ID快照' AFTER allocation_group_id"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_order_no', + "VARCHAR(64) NULL COMMENT '分摊业务订单号快照' AFTER allocation_order_id"); CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_inbound_id', - "BIGINT UNSIGNED NULL COMMENT '分摊入仓单ID快照' AFTER allocation_group_id"); + "BIGINT UNSIGNED NULL COMMENT '分摊入仓单ID快照' AFTER allocation_order_no"); CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_inbound_code', "VARCHAR(64) NULL COMMENT '分摊入仓码快照' AFTER allocation_inbound_id"); CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_group_amount', diff --git a/firstleg/src/main/resources/openapi/container-fees-v1.yaml b/firstleg/src/main/resources/openapi/container-fees-v1.yaml new file mode 100644 index 000000000..cf36252e9 --- /dev/null +++ b/firstleg/src/main/resources/openapi/container-fees-v1.yaml @@ -0,0 +1,45 @@ +openapi: 3.0.3 +info: { title: Firstleg Container Fee Allocation API, version: 1.0.0 } +paths: + /api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups: + post: + summary: 原子创建柜级公共费用分摊组 + description: 客户、订单必填;入仓ID与入仓码同时填写或同时留空。各分摊金额必须大于0且合计等于费用总额。 + security: [{ bearerAuth: [] }] + parameters: [{ $ref: '#/components/parameters/LoadingPlanId' }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/AllocationGroupRequest' } } } } + responses: { '200': { description: 按客户和业务归属生成的待审核费用行 }, '409': { description: 分摊合计或柜内真实归属不一致 } } + /api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups/{groupId}/{action}: + post: + summary: 整组审核、驳回、调整或作废柜级分摊费用 + description: action 仅为 approve、reject、adjust、void;单行接口拒绝处理分摊组,避免部分成功。 + security: [{ bearerAuth: [] }] + parameters: + - { $ref: '#/components/parameters/LoadingPlanId' } + - { in: path, name: groupId, required: true, schema: { type: string, pattern: '^[1-9][0-9]{0,19}$' } } + - { in: path, name: action, required: true, schema: { type: string, enum: [approve, reject, adjust, void] } } + responses: { '200': { description: 整组处理成功 }, '409': { description: 版本、状态或完整性冲突 } } +components: + parameters: + LoadingPlanId: { in: path, name: loadingPlanId, required: true, schema: { type: string, pattern: '^[1-9][0-9]{0,19}$' } } + schemas: + AllocationGroupRequest: + type: object + required: [fee, allocations] + properties: + fee: { type: object, description: 公共费用字段;amount为原始柜级总额 } + allocations: + type: array + minItems: 1 + maxItems: 200 + items: + type: object + required: [customerId, orderId, orderNo, amount] + properties: + customerId: { type: string } + orderId: { type: string } + orderNo: { type: string, maxLength: 64 } + inboundId: { type: string, nullable: true } + inboundCode: { type: string, maxLength: 64, nullable: true } + amount: { type: number, format: decimal, exclusiveMinimum: 0 } + securitySchemes: { bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java index ba6923554..8600e21d9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java @@ -3,6 +3,8 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.ContainerFeeDetailDtos; import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest; +import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.Allocation; +import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AllocationGroupRequest; import com.baiyue.firstleg.entity.ContainerFeeDetailEntity; import com.baiyue.firstleg.entity.FeeItemEntity; import com.baiyue.firstleg.entity.LoadingPlanEntity; @@ -233,15 +235,71 @@ class ContainerFeeDetailServiceTest { assertThat(captor.getValue().getPaymentVoucherFileIds()).containsExactly(901L); } + @Test + void createsAtomicCrossCustomerAllocationAndAllowsMissingInboundCode() { + LoadingPlanEntity plan = new LoadingPlanEntity(); + plan.setId(41L); + when(loading.plan(41L)).thenReturn(plan); + LoadingPlanItemEntity first = loadingItem(10001L, 501L, "FL-501", null, null); + LoadingPlanItemEntity second = loadingItem(10002L, 502L, "FL-502", 602L, "IB-602"); + when(loading.items(41L)).thenReturn(List.of(first, second)); + when(feeItems.findItem(11L)).thenReturn(item()); + var fee = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE", + LocalDate.of(2026, 8, 20), new BigDecimal("300.00"), "CNY", null, "柜级操作费"); + var request = new AllocationGroupRequest(fee, List.of( + new Allocation(10001L, 501L, "FL-501", null, null, new BigDecimal("120.00")), + new Allocation(10002L, 502L, "FL-502", 602L, "IB-602", new BigDecimal("180.00")))); + + List result = service.createAllocationGroup(41L, 9L, request); + + assertThat(result).hasSize(2); + ArgumentCaptor captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class); + verify(repository, org.mockito.Mockito.times(2)).insert(captor.capture()); + assertThat(captor.getAllValues()).extracting(ContainerFeeDetailEntity::getTenantId) + .containsExactly(10001L, 10002L); + assertThat(captor.getAllValues().get(0).getAllocationOrderNo()).isEqualTo("FL-501"); + assertThat(captor.getAllValues().get(0).getAllocationInboundId()).isNull(); + assertThat(captor.getAllValues().get(0).getAllocationInboundCode()).isNull(); + assertThat(captor.getAllValues()).extracting(ContainerFeeDetailEntity::getAllocationGroupId) + .doesNotContainNull().allMatch(captor.getAllValues().get(0).getAllocationGroupId()::equals); + } + + @Test + void rejectsAllocationWhenInboundIdAndCodeAreOnlyPartiallyProvided() { + LoadingPlanEntity plan = new LoadingPlanEntity(); + plan.setId(41L); + when(loading.plan(41L)).thenReturn(plan); + when(loading.items(41L)).thenReturn(List.of(loadingItem(10001L, 501L, "FL-501", null, null))); + when(feeItems.findItem(11L)).thenReturn(item()); + var fee = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE", + LocalDate.of(2026, 8, 20), new BigDecimal("100.00"), "CNY", null, null); + var request = new AllocationGroupRequest(fee, List.of( + new Allocation(10001L, 501L, "FL-501", 601L, null, new BigDecimal("100.00")))); + + assertThatThrownBy(() -> service.createAllocationGroup(41L, 9L, request)) + .isInstanceOf(BizException.class).hasMessageContaining("同时填写或同时留空"); + verify(repository, never()).insert(any()); + } + private void ownedPlan() { LoadingPlanEntity plan = new LoadingPlanEntity(); plan.setId(41L); when(loading.plan(41L)).thenReturn(plan); LoadingPlanItemEntity item = new LoadingPlanItemEntity(); item.setAccountId(10001L); + item.setOrderId(501L); + item.setOrderNo("FL-501"); when(loading.items(41L)).thenReturn(List.of(item)); } + private static LoadingPlanItemEntity loadingItem(long accountId, long orderId, String orderNo, + Long inboundId, String inboundCode) { + LoadingPlanItemEntity value = new LoadingPlanItemEntity(); + value.setAccountId(accountId); value.setOrderId(orderId); value.setOrderNo(orderNo); + value.setInboundId(inboundId); value.setInboundCode(inboundCode); + return value; + } + private static FeeItemEntity item() { FeeItemEntity value = new FeeItemEntity(); value.setId(11L); diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 47cc02fb1..57e81372f 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -37,6 +37,7 @@ | 一件代发接收、SKU/箱唛库存 | `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` | | 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` | | 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | +| 海外业务费用来源 | `financefee/`;海外入仓、采购、本土卡车、本土散板和海外仓服务费审核后以 Outbox/Event Bridge 进入独立 Finance,入仓记录和入仓码按事实选填;OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml` | | 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO | | 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | | 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java index 8ec07e596..b6b6823ee 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java @@ -16,8 +16,8 @@ public class OverseasFeeBusinessGuard { if (fee.businessId() == null) { if (!"WAREHOUSE_SERVICE".equals(fee.businessType())) throw bad("业务记录ID不能为空"); - if (fee.warehouseId() == null || (fee.inboundId() == null && blank(fee.inboundCode()))) - throw bad("海外仓服务费必须关联仓库以及入仓记录或入仓码"); + if (fee.warehouseId() == null) + throw bad("海外仓服务费必须关联海外仓;入仓记录和入仓码按实际业务选填"); return; } Table table = switch (fee.businessType()) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java index feef547df..ed5410c7e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java @@ -28,7 +28,7 @@ public class OverseasFeeOutboxService { @Resource private ObjectMapper objectMapper; public void publish(OverseasFeeSource source, String action) { - BusinessFeeSourceChangedV1 unsigned = payload(source, action, null); + BusinessFeeSourceChangedV1 unsigned = payload(source, null, null); String sourceHash = EventPayloadHash.sha256( EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned))); BusinessFeeSourceChangedV1 payload = payload(source, action, sourceHash); @@ -67,7 +67,7 @@ public class OverseasFeeOutboxService { value.getSettlementMode(), instant(value.getPaidAt()), value.getPaymentMethod(), value.getPaymentReferenceNo(), value.getPaymentVoucherFileIds() == null ? List.of() : value.getPaymentVoucherFileIds(), value.getBusinessDate(), value.getCurrency(), value.getAmount(), value.getReviewStatus(), - value.getPublicationStatus(), value.getSourceStatus(), hash); + "PUBLISHED", action == null && hash == null ? "ACTIVE" : value.getSourceStatus(), hash); } private static String text(Long value) { return value == null ? null : String.valueOf(value); } diff --git a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml new file mode 100644 index 000000000..d7895d875 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml @@ -0,0 +1,50 @@ +openapi: 3.0.3 +info: { title: Overseas Business Fee Source API, version: 1.0.0 } +paths: + /api/platform/v1/last-leg/finance-fee-sources: + get: + summary: 查询海外业务费用来源当前版本 + security: [{ bearerAuth: [] }] + parameters: + - { in: query, name: customerId, schema: { type: string } } + - { in: query, name: businessType, schema: { $ref: '#/components/schemas/BusinessType' } } + - { in: query, name: businessNo, schema: { type: string } } + responses: { '200': { description: 当前版本列表 } } + post: + summary: 创建海外业务费用来源草稿 + description: 客户和业务编号必填;入仓ID、入仓码均按业务事实选填,不得为补字段伪造。 + security: [{ bearerAuth: [] }] + parameters: [{ in: header, name: Idempotency-Key, required: true, schema: { type: string, maxLength: 128 } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SaveRequest' } } } } + responses: { '200': { description: 草稿费用来源 }, '400': { description: 业务归属或付款事实无效 } } + /api/platform/v1/last-leg/finance-fee-sources/{sourceFeeId}/{action}: + post: + summary: 提交、审核、调整或作废海外业务费用来源 + description: 审核通过与作废均事务性写入Outbox,经Event Bridge可靠投递至独立Finance;action为submit、review、adjust、void。 + security: [{ bearerAuth: [] }] + parameters: + - { in: path, name: sourceFeeId, required: true, schema: { type: string } } + - { in: path, name: action, required: true, schema: { type: string, enum: [submit, review, adjust, void] } } + responses: { '200': { description: 当前费用来源版本 }, '409': { description: 状态、版本或幂等冲突 } } +components: + schemas: + BusinessType: { type: string, enum: [OVERSEAS_INBOUND, OVERSEAS_PROCUREMENT, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE] } + SaveRequest: + type: object + required: [customerId, sourceType, businessType, businessNo, feeItemCode, feeItemName, direction, businessDate, currency, amount] + properties: + customerId: { type: string } + sourceType: { type: string, enum: [OVERSEAS_INBOUND_FEE, OVERSEAS_PROCUREMENT_FEE, OVERSEAS_TRUCK_FEE, OVERSEAS_LOOSEBOARD_FEE, OVERSEAS_WAREHOUSE_FEE] } + businessType: { $ref: '#/components/schemas/BusinessType' } + businessId: { type: string, nullable: true } + businessNo: { type: string, maxLength: 128 } + warehouseId: { type: string, nullable: true } + inboundId: { type: string, nullable: true } + inboundCode: { type: string, maxLength: 64, nullable: true } + feeItemCode: { type: string } + feeItemName: { type: string } + direction: { type: string, enum: [RECEIVABLE, PAYABLE] } + businessDate: { type: string, format: date } + currency: { type: string, enum: [CNY] } + amount: { type: number, format: decimal, exclusiveMinimum: 0 } + securitySchemes: { bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxServiceTest.java new file mode 100644 index 000000000..e76b49736 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxServiceTest.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baiyue.common.event.BusinessFeeSourceChangedV1; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OverseasFeeOutboxServiceTest { + private final LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class); + private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); + private final OverseasFeeOutboxService service = new OverseasFeeOutboxService(); + + @Test + void publishesCompleteFeeSnapshotWithOptionalInboundReference() throws Exception { + ReflectionTestUtils.setField(service, "mapper", mapper); + ReflectionTestUtils.setField(service, "objectMapper", json); + when(mapper.findByEventKey(anyString())).thenReturn(null); + OverseasFeeSource source = new OverseasFeeSource(); + source.setSourceFeeId(701L); source.setSourceVersion(1); source.setCustomerId("10001"); + source.setCustomerName("客户A"); source.setSourceType("OVERSEAS_PROCUREMENT_FEE"); + source.setSourceNo("PO-US-001"); source.setBusinessType("OVERSEAS_PROCUREMENT"); + source.setBusinessId(801L); source.setBusinessNo("PO-US-001"); source.setWarehouseId(91L); + source.setFeeItemCode("PROCUREMENT_SERVICE"); source.setFeeItemName("采购服务费"); + source.setDirection("RECEIVABLE"); source.setBusinessDate(LocalDate.of(2026, 9, 2)); + source.setCurrency("CNY"); source.setAmount(new BigDecimal("120.00")); + source.setReviewStatus("APPROVED"); source.setPublicationStatus("PUBLISHED"); + source.setSourceStatus("ACTIVE"); source.setPaymentVoucherFileIds(List.of()); + + service.publish(source, "PUBLISHED"); + + ArgumentCaptor row = ArgumentCaptor.forClass(LastlegEventOutbox.class); + verify(mapper).insert(row.capture()); + EventEnvelope envelope = json.readValue(row.getValue().getPayloadJson(), EventEnvelope.class).requireValid(); + BusinessFeeSourceChangedV1 payload = json.treeToValue(envelope.payload(), BusinessFeeSourceChangedV1.class); + assertThat(payload.customerId()).isEqualTo("10001"); + assertThat(payload.businessNo()).isEqualTo("PO-US-001"); + assertThat(payload.inboundId()).isNull(); + assertThat(payload.inboundCode()).isNull(); + assertThat(payload.sourceHash()).startsWith("sha256:").hasSize(71); + } +} diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 81332bff9..05e7418c8 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -3805,6 +3805,49 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab AND table_name='ll_cargo_discard_application' AND column_name='review_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER request_hash')); PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt; + +-- ============================================================================ +-- 2026-09-02 / 031 +-- 海外业务费用来源与写操作幂等审计;入仓码为可选业务引用。 +-- ============================================================================ +CREATE TABLE IF NOT EXISTS ll_finance_fee_source ( + id BIGINT NOT NULL COMMENT '费用版本记录ID', source_fee_id BIGINT NOT NULL COMMENT '跨版本稳定费用来源ID', + source_version INT NOT NULL COMMENT '费用来源版本,从1递增', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', + customer_name VARCHAR(200) NULL COMMENT '客户名称快照', source_type VARCHAR(64) NOT NULL COMMENT '海外费用来源类型', + source_no VARCHAR(128) NOT NULL COMMENT '来源业务编号或入仓码快照', business_type VARCHAR(32) NOT NULL COMMENT '来源业务类型', + business_id BIGINT NULL COMMENT '来源业务记录ID', business_no VARCHAR(128) NOT NULL COMMENT '来源业务编号', + order_id BIGINT NULL COMMENT '关联订单ID', inbound_id BIGINT NULL COMMENT '关联海外入仓计划或入仓记录ID', + inbound_code VARCHAR(64) NULL COMMENT '关联入仓码;并非所有费用必填', container_id BIGINT NULL COMMENT '关联柜次ID', + container_booking_id BIGINT NULL COMMENT '来源订舱柜ID', container_no VARCHAR(64) NULL COMMENT '真实集装箱号快照', + warehouse_id BIGINT NULL COMMENT '关联海外仓ID', fee_item_code VARCHAR(64) NOT NULL COMMENT '费用项目编码快照', + fee_item_name VARCHAR(200) NOT NULL COMMENT '费用项目名称快照', direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付', + supplier_id BIGINT NULL COMMENT '应付供应商ID', supplier_name VARCHAR(200) NULL COMMENT '应付供应商名称快照', + supplier_category VARCHAR(32) NULL COMMENT '应付供应商类别约束', settlement_mode VARCHAR(16) NULL COMMENT 'ON_ACCOUNT账期应付或DIRECT_PAID业务已付', + paid_at DATETIME(3) NULL COMMENT '直接已付时间', payment_method VARCHAR(32) NULL COMMENT '直接已付方式', + payment_reference_no VARCHAR(128) NULL COMMENT '直接已付流水号', payment_voucher_file_ids JSON NULL COMMENT '直接已付凭证文件ID快照', + business_date DATE NOT NULL COMMENT '费用业务日期', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,当前固定CNY', + amount DECIMAL(20,6) NOT NULL COMMENT '费用金额', review_status VARCHAR(16) NOT NULL COMMENT 'DRAFT、PENDING、APPROVED或REJECTED', + publication_status VARCHAR(16) NOT NULL COMMENT 'UNPUBLISHED或PUBLISHED', source_status VARCHAR(16) NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED', + review_reason VARCHAR(500) NULL COMMENT '审核或作废原因', reviewer_id VARCHAR(128) NULL COMMENT '审核人ID', reviewed_at DATETIME(3) NULL COMMENT '审核时间', + created_by VARCHAR(128) NOT NULL COMMENT '创建人ID', updated_by VARCHAR(128) NOT NULL COMMENT '最后更新人ID', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_version(source_fee_id,source_version), + KEY idx_ll_finance_fee_customer(customer_id,source_status,created_at), KEY idx_ll_finance_fee_business(business_type,business_no,created_at), + CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_finance_fee_currency CHECK(currency='CNY'), + CONSTRAINT chk_ll_finance_fee_direction CHECK(direction IN('RECEIVABLE','PAYABLE')), + CONSTRAINT chk_ll_finance_fee_review CHECK(review_status IN('DRAFT','PENDING','APPROVED','REJECTED')), + CONSTRAINT chk_ll_finance_fee_publication CHECK(publication_status IN('UNPUBLISHED','PUBLISHED')), + CONSTRAINT chk_ll_finance_fee_status CHECK(source_status IN('ACTIVE','SUPERSEDED','VOIDED')) +) ENGINE=InnoDB COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance'; + +CREATE TABLE IF NOT EXISTS ll_finance_fee_command ( + id BIGINT NOT NULL COMMENT '幂等命令ID', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', + action VARCHAR(16) NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID', idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', source_fee_id BIGINT NOT NULL COMMENT '稳定费用来源ID', + source_version INT NOT NULL COMMENT '命令结果版本', created_at DATETIME(3) NOT NULL COMMENT '命令创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_command(operator_id,action,idempotency_key), + KEY idx_ll_finance_fee_command_source(source_fee_id,source_version) +) ENGINE=InnoDB COMMENT='海外费用来源写操作幂等审计'; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='review_request_hash'), 'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_request_hash CHAR(64) NULL COMMENT ''审核请求内容哈希'' AFTER review_idempotency_key')); From 5099a46581ba961325c62dc62bd3018b5c24199c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 2 Sep 2026 17:36:37 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E4=B8=AD=E6=96=87=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 170 +++++++++++++++- .../resources/db/phase2-inbound-schema.sql | 6 +- .../db/phase3-container-customs-schema.sql | 186 +++++++++--------- .../db/phase3-container-overview.sql | 8 +- .../db/phase3-smart-loading-schema.sql | 32 +-- order/src/main/resources/db/order.sql | 12 +- 6 files changed, 286 insertions(+), 128 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 62b4336a8..cd224e64f 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -14636,12 +14636,9 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse_address', 'version', CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse_address', 'warehouse_id', '所属仓库ID'); -- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS CALL apply_queued_comments(); -DROP PROCEDURE IF EXISTS apply_queued_comments; -DROP PROCEDURE IF EXISTS apply_one_table_comments; -DROP PROCEDURE IF EXISTS queue_column_comment; -DROP PROCEDURE IF EXISTS queue_table_comment; -DROP TEMPORARY TABLE IF EXISTS tmp_column_comment_target; -DROP TEMPORARY TABLE IF EXISTS tmp_table_comment_target; +-- 后续还会创建整柜报关和同柜组最新基线,保留本会话内的结构保真注释升级器并清空首批队列。 +TRUNCATE TABLE tmp_column_comment_target; +TRUNCATE TABLE tmp_table_comment_target; -- 三期统一最终总断言:稳定箱号、单箱测量、墨家收件箱、客户尺寸和装柜权限。 USE baiyue_firstleg; @@ -16514,6 +16511,167 @@ CREATE TABLE IF NOT EXISTS fl_coload_command ( CONSTRAINT chk_coload_command_status CHECK(command_status IN('PROCESSING','SUCCESS')) )ENGINE=InnoDB COMMENT='同柜不可拆组幂等写命令'; +-- 最新追加基线在首轮注释升级之后创建;统一在最终断言前使用同一结构保真升级器收敛存量库注释。 +CALL queue_column_comment('baiyue_firstleg','fl_coload_command','action_type','命令动作类型'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_command','idempotency_key','幂等键'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_command','request_hash','请求摘要'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_command','command_status','命令状态:PROCESSING处理中、SUCCESS成功'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_command','result_group_id','命令结果组ID'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_command','created_at','命令创建时间'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_command','completed_at','命令完成时间'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_group','group_status','组状态:ACTIVE有效、LOCKED锁定、CONSUMED已消费、CANCELLED已取消'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_group','created_at','创建时间'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_group','updated_at','更新时间'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_group','locked_by','锁定操作人ID'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_group','locked_by_name','锁定操作人名称快照'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_group','locked_at','锁定时间'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_group','consumed_by','消费操作人ID'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_group','consumed_by_name','消费操作人名称快照'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_group','consumed_at','消费完成时间'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_group','cancelled_by','取消操作人ID'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_group','cancelled_by_name','取消操作人名称快照'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_group','cancelled_at','取消时间'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_group_history','group_id','同柜不可拆组ID'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_group_history','action_type','操作类型:CREATE创建、CANCEL取消、LOCK锁定、UNLOCK解锁、CONSUME消费'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_group_history','from_status','变更前组状态'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_group_history','to_status','变更后组状态'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_group_history','plan_id','关联装柜计划ID'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_group_history','remark','操作原因或备注'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_group_history','operator_id','操作人ID'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_group_history','operator_name','操作人名称快照'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_group_history','created_at','历史记录时间'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_group_member','created_at','成员创建时间'); +CALL queue_column_comment('baiyue_firstleg','fl_coload_group_member','closed_at','成员约束关闭时间'); + +CALL queue_column_comment('baiyue_firstleg','fl_customs_command','id','报关幂等命令ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_command','actor_id','命令操作人ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_command','action','命令动作类型'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_command','idempotency_key','客户端幂等键'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_command','request_hash','规范化请求SHA-256摘要'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_command','status','命令状态:PROCESSING处理中、SUCCESS成功'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_command','result_id','命令结果业务记录ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_command','created_at','命令创建时间'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_command','completed_at','命令完成时间'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_completion_document','id','报关完成文件ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_completion_document','file_id','File服务文件ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_completion_document','file_name','完成文件名称快照'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_completion_document','file_hash','完成文件SHA-256摘要'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_completion_document','content_type','完成文件媒体类型'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_completion_document','size_bytes','完成文件大小(字节)'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_completion_document','uploaded_by','上传操作人ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_completion_document','uploaded_by_name','上传操作人名称快照'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_completion_document','uploaded_at','上传时间'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_completion_document','version','完成文件版本号'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_completion_document','created_at','完成文件创建时间'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_completion_document','updated_at','完成文件更新时间'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_completion_document_version','id','完成文件版本记录ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_completion_document_version','completion_document_id','报关完成文件ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_completion_document_version','document_version','完成文件版本号'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_completion_document_version','file_id','本版本File服务文件ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_completion_document_version','file_name','本版本文件名称快照'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_completion_document_version','file_hash','本版本文件SHA-256摘要'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_completion_document_version','content_type','本版本文件媒体类型'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_completion_document_version','size_bytes','本版本文件大小(字节)'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_completion_document_version','operator_id','操作人ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_completion_document_version','operator_name','操作人名称快照'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_completion_document_version','created_at','版本创建时间'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_container','id','整柜报关聚合ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_container','status','报关状态:WAITING_CUSTOMER_DOCUMENTS待资料、READY_TO_START可开始、DECLARING申报中、COMPLETED已完成'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_container','started_by','开始报关操作人ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_container','started_by_name','开始报关操作人名称快照'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_container','started_at','开始报关时间'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_container','completed_by','完成报关操作人ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_container','completed_by_name','完成报关操作人名称快照'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_container','completed_at','报关完成时间'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_container','created_at','聚合创建时间'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_container','updated_at','聚合更新时间'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_container','version','乐观锁版本号'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_container_history','id','报关历史记录ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_container_history','customs_container_id','所属整柜报关聚合ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_container_history','action','报关操作类型'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_container_history','from_status','变更前报关状态'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_container_history','to_status','变更后报关状态'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_container_history','remark','操作备注'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_container_history','operator_id','操作人ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_container_history','operator_name','操作人名称快照'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_container_history','created_at','历史创建时间'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document','id','客户报送资料ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document','customs_container_id','所属整柜报关聚合ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document','file_id','File服务文件ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document','file_name','文件名称快照'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document','file_hash','文件SHA-256摘要'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document','content_type','文件媒体类型'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document','size_bytes','文件大小(字节)'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document','status','资料状态:ACTIVE有效、DELETED已删除'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document','uploaded_by','上传操作人ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document','uploaded_at','上传时间'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document','frozen_version','开始报关时冻结的资料版本'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document','frozen_at','资料冻结时间'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document','deleted_by','删除操作人ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document','deleted_at','删除时间'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document','version','资料版本号'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document','created_at','资料创建时间'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document','updated_at','资料更新时间'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document_inbound','document_id','客户报送资料ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document_inbound','inbound_id','覆盖入仓单ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document_inbound','tenant_id','覆盖入仓单客户ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document_inbound','order_id','覆盖订单ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document_inbound','inbound_code','覆盖入仓码快照'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document_inbound','created_at','关联创建时间'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document_version','id','资料版本记录ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document_version','document_id','客户报送资料ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document_version','document_version','资料版本号'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document_version','file_id','本版本File服务文件ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document_version','file_name','本版本文件名称快照'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document_version','file_hash','本版本文件SHA-256摘要'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document_version','content_type','本版本文件媒体类型'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document_version','size_bytes','本版本文件大小(字节)'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document_version','scope_snapshot_json','本版本覆盖入仓码范围快照'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document_version','action','版本动作:UPLOAD上传、REPLACE替换、SCOPE_CHANGE范围变化、DELETE删除'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document_version','operator_id','操作人ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_customer_document_version','created_at','版本创建时间'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_frozen_document_inbound','document_id','冻结的客户报送资料ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_frozen_document_inbound','inbound_id','冻结覆盖入仓单ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_frozen_document_inbound','tenant_id','冻结覆盖客户ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_frozen_document_inbound','order_id','冻结覆盖订单ID'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_frozen_document_inbound','inbound_code','冻结覆盖入仓码快照'); +CALL queue_column_comment('baiyue_firstleg','fl_customs_frozen_document_inbound','created_at','冻结关联创建时间'); + +CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','id','柜次证据记录ID'); +CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','file_id','File服务文件ID'); +CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','file_name','文件名称快照'); +CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','seal_no','带锁码照对应锁码'); +CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','remark','证据备注'); +CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','status','证据状态:ACTIVE有效、VOID作废'); +CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','void_reason','作废原因'); +CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','voided_by','作废操作人ID'); +CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','voided_by_name','作废操作人名称快照'); +CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','voided_at','作废时间'); +CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','version','乐观锁版本号'); +CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','created_by','创建操作人ID'); +CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','created_by_name','创建操作人名称快照'); +CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','updated_by','最后修改操作人ID'); +CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','updated_by_name','最后修改操作人名称快照'); +CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','created_at','创建时间'); +CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','updated_at','最后更新时间'); +CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_event','consumed_at','事件消费完成时间'); +CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','created_at','快照创建时间'); +CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','updated_at','快照更新时间'); +CALL queue_column_comment('baiyue_order','ord_customer_address','created_by','创建人ID'); +CALL queue_column_comment('baiyue_order','ord_customer_address','created_at','创建时间'); +CALL queue_column_comment('baiyue_order','ord_customer_address','updated_by','最后修改人ID'); +CALL queue_column_comment('baiyue_order','ord_customer_address','updated_at','更新时间'); +CALL queue_column_comment('baiyue_order','ord_customer_address','deleted','逻辑删除标记:0未删除、1已删除'); +CALL queue_column_comment('baiyue_order','ord_customer_address','version','乐观锁版本号'); + +CALL apply_queued_comments(); +DROP PROCEDURE IF EXISTS apply_queued_comments; +DROP PROCEDURE IF EXISTS apply_one_table_comments; +DROP PROCEDURE IF EXISTS queue_column_comment; +DROP PROCEDURE IF EXISTS queue_table_comment; +DROP TEMPORARY TABLE IF EXISTS tmp_column_comment_target; +DROP TEMPORARY TABLE IF EXISTS tmp_table_comment_target; + CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index d8718e033..8b02f6555 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -832,8 +832,8 @@ CREATE TABLE IF NOT EXISTS fl_order_declaration_snapshot ( cargoes_json JSON NOT NULL COMMENT '客户申报货物行完整快照,不覆盖实测事实', changes_json JSON NULL COMMENT '本版本申报字段变化摘要', occurred_at DATETIME(3) NOT NULL COMMENT 'Order事件发生时间', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '快照创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '快照更新时间', PRIMARY KEY (tenant_id, order_id), UNIQUE KEY uk_order_declaration_event (tenant_id, event_id), KEY idx_order_declaration_version (tenant_id, order_id, order_version) @@ -845,7 +845,7 @@ CREATE TABLE IF NOT EXISTS fl_order_declaration_event ( order_id BIGINT UNSIGNED NOT NULL COMMENT 'Order订单ID', order_version INT NOT NULL COMMENT '事件携带的Order聚合版本', request_hash CHAR(64) NOT NULL COMMENT '完整事件SHA-256,用于异参重放拒绝', - consumed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + consumed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事件消费完成时间', PRIMARY KEY (tenant_id, event_id), KEY idx_order_declaration_event_order (tenant_id, order_id, order_version) ) ENGINE=InnoDB COMMENT='Order客户申报事件永久幂等账本'; diff --git a/firstleg/src/main/resources/db/phase3-container-customs-schema.sql b/firstleg/src/main/resources/db/phase3-container-customs-schema.sql index 5f86e3d2b..f151d6a41 100644 --- a/firstleg/src/main/resources/db/phase3-container-customs-schema.sql +++ b/firstleg/src/main/resources/db/phase3-container-customs-schema.sql @@ -2,19 +2,19 @@ SET NAMES utf8mb4; USE baiyue_firstleg; CREATE TABLE IF NOT EXISTS fl_customs_container ( - id BIGINT UNSIGNED NOT NULL, + id BIGINT UNSIGNED NOT NULL COMMENT '整柜报关聚合ID', loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '已完成装柜计划ID;报关柜级聚合唯一键', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '物理货柜ID', - status VARCHAR(40) NOT NULL COMMENT 'WAITING_CUSTOMER_DOCUMENTS、READY_TO_START、DECLARING、COMPLETED', - started_by BIGINT UNSIGNED NULL, - started_by_name VARCHAR(64) NULL, - started_at DATETIME(3) NULL, - completed_by BIGINT UNSIGNED NULL, - completed_by_name VARCHAR(64) NULL, - completed_at DATETIME(3) NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - version INT UNSIGNED NOT NULL DEFAULT 0, + status VARCHAR(40) NOT NULL COMMENT '报关状态:WAITING_CUSTOMER_DOCUMENTS待资料、READY_TO_START可开始、DECLARING申报中、COMPLETED已完成', + started_by BIGINT UNSIGNED NULL COMMENT '开始报关操作人ID', + started_by_name VARCHAR(64) NULL COMMENT '开始报关操作人名称快照', + started_at DATETIME(3) NULL COMMENT '开始报关时间', + completed_by BIGINT UNSIGNED NULL COMMENT '完成报关操作人ID', + completed_by_name VARCHAR(64) NULL COMMENT '完成报关操作人名称快照', + completed_at DATETIME(3) NULL COMMENT '报关完成时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '聚合创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '聚合更新时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY(id), UNIQUE KEY uk_customs_container_plan(loading_plan_id), UNIQUE KEY uk_customs_container_booking(container_booking_id), @@ -24,24 +24,24 @@ CREATE TABLE IF NOT EXISTS fl_customs_container ( ) ENGINE=InnoDB COMMENT='装柜完成后的整柜出口报关聚合'; CREATE TABLE IF NOT EXISTS fl_customs_customer_document ( - id BIGINT UNSIGNED NOT NULL, - customs_container_id BIGINT UNSIGNED NOT NULL, + id BIGINT UNSIGNED NOT NULL COMMENT '客户报送资料ID', + customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键;只允许关联同客户退税票', - file_id BIGINT UNSIGNED NOT NULL, - file_name VARCHAR(255) NOT NULL, - file_hash CHAR(64) NOT NULL, - content_type VARCHAR(128) NULL, - size_bytes BIGINT UNSIGNED NOT NULL, - status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', - uploaded_by BIGINT UNSIGNED NOT NULL, - uploaded_at DATETIME(3) NOT NULL, - frozen_version INT UNSIGNED NULL, - frozen_at DATETIME(3) NULL, - deleted_by BIGINT UNSIGNED NULL, - deleted_at DATETIME(3) NULL, - version INT UNSIGNED NOT NULL DEFAULT 1, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID', + file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照', + file_hash CHAR(64) NOT NULL COMMENT '文件SHA-256摘要', + content_type VARCHAR(128) NULL COMMENT '文件媒体类型', + size_bytes BIGINT UNSIGNED NOT NULL COMMENT '文件大小(字节)', + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '资料状态:ACTIVE有效、DELETED已删除', + uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID', + uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间', + frozen_version INT UNSIGNED NULL COMMENT '开始报关时冻结的资料版本', + frozen_at DATETIME(3) NULL COMMENT '资料冻结时间', + deleted_by BIGINT UNSIGNED NULL COMMENT '删除操作人ID', + deleted_at DATETIME(3) NULL COMMENT '删除时间', + version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '资料版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '资料创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '资料更新时间', PRIMARY KEY(id), KEY idx_customs_customer_document_container(customs_container_id,status,tenant_id), CONSTRAINT fk_customs_customer_document_container FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id), @@ -51,30 +51,30 @@ CREATE TABLE IF NOT EXISTS fl_customs_customer_document ( ) ENGINE=InnoDB COMMENT='客户整份退税报送资料逻辑根'; CREATE TABLE IF NOT EXISTS fl_customs_customer_document_inbound ( - document_id BIGINT UNSIGNED NOT NULL, - inbound_id BIGINT UNSIGNED NOT NULL, - tenant_id BIGINT UNSIGNED NOT NULL, - order_id BIGINT UNSIGNED NOT NULL, - inbound_code VARCHAR(40) NOT NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + document_id BIGINT UNSIGNED NOT NULL COMMENT '客户报送资料ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '覆盖入仓单ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '覆盖入仓单客户ID', + order_id BIGINT UNSIGNED NOT NULL COMMENT '覆盖订单ID', + inbound_code VARCHAR(40) NOT NULL COMMENT '覆盖入仓码快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关联创建时间', PRIMARY KEY(document_id,inbound_id), KEY idx_customs_document_inbound(inbound_id,tenant_id), CONSTRAINT fk_customs_document_inbound_document FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id) ) ENGINE=InnoDB COMMENT='客户报送资料与当前柜退税入仓码多对多关系'; CREATE TABLE IF NOT EXISTS fl_customs_customer_document_version ( - id BIGINT UNSIGNED NOT NULL, - document_id BIGINT UNSIGNED NOT NULL, - document_version INT UNSIGNED NOT NULL, - file_id BIGINT UNSIGNED NOT NULL, - file_name VARCHAR(255) NOT NULL, - file_hash CHAR(64) NOT NULL, - content_type VARCHAR(128) NULL, - size_bytes BIGINT UNSIGNED NOT NULL, - scope_snapshot_json JSON NOT NULL, - action VARCHAR(24) NOT NULL COMMENT 'UPLOAD、REPLACE、SCOPE_CHANGE、DELETE', - operator_id BIGINT UNSIGNED NOT NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + id BIGINT UNSIGNED NOT NULL COMMENT '资料版本记录ID', + document_id BIGINT UNSIGNED NOT NULL COMMENT '客户报送资料ID', + document_version INT UNSIGNED NOT NULL COMMENT '资料版本号', + file_id BIGINT UNSIGNED NOT NULL COMMENT '本版本File服务文件ID', + file_name VARCHAR(255) NOT NULL COMMENT '本版本文件名称快照', + file_hash CHAR(64) NOT NULL COMMENT '本版本文件SHA-256摘要', + content_type VARCHAR(128) NULL COMMENT '本版本文件媒体类型', + size_bytes BIGINT UNSIGNED NOT NULL COMMENT '本版本文件大小(字节)', + scope_snapshot_json JSON NOT NULL COMMENT '本版本覆盖入仓码范围快照', + action VARCHAR(24) NOT NULL COMMENT '版本动作:UPLOAD上传、REPLACE替换、SCOPE_CHANGE范围变化、DELETE删除', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本创建时间', PRIMARY KEY(id), UNIQUE KEY uk_customs_customer_document_version(document_id,document_version), CONSTRAINT fk_customs_customer_document_version FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id), @@ -82,77 +82,77 @@ CREATE TABLE IF NOT EXISTS fl_customs_customer_document_version ( ) ENGINE=InnoDB COMMENT='客户报送资料文件与覆盖范围不可变版本'; CREATE TABLE IF NOT EXISTS fl_customs_frozen_document_inbound ( - document_id BIGINT UNSIGNED NOT NULL, - inbound_id BIGINT UNSIGNED NOT NULL, - tenant_id BIGINT UNSIGNED NOT NULL, - order_id BIGINT UNSIGNED NOT NULL, - inbound_code VARCHAR(40) NOT NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + document_id BIGINT UNSIGNED NOT NULL COMMENT '冻结的客户报送资料ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '冻结覆盖入仓单ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '冻结覆盖客户ID', + order_id BIGINT UNSIGNED NOT NULL COMMENT '冻结覆盖订单ID', + inbound_code VARCHAR(40) NOT NULL COMMENT '冻结覆盖入仓码快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '冻结关联创建时间', PRIMARY KEY(document_id,inbound_id), CONSTRAINT fk_customs_frozen_document FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id) ) ENGINE=InnoDB COMMENT='开始报关时冻结的客户资料覆盖范围'; CREATE TABLE IF NOT EXISTS fl_customs_completion_document ( - id BIGINT UNSIGNED NOT NULL, + id BIGINT UNSIGNED NOT NULL COMMENT '报关完成文件ID', source_document_id BIGINT UNSIGNED NOT NULL COMMENT '一份冻结客户资料严格对应一份完成文件逻辑根', - file_id BIGINT UNSIGNED NOT NULL, - file_name VARCHAR(255) NOT NULL, - file_hash CHAR(64) NOT NULL, - content_type VARCHAR(128) NULL, - size_bytes BIGINT UNSIGNED NOT NULL, - uploaded_by BIGINT UNSIGNED NOT NULL, - uploaded_by_name VARCHAR(64) NULL, - uploaded_at DATETIME(3) NOT NULL, - version INT UNSIGNED NOT NULL DEFAULT 1, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID', + file_name VARCHAR(255) NOT NULL COMMENT '完成文件名称快照', + file_hash CHAR(64) NOT NULL COMMENT '完成文件SHA-256摘要', + content_type VARCHAR(128) NULL COMMENT '完成文件媒体类型', + size_bytes BIGINT UNSIGNED NOT NULL COMMENT '完成文件大小(字节)', + uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID', + uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照', + uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间', + version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '完成文件版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '完成文件创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '完成文件更新时间', PRIMARY KEY(id), UNIQUE KEY uk_customs_completion_source(source_document_id), CONSTRAINT fk_customs_completion_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id) ) ENGINE=InnoDB COMMENT='报关完成文件当前版本'; CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version ( - id BIGINT UNSIGNED NOT NULL, - completion_document_id BIGINT UNSIGNED NOT NULL, - document_version INT UNSIGNED NOT NULL, - file_id BIGINT UNSIGNED NOT NULL, - file_name VARCHAR(255) NOT NULL, - file_hash CHAR(64) NOT NULL, - content_type VARCHAR(128) NULL, - size_bytes BIGINT UNSIGNED NOT NULL, - operator_id BIGINT UNSIGNED NOT NULL, - operator_name VARCHAR(64) NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + id BIGINT UNSIGNED NOT NULL COMMENT '完成文件版本记录ID', + completion_document_id BIGINT UNSIGNED NOT NULL COMMENT '报关完成文件ID', + document_version INT UNSIGNED NOT NULL COMMENT '完成文件版本号', + file_id BIGINT UNSIGNED NOT NULL COMMENT '本版本File服务文件ID', + file_name VARCHAR(255) NOT NULL COMMENT '本版本文件名称快照', + file_hash CHAR(64) NOT NULL COMMENT '本版本文件SHA-256摘要', + content_type VARCHAR(128) NULL COMMENT '本版本文件媒体类型', + size_bytes BIGINT UNSIGNED NOT NULL COMMENT '本版本文件大小(字节)', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本创建时间', PRIMARY KEY(id), UNIQUE KEY uk_customs_completion_version(completion_document_id,document_version), CONSTRAINT fk_customs_completion_version FOREIGN KEY(completion_document_id) REFERENCES fl_customs_completion_document(id) ) ENGINE=InnoDB COMMENT='报关完成文件不可变覆盖版本'; CREATE TABLE IF NOT EXISTS fl_customs_container_history ( - id BIGINT UNSIGNED NOT NULL, - customs_container_id BIGINT UNSIGNED NOT NULL, - action VARCHAR(40) NOT NULL, - from_status VARCHAR(40) NULL, - to_status VARCHAR(40) NULL, - remark VARCHAR(500) NULL, - operator_id BIGINT UNSIGNED NOT NULL, - operator_name VARCHAR(64) NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + id BIGINT UNSIGNED NOT NULL COMMENT '报关历史记录ID', + customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID', + action VARCHAR(40) NOT NULL COMMENT '报关操作类型', + from_status VARCHAR(40) NULL COMMENT '变更前报关状态', + to_status VARCHAR(40) NULL COMMENT '变更后报关状态', + remark VARCHAR(500) NULL COMMENT '操作备注', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史创建时间', PRIMARY KEY(id), KEY idx_customs_container_history(customs_container_id,created_at,id), CONSTRAINT fk_customs_container_history FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id) ) ENGINE=InnoDB COMMENT='整柜报关状态与系统触发历史'; CREATE TABLE IF NOT EXISTS fl_customs_command ( - id BIGINT UNSIGNED NOT NULL, - actor_id BIGINT UNSIGNED NOT NULL, - action VARCHAR(80) NOT NULL, - idempotency_key VARCHAR(128) NOT NULL, - request_hash CHAR(64) NOT NULL, - status VARCHAR(16) NOT NULL, - result_id BIGINT UNSIGNED NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - completed_at DATETIME(3) NULL, + id BIGINT UNSIGNED NOT NULL COMMENT '报关幂等命令ID', + actor_id BIGINT UNSIGNED NOT NULL COMMENT '命令操作人ID', + action VARCHAR(80) NOT NULL COMMENT '命令动作类型', + idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', + request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要', + status VARCHAR(16) NOT NULL COMMENT '命令状态:PROCESSING处理中、SUCCESS成功', + result_id BIGINT UNSIGNED NULL COMMENT '命令结果业务记录ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '命令创建时间', + completed_at DATETIME(3) NULL COMMENT '命令完成时间', PRIMARY KEY(id), UNIQUE KEY uk_customs_command(actor_id,action,idempotency_key), CONSTRAINT chk_customs_command_status CHECK(status IN('PROCESSING','SUCCESS')) diff --git a/firstleg/src/main/resources/db/phase3-container-overview.sql b/firstleg/src/main/resources/db/phase3-container-overview.sql index 0b85edca2..d721ddef3 100644 --- a/firstleg/src/main/resources/db/phase3-container-overview.sql +++ b/firstleg/src/main/resources/db/phase3-container-overview.sql @@ -1,10 +1,10 @@ USE baiyue_firstleg; CREATE TABLE IF NOT EXISTS fl_container_exception_history ( - id BIGINT UNSIGNED NOT NULL, container_booking_id BIGINT UNSIGNED NOT NULL, - action VARCHAR(16) NOT NULL COMMENT 'MARK/CANCEL', reason VARCHAR(500) NOT NULL, - operator_id BIGINT UNSIGNED NOT NULL, operator_name VARCHAR(64), + id BIGINT UNSIGNED NOT NULL COMMENT '货柜异常历史ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '物理货柜ID', + action VARCHAR(16) NOT NULL COMMENT '操作类型:MARK标记异常、CANCEL取消异常', reason VARCHAR(500) NOT NULL COMMENT '异常原因', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', operator_name VARCHAR(64) COMMENT '操作人名称快照', operation_id VARCHAR(128) NOT NULL COMMENT '同一操作人幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求摘要', - occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作发生时间', PRIMARY KEY(id), UNIQUE KEY uk_container_exception_operation(container_booking_id,operator_id,operation_id), KEY idx_container_exception_history(container_booking_id,occurred_at,id), CONSTRAINT fk_container_exception_booking FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id), diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 5f13bef87..00695df18 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -344,11 +344,11 @@ CREATE TABLE IF NOT EXISTS fl_container_document ( ) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联'; CREATE TABLE IF NOT EXISTS fl_container_exception_history ( - id BIGINT UNSIGNED NOT NULL, container_booking_id BIGINT UNSIGNED NOT NULL, - action VARCHAR(16) NOT NULL, reason VARCHAR(500) NOT NULL, - operator_id BIGINT UNSIGNED NOT NULL, operator_name VARCHAR(64) NULL, - operation_id VARCHAR(128) NOT NULL, request_hash CHAR(64) NOT NULL, - occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), + id BIGINT UNSIGNED NOT NULL COMMENT '货柜异常历史ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '物理货柜ID', + action VARCHAR(16) NOT NULL COMMENT '操作类型:MARK标记异常、CANCEL取消异常', reason VARCHAR(500) NOT NULL COMMENT '异常原因', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + operation_id VARCHAR(128) NOT NULL COMMENT '同一操作人幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求摘要', + occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作发生时间', PRIMARY KEY(id), UNIQUE KEY uk_container_exception_operation(container_booking_id,operator_id,operation_id), KEY idx_container_exception_history(container_booking_id,occurred_at,id), CONSTRAINT fk_container_exception_booking FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id), @@ -587,7 +587,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_completion_command ( pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA任务ID', operation_id VARCHAR(128) NOT NULL COMMENT '客户端全局幂等操作ID', request_hash CHAR(64) NOT NULL COMMENT '规范化请求摘要', - command_status VARCHAR(16) NOT NULL COMMENT 'PROCESSING、RETRYABLE、FAILED、COMPLETED', + command_status VARCHAR(16) NOT NULL COMMENT '命令状态:PROCESSING处理中、RETRYABLE可重试、FAILED失败、COMPLETED已完成', expected_task_version INT UNSIGNED NOT NULL COMMENT '受理时客户端任务版本', remark VARCHAR(500) NULL COMMENT '完成备注', operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', @@ -600,9 +600,9 @@ CREATE TABLE IF NOT EXISTS fl_loading_completion_command ( failure_code VARCHAR(32) NULL COMMENT '下游或业务错误码', failure_request_id VARCHAR(64) NULL COMMENT '下游请求ID', failure_trace_id VARCHAR(64) NULL COMMENT '下游链路ID', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - completed_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '命令创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '命令更新时间', + completed_at DATETIME(3) NULL COMMENT '命令完成时间', PRIMARY KEY (id), UNIQUE KEY uk_loading_completion_operation (operation_id), KEY idx_loading_completion_dispatch (command_status,next_retry_at,lease_until), @@ -611,13 +611,13 @@ CREATE TABLE IF NOT EXISTS fl_loading_completion_command ( ) ENGINE=InnoDB COMMENT='PDA装柜完成可靠异步命令'; CREATE TABLE IF NOT EXISTS fl_loading_completion_checkpoint ( - command_id BIGINT UNSIGNED NOT NULL, - tenant_id BIGINT UNSIGNED NOT NULL, - inbound_id BIGINT UNSIGNED NOT NULL, - owner_sequence INT UNSIGNED NOT NULL, - checkpoint_stage VARCHAR(24) NOT NULL COMMENT 'READY、SETTLED、INBOUND_COMPLETED', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + command_id BIGINT UNSIGNED NOT NULL COMMENT '可靠装柜完成命令ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '入仓单客户ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入仓单ID', + owner_sequence INT UNSIGNED NOT NULL COMMENT '稳定处理顺序', + checkpoint_stage VARCHAR(24) NOT NULL COMMENT '处理阶段:READY待处理、SETTLED资源已结算、INBOUND_COMPLETED入仓已完成', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '检查点创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '检查点更新时间', PRIMARY KEY (command_id,tenant_id,inbound_id), KEY idx_loading_completion_owner (command_id,owner_sequence), CONSTRAINT fk_loading_completion_checkpoint_command FOREIGN KEY (command_id) diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index 91651e923..aa9d75cf2 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -255,12 +255,12 @@ CREATE TABLE IF NOT EXISTS ord_customer_address ( postal_code VARCHAR(20) NULL COMMENT '邮政编码', default_address TINYINT(1) NOT NULL DEFAULT 0 COMMENT '同类型默认地址', active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否可选', - created_by BIGINT UNSIGNED NOT NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_by BIGINT UNSIGNED NOT NULL, - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - deleted TINYINT(1) NOT NULL DEFAULT 0, - version INT NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), KEY idx_customer_address_scope (tenant_id, address_type, active, updated_at) ) ENGINE=InnoDB COMMENT='客户自有下单地址簿;不承载平台仓库主档'; From 18c673811f824d40220dc90a13457288a591d79b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 2 Sep 2026 17:45:17 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E8=B4=A2=E5=8A=A1?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E7=9B=91=E6=8E=A7=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java | 1 + 1 file changed, 1 insertion(+) diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java index 85756ae0e..68fada419 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java @@ -30,6 +30,7 @@ public class EventBridgeMetrics { EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, EventTypes.OVERSEAS_CONTAINER_UNLOADED, + EventTypes.BUSINESS_FEE_SOURCE_CHANGED, EventTypes.IAM_USER_STATUS_CHANGED, EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_PERMISSION_VERSION_CHANGED,