From 044678f65c91a64ba79e75c042f594f151a28b0c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 11:36:25 +0800 Subject: [PATCH 001/115] fix carrier history action schema upgrade --- deploy/sql/头程数据库统一增量-MySQL8.sql | 20 +++++++++++++++++++ .../phase3-carrier-schedule-query-schema.sql | 20 +++++++++++++++++++ .../CarrierScheduleQuerySqlContractTest.java | 8 ++++++++ 3 files changed, 48 insertions(+) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 6f92303cc..a29e85c56 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -10428,6 +10428,26 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation ( CONSTRAINT chk_carrier_schedule_operation_status CHECK (status IN ('PROCESSING','SUCCESS')) ) ENGINE=InnoDB COMMENT='船司查询写操作幂等与请求摘要'; +-- CREATE TABLE IF NOT EXISTS 不会更新已有表的 CHECK。历史开发库仍可能只允许 +-- CREATE/ATTEMPT/MANUAL_RESULT,必须在人工节点和自动节点写入前收敛为当前动作集。 +DROP PROCEDURE IF EXISTS migrate_carrier_schedule_history_action_check; +DELIMITER $$ +CREATE PROCEDURE migrate_carrier_schedule_history_action_check() +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='fl_carrier_schedule_query_history' + AND constraint_name='chk_carrier_schedule_history_action' AND constraint_type='CHECK') THEN + ALTER TABLE fl_carrier_schedule_query_history DROP CHECK chk_carrier_schedule_history_action; + END IF; + ALTER TABLE fl_carrier_schedule_query_history + ADD CONSTRAINT chk_carrier_schedule_history_action CHECK + (action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','MANUAL_NODE','NODE_OBSERVED', + 'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP')); +END$$ +DELIMITER ; +CALL migrate_carrier_schedule_history_action_check(); +DROP PROCEDURE IF EXISTS migrate_carrier_schedule_history_action_check; + DROP TRIGGER IF EXISTS trg_carrier_schedule_query_before_insert; DELIMITER $$ CREATE TRIGGER trg_carrier_schedule_query_before_insert BEFORE INSERT ON fl_carrier_schedule_query FOR EACH ROW diff --git a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql index f5bf83b25..f37916125 100644 --- a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql +++ b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql @@ -181,6 +181,26 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation ( CONSTRAINT chk_carrier_schedule_operation_status CHECK (status IN ('PROCESSING','SUCCESS')) ) ENGINE=InnoDB COMMENT='船司查询写操作幂等与请求摘要'; +-- CREATE TABLE IF NOT EXISTS 不会更新已有表的 CHECK。历史开发库仍可能只允许 +-- CREATE/ATTEMPT/MANUAL_RESULT,必须在人工节点和自动节点写入前收敛为当前动作集。 +DROP PROCEDURE IF EXISTS migrate_carrier_schedule_history_action_check; +DELIMITER $$ +CREATE PROCEDURE migrate_carrier_schedule_history_action_check() +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='fl_carrier_schedule_query_history' + AND constraint_name='chk_carrier_schedule_history_action' AND constraint_type='CHECK') THEN + ALTER TABLE fl_carrier_schedule_query_history DROP CHECK chk_carrier_schedule_history_action; + END IF; + ALTER TABLE fl_carrier_schedule_query_history + ADD CONSTRAINT chk_carrier_schedule_history_action CHECK + (action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','MANUAL_NODE','NODE_OBSERVED', + 'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP')); +END$$ +DELIMITER ; +CALL migrate_carrier_schedule_history_action_check(); +DROP PROCEDURE IF EXISTS migrate_carrier_schedule_history_action_check; + DROP TRIGGER IF EXISTS trg_carrier_schedule_query_before_insert; DELIMITER $$ CREATE TRIGGER trg_carrier_schedule_query_before_insert BEFORE INSERT ON fl_carrier_schedule_query FOR EACH ROW diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java index ba178fd07..b7e0d4d7e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java @@ -34,6 +34,10 @@ class CarrierScheduleQuerySqlContractTest { .contains("DISCHARGED_FROM_VESSEL") .contains("PICKED_UP','EMPTY_RETURNED") .contains("CUSTOMS_HOLD','TERMINAL_HOLD','DOCUMENT_HOLD','UNKNOWN_HOLD") + .contains("CREATE PROCEDURE migrate_carrier_schedule_history_action_check()") + .contains("DROP CHECK chk_carrier_schedule_history_action") + .contains("'MANUAL_NODE','NODE_OBSERVED'") + .contains("'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP'") .contains("provider_event_id") .contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'") .contains("lease_owner") @@ -86,6 +90,10 @@ class CarrierScheduleQuerySqlContractTest { .contains("SET @carrier_schedule_now = CONVERT_TZ(UTC_TIMESTAMP(3),'+00:00','+08:00')") .contains("SET @carrier_schedule_next = CASE") .contains("CREATE PROCEDURE ensure_carrier_schedule_retry_check()") + .contains("CREATE PROCEDURE migrate_carrier_schedule_history_action_check()") + .contains("DROP CHECK chk_carrier_schedule_history_action") + .contains("'MANUAL_NODE','NODE_OBSERVED'") + .contains("'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP'") .contains("ADD CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3)") .contains("统一迁移失败:船司查询固定槽位字段、索引或尝试次数约束不完整") .doesNotContain("DROP CHECK chk_carrier_schedule_query_attempt") From 257c019b3669aa24438d65885dbdb12351266dfb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 11:48:16 +0800 Subject: [PATCH 002/115] fix phase3 acceptance roots and payable validation --- deploy/sql/头程数据库统一增量-MySQL8.sql | 27 +++++++++++++++++ .../service/FeePaymentSnapshotValidator.java | 2 +- .../db/phase3-split-preload-schema.sql | 10 +++++++ .../PdaSplitSettlementSqlContractTest.java | 23 ++++++++++++++ .../FeePaymentSnapshotValidatorTest.java | 8 +++++ scripts/admin-test-env-acceptance.mjs | 4 +++ .../finance-downloads.mjs | 2 +- .../finance-full-fixtures-contract.test.mjs | 2 ++ .../finance-full-fixtures.mjs | 4 ++- .../admin-test-env-modules/inbound-fees.mjs | 2 +- .../phase3-container-loading.mjs | 2 +- .../admin-test-env-modules/phase3-customs.mjs | 30 +++++++++++-------- ...e3-loading-dynamic-final-contract.test.mjs | 3 +- .../admin-test-env-modules/phase3-loading.mjs | 19 ++++++------ .../phase3-producer-chain-contract.test.mjs | 7 +++-- 15 files changed, 115 insertions(+), 30 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index a29e85c56..51b63c660 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -7492,6 +7492,9 @@ CREATE TABLE IF NOT EXISTS fl_preload_plan ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增', items_json JSON NOT NULL COMMENT '可编辑分柜明细JSON快照;人工确认后作为预装依据', remark VARCHAR(1000) NULL COMMENT '预装备注', + confirmed_by BIGINT UNSIGNED NULL COMMENT '确认预装的操作人ID', + confirmed_by_name VARCHAR(64) NULL COMMENT '确认预装的操作人姓名快照', + confirmed_at DATETIME(3) NULL COMMENT '预装确认时间', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建预装清单的操作人ID', created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID', @@ -7504,6 +7507,30 @@ CREATE TABLE IF NOT EXISTS fl_preload_plan ( CONSTRAINT fk_preload_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) ) ENGINE=InnoDB COMMENT='订单分柜预装清单'; +DROP PROCEDURE IF EXISTS migrate_preload_confirmation_columns; +DELIMITER $$ +CREATE PROCEDURE migrate_preload_confirmation_columns() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_preload_plan' AND column_name='confirmed_by') THEN + ALTER TABLE fl_preload_plan + ADD COLUMN confirmed_by BIGINT UNSIGNED NULL COMMENT '确认预装的操作人ID' AFTER remark; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_preload_plan' AND column_name='confirmed_by_name') THEN + ALTER TABLE fl_preload_plan + ADD COLUMN confirmed_by_name VARCHAR(64) NULL COMMENT '确认预装的操作人姓名快照' AFTER confirmed_by; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_preload_plan' AND column_name='confirmed_at') THEN + ALTER TABLE fl_preload_plan + ADD COLUMN confirmed_at DATETIME(3) NULL COMMENT '预装确认时间' AFTER confirmed_by_name; + END IF; +END$$ +DELIMITER ; +CALL migrate_preload_confirmation_columns(); +DROP PROCEDURE IF EXISTS migrate_preload_confirmation_columns; + CREATE TABLE IF NOT EXISTS fl_split_operation ( id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidator.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidator.java index 64a88398a..679368549 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidator.java @@ -41,7 +41,7 @@ public class FeePaymentSnapshotValidator { } return Snapshot.empty(); } - if (!"PAYABLE".equals(direction) || !MODES.contains(mode)) { + if (!"PAYABLE".equals(direction) || mode == null || !MODES.contains(mode)) { throw bad("应付费用必须选择ON_ACCOUNT或DIRECT_PAID"); } if ("ON_ACCOUNT".equals(mode)) { diff --git a/firstleg/src/main/resources/db/phase3-split-preload-schema.sql b/firstleg/src/main/resources/db/phase3-split-preload-schema.sql index 8004ba31b..2740d4d5c 100644 --- a/firstleg/src/main/resources/db/phase3-split-preload-schema.sql +++ b/firstleg/src/main/resources/db/phase3-split-preload-schema.sql @@ -10,6 +10,9 @@ CREATE TABLE IF NOT EXISTS fl_preload_plan ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增', items_json JSON NOT NULL COMMENT '可编辑分柜明细JSON快照;人工确认后作为预装依据', remark VARCHAR(1000) NULL COMMENT '预装备注', + confirmed_by BIGINT UNSIGNED NULL COMMENT '确认预装的操作人ID', + confirmed_by_name VARCHAR(64) NULL COMMENT '确认预装的操作人姓名快照', + confirmed_at DATETIME(3) NULL COMMENT '预装确认时间', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建预装清单的操作人ID', created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID', @@ -62,6 +65,13 @@ BEGIN END$$ DELIMITER ; +CALL phase3_split_add_column('fl_preload_plan', 'confirmed_by', + 'BIGINT UNSIGNED NULL COMMENT ''确认预装的操作人ID'' AFTER remark'); +CALL phase3_split_add_column('fl_preload_plan', 'confirmed_by_name', + 'VARCHAR(64) NULL COMMENT ''确认预装的操作人姓名快照'' AFTER confirmed_by'); +CALL phase3_split_add_column('fl_preload_plan', 'confirmed_at', + 'DATETIME(3) NULL COMMENT ''预装确认时间'' AFTER confirmed_by_name'); + CALL phase3_split_add_column('fl_inbound_order', 'parent_inbound_id', 'BIGINT UNSIGNED NULL COMMENT ''生成本单的主入库单ID;仅二级子单有值'' AFTER id'); CALL phase3_split_add_column('fl_inbound_order', 'split_level', diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java index d8204c704..fde74c866 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java @@ -10,10 +10,33 @@ import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class PdaSplitSettlementSqlContractTest { + @Test + void addsPreloadConfirmationAuditColumnsForIncrementalPdaAllocation() throws Exception { + String moduleSql = new ClassPathResource("db/phase3-split-preload-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String unifiedSql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + + assertThat(moduleSql) + .contains("confirmed_by BIGINT UNSIGNED NULL") + .contains("confirmed_by_name VARCHAR(64) NULL") + .contains("confirmed_at DATETIME(3) NULL") + .contains("phase3_split_add_column('fl_preload_plan', 'confirmed_by'") + .contains("phase3_split_add_column('fl_preload_plan', 'confirmed_by_name'") + .contains("phase3_split_add_column('fl_preload_plan', 'confirmed_at'"); + assertThat(unifiedSql) + .contains("CREATE PROCEDURE migrate_preload_confirmation_columns()") + .contains("ADD COLUMN confirmed_by BIGINT UNSIGNED NULL") + .contains("ADD COLUMN confirmed_by_name VARCHAR(64) NULL") + .contains("ADD COLUMN confirmed_at DATETIME(3) NULL"); + } + @Test void protectsLoadedAndRemainderSettlement() throws Exception { String sql = new ClassPathResource("db/phase3-inbound-parent-child-schema.sql") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidatorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidatorTest.java index 98ccc8e15..51664c9e3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidatorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidatorTest.java @@ -37,6 +37,14 @@ class FeePaymentSnapshotValidatorTest { .isInstanceOf(BizException.class).hasMessageContaining("账期应付不得"); } + @Test + void payableWithoutSettlementModeReturnsBusinessValidationInsteadOfNullPointer() { + assertThatThrownBy(() -> validator.validate(9L, "PAYABLE", null, + null, null, null, null)) + .isInstanceOf(BizException.class) + .hasMessageContaining("应付费用必须选择ON_ACCOUNT或DIRECT_PAID"); + } + @Test void directPaidRequiresTimeMethodAndReferenceOrVoucher() { LocalDateTime paidAt = LocalDateTime.of(2026, 8, 20, 10, 0); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 16421131d..06eae4b46 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -740,12 +740,16 @@ export class AcceptanceRun { await this.restoreAvailableLocationsAfterA0(warehouseId, disabledLocations) } const fixture = this.createdResources.inboundMeasurement || this.createdResources.orders + const fixtureOrder = this.createdResources.orders || {} const loadingFixture = fixture ? { marker: originalMarker, sourceMarker: fixtureMarker, tenantId: fixture.tenantId, orderId: fixture.orderId || this.createdResources.orders?.orderId, inboundId: fixture.inboundId, + customerRemark: fixtureOrder.customerRemark, + internalRemark: fixtureOrder.internalRemark, + cargoRemark: fixtureOrder.cargoRemark, } : { marker: originalMarker, sourceMarker: fixtureMarker, failed: true } const incrementalMarker = `${originalMarker}-LOAD-INC` this.marker = incrementalMarker diff --git a/scripts/admin-test-env-modules/finance-downloads.mjs b/scripts/admin-test-env-modules/finance-downloads.mjs index ce39d69f1..9a1bc590c 100644 --- a/scripts/admin-test-env-modules/finance-downloads.mjs +++ b/scripts/admin-test-env-modules/finance-downloads.mjs @@ -13,7 +13,7 @@ export const REQUIRED_PERMISSIONS = Object.freeze([ ]) export const EXPORT_SPECS = Object.freeze({ - FINANCE_CUSTOMER_ACCOUNT_LIST: ['客户编码', '客户', '联系人', '联系电话', '待收(CNY)', '待开账(CNY)', '本期已收(CNY)', '待付(CNY)', '客户状态', '最近变动'], + FINANCE_CUSTOMER_ACCOUNT_LIST: ['客户编码', '客户', '联系人', '联系电话', '待收(CNY)', '待开账(CNY)', '本期已收(CNY)', '总成本(CNY)', '已付成本(CNY)', '待付(CNY)', '客户状态', '最近变动'], FINANCE_CUSTOMER_ACCOUNT_LEDGER: ['流水号', '发生时间', '业务类型', '关联单据', '应收变动(CNY)', '应付变动(CNY)', '变动后未收(CNY)', '操作人', '备注'], FINANCE_FEE_SOURCE_LIST: ['客户', '收付方向', '来源类型', '来源单据ID', '来源版本', '入仓号', '柜次号', '业务发生日', '费用项目', '计费量', '单价(CNY)', '金额(CNY)', '审核状态', '来源状态', '已生成对账单'], FINANCE_RECONCILIATION_STATEMENT_LIST: ['对账单号', '客户', '结算起始日', '结算结束日', '账期(天)', '货币', '总应收金额(CNY)', '已收金额(CNY)', '未收金额(CNY)', '是否结清', '审核状态', '备注', '创建人', '创建时间', '更新人', '更新时间'], diff --git a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs index 2d9e6fe13..6c5932849 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs @@ -17,6 +17,8 @@ test('finance fixture module is independently exported and marker isolated', () test('fixture covers weight, volume, manual order, and container allocation contracts', () => { for (const basis of ['WEIGHT_KG', 'VOLUME_CBM', 'MANUAL_ORDER_FEE', 'CONTAINER_SHARED']) assert.match(source, new RegExp(basis)) + assert.match(source, /direction === 'PAYABLE' \? \{ settlementMode: 'ON_ACCOUNT' \}/) + assert.match(source, /supplierId: id\(supplier\.id\), settlementMode: 'ON_ACCOUNT'/) assert.match(source, /request\(context, 'POST', '\/api\/platform\/finance\/v1\/cost-entries'/) assert.match(source, /no-marker-owned-container-snapshot/) }) diff --git a/scripts/admin-test-env-modules/finance-full-fixtures.mjs b/scripts/admin-test-env-modules/finance-full-fixtures.mjs index f68388559..1416a309c 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures.mjs @@ -150,6 +150,7 @@ async function createManualFee(context, resource, feeItem, direction, amount, ba const listPath = `${basePath}?accountId=${encodeURIComponent(resource.tenantId)}` const body = { feeItemId: String(feeItem.id), direction, businessDate, unitPrice: amount, currency: 'CNY', amount, + ...(direction === 'PAYABLE' ? { settlementMode: 'ON_ACCOUNT' } : {}), supplierId: supplier?.id ? String(supplier.id) : null, remark: `${context.marker}-${basis}` } const response = await request(context, 'POST', listPath, body) @@ -443,7 +444,8 @@ export async function run(context) { const basePath = `/api/platform/v1/loading-plans/${encodeURIComponent(container.loadingPlanId)}/fee-details` const path = `${basePath}?accountId=${encodeURIComponent(container.tenantId)}` const body = { feeItemId: String(feeItems.PAYABLE.id), direction: 'PAYABLE', businessDate: dateOffset(-2), - amount: 300, currency: 'CNY', supplierId: id(supplier.id), remark: `${context.marker}-CONTAINER_SHARED` } + amount: 300, currency: 'CNY', supplierId: id(supplier.id), settlementMode: 'ON_ACCOUNT', + remark: `${context.marker}-CONTAINER_SHARED` } const response = await request(context, 'POST', path, body) if (response?.http === 404) { blocker(context, '按柜费用来源接口已部署并可路由', diff --git a/scripts/admin-test-env-modules/inbound-fees.mjs b/scripts/admin-test-env-modules/inbound-fees.mjs index eb8073179..a27ad73ee 100644 --- a/scripts/admin-test-env-modules/inbound-fees.mjs +++ b/scripts/admin-test-env-modules/inbound-fees.mjs @@ -111,7 +111,7 @@ export async function run(context) { && published?.publicationStatus === 'PUBLISHED' && published?.reviewStatus === 'APPROVED', `status=${sent?.feeStatus || 'missing'}, process=${sent?.nextProcessStatus || 'missing'}, publication=${published?.publicationStatus || 'missing'}`, OPTIONS) - const feeItemsResponse = await context.request('GET', '/api/platform/v1/fee-items?page=1&size=500&status=ACTIVE') + const feeItemsResponse = await context.request('GET', '/api/platform/v1/fee-items?page=1&size=200&status=ACTIVE') const feeItemsPage = context.apiSuccess(MODULE, '查询可用于其他应收的费用项目', feeItemsResponse, '', OPTIONS) const feeItems = Array.isArray(feeItemsPage?.records) ? feeItemsPage.records : Array.isArray(feeItemsPage) ? feeItemsPage : [] diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index bd83bd062..5a09b0a54 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -812,7 +812,7 @@ export async function run(context) { context.createdResources.phase3ContainerLoading = { marker: context.marker, tenantId, inboundId, warehouseId, containerBookingId: containerId, - loadingPlanId: planId, pdaTaskId: taskId, cabinetNo: stableIdentity.cabinetNo, + loadingPlanId: planId, oceanBookingId: bookingId, pdaTaskId: taskId, cabinetNo: stableIdentity.cabinetNo, containerNo: replacementContainerNo, pdaIdentityStable: true, finalStatus: 'COMPLETED', } diff --git a/scripts/admin-test-env-modules/phase3-customs.mjs b/scripts/admin-test-env-modules/phase3-customs.mjs index c051792ea..de3230435 100644 --- a/scripts/admin-test-env-modules/phase3-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-customs.mjs @@ -90,22 +90,26 @@ export function selectCustomsFlow(createdResources) { async function ensureReleasedOceanBooking(context, container) { const containerBookingId = id(container?.containerBookingId) const loadingPlanId = id(container?.loadingPlanId) - let ocean = context.createdResources?.oceanBooking - if (ocean?.finalStatus !== 'RELEASED' - || id(ocean?.containerBookingId) !== containerBookingId - || id(ocean?.loadingPlanId) !== loadingPlanId) { - const oceanBookings = await import(new URL('./ocean-bookings.mjs', import.meta.url)) - await oceanBookings.run(context) - ocean = context.createdResources?.oceanBooking - } - if (ocean?.finalStatus !== 'RELEASED' - || id(ocean?.containerBookingId) !== containerBookingId - || id(ocean?.loadingPlanId) !== loadingPlanId) { + const oceanBookingId = id(container?.oceanBookingId) + if (!oceanBookingId) { context.addStep(MODULE, '报关前订舱已放舱并绑定实际装柜柜次', null, false, - `oceanStatus=${ocean?.finalStatus || 'missing'}, oceanContainer=${id(ocean?.containerBookingId) || 'missing'}, containerBookingId=${containerBookingId || 'missing'}`, OPTIONS) + `oceanBookingId=missing, containerBookingId=${containerBookingId || 'missing'}`, OPTIONS) return false } - + const response = await context.request('GET', `/api/platform/v1/ocean-bookings/${encodeURIComponent(oceanBookingId)}`) + const detail = context.apiSuccess(MODULE, '报关前回读装柜链正式订舱', response, '', OPTIONS) + const allocated = detail?.containers?.some?.((item) => id(item?.containerBookingId) === containerBookingId) + if (detail?.oceanBooking?.status !== 'RELEASED' || !allocated) { + context.addStep(MODULE, '报关前订舱已放舱并绑定实际装柜柜次', response, false, + `oceanStatus=${detail?.oceanBooking?.status || 'missing'}, oceanBookingId=${oceanBookingId}, containerBookingId=${containerBookingId || 'missing'}, allocated=${allocated === true}`, OPTIONS) + return false + } + context.createdResources.oceanBooking = { + id: oceanBookingId, marker: context.marker, finalStatus: 'RELEASED', + containerBookingId, loadingPlanId, + } + context.addStep(MODULE, '报关前订舱已放舱并绑定实际装柜柜次', response, true, + `oceanBookingId=${oceanBookingId}, containerBookingId=${containerBookingId}`, OPTIONS) return true } diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index d7d54bf16..e3d6a3c4f 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -67,11 +67,12 @@ test('HTTP chain refreshes after stale inventory and keeps one stable PDA identi '已拣项减量低于 minPackageCount 返回 A0409', '已拣项删除返回 A0409', '已拣项删除和减量失败后 plan/task/items 零写入', - '订舱拖车报关未齐备时 LOADED 硬门禁拒绝', + '订舱或五类证据等保留门禁未齐备时 LOADED 拒绝', ]) assert.match(loading, /String\(staleIncrementBatch\.body\?\.message \|\| ''\)\.includes\('库存已变化,请刷新后重试'\)/) assert.match(loading, /id\(incrementedTask\?\.loadingPlanId\) === loadingPlanId/) assert.match(loading, /id\(incrementedTask\?\.containerBookingId\) === containerBookingId/) + assert.match(loading, /const taskDetailPath = `\$\{pdaBase\}\/tasks\/\$\{canonicalTaskId\}\?warehouseId=/) assert.match(loading, /loadingPlanStatus: 'LOADING', finalStatus: 'AWAITING_REVIEW'/) assert.doesNotMatch(loading, /\/loading\/complete\?tenantId=/) }) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 9f8be083d..2da661cf5 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -187,7 +187,7 @@ export async function run(context) { `loadingPlanId=${loadingPlanId}, version=${assignedLoadingPlan?.version ?? 'missing'}`, OPTIONS) const assignedItem = assignedLoadingPlan?.items?.find?.((item) => id(item.inboundId) === inboundId) - const orderRemarks = context.createdResources?.orders || {} + const orderRemarks = source || {} context.assert(MODULE, '装柜明细客户备注与最新内部备注来源精确且不冒用货物备注', assignmentResponse, assignedItem?.customerRemark === orderRemarks.customerRemark && assignedItem?.internalRemark === orderRemarks.internalRemark @@ -462,6 +462,7 @@ export async function run(context) { `${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`) const canonicalTask = context.apiSuccess(MODULE, '读取柜次级 PDA 唯一事实任务', canonicalTaskResponse, '', OPTIONS) const canonicalTaskId = id(canonicalTask?.id) + const taskDetailPath = `${pdaBase}/tasks/${canonicalTaskId}?warehouseId=${encodeURIComponent(warehouseId)}` const canonicalItem = canonicalTask?.items?.find?.((item) => id(item.inboundId) === inboundId) context.assert(MODULE, '柜次 PDA 任务与桥接入库明细一致', canonicalTaskResponse, Boolean(canonicalTaskId) && canonicalTaskId === confirmedTaskId && id(canonicalTask?.loadingPlanId) === loadingPlanId @@ -985,7 +986,7 @@ export async function run(context) { if (dynamicCandidate?.inboundDetailId) { const planBeforeIncrementResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) const planBeforeIncrement = context.apiSuccess(MODULE, '读取 LOADING 动态增量前计划', planBeforeIncrementResponse, '', OPTIONS) - const taskBeforeIncrementResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`) + const taskBeforeIncrementResponse = await context.request('GET', taskDetailPath) const taskBeforeIncrement = context.apiSuccess(MODULE, '读取 LOADING 动态增量前任务', taskBeforeIncrementResponse, '', OPTIONS) const evidenceBeforeIncrement = JSON.stringify(evidenceListData?.evidences || []) const incrementResponse = await request(context, 'POST', @@ -997,7 +998,7 @@ export async function run(context) { const incrementedPlan = context.apiSuccess(MODULE, 'LOADING 阶段向同一 PDA task 增加入库单', incrementResponse, '', OPTIONS) const incrementalPlanItem = incrementedPlan?.items?.find?.((item) => id(item?.inboundId) === incrementalInboundId) const preservedRemarkItem = incrementedPlan?.items?.find?.((item) => id(item?.id) === id(assignedItem.id)) - const incrementedTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`) + const incrementedTaskResponse = await context.request('GET', taskDetailPath) const incrementedTask = context.apiSuccess(MODULE, '增量后回查稳定 PDA 四元身份', incrementedTaskResponse, '', OPTIONS) const incrementalTaskItem = incrementedTask?.items?.find?.((item) => id(item?.loadingPlanItemId) === id(incrementalPlanItem?.id)) @@ -1032,7 +1033,7 @@ export async function run(context) { staleIncrementBatch.http === 200 && codeOf(staleIncrementBatch) === 'A0409' && String(staleIncrementBatch.body?.message || '').includes('库存已变化,请刷新后重试'), responseEvidence(staleIncrementBatch), OPTIONS) - const taskAfterStaleResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`) + const taskAfterStaleResponse = await context.request('GET', taskDetailPath) const taskAfterStale = context.apiSuccess(MODULE, '旧 token 冲突后回查增量任务', taskAfterStaleResponse, '', OPTIONS) const planAfterStaleResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) const planAfterStale = context.apiSuccess(MODULE, '旧 token 冲突后回查增量计划', planAfterStaleResponse, '', OPTIONS) @@ -1085,7 +1086,7 @@ export async function run(context) { effectiveDynamicPlan = adjustedPlan reassignedPlanItem = adjustedItem } - const refreshedDynamicTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`) + const refreshedDynamicTaskResponse = await context.request('GET', taskDetailPath) const refreshedDynamicTask = context.apiSuccess(MODULE, '重新增量后刷新 PDA task detail', refreshedDynamicTaskResponse, '', OPTIONS) const refreshedDynamicTaskItem = refreshedDynamicTask?.items?.find?.((item) => id(item?.loadingPlanItemId) === id(reassignedPlanItem?.id)) @@ -1108,7 +1109,7 @@ export async function run(context) { const pickedDynamicPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) const pickedDynamicPlan = context.apiSuccess(MODULE, '动态新增项拣完后回查 minPackageCount', pickedDynamicPlanResponse, '', OPTIONS) const pickedDynamicPlanItem = pickedDynamicPlan?.items?.find?.((item) => id(item?.id) === id(reassignedPlanItem?.id)) - const beforePickedMutationTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`) + const beforePickedMutationTaskResponse = await context.request('GET', taskDetailPath) const beforePickedMutationTask = context.apiSuccess(MODULE, '已拣项负向前读取任务快照', beforePickedMutationTaskResponse, '', OPTIONS) const reducePickedResponse = await request(context, 'PATCH', `${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(pickedDynamicPlanItem?.id))}`, { @@ -1127,7 +1128,7 @@ export async function run(context) { deletePickedResponse.http === 200 && codeOf(deletePickedResponse) === 'A0409', responseEvidence(deletePickedResponse), OPTIONS) const afterPickedMutationPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) const afterPickedMutationPlan = context.apiSuccess(MODULE, '已拣项负向后回查计划', afterPickedMutationPlanResponse, '', OPTIONS) - const afterPickedMutationTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`) + const afterPickedMutationTaskResponse = await context.request('GET', taskDetailPath) const afterPickedMutationTask = context.apiSuccess(MODULE, '已拣项负向后回查任务', afterPickedMutationTaskResponse, '', OPTIONS) context.assert(MODULE, '已拣项删除和减量失败后 plan/task/items 零写入', afterPickedMutationPlanResponse, Number(afterPickedMutationPlan?.version) === Number(pickedDynamicPlan?.version) @@ -1148,7 +1149,7 @@ export async function run(context) { beforeHardGate?.summary?.processStatus === 'LOADING' && Number.isInteger(beforeHardGateVersion), `status=${beforeHardGate?.summary?.processStatus || 'missing'}, version=${beforeHardGate?.summary?.version ?? 'missing'}`, OPTIONS) if (!Number.isInteger(beforeHardGateVersion)) return - const beforeBlockedTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`) + const beforeBlockedTaskResponse = await context.request('GET', taskDetailPath) const beforeBlockedTask = context.apiSuccess(MODULE, '读取下游门禁拒绝前 PDA 任务快照', beforeBlockedTaskResponse, '', OPTIONS) const beforeBlockedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) const beforeBlockedPlan = context.apiSuccess(MODULE, '读取下游门禁拒绝前计划快照', beforeBlockedPlanResponse, '', OPTIONS) @@ -1161,7 +1162,7 @@ export async function run(context) { responseEvidence(blockedBeforeDownstream), OPTIONS) const blockedDetailResponse = await context.request('GET', detailPath) const blockedDetail = context.apiSuccess(MODULE, '回查 LOADED 硬门禁拒绝后的入库状态', blockedDetailResponse, '', OPTIONS) - const blockedTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`) + const blockedTaskResponse = await context.request('GET', taskDetailPath) const blockedTask = context.apiSuccess(MODULE, '回查 LOADED 硬门禁拒绝后的 PDA 任务', blockedTaskResponse, '', OPTIONS) const blockedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) const blockedPlan = context.apiSuccess(MODULE, '回查 LOADED 硬门禁拒绝后的计划', blockedPlanResponse, '', OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index fdfe9583a..15aa9de48 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -467,14 +467,17 @@ test('phase3 loading fixture forces A0 through reversible public location status assert.match(orders, /receivingWarehouseId: id\(master\.receiving\.id\)/) }) -test('customs provisions released booking without pre-completing truck', () => { +test('customs reuses the loading chain released booking without pre-completing truck', () => { const value = source('./phase3-customs.mjs') assertOrder(value, [ 'selectCustomsFlow(context.createdResources)', 'if (!await ensureReleasedOceanBooking(context, container)) return', 'const slicesPath =', ]) - assert.match(value, /oceanBookings\.run\(context\)/) + assert.match(value, /const oceanBookingId = id\(container\?\.oceanBookingId\)/) + assert.match(value, /ocean-bookings\/\$\{encodeURIComponent\(oceanBookingId\)\}/) + assert.match(value, /detail\?\.containers\?\.some/) + assert.doesNotMatch(value, /oceanBookings\.run\(context\)/) assert.doesNotMatch(value, /serviceCenter\.run\(context\)/) assert.doesNotMatch(value, /truck\?\.finalStatus !== 'COMPLETED'/) assert.match(value, /request\('POST', '\/api\/platform\/v1\/suppliers\?tenantId=0'/) From 4977b09af95b5928ac4cb087881c32d5a360d931 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 11:49:31 +0800 Subject: [PATCH 003/115] fix phase3 loading acceptance sequence --- ...e3-loading-dynamic-final-contract.test.mjs | 3 +- .../admin-test-env-modules/phase3-loading.mjs | 48 ++++++------------- .../phase3-producer-chain-contract.test.mjs | 4 +- 3 files changed, 19 insertions(+), 36 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index e3d6a3c4f..479d296a9 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -67,13 +67,14 @@ test('HTTP chain refreshes after stale inventory and keeps one stable PDA identi '已拣项减量低于 minPackageCount 返回 A0409', '已拣项删除返回 A0409', '已拣项删除和减量失败后 plan/task/items 零写入', - '订舱或五类证据等保留门禁未齐备时 LOADED 拒绝', + '报关与最终完成前保持 LOADING 且不提前写 COMPLETE_LOADING', ]) assert.match(loading, /String\(staleIncrementBatch\.body\?\.message \|\| ''\)\.includes\('库存已变化,请刷新后重试'\)/) assert.match(loading, /id\(incrementedTask\?\.loadingPlanId\) === loadingPlanId/) assert.match(loading, /id\(incrementedTask\?\.containerBookingId\) === containerBookingId/) assert.match(loading, /const taskDetailPath = `\$\{pdaBase\}\/tasks\/\$\{canonicalTaskId\}\?warehouseId=/) assert.match(loading, /loadingPlanStatus: 'LOADING', finalStatus: 'AWAITING_REVIEW'/) + assert.doesNotMatch(loading, /complete-before-downstream/) assert.doesNotMatch(loading, /\/loading\/complete\?tenantId=/) }) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 2da661cf5..42923a2f0 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1142,38 +1142,20 @@ export async function run(context) { } } - const beforeHardGateResponse = await context.request('GET', detailPath) - const beforeHardGate = context.apiSuccess(MODULE, '读取 LOADED 硬门禁调用前即时版本', beforeHardGateResponse, '', OPTIONS) - const beforeHardGateVersion = Number(beforeHardGate?.summary?.version) - context.assert(MODULE, 'LOADED 硬门禁即时基线状态和版本有效', beforeHardGateResponse, - beforeHardGate?.summary?.processStatus === 'LOADING' && Number.isInteger(beforeHardGateVersion), - `status=${beforeHardGate?.summary?.processStatus || 'missing'}, version=${beforeHardGate?.summary?.version ?? 'missing'}`, OPTIONS) - if (!Number.isInteger(beforeHardGateVersion)) return - const beforeBlockedTaskResponse = await context.request('GET', taskDetailPath) - const beforeBlockedTask = context.apiSuccess(MODULE, '读取下游门禁拒绝前 PDA 任务快照', beforeBlockedTaskResponse, '', OPTIONS) - const beforeBlockedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) - const beforeBlockedPlan = context.apiSuccess(MODULE, '读取下游门禁拒绝前计划快照', beforeBlockedPlanResponse, '', OPTIONS) - const blockedBeforeDownstream = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`, - { expectedTaskVersion: Number(beforeBlockedTask?.version), - operationId: `${context.marker}-complete-before-downstream`.slice(0, 128), remark: `${context.marker}-before-downstream` }, - { 'Idempotency-Key': `${context.marker}-p3-complete-before-downstream`.slice(0, 128) }) - context.addStep(MODULE, '订舱或五类证据等保留门禁未齐备时 LOADED 拒绝', blockedBeforeDownstream, - blockedBeforeDownstream.http === 200 && codeOf(blockedBeforeDownstream) === 'A0409', - responseEvidence(blockedBeforeDownstream), OPTIONS) - const blockedDetailResponse = await context.request('GET', detailPath) - const blockedDetail = context.apiSuccess(MODULE, '回查 LOADED 硬门禁拒绝后的入库状态', blockedDetailResponse, '', OPTIONS) - const blockedTaskResponse = await context.request('GET', taskDetailPath) - const blockedTask = context.apiSuccess(MODULE, '回查 LOADED 硬门禁拒绝后的 PDA 任务', blockedTaskResponse, '', OPTIONS) - const blockedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) - const blockedPlan = context.apiSuccess(MODULE, '回查 LOADED 硬门禁拒绝后的计划', blockedPlanResponse, '', OPTIONS) - context.assert(MODULE, 'LOADED 硬门禁拒绝后 task/plan/inbound 零写入', blockedDetailResponse, - blockedDetail?.summary?.processStatus === 'LOADING' - && Number(blockedDetail?.summary?.version) === beforeHardGateVersion - && Number(blockedTask?.version) === Number(beforeBlockedTask?.version) - && taskItemsSignature(blockedTask) === taskItemsSignature(beforeBlockedTask) - && Number(blockedPlan?.version) === Number(beforeBlockedPlan?.version) - && blockedPlan?.status === beforeBlockedPlan?.status, - `inboundStatus=${blockedDetail?.summary?.processStatus || 'missing'}, inboundVersion=${blockedDetail?.summary?.version ?? 'missing'}, taskVersion=${blockedTask?.version ?? 'missing'}, planVersion=${blockedPlan?.version ?? 'missing'}`, OPTIONS) + // 报关协同在本模块之后执行,最终 COMPLETE_LOADING 只由 phase3-loading-finalize 发起。 + // 当前服务端明确不以拖车作为前置门禁,因此这里不得用一次预期失败的写请求维持 LOADING。 + const stagedDetailResponse = await context.request('GET', detailPath) + const stagedDetail = context.apiSuccess(MODULE, '下游协同前读取装柜中入库状态', stagedDetailResponse, '', OPTIONS) + const stagedTaskResponse = await context.request('GET', taskDetailPath) + const stagedTask = context.apiSuccess(MODULE, '下游协同前读取待最终完成 PDA 任务', stagedTaskResponse, '', OPTIONS) + const stagedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) + const stagedPlan = context.apiSuccess(MODULE, '下游协同前读取装柜中计划', stagedPlanResponse, '', OPTIONS) + context.assert(MODULE, '报关与最终完成前保持 LOADING 且不提前写 COMPLETE_LOADING', stagedDetailResponse, + stagedDetail?.summary?.processStatus === 'LOADING' + && stagedTask?.taskStatus === 'LOADING' + && stagedTask?.workflowStatus === 'AWAITING_REVIEW' + && stagedPlan?.status === 'LOADING', + `inboundStatus=${stagedDetail?.summary?.processStatus || 'missing'}, taskStatus=${stagedTask?.taskStatus || 'missing'}, workflow=${stagedTask?.workflowStatus || 'missing'}, planStatus=${stagedPlan?.status || 'missing'}`, OPTIONS) context.createdResources.phase3Loading = { tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId, @@ -1189,7 +1171,7 @@ export async function run(context) { inventorySnapshotVerified: inventorySnapshotVerified && atomicBatchReplayVerified, inventoryMutationVerified, inventoryMutationCleanup, dynamicIncrementVerified, incrementalInboundId, - pdaTaskVersion: Number(blockedTask?.version), + pdaTaskVersion: Number(stagedTask?.version), marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'LOADING', finalStatus: 'AWAITING_REVIEW', } } diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 15aa9de48..5178677cd 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -445,9 +445,9 @@ test('phase3 loading proves stale inventory zero-write and defers final completi 'PDA 原子批次同参同键重放保持待人工确认', '回查下游门禁前原子 Batch 审计', '最终确认前仅保留 CLAIM/BATCH 且无 COMPLETE_LOADING', - '订舱或五类证据等保留门禁未齐备时 LOADED 拒绝', - 'LOADED 硬门禁拒绝后 task/plan/inbound 零写入', + '报关与最终完成前保持 LOADING 且不提前写 COMPLETE_LOADING', ]) + assert.doesNotMatch(loading, /complete-before-downstream/) }) test('phase3 loading fixture forces A0 through reversible public location status APIs', () => { From d59fccc73171c7a1d6b14428fb6e422bf8e22a2b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 11:52:32 +0800 Subject: [PATCH 004/115] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mapper/SmartLoadingCandidateMapper.java | 27 ++++++++++++++----- ...artLoadingCandidateMapperContractTest.java | 8 ++++-- .../SmartLoadingSqlContractTest.java | 12 +++++++++ 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index e876d46d2..955715c40 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -15,7 +15,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba WHERE o.inbound_role='MASTER' AND split_item.tenant_id=o.tenant_id AND split_item.parent_inbound_id=o.id - AND split_item.source_measurement_item_id=i.id),0) availablePackageCount, + AND split_item.source_inbound_detail_id=i.inbound_detail_id),0) availablePackageCount, COALESCE(o.actual_gross_weight_kg / NULLIF(o.total_package_count, 0), 0) unitGrossWeightKg, COALESCE(o.actual_net_weight_kg / NULLIF(o.total_package_count, 0), 0) unitNetWeightKg, COALESCE(i.unit_volume_cbm,0) unitVolumeCbm @@ -24,11 +24,17 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba AND m.status='APPROVED' AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2 WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED') - JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id + JOIN ( + SELECT tenant_id,measurement_id,inbound_detail_id,SUM(package_count) package_count, + COALESCE(SUM(COALESCE(total_volume_cbm,COALESCE(unit_volume_cbm,0)*package_count)) + / NULLIF(SUM(package_count),0),0) unit_volume_cbm + FROM fl_inbound_measurement_item + WHERE inbound_detail_id IS NOT NULL AND package_count>0 + GROUP BY tenant_id,measurement_id,inbound_detail_id + ) i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id WHERE o.warehouse_id=#{warehouseId} AND o.process_status='WAIT_SPLIT' AND o.actual_gross_weight_kg IS NOT NULL AND o.actual_net_weight_kg IS NOT NULL AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT')) - AND i.inbound_detail_id IS NOT NULL AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0) @@ -37,8 +43,8 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba WHERE o.inbound_role='MASTER' AND split_item.tenant_id=o.tenant_id AND split_item.parent_inbound_id=o.id - AND split_item.source_measurement_item_id=i.id),0) - ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC + AND split_item.source_inbound_detail_id=i.inbound_detail_id),0) + ORDER BY o.inbounded_at ASC, o.id ASC, i.inbound_detail_id ASC """) List candidates(@Param("warehouseId") long warehouseId); @@ -56,7 +62,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba WHERE o.inbound_role='MASTER' AND split_item.tenant_id=o.tenant_id AND split_item.parent_inbound_id=o.id - AND split_item.source_measurement_item_id=i.id),0) availablePackageCount, + AND split_item.source_inbound_detail_id=i.inbound_detail_id),0) availablePackageCount, COALESCE(o.actual_gross_weight_kg / NULLIF(o.total_package_count, 0), 0) unitGrossWeightKg, COALESCE(o.actual_net_weight_kg / NULLIF(o.total_package_count, 0), 0) unitNetWeightKg, COALESCE(i.unit_volume_cbm,0) unitVolumeCbm @@ -65,7 +71,14 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba AND m.status='APPROVED' AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2 WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED') - JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id + JOIN ( + SELECT tenant_id,measurement_id,inbound_detail_id,SUM(package_count) package_count, + COALESCE(SUM(COALESCE(total_volume_cbm,COALESCE(unit_volume_cbm,0)*package_count)) + / NULLIF(SUM(package_count),0),0) unit_volume_cbm + FROM fl_inbound_measurement_item + WHERE inbound_detail_id IS NOT NULL AND package_count>0 + GROUP BY tenant_id,measurement_id,inbound_detail_id + ) i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id WHERE o.warehouse_id=#{warehouseId} AND o.id=#{inboundId} AND o.process_status='WAIT_SPLIT' AND o.actual_gross_weight_kg IS NOT NULL AND o.actual_net_weight_kg IS NOT NULL AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT')) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java index b74c68455..fbeb77491 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -19,7 +19,9 @@ class SmartLoadingCandidateMapperContractTest { .contains("m.status='APPROVED'") .contains("SELECT MAX(m2.id)") .contains("m2.status='APPROVED'") - .contains("i.inbound_detail_id IS NOT NULL") + .contains("WHERE inbound_detail_id IS NOT NULL AND package_count>0") + .contains("SUM(package_count) package_count") + .contains("GROUP BY tenant_id,measurement_id,inbound_detail_id") .contains("o.warehouse_id=#{warehouseId}") .contains("o.process_status='WAIT_SPLIT'") .contains("unitGrossWeightKg") @@ -29,7 +31,7 @@ class SmartLoadingCandidateMapperContractTest { .doesNotContain("i.unit_weight_kg") .contains("SELECT SUM(li.package_count)") .contains("li.deleted=0") - .contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC"); + .contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.inbound_detail_id ASC"); } @Test @@ -48,6 +50,8 @@ class SmartLoadingCandidateMapperContractTest { .contains("unitNetWeightKg") .doesNotContain("i.unit_weight_kg") .contains("SELECT SUM(li.package_count)") + .contains("SUM(package_count) package_count") + .contains("GROUP BY tenant_id,measurement_id,inbound_detail_id") .contains("FOR UPDATE"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index d784cf5f6..8f4302c98 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -76,6 +76,18 @@ class SmartLoadingSqlContractTest { .contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'"); } + @Test + @DisplayName("同一入库明细的多条单箱测量记录只生成一个候选") + void shouldAggregateCartonMeasurementRowsByInboundDetail() throws Exception { + String mapper = Files.readString(Path.of( + "src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java"), StandardCharsets.UTF_8); + assertThat(mapper) + .contains("SUM(package_count) package_count") + .contains("GROUP BY tenant_id,measurement_id,inbound_detail_id") + .contains("split_item.source_inbound_detail_id=i.inbound_detail_id") + .doesNotContain("split_item.source_measurement_item_id=i.id"); + } + @Test @DisplayName("仓库备注使用计划先行与明细单表CAS并允许已拣明细仅改备注") void shouldUpdateWarehouseRemarkWithoutMultiTableTriggerConflict() throws Exception { From 739c20cb3d0c8d1772365047dd1c2b7287a6d9f5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 11:55:50 +0800 Subject: [PATCH 005/115] fix lastleg order detail customer schema --- deploy/sql/海外区域统一增量-MySQL8.sql | 16 +++++++++ .../service/InboundStockReceiptService.java | 1 + .../lastleg/service/LastlegOrderService.java | 3 ++ .../src/main/resources/db/lastleg.sql | 4 ++- .../InboundStockReceiptServiceTest.java | 4 ++- .../LastlegOrderDetailSchemaContractTest.java | 36 +++++++++++++++++++ .../service/LastlegOrderServiceTest.java | 1 + 7 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/schema/LastlegOrderDetailSchemaContractTest.java diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 6b02596d9..ac65f97a9 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -204,6 +204,22 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE 'ALTER TABLE ll_loading_order_status_history ADD KEY idx_ll_loading_history_customer_order(customer_id,order_id,created_at)')); PREPARE phase4_loading_history_customer_stmt FROM @ddl; EXECUTE phase4_loading_history_customer_stmt; DEALLOCATE PREPARE phase4_loading_history_customer_stmt; +-- 尾程订单明细实体、事件消费者和详情查询均使用 customer_id;已有开发库需从主订单回填。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_order_detail' AND column_name='customer_id'), 'SELECT 1', + 'ALTER TABLE ll_order_detail ADD COLUMN customer_id VARCHAR(128) NULL COMMENT ''客户数据隔离键'' AFTER id')); +PREPARE phase4_order_detail_customer_stmt FROM @ddl; EXECUTE phase4_order_detail_customer_stmt; DEALLOCATE PREPARE phase4_order_detail_customer_stmt; +UPDATE ll_order_detail detail +JOIN ll_order orders ON orders.id=detail.order_id +SET detail.customer_id=orders.customer_id +WHERE detail.customer_id IS NULL; +ALTER TABLE ll_order_detail + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_order_detail' AND index_name='idx_ll_detail_customer_order'), 'SELECT 1', + 'ALTER TABLE ll_order_detail ADD KEY idx_ll_detail_customer_order(customer_id,order_id,id)')); +PREPARE phase4_order_detail_customer_stmt FROM @ddl; EXECUTE phase4_order_detail_customer_stmt; DEALLOCATE PREPARE phase4_order_detail_customer_stmt; + -- 四期平台排车查询、智能预排与草稿锁;不开放编辑、发车、PDA 出库或派送。 CREATE TABLE IF NOT EXISTS ll_dispatch_order ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java index 69fa42e4b..5bb25ffc9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -116,6 +116,7 @@ public class InboundStockReceiptService { order.setUpdatedAt(completedAt); orders.updateById(order); LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); + history.setCustomerId(order.getCustomerId()); history.setOrderId(order.getId()); history.setFromStatus(before); history.setToStatus("INBOUND_COMPLETED"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 2d144ffc4..1f2845e1c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -46,9 +46,11 @@ public class LastlegOrderService { public LastlegOrderDetailResponse detail(String customerId, long orderId) { LastlegOrder order = findOwned(customerId, orderId); List cargo = detailMapper.selectList(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, order.getCustomerId()) .eq(LastlegOrderDetail::getOrderId, orderId).orderByAsc(LastlegOrderDetail::getId)); List history = historyMapper.selectList( new LambdaQueryWrapper() + .eq(LastlegOrderStatusHistory::getCustomerId, order.getCustomerId()) .eq(LastlegOrderStatusHistory::getOrderId, orderId) .orderByAsc(LastlegOrderStatusHistory::getCreatedAt)); return new LastlegOrderDetailResponse(summary(order), cargo, history.stream() @@ -89,6 +91,7 @@ public class LastlegOrderService { .eq(LastlegOrderDetail::getOrderId, order.getId())); LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); + history.setCustomerId(order.getCustomerId()); history.setOrderId(order.getId()); history.setFromStatus(fromStatus); history.setToStatus(DESTINATION_ASSIGNED); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 5da318484..3c2f5c587 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -79,6 +79,7 @@ CREATE TABLE IF NOT EXISTS ll_order ( CREATE TABLE IF NOT EXISTS ll_order_detail ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', order_id BIGINT NOT NULL COMMENT '尾程订单 ID', source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细 ID', sku_code VARCHAR(128) COMMENT 'SKU 编码', @@ -99,7 +100,8 @@ CREATE TABLE IF NOT EXISTS ll_order_detail ( shipping_mark VARCHAR(255) COMMENT '唛头', PRIMARY KEY(id), UNIQUE KEY uk_ll_detail_source(order_id,source_inbound_detail_id), - KEY idx_ll_detail_order(order_id) + KEY idx_ll_detail_order(order_id), + KEY idx_ll_detail_customer_order(customer_id,order_id,id) ) ENGINE=InnoDB COMMENT='尾程订单实装商品冻结明细'; CREATE TABLE IF NOT EXISTS ll_loading_order_status_history ( diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index 1199e5409..1e28bfc5b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -84,7 +84,9 @@ class InboundStockReceiptServiceTest { assertEquals(10L, receipt.getValue().getInventoryPositionId()); assertEquals("INBOUND_COMPLETED", fixture.order.getCurrentStatus()); assertEquals("INBOUND_PLAN:COMPLETED", fixture.order.getSubOrderStatus()); - verify(orderHistories).insert(any(LastlegOrderStatusHistory.class)); + ArgumentCaptor history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class); + verify(orderHistories).insert(history.capture()); + assertEquals("100", history.getValue().getCustomerId()); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/LastlegOrderDetailSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/LastlegOrderDetailSchemaContractTest.java new file mode 100644 index 000000000..f1276e0ed --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/LastlegOrderDetailSchemaContractTest.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.schema; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class LastlegOrderDetailSchemaContractTest { + @Test + void orderDetailHasCustomerIsolationInBaselineAndRepeatableUpgrade() throws Exception { + String baseline = new ClassPathResource("db/lastleg.sql") + .getContentAsString(StandardCharsets.UTF_8); + String unified = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + String service = Files.readString(Path.of("src", "main", "java", "com", "baiyue", "lastleg", + "service", "LastlegOrderService.java"), StandardCharsets.UTF_8); + + assertThat(baseline) + .contains("CREATE TABLE IF NOT EXISTS ll_order_detail") + .contains("customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键'") + .contains("KEY idx_ll_detail_customer_order(customer_id,order_id,id)"); + assertThat(unified) + .contains("table_name='ll_order_detail' AND column_name='customer_id'") + .contains("UPDATE ll_order_detail detail") + .contains("SET detail.customer_id=orders.customer_id") + .contains("MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键'") + .contains("ADD KEY idx_ll_detail_customer_order(customer_id,order_id,id)"); + assertThat(service) + .contains(".eq(LastlegOrderDetail::getCustomerId, order.getCustomerId())") + .contains(".eq(LastlegOrderStatusHistory::getCustomerId, order.getCustomerId())"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index 5c71ee8db..fdc785be6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -78,6 +78,7 @@ class LastlegOrderServiceTest { assertEquals("LAX-02", cargo.getValue().getDestinationWarehouseCode()); ArgumentCaptor history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class); verify(histories).insert(history.capture()); + assertEquals("100", history.getValue().getCustomerId()); assertEquals("PENDING_ASSIGNMENT", history.getValue().getFromStatus()); assertEquals("DESTINATION_ASSIGNED", history.getValue().getToStatus()); } From f1639b9fe3f4ebf60872f02c1b5f4a00bc8bc644 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 12:01:37 +0800 Subject: [PATCH 006/115] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Jenkinsfile | 13 ------------- scripts/controller-pipeline-contract.test.mjs | 6 ++++++ 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 34c3f882e..fc17895a0 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -60,7 +60,6 @@ pipeline { booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)') choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境') booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署') - booleanParam(name: 'INITIALIZE_TEST_DATABASE', defaultValue: false, description: '仅 test:调度服务前幂等初始化测试数据库') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选') string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库地址') string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像') @@ -165,18 +164,6 @@ pipeline { } } - stage('测试数据库初始化(可选)') { - when { - allOf { - expression { env.RESOLVED_ENVIRONMENT == 'test' && params.INITIALIZE_TEST_DATABASE } - } - } - agent { label 'java-test' } - steps { - sh 'sh deploy/scripts/initialize-test-databases.sh' - } - } - stage('并行调度独立服务 Job') { when { expression { env.NO_SERVICE_CHANGE != 'true' } } steps { diff --git a/scripts/controller-pipeline-contract.test.mjs b/scripts/controller-pipeline-contract.test.mjs index 5bf92b4ed..bd941c9cd 100644 --- a/scripts/controller-pipeline-contract.test.mjs +++ b/scripts/controller-pipeline-contract.test.mjs @@ -23,3 +23,9 @@ test('database-only changes are reported as a separate migration action', () => assert.match(pipeline, /仅数据库迁移变更 \| 需单独审核执行/) assert.match(pipeline, /统一 SQL 须独立审核并执行/) }) + +test('coordinator cannot initialize test databases', () => { + assert.doesNotMatch(pipeline, /INITIALIZE_TEST_DATABASE/) + assert.doesNotMatch(pipeline, /initialize-test-databases\.sh/) + assert.doesNotMatch(pipeline, /测试数据库初始化/) +}) From 2389a638586369abf597eaece93ed240b0fb355d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 12:30:18 +0800 Subject: [PATCH 007/115] fix IAM security event correlation id --- .../service/SecurityEventOutboxService.java | 3 +- .../SecurityEventOutboxServiceTest.java | 46 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java index d9372a2e2..fcc158420 100644 --- a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java +++ b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java @@ -53,9 +53,10 @@ public class SecurityEventOutboxService { long aggregateVersion, Object value) { JsonNode payload = objectMapper.valueToTree(value); String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)); + String flowId = EventIdentifiers.newUuid(); EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), type, IamSecurityEventsV1.VERSION, Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, String.valueOf(tenantId), aggregateType, - aggregateId, aggregateVersion, null, EventIdentifiers.newUuid(), null, null, payload, hash).requireValid(); + aggregateId, aggregateVersion, null, flowId, flowId, null, payload, hash).requireValid(); try { jdbc.update("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) " + "VALUES (?,?,?,?,CAST(? AS JSON))", envelope.eventId(), type, aggregateType, aggregateId, diff --git a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java new file mode 100644 index 000000000..432587f3f --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java @@ -0,0 +1,46 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.event.EventEnvelope; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SecurityEventOutboxServiceTest { + @Mock JdbcTemplate jdbc; + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + private SecurityEventOutboxService service; + + @BeforeEach + void setUp() { + service = new SecurityEventOutboxService(); + ReflectionTestUtils.setField(service, "jdbc", jdbc); + ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + } + + @Test + void userStatusWritesAValidEnvelopeWithCorrelationId() throws Exception { + when(jdbc.queryForObject(anyString(), eq(Long.class), any())).thenReturn(1L); + + service.userStatus(101L, 0L, "PLATFORM", "DISABLED"); + + ArgumentCaptor parameters = ArgumentCaptor.forClass(Object[].class); + verify(jdbc).update(eq("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) " + + "VALUES (?,?,?,?,CAST(? AS JSON))"), parameters.capture()); + EventEnvelope envelope = objectMapper.readValue(String.valueOf(parameters.getValue()[4]), EventEnvelope.class); + assertThat(envelope.requireValid().correlationId()).isNotBlank().isEqualTo(envelope.traceId()); + } +} From 4377a7acd933987fe8e94185110d5076180074b7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 12:34:34 +0800 Subject: [PATCH 008/115] fix(firstleg): allow confirmed preload reassign during loading --- .../firstleg/service/SmartLoadingService.java | 9 +++++++-- .../split/service/SplitPreloadService.java | 18 +++++++++++++++++- .../service/SmartLoadingServiceTest.java | 2 ++ .../split/service/SplitPreloadServiceTest.java | 12 ++++++++++++ 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index e902a6e79..33230e466 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -136,8 +136,13 @@ public class SmartLoadingService { if (candidate == null) throw conflict("入库货物不存在或尚未完成入库"); requireSameChannel(booking, candidate); requireSameCustomerScope(booking, candidate); - splitPreloadService.ensurePendingForLoadingAllocation(candidate.accountId(), candidate.inboundId(), - operatorId, operatorName); + if ("LOADING".equals(plan.getPlanStatus())) { + splitPreloadService.ensureForRunningLoadingAllocation(candidate.accountId(), candidate.inboundId(), + operatorId, operatorName); + } else { + splitPreloadService.ensurePendingForLoadingAllocation(candidate.accountId(), candidate.inboundId(), + operatorId, operatorName); + } OrderCargoClient.CargoBinding cargo = loadCargo(candidate); LoadingPlanItemEntity existing = repository.activeItem(planId, candidate.accountId(), request.inboundDetailId()); int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java index 70146cf38..3a5314063 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java @@ -68,11 +68,27 @@ public class SplitPreloadService { @Transactional public void ensurePendingForLoadingAllocation(long tenantId, long inboundId, long operatorId, String operatorName) { + ensureForLoadingAllocation(tenantId, inboundId, operatorId, operatorName, false); + } + + /** + * A running loading task may re-add an inbound whose preload was confirmed by an earlier + * incremental allocation. Draft plans must still use the stricter pending-only entry point. + */ + @Transactional + public void ensureForRunningLoadingAllocation(long tenantId, long inboundId, long operatorId, + String operatorName) { + ensureForLoadingAllocation(tenantId, inboundId, operatorId, operatorName, true); + } + + private void ensureForLoadingAllocation(long tenantId, long inboundId, long operatorId, + String operatorName, boolean allowConfirmed) { InboundSnapshot inbound = requireInbound(tenantId, inboundId); requireWaitingSplit(inbound, "仅待分柜订单可以建立智能装柜预装计划"); PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId); if (plan != null) { - if (!PreloadStatus.PENDING.equals(plan.getStatus())) { + if (!PreloadStatus.PENDING.equals(plan.getStatus()) + && !(allowConfirmed && PreloadStatus.CONFIRMED.equals(plan.getStatus()))) { throw new BizException(ResultCode.CONFLICT, "预装计划已进入确认流程,不能修改智能装柜分配"); } return; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index c26f94ef7..8cc6b1698 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -360,6 +360,8 @@ class SmartLoadingServiceTest { Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-loading"), 7, "u"); assertThat(result.status()).isEqualTo("LOADING"); + verify(splitPreloadService).ensureForRunningLoadingAllocation(9L, 10L, 7L, "u"); + verify(splitPreloadService, never()).ensurePendingForLoadingAllocation(anyLong(), anyLong(), anyLong(), anyString()); var order = inOrder(loadingPickService, repository, loadingModelBridge); order.verify(loadingPickService).lockMutable(1L); order.verify(repository).touch(1, 5, 7, "u"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java index 267aaaf36..08ea4d1bb 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java @@ -80,4 +80,16 @@ class SplitPreloadServiceTest { verify(repository, never()).createPlan(any()); } + @Test + void runningLoadingAllocationMayReuseConfirmedPreload() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3)); + PreloadPlanEntity plan = new PreloadPlanEntity(); + plan.setStatus("CONFIRMED"); + when(repository.findPlan(10, 20)).thenReturn(plan); + + service.ensureForRunningLoadingAllocation(10, 20, 7, "admin"); + + verify(repository, never()).createPlan(any()); + } + } From 92dbeb1676ef53814fb0c5c94e63c18d8e55045c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 12:52:23 +0800 Subject: [PATCH 009/115] fix(firstleg): finalize loading before customs acceptance --- .../firstleg/service/SmartLoadingService.java | 6 ++++++ .../service/SmartLoadingServiceTest.java | 18 ++++++++++++++++++ scripts/admin-test-env-acceptance.mjs | 11 ++++++++--- ...se3-loading-dynamic-final-contract.test.mjs | 1 + .../phase3-loading-finalize.mjs | 5 +++-- .../phase3-producer-chain-contract.test.mjs | 4 ++-- .../full-regression-entry-contract.test.mjs | 6 +++--- 7 files changed, 41 insertions(+), 10 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 33230e466..f60bca467 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -298,6 +298,12 @@ public class SmartLoadingService { LoadingPlanItemEntity item = repository.item(planId, itemId); if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在"); if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突"); + LoadingPickService.MutationSnapshot pickSnapshot = loadingPickService.mutationSnapshot(planId); + int pickedPackageCount = pickSnapshot == null ? 0 + : pickSnapshot.pickedByPlanItem().getOrDefault(itemId, 0); + if (request.packageCount() < pickedPackageCount) { + throw conflict("计划箱数不能低于已拣货箱数"); + } ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); Candidate candidate = repository.candidate(booking.getWarehouseId(), item.getInboundId(), item.getInboundDetailId()); if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 8cc6b1698..2f42a8329 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -41,6 +41,7 @@ import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.catchThrowableOfType; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; @@ -595,6 +596,23 @@ class SmartLoadingServiceTest { assertThat(updated.getValue().getWarehouseRemark()).isEqualTo("装柜时注意外箱"); } + @Test + void pickedQuantityConflictPrecedesCandidateAvailabilityValidation() { + LoadingPlanEntity plan = plan("LOADING", 2); + LoadingPlanItemEntity line = item(8, 9, 20, 2); + when(repository.plan(1L)).thenReturn(plan); + when(repository.item(1L, 8L)).thenReturn(line); + when(loadingPickService.mutationSnapshot(1L)).thenReturn( + new LoadingPickService.MutationSnapshot(77L, "LOADING", 6, Map.of(8L, 2))); + + assertThatThrownBy(() -> service.adjust(1L, 8L, + new AdjustRequest(1, 2, 0, "adjust-picked"), 7L, "u")) + .hasMessageContaining("不能低于已拣货箱数"); + + verify(repository, never()).candidate(anyLong(), anyLong(), anyLong()); + verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString()); + } + @Test void preservesDownstreamCode() { stubAssignment(); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 06eae4b46..f01a0e298 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -13,7 +13,7 @@ const DEFAULT_GATEWAY = 'http://8.163.30.128:9000' const DEFAULT_OVERSEAS_GATEWAY = 'http://8.163.30.128:9300' const DEFAULT_USERNAME = 'platform_admin' const DEFAULT_PASSWORD = 'ChangeMe123!' -const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] +const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] export function normalizeModuleOrder(modules) { const normalized = [...modules] @@ -33,8 +33,8 @@ export function normalizeModuleOrder(modules) { 'phase3-container-loading', 'phase3-loading', 'ocean-bookings', - 'phase3-customs', 'phase3-loading-finalize', + 'phase3-customs', ] for (const prerequisite of prerequisites) { if (!normalized.includes(prerequisite)) { @@ -98,6 +98,12 @@ export function normalizeModuleOrder(modules) { const missing = prerequisites.filter((name) => !normalized.includes(name)) if (missing.length > 0) normalized.splice(targetIndex, 0, ...missing) } + const customsIndex = normalized.indexOf('phase3-customs') + const finalizeIndex = normalized.indexOf('phase3-loading-finalize') + if (customsIndex >= 0 && finalizeIndex >= 0 && customsIndex < finalizeIndex) { + normalized.splice(finalizeIndex, 1) + normalized.splice(normalized.indexOf('phase3-customs'), 0, 'phase3-loading-finalize') + } const loadingIndex = normalized.indexOf('phase3-loading') const overviewIndex = normalized.indexOf('phase3-container-overview') if (loadingIndex < 0 || overviewIndex < 0 || overviewIndex > loadingIndex) return normalized @@ -218,7 +224,6 @@ export class AcceptanceRun { return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } } } - async requestOverseas(method, path, body = undefined, extraHeaders = {}) { const headers = { Accept: 'application/json', ...extraHeaders } if (this.overseasToken) headers.Authorization = `Bearer ${this.overseasToken}` diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index 479d296a9..1ad0b9077 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -90,6 +90,7 @@ test('downstream finalizer uses the frozen truck-coupled complete contract', () '结算递增 inboundVersion 后 final complete 必须使用最新版本', '入库单终态 LOADED、版本递增且审计历史存在', ]) + assert.match(finalize, /const taskDetailPath = `\$\{taskPath\}\?warehouseId=/) assert.doesNotMatch(finalize, /BLOCKED_CONTRACT/) assert.doesNotMatch(finalize, /truck\?\.finalStatus === 'COMPLETED'/) assert.doesNotMatch(finalize, /\/loading\/complete\?tenantId=/) diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index ade1a84c0..a74772358 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -70,7 +70,8 @@ export async function run(context) { const base = `/api/platform/v1/inbounds/${inboundId}` const taskPath = `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}` - const taskBeforeResponse = await context.request('GET', taskPath) + const taskDetailPath = `${taskPath}?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}` + const taskBeforeResponse = await context.request('GET', taskDetailPath) const taskBefore = context.apiSuccess(MODULE, '读取最终完成前 PDA 任务即时版本', taskBeforeResponse, '', OPTIONS) context.assert(MODULE, '最终完成前四元身份稳定且任务等待人工确认', taskBeforeResponse, id(taskBefore?.id) === taskId && id(taskBefore?.inboundId) === inboundId @@ -105,7 +106,7 @@ export async function run(context) { if (completeResponse.http !== 200 || codeOf(completeResponse) !== '200') { context.addStep(MODULE, '结算递增 inboundVersion 后 final complete 必须使用最新版本', completeResponse, false, `expected=success after settlement refresh; HTTP=${completeResponse.http}, code=${codeOf(completeResponse)}, message=${completeResponse.body?.message || 'missing'}, taskId=${taskId}, inboundId=${inboundId}, taskVersion=${taskBefore?.version ?? 'missing'}, inboundVersion=${version}, requestId=${completeResponse.requestId || 'missing'}, traceId=${completeResponse.traceId || 'missing'}`, OPTIONS) - const taskAfterFailureResponse = await context.request('GET', taskPath) + const taskAfterFailureResponse = await context.request('GET', taskDetailPath) const taskAfterFailure = context.apiSuccess(MODULE, 'final complete 失败后回查 PDA 任务', taskAfterFailureResponse, '', OPTIONS) const planAfterFailureResponse = await context.request('GET', planPath) const planAfterFailure = context.apiSuccess(MODULE, 'final complete 失败后回查计划', planAfterFailureResponse, '', OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 5178677cd..d140ddd20 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -508,15 +508,15 @@ test('downstream container consumers prefer the phase3Loading fixture', () => { assert.match(schedules, /if \(containerBookingId && !knownAllocation\)/) }) -test('default acceptance order verifies loading evidence before finalizing LOADED', () => { +test('default acceptance order finalizes LOADED before customs reads fulfillment slices', () => { const entry = source('../admin-test-env-acceptance.mjs') assertOrder(entry, [ "'phase3-loading'", "'phase3-loading-model-bridge'", "'phase3-pda-convergence'", "'phase3-evidence-gate'", - "'phase3-customs'", "'phase3-loading-finalize'", + "'phase3-customs'", ]) }) diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs index f694486e0..f730fc849 100644 --- a/scripts/full-regression-entry-contract.test.mjs +++ b/scripts/full-regression-entry-contract.test.mjs @@ -110,7 +110,7 @@ test('default loading chain carries the three loading-list remarks contract', () assert.match(loading, /preservedRemarkItem\?\.warehouseRemark === warehouseRemark/) }) -test('default Admin HTTP entry carries evidence chain through settlement-aware PDA completion', () => { +test('default Admin HTTP entry completes settlement before customs consumes loaded slices', () => { const focused = normalizeModuleOrder([ 'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-container-overview', @@ -120,8 +120,8 @@ test('default Admin HTTP entry carries evidence chain through settlement-aware P assert.deepEqual(focused, [ 'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-container-overview', - 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-customs', - 'phase3-loading-finalize', + 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-loading-finalize', + 'phase3-customs', ]) const loading = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading.mjs'), 'utf8') From 6ddf0d823b8c44804bdcc103b99a03d57cecc160 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 13:37:47 +0800 Subject: [PATCH 010/115] fix(firstleg): share container evidence across loading owners --- .../service/LoadingDeliveryService.java | 26 +++++++++++++++---- .../mapper/LoadingPickItemMapper.java | 4 ++- .../firstleg/service/LoadingPickService.java | 15 +++++++---- .../service/LoadingPickServiceTest.java | 18 ++++++++----- 4 files changed, 46 insertions(+), 17 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java index e2703301b..52467a049 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java @@ -97,13 +97,19 @@ public class LoadingDeliveryService { @Transactional public LoadingActionResponse completeLoading(long tenantId, long inboundId, long operatorId, String operatorName, String key, CompleteLoadingRequest request) { + return completeLoading(tenantId, inboundId, inboundId, operatorId, operatorName, key, request); + } + + private LoadingActionResponse completeLoading(long tenantId, long inboundId, long evidenceInboundId, + long operatorId, String operatorName, String key, + CompleteLoadingRequest request) { String hash = hash(request); LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, hash); if (replay != null) return replay; Snapshot inbound = requireInbound(tenantId, inboundId); CompletionTarget target = repository.completionTarget(tenantId, inboundId); if (target != null) { - return completeFulfillmentSlice(tenantId, inboundId, operatorId, operatorName, key, request, target); + return completeFulfillmentSlice(tenantId, evidenceInboundId, operatorId, operatorName, key, request, target); } if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) { loadingModelBridge.requireCompleted(tenantId, inboundId); @@ -114,7 +120,7 @@ public class LoadingDeliveryService { loadingModelBridge.requireCompleted(tenantId, inboundId); completionGate.requireReleasedBooking(tenantId, inboundId); requireCompletedPicking(tenantId, inboundId); - requireCompletionEvidence(tenantId, inboundId); + requireCompletionEvidence(tenantId, evidenceInboundId); if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent(); addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName); return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, @@ -129,13 +135,19 @@ public class LoadingDeliveryService { public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId, long operatorId, String operatorName, String key, String remark) { + return completeAfterPdaSettlement(tenantId, inboundId, inboundId, operatorId, operatorName, key, remark); + } + + public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId, + long evidenceInboundId, long operatorId, + String operatorName, String key, String remark) { CompletionTarget target = repository.completionTarget(tenantId, inboundId); long completionInboundId = target == null ? inboundId : target.completionInboundId(); Snapshot current = repository.findInboundForUpdate(tenantId, completionInboundId); if (current == null || current.version() == null) { throw new BizException(ResultCode.CONFLICT, "PDA结算后的实装履约分片不存在,请刷新后重试"); } - return completeLoading(tenantId, inboundId, operatorId, operatorName, key, + return completeLoading(tenantId, inboundId, evidenceInboundId, operatorId, operatorName, key, new CompleteLoadingRequest(current.version(), remark)); } @@ -144,13 +156,17 @@ public class LoadingDeliveryService { * every external/business hard gate must pass before the PDA task is mutated. */ public void requirePdaCompletionReady(long tenantId, long inboundId) { + requirePdaCompletionReady(tenantId, inboundId, inboundId); + } + + public void requirePdaCompletionReady(long tenantId, long inboundId, long evidenceInboundId) { Snapshot inbound = requireInbound(tenantId, inboundId); if (!"LOADING".equals(inbound.processStatus())) { throw new BizException(ResultCode.CONFLICT, "PDA确认装柜完成前订单必须处于LOADING"); } loadingModelBridge.requireStarted(tenantId, inboundId); completionGate.requireReleasedBooking(tenantId, inboundId); - requireCompletionEvidence(tenantId, inboundId); + requireCompletionEvidence(tenantId, evidenceInboundId); } private LoadingActionResponse completeFulfillmentSlice(long tenantId, long evidenceInboundId, @@ -167,7 +183,7 @@ public class LoadingDeliveryService { loadingModelBridge.requireCompleted(tenantId, target.sourceInboundId()); completionGate.requireReleasedBooking(tenantId, target.sourceInboundId()); requireCompletedPicking(tenantId, target.sourceInboundId()); - requireCompletionEvidence(tenantId, target.sourceInboundId()); + requireCompletionEvidence(tenantId, evidenceInboundId); if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId, request.expectedVersion()) != 1) throw concurrent(); repository.aggregateRootLoaded(tenantId, target.rootInboundId(), operatorId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java index ea943e0b9..d57e01168 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -107,10 +107,12 @@ public interface LoadingPickItemMapper extends BaseMapper } @Select(""" - SELECT DISTINCT plan_item.account_id tenantId,plan_item.inbound_id inboundId + SELECT plan_item.account_id tenantId,plan_item.inbound_id inboundId FROM fl_loading_pick_item pick_item JOIN fl_loading_plan_item plan_item ON plan_item.id=pick_item.loading_plan_item_id WHERE pick_item.pick_task_id=#{taskId} + GROUP BY plan_item.account_id,plan_item.inbound_id + ORDER BY MIN(pick_item.id) """) java.util.List selectInboundOwners(@Param("taskId") long taskId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 6fda57d36..4faf88928 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -450,16 +450,20 @@ public class LoadingPickService { throw conflict("存在尚未处理的PDA拣货明细,不能确认装柜完成"); } if (repository.review(taskId) != null) throw conflict("PDA任务已经确认装柜完成"); - List owners = repository.inboundOwners(taskId).stream() + List discoveredOwners = repository.inboundOwners(taskId).stream() .filter(owner -> owner.tenantId() != null && owner.inboundId() != null) + .toList(); + if (discoveredOwners.isEmpty()) throw conflict("PDA任务未关联有效入库单"); + LoadingPickItemMapper.InboundOwner evidenceOwner = discoveredOwners.get(0); + List owners = discoveredOwners.stream() .sorted(Comparator.comparing(LoadingPickItemMapper.InboundOwner::tenantId) .thenComparing(LoadingPickItemMapper.InboundOwner::inboundId)) .toList(); - if (owners.isEmpty()) throw conflict("PDA任务未关联有效入库单"); for (LoadingPickItemMapper.InboundOwner owner : owners) { LoadingInboundMapper.Snapshot snapshot = repository.inboundForUpdate(owner.tenantId(), owner.inboundId()); if (snapshot == null) throw conflict("PDA任务关联入库单不存在"); - loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId()); + loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId(), + evidenceOwner.inboundId()); } settlementService.settle(taskId, operatorId, operatorName); if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) { @@ -472,8 +476,9 @@ public class LoadingPickService { loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName); repository.syncInboundProjections(taskId, operatorId, operatorName); for (LoadingPickItemMapper.InboundOwner owner : owners) { - loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(), operatorId, - operatorName, "PDA-" + operationId + '-' + owner.inboundId(), remark); + loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(), + evidenceOwner.inboundId(), operatorId, operatorName, + "PDA-" + operationId + '-' + owner.inboundId(), remark); } loadingListService.publishCompletedEvent(task.getLoadingPlanId()); completeTruckAfterCommit(task.getContainerBookingId(), taskId, operatorId, operatorName); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index dbaefc6b3..6279bf6bd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -508,9 +508,12 @@ class LoadingPickServiceTest { when(repository.allPlanItemsPicked(10L)).thenReturn(true); when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L); - when(repository.inboundOwners(20L)).thenReturn(List.of(owner)); + var incrementalOwner = new LoadingPickItemMapper.InboundOwner(101L, 60L); + when(repository.inboundOwners(20L)).thenReturn(List.of(owner, incrementalOwner)); when(repository.inboundForUpdate(101L, 50L)).thenReturn( new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); + when(repository.inboundForUpdate(101L, 60L)).thenReturn( + new LoadingInboundMapper.Snapshot(60L, 101L, "LOADING", 3)); service.complete(20L, new CompleteLoadingRequest(0, "complete-1", "checked"), 9L, "operator"); service.complete(20L, new CompleteLoadingRequest(0, "complete-1", "checked"), 9L, "operator"); @@ -519,14 +522,17 @@ class LoadingPickServiceTest { verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); verify(settlementService).settle(20L, 9L, "operator"); verify(repository).syncInboundProjections(20L, 9L, "operator"); - verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L); + verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L, 50L); + verify(loadingDeliveryService).requirePdaCompletionReady(101L, 60L, 50L); verify(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator"); - verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 50L, 9L, "operator", "PDA-complete-1-50", "checked"); + verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 60L, 50L, 9L, "operator", + "PDA-complete-1-60", "checked"); verify(loadingListService).publishCompletedEvent(10L); InOrder finalization = inOrder(loadingListService, loadingDeliveryService); finalization.verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); - finalization.verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + finalization.verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 50L, 9L, "operator", "PDA-complete-1-50", "checked"); finalization.verify(loadingListService).publishCompletedEvent(10L); } @@ -556,7 +562,7 @@ class LoadingPickServiceTest { verify(settlementService).settle(20L, 9L, "operator"); verify(repository).approve(20L, 9L, "operator", "checked"); verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); - verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 50L, 9L, "operator", "PDA-complete-not-blocked-50", "checked"); verify(loadingListService).publishCompletedEvent(10L); } @@ -574,7 +580,7 @@ class LoadingPickServiceTest { new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "入库单并发修改")) - .when(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + .when(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 50L, 9L, "operator", "PDA-complete-finalize-fails-50", "checked"); assertThatThrownBy(() -> service.complete(20L, From 9dd2fcb7e56d65034e3e7c8cc7d46a4c4ffa215b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 13:41:59 +0800 Subject: [PATCH 011/115] test(phase3): validate multi-owner loaded slices --- ...e3-loading-dynamic-final-contract.test.mjs | 2 +- .../phase3-loading-finalize.mjs | 20 ++++++++++--------- .../phase3-producer-chain-contract.test.mjs | 2 +- .../full-regression-entry-contract.test.mjs | 2 +- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index 1ad0b9077..d08a32442 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -136,7 +136,7 @@ test('HTTP finalizer preserves failure evidence and verifies the settled slice', 'final complete 失败后回查入库单', '结算或版本冲突失败后 task/plan/inbound/operation 全部零写入', '结算后回查柜次实际履约分片', - '结算递增版本后实际完成分片唯一且柜次身份稳定', + '结算后每个实装来源分片唯一且柜次身份稳定', ]) assert.match(finalize, /requestId=\$\{completeResponse\.requestId \|\| 'missing'\}/) assert.match(finalize, /traceId=\$\{completeResponse\.traceId \|\| 'missing'\}/) diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index a74772358..4a81d6c8a 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -172,12 +172,14 @@ export async function run(context) { && id(item?.sourceOrderId) === id(loadedSlice?.sourceOrderId)) : null const settlementMode = remainderSlice ? 'PARTIAL' : 'FULL' - context.assert(MODULE, '结算递增版本后实际完成分片唯一且柜次身份稳定', slicesResponse, - loadedSlices.length === 1 && Boolean(loadedSlice) - && id(loadedSlice?.tenantId) === tenantId - && id(loadedSlice?.containerBookingId) === containerBookingId - && id(loadedSlice?.loadingPlanId) === loadingPlanId - && Number.isInteger(Number(loadedSlice?.packageCount)) && Number(loadedSlice.packageCount) > 0, + const loadedSourceIds = loadedSlices.map((item) => id(item?.sourceOrderId)).filter(Boolean) + context.assert(MODULE, '结算后每个实装来源分片唯一且柜次身份稳定', slicesResponse, + loadedSlices.length > 0 && Boolean(loadedSlice) + && new Set(loadedSourceIds).size === loadedSlices.length + && loadedSlices.every((item) => id(item?.tenantId) === tenantId + && id(item?.containerBookingId) === containerBookingId + && id(item?.loadingPlanId) === loadingPlanId + && Number.isInteger(Number(item?.packageCount)) && Number(item.packageCount) > 0), `mode=${settlementMode}, loadedSlices=${loadedSlices.length}, sourceOrderId=${id(loadedSlice?.sourceOrderId) || 'missing'}, childOrderId=${id(loadedSlice?.childOrderId) || 'missing'}, packageCount=${loadedSlice?.packageCount ?? 'missing'}`, OPTIONS) const unifiedDetailPath = `/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}` @@ -223,9 +225,9 @@ export async function run(context) { { expectedTaskVersion: Number(completed?.version), operationId: `${context.marker}-pda-final-complete-new-key`.slice(0, 128), remark: `${context.marker}-loaded-new-key` }, { 'Idempotency-Key': `${context.marker}-p3-complete-loaded-new-key`.slice(0, 128) }) - context.assert(MODULE, 'LOADED 后使用新键重复 complete 幂等保持终态', completeNewKey, - completeNewKey.http === 200 && codeOf(completeNewKey) === '200' - && completeNewKey.body?.data?.taskStatus === 'COMPLETED', + context.assert(MODULE, 'LOADED 后使用新键重复 complete 返回冲突', completeNewKey, + completeNewKey.http === 200 && codeOf(completeNewKey) === 'A0409' + && Boolean(completeNewKey.requestId) && Boolean(completeNewKey.traceId), `HTTP=${completeNewKey.http}, code=${codeOf(completeNewKey)}, status=${completeNewKey.body?.data?.taskStatus || 'missing'}`, OPTIONS) const completeSameKeyDifferentBody = await request(context, 'POST', diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index d140ddd20..cb213aac1 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -613,7 +613,7 @@ test('loading evidence acceptance covers idempotency, reversible missing-type ga test('loaded boundary acceptance proves evidence and transport immutability with zero drift', () => { const finalize = source('./phase3-loading-finalize.mjs') assertOrder(finalize, [ - 'LOADED 后使用新键重复 complete 幂等保持终态', + 'LOADED 后使用新键重复 complete 返回冲突', 'LOADED complete 同键异参返回冲突', 'LOADED 后新增装柜证据被拒绝', 'LOADED 后作废装柜证据被拒绝', diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs index f730fc849..34794dea8 100644 --- a/scripts/full-regression-entry-contract.test.mjs +++ b/scripts/full-regression-entry-contract.test.mjs @@ -146,7 +146,7 @@ test('default Admin HTTP entry completes settlement before customs consumes load assert.ok(finalize.indexOf('const taskPath =') < finalize.indexOf('结算递增 inboundVersion 后 final complete 必须使用最新版本')) assert.match(finalize, /结算或版本冲突失败后 task\/plan\/inbound\/operation 全部零写入/) - assert.match(finalize, /结算递增版本后实际完成分片唯一且柜次身份稳定/) + assert.match(finalize, /结算后每个实装来源分片唯一且柜次身份稳定/) assert.doesNotMatch(finalize, /truck\?\.finalStatus === 'COMPLETED'/) }) From b012d64b54ceb1e47e3bb6d9096969aadd121584 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 13:43:38 +0800 Subject: [PATCH 012/115] perf(ci): run selected tests once before service builds --- Jenkinsfile | 33 ++++++++++++++++++- Jenkinsfile.service | 9 +++-- scripts/controller-pipeline-contract.test.mjs | 11 +++++++ ...est-environment-pipeline-contract.test.mjs | 11 +++++-- 4 files changed, 58 insertions(+), 6 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index d1a5cc91b..e80ce973e 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -108,6 +108,9 @@ pipeline { } env.RESOLVED_JOB_PREFIX = jobPrefix env.DATABASE_CHANGE_DETECTED = 'false' + if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS) { + error('生产环境禁止跳过测试') + } List selected = [] if (params.BUILD_MODE == 'ALL') { @@ -177,6 +180,33 @@ pipeline { } } + stage('统一 Maven 测试门禁') { + when { + expression { env.NO_SERVICE_CHANGE != 'true' && !params.SKIP_TESTS } + } + agent { label 'java-builder' } + tools { maven 'maven-3.8.8' } + steps { + script { + String selectedModules = env.SELECTED_SERVICES.split(',') + .collect { it.trim() } + .findAll { it } + .join(',') + echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}" + sh """ + test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\" + mvn --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test + """ + echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试" + } + } + post { + always { + junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml' + } + } + } + stage('并行调度独立服务 Job') { when { expression { env.NO_SERVICE_CHANGE != 'true' } } steps { @@ -196,7 +226,8 @@ pipeline { string(name: 'IMAGE_TAG', value: env.IMAGE_TAG), string(name: 'ENVIRONMENT', value: env.RESOLVED_ENVIRONMENT), booleanParam(name: 'DEPLOY', value: params.DEPLOY), - booleanParam(name: 'SKIP_TESTS', value: params.SKIP_TESTS), + // 总控已完成统一测试,或用户已明确选择跳过;下游仅负责编译、打包和部署。 + booleanParam(name: 'SKIP_TESTS', value: true), string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY), string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE), string(name: 'EVENT_BRIDGE_REGION', value: params.EVENT_BRIDGE_REGION) diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 379caf60c..ed42f05bf 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -114,12 +114,15 @@ pipeline { stage('Maven 测试与打包') { steps { script { - if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS) { - error('生产环境禁止跳过测试') + boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean + if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS && !coordinatorPinnedCommit) { + error('生产环境手工构建禁止跳过测试;总控流水线可在统一测试通过后执行快速打包') } boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS String testOption = skipTestExecution ? '-DskipTests' : '' - if (params.ENVIRONMENT == 'test') { + if (params.SKIP_TESTS && coordinatorPinnedCommit) { + echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包" + } else if (params.ENVIRONMENT == 'test') { echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行' } sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}" diff --git a/scripts/controller-pipeline-contract.test.mjs b/scripts/controller-pipeline-contract.test.mjs index bd941c9cd..802e7e569 100644 --- a/scripts/controller-pipeline-contract.test.mjs +++ b/scripts/controller-pipeline-contract.test.mjs @@ -13,6 +13,17 @@ test('coordinator releases its builder while waiting for downstream jobs', () => assert.doesNotMatch(pipeline, /cleanWs\(/) }) +test('coordinator runs selected tests once before parallel service packaging', () => { + const gateIndex = pipeline.indexOf("stage('统一 Maven 测试门禁')") + const dispatchIndex = pipeline.indexOf("stage('并行调度独立服务 Job')") + + assert.ok(gateIndex >= 0, 'unified test gate must exist') + assert.ok(dispatchIndex > gateIndex, 'unified tests must finish before downstream dispatch') + assert.match(pipeline, /-pl \$\{selectedModules\} -am test/) + assert.match(pipeline, /booleanParam\(name: 'SKIP_TESTS', value: true\)/) + assert.doesNotMatch(pipeline, /TESTED_COMMIT/) +}) + test('AUTO detects database migrations without expanding them to all services', () => { assert.match(pipeline, /def databaseMigrationPaths = \['deploy\/sql\/'\]/) assert.match(pipeline, /!databaseMigration && commonPaths\.any/) diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index 85f55937c..c6f238ce2 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -30,8 +30,9 @@ test('coordinator jobs present their own deployment environment as the default c }) test('production deployments cannot skip tests', () => { - assert.match(pipeline, /params\.ENVIRONMENT == 'production' && params\.SKIP_TESTS/) - assert.match(pipeline, /error\('生产环境禁止跳过测试'\)/) + assert.match(coordinatorPipeline, /env\.RESOLVED_ENVIRONMENT == 'production' && params\.SKIP_TESTS/) + assert.match(coordinatorPipeline, /error\('生产环境禁止跳过测试'\)/) + assert.match(pipeline, /生产环境手工构建禁止跳过测试/) }) test('Java builds use the shared builder pool and serialize incremental target deployments', () => { @@ -51,6 +52,12 @@ test('Java builds use the shared builder pool and serialize incremental target d assert.match(directDeployScript, /--pull never/) }) +test('production only accepts skipped downstream tests for a coordinator-pinned commit', () => { + assert.match(pipeline, /boolean coordinatorPinnedCommit = params\.SOURCE_COMMIT\?\.trim\(\) as boolean/) + assert.match(pipeline, /params\.ENVIRONMENT == 'production' && params\.SKIP_TESTS && !coordinatorPinnedCommit/) + assert.match(pipeline, /生产环境手工构建禁止跳过测试/) +}) + test('development and test deployment locks are acquired before target executors', () => { const directDeployStage = pipeline.slice( pipeline.indexOf("stage('部署开发或测试环境')"), From 98ec62f66efb0141bd2cda22de9b869d05b5dd4a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 13:48:16 +0800 Subject: [PATCH 013/115] test(phase3): align full acceptance fixtures --- ...manual-state-loading-pda-contract.test.mjs | 6 ++--- .../manual-state-loading-pda.mjs | 9 +++++-- .../phase3-container-overview.mjs | 8 ++++++- .../phase3-order-splitting.mjs | 24 +++++++++++++------ .../phase3-pda-convergence.mjs | 18 +++++++------- .../service-center-flow.mjs | 4 +++- 6 files changed, 46 insertions(+), 23 deletions(-) diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs index 2feac4e9e..864e09dd3 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs @@ -35,9 +35,9 @@ test('retains all clickable container, loading-list and PDA workflow states thro assert.match(source, /计划确认同键同参精确重放/) assert.doesNotMatch(source, /\/preload\/confirming|\/preload\/confirm\?tenantId=/) assert.match(source, /transported\?\.cabinetNo !== container\?\.cabinetNo/) - assert.match(source, /shippingCompanyId: null, shippingCompanyName: null/) - assert.match(source, /bookingAgentId: null, bookingAgentName: null/) - assert.match(source, /billOfLadingNo: confirmed\?\.oceanBooking\?\.billOfLadingNo/) + assert.match(source, /shippingCompanyId: id\(agent\.id\), shippingCompanyName: agent\.name/) + assert.match(source, /bookingAgentId: id\(agent\.id\), bookingAgentName: agent\.name/) + assert.match(source, /billOfLadingNo: `BL-\$\{value\}`/) const batch = source.slice(source.indexOf('async function pickAll'), source.indexOf('async function complete')) assert.doesNotMatch(batch, /containerNo:/) }) diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs index 96eaed373..5f9f8fb1f 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -99,12 +99,17 @@ async function createConfirmedContainer(context, source, label) { const value = suffix(context, label) const containerNo = `MAN-${value}`.slice(0, 64) const summary = source.detail.summary + const agentResponse = await context.request('GET', + '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20') + const agentPage = context.apiSuccess(MODULE, `${label} 读取 ACTIVE 订舱代理`, agentResponse, '', OPTIONS) + const agent = agentPage?.records?.find?.((item) => item?.id && item?.name) + if (!agent) throw new Error(`${label} 未取得 ACTIVE 订舱代理`) const draft = { customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户', channelCode: summary.channelCode, warehouseId: source.warehouseId, billOfLadingNo: `BL-${value}`.slice(0, 128), - shippingCompanyId: null, shippingCompanyName: null, - bookingAgentId: null, bookingAgentName: null, departurePortCode: 'CNSZX', + shippingCompanyId: id(agent.id), shippingCompanyName: agent.name, + bookingAgentId: id(agent.id), bookingAgentName: agent.name, departurePortCode: 'CNSZX', vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64), departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', diff --git a/scripts/admin-test-env-modules/phase3-container-overview.mjs b/scripts/admin-test-env-modules/phase3-container-overview.mjs index 3c05d8e29..8da3091d7 100644 --- a/scripts/admin-test-env-modules/phase3-container-overview.mjs +++ b/scripts/admin-test-env-modules/phase3-container-overview.mjs @@ -103,7 +103,13 @@ export async function run(context) { const clearance = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}/clearance`) context.apiSuccess(MODULE, '查询目的港清关资料', clearance, `containerBookingId=${containerId}`, OPTIONS) const customs = await context.request('GET', `/api/platform/v1/first-leg/loading-lists/${encodeURIComponent(planId)}/customs?warehouseId=${encodeURIComponent(warehouseId)}`) - context.apiSuccess(MODULE, '查询出口报关资料', customs, `loadingPlanId=${planId}, warehouseId=${warehouseId}`, OPTIONS) + if (fixture?.finalStatus === 'LOADED') { + context.apiSuccess(MODULE, '查询出口报关资料', customs, `loadingPlanId=${planId}, warehouseId=${warehouseId}`, OPTIONS) + } else { + context.assert(MODULE, '运行中计划尚未生成完成装柜清单,出口报关资料拒绝读取', customs, + customs.http === 200 && String(customs.body?.code) === 'A0409', + `loadingPlanId=${planId}, status=${fixture?.finalStatus || 'missing'}`, OPTIONS) + } context.addStep(MODULE, '异常标记/取消、单证上传和完成锁定保持写入阻断', null, true, '默认全量脚本只读;写场景需显式独立授权和一次性测试柜次', OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index 8f27d2bf8..21d0994a7 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -135,18 +135,25 @@ function snapshotMatches(tree, original, loaded, remaining, activeSuffix) { && activeMatches && quantityConserved(tree) } -function bookingPayload(marker, sequence, source) { +function bookingPayload(marker, sequence, source, agent) { const suffix = `${marker}-${sequence}`.replace(/[^A-Za-z0-9]/g, '').slice(-18) return oceanBookingDraft({ marker }, suffix, { customerId: source.tenantId, customerName: source.customerName, channelCode: source.channelCode, warehouseId: source.warehouseId, warehouseName: source.warehouseName, }, { vesselName: `SPLIT-VESSEL-${suffix}`.slice(0, 128), voyageNo: `SV-${sequence}`.slice(0, 64), + shippingCompanyId: id(agent.id), shippingCompanyName: agent.name, + bookingAgentId: id(agent.id), bookingAgentName: agent.name, estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00' }) } async function loadOneFragment(context, state) { const { tenantId, rootOrderId, sourceOrderId, sourceOrderNo, warehouseId, sourcePackageCount, sequence } = state const operationBase = `${context.marker}-chain-${sequence}`.slice(0, 100) + const agentResponse = await context.request('GET', + '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20') + const agentPage = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 读取 ACTIVE 订舱代理`, agentResponse, '', OPTIONS) + const agent = agentPage?.records?.find?.((item) => item?.id && item?.name) + if (!agent) return null const simulationResponse = await request(context, 'POST', CONTAINERS, { channelCode: state.channelCode, warehouseId, containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, }) @@ -155,7 +162,7 @@ async function loadOneFragment(context, state) { if (!containerId) return null context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(containerId) const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', { - expectedVersion: 0, data: bookingPayload(context.marker, sequence, state), + expectedVersion: 0, data: bookingPayload(context.marker, sequence, state, agent), }, { 'Idempotency-Key': `${operationBase}-booking-create` }) const bookingCreated = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 创建渠道仓库一致的订舱`, bookingCreate, '', OPTIONS) const oceanBookingId = id(bookingCreated?.oceanBooking?.id) @@ -474,11 +481,14 @@ export async function run(context) { const initialSlices = await context.request('GET', `/api/platform/v1/orders/container-bookings/${encodeURIComponent(containerBookingId)}/fulfillment-slices?tenantId=${encodeURIComponent(tenantId)}`) const initialSliceRows = context.apiSuccess(MODULE, '按首次柜次查询来源与余量分片', initialSlices, '', OPTIONS) - context.assert(MODULE, '首次柜次只含来源 LOADED 记录与一个 REMAINDER 记录', initialSlices, - Array.isArray(initialSliceRows) && initialSliceRows.length === 2 - && initialSliceRows.some((row) => row.splitRole === 'LOADED' && id(row.childOrderId) === id(row.sourceOrderId)) - && initialSliceRows.some((row) => row.splitRole === 'REMAINDER' && id(row.childOrderId) !== id(row.sourceOrderId)), - `rows=${Array.isArray(initialSliceRows) ? initialSliceRows.length : 'missing'}`, OPTIONS) + const initialSourceRows = Array.isArray(initialSliceRows) + ? initialSliceRows.filter((row) => id(row.sourceOrderId) === orderId) + : [] + context.assert(MODULE, '首次柜次对当前来源只含一条 LOADED 与一条 REMAINDER', initialSlices, + initialSourceRows.length === 2 + && initialSourceRows.some((row) => row.splitRole === 'LOADED' && id(row.childOrderId) === id(row.sourceOrderId)) + && initialSourceRows.some((row) => row.splitRole === 'REMAINDER' && id(row.childOrderId) !== id(row.sourceOrderId)), + `sourceRows=${initialSourceRows.length}, containerRows=${Array.isArray(initialSliceRows) ? initialSliceRows.length : 'missing'}`, OPTIONS) context.createdResources.phase3OrderSplitting = { marker: context.marker, tenantId, rootOrderId: orderId, rootOrderNo: firstTree.parentOrderNo, diff --git a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs index d1ea44db2..f7e7e6232 100644 --- a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs @@ -31,18 +31,18 @@ export async function run(context) { '本轮未取得 marker 隔离的柜次 PDA 与入库汇总 fixture,未使用历史资源', 'fixture') return } - const loadingListResponse = await request(context, 'GET', - `/api/platform/v1/first-leg/loading-lists/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`) - const loadingList = context.apiSuccess(MODULE, '回查柜次 PDA 待最终完成的装柜清单', loadingListResponse, '', OPTIONS) - context.assert(MODULE, '装柜清单保持 LOADING 且尚无最终完成审计', loadingListResponse, - id(loadingList?.summary?.planId) === loadingPlanId && loadingList?.summary?.status === 'LOADING' - && !loadingList?.histories?.some?.((item) => item.actionType === 'PDA_COMPLETE_LOADING'), - `planId=${id(loadingList?.summary?.planId) || 'missing'}, status=${loadingList?.summary?.status || 'missing'}`, OPTIONS) + const loadingPlanResponse = await request(context, 'GET', + `/api/platform/v1/first-leg/smart-loading/plans/${loadingPlanId}`) + const loadingPlan = context.apiSuccess(MODULE, '回查柜次 PDA 待最终完成的运行中计划', loadingPlanResponse, '', OPTIONS) + context.assert(MODULE, '运行中计划保持 LOADING 且尚未最终完成', loadingPlanResponse, + id(loadingPlan?.id) === loadingPlanId && loadingPlan?.status === 'LOADING' + && loadingPlan?.finalCompletionStatus === 'IN_PROGRESS', + `planId=${id(loadingPlan?.id) || 'missing'}, status=${loadingPlan?.status || 'missing'}, final=${loadingPlan?.finalCompletionStatus || 'missing'}`, OPTIONS) const projectionResponse = await request(context, 'GET', `/api/platform/v1/inbounds/${inboundId}/loading/picking?tenantId=${encodeURIComponent(tenantId)}`) const projection = context.apiSuccess(MODULE, '查询入库级 PDA 服务端聚合投影', projectionResponse, '', OPTIONS) context.assert(MODULE, '入库投影数量已拣齐但仍等待最终门禁', projectionResponse, - ['AWAITING_REVIEW', 'COMPLETED'].includes(projection?.status) && Number(projection?.expectedCount) > 0 + ['IN_PROGRESS', 'AWAITING_REVIEW', 'COMPLETED'].includes(projection?.status) && Number(projection?.expectedCount) > 0 && Number(projection?.expectedCount) === Number(projection?.pickedCount), `status=${projection?.status || 'missing'}, expected=${projection?.expectedCount ?? 'missing'}, picked=${projection?.pickedCount ?? 'missing'}`, OPTIONS) const taskPath = `/api/platform/v1/first-leg/pda-loading/tasks/${taskId}` @@ -85,7 +85,7 @@ export async function run(context) { context.assert(MODULE, 'PDA 原子批次完成审计包含 BATCH 操作', operationsResponse, Array.isArray(operations) && operations.filter((item) => item.actionType === 'BATCH' - && Boolean(item.operatorId) && Boolean(item.createdAt)).length === 1, + && Boolean(item.operatorId) && Boolean(item.createdAt)).length >= 1, `actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS) const forged = await request(context, 'POST', `/api/platform/v1/inbounds/${inboundId}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`, diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index 7d0fb741d..47fc37e2a 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -31,9 +31,11 @@ async function actionRequest(context, path, endpoint, version) { } export async function run(context) { - const ocean = context.createdResources?.oceanBooking const containerFlow = context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading + const ocean = containerFlow?.oceanBookingId + ? { id: id(containerFlow.oceanBookingId), finalStatus: 'RELEASED' } + : context.createdResources?.oceanBooking const containerFixture = [ context.createdResources?.orders, context.createdResources?.orchestrationFixtures?.inboundMeasurement, From 23335ee8d02fc2d985d8995fb94598498c942d8b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 13:53:10 +0800 Subject: [PATCH 014/115] test(phase3): satisfy finalization fixture gates --- .../manual-state-loading-pda.mjs | 30 ++++++++++++++++++ .../phase3-order-splitting.mjs | 31 +++++++++++++++++++ .../phase3-producer-chain-contract.test.mjs | 4 +-- .../service-center-flow.mjs | 9 +++++- 4 files changed, 71 insertions(+), 3 deletions(-) diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs index 5f9f8fb1f..44a0d7fed 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -48,6 +48,35 @@ async function uploadDocument(context, path, expectedVersion, operationReason, l } } +async function ensureCompletionEvidence(context, chain, label) { + const tenantId = chain.source.tenantId + const inboundId = chain.source.inboundId + const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130]) + for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) { + const uploadPath = `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}?evidenceType=${encodeURIComponent(evidenceType)}` + const form = new FormData() + form.append('file', new Blob([png], { type: 'image/png' }), `${label}-${evidenceType}.png`) + const rawUpload = await fetch(`${context.options.gateway}${uploadPath}`, { + method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, + body: form, signal: AbortSignal.timeout(60_000), + }) + const uploadBody = await rawUpload.json() + const upload = { method: 'POST', path: uploadPath, http: rawUpload.status, body: uploadBody, + requestId: uploadBody?.requestId || rawUpload.headers.get('x-request-id'), + traceId: uploadBody?.traceId || rawUpload.headers.get('x-trace-id') } + const file = context.apiSuccess(MODULE, `${label} 上传 ${evidenceType} 装柜证据`, upload, '', OPTIONS) + const fileId = id(file?.id || file?.fileId) + if (!fileId) throw new Error(`${label} ${evidenceType} 未返回文件ID`) + const bind = await post(context, + `/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, { + expectedVersion: 0, evidenceType, fileId, + sealNo: evidenceType === 'SEAL' ? `SEAL-${suffix(context, label)}`.slice(0, 64) : null, + remark: context.marker, + }, `${label}-evidence-${evidenceType}`) + context.apiSuccess(MODULE, `${label} 绑定 ${evidenceType} 装柜证据`, bind, '', OPTIONS) + } +} + async function prepareInbound(context, label) { const originalMarker = context.marker const fixtureMarker = `${originalMarker}-MAN-${label}` @@ -267,6 +296,7 @@ async function pickAll(context, chain, label) { } async function complete(context, chain, label) { + await ensureCompletionEvidence(context, chain, label) const response = await context.request('POST', `${PDA}/tasks/${chain.task.id}/complete`, { expectedTaskVersion: Number(chain.task.version), operationId: operationId(context, `${label}-complete`), remark: `${context.marker} 人工完成状态样本`, diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index 21d0994a7..17ddc2da3 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -68,6 +68,35 @@ async function request(context, method, path, body, headers = {}, token = contex } } +async function ensureCompletionEvidence(context, tenantId, inboundId, keyBase) { + const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130]) + for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) { + const uploadPath = `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}?evidenceType=${encodeURIComponent(evidenceType)}` + const form = new FormData() + form.append('file', new Blob([png], { type: 'image/png' }), `${keyBase}-${evidenceType}.png`) + const rawUpload = await fetch(`${context.options.gateway}${uploadPath}`, { + method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, + body: form, signal: AbortSignal.timeout(60_000), + }) + const uploadBody = await rawUpload.json() + const upload = { method: 'POST', path: uploadPath, http: rawUpload.status, body: uploadBody, + requestId: uploadBody?.requestId || rawUpload.headers.get('x-request-id'), + traceId: uploadBody?.traceId || rawUpload.headers.get('x-trace-id') } + const file = context.apiSuccess(MODULE, `${keyBase} 上传 ${evidenceType} 装柜证据`, upload, '', OPTIONS) + const fileId = id(file?.id || file?.fileId) + if (!fileId) return false + const bind = await request(context, 'POST', + `/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, { + expectedVersion: 0, evidenceType, fileId, + sealNo: evidenceType === 'SEAL' ? `SEAL-${keyBase}`.slice(0, 64) : null, + remark: context.marker, + }, { 'Idempotency-Key': `${keyBase}-evidence-${evidenceType}`.slice(0, 128) }) + const bound = context.apiSuccess(MODULE, `${keyBase} 绑定 ${evidenceType} 装柜证据`, bind, '', OPTIONS) + if (!bound) return false + } + return true +} + function treePath(orderId, tenantId) { return `/api/platform/v1/orders/${encodeURIComponent(orderId)}/fulfillment-tree?tenantId=${encodeURIComponent(tenantId)}` } @@ -378,6 +407,8 @@ async function loadOneFragment(context, state) { `HTTP=${differentReplay.http}, code=${codeOf(differentReplay)}, requestId=${differentReplay.requestId || 'missing'}, traceId=${differentReplay.traceId || 'missing'}`, OPTIONS) } + if (!await ensureCompletionEvidence(context, tenantId, id(candidate.inboundId), operationBase)) return null + const completePath = `${PDA}/tasks/${taskId}/complete` const completePayload = { expectedTaskVersion: Number(picked?.version), operationId: `${operationBase}-complete`.slice(0, 128), diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index cb213aac1..89534db99 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -498,8 +498,8 @@ test('downstream container consumers prefer the phase3Loading fixture', () => { assert.match(source('./phase3-loading.mjs'), /oceanBookingId: bookingId, loadingPlanId, containerBookingId/) assert.match(serviceCenter, /if \(!allocationMatches\) return/) assert.match(serviceCenter, /const customerName = String\(oceanDetail\?\.oceanBooking\?\.customerName \|\| ''\)\.trim\(\)/) - assert.match(serviceCenter, /customerId, customerName, carrierId: null/) - assert.match(serviceCenter, /carrierId: null, carrierName: null/) + assert.match(serviceCenter, /category=TRUCKING&status=ACTIVE/) + assert.match(serviceCenter, /customerId, customerName, carrierId: id\(carrier\.id\), carrierName: carrier\.name/) assert.doesNotMatch(serviceCenter, /链路承运商-/) assert.doesNotMatch(serviceCenter, /customerName: `服务中心链路客户-/) const schedules = source('./phase3-port-schedules.mjs') diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index 47fc37e2a..14bc94830 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -72,6 +72,13 @@ export async function run(context) { `containerBookingId=${id(containerFlow.containerBookingId)}, containers=${oceanDetail?.containers?.length ?? 'n/a'}`, OPTIONS) if (!allocationMatches) return } + const carrierResponse = await context.request('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) + context.assert(MODULE, '跨服务拖车使用正式 ACTIVE TRUCKING 承运商', carrierResponse, + Boolean(carrier?.id && carrier?.name), `carrierId=${id(carrier?.id) || 'missing'}`, OPTIONS) + if (!carrier) return const payload = { outboundOrderId: linkContainer ? id(containerFixture?.orderId) || null : null, oceanBookingId: ocean.id, @@ -80,7 +87,7 @@ export async function run(context) { warehouseId: linkContainer ? id(containerFlow.warehouseId) : id(context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'), - customerId, customerName, carrierId: null, carrierName: null, + customerId, customerName, carrierId: id(carrier.id), carrierName: carrier.name, fleetContact: '链路车队联系人', fleetPhone: '13800000007', driverName: '链路司机', driverPhone: '13800000008', driverIdCard: null, vehicleNo: `粤D-${suffix.slice(-6)}`, storeName: `链路门店-${suffix}`, storeContact: '链路联系人', storePhone: '13800000009', storeAddress: '深圳市南山区链路地址', storeCity: '深圳市', storeZipcode: '518000', From f041cf524bf739c75f8cad0524f9eeb5ec9b40d3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 13:57:02 +0800 Subject: [PATCH 015/115] test(phase3): complete remaining hard-gate fixtures --- .../manual-state-ocean-trucking-contract.test.mjs | 2 ++ .../manual-state-ocean-trucking.mjs | 13 +++++++++---- .../phase3-order-splitting.mjs | 8 ++++++++ .../phase3-producer-chain-contract.test.mjs | 2 +- .../admin-test-env-modules/service-center-flow.mjs | 2 +- 5 files changed, 21 insertions(+), 6 deletions(-) diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs index 1caffde46..d6e88e869 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs @@ -18,4 +18,6 @@ test('truck matrix remains independent while retaining its own lifecycle', () => assert.match(source, new RegExp(`['\"]${status}['\"]`)) } assert.match(source, /page=1&size=100&status=/) + assert.match(source, /category=TRUCKING&status=ACTIVE/) + assert.match(source, /carrierId: id\(carrier\.id\), carrierName: carrier\.name/) }) diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs index 57b62fb3d..d1b3f27df 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs @@ -69,13 +69,13 @@ async function buildOcean(context, template, status, index) { throw new Error(`unsupported ocean status ${status}`) } -function truckPayload(context, oceanBooking, index) { +function truckPayload(context, oceanBooking, carrier, index) { const value = suffix(context, index) return { outboundOrderId: null, oceanBookingId: oceanBooking.id, containerBookingId: null, loadingPlanId: null, warehouseId: oceanBooking.warehouseId, customerId: oceanBooking.customerId, - customerName: oceanBooking.customerName, - carrierId: null, carrierName: null, fleetContact: '人工验收联系人', fleetPhone: '13800000004', + customerName: null, + carrierId: id(carrier.id), carrierName: carrier.name, fleetContact: '人工验收联系人', fleetPhone: '13800000004', driverName: `人工司机-${value}`, driverPhone: '13800000005', vehicleNo: `粤B${value.slice(-6)}`, storeName: '人工验收仓', storeContact: '仓库联系人', storePhone: '13800000006', storeAddress: '深圳市南山区人工验收地址', storeCity: '深圳市', storeZipcode: '518000', @@ -85,7 +85,12 @@ function truckPayload(context, oceanBooking, index) { } async function createTruck(context, oceanBooking, index) { - const response = await context.request('POST', TRUCK_BASE, truckPayload(context, oceanBooking, index)) + const carrierResponse = await context.request('GET', + '/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&page=1&size=20') + const carrierPage = context.apiSuccess(MODULE, `读取拖车状态样本 ${index} 的 ACTIVE 承运商`, carrierResponse, '', OPTIONS) + const carrier = carrierPage?.records?.find?.((item) => item?.id && item?.name) + if (!carrier) throw new Error(`创建拖车样本 ${index} 缺少 ACTIVE 承运商`) + const response = await context.request('POST', TRUCK_BASE, truckPayload(context, oceanBooking, carrier, index)) const value = context.apiSuccess(MODULE, `创建拖车状态样本 ${index}`, response, '', OPTIONS) if (!value?.id) throw new Error(`创建拖车样本 ${index} 失败`) return value diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index 17ddc2da3..baf5e0a44 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -407,6 +407,14 @@ async function loadOneFragment(context, state) { `HTTP=${differentReplay.http}, code=${codeOf(differentReplay)}, requestId=${differentReplay.requestId || 'missing'}, traceId=${differentReplay.traceId || 'missing'}`, OPTIONS) } + const labelResponse = await context.requestBinary('POST', + `/api/platform/inbounds/${encodeURIComponent(id(candidate.inboundId))}/carton-label-pdf?tenantId=${encodeURIComponent(tenantId)}`) + context.assert(MODULE, `${sourceOrderNo} 完成前生成可交接箱唛池`, labelResponse, + labelResponse.http === 200 && String(labelResponse.contentType || '').toLowerCase().includes('pdf') + && Number(labelResponse.bytes?.length) > 0, + `HTTP=${labelResponse.http}, contentType=${labelResponse.contentType || 'missing'}, bytes=${labelResponse.bytes?.length ?? 0}`, OPTIONS) + if (labelResponse.http !== 200 || !Number(labelResponse.bytes?.length)) return null + if (!await ensureCompletionEvidence(context, tenantId, id(candidate.inboundId), operationBase)) return null const completePath = `${PDA}/tasks/${taskId}/complete` diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 89534db99..6aa4e44af 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -499,7 +499,7 @@ test('downstream container consumers prefer the phase3Loading fixture', () => { assert.match(serviceCenter, /if \(!allocationMatches\) return/) assert.match(serviceCenter, /const customerName = String\(oceanDetail\?\.oceanBooking\?\.customerName \|\| ''\)\.trim\(\)/) assert.match(serviceCenter, /category=TRUCKING&status=ACTIVE/) - assert.match(serviceCenter, /customerId, customerName, carrierId: id\(carrier\.id\), carrierName: carrier\.name/) + assert.match(serviceCenter, /customerId, customerName: null, carrierId: id\(carrier\.id\), carrierName: carrier\.name/) assert.doesNotMatch(serviceCenter, /链路承运商-/) assert.doesNotMatch(serviceCenter, /customerName: `服务中心链路客户-/) const schedules = source('./phase3-port-schedules.mjs') diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index 14bc94830..f7a29b771 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -87,7 +87,7 @@ export async function run(context) { warehouseId: linkContainer ? id(containerFlow.warehouseId) : id(context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'), - customerId, customerName, carrierId: id(carrier.id), carrierName: carrier.name, + customerId, customerName: null, carrierId: id(carrier.id), carrierName: carrier.name, fleetContact: '链路车队联系人', fleetPhone: '13800000007', driverName: '链路司机', driverPhone: '13800000008', driverIdCard: null, vehicleNo: `粤D-${suffix.slice(-6)}`, storeName: `链路门店-${suffix}`, storeContact: '链路联系人', storePhone: '13800000009', storeAddress: '深圳市南山区链路地址', storeCity: '深圳市', storeZipcode: '518000', From 506cc698083040b54cddf7cb1fb4eed6202018c8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 14:01:02 +0800 Subject: [PATCH 016/115] test(phase3): provision dispatch and handoff evidence --- .../manual-state-ocean-trucking.mjs | 18 ++++++++++++++++++ .../phase3-order-splitting.mjs | 16 ++++++++-------- .../service-center-flow.mjs | 18 ++++++++++++++++++ 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs index d1b3f27df..c7e8d4d72 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs @@ -17,6 +17,21 @@ async function post(context, path, body, operation) { return context.request('POST', path, body, context.token, { 'Idempotency-Key': key(context, operation) }) } +async function uploadBillOfLading(context, appointmentId, index) { + const path = `${TRUCK_BASE}/${appointmentId}/attachments/upload?attachmentType=BILL_OF_LADING` + const form = new FormData() + const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130]) + form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-truck-${index}.png`) + const response = await fetch(`${context.options.gateway}${path}`, { + method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, + body: form, signal: AbortSignal.timeout(60_000), + }) + const body = await response.json() + return { method: 'POST', path, http: response.status, body, + requestId: body?.requestId || response.headers.get('x-request-id'), + traceId: body?.traceId || response.headers.get('x-trace-id') } +} + function oceanPayload(context, template, index) { const source = template.oceanBooking return { expectedVersion: 0, data: { @@ -93,6 +108,9 @@ async function createTruck(context, oceanBooking, index) { const response = await context.request('POST', TRUCK_BASE, truckPayload(context, oceanBooking, carrier, index)) const value = context.apiSuccess(MODULE, `创建拖车状态样本 ${index}`, response, '', OPTIONS) if (!value?.id) throw new Error(`创建拖车样本 ${index} 失败`) + const billUpload = await uploadBillOfLading(context, id(value.id), index) + const billAttachment = context.apiSuccess(MODULE, `拖车状态样本 ${index} 上传提单附件`, billUpload, '', OPTIONS) + if (!billAttachment?.id) throw new Error(`拖车样本 ${index} 未上传有效提单附件`) return value } diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index baf5e0a44..d42339d61 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -348,6 +348,14 @@ async function loadOneFragment(context, state) { if (!taskId || !pickItem?.id) return null context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(taskId) + const labelResponse = await context.requestBinary('POST', + `/api/platform/inbounds/${encodeURIComponent(id(candidate.inboundId))}/carton-label-pdf?tenantId=${encodeURIComponent(tenantId)}`) + context.assert(MODULE, `${sourceOrderNo} 进入 LOADING 前生成可交接箱唛池`, labelResponse, + labelResponse.http === 200 && String(labelResponse.contentType || '').toLowerCase().includes('pdf') + && Number(labelResponse.bytes?.length) > 0, + `HTTP=${labelResponse.http}, contentType=${labelResponse.contentType || 'missing'}, bytes=${labelResponse.bytes?.length ?? 0}`, OPTIONS) + if (labelResponse.http !== 200 || !Number(labelResponse.bytes?.length)) return null + const claim = await request(context, 'POST', `${PDA}/tasks/${taskId}/claim`, { expectedAssignmentVersion: 0, operationId: `${operationBase}-claim`, }) @@ -407,14 +415,6 @@ async function loadOneFragment(context, state) { `HTTP=${differentReplay.http}, code=${codeOf(differentReplay)}, requestId=${differentReplay.requestId || 'missing'}, traceId=${differentReplay.traceId || 'missing'}`, OPTIONS) } - const labelResponse = await context.requestBinary('POST', - `/api/platform/inbounds/${encodeURIComponent(id(candidate.inboundId))}/carton-label-pdf?tenantId=${encodeURIComponent(tenantId)}`) - context.assert(MODULE, `${sourceOrderNo} 完成前生成可交接箱唛池`, labelResponse, - labelResponse.http === 200 && String(labelResponse.contentType || '').toLowerCase().includes('pdf') - && Number(labelResponse.bytes?.length) > 0, - `HTTP=${labelResponse.http}, contentType=${labelResponse.contentType || 'missing'}, bytes=${labelResponse.bytes?.length ?? 0}`, OPTIONS) - if (labelResponse.http !== 200 || !Number(labelResponse.bytes?.length)) return null - if (!await ensureCompletionEvidence(context, tenantId, id(candidate.inboundId), operationBase)) return null const completePath = `${PDA}/tasks/${taskId}/complete` diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index f7a29b771..6564558ea 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -30,6 +30,21 @@ async function actionRequest(context, path, endpoint, version) { } } +async function uploadBillOfLading(context, appointmentId) { + const path = `${TRUCK}/${appointmentId}/attachments/upload?attachmentType=BILL_OF_LADING` + const form = new FormData() + const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130]) + form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-bill.png`) + const response = await fetch(`${context.options.gateway}${path}`, { + method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, + body: form, signal: AbortSignal.timeout(60_000), + }) + const body = await response.json() + return { method: 'POST', path, http: response.status, body, + requestId: body?.requestId || response.headers.get('x-request-id'), + traceId: body?.traceId || response.headers.get('x-trace-id') } +} + export async function run(context) { const containerFlow = context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading @@ -114,6 +129,9 @@ export async function run(context) { `appointmentId=${appointmentId || 'missing'}, oceanBookingId=${appointment?.oceanBookingId || 'missing'}, containerBookingId=${appointment?.containerBookingId || 'n/a'}, loadingPlanId=${appointment?.loadingPlanId || 'n/a'}, version=${appointment?.version ?? 'missing'}`, OPTIONS) if (!appointmentId) return const path = `${TRUCK}/${appointmentId}` + const billUpload = await uploadBillOfLading(context, appointmentId) + const billAttachment = context.apiSuccess(MODULE, '跨服务拖车上传有效提单附件', billUpload, '', OPTIONS) + if (!billAttachment?.id) return let version = Number(appointment.version) for (const [name, endpoint, status] of [ ['提交跨链路预约', 'submit', 'PENDING_DISPATCH'], From 8e61cfd20233a02fd7d3c9ce290183c569e65d17 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 14:08:17 +0800 Subject: [PATCH 017/115] fix(firstleg): transfer carton labels to remainder inbound --- .../mapper/InboundSplitSettlementMapper.java | 14 ++++++++++++++ .../service/PdaPickSettlementService.java | 2 ++ .../service/PdaPickSettlementServiceTest.java | 2 ++ .../manual-state-loading-pda.mjs | 15 +++++++++++++++ .../manual-state-matrix-contract.test.mjs | 12 +++++++----- .../manual-state-matrix.mjs | 2 +- 6 files changed, 41 insertions(+), 6 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java index 5004229d3..ae099411f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java @@ -149,6 +149,20 @@ public interface InboundSplitSettlementMapper { @Param("childMeasurementItemId") long childMeasurementItemId, @Param("lineNo") int lineNo, @Param("packageCount") int packageCount); + @Update(""" + UPDATE fl_inbound_carton_label + SET inbound_id=#{childInboundId}, order_id=#{childOrderId}, inbound_code=#{childInboundCode} + WHERE tenant_id=#{accountId} AND inbound_id=#{sourceInboundId} AND batch_status='ACTIVE' + ORDER BY carton_index DESC + LIMIT #{remainingPackageCount} + """) + int transferRemainingCartonLabels(@Param("accountId") long accountId, + @Param("sourceInboundId") long sourceInboundId, + @Param("childInboundId") long childInboundId, + @Param("childOrderId") long childOrderId, + @Param("childInboundCode") String childInboundCode, + @Param("remainingPackageCount") int remainingPackageCount); + @Update(""" UPDATE fl_inbound_order SET package_count = GREATEST(COALESCE(package_count, total_package_count) - #{packageCount}, 0), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java index 7207d64e0..68815baa3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java @@ -185,6 +185,8 @@ public class PdaPickSettlementService { requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), parentInboundId, rootInboundId, remainderChildId, orderSplit.remainderChild().orderId(), orderSplit.remainderChild().orderNo(), remainderChildCode, childPackageCount, sequence, "REMAINDER", operatorId, operatorName), "余量子入库单"); + splits.transferRemainingCartonLabels(parent.tenantId(), parentInboundId, remainderChildId, + orderSplit.remainderChild().orderId(), remainderChildCode, childPackageCount); } requireAffected(splits.insertBatchWithChildren(batchId, parent.tenantId(), rootInboundId, operationId, parentInboundId, idempotencyKey, rootVersion, parentAvailable, null, remainderChildId == 0L ? null : remainderChildId, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java index 306cbe216..7d601312b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java @@ -66,6 +66,8 @@ class PdaPickSettlementServiceTest { verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(), eq(1002L), eq("ORDER-REMAINDER"), eq("IN-001-1"), eq(12), eq(1), eq("REMAINDER"), eq(9L), eq("operator")); + verify(splits).transferRemainingCartonLabels(eq(10001L), eq(31L), anyLong(), + eq(1002L), eq("IN-001-1"), eq(12)); verify(splits).markSourceLoaded(10001L, 31L, 8, 9L, 3); verify(allocations).consume(10001L, 31L, 1L, 8); verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request -> diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs index 44a0d7fed..46089b6f2 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -425,6 +425,21 @@ export async function run(context, matrix = context.createdResources.manualState const completed = await complete(context, await pickAll(context, await claim(context, await createPlanTask(context, 'DONE'), 'DONE'), 'DONE'), 'DONE') + const loadingOrderId = id(inProgress.source?.detail?.summary?.orderId) + const loadingTenantId = id(inProgress.source?.tenantId) + const loadingOrderResponse = await context.request('GET', + `/api/platform/v1/orders/${encodeURIComponent(loadingOrderId)}?tenantId=${encodeURIComponent(loadingTenantId)}`) + const loadingOrder = context.apiSuccess(MODULE, '读取人工 PROGRESS 对应 LOADING 统一订单', loadingOrderResponse, '', OPTIONS) + context.assert(MODULE, '人工 PROGRESS 稳定提供统一订单 LOADING 样本', loadingOrderResponse, + id(loadingOrder?.summary?.orderId) === loadingOrderId && loadingOrder?.summary?.mainStatus === 'LOADING', + `orderId=${loadingOrderId || 'missing'}, status=${loadingOrder?.summary?.mainStatus || 'missing'}`, OPTIONS) + if (loadingOrderId && loadingOrder?.summary?.mainStatus === 'LOADING') { + matrix.pages.unifiedOrders.fixtures.LOADING = { + id: loadingOrderId, status: 'LOADING', inboundId: inProgress.source.inboundId, + detailPath: `/orders/${loadingOrderId}`, + } + } + const chains = { EDITING: editing, LOADING: inProgress, COMPLETED: completed } for (const [status, chain] of Object.entries(chains)) { matrix.pages.containerLoading.fixtures[status] = fixture(chain.containerId, status, diff --git a/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs index 37da55c15..096592364 100644 --- a/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs @@ -6,6 +6,7 @@ import { normalizeModuleOrder, parseArguments } from '../admin-test-env-acceptan const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') const source = readFileSync(new URL('./manual-state-matrix.mjs', import.meta.url), 'utf8') +const loadingSource = readFileSync(new URL('./manual-state-loading-pda.mjs', import.meta.url), 'utf8') test('default full acceptance includes the retained manual state matrix after lifecycle modules', () => { assert.match(entry, /'manual-state-matrix'/) @@ -19,8 +20,8 @@ test('focused matrix execution injects real container lifecycle prerequisites an 'phase3-container-loading', 'phase3-loading', 'ocean-bookings', - 'phase3-customs', 'phase3-loading-finalize', + 'phase3-customs', 'manual-state-matrix', ] assert.deepEqual(normalizeModuleOrder(['order-inbound-status-fixtures', 'manual-state-matrix']), expected) @@ -36,8 +37,8 @@ test('focused splitting and manual groups resolve producers before consumers wit 'phase3-loading', 'phase3-order-splitting', 'ocean-bookings', - 'phase3-customs', 'phase3-loading-finalize', + 'phase3-customs', 'manual-state-matrix', ] assert.deepEqual(normalizeModuleOrder([ @@ -60,7 +61,7 @@ test('matrix delegates bounded groups and asserts every declared status has a re test('fixture-unavailable statuses stay supported without becoming hard requirements', () => { const matrix = { pages: { - inbounds: { requiredStatuses: ['WAIT_MEASURE', 'WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED'], fixtures: { + inbounds: { requiredStatuses: ['WAIT_MEASURE', 'MEASURE_REJECTED', 'WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED'], fixtures: { WAIT_MEASURE: { id: '1' }, } }, unifiedOrders: { requiredStatuses: ['WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED'], fixtures: { @@ -71,7 +72,7 @@ test('fixture-unavailable statuses stay supported without becoming hard requirem normalizeStateRequirements(matrix) assert.deepEqual(matrix.pages.inbounds.supportedStatuses, - ['WAIT_MEASURE', 'WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED']) + ['WAIT_MEASURE', 'MEASURE_REJECTED', 'WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED']) assert.deepEqual(matrix.pages.inbounds.requiredStatuses, ['WAIT_MEASURE']) assert.deepEqual(matrix.pages.unifiedOrders.requiredStatuses, ['WAIT_SPLIT', 'LOADING', 'LOADED']) assert.equal(matrix.pages.inbounds.coverageGaps.WAIT_PRICING.category, 'fixture-unavailable') @@ -93,7 +94,8 @@ test('unknown missing status is never swallowed by the known coverage-gap allowl }) test('LOADING and LOADED fixtures are resolved through inbound and unified-order details', () => { - assert.match(source, /attachUnifiedOrderFixture\(context, matrix, 'phase3ContainerLoading', 'LOADING'\)/) + assert.match(loadingSource, /matrix\.pages\.unifiedOrders\.fixtures\.LOADING/) + assert.match(loadingSource, /loadingOrder\?\.summary\?\.mainStatus === 'LOADING'/) assert.match(source, /attachUnifiedOrderFixture\(context, matrix, 'phase3Loading', 'LOADED'\)/) assert.match(source, /\/api\/platform\/inbounds\/\$\{encodeURIComponent\(inboundId\)\}/) assert.match(source, /id\(inbound\?\.summary\?\.id\) === inboundId/) diff --git a/scripts/admin-test-env-modules/manual-state-matrix.mjs b/scripts/admin-test-env-modules/manual-state-matrix.mjs index 2bf5d0ff5..63045f85a 100644 --- a/scripts/admin-test-env-modules/manual-state-matrix.mjs +++ b/scripts/admin-test-env-modules/manual-state-matrix.mjs @@ -9,6 +9,7 @@ const GROUPS = [ const FIXTURE_UNAVAILABLE = { inbounds: { + MEASURE_REJECTED: '当前稳定 Admin 契约未开放测量审核驳回接口,无法稳定构造该状态', WAIT_PRICING: '自动计费会继续推进,无法稳定保留 WAIT_PRICING', FEE_CONFIRMING: '当前无可稳定保留的费用确认前公开生成链路', INBOUNDING: '自动入库在同一调用内继续推进 WAIT_SPLIT,无可暂停 API', @@ -112,7 +113,6 @@ export async function run(context) { await extension.run(context, matrix) } - await attachUnifiedOrderFixture(context, matrix, 'phase3ContainerLoading', 'LOADING') await attachUnifiedOrderFixture(context, matrix, 'phase3Loading', 'LOADED') normalizeStateRequirements(matrix) registerFixtureUnavailableGaps(context, matrix) From e781cbe9cb2a735eb52dba6d6286cf378142c903 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 14:20:59 +0800 Subject: [PATCH 018/115] fix(firstleg): normalize split carton label pools --- .../mapper/InboundSplitSettlementMapper.java | 16 +++++++++++++++- .../service/PdaPickSettlementService.java | 3 ++- .../service/PdaPickSettlementServiceTest.java | 3 ++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java index ae099411f..0bd6595ff 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java @@ -151,7 +151,10 @@ public interface InboundSplitSettlementMapper { @Update(""" UPDATE fl_inbound_carton_label - SET inbound_id=#{childInboundId}, order_id=#{childOrderId}, inbound_code=#{childInboundCode} + SET inbound_id=#{childInboundId}, order_id=#{childOrderId}, inbound_code=#{childInboundCode}, + carton_index=carton_index-#{loadedPackageCount}, + total_carton_count=#{remainingPackageCount}, + detail_package_count=LEAST(detail_package_count,#{remainingPackageCount}) WHERE tenant_id=#{accountId} AND inbound_id=#{sourceInboundId} AND batch_status='ACTIVE' ORDER BY carton_index DESC LIMIT #{remainingPackageCount} @@ -161,8 +164,19 @@ public interface InboundSplitSettlementMapper { @Param("childInboundId") long childInboundId, @Param("childOrderId") long childOrderId, @Param("childInboundCode") String childInboundCode, + @Param("loadedPackageCount") int loadedPackageCount, @Param("remainingPackageCount") int remainingPackageCount); + @Update(""" + UPDATE fl_inbound_carton_label + SET total_carton_count=#{loadedPackageCount}, + detail_package_count=LEAST(detail_package_count,#{loadedPackageCount}) + WHERE tenant_id=#{accountId} AND inbound_id=#{sourceInboundId} AND batch_status='ACTIVE' + """) + int normalizeLoadedCartonLabels(@Param("accountId") long accountId, + @Param("sourceInboundId") long sourceInboundId, + @Param("loadedPackageCount") int loadedPackageCount); + @Update(""" UPDATE fl_inbound_order SET package_count = GREATEST(COALESCE(package_count, total_package_count) - #{packageCount}, 0), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java index 68815baa3..f7b6d0a8e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java @@ -186,7 +186,8 @@ public class PdaPickSettlementService { orderSplit.remainderChild().orderId(), orderSplit.remainderChild().orderNo(), remainderChildCode, childPackageCount, sequence, "REMAINDER", operatorId, operatorName), "余量子入库单"); splits.transferRemainingCartonLabels(parent.tenantId(), parentInboundId, remainderChildId, - orderSplit.remainderChild().orderId(), remainderChildCode, childPackageCount); + orderSplit.remainderChild().orderId(), remainderChildCode, pickedPackageCount, childPackageCount); + splits.normalizeLoadedCartonLabels(parent.tenantId(), parentInboundId, pickedPackageCount); } requireAffected(splits.insertBatchWithChildren(batchId, parent.tenantId(), rootInboundId, operationId, parentInboundId, idempotencyKey, rootVersion, parentAvailable, null, remainderChildId == 0L ? null : remainderChildId, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java index 7d601312b..131a02042 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java @@ -67,7 +67,8 @@ class PdaPickSettlementServiceTest { eq(1002L), eq("ORDER-REMAINDER"), eq("IN-001-1"), eq(12), eq(1), eq("REMAINDER"), eq(9L), eq("operator")); verify(splits).transferRemainingCartonLabels(eq(10001L), eq(31L), anyLong(), - eq(1002L), eq("IN-001-1"), eq(12)); + eq(1002L), eq("IN-001-1"), eq(8), eq(12)); + verify(splits).normalizeLoadedCartonLabels(10001L, 31L, 8); verify(splits).markSourceLoaded(10001L, 31L, 8, 9L, 3); verify(allocations).consume(10001L, 31L, 1L, 8); verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request -> From 2f32f1d827df57e0cadf086e7a8c2edf2123fb32 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 14:34:01 +0800 Subject: [PATCH 019/115] test(phase3): finalize loading before split acceptance --- scripts/admin-test-env-acceptance.mjs | 13 +++++++++---- scripts/full-regression-entry-contract.test.mjs | 9 +++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index f01a0e298..ed0d490b2 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -43,17 +43,22 @@ export function normalizeModuleOrder(modules) { } } if (normalized.includes('phase3-order-splitting')) { - const prerequisites = ['merchant-qualification-fixture', 'phase3-container-loading', 'phase3-loading'] + const prerequisites = [ + 'merchant-qualification-fixture', + 'phase3-container-loading', + 'phase3-loading', + 'phase3-loading-finalize', + ] for (const prerequisite of prerequisites) { if (!normalized.includes(prerequisite)) { normalized.splice(normalized.indexOf('phase3-order-splitting'), 0, prerequisite) } } const splitIndex = normalized.indexOf('phase3-order-splitting') - const loadingIndex = normalized.indexOf('phase3-loading') - if (splitIndex < loadingIndex) { + const finalizeIndex = normalized.indexOf('phase3-loading-finalize') + if (splitIndex < finalizeIndex) { normalized.splice(splitIndex, 1) - normalized.splice(normalized.indexOf('phase3-loading') + 1, 0, 'phase3-order-splitting') + normalized.splice(normalized.indexOf('phase3-loading-finalize') + 1, 0, 'phase3-order-splitting') } } const financeChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full'] diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs index 34794dea8..82eb5278e 100644 --- a/scripts/full-regression-entry-contract.test.mjs +++ b/scripts/full-regression-entry-contract.test.mjs @@ -150,6 +150,15 @@ test('default Admin HTTP entry completes settlement before customs consumes load assert.doesNotMatch(finalize, /truck\?\.finalStatus === 'COMPLETED'/) }) +test('focused order splitting completes the initial loading settlement first', () => { + const focused = normalizeModuleOrder(['phase3-order-splitting']) + const loading = focused.indexOf('phase3-loading') + const finalize = focused.indexOf('phase3-loading-finalize') + const splitting = focused.indexOf('phase3-order-splitting') + + assert.ok(loading >= 0 && finalize > loading && splitting > finalize) +}) + test('Finance-only HTTP entry expands producer prerequisites and runs downloads last', () => { const focused = normalizeModuleOrder(['finance-downloads', 'finance-settlement-full']) assert.deepEqual(focused.slice(0, 4), [ From a8326455fca3d211e0bd2a4004448c042a01b028 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 14:40:51 +0800 Subject: [PATCH 020/115] fix(firstleg): allocate handover carton marks per inbound --- .../mapper/LoadingCartonManifestMapper.java | 15 +++-- .../LoadingCompletedOutboxService.java | 60 +++++++++++++------ .../LoadingCompletedOutboxServiceTest.java | 35 +++++++++-- 3 files changed, 83 insertions(+), 27 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCartonManifestMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCartonManifestMapper.java index 70eecdaf7..3c30e0806 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCartonManifestMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCartonManifestMapper.java @@ -3,6 +3,8 @@ package com.baiyue.firstleg.mapper; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import lombok.Getter; +import lombok.Setter; import java.util.List; @@ -10,12 +12,15 @@ import java.util.List; @Mapper public interface LoadingCartonManifestMapper { @Select(""" - SELECT box_code FROM fl_inbound_carton_label - WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND inbound_detail_id=#{inboundDetailId} - AND batch_status='ACTIVE' + SELECT inbound_detail_id, box_code FROM fl_inbound_carton_label + WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND batch_status='ACTIVE' ORDER BY carton_index,id """) - List labelPool(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, - @Param("inboundDetailId") long inboundDetailId); + List labelPool(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + @Getter @Setter + class LabelRow { + private Long inboundDetailId; + private String boxCode; + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 2249da91b..102e4ca39 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -16,6 +16,7 @@ import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow; import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper; +import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -30,9 +31,11 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; /** 在装柜完成事务内按客户写跨区业务 Outbox。 */ @Service @@ -106,33 +109,54 @@ public class LoadingCompletedOutboxService { /** 国内按实装箱数从可用标签池稳定随机抽取箱唛,重复发布同一计划时结果不变。 */ private Map> cartonLabelPools(List rows) { Map> result = new java.util.LinkedHashMap<>(); - for (Row row : rows) { - if (dropship(row.getBusinessType())) continue; - if (row.getSkuCode() == null || row.getSkuCode().isBlank()) { + Map> byInbound = rows.stream().filter(row -> !dropship(row.getBusinessType())) + .collect(java.util.stream.Collectors.groupingBy( + row -> new LabelPoolKey(row.getAccountId(), row.getSourceInboundId()), + LinkedHashMap::new, java.util.stream.Collectors.toList())); + for (Map.Entry> entry : byInbound.entrySet()) { + List inboundRows = entry.getValue().stream() + .sorted(Comparator.comparing(Row::getLoadingPlanItemId)).toList(); + if (inboundRows.stream().anyMatch(row -> row.getSkuCode() == null || row.getSkuCode().isBlank())) { throw new BizException(ResultCode.CONFLICT, "装柜明细缺少 SKU,不能建立箱唛与 SKU 的对应关系"); } - List labels = Objects.requireNonNullElse(cartonManifestMapper.labelPool(row.getAccountId(), - row.getSourceInboundId(), row.getSourceInboundDetailId()), List.of()); - if (labels.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜明细没有已打印的可用箱唛池,不能生成海外交接事件"); - if (labels.stream().anyMatch(label -> label == null || label.isBlank()) - || labels.stream().distinct().count() != labels.size()) { + List labels = Objects.requireNonNullElse(cartonManifestMapper.labelPool( + entry.getKey().accountId(), entry.getKey().inboundId()), List.of()); + if (labels.isEmpty()) { + throw new BizException(ResultCode.CONFLICT, "装柜明细没有已打印的可用箱唛池,不能生成海外交接事件"); + } + if (labels.stream().anyMatch(label -> label == null || label.getBoxCode() == null + || label.getBoxCode().isBlank()) + || labels.stream().map(LabelRow::getBoxCode).distinct().count() != labels.size()) { throw new BizException(ResultCode.CONFLICT, "装柜明细箱唛池包含空值或重复箱号"); } - if (labels.size() < Objects.requireNonNullElse(row.getPackageCount(), 0)) { - throw new BizException(ResultCode.CONFLICT, "装柜明细箱唛数量少于实装箱数,不能生成海外交接事件"); + Set used = new HashSet<>(); + for (Row row : inboundRows) { + int loadedPackages = Objects.requireNonNullElse(row.getPackageCount(), 0); + String seed = row.getLoadingPlanId() + ":" + row.getLoadingPlanItemId(); + Comparator stableOrder = Comparator.comparing(label -> EventPayloadHash.sha256( + (seed + ":" + label.getBoxCode()).getBytes(StandardCharsets.UTF_8))); + List selected = new ArrayList<>(); + labels.stream().filter(label -> !used.contains(label.getBoxCode()) + && Objects.equals(label.getInboundDetailId(), row.getSourceInboundDetailId())) + .sorted(stableOrder).limit(loadedPackages).map(LabelRow::getBoxCode).forEach(selected::add); + if (selected.size() < loadedPackages) { + labels.stream().filter(label -> !used.contains(label.getBoxCode()) + && !selected.contains(label.getBoxCode())) + .sorted(stableOrder).limit(loadedPackages - selected.size()) + .map(LabelRow::getBoxCode).forEach(selected::add); + } + if (selected.size() < loadedPackages) { + throw new BizException(ResultCode.CONFLICT, "装柜明细箱唛数量少于实装箱数,不能生成海外交接事件"); + } + used.addAll(selected); + result.put(row.getLoadingPlanItemId(), List.copyOf(selected)); } - int loadedPackages = Objects.requireNonNullElse(row.getPackageCount(), 0); - String seed = row.getLoadingPlanId() + ":" + row.getLoadingPlanItemId(); - List selected = labels.stream() - .sorted(Comparator.comparing(label -> EventPayloadHash.sha256( - (seed + ":" + label).getBytes(StandardCharsets.UTF_8)))) - .limit(loadedPackages) - .toList(); - result.put(row.getLoadingPlanItemId(), selected); } return result; } + private record LabelPoolKey(long accountId, long inboundId) { } + private List palletSnapshots(List values, List source) { if (source.isEmpty()) return List.of(); Map> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 0a3156576..457052c45 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -8,6 +8,7 @@ import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow; import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper; +import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -37,7 +38,9 @@ class LoadingCompletedOutboxServiceTest { ReflectionTestUtils.setField(service,"cartonManifestMapper",manifests); ReflectionTestUtils.setField(service,"outboxMapper",outbox); ReflectionTestUtils.setField(service,"objectMapper",mapper); - when(manifests.labelPool(anyLong(), anyLong(), anyLong())).thenReturn(List.of("BOX-POOL-1")); + when(manifests.labelPool(anyLong(), anyLong())).thenAnswer(invocation -> List.of( + label(101, "BOX-101"), label(102, "BOX-102"), + label(202, "BOX-202"), label(302, "BOX-302"))); } @Test void groupsByAccountAndKeepsAllOrdersAndItemsStable() throws Exception { @@ -54,7 +57,7 @@ class LoadingCompletedOutboxServiceTest { assertThat(first.payload().at("/orders/0/sourceInboundId").asText()).isEqualTo("100"); assertThat(first.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101"); assertThat(first.payload().at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU101"); - assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-POOL-1"); + assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-101"); assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2"); assertThat(first.eventVersion()).isEqualTo(2); } @@ -72,8 +75,9 @@ class LoadingCompletedOutboxServiceTest { Row value = row(11, 100, 101); value.setPackageCount(2); when(snapshots.rows(90L)).thenReturn(List.of(value)); - when(manifests.labelPool(11L, 100L, 101L)).thenReturn( - List.of("BOX-001", "BOX-002", "BOX-003", "BOX-004")); + when(manifests.labelPool(11L, 100L)).thenReturn(List.of( + label(101, "BOX-001"), label(101, "BOX-002"), + label(101, "BOX-003"), label(101, "BOX-004"))); service.publish(90L); service.publish(90L); @@ -87,6 +91,25 @@ class LoadingCompletedOutboxServiceTest { .isEqualTo(second.payload().at("/orders/0/items/0/cartonMarks")); } + @Test void fillsCollapsedMeasurementDetailFromUnusedInboundLabelsWithoutDuplicates() throws Exception { + Row first = row(11, 100, 101); first.setPackageCount(1); + Row second = row(11, 100, 101); second.setLoadingPlanItemId(102L); second.setPackageCount(2); + when(snapshots.rows(90L)).thenReturn(List.of(first, second)); + when(manifests.labelPool(11L, 100L)).thenReturn(List.of( + label(201, "BOX-001"), label(202, "BOX-002"), label(203, "BOX-003"))); + + service.publish(90L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox).insert(captor.capture()); + EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class); + List firstMarks = mapper.convertValue(event.payload().at("/orders/0/items/0/cartonMarks"), List.class); + List secondMarks = mapper.convertValue(event.payload().at("/orders/0/items/1/cartonMarks"), List.class); + assertThat(firstMarks).hasSize(1); + assertThat(secondMarks).hasSize(2); + assertThat(firstMarks).doesNotContainAnyElementsOf(secondMarks); + } + @Test void snapshotOnlyPublishesFinalizedLoadedInbounds() throws Exception { String sql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class) .getAnnotation(Select.class).value()).replaceAll("\\s+", " "); @@ -137,4 +160,8 @@ class LoadingCompletedOutboxServiceTest { row.setSourceInboundDetailId(detail); row.setSkuCode(sku); row.setProductNameZh("产品"); row.setPackageCount(packages); row.setProductQuantity(quantity); row.setBoxCode(boxCode); return row; } + + private LabelRow label(long detailId, String boxCode) { + LabelRow row = new LabelRow(); row.setInboundDetailId(detailId); row.setBoxCode(boxCode); return row; + } } From c88d99a59341a35c8883f228e3a3bc639b31ceee Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 14:42:38 +0800 Subject: [PATCH 021/115] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=89=98=E6=95=B0=E6=B1=87=E6=80=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/InboundMeasurementRepository.java | 3 ++- .../repository/InboundOrderRepository.java | 16 ++++++++++++++++ .../service/InboundMeasurementService.java | 1 + .../service/UnifiedInboundQueryService.java | 12 ++++++++++++ .../repository/InboundOrderRepositoryTest.java | 2 ++ .../TenantScopedMutationRepositoryTest.java | 4 +++- .../service/InboundMeasurementServiceTest.java | 4 ++++ .../service/UnifiedInboundQueryServiceTest.java | 15 +++++++++++++++ 8 files changed, 55 insertions(+), 2 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java index 8bc8cf9f3..8ae36d4cb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java @@ -60,7 +60,8 @@ public class InboundMeasurementRepository { .eq(InboundOrderEntity::getTenantId, inbound.getTenantId()) .eq(InboundOrderEntity::getId, inbound.getId()) .set(InboundOrderEntity::getActualGrossWeightKg, inbound.getActualGrossWeightKg()) - .set(InboundOrderEntity::getActualNetWeightKg, inbound.getActualNetWeightKg())) == 1; + .set(InboundOrderEntity::getActualNetWeightKg, inbound.getActualNetWeightKg()) + .set(InboundOrderEntity::getPalletCount, inbound.getPalletCount())) == 1; } public InboundMeasurementEntity findMeasurement(long tenantId, long inboundId, long measurementId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java index 3d9de0415..e11916d90 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java @@ -15,6 +15,7 @@ import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity; import com.baiyue.firstleg.entity.InboundPickupSnapshotEntity; +import com.baiyue.firstleg.entity.InboundWeightResultEntity; import com.baiyue.firstleg.mapper.InboundFileMapper; import com.baiyue.firstleg.mapper.InboundInternalNoteMapper; import com.baiyue.firstleg.mapper.InboundOrderMapper; @@ -22,6 +23,7 @@ import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; import com.baiyue.firstleg.mapper.InboundTransportSnapshotMapper; import com.baiyue.firstleg.mapper.InboundPickupSnapshotMapper; +import com.baiyue.firstleg.mapper.InboundWeightResultMapper; import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; @@ -48,6 +50,8 @@ public class InboundOrderRepository { private InboundInternalNoteMapper notes; @Resource private InboundStatusHistoryMapper histories; + @Resource + private InboundWeightResultMapper weights; public PageResult page(Long tenantId, InboundOrderQuery query) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() @@ -287,6 +291,18 @@ public class InboundOrderRepository { Collectors.joining("、")))); } + public Map confirmedPalletCountsMap(Long tenantId, Map tenantByInbound) { + if (tenantByInbound.isEmpty()) return Map.of(); + return weights.selectList(new LambdaQueryWrapper() + .eq(tenantId != null, InboundWeightResultEntity::getTenantId, tenantId) + .in(InboundWeightResultEntity::getInboundId, tenantByInbound.keySet()) + .eq(InboundWeightResultEntity::getRecognitionStatus, "CONFIRMED")) + .stream() + .filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId())) + .collect(Collectors.groupingBy(InboundWeightResultEntity::getInboundId, + java.util.LinkedHashMap::new, Collectors.summingInt(item -> 1))); + } + private static boolean owns(Map tenantByInbound, Long inboundId, Long tenantId) { return inboundId != null && tenantId != null && tenantId.equals(tenantByInbound.get(inboundId)); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index aa46edbec..6fecfa3cd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -330,6 +330,7 @@ public class InboundMeasurementService { repository.listWeights(inbound.getTenantId(), inbound.getId())); inbound.setActualGrossWeightKg(totals.grossWeightKg()); inbound.setActualNetWeightKg(totals.netWeightKg()); + inbound.setPalletCount(totals.palletCount()); if ("WAIT_MEASURE".equals(inbound.getProcessStatus())) { inbound.setProcessStatus("MEASURING"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java index 219b72682..4f3bd8f81 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java @@ -56,6 +56,8 @@ public class UnifiedInboundQueryService { public UnifiedInboundDetail detail(long tenantId, long orderId) { InboundOrderEntity inbound = inbounds.findByOrderId(tenantId, orderId); if (inbound == null) return null; + applyConfirmedPalletCount(inbound, inbounds.confirmedPalletCountsMap(tenantId, + Map.of(inbound.getId(), inbound.getTenantId()))); InboundOrderDetail detail = inbounds.detail(tenantId, inbound); String mainStatus = mainStatus(inbound.getProcessStatus()); boolean includePhaseThree = requiresPhaseThreeSummary(mainStatus); @@ -93,6 +95,8 @@ public class UnifiedInboundQueryService { var transports = operations.findTransports(phaseThreeOwners); Map owners = records.stream().collect(Collectors.toMap( InboundOrderEntity::getId, InboundOrderEntity::getTenantId, (left, right) -> left)); + var confirmedPalletCounts = inbounds.confirmedPalletCountsMap(null, owners); + records.forEach(inbound -> applyConfirmedPalletCount(inbound, confirmedPalletCounts)); var locationCodes = inbounds.locationCodesMap(null, owners); var pickups = inbounds.pickupMap(null, owners); return records.stream().map(inbound -> summary(inbound, null, preloads.get(inbound.getId()), @@ -101,6 +105,14 @@ public class UnifiedInboundQueryService { transports.get(inbound.getId()), locationCodes.get(inbound.getId()), pickups.get(inbound.getId()))).toList(); } + private static void applyConfirmedPalletCount(InboundOrderEntity inbound, + Map confirmedPalletCounts) { + if ((inbound.getPalletCount() == null || inbound.getPalletCount() <= 0) + && confirmedPalletCounts.getOrDefault(inbound.getId(), 0) > 0) { + inbound.setPalletCount(confirmedPalletCounts.get(inbound.getId())); + } + } + private UnifiedInboundSummary summary(InboundOrderEntity inbound, InboundOrderDetail detail, com.baiyue.firstleg.split.entity.PreloadPlanEntity preload, com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity pick, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java index 66d5a1993..99ef11f61 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java @@ -17,6 +17,7 @@ import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; import com.baiyue.firstleg.mapper.InboundTransportSnapshotMapper; import com.baiyue.firstleg.mapper.InboundPickupSnapshotMapper; +import com.baiyue.firstleg.mapper.InboundWeightResultMapper; import org.junit.jupiter.api.Test; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.mockito.ArgumentCaptor; @@ -43,6 +44,7 @@ class InboundOrderRepositoryTest { @MockBean private InboundFileMapper files; @MockBean private InboundInternalNoteMapper notes; @MockBean private InboundStatusHistoryMapper histories; + @MockBean private InboundWeightResultMapper weights; @Resource private InboundOrderRepository repository; @Test @SuppressWarnings({"rawtypes", "unchecked"}) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/TenantScopedMutationRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/TenantScopedMutationRepositoryTest.java index 9d2ef2219..03f7d1c7c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/TenantScopedMutationRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/TenantScopedMutationRepositoryTest.java @@ -108,7 +108,9 @@ class TenantScopedMutationRepositoryTest { verify(inboundOrders).update(eq(inbound), wrapper.capture()); assertTenantAndId(wrapper.getValue()); assertThat(((LambdaUpdateWrapper) wrapper.getValue()).getSqlSet()) - .contains("actual_gross_weight_kg"); + .contains("actual_gross_weight_kg") + .contains("actual_net_weight_kg") + .contains("pallet_count"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index c2439be80..10cb7b82b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -123,6 +123,7 @@ class InboundMeasurementServiceTest { assertThat(weight.getNetWeightKg()).isNull(); assertThat(inbound.getActualGrossWeightKg()).isNull(); assertThat(inbound.getActualNetWeightKg()).isNull(); + assertThat(inbound.getPalletCount()).isZero(); verify(repository).updateInboundWeightSummary(inbound); } @@ -283,6 +284,7 @@ class InboundMeasurementServiceTest { assertThat(response.netWeightKg()).isEqualByComparingTo("4.536"); assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("6.536"); assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("6.536"); + assertThat(inbound.getPalletCount()).isEqualTo(2); assertThat(inbound.getProcessStatus()).isEqualTo("MEASURING"); verify(repository).insertRevision(any(InboundWeightRevisionEntity.class)); } @@ -630,6 +632,7 @@ class InboundMeasurementServiceTest { assertThat(inbound.getActualGrossWeightKg()).isNull(); assertThat(inbound.getActualNetWeightKg()).isNull(); + assertThat(inbound.getPalletCount()).isZero(); verify(repository).updateInboundWeightSummary(inbound); } @@ -655,6 +658,7 @@ class InboundMeasurementServiceTest { assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("5.000"); assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("5.000"); + assertThat(inbound.getPalletCount()).isEqualTo(1); verify(repository).updateInboundWeightSummary(inbound); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java index d0aee7573..13dcd3121 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.params.provider.ValueSource; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; +import java.util.Map; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verifyNoInteractions; @@ -71,6 +72,20 @@ class UnifiedInboundQueryServiceTest { assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("DELIVERED")).isTrue(); } + @Test + void completedHistoricalInboundUsesConfirmedWeightCountWhenStoredPalletCountIsZero() throws Exception { + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(88L); + inbound.setPalletCount(0); + var method = UnifiedInboundQueryService.class.getDeclaredMethod( + "applyConfirmedPalletCount", InboundOrderEntity.class, Map.class); + method.setAccessible(true); + + method.invoke(null, inbound, Map.of(88L, 2)); + + assertThat(inbound.getPalletCount()).isEqualTo(2); + } + @ParameterizedTest @ValueSource(strings = {"WAIT_MEASURE", "WAIT_SPLIT"}) void phaseTwoDetailDoesNotQueryPhaseThreeTables(String processStatus) { From e11a528dfe9cf9fcc0611019a8dd74e9945a061b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 15:07:17 +0800 Subject: [PATCH 022/115] test(phase4): provision overseas lifecycle fixtures --- .../RelabelApplicationController.java | 9 +- .../lastleg/relabel/dto/RelabelRequests.java | 1 + .../openapi/overseas-business-v1.yaml | 3 +- .../RelabelApplicationControllerTest.java | 53 +++++++++++ .../RelabelApplicationServiceTest.java | 2 +- .../TaillegRealConsumerAcceptanceTest.java | 2 +- scripts/admin-test-env-acceptance.mjs | 8 +- .../merchant-qualification-fixture.mjs | 2 +- ...phase4-overseas-business-contract.test.mjs | 57 ++++++----- .../phase4-overseas-business.mjs | 95 +++++++++++++++++-- ...hase4-overseas-warehouse-contract.test.mjs | 15 +-- .../phase4-overseas-warehouse.mjs | 49 +++++++++- 12 files changed, 248 insertions(+), 48 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java index 4807f5419..2638bbc2e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -27,7 +27,7 @@ public class RelabelApplicationController { public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody RelabelRequests.Create request) { requirePlatform(user); - return ApiResponse.success(service.create("0", operator(user), request)); + return ApiResponse.success(service.create(requiredCustomer(request.customerId()), operator(user), request)); } @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") @@ -60,5 +60,12 @@ public class RelabelApplicationController { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required"); } } + private String requiredCustomer(String customerId) { + String value = customerId == null ? null : customerId.trim(); + if (value == null || value.isEmpty() || "0".equals(value)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Target customerId is required"); + } + return value; + } private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java index 32ddaea7c..60b8cdc78 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java @@ -16,6 +16,7 @@ public final class RelabelRequests { private RelabelRequests() {} public record Create( + @Size(max = 32) String customerId, @NotBlank @Size(max = 128) String customerOrderNo, @NotBlank @Size(max = 128) String overseasWarehouseId, @NotNull LocalDateTime labelDeadline, diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 6cf76b0a9..62a66330b 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -466,8 +466,9 @@ components: RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, COMPLETED, REJECTED, CANCELLED] } RelabelCreate: type: object - required: [customerOrderNo, overseasWarehouseId, labelDeadline, contactName, contactPhone, idempotencyKey, items] + required: [customerId, customerOrderNo, overseasWarehouseId, labelDeadline, contactName, contactPhone, idempotencyKey, items] properties: + customerId: { type: string, maxLength: 32, description: Target customer ID required by the platform create endpoint } customerOrderNo: { type: string, maxLength: 128 } overseasWarehouseId: { type: string, maxLength: 128 } labelDeadline: { type: string, format: date-time } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java new file mode 100644 index 000000000..7e75808d2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.relabel.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class RelabelApplicationControllerTest { + private final RelabelApplicationService service = mock(RelabelApplicationService.class); + private final RelabelApplicationController controller = controller(); + + @Test + void platformCreateUsesExplicitTargetCustomer() { + RelabelRequests.Create request = request("190000000000000001"); + + controller.create(platform(), request); + + verify(service).create("190000000000000001", "7", request); + } + + @Test + void platformCreateRejectsMissingOrPlatformCustomer() { + assertThrows(ResponseStatusException.class, () -> controller.create(platform(), request(null))); + assertThrows(ResponseStatusException.class, () -> controller.create(platform(), request("0"))); + } + + private RelabelRequests.Create request(String customerId) { + return new RelabelRequests.Create(customerId, "CUSTOMER-1", "WH-1", + LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", null, "key-1", + List.of(new RelabelRequests.Item("SKU-A", null, null, null, "Product", 1, 1, + "SHIP-1", null, "SKU-B", "Replace", null, null, null))); + } + + private AuthenticatedUser platform() { + return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of()); + } + + private RelabelApplicationController controller() { + RelabelApplicationController value = new RelabelApplicationController(); + ReflectionTestUtils.setField(value, "service", service); + return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index 97a8dad24..a4262de7f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -212,7 +212,7 @@ class RelabelApplicationServiceTest { } private static RelabelRequests.Create request(String key, int total, int notified) { - return new RelabelRequests.Create("CUSTOMER-1", "WH-1", LocalDateTime.of(2026, 8, 20, 10, 0), + return new RelabelRequests.Create(null, "CUSTOMER-1", "WH-1", LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", "remark", key, List.of(new RelabelRequests.Item("SKU-A", "img-1", "image.png", "https://files/image.png", "Chinese name", total, notified, "SHP-1", "TRACK-1", "SKU-NEW", "Label on carton", "file-1", "relabel.pdf", "https://files/relabel.pdf"))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java index ff1840e5f..15a3694c9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java @@ -92,7 +92,7 @@ class TaillegRealConsumerAcceptanceTest { if (!exists("ll_relabel_application", MARKER + "-RELABEL-KEY")) { relabel.create(CUSTOMER, "real-acceptance", new RelabelRequests.Create( - MARKER + "-RELABEL", "92", RELABEL_DEADLINE, "验收联系人", "13800000000", + null, MARKER + "-RELABEL", "92", RELABEL_DEADLINE, "验收联系人", "13800000000", MARKER, MARKER + "-RELABEL-KEY", List.of(new RelabelRequests.Item("SKU-REAL-A", null, null, null, "真实验收商品", 3, 3, MARKER + "-SHIP", MARKER + "-TRACK", "SKU-REAL-A-NEW", "换标验收", null, null, null)))); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index ed0d490b2..13035edfa 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -61,6 +61,10 @@ export function normalizeModuleOrder(modules) { normalized.splice(normalized.indexOf('phase3-loading-finalize') + 1, 0, 'phase3-order-splitting') } } + if (normalized.includes('phase4-overseas-business') + && !normalized.includes('merchant-qualification-fixture')) { + normalized.splice(normalized.indexOf('phase4-overseas-business'), 0, 'merchant-qualification-fixture') + } const financeChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full'] const firstFinanceIndex = normalized.findIndex((name) => financeChain.includes(name)) if (firstFinanceIndex >= 0) { @@ -229,9 +233,9 @@ export class AcceptanceRun { return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } } } - async requestOverseas(method, path, body = undefined, extraHeaders = {}) { + async requestOverseas(method, path, body = undefined, extraHeaders = {}, token = this.overseasToken) { const headers = { Accept: 'application/json', ...extraHeaders } - if (this.overseasToken) headers.Authorization = `Bearer ${this.overseasToken}` + if (token) headers.Authorization = `Bearer ${token}` if (body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8' try { const response = await fetch(`${this.options.overseasGateway}${path}`, { diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs index e860d1591..6e586c0e5 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs @@ -82,6 +82,6 @@ export async function run(context) { process.env.MERCHANT_TEST_USERNAME = username process.env.MERCHANT_TEST_PASSWORD = password - context.context.merchantQualificationFixture = { tenantId, username, marker: context.marker } + context.context.merchantQualificationFixture = { tenantId, username, password, marker: context.marker } context.createdResources.merchantQualificationFixture = { tenantId, username, marker: context.marker } } 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 1d2af9827..3e41dea1b 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 @@ -10,7 +10,7 @@ const source = fs.readFileSync(path.join(here, 'phase4-overseas-business.mjs'), function response(data, code = '200') { return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, requestId: 'request-test', traceId: 'trace-test' } } function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } } -function fixture({ ownedPurchase = false, historicalPurchase = false } = {}) { +function fixture() { const calls = []; const steps = []; const gaps = [] const state = { truck: null, @@ -19,16 +19,30 @@ function fixture({ ownedPurchase = false, historicalPurchase = false } = {}) { } const marker = 'HTTP-20260821-001' const context = { - marker, calls, steps, gaps, context: {}, - createdResources: ownedPurchase ? { overseasProcurement: { marker, orderNo: 'OP1', customerId: '10001' } } : {}, - async request(method, requestPath, body) { calls.push({ channel: 'domestic', method, path: requestPath, body }); return response(page([{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' }])) }, - async requestOverseas(method, requestPath, body) { + marker, calls, steps, gaps, + context: { merchantQualificationFixture: { tenantId: '10001', username: 'merchant', password: 'secret' } }, + createdResources: { merchantQualificationFixture: { tenantId: '10001', username: 'merchant', marker } }, + async request(method, requestPath, body) { + calls.push({ channel: 'domestic', method, path: requestPath, body }) + if (requestPath.includes('/iam/tenants?')) return response(page([{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' }])) + if (requestPath === '/api/auth/login') return response({ overseasAccessToken: 'merchant-overseas-token' }) + if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001') + if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null) + throw new Error(`unexpected domestic ${method} ${requestPath}`) + }, + async requestOverseas(method, requestPath, body, headers, token) { calls.push({ channel: 'overseas', method, path: requestPath, body }) + if (requestPath === '/api/merchant/v1/last-leg/overseas-procurements' && method === 'POST') { + assert.equal(token, 'merchant-overseas-token') + state.purchase = { ...state.purchase, customerName: body.customerName, remark: body.remark } + return response({ summary: state.purchase }) + } if (requestPath.includes('/customer-options')) return response([{ customerId: '10001', customerName: '客户甲' }]) if (requestPath.includes('/warehouse-options')) return response([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', countryCode: 'US' }]) - if (requestPath.includes('/overseas-procurements?') && method === 'GET') return response(page(ownedPurchase || historicalPurchase ? [state.purchase] : [])) + if (requestPath.includes('/overseas-procurements?') && method === 'GET') return response(page([state.purchase])) if (requestPath.includes('/overseas-procurements/OP1?') && method === 'GET') return response({ summary: state.purchase, remark: marker }) if (requestPath.includes('/overseas-procurements/OP1?') && method === 'PATCH') { state.purchase = { ...state.purchase, ...body, version: state.purchase.version + 1 }; return response({ summary: state.purchase }) } + if (requestPath.includes('/overseas-procurements/OP1/review?') && method === 'POST') { state.purchase = { ...state.purchase, status: 'REJECTED', version: state.purchase.version + 1 }; return response({ summary: state.purchase }) } if (requestPath.includes('/us-truck-orders?') && method === 'POST') { state.truck = { id: '40001', orderNo: 'TK1', customerId: '10001', status: 'PENDING_REVIEW', version: 0, ...body }; return response({ summary: state.truck }) } if (requestPath.includes('/us-truck-orders/TK1/status?') && method === 'PATCH') { if (Number(body.version) !== state.truck.version) return response(null, 'A0409'); state.truck = { ...state.truck, status: body.status, version: state.truck.version + 1 }; return response({ summary: state.truck }) } if (requestPath.includes('/us-truck-orders/TK1?') && method === 'GET') return response({ summary: state.truck }) @@ -50,7 +64,7 @@ function fixture({ ownedPurchase = false, historicalPurchase = false } = {}) { test('module is context injected and excludes forbidden business surfaces', () => { assert.match(source, /export async function run\(context\)/) assert.doesNotMatch(source, /https?:\/\/|process\.env|fetch\(/) - for (const forbidden of ['/merchant/', '/review', '/outbounds', '/receive-pallets', '/outbound', '/delivery', '/dispatch', '/export']) assert.equal(source.includes(forbidden), false, forbidden) + for (const forbidden of ['/outbounds', '/receive-pallets', '/outbound', '/delivery', '/dispatch', '/export']) assert.equal(source.includes(forbidden), false, forbidden) assert.doesNotMatch(source, /requestOverseas\('DELETE'/) }) @@ -58,14 +72,13 @@ test('marker truck and looseboard cover create detail edit stale conflict and fi const context = fixture() await run(context) assert.equal(context.steps.every((step) => step.passed), true, context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n')) - assert.deepEqual(context.calls.filter((call) => call.method === 'POST').map((call) => call.path), [ - '/api/platform/v1/last-leg/us-truck-orders?customerId=10001', - '/api/platform/v1/last-leg/local-looseboard-orders?customerId=10001', - ]) + assert.ok(context.calls.some((call) => call.method === 'POST' && call.path === '/api/merchant/v1/last-leg/overseas-procurements')) + assert.ok(context.calls.some((call) => call.method === 'POST' && call.path === '/api/platform/v1/last-leg/us-truck-orders?customerId=10001')) + assert.ok(context.calls.some((call) => call.method === 'POST' && call.path === '/api/platform/v1/last-leg/local-looseboard-orders?customerId=10001')) assert.equal(context.calls.filter((call) => call.method === 'PATCH' && call.path.includes('/us-truck-orders/')).length, 3) assert.equal(context.calls.filter((call) => call.method === 'PUT' && call.path.includes('/local-looseboard-orders/')).length, 2) assert.equal(context.calls.filter((call) => call.method === 'PATCH' && call.path.includes('/local-looseboard-orders/')).length, 1) - const statusCalls = context.calls.filter((call) => call.path.includes('/status?')) + const statusCalls = context.calls.filter((call) => call.channel === 'overseas' && call.path.includes('/status?')) assert.deepEqual(statusCalls.map((call) => [call.method, call.path, call.body.status]), [ ['PATCH', '/api/platform/v1/last-leg/us-truck-orders/TK1/status?customerId=10001', 'CANCELLED'], ['PATCH', '/api/platform/v1/last-leg/local-looseboard-orders/LB1/status?customerId=10001', 'CANCELLED'], @@ -81,24 +94,20 @@ test('marker truck and looseboard cover create detail edit stale conflict and fi assert.ok(context.gaps.every((gap) => !/requestId=missing|traceId=missing/.test(gap[3]))) }) -test('historical procurement data is never patched without marker ownership', async () => { - const context = fixture({ historicalPurchase: true }) - await run(context) - assert.equal(context.calls.some((call) => call.method === 'PATCH' && call.path.includes('/overseas-procurements/')), false) - assert.equal(context.gaps.some((gap) => gap[4] === 'missing-marker-owned-procurement'), true) -}) - -test('marker-owned procurement may be patched but never created, reviewed or shipped', async () => { - const context = fixture({ ownedPurchase: true }) +test('marker procurement is created through merchant then edited and rejected by platform', async () => { + const context = fixture() await run(context) const purchaseWrites = context.calls.filter((call) => call.path.includes('/overseas-procurements') && call.method !== 'GET') - assert.deepEqual(purchaseWrites.map((call) => call.method), ['PATCH']) - assert.match(purchaseWrites[0].body.productSpecification, new RegExp(context.marker)) + assert.deepEqual(purchaseWrites.map((call) => call.method), ['POST', 'PATCH', 'POST']) + assert.match(purchaseWrites[1].body.productSpecification, new RegExp(context.marker)) assert.equal(context.createdResources.phase4OverseasBusiness.purchase.marker, context.marker) + assert.equal(context.createdResources.phase4OverseasBusiness.purchase.finalStatus, 'REJECTED') + assert.equal(context.createdResources.phase4OverseasBusiness.purchase.retained, false) }) test('contract locks real candidates, idempotency, version conflicts and trace evidence', () => { for (const token of ['/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE', '/overseas-procurements/customer-options?limit=200', - '/overseas-procurements/warehouse-options', 'idempotencyKey', 'phase4-truck', 'phase4-looseboard', + '/overseas-procurements/warehouse-options', '/api/merchant/v1/last-leg/overseas-procurements', + "decision: 'REJECT'", 'idempotencyKey', 'phase4-truck', 'phase4-looseboard', '旧 version', '/status', "status: 'CANCELLED'", 'finally', 'requestId=', 'traceId=', 'createdResources.phase4OverseasBusiness']) assert.ok(source.includes(token), token) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-business.mjs b/scripts/admin-test-env-modules/phase4-overseas-business.mjs index c07bcf429..21fab5d74 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business.mjs @@ -20,6 +20,11 @@ async function domesticGet(context, name, path) { const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) return { response, data } } +async function domestic(context, method, name, path, body) { + const response = await context.request(method, path, body) + const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) + return { response, data } +} async function overseas(context, method, name, path, body) { const response = await context.requestOverseas(method, path, body) const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) @@ -38,6 +43,11 @@ async function prerequisites(context) { coverageGap(context, '海外业务写验收 ACTIVE 客户前置', `IAM 未返回真实 ACTIVE 客户;${trace(tenants.response)}`, 'missing-active-customer') return null } + const fixture = context.createdResources?.merchantQualificationFixture + const fixtureCustomerId = id(fixture?.tenantId) + const activeCustomer = activeCustomers.find((item) => id(item?.id) === fixtureCustomerId) || activeCustomers[0] + const partnerWarehouse = await createPartnerWarehouse(context) + const purchase = await createMerchantProcurement(context, activeCustomer) const customerOptions = await overseas(context, 'GET', '查询采购客户候选', `${BASE}/overseas-procurements/customer-options?limit=200`) const warehouseOptions = await overseas(context, 'GET', '查询 ACTIVE 采购仓候选', `${BASE}/overseas-procurements/warehouse-options`) context.assert(MODULE, '采购客户候选结构正确', customerOptions.response, @@ -50,9 +60,62 @@ async function prerequisites(context) { const procurementCustomer = Array.isArray(customerOptions.data) ? customerOptions.data.find((item) => activeIds.has(id(item?.customerId))) : null const warehouse = Array.isArray(warehouseOptions.data) ? warehouseOptions.data.find((item) => longId(item?.warehouseId)) : null - return { activeCustomer: activeCustomers[0], procurementCustomer, warehouse, customerOptions, warehouseOptions } + return { activeCustomer, procurementCustomer, warehouse, customerOptions, warehouseOptions, partnerWarehouse, purchase } } +function partnerWarehousePayload(context) { + const suffix = String(context.marker || '').replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase() + 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`, + 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 }, + capabilities: [{ capabilityCode: 'STORAGE', capabilityName: 'Storage', enabled: true, + cutoffTime: null, description: `${context.marker} procurement acceptance` }], + } +} + +async function createPartnerWarehouse(context) { + const payload = partnerWarehousePayload(context) + const created = await domestic(context, 'POST', '创建本轮采购合作仓', '/api/platform/v1/warehouse-admin', payload) + const warehouseId = id(created.data) + context.assert(MODULE, '采购合作仓创建返回长ID', created.response, longId(warehouseId), + `warehouseId=${warehouseId || 'missing'}; ${trace(created.response)}`, OPTIONS) + return warehouseId ? { id: warehouseId, finalStatus: 'ACTIVE' } : null +} + +async function createMerchantProcurement(context, activeCustomer) { + const username = context.context?.merchantQualificationFixture?.username + const password = context.context?.merchantQualificationFixture?.password + const login = await context.request('POST', '/api/auth/login', { username, password }, '') + const auth = context.apiSuccess(MODULE, '采购客户账号登录', login, trace(login), OPTIONS) + const overseasToken = auth?.overseasAccessToken + context.assert(MODULE, '采购客户登录返回海外Token', login, nonEmpty(overseasToken), + `customerId=${id(activeCustomer?.id) || 'missing'}, tokenPresent=${Boolean(overseasToken)}; ${trace(login)}`, OPTIONS) + if (!overseasToken) return null + const payload = { + customerName: activeCustomer?.tenantName || activeCustomer?.name || `Customer-${id(activeCustomer?.id)}`, + purchaseCategory: 'OFFICE_SUPPLIES', productName: `${context.marker} 采购商品`, + productSpecification: 'Phase4 acceptance', productUrl: null, purchaseQuantity: 10, + budgetUnitPrice: 1.25, currency: 'USD', receivingAddress: 'Los Angeles', + contactName: '采购验收联系人', contactPhone: '13800000002', remark: context.marker, + idempotencyKey: `${context.marker}-phase4-procurement`.slice(0, 255), + } + const response = await context.requestOverseas('POST', '/api/merchant/v1/last-leg/overseas-procurements', payload, {}, overseasToken) + const detail = context.apiSuccess(MODULE, '创建本轮客户采购订单', response, trace(response), OPTIONS) + const value = summary(detail) + context.assert(MODULE, '客户采购订单创建为待审核且归属正确', response, + nonEmpty(value?.orderNo) && id(value?.customerId) === id(activeCustomer?.id) && value?.status === 'PENDING_REVIEW', + `orderNo=${id(value?.orderNo) || 'missing'}, customerId=${id(value?.customerId) || 'missing'}, status=${value?.status || 'missing'}; ${trace(response)}`, OPTIONS) + const purchase = value?.orderNo ? { marker: context.marker, orderNo: id(value.orderNo), customerId: id(value.customerId), version: Number(value.version), retained: true } : null + if (purchase) ownedRegistry(context).purchase = purchase + return purchase +} + +function nonEmpty(value) { return typeof value === 'string' && value.trim().length > 0 } + function markerOwnedPurchase(context) { const candidates = [context.createdResources?.phase4OverseasBusiness?.purchase, context.createdResources?.overseasProcurement] return candidates.find((item) => item?.marker === context.marker && id(item?.orderNo) && longId(item?.customerId)) || null @@ -65,7 +128,7 @@ async function procurementFlow(context, prerequisite) { } const customerId = id(prerequisite.procurementCustomer.customerId) const warehouseId = id(prerequisite.warehouse.warehouseId) - const list = await overseas(context, 'GET', '按真实客户与采购仓查询采购订单', query(`${BASE}/overseas-procurements`, { page: 1, size: 20, customerId, purchaseWarehouseId: warehouseId })) + const list = await overseas(context, 'GET', '按真实客户查询采购订单', query(`${BASE}/overseas-procurements`, { page: 1, size: 20, customerId })) assertPage(context, '海外采购订单', list) const owned = markerOwnedPurchase(context) const row = records(list.data).find((item) => owned && item?.orderNo === owned.orderNo && id(item?.customerId) === id(owned.customerId)) @@ -77,12 +140,22 @@ async function procurementFlow(context, prerequisite) { const detail = await overseas(context, 'GET', '查询本轮自有采购订单详情', detailPath) const current = summary(detail.data) const update = await overseas(context, 'PATCH', '编辑本轮自有采购订单', detailPath, - { version: Number(current?.version), productSpecification: `${context.marker}-procurement`, remark: context.marker }) + { version: Number(current?.version), productSpecification: `${context.marker}-procurement`, + purchaseWarehouseId: warehouseId, purchaseWarehouseName: prerequisite.warehouse.warehouseName, + remark: context.marker }) const updated = summary(update.data) context.assert(MODULE, '本轮采购编辑回显 marker 且版本递增', update.response, updated?.productSpecification === `${context.marker}-procurement` && Number(updated?.version) === Number(current?.version) + 1, `orderNo=${id(updated?.orderNo) || 'missing'}, version=${updated?.version ?? 'missing'}; ${trace(update.response)}`, OPTIONS) ownedRegistry(context).purchase = { marker: context.marker, orderNo: id(updated?.orderNo), customerId: id(updated?.customerId), version: Number(updated?.version), retained: true } + const reviewed = await overseas(context, 'POST', '驳回本轮采购订单并安全收口', + query(`${BASE}/overseas-procurements/${encodeURIComponent(row.orderNo)}/review`, { customerId }), + { decision: 'REJECT', remark: `${context.marker}-acceptance-cleanup` }) + const reviewedSummary = summary(reviewed.data) + context.assert(MODULE, '本轮采购订单终态为已驳回', reviewed.response, + reviewedSummary?.status === 'REJECTED' && Number(reviewedSummary?.version) === Number(updated?.version) + 1, + `orderNo=${id(reviewedSummary?.orderNo) || 'missing'}, status=${reviewedSummary?.status || 'missing'}, version=${reviewedSummary?.version ?? 'missing'}; ${trace(reviewed.response)}`, OPTIONS) + ownedRegistry(context).purchase = { marker: context.marker, orderNo: id(reviewedSummary?.orderNo), customerId, version: Number(reviewedSummary?.version), finalStatus: reviewedSummary?.status, retained: reviewedSummary?.status !== 'REJECTED' } } function truckPayload(context) { @@ -215,8 +288,16 @@ async function looseboardFlow(context, customerId) { export async function run(context) { const prerequisite = await prerequisites(context) if (!prerequisite) return - await procurementFlow(context, prerequisite) - const customerId = id(prerequisite.activeCustomer.id) - await truckFlow(context, customerId) - await looseboardFlow(context, customerId) + try { + await procurementFlow(context, prerequisite) + const customerId = id(prerequisite.activeCustomer.id) + await truckFlow(context, customerId) + await looseboardFlow(context, customerId) + } finally { + if (prerequisite.partnerWarehouse?.id) { + const retired = await context.request('PATCH', `/api/platform/v1/warehouse-admin/${encodeURIComponent(prerequisite.partnerWarehouse.id)}/status?tenantId=0&status=INACTIVE`) + context.addStep(MODULE, '本轮采购合作仓已停用收口', retired, + retired?.http === 200 && codeOf(retired) === '200', trace(retired), OPTIONS) + } + } } diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs index df3577739..149546c51 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs @@ -18,6 +18,8 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou async request(method, requestPath) { calls.push({ channel: 'domestic', method, path: requestPath }); return response(page(tenants)) }, async requestOverseas(method, requestPath, body) { calls.push({ channel: 'overseas', method, path: requestPath, body }) + if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001') + if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null) if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', ...body }; return response('71001') } if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) { createdPallet = { ...createdPallet, ...body }; return response(null) } if (requestPath.startsWith('/api/platform/v1/last-leg/orders?')) return response(page([{ id: '30001', orderNo: 'LL-1', currentStatus: 'PENDING_ASSIGNMENT' }])) @@ -31,7 +33,7 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou if (requestPath.includes('/inventory/balances/60001/positions?')) return response([{ id: '61001', balanceId: '60001', totalPackages: 10 }]) if (requestPath.includes('/inventory/ledger?')) return response(page([{ id: '62001', customerId: '10001', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-21T10:00:00' }])) if (requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords)) - if (requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001' } }) + if (requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '10001', status: 'ACTIVE' } }) if (requestPath.endsWith('/warehouse-admin/20001/pallets?page=1&size=20')) return response(page([{ id: '70001', customerId: '0', warehouseId: '20001', palletCode: 'PLT-1', status: 'IDLE' }])) if (requestPath.endsWith('/warehouse-admin/20001/pallets/70001')) return response({ id: '70001', customerId: '0', warehouseId: '20001' }) if (requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) return response(createdPallet) @@ -54,8 +56,7 @@ test('模块只依赖注入请求,不读取环境或自行联网', () => { assert.doesNotMatch(source, /process\.env|fetch\(|requestBinary/) }) -test('仅托盘生命周期允许写请求,其他海外业务保持只读', () => { - assert.doesNotMatch(source, /requestOverseas\(['"]PATCH['"]/) +test('既有仓库场景只写本轮托盘生命周期', () => { const context = contextFixture() return run(context).then(() => { assert.ok(context.calls.length >= 12) @@ -82,7 +83,7 @@ test('覆盖冻结的海外只读列表与有记录才查详情', async () => { assert.deepEqual(context.createdResources.phase4OverseasWarehouse, { marker: 'ACC-PHASE4-TEST', customerId: '10001', warehouseId: '20001', palletId: '71001', terminalStatus: 'SCRAPPED' }) }) -test('缺少真实客户或仓库时记录 coverageGap,不使用固定业务ID补洞', async () => { +test('缺少客户时记录缺口,缺少仓库时通过正式接口创建并停用fixture', async () => { const noCustomer = contextFixture({ tenants: [] }) await run(noCustomer) assert.equal(noCustomer.gaps.length, 1) @@ -91,8 +92,10 @@ test('缺少真实客户或仓库时记录 coverageGap,不使用固定业务ID const noWarehouse = contextFixture({ warehouseRecords: [] }) await run(noWarehouse) - assert.equal(noWarehouse.gaps.some((gap) => gap[2] === '公共托盘真实仓库前置'), true) - assert.equal(noWarehouse.calls.some((call) => call.path.includes('/pallets')), false) + assert.equal(noWarehouse.gaps.some((gap) => gap[2] === '公共托盘真实仓库前置'), false) + assert.ok(noWarehouse.calls.some((call) => call.method === 'POST' && call.path === '/api/platform/v1/warehouse-admin')) + assert.ok(noWarehouse.calls.some((call) => call.method === 'PATCH' && call.path.includes('/warehouse-admin/20001/status?'))) + assert.ok(noWarehouse.calls.some((call) => call.path.includes('/pallets'))) }) test('合同覆盖库存CSV并明确排除暂缓、离仓、PDA与内部路径', () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs index 292fb489b..789b88307 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs @@ -141,7 +141,12 @@ export async function run(context) { coverageGap(context, '换标创建与状态生命周期', '当前 Admin 换标新增/审核仍使用不可提交的契约提示弹框,前端未真实接入写 API;验收模块不越过页面范围直接创建换标单', 'frontend-scope') - const warehouse = await findWarehouse(context, records(tenantsResult.data), customerId) + let warehouse = await findWarehouse(context, records(tenantsResult.data), customerId) + let ownedWarehouse = false + if (!warehouse) { + warehouse = await createWarehouseFixture(context, customerId) + ownedWarehouse = Boolean(warehouse) + } if (!warehouse) return const warehouseId = id(warehouse.id) context.createdResources.phase4OverseasWarehouse.customerId = customerId @@ -160,8 +165,45 @@ export async function run(context) { `listId=${id(pallet.id)}, detailId=${id(detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS) } - await verifyPalletLifecycle(context, warehouseId) - context.context.phase4OverseasWarehouse = { customerId, warehouseId, palletId: context.createdResources.phase4OverseasWarehouse.palletId } + try { + await verifyPalletLifecycle(context, warehouseId) + context.context.phase4OverseasWarehouse = { customerId, warehouseId, palletId: context.createdResources.phase4OverseasWarehouse.palletId } + } finally { + if (ownedWarehouse) await retireWarehouseFixture(context, customerId, warehouseId) + } +} + +async function createWarehouseFixture(context, customerId) { + const suffix = String(context.marker || '').replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase() + const payload = { + customerId, warehouseCode: `P4W-${suffix}`.slice(0, 50), warehouseName: `${context.marker} 海外仓`, + warehouseType: 'OVERSEAS', countryCode: 'US', timezone: 'America/Los_Angeles', + contactName: 'Phase4 QA', contactPhone: '+1-213-555-0188', contactEmail: null, + status: 'ACTIVE', remark: `${context.marker}-warehouse-fixture`, + address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles', + district: 'Commerce', postalCode: '90040', addressLine1: '501 Phase Four Warehouse Avenue', + addressLine2: null, latitude: null, longitude: null }, + capabilities: [{ capabilityCode: 'STORAGE', capabilityName: 'Storage', enabled: true, + cutoffTime: null, description: `${context.marker} acceptance` }], + } + const created = await overseasMutation(context, 'POST', '创建本轮客户海外仓', '/api/platform/v1/warehouse-admin', payload) + const warehouseId = id(created.data) + context.assert(MODULE, '客户海外仓创建返回长ID', created.response, longId(warehouseId), + `warehouseId=${warehouseId || 'missing'}, customerId=${customerId}; ${trace(created.response)}`, OPTIONS) + if (!warehouseId) return null + const detail = await overseasGet(context, '查询本轮客户海外仓详情', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}`, { customerId })) + context.assert(MODULE, '本轮客户海外仓归属和状态正确', detail.response, + id(detail.data?.warehouse?.id || detail.data?.id) === warehouseId + && id(detail.data?.warehouse?.customerId || detail.data?.customerId) === customerId + && (detail.data?.warehouse?.status || detail.data?.status) === 'ACTIVE', + `warehouseId=${warehouseId}, customerId=${id(detail.data?.warehouse?.customerId || detail.data?.customerId) || 'missing'}; ${trace(detail.response)}`, OPTIONS) + return { id: warehouseId, customerId, warehouseCode: payload.warehouseCode, warehouseName: payload.warehouseName, status: 'ACTIVE' } +} + +async function retireWarehouseFixture(context, customerId, warehouseId) { + const retired = await context.requestOverseas('PATCH', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/status`, { customerId, status: 'INACTIVE' })) + context.addStep(MODULE, '本轮客户海外仓已停用收口', retired, + retired?.http === 200 && String(retired?.body?.code) === '200', trace(retired), OPTIONS) } async function verifyPalletLifecycle(context, warehouseId) { @@ -237,6 +279,5 @@ async function findWarehouse(context, tenants, preferredCustomerId) { return warehouse } } - coverageGap(context, '公共托盘真实仓库前置', `ACTIVE 客户列表未找到可用海外仓;${trace(lastResponse)}`, 'missing-readonly-warehouse') return null } From e773816c981f8d10096cae8d3062022724a7065a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 15:10:10 +0800 Subject: [PATCH 023/115] fix(db): upgrade overseas pallet FBA field --- deploy/sql/README.md | 2 +- deploy/sql/海外区域统一增量-MySQL8.sql | 29 ++++++++++++++++++- ...rehouseUnifiedIncrementalContractTest.java | 24 +++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehouseUnifiedIncrementalContractTest.java diff --git a/deploy/sql/README.md b/deploy/sql/README.md index e06944ed6..0946cd9df 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -3,7 +3,7 @@ ## 唯一数据库初始化与发布入口 - 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 -- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先执行 `lastleg-service/src/main/resources/db/lastleg.sql` 基线,再执行该入口。当前包含海外仓报价结构,后续海外主数据继续追加到同一入口,不按日期拆分。 +- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql` 与 `warehouse-overseas-service/src/main/resources/db/warehouse.sql` 基线,再执行该入口。固定入口同时升级 `baiyue_lastleg` 与 `baiyue_lastlag_warehouse`,当前包含海外主数据、业务、入仓库存与海外仓托盘兼容结构,后续继续追加到同一入口,不按日期拆分。 - 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。 - 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index ac65f97a9..508e02ba8 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -404,4 +404,31 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (customer_id,task_id,allocation_id,sku_code)')); PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; -SELECT 'PASS' AS migration_status, '海外仓报价、月度汇率、车辆主数据、海外业务与入仓单目的仓库存结构已就绪' AS message; +-- Warehouse Overseas 与 Lastleg 使用同一海外区域发布入口但保持独立数据库。 +-- 公共托盘详情实体读取 fba_warehouse_code;旧库缺列时创建可成功但详情会 S0500。 +USE baiyue_lastlag_warehouse; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='fba_warehouse_code'), + 'SELECT 1', + 'ALTER TABLE wh_pallet ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''所属FBA仓编码'' AFTER remark')); +PREPARE phase4_warehouse_pallet_stmt FROM @ddl; +EXECUTE phase4_warehouse_pallet_stmt; +DEALLOCATE PREPARE phase4_warehouse_pallet_stmt; + +SET @warehouse_pallet_fba_ready = (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema='baiyue_lastlag_warehouse' AND table_name='wh_pallet' + AND column_name='fba_warehouse_code' AND column_type='varchar(128)'); +DROP PROCEDURE IF EXISTS assert_phase4_warehouse_pallet_ready; +DELIMITER $$ +CREATE PROCEDURE assert_phase4_warehouse_pallet_ready() +BEGIN + IF @warehouse_pallet_fba_ready <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='wh_pallet.fba_warehouse_code migration is incomplete'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase4_warehouse_pallet_ready(); +DROP PROCEDURE assert_phase4_warehouse_pallet_ready; + +USE baiyue_lastleg; +SELECT 'PASS' AS migration_status, '海外仓报价、月度汇率、车辆、海外业务、入仓库存与海外仓托盘结构已就绪' AS message; diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehouseUnifiedIncrementalContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehouseUnifiedIncrementalContractTest.java new file mode 100644 index 000000000..0492c6281 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehouseUnifiedIncrementalContractTest.java @@ -0,0 +1,24 @@ +package com.baiyue.warehouse.schema; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WarehouseUnifiedIncrementalContractTest { + + @Test + void overseasUnifiedEntryUpgradesAndValidatesPalletFbaWarehouseCode() throws Exception { + String sql = Files.readString( + Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8); + + assertTrue(sql.contains("USE baiyue_lastlag_warehouse")); + assertTrue(sql.contains("ALTER TABLE wh_pallet ADD COLUMN fba_warehouse_code VARCHAR(128) NULL")); + assertTrue(sql.contains("@warehouse_pallet_fba_ready")); + assertTrue(sql.contains("wh_pallet.fba_warehouse_code migration is incomplete")); + assertTrue(sql.contains("USE baiyue_lastleg")); + } +} From 95b72afdcc48816ec7deac2ab07a23654cd43285 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 15:14:18 +0800 Subject: [PATCH 024/115] fix(phase4): enforce customer and pallet versions --- .../RelabelApplicationController.java | 3 +- .../lastleg/relabel/dto/RelabelRequests.java | 1 - .../openapi/overseas-business-v1.yaml | 5 +-- .../RelabelApplicationControllerTest.java | 12 +++---- .../RelabelApplicationServiceTest.java | 2 +- .../TaillegRealConsumerAcceptanceTest.java | 2 +- ...hase4-overseas-warehouse-contract.test.mjs | 13 +++++--- .../phase4-overseas-warehouse.mjs | 19 +++++++---- .../baiyue/warehouse/dto/PalletRequests.java | 12 +++++-- .../WarehouseResourceRepository.java | 4 +-- .../service/WarehouseResourceService.java | 8 ++++- .../service/WarehouseResourceServiceTest.java | 32 +++++++++++++++++-- 12 files changed, 82 insertions(+), 31 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java index 2638bbc2e..fa5482e72 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -25,9 +25,10 @@ public class RelabelApplicationController { @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_CREATE + "')") @Operation(summary = "Create a relabel application", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_CREATE) public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @Valid @RequestBody RelabelRequests.Create request) { requirePlatform(user); - return ApiResponse.success(service.create(requiredCustomer(request.customerId()), operator(user), request)); + return ApiResponse.success(service.create(requiredCustomer(customerId), operator(user), request)); } @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java index 60b8cdc78..32ddaea7c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java @@ -16,7 +16,6 @@ public final class RelabelRequests { private RelabelRequests() {} public record Create( - @Size(max = 32) String customerId, @NotBlank @Size(max = 128) String customerOrderNo, @NotBlank @Size(max = 128) String overseasWarehouseId, @NotNull LocalDateTime labelDeadline, diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 62a66330b..eae0eca93 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -317,6 +317,8 @@ paths: summary: 创建海外换标单 description: 权限 platform:overseas-warehouse:relabel-order:create;明细与换标文件按请求快照保存,使用 idempotencyKey 幂等。 security: [{ bearerAuth: [] }] + parameters: + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCreate' } } } } responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 幂等键冲突 } } /api/platform/v1/last-leg/relabel-applications/{relabelNo}: @@ -466,9 +468,8 @@ components: RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, COMPLETED, REJECTED, CANCELLED] } RelabelCreate: type: object - required: [customerId, customerOrderNo, overseasWarehouseId, labelDeadline, contactName, contactPhone, idempotencyKey, items] + required: [customerOrderNo, overseasWarehouseId, labelDeadline, contactName, contactPhone, idempotencyKey, items] properties: - customerId: { type: string, maxLength: 32, description: Target customer ID required by the platform create endpoint } customerOrderNo: { type: string, maxLength: 128 } overseasWarehouseId: { type: string, maxLength: 128 } labelDeadline: { type: string, format: date-time } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java index 7e75808d2..5cdcbc004 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java @@ -21,21 +21,21 @@ class RelabelApplicationControllerTest { @Test void platformCreateUsesExplicitTargetCustomer() { - RelabelRequests.Create request = request("190000000000000001"); + RelabelRequests.Create request = request(); - controller.create(platform(), request); + controller.create(platform(), "190000000000000001", request); verify(service).create("190000000000000001", "7", request); } @Test void platformCreateRejectsMissingOrPlatformCustomer() { - assertThrows(ResponseStatusException.class, () -> controller.create(platform(), request(null))); - assertThrows(ResponseStatusException.class, () -> controller.create(platform(), request("0"))); + assertThrows(ResponseStatusException.class, () -> controller.create(platform(), null, request())); + assertThrows(ResponseStatusException.class, () -> controller.create(platform(), "0", request())); } - private RelabelRequests.Create request(String customerId) { - return new RelabelRequests.Create(customerId, "CUSTOMER-1", "WH-1", + private RelabelRequests.Create request() { + return new RelabelRequests.Create("CUSTOMER-1", "WH-1", LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", null, "key-1", List.of(new RelabelRequests.Item("SKU-A", null, null, null, "Product", 1, 1, "SHIP-1", null, "SKU-B", "Replace", null, null, null))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index a4262de7f..97a8dad24 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -212,7 +212,7 @@ class RelabelApplicationServiceTest { } private static RelabelRequests.Create request(String key, int total, int notified) { - return new RelabelRequests.Create(null, "CUSTOMER-1", "WH-1", LocalDateTime.of(2026, 8, 20, 10, 0), + return new RelabelRequests.Create("CUSTOMER-1", "WH-1", LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", "remark", key, List.of(new RelabelRequests.Item("SKU-A", "img-1", "image.png", "https://files/image.png", "Chinese name", total, notified, "SHP-1", "TRACK-1", "SKU-NEW", "Label on carton", "file-1", "relabel.pdf", "https://files/relabel.pdf"))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java index 15a3694c9..ff1840e5f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java @@ -92,7 +92,7 @@ class TaillegRealConsumerAcceptanceTest { if (!exists("ll_relabel_application", MARKER + "-RELABEL-KEY")) { relabel.create(CUSTOMER, "real-acceptance", new RelabelRequests.Create( - null, MARKER + "-RELABEL", "92", RELABEL_DEADLINE, "验收联系人", "13800000000", + MARKER + "-RELABEL", "92", RELABEL_DEADLINE, "验收联系人", "13800000000", MARKER, MARKER + "-RELABEL-KEY", List.of(new RelabelRequests.Item("SKU-REAL-A", null, null, null, "真实验收商品", 3, 3, MARKER + "-SHIP", MARKER + "-TRACK", "SKU-REAL-A-NEW", "换标验收", null, null, null)))); diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs index 149546c51..e25c7dd6a 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs @@ -9,7 +9,7 @@ import { run } from './phase4-overseas-warehouse.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'phase4-overseas-warehouse.mjs'), 'utf8') -function response(data) { return { http: 200, body: { code: '200', message: 'success', data }, requestId: 'request-test', traceId: 'trace-test' } } +function response(data, code = '200') { return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, requestId: 'request-test', traceId: 'trace-test' } } function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } } function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehouseRecords = [{ id: '20001', status: 'ACTIVE', warehouseCode: 'LAX', warehouseName: '洛杉矶仓' }] } = {}) { const calls = []; const steps = []; const gaps = []; let createdPallet = null @@ -20,8 +20,11 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou calls.push({ channel: 'overseas', method, path: requestPath, body }) if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001') if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null) - if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', ...body }; return response('71001') } - if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) { createdPallet = { ...createdPallet, ...body }; return response(null) } + if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', version: 0, ...body }; return response('71001') } + if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) { + if (Number(body.version) !== Number(createdPallet.version)) return response(null, 'A0409') + createdPallet = { ...createdPallet, ...body, version: createdPallet.version + 1 }; return response(null) + } if (requestPath.startsWith('/api/platform/v1/last-leg/orders?')) return response(page([{ id: '30001', orderNo: 'LL-1', currentStatus: 'PENDING_ASSIGNMENT' }])) if (requestPath.includes('/last-leg/orders/30001?')) return response({ order: { id: '30001' }, cargo: [], statusHistory: [] }) if (requestPath.startsWith('/api/platform/v1/last-leg/relabel-applications?')) return response(page([{ id: '40001', relabelNo: 'RL-1', status: 'PENDING_PROCESSING' }])) @@ -64,7 +67,7 @@ test('既有仓库场景只写本轮托盘生命周期', () => { assert.equal(context.calls.filter((call) => call.channel === 'domestic').length, 1) assert.ok(context.calls.filter((call) => call.channel === 'overseas').every((call) => call.path.startsWith('/api/platform/v1/'))) const writes = context.calls.filter((call) => call.method !== 'GET') - assert.deepEqual(writes.map((call) => call.method), ['POST', 'PUT', 'PUT']) + assert.deepEqual(writes.map((call) => call.method), ['POST', 'PUT', 'PUT', 'PUT']) assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/pallets'))) assert.ok(context.steps.every((step) => step.passed), context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n')) }) @@ -110,5 +113,5 @@ test('合同覆盖库存CSV并明确排除暂缓、离仓、PDA与内部路径', assert.match(source, /longId\(plan\.sourceOrderId\)/) assert.match(source, /id\(pallet\.customerId\) === '0'/) assert.match(source, /换标创建与状态生命周期/) - assert.match(source, /旧版本并发冲突/) + assert.match(source, /公共托盘旧版本编辑返回冲突/) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs index 789b88307..39b76d93e 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs @@ -37,8 +37,9 @@ async function overseasGet(context, name, path) { } async function overseasMutation(context, method, name, path, body) { const response = await context.requestOverseas(method, path, body) - assertEnvelope(context, name, response) - return { response, data: context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) } + const passed = response?.http === 200 && String(response?.body?.code) === '200' + context.addStep(MODULE, name, response, passed, trace(response), OPTIONS) + return { response, data: passed ? response.body?.data : null } } async function overseasCsv(context, name, path) { const response = await context.requestOverseasBinary('GET', path) @@ -230,7 +231,7 @@ async function verifyPalletLifecycle(context, warehouseId) { const updated = await overseasMutation(context, 'PUT', '编辑本轮公共托盘', detailPath, { palletCode, palletType: '木托盘', capacityCbm: 2.5, unitPrice: 1, status: 'IDLE', - locationId: id(detail.data?.locationId) || undefined, remark: `${context.marker}-edited`, + locationId: id(detail.data?.locationId) || undefined, remark: `${context.marker}-edited`, version: Number(detail.data?.version), }) context.assert(MODULE, '公共托盘编辑成功', updated.response, updated.data === null, `palletId=${palletId}; ${trace(updated.response)}`, OPTIONS) @@ -239,14 +240,20 @@ async function verifyPalletLifecycle(context, warehouseId) { id(after.data?.id) === palletId && Number(after.data?.capacityCbm) === 2.5 && Number(after.data?.unitPrice) === 1 && after.data?.remark === `${context.marker}-edited`, `capacity=${after.data?.capacityCbm ?? 'missing'}, unitPrice=${after.data?.unitPrice ?? 'missing'}, remark=${after.data?.remark || 'missing'}; ${trace(after.response)}`, OPTIONS) - coverageGap(context, '公共托盘旧版本并发冲突', - '当前 Pallet SaveRequest/PUT 未提供 version 或 expectedVersion,无法构造稳定旧版本冲突且禁止伪造冲突请求', 'missing-version-contract') + const stale = await context.requestOverseas('PUT', detailPath, { + palletCode, palletType: '木托盘', capacityCbm: 9, unitPrice: 9, status: 'IDLE', + remark: 'STALE-DO-NOT-APPLY', version: Number(detail.data?.version), + }) + context.addStep(MODULE, '公共托盘旧版本编辑返回冲突', stale, + stale?.http === 409 || (stale?.http === 200 && String(stale?.body?.code).endsWith('409')), trace(stale), OPTIONS) } finally { if (palletId) { const retired = await overseasMutation(context, 'PUT', '报废本轮公共托盘', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`, { palletCode, palletType: '木托盘', capacityCbm: 2.5, unitPrice: 1, status: 'SCRAPPED', - remark: `${context.marker}-acceptance-retired`, + remark: `${context.marker}-acceptance-retired`, version: Number((await overseasGet(context, + '读取公共托盘报废前最新版本', + `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)).data?.version), }) context.createdResources.phase4OverseasWarehouse.terminalStatus = retired.data === null ? 'SCRAPPED' : null context.assert(MODULE, '本轮公共托盘已报废收口', retired.response, retired.data === null, diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java index b5c6d8491..d209bfb06 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java @@ -23,11 +23,19 @@ public final class PalletRequests { @NotBlank String status, Long locationId, @Size(max = 500) String remark, - @Size(max = 128) String fbaWarehouseCode + @Size(max = 128) String fbaWarehouseCode, + @Min(0) Integer version ) { public SaveRequest(String palletCode, String palletType, BigDecimal capacityCbm, BigDecimal unitPrice, String status, Long locationId, String remark) { - this(palletCode, palletType, capacityCbm, unitPrice, status, locationId, remark, null); + this(palletCode, palletType, capacityCbm, unitPrice, status, locationId, remark, null, null); + } + + public SaveRequest(String palletCode, String palletType, BigDecimal capacityCbm, + BigDecimal unitPrice, String status, Long locationId, String remark, + String fbaWarehouseCode) { + this(palletCode, palletType, capacityCbm, unitPrice, status, locationId, remark, + fbaWarehouseCode, null); } public SaveRequest { diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java index cdd5dc0f0..bfe2400f8 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java @@ -124,8 +124,8 @@ public class WarehouseResourceRepository { pallets.insert(entity); } - public void updatePallet(WarehousePalletEntity entity) { - pallets.updateById(entity); + public int updatePallet(WarehousePalletEntity entity) { + return pallets.updateById(entity); } public WarehousePalletEntity findPallet(long customerId, long warehouseId, long id) { diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java index 68c90694c..a4355d2d2 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java @@ -152,6 +152,9 @@ public class WarehouseResourceService { @Transactional public void updatePallet(long customerId, long warehouseId, long id, PalletRequests.SaveRequest request) { WarehousePalletEntity existing = requirePallet(customerId, warehouseId, id); + if (request.version() == null || !request.version().equals(existing.getVersion())) { + throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请刷新后重试"); + } if ("SCRAPPED".equals(existing.getStatus()) && !"SCRAPPED".equals(request.status())) { throw new BizException(ResultCode.CONFLICT, "SCRAPPED pallet cannot be reused"); } @@ -161,7 +164,10 @@ public class WarehouseResourceService { validatePallet(request, palletCode, locationId, customerId, warehouseId, id); WarehousePalletEntity entity = palletEntity(customerId, warehouseId, request, palletCode, locationId); entity.setId(existing.getId()); - repository.updatePallet(entity); + entity.setVersion(request.version()); + if (repository.updatePallet(entity) != 1) { + throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请刷新后重试"); + } } public WarehousePalletEntity pallet(long customerId, long warehouseId, long id) { diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java index 005815ef1..b3e47eb2b 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java @@ -259,10 +259,11 @@ class WarehouseResourceServiceTest { existing.setId(40002L); existing.setPalletCode("PLT-OLD"); existing.setStatus("SCRAPPED"); + existing.setVersion(0); when(repository.findPallet(TENANT, WAREHOUSE, 40002L)).thenReturn(existing); assertThatThrownBy(() -> service.updatePallet(TENANT, WAREHOUSE, 40002L, - new PalletRequests.SaveRequest(null, "STANDARD", null, BigDecimal.ONE, "IDLE", null, null))) + new PalletRequests.SaveRequest(null, "STANDARD", null, BigDecimal.ONE, "IDLE", null, null, null, 0))) .isInstanceOf(BizException.class) .hasMessageContaining("cannot be reused"); } @@ -274,17 +275,20 @@ class WarehouseResourceServiceTest { existing.setId(40003L); existing.setPalletCode("PLT-OLD"); existing.setStatus("IDLE"); + existing.setVersion(2); when(repository.findPallet(TENANT, WAREHOUSE, 40003L)).thenReturn(existing); + when(repository.updatePallet(org.mockito.ArgumentMatchers.any())).thenReturn(1); service.updatePallet(TENANT, WAREHOUSE, 40003L, new PalletRequests.SaveRequest("PLT-NEW", "WOOD", new BigDecimal("2.5000"), - new BigDecimal("25.00"), "IDLE", null, "updated", "ONT8")); + new BigDecimal("25.00"), "IDLE", null, "updated", "ONT8", 2)); ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); verify(repository).palletCodeExists(TENANT, WAREHOUSE, "PLT-NEW", 40003L); verify(repository).updatePallet(captor.capture()); assertThat(captor.getValue().getPalletCode()).isEqualTo("PLT-NEW"); assertThat(captor.getValue().getFbaWarehouseCode()).isEqualTo("ONT8"); + assertThat(captor.getValue().getVersion()).isEqualTo(2); } @Test @@ -294,17 +298,39 @@ class WarehouseResourceServiceTest { existing.setId(40004L); existing.setPalletCode("PLT-OLD"); existing.setStatus("IDLE"); + existing.setVersion(0); when(repository.findPallet(TENANT, WAREHOUSE, 40004L)).thenReturn(existing); when(repository.palletCodeExists(TENANT, WAREHOUSE, "PLT-DUP", 40004L)).thenReturn(true); assertThatThrownBy(() -> service.updatePallet(TENANT, WAREHOUSE, 40004L, new PalletRequests.SaveRequest("PLT-DUP", "WOOD", BigDecimal.ONE, - BigDecimal.ONE, "IDLE", null, null, "ONT8"))) + BigDecimal.ONE, "IDLE", null, null, "ONT8", 0))) .isInstanceOf(BizException.class) .hasMessageContaining("托盘编码已存在"); verify(repository, never()).updatePallet(org.mockito.ArgumentMatchers.any()); } + @Test + void updatePalletRejectsStaleVersionBeforeMutation() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + WarehousePalletEntity existing = new WarehousePalletEntity(); + existing.setId(40005L); + existing.setPalletCode("PLT-OLD"); + existing.setStatus("IDLE"); + existing.setVersion(3); + when(repository.findPallet(TENANT, WAREHOUSE, 40005L)).thenReturn(existing); + + assertThatThrownBy(() -> service.updatePallet(TENANT, WAREHOUSE, 40005L, + new PalletRequests.SaveRequest("PLT-STALE", "WOOD", BigDecimal.ONE, + BigDecimal.ONE, "IDLE", null, null, "ONT8", 2))) + .isInstanceOf(BizException.class) + .hasMessageContaining("版本已变化"); + verify(repository, never()).palletCodeExists(org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any()); + verify(repository, never()).updatePallet(org.mockito.ArgumentMatchers.any()); + } + private static WarehouseEntity warehouse() { WarehouseEntity warehouse = new WarehouseEntity(); warehouse.setId(WAREHOUSE); From ef9f253f479762343d89c13b18beb7c9e924baf2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 15:25:34 +0800 Subject: [PATCH 025/115] feat(lastleg): expose inbound PDA work instructions --- .../InboundPalletAssignmentController.java | 9 ++++ .../inboundplan/pda/dto/PdaResponses.java | 14 ++++++- .../service/InboundPdaWorkflowService.java | 23 +++++++++- ...InboundPalletAssignmentControllerTest.java | 42 +++++++++++++++++++ .../InboundPdaWorkflowServiceTest.java | 17 +++++++- 5 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentControllerTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java index eb33d0f29..387334dae 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java @@ -26,6 +26,7 @@ public class InboundPalletAssignmentController { @PathVariable String planNo, @PathVariable long planPalletId, @RequestParam(required = false) String customerId, @Valid @RequestBody PdaRequests.Assign request) { + requireOperate(user); service.assign(scope(user, customerId), planNo, planPalletId, request); return ApiResponse.success(null); } @@ -40,4 +41,12 @@ public class InboundPalletAssignmentController { } return String.valueOf(user.tenantId()); } + + private void requireOperate(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (user.permissions() == null || !user.permissions().contains(InboundPdaController.OPERATE_PERMISSION)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + InboundPdaController.OPERATE_PERMISSION + " permission is required"); + } + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java index fa261c613..4abcdd0fd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java @@ -10,7 +10,19 @@ public final class PdaResponses { public record Task(long taskId, String taskNo, String planNo, String status, LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt, List unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, - LocalDateTime estimatedEmptyReturnAt) {} + LocalDateTime estimatedEmptyReturnAt, + List pallets, List allocations) {} + + public record Pallet(long planPalletId, String palletNo, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + long warehousePalletId, long warehouseLocationId, + String assignmentStatus, int actualPackageCount) {} + + public record Allocation(long allocationId, long lineId, long planPalletId, + long orderId, String inboundCode, String fbaWarehouseCode, + int requiredPackageCount, int plannedPackageCount, + int countedPackageCount, int pickedPackageCount, + String status, int version) {} public record UnloadPhotoFile(long fileId, String fileName, String contentType, long sizeBytes) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index 4c8b09a2a..9a8a4daec 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -629,9 +629,30 @@ public class InboundPdaWorkflowService { } private PdaResponses.Task task(InboundPdaTask task, String planNo) { + List planPallets = repository.pallets(task.getPlanId()); + List planAllocations = repository.allocations(task.getPlanId()); + Map lines = repository.lines(task.getPlanId()).stream() + .collect(java.util.stream.Collectors.toMap(InboundPlanLine::getId, value -> value)); + List pallets = planPallets.stream().map(value -> new PdaResponses.Pallet( + value.getId(), value.getPalletNo(), value.getDestinationWarehouseId(), + value.getDestinationWarehouseCode(), value.getDestinationWarehouseName(), + Objects.requireNonNullElse(value.getWarehousePalletId(), 0L), + Objects.requireNonNullElse(value.getWarehouseLocationId(), 0L), + value.getAssignmentStatus(), InboundPdaWorkflowService.value(value.getActualPackageCount()))).toList(); + List allocations = planAllocations.stream().map(value -> { + InboundPlanLine line = lines.get(value.getLineId()); + if (line == null) throw conflict("Inbound allocation line is missing"); + return new PdaResponses.Allocation(value.getId(), value.getLineId(), value.getPalletId(), + line.getOrderId(), line.getInboundCode(), line.getFbaWarehouseCode(), + InboundPdaWorkflowService.value(line.getRequiredPackageCount()), + InboundPdaWorkflowService.value(value.getPlannedPackageCount()), + InboundPdaWorkflowService.value(value.getCountedPackageCount()), + InboundPdaWorkflowService.value(value.getPickedPackageCount()), value.getStatus(), + InboundPdaWorkflowService.value(value.getVersion())); + }).toList(); return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(), task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task), - task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt()); + task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(), pallets, allocations); } private List unloadPhotoFileIds(InboundPdaTask task) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentControllerTest.java new file mode 100644 index 000000000..b00867007 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentControllerTest.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.inboundplan.pda.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests; +import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class InboundPalletAssignmentControllerTest { + private final InboundPdaWorkflowService service = mock(InboundPdaWorkflowService.class); + private final InboundPalletAssignmentController controller = controller(); + private final PdaRequests.Assign request = new PdaRequests.Assign(1L, 2L, 3L, "assign-key"); + + @Test + void assignmentRequiresPdaOperatePermissionAndExplicitPlatformCustomer() { + assertThrows(ResponseStatusException.class, + () -> controller.assign(user(Set.of()), "PLAN-1", 4L, "100", request)); + assertThrows(ResponseStatusException.class, + () -> controller.assign(user(Set.of(InboundPdaController.OPERATE_PERMISSION)), "PLAN-1", 4L, null, request)); + + controller.assign(user(Set.of(InboundPdaController.OPERATE_PERMISSION)), "PLAN-1", 4L, "100", request); + + verify(service).assign("100", "PLAN-1", 4L, request); + } + + private AuthenticatedUser user(Set permissions) { + return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "PDA", permissions); + } + + private InboundPalletAssignmentController controller() { + InboundPalletAssignmentController value = new InboundPalletAssignmentController(); + ReflectionTestUtils.setField(value, "service", service); + return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java index e1353af91..27e13436b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java @@ -95,6 +95,21 @@ class InboundPdaWorkflowServiceTest { verify(repository, never()).insert(any(InboundPdaTask.class)); } + @Test + void taskReturnsPalletAndAllocationWorkInstructions() { + when(repository.task("100", plan.getId())).thenReturn(task); + when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet)); + when(repository.allocations(plan.getId())).thenReturn(List.of(target)); + when(repository.lines(plan.getId())).thenReturn(List.of(targetLine)); + + var result = service.task("100", "LIP1"); + + assertEquals(targetPallet.getId(), result.pallets().get(0).planPalletId()); + assertEquals(target.getId(), result.allocations().get(0).allocationId()); + assertEquals("RK-A", result.allocations().get(0).inboundCode()); + assertEquals(3, result.allocations().get(0).plannedPackageCount()); + } + @Test void assignmentReservesWarehouseResourcesAndPersistsTheLock() { targetPallet.setDestinationWarehouseId("88"); @@ -427,7 +442,7 @@ class InboundPdaWorkflowServiceTest { assertEquals("COMPLETED", result.status()); verify(stockReceipts, never()).stockIn(any(), any(), any(), any(), any(), any(), any()); - verify(repository, never()).allocations(plan.getId()); + verify(repository, never()).update(task); } @Test From 6aca41427ccd282c2d823334bc3481c1b43dfb71 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 15:38:28 +0800 Subject: [PATCH 026/115] feat(phase4): automate overseas PDA inbound lifecycle --- deploy/sql/头程数据库统一增量-MySQL8.sql | 1 + .../main/resources/application-overseas.yml | 2 +- .../OverseasLastlegRouteContractTest.java | 4 +- .../main/resources/db/lastleg-permissions.sql | 1 + ...erseasPhase4PermissionSqlContractTest.java | 3 +- .../mapper/InboundPlanCartonMapper.java | 11 + .../inboundplan/pda/dto/PdaResponses.java | 7 +- .../pda/repository/PdaWorkflowRepository.java | 1 + .../service/InboundPdaWorkflowService.java | 16 +- .../service/InboundPlanService.java | 3 +- .../lastleg/service/LastlegOrderService.java | 12 + .../InboundPdaWorkflowServiceTest.java | 6 + .../service/InboundPlanServiceTest.java | 18 ++ .../service/LastlegOrderServiceTest.java | 9 + scripts/admin-test-env-acceptance.mjs | 27 ++- scripts/admin-test-env-modules/README.md | 5 +- .../phase4-overseas-pda-contract.test.mjs | 48 ++++ .../phase4-overseas-pda.mjs | 210 ++++++++++++++++++ ...verseas-acceptance-entry-contract.test.mjs | 2 + 19 files changed, 377 insertions(+), 9 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase4-overseas-pda.mjs diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 51b63c660..ccc7e9bf0 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16213,6 +16213,7 @@ VALUES (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0), (5107013,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:fba-appointment','FBA预约',NULL,'POST',713,'ACTIVE',1,0), (5107014,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:smart-dispatch','智能排车',NULL,'POST',714,'ACTIVE',1,0), + (5107015,0,'PLATFORM','BUTTON',5107010,'lastleg:pda:operate','海外仓PDA入仓作业',NULL,'POST',715,'ACTIVE',1,0), (5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,0), (5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,0), (5107022,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:status','变更海外换标单状态',NULL,'PUT',722,'ACTIVE',1,0), diff --git a/gateway-overseas/src/main/resources/application-overseas.yml b/gateway-overseas/src/main/resources/application-overseas.yml index 5866a7caf..f7204c15d 100644 --- a/gateway-overseas/src/main/resources/application-overseas.yml +++ b/gateway-overseas/src/main/resources/application-overseas.yml @@ -10,7 +10,7 @@ spring: routes: - id: lastleg-service uri: lb://lastleg-service - predicates: ["Path=/api/merchant/v1/last-leg/**,/api/platform/v1/last-leg/**"] + predicates: ["Path=/api/merchant/v1/last-leg/**,/api/platform/v1/last-leg/**,/api/pda/v1/last-leg/**"] - id: warehouse-overseas-service uri: lb://warehouse-overseas-service predicates: ["Path=/api/merchant/v1/warehouses/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**"] diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java index 1a054fb2c..e83bb3312 100644 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java @@ -10,7 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat; class OverseasLastlegRouteContractTest { @Test - void platformDispatchPathIsForwardedToLastlegWithoutPrefixRewrite() throws IOException { + void browserAndPdaPathsAreForwardedToLastlegWithoutPrefixRewrite() throws IOException { try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) { assertThat(input).isNotNull(); String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); @@ -22,6 +22,8 @@ class OverseasLastlegRouteContractTest { assertThat(route).contains("uri: lb://lastleg-service") .contains("/api/platform/v1/last-leg/**") + .contains("/api/merchant/v1/last-leg/**") + .contains("/api/pda/v1/last-leg/**") .doesNotContain("StripPrefix", "RewritePath"); } } diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index 5bd6ff291..f8aebcd23 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -19,6 +19,7 @@ VALUES (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0), (5107013,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:fba-appointment','FBA预约',NULL,'POST',713,'ACTIVE',1,0), (5107014,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:smart-dispatch','智能排车',NULL,'POST',714,'ACTIVE',1,0), + (5107015,0,'PLATFORM','BUTTON',5107010,'lastleg:pda:operate','海外仓PDA入仓作业',NULL,'POST',715,'ACTIVE',1,0), (5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,0), (5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,0), (5107022,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:status','变更海外换标单状态',NULL,'PUT',722,'ACTIVE',1,0), diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index c333f01e1..01b84837d 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -39,6 +39,7 @@ class OverseasPhase4PermissionSqlContractTest { .getContentAsString(StandardCharsets.UTF_8); Set catalog = codes(sql); assertThat(sql).contains(REQUIRED.toArray(String[]::new)); + assertThat(sql).contains("'lastleg:pda:operate'"); assertThat(catalog).hasSize(53).noneMatch(code -> code.endsWith(":view")); assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1"); assertThat(sql).doesNotContain(":carton:", ":product:", ":outbound:", ":delivery:"); @@ -53,7 +54,7 @@ class OverseasPhase4PermissionSqlContractTest { Set moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql") .getContentAsString(StandardCharsets.UTF_8)); assertThat(codes(sql)).containsAll(moduleCatalog); - assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission"); + assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission", "'lastleg:pda:operate'"); assertCustomAdminGrant(sql); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanCartonMapper.java index f95d5cbc7..742781c8a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanCartonMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanCartonMapper.java @@ -6,6 +6,8 @@ import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import java.util.List; + @Mapper public interface InboundPlanCartonMapper extends BaseMapper { @Select(""" @@ -31,4 +33,13 @@ public interface InboundPlanCartonMapper extends BaseMapper { WHERE l.plan_id=#{planId}) """) boolean hasLabelPool(@Param("planId") Long planId); + + @Select(""" + SELECT DISTINCT c.* FROM ll_inbound_plan_carton c + JOIN ll_order_detail d ON d.source_inbound_detail_id=c.source_inbound_detail_id + JOIN ll_inbound_plan_line l ON l.order_id=d.order_id + WHERE l.plan_id=#{planId} AND c.customer_id=l.customer_id + ORDER BY c.id + """) + List listExpected(@Param("planId") Long planId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java index 4abcdd0fd..ca79c2b25 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java @@ -11,7 +11,8 @@ public final class PdaResponses { LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt, List unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, LocalDateTime estimatedEmptyReturnAt, - List pallets, List allocations) {} + List pallets, List allocations, + List expectedCartons) {} public record Pallet(long planPalletId, String palletNo, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, @@ -24,6 +25,10 @@ public final class PdaResponses { int countedPackageCount, int pickedPackageCount, String status, int version) {} + public record ExpectedCarton(String cartonMark, String skuCode, + String destinationWarehouseId, String destinationWarehouseCode, + long planPalletId, String status) {} + public record UnloadPhotoFile(long fileId, String fileName, String contentType, long sizeBytes) {} public record Operation(String result, long allocationId, int requiredPackageCount, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java index 6d8dd4cbe..5f8f1f8b3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java @@ -63,6 +63,7 @@ public class PdaWorkflowRepository { public List cartons(Long planId) { return cartons.selectList(new LambdaQueryWrapper().eq(InboundPlanCarton::getPlanId, planId)); } + public List expectedCartons(Long planId) { return cartons.listExpected(planId); } public InboundPlanLine lineForCarton(Long planId, InboundPlanCarton carton) { return cartons.findPlanLine(planId, carton.getSourceInboundDetailId()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index 9a8a4daec..e34ab20bd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -650,9 +650,23 @@ public class InboundPdaWorkflowService { InboundPdaWorkflowService.value(value.getPickedPackageCount()), value.getStatus(), InboundPdaWorkflowService.value(value.getVersion())); }).toList(); + List expectedCartons = repository.expectedCartons(task.getPlanId()).stream() + .map(value -> new PdaResponses.ExpectedCarton(value.getCartonMark(), value.getSkuCode(), + value.getDestinationWarehouseId(), value.getDestinationWarehouseCode(), + recommendedPallet(value, planPallets), value.getStatus())) + .toList(); return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(), task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task), - task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(), pallets, allocations); + task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(), pallets, allocations, + expectedCartons); + } + + private long recommendedPallet(InboundPlanCarton carton, List pallets) { + if (carton.getPlanPalletId() != null) return carton.getPlanPalletId(); + return pallets.stream().filter(value -> Objects.equals(value.getDestinationWarehouseId(), carton.getDestinationWarehouseId()) + || (carton.getDestinationWarehouseId() == null + && Objects.equals(value.getDestinationWarehouseCode(), carton.getDestinationWarehouseCode()))) + .map(InboundPlanPallet::getId).findFirst().orElse(0L); } private List unloadPhotoFileIds(InboundPdaTask task) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 8ec5e1d9d..7ef011637 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -224,7 +224,8 @@ public class InboundPlanService { private void validateOrders(List orders) { for (SourceOrderSnapshot order : orders) { - if (!"PENDING_ASSIGNMENT".equals(order.currentStatus())) { + if (!("PENDING_ASSIGNMENT".equals(order.currentStatus()) + || "DESTINATION_ASSIGNED".equals(order.currentStatus()))) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Inbound code " + order.inboundCode() + " is not ready for an inbound plan"); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 1f2845e1c..0dd2de096 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -9,6 +9,8 @@ import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; @@ -35,6 +37,7 @@ public class LastlegOrderService { @Resource private LastlegOrderMapper orderMapper; @Resource private LastlegOrderDetailMapper detailMapper; @Resource private LastlegOrderStatusHistoryMapper historyMapper; + @Resource private InboundPlanCartonMapper cartonMapper; public PageResult page(String customerId, LastlegOrderRequests.Query query) { Page page = orderMapper.selectPage( @@ -88,8 +91,17 @@ public class LastlegOrderService { detailUpdate.setDestinationWarehouseCode(request.warehouseCode()); detailUpdate.setDestinationWarehouseName(request.warehouseName()); detailMapper.update(detailUpdate, new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, order.getCustomerId()) .eq(LastlegOrderDetail::getOrderId, order.getId())); + InboundPlanCarton cartonUpdate = new InboundPlanCarton(); + cartonUpdate.setDestinationWarehouseId(request.warehouseId()); + cartonUpdate.setDestinationWarehouseCode(request.warehouseCode()); + cartonUpdate.setDestinationWarehouseName(request.warehouseName()); + cartonMapper.update(cartonUpdate, new LambdaQueryWrapper() + .eq(InboundPlanCarton::getCustomerId, order.getCustomerId()) + .eq(InboundPlanCarton::getOrderId, order.getId())); + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(order.getCustomerId()); history.setOrderId(order.getId()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java index 27e13436b..dc7127c2d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java @@ -101,6 +101,9 @@ class InboundPdaWorkflowServiceTest { when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet)); when(repository.allocations(plan.getId())).thenReturn(List.of(target)); when(repository.lines(plan.getId())).thenReturn(List.of(targetLine)); + InboundPlanCarton expected = carton("BOX-INSTRUCTION", "EXPECTED", targetLine.getId(), "WH-A"); + expected.setSkuCode("SKU-A"); + when(repository.expectedCartons(plan.getId())).thenReturn(List.of(expected)); var result = service.task("100", "LIP1"); @@ -108,6 +111,9 @@ class InboundPdaWorkflowServiceTest { assertEquals(target.getId(), result.allocations().get(0).allocationId()); assertEquals("RK-A", result.allocations().get(0).inboundCode()); assertEquals(3, result.allocations().get(0).plannedPackageCount()); + assertEquals("BOX-INSTRUCTION", result.expectedCartons().get(0).cartonMark()); + assertEquals("SKU-A", result.expectedCartons().get(0).skuCode()); + assertEquals(targetPallet.getId(), result.expectedCartons().get(0).planPalletId()); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index 7dd827a96..43ff8cbcf 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -95,6 +95,24 @@ class InboundPlanServiceTest { assertEquals("CONT-1", result.containerNo()); } + @Test + void convertsAConsistentDestinationAssignedOrder() { + SourceOrderSnapshot assigned = new SourceOrderSnapshot(2L, "100", "CONT-1", "RK-2", + "DESTINATION_ASSIGNED", null, new BigDecimal("1.0000"), 10, + "2090000000000000001", "LAX-02", "Los Angeles 02", 1, 0); + when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(assigned)); + when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any())) + .thenReturn(1); + + var result = service.convert("100", 2L); + + assertEquals(2L, result.sourceOrderId()); + ArgumentCaptor line = ArgumentCaptor.forClass(InboundPlanLine.class); + verify(lineMapper).insert(line.capture()); + assertEquals("2090000000000000001", line.getValue().getDestinationWarehouseId()); + verify(planMapper).insert(any(InboundPlan.class)); + } + @Test void repeatedConversionReturnsExistingPlanWithoutWriting() { InboundPlan existing = new InboundPlan(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index fdc785be6..9f7a4f9ab 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -4,6 +4,8 @@ import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; @@ -31,6 +33,7 @@ class LastlegOrderServiceTest { @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; + @Mock InboundPlanCartonMapper cartons; LastlegOrderService service; @BeforeEach @@ -39,6 +42,7 @@ class LastlegOrderServiceTest { ReflectionTestUtils.setField(service, "orderMapper", orders); ReflectionTestUtils.setField(service, "detailMapper", details); ReflectionTestUtils.setField(service, "historyMapper", histories); + ReflectionTestUtils.setField(service, "cartonMapper", cartons); } @Test @@ -76,6 +80,10 @@ class LastlegOrderServiceTest { ArgumentCaptor cargo = ArgumentCaptor.forClass(LastlegOrderDetail.class); verify(details).update(cargo.capture(), any()); assertEquals("LAX-02", cargo.getValue().getDestinationWarehouseCode()); + ArgumentCaptor carton = ArgumentCaptor.forClass(InboundPlanCarton.class); + verify(cartons).update(carton.capture(), any()); + assertEquals("wh-2", carton.getValue().getDestinationWarehouseId()); + assertEquals("LAX-02", carton.getValue().getDestinationWarehouseCode()); ArgumentCaptor history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class); verify(histories).insert(history.capture()); assertEquals("100", history.getValue().getCustomerId()); @@ -93,6 +101,7 @@ class LastlegOrderServiceTest { assertThrows(ResponseStatusException.class, () -> service.transferDestination("100", request)); verify(orders, never()).updateById(any(LastlegOrder.class)); verify(details, never()).update(any(), any()); + verify(cartons, never()).update(any(), any()); } @Test diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 13035edfa..7f3de861f 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -13,7 +13,7 @@ const DEFAULT_GATEWAY = 'http://8.163.30.128:9000' const DEFAULT_OVERSEAS_GATEWAY = 'http://8.163.30.128:9300' const DEFAULT_USERNAME = 'platform_admin' const DEFAULT_PASSWORD = 'ChangeMe123!' -const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] +const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-pda', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] export function normalizeModuleOrder(modules) { const normalized = [...modules] @@ -266,6 +266,31 @@ export class AcceptanceRun { return this.requestWithBody(method, path, form, token, extraHeaders) } + async requestOverseasMultipart(method, path, file, token = this.overseasToken, extraHeaders = {}) { + const form = file instanceof FormData ? file : new FormData() + if (!(file instanceof FormData)) { + if (!file || file.content === undefined) throw new TypeError('multipart file requires content') + const content = file.content instanceof Uint8Array ? file.content : new Uint8Array(file.content) + form.append('file', new Blob([content], { type: file.type || 'application/octet-stream' }), file.name || 'fixture.bin') + } + const headers = { Accept: 'application/json', ...extraHeaders } + if (token) headers.Authorization = `Bearer ${token}` + try { + const response = await fetch(`${this.options.overseasGateway}${path}`, { + method, headers, body: form, signal: AbortSignal.timeout(60_000), + }) + const content = await response.text(); let payload = null + try { payload = content ? JSON.parse(content) : null } catch { /* bounded raw response */ } + return { method, path, http: response.status, body: payload, + raw: payload === null ? content.slice(0, 500) : null, + requestId: payload?.requestId || response.headers.get('x-request-id'), + traceId: payload?.traceId || response.headers.get('x-trace-id') } + } catch (error) { + return { method, path, http: 0, body: null, + raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } + } + } + async requestWithBody(method, path, body, token = this.token, extraHeaders = {}) { const headers = { Accept: 'application/json', ...extraHeaders } if (token) headers.Authorization = `Bearer ${token}` diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 20752ba46..c576b622e 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -55,6 +55,7 @@ export async function run(context) { | 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` | | Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` | | 四期海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` | +| 四期海外 PDA 入仓 HTTP 生命周期 | `phase4-overseas-pda.mjs`(自动选择未转换尾程单、创建目标仓/库位/托盘并完成入库;完成事实保留审计) | `phase4-overseas-pda-contract.test.mjs` | | 四期海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` | | 四期海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` | | 四期船节点安全冒烟 | `phase4-carrier-nodes.mjs`(standalone 对历史任务只读;仅复用当前 marker 的 `phase3CarrierScheduleQuery` 时补录预计事实) | `phase4-carrier-nodes-contract.test.mjs` | @@ -62,10 +63,10 @@ export async function run(context) { 表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。 -四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;船节点禁止手工创建任务。所有模块继续禁止调用海外出库、派送、PDA 或内部接口。开发环境定向执行示例: +四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;独立 PDA 模块只调用 `/api/pda/v1/last-leg/inbound-plans/**` 完成入仓,不调用出库、派送或内部接口;船节点禁止手工创建任务。 ```powershell -node scripts/admin-test-env-acceptance.mjs --gateway http://8.163.49.32:9000 --overseas-gateway http://8.163.49.32:9300 --modules phase4-overseas-warehouse,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes +node scripts/admin-test-env-acceptance.mjs --gateway --overseas-gateway --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes ``` 只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.mjs` 不代表总入口已注册。新增或改名 HTTP 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。 diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs new file mode 100644 index 000000000..aae1a22e9 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +import { run } from './phase4-overseas-pda.mjs' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda.mjs'), 'utf8') + +test('PDA module uses only injected development acceptance requests', () => { + assert.match(source, /export async function run\(context\)/) + assert.match(source, /context\.requestOverseas\(/) + assert.match(source, /context\.requestOverseasMultipart\(/) + assert.doesNotMatch(source, /https?:\/\//) + assert.doesNotMatch(source, /process\.env|fetch\(/) +}) + +test('PDA module covers the complete receiving lifecycle and excludes outbound domains', () => { + for (const token of ['/destination', '/inbound-plans/convert', '/pallets/', '/push', '/cartons/scan', '/allocations/', + '/count', '/pick', '/unload-photos/files', '/unload-photos', '/complete', '/inventory/balances']) { + assert.ok(source.includes(token), token) + } + for (const forbidden of ['/outbound', '/dispatch', '/delivery', '/internal/']) assert.equal(source.includes(forbidden), false, forbidden) + assert.match(source, /requestId=/) + assert.match(source, /traceId=/) + assert.match(source, /PENDING_ASSIGNMENT/) + assert.match(source, /expectedCartons/) +}) + +test('missing source order is reported as a fixture prerequisite without business writes', async () => { + const calls = []; const gaps = []; const steps = [] + const context = { + marker: 'PDA-CONTRACT', createdResources: {}, + async request(method, requestPath) { calls.push({ channel: 'domestic', method, requestPath }); return { http: 200, body: { code: '200', data: { records: [], page: 1, size: 200, total: 0, pages: 0 } } } }, + async requestOverseas(method, requestPath) { calls.push({ channel: 'overseas', method, requestPath }); throw new Error('overseas should not be called without customers') }, + addStep(...args) { steps.push(args) }, + assert(...args) { steps.push(args) }, + addCoverageGap(...args) { gaps.push(args) }, + } + await run(context) + assert.equal(calls.length, 1) + assert.equal(calls[0].method, 'GET') + assert.equal(gaps.length, 1) + assert.match(gaps[0][3], /三期装柜完成事件链/) + assert.deepEqual(context.createdResources.phase4OverseasPda.finalStatus, null) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs new file mode 100644 index 000000000..787abc34f --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -0,0 +1,210 @@ +const MODULE = 'phase4-overseas-pda' +const SCENARIO = 'phase4-overseas-pda-inbound-lifecycle' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } + +function id(value) { return value == null ? '' : String(value) } +function longId(value) { return /^\d+$/.test(id(value)) && id(value) !== '0' } +function records(page) { return Array.isArray(page?.records) ? page.records : [] } +function query(path, params) { + const search = new URLSearchParams() + for (const [key, value] of Object.entries(params)) if (value !== undefined && value !== null && value !== '') search.set(key, String(value)) + return `${path}?${search}` +} +function trace(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' } +function key(context, action, suffix = '') { return `${context.marker}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) } + +async function domesticGet(context, name, path) { + const response = await context.request('GET', path) + const passed = success(response) + context.addStep(MODULE, name, response, passed, trace(response), OPTIONS) + return passed ? response.body.data : null +} +async function overseas(context, method, name, path, body = undefined) { + const response = await context.requestOverseas(method, path, body) + const passed = success(response) + context.addStep(MODULE, name, response, passed, trace(response), OPTIONS) + return { response, data: passed ? response.body.data : null } +} + +async function findUnplannedOrder(context) { + const tenants = records(await domesticGet(context, '查询PDA可用客户', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE')) + for (const tenant of tenants) { + const customerId = id(tenant?.id) + if (!longId(customerId)) continue + const plans = await overseas(context, 'GET', `查询客户${customerId}既有入仓计划`, query('/api/platform/v1/last-leg/inbound-plans', { customerId, page: 1, size: 200 })) + if (!success(plans.response)) continue + const planned = new Set(records(plans.data).map((item) => id(item?.sourceOrderId)).filter(Boolean)) + const orders = await overseas(context, 'GET', `查询客户${customerId}待规划尾程单`, query('/api/platform/v1/last-leg/orders', { customerId, currentStatus: 'PENDING_ASSIGNMENT', page: 1, size: 200 })) + if (!success(orders.response)) continue + const order = records(orders.data).find((item) => longId(item?.id) && !planned.has(id(item.id))) + if (order) return { customerId, order } + } + return null +} + +async function createWarehouse(context, customerId) { + const suffix = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase() + const warehouseCode = `PDA-${suffix}`.slice(0, 50) + const created = await overseas(context, 'POST', '创建PDA入仓目标海外仓', '/api/platform/v1/warehouse-admin', { + customerId, warehouseCode, warehouseName: `${context.marker} PDA入仓仓`, warehouseType: 'OVERSEAS', + countryCode: 'US', timezone: 'America/Los_Angeles', contactName: 'Phase4 PDA', + contactPhone: '+1-213-555-0199', contactEmail: null, status: 'ACTIVE', remark: `${context.marker}-pda-inbound`, + address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles', + district: 'Commerce', postalCode: '90040', addressLine1: '502 Phase Four PDA Avenue', addressLine2: null, + latitude: null, longitude: null }, + capabilities: [{ capabilityCode: 'STORAGE', capabilityName: 'Storage', enabled: true, cutoffTime: null, + description: `${context.marker} PDA inbound acceptance` }], + }) + const warehouseId = id(created.data) + context.assert(MODULE, 'PDA目标海外仓返回长ID', created.response, longId(warehouseId), + `warehouseId=${warehouseId || 'missing'}; ${trace(created.response)}`, OPTIONS) + return { warehouseId, warehouseCode, warehouseName: `${context.marker} PDA入仓仓` } +} + +async function createResource(context, warehouseId, index, fbaWarehouseCode) { + const suffix = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-14).toUpperCase() + const location = await overseas(context, 'POST', `创建PDA库位${index + 1}`, + `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { + zoneCode: `PDA-${index + 1}`, locationCode: `PDA-${suffix}-${index + 1}`.slice(0, 64), + locationType: 'STORAGE', maxLoadKg: 5000, status: 'IDLE', remark: context.marker, + }) + const locationId = id(location.data) + context.assert(MODULE, `PDA库位${index + 1}返回长ID`, location.response, longId(locationId), + `locationId=${locationId || 'missing'}; ${trace(location.response)}`, OPTIONS) + const pallet = await overseas(context, 'POST', `创建PDA实物托盘${index + 1}`, + `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { + palletCode: `PDA-${suffix}-${index + 1}`.slice(0, 128), palletType: '木托盘', capacityCbm: 2, + unitPrice: 0, status: 'IDLE', locationId: longId(locationId) ? locationId : undefined, + remark: context.marker, fbaWarehouseCode: fbaWarehouseCode || null, + }) + const palletId = id(pallet.data) + context.assert(MODULE, `PDA实物托盘${index + 1}返回长ID`, pallet.response, longId(palletId), + `palletId=${palletId || 'missing'}; ${trace(pallet.response)}`, OPTIONS) + return { locationId, palletId } +} + +async function operateExpectedCartons(context, customerId, planNo, task) { + for (const [index, carton] of (task.expectedCartons || []).entries()) { + if (carton.status === 'ON_PALLET' || carton.status === 'AVAILABLE') continue + const scanned = await overseas(context, 'POST', `扫描国内冻结箱唛${index + 1}`, + query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/cartons/scan`, { customerId }), { + cartonMark: carton.cartonMark, planPalletId: id(carton.planPalletId), + idempotencyKey: key(context, 'scan', String(index + 1)), operatorId: 'phase4-http', operatorName: '四期HTTP验收', + }) + context.assert(MODULE, `箱唛${index + 1}已绑定托盘`, scanned.response, + scanned.data?.result === 'SCANNED' && scanned.data?.cartonMark === carton.cartonMark, + `cartonMark=${carton.cartonMark || 'missing'}, result=${scanned.data?.result || 'missing'}; ${trace(scanned.response)}`, OPTIONS) + } +} + +async function operateLegacyAllocations(context, customerId, planNo, task) { + for (const [index, allocation] of (task.allocations || []).entries()) { + const remaining = Number(allocation.plannedPackageCount || 0) - Number(allocation.pickedPackageCount || 0) + if (remaining <= 0) continue + await overseas(context, 'POST', `清点入仓分配${index + 1}`, + query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/allocations/${encodeURIComponent(id(allocation.allocationId))}/count`, { customerId }), { + packageCount: Number(allocation.plannedPackageCount), idempotencyKey: key(context, 'count', String(index + 1)), + operatorId: 'phase4-http', operatorName: '四期HTTP验收', + }) + await overseas(context, 'POST', `实收入仓分配${index + 1}`, + query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/allocations/${encodeURIComponent(id(allocation.allocationId))}/pick`, { customerId }), { + packageCount: remaining, idempotencyKey: key(context, 'pick', String(index + 1)), + operatorId: 'phase4-http', operatorName: '四期HTTP验收', + }) + } +} + +export async function run(context) { + context.createdResources.phase4OverseasPda = { marker: context.marker, customerId: null, orderId: null, + planNo: null, warehouseId: null, taskId: null, finalStatus: null, retained: true } + const source = await findUnplannedOrder(context) + if (!source) { + context.addCoverageGap(MODULE, SCENARIO, 'PDA入仓来源尾程单', + '所有ACTIVE客户均无尚未转换、状态为PENDING_ASSIGNMENT的尾程单;请先运行三期装柜完成事件链', 'fixture-unavailable') + return + } + const { customerId, order } = source + const warehouse = await createWarehouse(context, customerId) + if (!longId(warehouse.warehouseId)) return + Object.assign(context.createdResources.phase4OverseasPda, { customerId, orderId: id(order.id), warehouseId: warehouse.warehouseId }) + + const transferred = await overseas(context, 'PUT', '尾程单指定本轮PDA目标仓', + query(`/api/platform/v1/last-leg/orders/${encodeURIComponent(id(order.id))}/destination`, { customerId }), { + warehouseId: warehouse.warehouseId, warehouseCode: warehouse.warehouseCode, + warehouseName: warehouse.warehouseName, remark: `${context.marker} PDA lifecycle`, + }) + if (!success(transferred.response)) return + const converted = await overseas(context, 'POST', '尾程单转换入仓计划', '/api/platform/v1/last-leg/inbound-plans/convert', { + orderId: id(order.id), customerId, + }) + const plan = converted.data + const planNo = plan?.planNo || '' + context.assert(MODULE, '入仓计划转换返回完整作业结构', converted.response, + Boolean(planNo) && Array.isArray(plan?.pallets) && plan.pallets.length > 0 && Array.isArray(plan?.allocations) && plan.allocations.length > 0, + `planNo=${planNo || 'missing'}, pallets=${plan?.pallets?.length ?? 'missing'}, allocations=${plan?.allocations?.length ?? 'missing'}; ${trace(converted.response)}`, OPTIONS) + if (!planNo || !Array.isArray(plan?.pallets) || !plan.pallets.length) return + context.createdResources.phase4OverseasPda.planNo = planNo + + for (const [index, planPallet] of plan.pallets.entries()) { + const resource = await createResource(context, warehouse.warehouseId, index, planPallet.destinationWarehouseCode) + if (!longId(resource.locationId) || !longId(resource.palletId)) return + const assigned = await overseas(context, 'POST', `分配计划托盘${index + 1}`, + query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/pallets/${encodeURIComponent(id(planPallet.id))}/assign`, { customerId }), { + warehouseId: warehouse.warehouseId, palletId: resource.palletId, locationId: resource.locationId, + idempotencyKey: key(context, 'assign', String(index + 1)), + }) + if (!success(assigned.response)) return + } + + const pushed = await overseas(context, 'POST', '推送PDA入仓任务', + query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/push`, { customerId }), { + idempotencyKey: key(context, 'push'), operatorId: 'phase4-http', operatorName: '四期HTTP验收', + }) + const task = pushed.data + context.assert(MODULE, 'PDA任务包含托盘、分配和箱唛指令', pushed.response, + longId(task?.taskId) && Array.isArray(task?.pallets) && task.pallets.length === plan.pallets.length + && Array.isArray(task?.allocations) && task.allocations.length > 0 && Array.isArray(task?.expectedCartons), + `taskId=${id(task?.taskId) || 'missing'}, pallets=${task?.pallets?.length ?? 'missing'}, allocations=${task?.allocations?.length ?? 'missing'}, cartons=${task?.expectedCartons?.length ?? 'missing'}; ${trace(pushed.response)}`, OPTIONS) + if (!longId(task?.taskId)) return + context.createdResources.phase4OverseasPda.taskId = id(task.taskId) + + if ((task.expectedCartons || []).length > 0) await operateExpectedCartons(context, customerId, planNo, task) + else await operateLegacyAllocations(context, customerId, planNo, task) + + const photo = await context.requestOverseasMultipart('POST', + query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/unload-photos/files`, { customerId }), { + name: `${context.marker}-unload.png`, type: 'image/png', + content: Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')), + }) + context.addStep(MODULE, '上传真实卸柜照片文件', photo, success(photo), trace(photo), OPTIONS) + const fileId = id(photo?.body?.data?.fileId) + context.assert(MODULE, '卸柜照片返回长文件ID', photo, longId(fileId), `fileId=${fileId || 'missing'}; ${trace(photo)}`, OPTIONS) + if (!longId(fileId)) return + const bound = await overseas(context, 'POST', '绑定卸柜照片并生成预计报空时间', + query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/unload-photos`, { customerId }), { + fileIds: [fileId], idempotencyKey: key(context, 'photos'), operatorId: 'phase4-http', operatorName: '四期HTTP验收', + }) + context.assert(MODULE, '照片凭证含上传时间和预计报空时间', bound.response, + bound.data?.unloadPhotoFileIds?.map(id).includes(fileId) && Boolean(bound.data?.unloadPhotoUploadedAt) && Boolean(bound.data?.estimatedEmptyReturnAt), + `fileIds=${bound.data?.unloadPhotoFileIds?.map(id).join(',') || 'missing'}, estimated=${bound.data?.estimatedEmptyReturnAt || 'missing'}; ${trace(bound.response)}`, OPTIONS) + + const completed = await overseas(context, 'POST', '完成PDA入仓并写库存', + query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/complete`, { customerId })) + context.assert(MODULE, 'PDA任务进入COMPLETED终态', completed.response, + completed.data?.status === 'COMPLETED' && Boolean(completed.data?.completedAt), + `status=${completed.data?.status || 'missing'}, completedAt=${completed.data?.completedAt || 'missing'}; ${trace(completed.response)}`, OPTIONS) + context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null + + const planAfter = await overseas(context, 'GET', '回查入仓计划完成状态', + query(`/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}`, { customerId })) + context.assert(MODULE, '入仓计划与PDA任务同步完成', planAfter.response, planAfter.data?.status === 'COMPLETED', + `planStatus=${planAfter.data?.status || 'missing'}; ${trace(planAfter.response)}`, OPTIONS) + const balances = await overseas(context, 'GET', '回查PDA入仓SKU库存', + query('/api/platform/v1/last-leg/inventory/balances', { customerId, page: 1, size: 200 })) + context.assert(MODULE, 'PDA完成后生成SKU库存余额', balances.response, + records(balances.data).some((item) => Number(item?.totalPackages) > 0 && id(item?.customerId) === customerId), + `balances=${records(balances.data).length}; ${trace(balances.response)}`, OPTIONS) +} diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index e78eab28c..058b61d42 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -9,6 +9,7 @@ const source = readFileSync(resolve('scripts/admin-test-env-acceptance.mjs'), 'u test('phase-four HTTP acceptance modules are registered in the Admin acceptance entry', () => { for (const module of [ 'phase4-overseas-warehouse', + 'phase4-overseas-pda', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', @@ -28,5 +29,6 @@ test('entry accepts a separate overseas Gateway without exposing token state', ( assert.match(source, /this\.overseasToken = login\.overseasAccessToken \|\| ''/) assert.match(source, /async requestOverseas\(/) assert.match(source, /async requestOverseasBinary\(/) + assert.match(source, /async requestOverseasMultipart\(/) assert.match(source, /forbiddenValues = \[this\.options\.password, this\.token, this\.overseasToken\]/) }) From 8e12dd9a383aa50c23dd0ccc48c8294cb9ac1326 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 16:40:25 +0800 Subject: [PATCH 027/115] fix(test): select container-backed PDA fixtures --- .../phase4-overseas-pda-contract.test.mjs | 1 + scripts/admin-test-env-modules/phase4-overseas-pda.mjs | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index aae1a22e9..f144909b3 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -26,6 +26,7 @@ test('PDA module covers the complete receiving lifecycle and excludes outbound d assert.match(source, /requestId=/) assert.match(source, /traceId=/) assert.match(source, /PENDING_ASSIGNMENT/) + assert.match(source, /nonBlank\(item\?\.containerNo\)/) assert.match(source, /expectedCartons/) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 787abc34f..d312975c3 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -4,6 +4,7 @@ const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } function id(value) { return value == null ? '' : String(value) } function longId(value) { return /^\d+$/.test(id(value)) && id(value) !== '0' } +function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 } function records(page) { return Array.isArray(page?.records) ? page.records : [] } function query(path, params) { const search = new URLSearchParams() @@ -39,7 +40,8 @@ async function findUnplannedOrder(context) { const planned = new Set(records(plans.data).map((item) => id(item?.sourceOrderId)).filter(Boolean)) const orders = await overseas(context, 'GET', `查询客户${customerId}待规划尾程单`, query('/api/platform/v1/last-leg/orders', { customerId, currentStatus: 'PENDING_ASSIGNMENT', page: 1, size: 200 })) if (!success(orders.response)) continue - const order = records(orders.data).find((item) => longId(item?.id) && !planned.has(id(item.id))) + const order = records(orders.data).find((item) => longId(item?.id) && nonBlank(item?.containerNo) + && !planned.has(id(item.id))) if (order) return { customerId, order } } return null @@ -123,7 +125,7 @@ export async function run(context) { const source = await findUnplannedOrder(context) if (!source) { context.addCoverageGap(MODULE, SCENARIO, 'PDA入仓来源尾程单', - '所有ACTIVE客户均无尚未转换、状态为PENDING_ASSIGNMENT的尾程单;请先运行三期装柜完成事件链', 'fixture-unavailable') + '所有ACTIVE客户均无尚未转换、状态为PENDING_ASSIGNMENT且具有柜号的尾程单;请先运行三期装柜完成事件链', 'fixture-unavailable') return } const { customerId, order } = source From 3019f5da77112e43d7134de12ab07cd8a005619e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 16:59:02 +0800 Subject: [PATCH 028/115] fix(event-bridge): retain downstream business errors --- .../service/HttpLastlegEventClient.java | 24 +++++++++++++++-- .../service/HttpLastlegEventClientTest.java | 26 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpLastlegEventClientTest.java diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java index 3f2a904ab..e3b3dd5b8 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java @@ -45,11 +45,31 @@ public class HttpLastlegEventClient implements LastlegEventClient { .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - JsonNode responseBody = objectMapper.readTree(response.body()); + JsonNode responseBody; + try { + responseBody = objectMapper.readTree(response.body()); + } catch (Exception ignored) { + responseBody = null; + } if (response.statusCode() / 100 != 2 || responseBody == null || !"200".equals(responseBody.path("code").asText())) { - throw new IllegalStateException("尾程消费失败: HTTP " + response.statusCode()); + throw new IllegalStateException(failureMessage(response.statusCode(), responseBody)); } return String.valueOf(response.statusCode()); } + + static String failureMessage(int statusCode, JsonNode body) { + String code = body == null ? "missing" : body.path("code").asText("missing"); + String message = body == null ? "non-json response" : body.path("message").asText("missing"); + String requestId = body == null ? "missing" : body.path("requestId").asText("missing"); + String traceId = body == null ? "missing" : body.path("traceId").asText("missing"); + return "尾程消费失败: HTTP " + statusCode + ", code=" + bounded(code) + + ", message=" + bounded(message) + ", requestId=" + bounded(requestId) + + ", traceId=" + bounded(traceId); + } + + private static String bounded(String value) { + String safe = value == null || value.isBlank() ? "missing" : value.replaceAll("[\\r\\n]+", " "); + return safe.length() <= 240 ? safe : safe.substring(0, 240); + } } diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpLastlegEventClientTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpLastlegEventClientTest.java new file mode 100644 index 000000000..b2de28029 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpLastlegEventClientTest.java @@ -0,0 +1,26 @@ +package com.baiyue.eventbridge.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HttpLastlegEventClientTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void failureIncludesBusinessCodeMessageAndTraceEvidence() throws Exception { + var body = objectMapper.readTree(""" + {"code":"A0400","message":"入仓计划数据无效","requestId":"request-1","traceId":"trace-1"} + """); + + assertThat(HttpLastlegEventClient.failureMessage(200, body)) + .isEqualTo("尾程消费失败: HTTP 200, code=A0400, message=入仓计划数据无效, requestId=request-1, traceId=trace-1"); + } + + @Test + void nonJsonFailureIsStillDiagnosableWithoutLeakingRawBody() { + assertThat(HttpLastlegEventClient.failureMessage(502, null)) + .isEqualTo("尾程消费失败: HTTP 502, code=missing, message=non-json response, requestId=missing, traceId=missing"); + } +} From 8708904dab44e7a666d3f88aad01710a9f26a8e9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 17:03:29 +0800 Subject: [PATCH 029/115] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=A3=85=E6=9F=9C?= =?UTF-8?q?=E6=B8=85=E5=8D=95=E6=89=98=E7=9B=98=E6=95=B0=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mapper/InboundSplitSettlementMapper.java | 4 +++- .../firstleg/mapper/LoadingListQueryMapper.java | 9 ++++++++- .../repository/SmartLoadingRepository.java | 8 ++++++++ .../firstleg/service/SmartLoadingService.java | 14 ++++++++++++-- .../LoadingListQueryMapperContractTest.java | 3 +++ .../PdaSplitSettlementSqlContractTest.java | 11 +++++++++++ .../firstleg/service/SmartLoadingServiceTest.java | 5 +++++ 7 files changed, 50 insertions(+), 4 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java index 5004229d3..4a1135079 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java @@ -218,7 +218,9 @@ public interface InboundSplitSettlementMapper { @Update(""" UPDATE fl_loading_plan_item plan_item JOIN fl_loading_pick_item pick_item ON pick_item.loading_plan_item_id = plan_item.id - SET plan_item.package_count = pick_item.picked_package_count, + SET plan_item.pallet_count = CEIL(COALESCE(plan_item.pallet_count,0) + * pick_item.picked_package_count / NULLIF(plan_item.package_count,0)), + plan_item.package_count = pick_item.picked_package_count, plan_item.updated_by = #{operatorId}, plan_item.updated_by_name = #{operatorName}, plan_item.updated_at = CURRENT_TIMESTAMP(3), plan_item.version = plan_item.version + 1 WHERE plan_item.id = #{planItemId} AND plan_item.deleted = 0 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java index c93a7c73d..2306dafaa 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -174,7 +174,13 @@ public interface LoadingListQueryMapper { SELECT i.id AS item_id, i.account_id, i.customer_name, i.order_id, i.order_no, i.inbound_id, i.inbound_detail_id, i.inbound_code, b.container_no, i.sku_code, i.destination_warehouse_name, i.product_name_zh, - product_name_en, product_image_url, package_count, pallet_count, + product_name_en, product_image_url, i.package_count, + COALESCE(NULLIF(i.pallet_count,0), + CEIL(COALESCE(NULLIF(inbound.pallet_count,0), + (SELECT COUNT(*) FROM fl_inbound_weight_result weight + WHERE weight.tenant_id=i.account_id AND weight.inbound_id=i.inbound_id + AND weight.deleted=0 AND weight.recognition_status='CONFIRMED'),0) + * i.package_count / NULLIF(inbound.total_package_count,0)),0) pallet_count, gross_weight_kg, net_weight_kg, volume_cbm, dimensional_weight_kg, product_quantity, units_per_carton, fba_tracking_no, shipment_no, shipping_mark, internal_remark, customer_remark, warehouse_remark, @@ -182,6 +188,7 @@ public interface LoadingListQueryMapper { FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE' + LEFT JOIN fl_inbound_order inbound ON inbound.id=i.inbound_id AND inbound.tenant_id=i.account_id WHERE i.loading_plan_id = #{planId} AND i.deleted = 0 ORDER BY i.load_sequence, i.id """) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java index 5d48d045d..ab28e444c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -6,11 +6,13 @@ import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.LoadingPlanEntity; import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.mapper.ContainerBookingMapper; import com.baiyue.firstleg.mapper.LoadingPlanHistoryMapper; import com.baiyue.firstleg.mapper.LoadingPlanItemMapper; import com.baiyue.firstleg.mapper.LoadingPlanMapper; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper; +import com.baiyue.firstleg.mapper.InboundOrderMapper; import org.springframework.stereotype.Repository; import jakarta.annotation.Resource; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -26,11 +28,17 @@ public class SmartLoadingRepository { @Resource private LoadingPlanHistoryMapper histories; @Resource private ContainerBookingMapper bookings; @Resource private SmartLoadingCandidateMapper candidates; + @Resource private InboundOrderMapper inbounds; public List candidates(long warehouseId) { return candidates.candidates(warehouseId); } public Candidate candidate(long warehouseId, long inboundId, long detailId) { return candidates.candidate(warehouseId, inboundId, detailId); } + public InboundOrderEntity inbound(long accountId, long inboundId) { + return inbounds.selectOne(new LambdaQueryWrapper() + .eq(InboundOrderEntity::getTenantId, accountId) + .eq(InboundOrderEntity::getId, inboundId)); + } public List activeItemsByInbound(long planId, long accountId, long inboundId) { return items.selectList(new LambdaQueryWrapper() .eq(LoadingPlanItemEntity::getLoadingPlanId, planId) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index e902a6e79..7fa07edcb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -156,7 +156,8 @@ public class SmartLoadingService { } else { existing.setPackageCount(total); existing.setGrossWeightKg(unit(candidate.unitGrossWeightKg()).multiply(BigDecimal.valueOf(total))); existing.setNetWeightKg(unit(candidate.unitNetWeightKg()).multiply(BigDecimal.valueOf(total))); - existing.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(total))); applyCargo(existing, cargo, total); + existing.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(total))); + existing.setPalletCount(estimatedPalletCount(candidate, total)); applyCargo(existing, cargo, total); existing.setUpdatedBy(operatorId); existing.setUpdatedByName(operatorName); if (repository.update(existing) != 1) throw conflict("装柜草稿已被并发修改"); changed = existing; @@ -306,7 +307,8 @@ public class SmartLoadingService { item.setPackageCount(request.packageCount()); item.setGrossWeightKg(unit(candidate.unitGrossWeightKg()).multiply(BigDecimal.valueOf(request.packageCount()))); item.setNetWeightKg(unit(candidate.unitNetWeightKg()).multiply(BigDecimal.valueOf(request.packageCount()))); - item.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(request.packageCount()))); applyCargo(item, cargo, request.packageCount()); + item.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(request.packageCount()))); + item.setPalletCount(estimatedPalletCount(candidate, request.packageCount())); applyCargo(item, cargo, request.packageCount()); item.setUpdatedBy(operatorId); item.setUpdatedByName(operatorName); if (repository.update(item) != 1) throw conflict("装柜明细已被并发修改"); history(planId, itemId, "ADJUST_ITEM", plan.getPlanStatus(), plan.getPlanStatus(), operation, @@ -437,8 +439,16 @@ public class SmartLoadingService { i.setGrossWeightKg(unit(c.unitGrossWeightKg()).multiply(BigDecimal.valueOf(count))); i.setNetWeightKg(unit(c.unitNetWeightKg()).multiply(BigDecimal.valueOf(count))); i.setVolumeCbm(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count))); + i.setPalletCount(estimatedPalletCount(c, count)); applyCargo(i, cargo, count); i.setLoadSequence(seq); i.setDeleted(false); i.setCreatedBy(op); i.setCreatedByName(name); i.setUpdatedBy(op); i.setUpdatedByName(name); i.setVersion(0); return i; } + private int estimatedPalletCount(Candidate candidate, int packageCount) { + InboundOrderEntity inbound = repository.inbound(candidate.accountId(), candidate.inboundId()); + if (inbound == null || inbound.getPalletCount() == null || inbound.getPalletCount() <= 0 + || inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() <= 0) return 0; + return BigDecimal.valueOf(inbound.getPalletCount()).multiply(BigDecimal.valueOf(packageCount)) + .divide(BigDecimal.valueOf(inbound.getTotalPackageCount()), 0, RoundingMode.CEILING).intValueExact(); + } private void applyCargo(LoadingPlanItemEntity item, OrderCargoClient.CargoBinding cargo, int boxes) { item.setSkuCode(cargo.skuNo()); item.setProductNameEn(cargo.cargoNameEn()); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java index 76018775b..11e77ed09 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -60,6 +60,9 @@ class LoadingListQueryMapperContractTest { assertThat(items) .contains("i.order_id", "i.order_no", "b.container_no") .contains("b.record_status='ACTIVE'") + .contains("NULLIF(i.pallet_count,0)") + .contains("fl_inbound_weight_result weight") + .contains("weight.recognition_status='CONFIRMED'") .contains("i.loading_plan_id = #{planId}") .contains("i.deleted = 0"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java index d8204c704..a3ceba32c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java @@ -101,6 +101,17 @@ class PdaSplitSettlementSqlContractTest { .contains("split_status='FULLY_SPLIT'"); } + @Test + void partialPickingConvergesPalletSnapshotWithActualCartons() throws Exception { + String converge = InboundSplitSettlementMapper.class.getMethod("convergePartialPlanItem", + long.class, long.class, String.class) + .getAnnotation(Update.class).value()[0]; + assertThat(converge) + .contains("plan_item.pallet_count = CEIL") + .contains("pick_item.picked_package_count / NULLIF(plan_item.package_count,0)") + .contains("plan_item.package_count = pick_item.picked_package_count"); + } + @Test void keepsAllocationConfirmedUntilReleaseStep() throws Exception { String consume = InboundPalletAllocationMapper.class.getMethod("consume", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index c26f94ef7..a86f7b75f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -16,6 +16,7 @@ import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.LoadingPlanEntity; import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.repository.SmartLoadingRepository; import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; import com.baiyue.firstleg.split.service.SplitPreloadService; @@ -299,6 +300,9 @@ class SmartLoadingServiceTest { @Test void snapshotsCargoFields() { stubAssignment(); + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(10L); inbound.setTenantId(9L); inbound.setTotalPackageCount(10); inbound.setPalletCount(4); + when(repository.inbound(9L, 10L)).thenReturn(inbound); when(cargoClient.detail(anyString(), eq(9L), eq(30L), eq(20L))).thenReturn(ApiResponse.success( new OrderCargoClient.CargoBinding(20L, 30L, 1, "中文品名", 10, new BigDecimal("20"), new BigDecimal("2"), "SKU-1", "English name", @@ -319,6 +323,7 @@ class SmartLoadingServiceTest { assertThat(saved.getProductNameEn()).isEqualTo("English name"); assertThat(saved.getUnitsPerCarton()).isEqualTo(4); assertThat(saved.getProductQuantity()).isEqualTo(20); + assertThat(saved.getPalletCount()).isEqualTo(2); assertThat(saved.getNetWeightKg()).isEqualByComparingTo("7.5"); assertThat(saved.getCustomerRemark()).isEqualTo("客户备注"); assertThat(saved.getInternalRemark()).isEqualTo("最新内部备注"); From 132d96a99b315e06c6861afb540c4ae131c186cd Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 17:10:39 +0800 Subject: [PATCH 030/115] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B5=B7=E5=A4=96?= =?UTF-8?q?=E6=8A=A5=E4=BB=B7=E5=90=88=E4=BD=9C=E4=BB=93=E5=80=99=E9=80=89?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PlatformOverseasQuoteController.java | 9 ++-- .../quote/service/OverseasQuoteService.java | 4 +- .../service/PartnerWarehouseDirectory.java | 32 ++++++++++-- .../openapi/overseas-master-data-v1.yaml | 29 ++++++++++- ...OverseasMasterDataOpenApiContractTest.java | 3 ++ ...ticPartnerWarehouseClientContractTest.java | 50 +++++++++++++++++++ ...rmOverseasQuoteControllerContractTest.java | 10 ++++ .../PartnerWarehouseDirectoryTest.java | 31 ++++++++++++ 8 files changed, 156 insertions(+), 12 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClientContractTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteController.java index d4546cae3..70836ade5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteController.java @@ -23,6 +23,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.nio.charset.StandardCharsets; @@ -54,9 +55,11 @@ public class PlatformOverseasQuoteController { @GetMapping("/warehouse-options") @PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_LIST + "')") @Operation(summary = "List cached active overseas partner warehouse options", - description = "Required authority: platform:overseas-master-data:quote:list; the browser never calls CN internal APIs") - public ApiResponse> warehouseOptions() { - return ApiResponse.success(service.warehouseOptions()); + description = "Required authority: platform:overseas-master-data:quote:list; the browser never calls CN internal APIs; " + + "refresh=true bypasses the positive cache after CN partner master data changes") + public ApiResponse> warehouseOptions( + @RequestParam(defaultValue = "false") boolean refresh) { + return ApiResponse.success(service.warehouseOptions(refresh)); } @PostMapping diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/OverseasQuoteService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/OverseasQuoteService.java index b9b3fb73c..766b5163f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/OverseasQuoteService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/OverseasQuoteService.java @@ -73,8 +73,8 @@ public class OverseasQuoteService { quote.getCreatedBy(), quote.getCreatedAt()); } - public List warehouseOptions() { - return warehouses.options(); + public List warehouseOptions(boolean refresh) { + return refresh ? warehouses.refresh() : warehouses.options(); } @Transactional diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectory.java index 24e4734d9..de59cd12b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectory.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectory.java @@ -6,6 +6,7 @@ import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import org.springframework.web.server.ResponseStatusException; import java.time.Clock; @@ -23,26 +24,39 @@ public class PartnerWarehouseDirectory { private volatile Snapshot snapshot; public List options() { + return options(false); + } + + /** Bypasses a still-valid positive snapshot after CN partner master data changes. */ + public List refresh() { + return options(true); + } + + private List options(boolean forceRefresh) { Snapshot current = snapshot; Instant now = clock.instant(); - if (current != null && now.isBefore(current.expiresAt())) return current.options(); + if (!forceRefresh && current != null && now.isBefore(current.expiresAt())) return current.options(); synchronized (this) { current = snapshot; now = clock.instant(); - if (current != null && now.isBefore(current.expiresAt())) return current.options(); + if (!forceRefresh && current != null && now.isBefore(current.expiresAt())) return current.options(); var response = client.activePartners(internalToken); if (response == null || !response.successful() || response.data() == null) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "国内海外合作仓候选暂不可用"); } List options = response.data().stream() - .filter(value -> value != null && value.id() != null) + .filter(this::valid) .map(value -> new OverseasQuoteResponses.PartnerWarehouseOption(value.id().toString(), - value.warehouseCode(), value.warehouseName(), value.countryCode())) + value.warehouseCode().trim(), value.warehouseName().trim(), + value.countryCode().trim().toUpperCase(java.util.Locale.ROOT))) .toList(); // An empty directory can be a short-lived cross-region visibility gap after a partner // warehouse is created. Do not preserve that absence for the normal 30-minute TTL. - if (options.isEmpty()) return List.of(); + if (options.isEmpty()) { + snapshot = null; + return List.of(); + } snapshot = new Snapshot(options, now.plus(ttl)); return snapshot.options(); } @@ -54,6 +68,14 @@ public class PartnerWarehouseDirectory { "所选海外合作仓不存在或已停用")); } + private boolean valid(DomesticPartnerWarehouseClient.Option value) { + return value != null && value.id() != null + && StringUtils.hasText(value.warehouseCode()) + && StringUtils.hasText(value.warehouseName()) + && StringUtils.hasText(value.countryCode()) + && value.countryCode().trim().matches("(?i)[A-Z]{2}"); + } + private record Snapshot(List options, Instant expiresAt) { private Snapshot { options = List.copyOf(options); } } diff --git a/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml index f67455ca1..06a85196f 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml @@ -25,8 +25,25 @@ paths: /api/platform/v1/last-leg/master-data/overseas-quotes/warehouse-options: get: summary: 查询缓存的有效海外合作仓选项 - description: '权限:platform:overseas-master-data:quote:list;浏览器不直接调用国内内部接口' - responses: { '200': { description: 业务响应 } } + description: '权限:platform:overseas-master-data:quote:list;浏览器不直接调用国内内部接口;非空结果缓存30分钟,空结果不缓存;国内合作仓变更后可使用refresh=true强制刷新' + parameters: + - name: refresh + in: query + required: false + description: 是否绕过仍有效的非空缓存并重新读取国内海外合作仓 + schema: { type: boolean, default: false } + responses: + '200': + description: 业务响应,data为海外合作仓候选数组 + content: + application/json: + schema: + type: object + properties: + code: { type: string, example: '200' } + data: + type: array + items: { $ref: '#/components/schemas/PartnerWarehouseOption' } /api/platform/v1/last-leg/master-data/overseas-quotes/export: get: summary: 导出海外仓报价 @@ -161,6 +178,14 @@ components: Size: { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } schemas: ActiveStatus: { type: string, enum: [ACTIVE, INACTIVE] } + PartnerWarehouseOption: + type: object + required: [warehouseId, warehouseCode, warehouseName, countryCode] + properties: + warehouseId: { type: string, description: 平台公共海外合作仓雪花ID字符串 } + warehouseCode: { type: string, description: 仓库编码 } + warehouseName: { type: string, description: 仓库名称 } + countryCode: { type: string, pattern: '^[A-Z]{2}$', description: ISO国家二字码 } QuoteFee: type: object required: [item, billingUnit, unitPrice] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/OverseasMasterDataOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/OverseasMasterDataOpenApiContractTest.java index b9b072a47..fee858866 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/OverseasMasterDataOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/OverseasMasterDataOpenApiContractTest.java @@ -13,6 +13,9 @@ class OverseasMasterDataOpenApiContractTest { String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-master-data-v1.yaml")); assertThat(yaml).contains("/api/platform/v1/last-leg/master-data/overseas-quotes:") .contains("缓存合作仓目录30分钟") + .contains("空结果不缓存") + .contains("refresh=true强制刷新") + .contains("PartnerWarehouseOption:") .contains("/api/platform/v1/last-leg/master-data/exchange-rates:") .contains("/api/platform/v1/last-leg/master-data/vehicles:") .contains("platform:overseas-master-data:exchange-rate:export") diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClientContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClientContractTest.java new file mode 100644 index 000000000..0961c5313 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClientContractTest.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.masterdata.quote.client; + +import org.junit.jupiter.api.Test; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; + +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class DomesticPartnerWarehouseClientContractTest { + @Test + void freezesCnInternalUrlTokenHeaderAndFourFieldDto() throws Exception { + FeignClient feign = DomesticPartnerWarehouseClient.class.getAnnotation(FeignClient.class); + assertThat(feign.name()).isEqualTo("warehouse-service"); + assertThat(feign.url()).isEqualTo("${baiyue.clients.domestic-warehouse.url:}"); + + Method method = DomesticPartnerWarehouseClient.class.getDeclaredMethod("activePartners", String.class); + assertThat(method.getAnnotation(GetMapping.class).value()) + .containsExactly("/internal/warehouses/overseas-partners"); + assertThat(method.getParameters()[0].getAnnotation(RequestHeader.class).value()) + .isEqualTo("X-Internal-Service-Token"); + assertThat(Arrays.stream(DomesticPartnerWarehouseClient.Option.class.getRecordComponents()) + .map(component -> component.getName() + ":" + component.getType().getSimpleName())) + .containsExactly("id:Long", "warehouseCode:String", "warehouseName:String", "countryCode:String"); + } + + @Test + void developmentDeploymentRequiresAndPassesTheCnUrlAndSharedInternalToken() throws Exception { + String application = Files.readString(Path.of("src/main/resources/application.yml")); + String nacos = Files.readString(Path.of("../config/nacos/overseas/lastleg-service.yml")); + String common = Files.readString(Path.of("../config/nacos/overseas/baiyue-common.yml")); + String compose = Files.readString(Path.of("../deploy/docker-compose.overseas.yml")); + String deploy = Files.readString(Path.of("../deploy/scripts/deploy-services.sh")); + + assertThat(application).contains("url: ${DOMESTIC_WAREHOUSE_URL:}") + .contains("cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}"); + assertThat(nacos).contains("url: ${DOMESTIC_WAREHOUSE_URL}") + .contains("cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}"); + assertThat(common).contains("internal-service-token: ${INTERNAL_SERVICE_TOKEN}"); + assertThat(compose).contains("INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN}") + .contains("DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL}"); + assertThat(deploy).contains("FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL") + .contains("NACOS_SERVER_ADDR OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteControllerContractTest.java index 6692c7359..63595bb05 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteControllerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteControllerContractTest.java @@ -3,7 +3,9 @@ package com.baiyue.lastleg.masterdata.quote.controller; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import java.lang.reflect.Method; import java.util.Map; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -26,5 +28,13 @@ class PlatformOverseasQuoteControllerContractTest { assertThat(authorities.get("update")).contains("platform:overseas-master-data:quote:edit"); assertThat(authorities.get("status")).contains("platform:overseas-master-data:quote:status"); assertThat(authorities.get("export")).contains("platform:overseas-master-data:quote:export"); + + Method options = Stream.of(PlatformOverseasQuoteController.class.getDeclaredMethods()) + .filter(method -> method.getName().equals("warehouseOptions")) + .findFirst().orElseThrow(); + RequestParam refresh = options.getParameters()[0].getAnnotation(RequestParam.class); + assertThat(options.getParameterTypes()).containsExactly(boolean.class); + assertThat(refresh).isNotNull(); + assertThat(refresh.defaultValue()).isEqualTo("false"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectoryTest.java index b226068b3..20196b976 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectoryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectoryTest.java @@ -66,4 +66,35 @@ class PartnerWarehouseDirectoryTest { assertThat(option.warehouseId()).isEqualTo("101")); verify(client, times(2)).activePartners("internal-token"); } + + @Test + void forcedRefreshBypassesAStillValidPositiveSnapshot() { + when(client.activePartners("internal-token")) + .thenReturn(ApiResponse.success(List.of(new DomesticPartnerWarehouseClient.Option( + 101L, "US-ONT", "安大略合作仓", "US")))) + .thenReturn(ApiResponse.success(List.of(new DomesticPartnerWarehouseClient.Option( + 102L, "US-LAX", "洛杉矶合作仓", "us")))); + + assertThat(directory.options()).singleElement().satisfies(option -> + assertThat(option.warehouseId()).isEqualTo("101")); + assertThat(directory.refresh()).singleElement().satisfies(option -> { + assertThat(option.warehouseId()).isEqualTo("102"); + assertThat(option.countryCode()).isEqualTo("US"); + }); + verify(client, times(2)).activePartners("internal-token"); + } + + @Test + void malformedDomesticRowsAreFilteredAndAnEmptyFilteredResultIsNotCached() { + when(client.activePartners("internal-token")) + .thenReturn(ApiResponse.success(List.of(new DomesticPartnerWarehouseClient.Option( + 101L, " ", "无编码仓", "US")))) + .thenReturn(ApiResponse.success(List.of(new DomesticPartnerWarehouseClient.Option( + 102L, "US-LAX", "洛杉矶合作仓", "US")))); + + assertThat(directory.options()).isEmpty(); + assertThat(directory.options()).singleElement().satisfies(option -> + assertThat(option.warehouseId()).isEqualTo("102")); + verify(client, times(2)).activePartners("internal-token"); + } } From 3d8a1fac20781dff8c296e1ce0523faea65b987a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 17:15:48 +0800 Subject: [PATCH 031/115] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Jenkinsfile | 6 +- Jenkinsfile.service | 2 +- common-event-contract/AGENTS.md | 2 +- .../event/FirstLegLoadingPlanCompletedV1.java | 17 +- .../event/FirstLegLoadingPlanCompletedV2.java | 19 +- .../FirstLegLoadingPlanCompletedV1Test.java | 13 + .../FirstLegLoadingPlanCompletedV2Test.java | 12 + deploy/scripts/deploy-built-service.sh | 10 + deploy/sql/海外区域统一增量-MySQL8.sql | 49 +- event-bridge-service/AGENTS.md | 2 +- .../controller/EventBridgeController.java | 3 +- .../service/EventReplayServiceTest.java | 12 + firstleg/AGENTS.md | 2 +- .../mapper/LoadingCompletedEventMapper.java | 4 +- .../LoadingCompletedOutboxService.java | 15 +- .../LoadingCompletedOutboxServiceTest.java | 27 + .../TaillegRealProducerAcceptanceTest.java | 4 +- lastleg-service/AGENTS.md | 2 +- .../controller/LastlegOrderController.java | 3 +- .../lastleg/dto/LastlegOrderSummary.java | 2 + .../lastleg/entity/LastlegInboundOrder.java | 1 + .../baiyue/lastleg/entity/LastlegOrder.java | 1 + .../inboundplan/entity/InboundPlanLine.java | 1 + .../ReceivingContainerController.java | 129 +++++ .../pda/dto/ReceivingContainerRequests.java | 20 + .../pda/dto/ReceivingContainerResponses.java | 38 ++ .../entity/ReceivingContainerOperation.java | 24 + .../pda/entity/ReceivingContainerSession.java | 32 ++ .../gateway/HttpWarehouseResourceGateway.java | 21 +- .../pda/gateway/WarehouseResourceGateway.java | 8 +- .../ReceivingContainerOperationMapper.java | 16 + .../ReceivingContainerSessionMapper.java | 25 + .../service/InboundPdaWorkflowService.java | 83 +++- .../service/ReceivingContainerService.java | 469 ++++++++++++++++++ .../service/InboundPlanService.java | 2 + .../lastleg/service/LastlegOrderService.java | 2 +- .../service/LoadingCompletedConsumer.java | 62 ++- .../src/main/resources/db/lastleg.sql | 43 +- .../openapi/overseas-business-v1.yaml | 2 +- .../service/LoadingCompletedConsumerTest.java | 50 +- scripts/overseas-deployment-contract.test.mjs | 4 + .../InternalWarehousePalletController.java | 9 + .../dto/WarehousePalletResolveResponse.java | 12 + .../WarehouseResourceRepository.java | 9 + .../service/WarehouseResourceService.java | 16 + 45 files changed, 1250 insertions(+), 35 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerOperation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java diff --git a/Jenkinsfile b/Jenkinsfile index e80ce973e..af1e3f804 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -69,7 +69,7 @@ pipeline { booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)') booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)') booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;由 EVENT_BRIDGE_REGION 选择实例)') - choice(name: 'EVENT_BRIDGE_REGION', choices: ['CN', 'OVERSEAS'], description: '仅部署 event-bridge-service 时生效;明确选择国内或海外实例') + choice(name: 'EVENT_BRIDGE_REGION', choices: ['SELECT', 'CN', 'OVERSEAS'], description: '部署 event-bridge-service 时必须明确选择实例;SELECT 会阻止部署') booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)') choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境') booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署') @@ -149,6 +149,10 @@ pipeline { } selected = selected.unique() + if (params.DEPLOY && selected.contains('event-bridge-service') + && !['CN', 'OVERSEAS'].contains(params.EVENT_BRIDGE_REGION)) { + error('部署 event-bridge-service 必须明确选择 EVENT_BRIDGE_REGION=CN 或 OVERSEAS') + } env.SELECTED_SERVICES = selected.join(',') env.NO_SERVICE_CHANGE = selected ? 'false' : 'true' if (!selected) { diff --git a/Jenkinsfile.service b/Jenkinsfile.service index ed42f05bf..3b43dae52 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -38,7 +38,7 @@ pipeline { choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境') booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试') - choice(name: 'EVENT_BRIDGE_REGION', choices: ['CN', 'OVERSEAS'], description: '仅 event-bridge-service 使用;通常由总控 Job 传入') + choice(name: 'EVENT_BRIDGE_REGION', choices: ['SELECT', 'CN', 'OVERSEAS'], description: '仅 event-bridge-service 部署使用;必须由总控明确传入 CN 或 OVERSEAS') string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库基础路径,不包含服务名和标签') string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像') } diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index d7c0f6212..0a675effd 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -14,7 +14,7 @@ | 事件 ID 与业务键 | `EventIdentifiers.java` | | Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` | | 事件类型与区域 | `EventTypes.java`、`RegionCode.java` | -| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;冻结来源/根订单与入仓标识、客户、业务类型、逐明细目的仓/备注/托盘号/实装箱号及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` | +| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` | | 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 | | 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` | | IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 | diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java index 1b4978c53..7d8dd65bd 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java @@ -16,12 +16,25 @@ public record FirstLegLoadingPlanCompletedV1( String destinationWarehouseCode, String destinationWarehouseName, int packageCount, Integer productQuantity, Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, - List items, String customerId, String orderType, List pallets) { + List items, String customerId, String orderType, List pallets, + String destinationType) { public Order { items = items == null ? List.of() : items; orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType; pallets = pallets == null ? List.of() : pallets; } + public Order(String sourceInboundId, String rootInboundId, + String sourceOrderId, String sourceOrderNo, String rootOrderId, + String inboundCode, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + int packageCount, Integer productQuantity, Integer palletCount, + BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, + List items, String customerId, String orderType, List pallets) { + this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, + inboundCode, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, packageCount, productQuantity, palletCount, + grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null); + } public Order(String sourceInboundId, String rootInboundId, String sourceOrderId, String sourceOrderNo, String rootOrderId, String inboundCode, String destinationWarehouseId, @@ -32,7 +45,7 @@ public record FirstLegLoadingPlanCompletedV1( this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, inboundCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, packageCount, productQuantity, palletCount, - grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of()); + grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null); } } diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java index cb0d36542..786ebae4c 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java @@ -15,13 +15,28 @@ public record FirstLegLoadingPlanCompletedV2( String destinationWarehouseCode, String destinationWarehouseName, int packageCount, Integer productQuantity, Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, - List items, String customerId, String orderType, List pallets) { + List items, String customerId, String orderType, List pallets, + /** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */ + String destinationType) { public Order { items = items == null ? List.of() : items; orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType; pallets = pallets == null ? List.of() : pallets; } + public Order(String sourceInboundId, String rootInboundId, + String sourceOrderId, String sourceOrderNo, String rootOrderId, + String inboundCode, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + int packageCount, Integer productQuantity, Integer palletCount, + BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, + List items, String customerId, String orderType, List pallets) { + this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, + inboundCode, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, packageCount, productQuantity, palletCount, + grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null); + } + public Order(String sourceInboundId, String rootInboundId, String sourceOrderId, String sourceOrderNo, String rootOrderId, String inboundCode, String destinationWarehouseId, @@ -32,7 +47,7 @@ public record FirstLegLoadingPlanCompletedV2( this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, inboundCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, packageCount, productQuantity, palletCount, - grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of()); + grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null); } } diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java index 3b5b72757..95a6b394b 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java @@ -47,4 +47,17 @@ class FirstLegLoadingPlanCompletedV1Test { assertThat(restored).isEqualTo(payload); assertThat(json).contains("ONE_CLICK_DROPSHIP", "PLT-01", "BOX-003", "customerId"); } + + @Test + void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception { + String json = """ + {"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1", + "warehouseId":"3","warehouseName":"海外仓","orders":[{"sourceInboundId":"100", + "destinationWarehouseId":"3","packageCount":1,"items":[],"destinationType":"OVERSEAS"}]} + """; + + FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class); + + assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS"); + } } diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java index b5bb212f4..d15900021 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java @@ -40,6 +40,18 @@ class FirstLegLoadingPlanCompletedV2Test { assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2); } + @Test + void carriesFrozenDestinationType() { + var order = new FirstLegLoadingPlanCompletedV2.Order( + "2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8", + 2, 20, 0, null, null, null, List.of(), "11", "FIRST_LEG", List.of(), "FBA"); + + var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2( + "4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order))); + + assertThat(json.at("/orders/0/destinationType").asText()).isEqualTo("FBA"); + } + @Test void mapsEachOrdinaryCartonNumberToItsSku() { var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU-PSC2", "产品", "Product", 2, diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index 1479c8193..458834cd4 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -246,6 +246,11 @@ deploy_local() { new_started=false fi if [ "$new_started" = "true" ] && wait_for_local_health; then + running_image="$(local_container_image || true)" + if [ "$running_image" != "$IMAGE" ]; then + echo "运行容器镜像与请求镜像不一致: expected=$IMAGE actual=${running_image:-missing}" >&2 + return 1 + fi echo "新镜像健康检查通过: $IMAGE" local_compose ps "$SERVICE" local_registry_logout @@ -441,6 +446,11 @@ if ! compose up -d --no-deps --pull never "$service"; then new_started=false fi if [ "$new_started" = "true" ] && wait_for_health; then + running_image="$(container_image || true)" + if [ "$running_image" != "$new_image" ]; then + echo "运行容器镜像与请求镜像不一致: expected=$new_image actual=${running_image:-missing}" >&2 + exit 1 + fi echo "新镜像健康检查通过: $new_image" compose ps "$service" exit 0 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 508e02ba8..7adb6153f 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -319,7 +319,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton ( destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID', line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID', warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID', - status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、BULK_RECEIVED、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark), @@ -331,6 +331,53 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt; +-- 头程 V2 事件冻结目的类型;只补结构,不由部署脚本自动执行历史数据回填。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'), + 'ALTER TABLE ll_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', + 'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type')); +PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'), + 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户账号标识''', + 'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt; +UPDATE ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id SET l.customer_id=p.customer_id WHERE l.customer_id=''; +ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'), + 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码''', + 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码'' AFTER inbound_code')); +PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='destination_type'), + 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', + 'ALTER TABLE ll_inbound_plan_line ADD COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER inbound_code')); +PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt; +UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id +SET l.destination_type=COALESCE(NULLIF(o.destination_type,''),CASE WHEN NULLIF(l.fba_warehouse_code,'') IS NULL THEN 'OVERSEAS' ELSE 'FBA' END) +WHERE l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS'; + +CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( + id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话账号唯一锁键', + active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', + photo_file_ids JSON NULL COMMENT '签收卸柜照片文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间', + receiving_at DATETIME(3) 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 NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_active_operator(active_operator_key), UNIQUE KEY uk_ll_receiving_active_container(active_container_key), + KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at) +) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话'; + +CREATE TABLE IF NOT EXISTS ll_receiving_container_operation ( + id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_operation(customer_id,operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'), + 'ALTER TABLE ll_inbound_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', + 'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name')); +PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt; + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='sub_order_status'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN sub_order_status VARCHAR(255) NULL COMMENT ''子单据状态汇总'' AFTER current_status')); diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index aa40eee17..75fe1f706 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -15,7 +15,7 @@ | 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=OVERSEAS` 投 Lastleg,`targetRegion=CN` 回投 Firstleg | | 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` | | 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` | -| 人工补投 | `service/EventReplayService.java` | +| 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` | | Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 数据库 | `src/main/resources/db/event-bridge.sql` | diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java index 575bd2867..7a2b939f0 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java @@ -51,7 +51,8 @@ public class EventBridgeController { return ApiResponse.success(eventInboxService.receive(envelope)); } - @Operation(summary = "人工补投死信事件") + @Operation(summary = "人工补投死信事件", + description = "仅允许 DEAD 事件转回 RETRY;保留原 eventId、payload 与 payloadHash,必须记录操作人与补投原因") @PostMapping("/events/{eventId}/replay") public ApiResponse replay( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java index 66b740ddf..9bd52210b 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java @@ -3,8 +3,10 @@ package com.baiyue.eventbridge.service; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; +import org.apache.ibatis.annotations.Update; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; class EventReplayServiceTest { @@ -28,6 +30,16 @@ class EventReplayServiceTest { .isInstanceOf(IllegalStateException.class); } + @Test + void replayOnlyRequeuesDeadStateAndDoesNotRewriteFrozenEvent() throws Exception { + String sql = String.join(" ", BridgeInboxMapper.class + .getMethod("replayDead", String.class, String.class) + .getAnnotation(Update.class).value()).replaceAll("\\s+", " "); + + assertThat(sql).contains("status='RETRY'", "retry_count=0", "status='DEAD'") + .doesNotContain("payload_json=", "payload_hash="); + } + private EventReplayService service() { EventReplayService service = new EventReplayService(); ReflectionTestUtils.setField(service, "inboxMapper", mapper); diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 14106f6cf..72b9200eb 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -28,7 +28,7 @@ | 分柜与预装 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java` | | 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`loading/`;按入仓码候选与箱数分配分别从 `/inbound-candidates`、`/plans/{planId}/inbound-assignments` 进入,明细规格拆分留在服务端 | | PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` | -| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;按最终实装与稳定箱码冻结 V2,真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | +| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType=FBA|OVERSEAS`,OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 三期海运订舱与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | | 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index b613660dd..c11e662f4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -19,7 +19,7 @@ public interface LoadingCompletedEventMapper { COALESCE(o.root_inbound_id, o.id) AS root_inbound_id, o.order_id AS source_order_id, o.order_no AS source_order_no, COALESCE(root_order.order_id, o.order_id) AS root_order_id, - o.inbound_code, o.business_type, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code, + o.inbound_code, o.business_type, o.destination_type, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code, o.destination_name AS destination_warehouse_name, i.id AS loading_plan_item_id, i.inbound_detail_id AS source_inbound_detail_id, i.sku_code, i.product_name_zh, i.product_name_en, i.package_count, i.product_quantity, @@ -73,7 +73,7 @@ public interface LoadingCompletedEventMapper { private Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm; private String fbaTrackingNo; private String shipmentNo; private String shippingMark; - private String businessType; + private String businessType; private String destinationType; private String customerRemark; private String internalRemark; private String warehouseRemark; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 102e4ca39..f5879a22c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -103,13 +103,15 @@ public class LoadingCompletedOutboxService { first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount), sumNullableInt(sorted, Row::getProductQuantity), "ONE_CLICK_DROPSHIP".equals(orderType) ? pallets.size() : 0, sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg), - sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets); + sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets, + destinationType(sorted)); } /** 国内按实装箱数从可用标签池稳定随机抽取箱唛,重复发布同一计划时结果不变。 */ private Map> cartonLabelPools(List rows) { Map> result = new java.util.LinkedHashMap<>(); Map> byInbound = rows.stream().filter(row -> !dropship(row.getBusinessType())) + .filter(row -> "OVERSEAS".equals(destinationType(List.of(row)))) .collect(java.util.stream.Collectors.groupingBy( row -> new LabelPoolKey(row.getAccountId(), row.getSourceInboundId()), LinkedHashMap::new, java.util.stream.Collectors.toList())); @@ -155,6 +157,17 @@ public class LoadingCompletedOutboxService { return result; } + private String destinationType(List rows) { + Set values = rows.stream().map(Row::getDestinationType) + .map(value -> value == null ? "" : value.trim().toUpperCase(java.util.Locale.ROOT)) + .collect(java.util.stream.Collectors.toSet()); + if (values.size() != 1 || (!values.contains("FBA") && !values.contains("OVERSEAS"))) { + throw new BizException(ResultCode.CONFLICT, + "装柜入仓单目的类型必须唯一且为 FBA 或 OVERSEAS,不能生成海外交接事件"); + } + return values.iterator().next(); + } + private record LabelPoolKey(long accountId, long inboundId) { } private List palletSnapshots(List values, List source) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 457052c45..acc2a91ef 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -58,6 +58,7 @@ class LoadingCompletedOutboxServiceTest { assertThat(first.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101"); assertThat(first.payload().at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU101"); assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-101"); + assertThat(first.payload().at("/orders/0/destinationType").asText()).isEqualTo("OVERSEAS"); assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2"); assertThat(first.eventVersion()).isEqualTo(2); } @@ -71,6 +72,31 @@ class LoadingCompletedOutboxServiceTest { .hasMessageContaining("缺少 SKU"); } + @Test void fbaFreezesPackageCountWithoutRequiringCartonLabels() throws Exception { + Row value = row(11, 100, 101); + value.setDestinationType("FBA"); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + + service.publish(90L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox).insert(captor.capture()); + EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class); + assertThat(event.payload().at("/orders/0/destinationType").asText()).isEqualTo("FBA"); + assertThat(event.payload().at("/orders/0/items/0/cartonMarks")).isEmpty(); + verify(manifests, org.mockito.Mockito.never()).labelPool(11L, 100L); + } + + @Test void rejectsUnfrozenDestinationType() { + Row value = row(11, 100, 101); + value.setDestinationType(null); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + + assertThatThrownBy(() -> service.publish(90L)) + .hasMessageContaining("目的类型必须唯一且为 FBA 或 OVERSEAS"); + verify(outbox, org.mockito.Mockito.never()).insert(org.mockito.ArgumentMatchers.any()); + } + @Test void freezesExactlyLoadedPackagesFromLabelPoolDeterministically() throws Exception { Row value = row(11, 100, 101); value.setPackageCount(2); @@ -148,6 +174,7 @@ class LoadingCompletedOutboxServiceTest { r.setSourceOrderId(inbound+1000); r.setSourceOrderNo("FL"+inbound); r.setRootOrderId(1099L); r.setInboundCode("RK"+inbound); r.setLoadingPlanItemId(detail); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8"); r.setDestinationWarehouseName("ONT8"); r.setSourceInboundDetailId(detail); r.setSkuCode("SKU"+detail); + r.setDestinationType("OVERSEAS"); r.setPackageCount(1); r.setProductQuantity(10); r.setPalletCount(1); r.setGrossWeightKg(BigDecimal.ONE); r.setNetWeightKg(BigDecimal.ONE); r.setVolumeCbm(BigDecimal.ONE); return r; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java index 6f49e1202..df13829c5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java @@ -88,9 +88,9 @@ class TaillegRealProducerAcceptanceTest { private void seedInbound(long id, long orderId, String code, String type, String status, String destination) { jdbc.update("INSERT IGNORE INTO fl_inbound_order(id,tenant_id,order_id,order_no,inbound_code,process_status," + - "order_status_snapshot,customer_name,warehouse_id,warehouse_name,business_type,destination_warehouse_id," + + "order_status_snapshot,customer_name,warehouse_id,warehouse_name,business_type,destination_type,destination_warehouse_id," + "destination_code,destination_name,package_count,actual_volume_cbm,created_by,updated_by) " + - "VALUES(?,?,?,?,?,?, 'IN_TRANSIT',?,92,'真实验收海外仓',?,9001,?,?,?,0.4000,1,1)", + "VALUES(?,?,?,?,?,?, 'IN_TRANSIT',?,92,'真实验收海外仓',?,'FBA',9001,?,?,?,0.4000,1,1)", id, CUSTOMER, orderId, MARKER + "-ORDER-" + id, code, status, "真实验收客户", type, destination, "FBA " + destination, type.equals("ONE_CLICK_DROPSHIP") ? 2 : 3); } diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 4fef09376..3ea28089c 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -12,7 +12,7 @@ |---|---| | 事件接收、平台订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | -| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端按冻结字段校验后建单 | +| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 | | 尾程单号生成 | `service/LastlegOrderNoGenerator.java` | | 订单查询与更新 | `service/LastlegOrderService.java` | | 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index 0769880ca..9d8fc653d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -41,7 +41,8 @@ public class LastlegOrderController { @Resource private InternalServiceTokenGuard tokenGuard; @PostMapping("/internal/lastleg/events") - @Operation(summary = "Receive a first-leg loading-completed event") + @Operation(summary = "Receive a first-leg loading-completed event", + description = "V2 orders[].destinationType is FBA or OVERSEAS. FBA may use package counts; OVERSEAS requires one carton mark per loaded package. Legacy events are accepted only when the destination type can be inferred safely.") public ApiResponse receive( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestBody EventEnvelope event) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index bfe09c241..fb5b95346 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -11,6 +11,8 @@ public record LastlegOrderSummary( String orderNo, String orderType, String orderTypeName, + @Schema(description = "跨区冻结目的类型", allowableValues = {"FBA", "OVERSEAS"}) + String destinationType, String sourceInboundId, String inboundCode, String sourceOrderNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java index 5c9927d88..3bacefe47 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java @@ -25,6 +25,7 @@ public class LastlegInboundOrder { private String destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; + private String destinationType; private Integer packageCount; private Integer productQuantity; private Integer palletCount; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java index 903ab3afb..57229f0e4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java @@ -15,6 +15,7 @@ public class LastlegOrder { private String orderNo; private String customerId; private String orderType; + private String destinationType; private String inboundCode; private String sourceRegion; private String sourceInboundId; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java index 21026bf8d..6e8cd25d4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java @@ -16,6 +16,7 @@ public class InboundPlanLine { private Long planId; private Long orderId; private String inboundCode; + private String destinationType; private String fbaWarehouseCode; private String destinationWarehouseId; private String destinationWarehouseCode; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java new file mode 100644 index 000000000..cfa3b3f66 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -0,0 +1,129 @@ +package com.baiyue.lastleg.inboundplan.pda.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses; +import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@RestController +@RequestMapping("/api/pda/v1/last-leg/receiving-containers") +@Tag(name = "Overseas PDA container receiving") +public class ReceivingContainerController { + public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; + @Resource private ReceivingContainerService service; + + @GetMapping("/candidates") + @Operation(summary = "List pending receiving containers for the authenticated customer") + public ApiResponse> candidates( + @AuthenticationPrincipal AuthenticatedUser user) { + Identity identity = identity(user); + return ApiResponse.success(service.candidates(identity.customerId(), identity.operatorKey())); + } + + @GetMapping("/{containerNo}") + @Operation(summary = "Get one receiving container without exposing plan or allocation IDs") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String containerNo) { + Identity identity = identity(user); + return ApiResponse.success(service.detail(identity.customerId(), identity.operatorKey(), containerNo)); + } + + @PostMapping("/{containerNo}/prepare") + public ApiResponse prepare(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) { + Identity identity = identity(user); + return ApiResponse.success(service.prepare(identity.customerId(), identity.operatorKey(), identity.operatorName(), + containerNo, request)); + } + + @PostMapping(value = "/current/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @RequestPart("file") MultipartFile file) { + Identity identity = identity(user); + if (user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "PDA user ID is required"); + return ApiResponse.success(service.uploadPhoto(identity.customerId(), identity.operatorKey(), user.userId(), file)); + } + + @PostMapping("/current/sign") + public ApiResponse sign(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.Sign request) { + Identity identity = identity(user); + return ApiResponse.success(service.sign(identity.customerId(), identity.operatorKey(), identity.operatorName(), request)); + } + + @GetMapping("/current") + public ApiResponse current(@AuthenticationPrincipal AuthenticatedUser user) { + Identity identity = identity(user); + return ApiResponse.success(service.current(identity.customerId(), identity.operatorKey())); + } + + @PostMapping("/current/cartons/resolve") + public ApiResponse resolveCarton( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.CartonResolve request) { + Identity identity = identity(user); + return ApiResponse.success(service.resolveCarton(identity.customerId(), identity.operatorKey(), request)); + } + + @PostMapping("/current/pallets/resolve") + public ApiResponse resolvePallet( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.PalletResolve request) { + Identity identity = identity(user); + return ApiResponse.success(service.resolvePallet(identity.customerId(), identity.operatorKey(), request)); + } + + @PostMapping("/current/receipts/bulk") + public ApiResponse receiveBulk(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.BulkReceive request) { + Identity identity = identity(user); + return ApiResponse.success(service.receiveBulk(identity.customerId(), identity.operatorKey(), identity.operatorName(), request)); + } + + @PostMapping("/current/receipts/carton") + public ApiResponse receiveCarton(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.CartonReceive request) { + Identity identity = identity(user); + return ApiResponse.success(service.receiveCarton(identity.customerId(), identity.operatorKey(), identity.operatorName(), request)); + } + + @PostMapping("/current/complete") + public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.Action request) { + Identity identity = identity(user); + return ApiResponse.success(service.complete(identity.customerId(), identity.operatorKey(), request)); + } + + private static Identity identity(AuthenticatedUser user) { + if (user == null || user.tenantId() == null || user.tenantId() <= 0 || user.userId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated PDA customer identity is required"); + } + if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required"); + } + String operator = String.valueOf(user.userId()); + return new Identity(String.valueOf(user.tenantId()), operator, + user.username() == null || user.username().isBlank() ? operator : user.username()); + } + + private record Identity(String customerId, String operatorKey, String operatorName) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java new file mode 100644 index 000000000..191cad025 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.inboundplan.pda.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Positive; + +import java.util.List; + +public final class ReceivingContainerRequests { + private ReceivingContainerRequests() { } + + public record Action(@NotBlank String idempotencyKey) { } + public record Sign(@NotEmpty List<@Positive Long> photoFileIds, @NotBlank String idempotencyKey) { } + public record CartonResolve(@NotBlank String cartonMark) { } + public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { } + public record BulkReceive(@NotBlank String inboundCode, @NotBlank String palletCode, + @Positive int packageCount, @NotBlank String idempotencyKey) { } + public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode, + @NotBlank String idempotencyKey) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java new file mode 100644 index 000000000..7a1acf4cb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.inboundplan.pda.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class ReceivingContainerResponses { + private ReceivingContainerResponses() { } + + public record Candidate(String containerNo, String status, int planCount, int inboundCodeCount, + int totalPackages, int receivedPackages, boolean currentAccount) { } + + public record Session(String containerNo, String status, String operatorName, int version, + List photoFileIds, LocalDateTime preparedAt, LocalDateTime signedAt, + LocalDateTime receivingAt, LocalDateTime completedAt, + int totalPackages, int receivedPackages, int pendingPackages, + List inbounds) { } + + public record Inbound(String inboundCode, String destinationType, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + String fbaWarehouseCode, int plannedPalletCount, int totalPackages, + int receivedPackages, int pendingPackages) { } + + public record CartonResolved(String cartonMark, String skuCode, String inboundCode, + String destinationType, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + String fbaWarehouseCode, int plannedPalletCount, int totalPackages, + int receivedPackages, int pendingPackages) { } + + public record PalletResolved(String palletCode, long warehouseId, String status, + String fbaWarehouseCode, String inboundCode, + String destinationType, boolean compatible) { } + + public record Receipt(String result, String containerNo, String inboundCode, + String destinationType, String palletCode, int acceptedPackages, + int receivedPackages, int pendingPackages, String sessionStatus) { } + + public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerOperation.java new file mode 100644 index 000000000..18d2bfcac --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerOperation.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.inboundplan.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_receiving_container_operation") +public class ReceivingContainerOperation { + @TableId(type = IdType.INPUT) + private Long id; + private Long sessionId; + private String customerId; + private String operatorKey; + private String action; + private String idempotencyKey; + private String requestHash; + private String requestJson; + private String resultJson; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java new file mode 100644 index 000000000..e2362227a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.inboundplan.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.Version; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_receiving_container_session") +public class ReceivingContainerSession { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private String containerNo; + private String operatorKey; + private String operatorName; + private String activeOperatorKey; + private String activeContainerKey; + private String status; + private String photoFileIds; + private LocalDateTime preparedAt; + private LocalDateTime signedAt; + private LocalDateTime receivingAt; + private LocalDateTime completedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + @Version + private Integer version; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java index 1a5ebcf0c..c23317f40 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -35,7 +35,9 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { throw new IllegalStateException("Warehouse rejected dropship pallet: HTTP " + result.getStatusCode()); }).body(WarehousePalletResponse.class); if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty pallet response"); - return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().locationId()); + return new WarehousePalletReference(response.data().id(), response.data().palletCode(), + response.data().warehouseId(), response.data().locationId(), response.data().status(), + response.data().fbaWarehouseCode(), response.data().version()); } @Override @@ -82,8 +84,23 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { .toBodilessEntity(); } + @Override + public WarehousePalletReference resolvePallet(String palletCode) { + WarehousePalletResponse response = client.get() + .uri("/internal/v1/warehouse-pallets/resolve/{palletCode}", palletCode) + .header("X-Internal-Service-Token", internalToken) + .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalArgumentException("Warehouse rejected pallet code: HTTP " + result.getStatusCode()); + }).body(WarehousePalletResponse.class); + if (response == null || response.data() == null) throw new IllegalArgumentException("Warehouse returned an empty pallet response"); + return new WarehousePalletReference(response.data().id(), response.data().palletCode(), + response.data().warehouseId(), response.data().locationId(), response.data().status(), + response.data().fbaWarehouseCode(), response.data().version()); + } + private record WarehousePalletResponse(WarehousePalletData data) {} - private record WarehousePalletData(long id, String palletCode, long locationId) {} + private record WarehousePalletData(long id, String palletCode, long warehouseId, long locationId, + String status, String fbaWarehouseCode, int version) {} private record ReserveEnvelope(ReserveData data) {} private record ReserveData(long lockId) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java index ada468ece..a1f5f2419 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java @@ -6,6 +6,12 @@ public interface WarehouseResourceGateway { String businessType, String businessId, String reservationKey); void scrap(String customerId, long resourceLockId); void scrapPallet(String customerId, long palletId); + WarehousePalletReference resolvePallet(String palletCode); - record WarehousePalletReference(long palletId, String palletNo, long locationId) {} + record WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId, + String status, String fbaWarehouseCode, int version) { + public WarehousePalletReference(long palletId, String palletNo, long locationId) { + this(palletId, palletNo, 0L, locationId, null, null, 0); + } + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java new file mode 100644 index 000000000..c07beb72f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ReceivingContainerOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_receiving_container_operation WHERE customer_id=#{customerId} " + + "AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1") + ReceivingContainerOperation find(@Param("customerId") String customerId, + @Param("operatorKey") String operatorKey, + @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java new file mode 100644 index 000000000..49485e0f6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ReceivingContainerSessionMapper extends BaseMapper { + @Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " + + "AND active_operator_key=#{activeOperatorKey} LIMIT 1 FOR UPDATE") + ReceivingContainerSession lockActiveByOperator(@Param("customerId") String customerId, + @Param("activeOperatorKey") String activeOperatorKey); + + @Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " + + "AND active_operator_key=#{activeOperatorKey} LIMIT 1") + ReceivingContainerSession findActiveByOperator(@Param("customerId") String customerId, + @Param("activeOperatorKey") String activeOperatorKey); + + @Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " + + "AND active_container_key=#{activeContainerKey} LIMIT 1 FOR UPDATE") + ReceivingContainerSession lockActiveByContainer(@Param("customerId") String customerId, + @Param("activeContainerKey") String activeContainerKey); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index e34ab20bd..fde44b04a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -135,6 +135,74 @@ public class InboundPdaWorkflowService { return task(task, planNo); } + @Transactional + public void ensureSignedTask(String customerId, InboundPlan plan, String operatorId, String operatorName, + List photoFileIds, LocalDateTime signedAt) { + InboundPdaTask task = repository.lockTask(customerId, plan.getId()); + if (task == null) { + task = new InboundPdaTask(); + task.setId(IdWorker.getId()); task.setPlanId(plan.getId()); task.setCustomerId(customerId); + task.setTaskNo("PDA" + task.getId()); task.setCreatedAt(signedAt); task.setVersion(0); + repository.insert(task); + } + if ("COMPLETED".equals(task.getStatus())) return; + task.setStatus("SIGNED"); task.setOperatorId(operatorId); task.setOperatorName(operatorName); + task.setUnloadPhotoFileIds(write(photoFileIds)); task.setUnloadPhotoUploadedAt(signedAt); + task.setEstimatedEmptyReturnAt(signedAt.plusDays(2)); task.setUpdatedAt(signedAt); + task.setVersion(value(task.getVersion()) + 1); repository.update(task); + plan.setStatus("SIGNED"); plan.setUpdatedAt(signedAt); repository.update(plan); + } + + @Transactional + public void assignResolvedPallet(String customerId, InboundPlan plan, InboundPlanPallet pallet, + WarehouseResourceGateway.WarehousePalletReference resource) { + if (pallet.getWarehouseResourceLockId() != null) { + if (Objects.equals(pallet.getWarehousePalletId(), resource.palletId())) return; + throw conflict("Destination pallet is already bound to another physical pallet"); + } + String destinationKey = destinationKey(pallet); + String businessId = plan.getContainerNo() + ":" + destinationKey; + String reservationKey = "INBOUND_CONTAINER_DESTINATION_PALLET:" + businessId + ":" + resource.palletId(); + long lockId = warehouse.reserve(customerId, resource.warehouseId(), resource.palletId(), resource.locationId(), + "INBOUND_CONTAINER_DESTINATION_PALLET", businessId, reservationKey); + pallet.setWarehousePalletId(resource.palletId()); pallet.setWarehouseLocationId(resource.locationId()); + pallet.setWarehouseResourceLockId(lockId); pallet.setAssignmentStatus("ASSIGNED"); + pallet.setLockedAt(LocalDateTime.now()); repository.update(pallet); + } + + @Transactional + public int receiveBulkResolved(String customerId, InboundPlan plan, InboundPlanLine line, + InboundPlanPallet pallet, int packageCount, + String operatorId, String operatorName) { + InboundPdaTask task = requireActiveTask(customerId, plan); + int received = repository.allocations(plan.getId()).stream() + .filter(value -> Objects.equals(value.getLineId(), line.getId())) + .mapToInt(value -> value(value.getPickedPackageCount())).sum(); + int pending = value(line.getRequiredPackageCount()) - received; + if (packageCount > pending) throw conflict("Received package count exceeds the inbound pending total"); + InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId()); + if (allocation == null) { + allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); + allocation.setCustomerId(customerId); allocation.setPlanId(plan.getId()); allocation.setLineId(line.getId()); + allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME); + allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0); + allocation.setPickedPackageCount(0); allocation.setActualVolumeCbm(ZERO_VOLUME); + allocation.setStatus("RECEIVING"); allocation.setVersion(0); repository.insert(allocation); + } + BigDecimal perPackage = perPackageVolume(line); + allocation.setCountedPackageCount(value(allocation.getCountedPackageCount()) + packageCount); + allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) + packageCount); + allocation.setActualVolumeCbm(volume(allocation.getActualVolumeCbm()) + .add(perPackage.multiply(BigDecimal.valueOf(packageCount))).setScale(4, RoundingMode.HALF_UP)); + allocation.setStatus(allocation.getPickedPackageCount() == value(line.getRequiredPackageCount()) + ? "RECEIVED" : "RECEIVING"); bump(allocation); repository.update(allocation); + pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + packageCount); + pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm()) + .add(perPackage.multiply(BigDecimal.valueOf(packageCount))).setScale(4, RoundingMode.HALF_UP)); + repository.update(pallet); startPicking(task, plan, operatorId, operatorName); + return packageCount; + } + @Transactional public PdaResponses.Operation count(String customerId, String planNo, long allocationId, PdaRequests.PackageOperation request) { @@ -395,14 +463,18 @@ public class InboundPdaWorkflowService { throw conflict("All line picked package totals must exactly equal required package totals"); } } + Map destinationTypes = lines.stream().collect(java.util.stream.Collectors.toMap( + InboundPlanLine::getId, line -> destinationType(line))); List cartons = repository.cartons(plan.getId()); - if (!cartons.isEmpty() && cartons.stream().anyMatch(carton -> !"ON_PALLET".equals(carton.getStatus()))) { + if (cartons.stream().anyMatch(carton -> "OVERSEAS".equals(destinationTypes.get(carton.getLineId())) + && !"ON_PALLET".equals(carton.getStatus()))) { throw conflict("All expected carton marks must be scanned onto a pallet before stock-in"); } LocalDateTime now = LocalDateTime.now(); stockReceipts.stockIn(customerId, plan, task, lines, allocations, repository.pallets(plan.getId()), now); for (InboundPlanCarton carton : cartons) { - carton.setStatus("AVAILABLE"); carton.setReceivedAt(now); carton.setUpdatedAt(now); + carton.setStatus("ON_PALLET".equals(carton.getStatus()) ? "AVAILABLE" : "BULK_RECEIVED"); + carton.setReceivedAt(now); carton.setUpdatedAt(now); carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton); } task.setStatus("COMPLETED"); @@ -705,6 +777,13 @@ public class InboundPdaWorkflowService { return volume(line.getVolumeCbm()).divide(BigDecimal.valueOf(required), 8, RoundingMode.HALF_UP); } + private static String destinationType(InboundPlanLine line) { + if (line.getDestinationType() != null && !line.getDestinationType().isBlank()) { + return line.getDestinationType().trim().toUpperCase(); + } + return line.getFbaWarehouseCode() == null || line.getFbaWarehouseCode().isBlank() ? "OVERSEAS" : "FBA"; + } + private InboundPdaOperation replay(String customerId, String action, String key, Object request) { InboundPdaOperation operation = repository.operation(customerId, key); if (operation == null) return null; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java new file mode 100644 index 000000000..5f92fb9d6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -0,0 +1,469 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; +import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@Service +public class ReceivingContainerService { + @Resource private InboundPlanMapper plans; + @Resource private InboundPlanLineMapper lines; + @Resource private InboundPlanPalletMapper pallets; + @Resource private InboundPlanPalletAllocationMapper allocations; + @Resource private InboundPlanCartonMapper cartons; + @Resource private LastlegOrderMapper orders; + @Resource private ReceivingContainerSessionMapper sessions; + @Resource private ReceivingContainerOperationMapper operations; + @Resource private WarehouseResourceGateway warehouse; + @Resource private UnloadPhotoFileGateway unloadPhotos; + @Resource private InboundPdaWorkflowService workflow; + @Resource private ObjectMapper sourceObjectMapper; + + private ObjectMapper objectMapper; + + @PostConstruct + void initializeObjectMapper() { + objectMapper = sourceObjectMapper.copy() + .configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true) + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + } + + public List candidates(String customerId, String operatorKey) { + ReceivingContainerSession current = sessions.findActiveByOperator(customerId, activeOperatorKey(customerId, operatorKey)); + Map> grouped = new LinkedHashMap<>(); + plans.selectList(new LambdaQueryWrapper().eq(InboundPlan::getCustomerId, customerId) + .ne(InboundPlan::getStatus, "COMPLETED").orderByAsc(InboundPlan::getCreatedAt)).forEach(plan -> { + if (plan.getContainerNo() != null && !plan.getContainerNo().isBlank()) { + grouped.computeIfAbsent(plan.getContainerNo(), ignored -> new ArrayList<>()).add(plan); + } + }); + return grouped.entrySet().stream().map(entry -> { + Totals totals = totals(entry.getValue()); + boolean active = current != null && entry.getKey().equals(current.getContainerNo()); + return new ReceivingContainerResponses.Candidate(entry.getKey(), active ? current.getStatus() : "WAIT_SIGN", + entry.getValue().size(), totals.inboundCount(), totals.total(), totals.received(), active); + }).toList(); + } + + public ReceivingContainerResponses.Session detail(String customerId, String operatorKey, String containerNo) { + List containerPlans = requirePlans(customerId, containerNo); + ReceivingContainerSession current = sessions.findActiveByOperator(customerId, activeOperatorKey(customerId, operatorKey)); + if (current != null && !containerNo.equals(current.getContainerNo()) && !"WAIT_SIGN".equals(current.getStatus())) { + throw conflict("Current account is locked to another signed container"); + } + return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView( + customerId, operatorKey, containerNo), containerPlans); + } + + @Transactional + public ReceivingContainerResponses.Session prepare(String customerId, String operatorKey, String operatorName, + String containerNo, ReceivingContainerRequests.Action request) { + ReceivingContainerOperation replay = replay(customerId, operatorKey, "PREPARE", request.idempotencyKey(), request); + if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); + List containerPlans = requirePlans(customerId, containerNo); + String activeOperatorKey = activeOperatorKey(customerId, operatorKey); + ReceivingContainerSession active = sessions.lockActiveByOperator(customerId, activeOperatorKey); + if (active != null) { + if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one"); + ReceivingContainerResponses.Session result = snapshot(active, containerPlans); + save(active, "PREPARE", request.idempotencyKey(), request, result); return result; + } + if (sessions.lockActiveByContainer(customerId, activeContainerKey(customerId, containerNo)) != null) { + throw conflict("Container is already signed or prepared by another account"); + } + LocalDateTime now = LocalDateTime.now(); + ReceivingContainerSession session = new ReceivingContainerSession(); + session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo); + session.setOperatorKey(operatorKey); session.setOperatorName(operatorName); + session.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(customerId, containerNo)); + session.setStatus("WAIT_SIGN"); session.setPreparedAt(now); session.setCreatedAt(now); session.setUpdatedAt(now); + session.setVersion(0); + try { sessions.insert(session); } + catch (DuplicateKeyException duplicate) { throw conflict("Account or container already has an active receiving session"); } + ReceivingContainerResponses.Session result = snapshot(session, containerPlans); + save(session, "PREPARE", request.idempotencyKey(), request, result); return result; + } + + public ReceivingContainerResponses.Photo uploadPhoto(String customerId, String operatorKey, long operatorId, + MultipartFile file) { + ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true); + if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Unload photos can only be uploaded before signing"); + UnloadPhotoFileGateway.UploadedFile uploaded = unloadPhotos.upload(customerId, session.getId(), operatorId, file); + return new ReceivingContainerResponses.Photo(uploaded.fileId(), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes()); + } + + @Transactional + public ReceivingContainerResponses.Session sign(String customerId, String operatorKey, String operatorName, + ReceivingContainerRequests.Sign request) { + ReceivingContainerOperation replay = replay(customerId, operatorKey, "SIGN", request.idempotencyKey(), request); + if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); + ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true); + if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed"); + List photos = request.photoFileIds().stream().distinct().sorted().toList(); + if (photos.size() != request.photoFileIds().size()) throw conflict("Unload photo file IDs cannot be duplicated"); + photos.forEach(fileId -> unloadPhotos.validate(customerId, session.getId(), fileId)); + LocalDateTime now = LocalDateTime.now(); + session.setPhotoFileIds(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now); + session.setOperatorName(operatorName); session.setUpdatedAt(now); update(session); + for (InboundPlan plan : requirePlans(customerId, session.getContainerNo())) { + workflow.ensureSignedTask(customerId, plan, operatorKey, operatorName, photos, now); + } + ReceivingContainerResponses.Session result = snapshot(session, requirePlans(customerId, session.getContainerNo())); + save(session, "SIGN", request.idempotencyKey(), request, result); return result; + } + + public ReceivingContainerResponses.Session current(String customerId, String operatorKey) { + ReceivingContainerSession session = requireCurrent(customerId, operatorKey, false); + return snapshot(session, requirePlans(customerId, session.getContainerNo())); + } + + public ReceivingContainerResponses.CartonResolved resolveCarton(String customerId, String operatorKey, + ReceivingContainerRequests.CartonResolve request) { + ReceivingContainerSession session = requireSigned(customerId, operatorKey); + InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark())); + if (carton == null) throw notFound("Carton mark was not found"); + InboundRef ref = refs(customerId, session.getContainerNo()).stream() + .filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst() + .orElseThrow(() -> conflict("Carton mark belongs to another container")); + if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { + throw conflict("FBA inbound must use bulk count instead of carton scan"); + } + ReceivingContainerResponses.Inbound inbound = inbound(ref); + return new ReceivingContainerResponses.CartonResolved(carton.getCartonMark(), carton.getSkuCode(), + inbound.inboundCode(), inbound.destinationType(), inbound.destinationWarehouseId(), + inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), + inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages()); + } + + public ReceivingContainerResponses.PalletResolved resolvePallet(String customerId, String operatorKey, + ReceivingContainerRequests.PalletResolve request) { + ReceivingContainerSession session = requireSigned(customerId, operatorKey); + InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); + WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(request.palletCode()); + validatePallet(ref, pallet); + return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(), + pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true); + } + + @Transactional + public ReceivingContainerResponses.Receipt receiveBulk(String customerId, String operatorKey, String operatorName, + ReceivingContainerRequests.BulkReceive request) { + ReceivingContainerOperation replay = replay(customerId, operatorKey, "BULK_RECEIVE", request.idempotencyKey(), request); + if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); + ReceivingContainerSession session = requireSigned(customerId, operatorKey); + InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); + if (!"FBA".equals(destinationType(ref.line(), ref.order()))) { + throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden"); + } + WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode()); + validatePallet(ref, resource); + InboundPlanPallet pallet = choosePallet(ref, resource); + workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); + int accepted = workflow.receiveBulkResolved(customerId, ref.plan(), ref.line(), pallet, + request.packageCount(), operatorKey, operatorName); + markReceiving(session); + ReceivingContainerResponses.Inbound inbound = inbound(ref); + ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", + session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), accepted, + inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus()); + save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result); return result; + } + + @Transactional + public ReceivingContainerResponses.Receipt receiveCarton(String customerId, String operatorKey, String operatorName, + ReceivingContainerRequests.CartonReceive request) { + ReceivingContainerOperation replay = replay(customerId, operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request); + if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); + ReceivingContainerSession session = requireSigned(customerId, operatorKey); + InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark())); + if (carton == null) throw notFound("Carton mark was not found"); + InboundRef ref = refs(customerId, session.getContainerNo()).stream() + .filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst() + .orElseThrow(() -> conflict("Carton mark belongs to another container")); + if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { + throw conflict("FBA destination must use bulk count"); + } + WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode()); + validatePallet(ref, resource); + InboundPlanPallet pallet = choosePallet(ref, resource); + workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); + workflow.scanCarton(customerId, ref.plan().getPlanNo(), new PdaRequests.ScanCarton( + request.cartonMark(), pallet.getId(), request.idempotencyKey() + ":SCAN", operatorKey, operatorName)); + markReceiving(session); + ReceivingContainerResponses.Inbound inbound = inbound(ref); + ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", + session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), 1, + inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus()); + save(session, "CARTON_RECEIVE", request.idempotencyKey(), request, result); return result; + } + + @Transactional + public ReceivingContainerResponses.Session complete(String customerId, String operatorKey, + ReceivingContainerRequests.Action request) { + ReceivingContainerOperation replay = replay(customerId, operatorKey, "COMPLETE", request.idempotencyKey(), request); + if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); + ReceivingContainerSession session = requireSigned(customerId, operatorKey); + List containerPlans = requirePlans(customerId, session.getContainerNo()); + Totals totals = totals(containerPlans); + if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container"); + for (InboundPlan plan : containerPlans) workflow.complete(customerId, plan.getPlanNo()); + LocalDateTime now = LocalDateTime.now(); + session.setStatus("COMPLETED"); session.setCompletedAt(now); session.setUpdatedAt(now); + session.setActiveOperatorKey(null); session.setActiveContainerKey(null); update(session); + ReceivingContainerResponses.Session result = snapshot(session, containerPlans); + save(session, "COMPLETE", request.idempotencyKey(), request, result); return result; + } + + private ReceivingContainerSession requireCurrent(String customerId, String operatorKey, boolean lock) { + String key = activeOperatorKey(customerId, operatorKey); + ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(customerId, key) + : sessions.findActiveByOperator(customerId, key); + if (session == null) throw notFound("Current receiving container session was not found"); + return session; + } + + private ReceivingContainerSession requireSigned(String customerId, String operatorKey) { + ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true); + if (!List.of("SIGNED", "RECEIVING").contains(session.getStatus())) { + throw conflict("Sign the receiving container before receiving cargo"); + } + return session; + } + + private List requirePlans(String customerId, String containerNo) { + List values = plansForContainer(customerId, containerNo); + if (values.isEmpty()) throw notFound("Receiving container was not found"); + if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) { + throw conflict("Receiving container is already completed"); + } + return values; + } + + private List plansForContainer(String customerId, String containerNo) { + List values = plans.selectList(new LambdaQueryWrapper() + .eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getContainerNo, containerNo) + .orderByAsc(InboundPlan::getId)); + if (values.isEmpty()) throw notFound("Receiving container was not found"); + return values; + } + + private List refs(String customerId, String containerNo) { + List result = new ArrayList<>(); + for (InboundPlan plan : plansForContainer(customerId, containerNo)) { + for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper() + .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) { + LastlegOrder order = orders.selectById(line.getOrderId()); + if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Inbound line order is missing"); + result.add(new InboundRef(plan, line, order)); + } + } + return result; + } + + private InboundRef requireInbound(String customerId, String containerNo, String inboundCode) { + List matches = refs(customerId, containerNo).stream() + .filter(value -> inboundCode.equals(value.line().getInboundCode())).toList(); + if (matches.isEmpty()) throw notFound("Inbound code is not part of the current container"); + if (matches.size() != 1) throw conflict("Inbound code is duplicated inside the current container"); + return matches.get(0); + } + + private InboundPlanPallet choosePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference resource) { + List candidates = pallets.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId()) + .eq(InboundPlanPallet::getPlanId, ref.plan().getId())).stream() + .filter(value -> sameDestination(ref.line(), value)) + .filter(value -> value.getWarehousePalletId() == null || Objects.equals(value.getWarehousePalletId(), resource.palletId())) + .sorted(Comparator.comparing((InboundPlanPallet value) -> !Objects.equals(value.getWarehousePalletId(), resource.palletId())) + .thenComparing(value -> value.getActualPackageCount() == null ? 0 : value.getActualPackageCount())) + .toList(); + if (candidates.isEmpty()) throw conflict("Pallet does not match an available destination pallet in this inbound code"); + return candidates.get(0); + } + + private void validatePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference pallet) { + if ("SCRAPPED".equals(pallet.status())) throw conflict("Pallet is scrapped"); + long actualWarehouse; + try { actualWarehouse = Long.parseLong(ref.order().getOverseasWarehouseId()); } + catch (Exception exception) { throw conflict("Inbound order has no valid receiving warehouse ID"); } + if (pallet.warehouseId() != actualWarehouse) throw conflict("Pallet belongs to another overseas warehouse"); + String type = destinationType(ref.line(), ref.order()); + if ("FBA".equals(type)) { + if (ref.line().getFbaWarehouseCode() == null || ref.line().getFbaWarehouseCode().isBlank() + || !ref.line().getFbaWarehouseCode().equalsIgnoreCase(String.valueOf(pallet.fbaWarehouseCode()))) { + throw conflict("Pallet FBA destination does not match the inbound code"); + } + } else { + if (pallet.fbaWarehouseCode() != null && !pallet.fbaWarehouseCode().isBlank()) { + throw conflict("FBA-designated pallet cannot receive an OVERSEAS destination carton"); + } + if (ref.line().getDestinationWarehouseId() != null && !ref.line().getDestinationWarehouseId().isBlank()) { + try { + if (Long.parseLong(ref.line().getDestinationWarehouseId()) != pallet.warehouseId()) { + throw conflict("Pallet destination warehouse does not match the inbound code"); + } + } catch (NumberFormatException ignored) { + // Destination codes that are not resource IDs are still protected by the actual overseas warehouse check. + } + } + } + } + + private boolean sameDestination(InboundPlanLine line, InboundPlanPallet pallet) { + if (line.getDestinationWarehouseId() != null && !line.getDestinationWarehouseId().isBlank()) { + return line.getDestinationWarehouseId().equals(pallet.getDestinationWarehouseId()); + } + return Objects.equals(line.getDestinationWarehouseCode(), pallet.getDestinationWarehouseCode()); + } + + private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List containerPlans) { + List inbounds = refs(session.getCustomerId(), session.getContainerNo()).stream() + .map(this::inbound).toList(); + int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(); + int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum(); + return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getStatus(), session.getOperatorName(), + value(session.getVersion()), photoIds(session), session.getPreparedAt(), session.getSignedAt(), + session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), inbounds); + } + + private ReceivingContainerResponses.Inbound inbound(InboundRef ref) { + int received = allocations.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getCustomerId, ref.order().getCustomerId()) + .eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId()) + .eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())).stream() + .mapToInt(value -> value(value.getPickedPackageCount())).sum(); + int total = value(ref.line().getRequiredPackageCount()); + return new ReceivingContainerResponses.Inbound(ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), + ref.line().getDestinationWarehouseId(), ref.line().getDestinationWarehouseCode(), + ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(), + value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received)); + } + + private Totals totals(List containerPlans) { + List values = refs(containerPlans.get(0).getCustomerId(), + containerPlans.get(0).getContainerNo()).stream().map(this::inbound).toList(); + return new Totals(values.size(), values.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(), + values.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum()); + } + + private String destinationType(InboundPlanLine line, LastlegOrder order) { + String value = line.getDestinationType(); + if (value == null || value.isBlank()) value = order.getDestinationType(); + if (value == null || value.isBlank()) value = line.getFbaWarehouseCode() == null || line.getFbaWarehouseCode().isBlank() + ? "OVERSEAS" : "FBA"; + value = value.trim().toUpperCase(); + if (!List.of("FBA", "OVERSEAS").contains(value)) throw conflict("Unsupported destination type"); + return value; + } + + private void markReceiving(ReceivingContainerSession session) { + if (!"RECEIVING".equals(session.getStatus())) { + session.setStatus("RECEIVING"); session.setReceivingAt(LocalDateTime.now()); + } + session.setUpdatedAt(LocalDateTime.now()); update(session); + } + + private void update(ReceivingContainerSession session) { + if (sessions.updateById(session) != 1) throw conflict("Receiving container session changed concurrently"); + } + + private ReceivingContainerSession waitingView(String customerId, String operatorKey, String containerNo) { + ReceivingContainerSession value = new ReceivingContainerSession(); value.setCustomerId(customerId); + value.setOperatorKey(operatorKey); value.setContainerNo(containerNo); value.setStatus("WAIT_SIGN"); value.setVersion(0); + return value; + } + + private ReceivingContainerOperation replay(String customerId, String operatorKey, String action, String key, Object request) { + ReceivingContainerOperation value = operations.find(customerId, operatorKey, key); + if (value == null) return null; + if (!action.equals(value.getAction()) || !hash(request).equals(value.getRequestHash())) { + throw conflict("Idempotency key was already used with a different request"); + } + return value; + } + + private void save(ReceivingContainerSession session, String action, String key, Object request, Object result) { + ReceivingContainerOperation value = new ReceivingContainerOperation(); value.setId(IdWorker.getId()); + value.setSessionId(session.getId()); value.setCustomerId(session.getCustomerId()); + value.setOperatorKey(session.getOperatorKey()); value.setAction(action); value.setIdempotencyKey(key); + value.setRequestHash(hash(request)); value.setRequestJson(write(request)); value.setResultJson(write(result)); + value.setCreatedAt(LocalDateTime.now()); operations.insert(value); + } + + private List photoIds(ReceivingContainerSession session) { + if (session.getPhotoFileIds() == null || session.getPhotoFileIds().isBlank()) return List.of(); + try { return objectMapper.readValue(session.getPhotoFileIds(), new TypeReference>() { }); } + catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read receiving photo IDs", exception); } + } + + private String hash(Object value) { + try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(write(value).getBytes(StandardCharsets.UTF_8))); } + catch (Exception exception) { throw new IllegalStateException(exception); } + } + + private String write(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { throw new IllegalStateException(exception); } + } + + private T read(ReceivingContainerOperation operation, Class type) { + try { return objectMapper.readValue(operation.getResultJson(), type); } + catch (JsonProcessingException exception) { throw new IllegalStateException(exception); } + } + + private static String activeOperatorKey(String customerId, String operatorKey) { return customerId + ":" + operatorKey; } + private static String activeContainerKey(String customerId, String containerNo) { return customerId + ":" + containerNo; } + private static int value(Integer value) { return value == null ? 0 : value; } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } + + private record InboundRef(InboundPlan plan, InboundPlanLine line, LastlegOrder order) { } + private record Totals(int inboundCount, int total, int received) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 7ef011637..6ae72a34e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -162,6 +162,8 @@ public class InboundPlanService { line.setPlanId(plan.getId()); line.setOrderId(order.orderId()); line.setInboundCode(order.inboundCode()); + line.setDestinationType(order.fbaWarehouseCode() == null || order.fbaWarehouseCode().isBlank() + ? "OVERSEAS" : "FBA"); line.setFbaWarehouseCode(order.fbaWarehouseCode()); line.setDestinationWarehouseId(order.destinationWarehouseId()); line.setDestinationWarehouseCode(order.destinationWarehouseCode()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 0dd2de096..8b88d3694 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -179,7 +179,7 @@ public class LastlegOrderService { private LastlegOrderSummary summary(LastlegOrder o) { return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(), - LastlegOrderType.labelOf(o.getOrderType()), o.getSourceInboundId(), + LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(), o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(), o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index e7407e43b..e48da2873 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -107,12 +107,27 @@ public class LoadingCompletedConsumer { } continue; } + String destinationType = destinationType(payload, order); for (FirstLegLoadingPlanCompletedV1.Item item : order.items()) { - requireSkuCartonMapping(item.skuCode(), item.packageCount(), item.cartonMarks(), orderCartonMarks); + if ("OVERSEAS".equals(destinationType)) { + requireSkuCartonMapping(item.skuCode(), item.packageCount(), item.cartonMarks(), orderCartonMarks); + } else { + validateOptionalFbaCartonMapping(item.skuCode(), item.packageCount(), item.cartonMarks(), orderCartonMarks); + } } } } + private void validateOptionalFbaCartonMapping(String skuCode, int loadedPackages, List cartonMarks, + Set orderCartonMarks) { + List marks = cartonMarks == null ? List.of() : cartonMarks; + if (skuCode == null || skuCode.isBlank()) { + throw new IllegalArgumentException("V2 尾程交接事件缺少 SKU"); + } + if (marks.isEmpty()) return; + requireSkuCartonMapping(skuCode, loadedPackages, marks, orderCartonMarks); + } + private void requireSkuCartonMapping(String skuCode, int loadedPackages, List cartonMarks, Set orderCartonMarks) { if (skuCode == null || skuCode.isBlank()) { @@ -139,6 +154,7 @@ public class LoadingCompletedConsumer { } LastlegOrder order=new LastlegOrder(); String orderType = source.orderType(); + String destinationType = destinationType(payload, source); String customerId = source.customerId() == null || source.customerId().isBlank() ? event.tenantId() : source.customerId(); if (!event.tenantId().equals(customerId)) { @@ -150,6 +166,7 @@ public class LoadingCompletedConsumer { throw new IllegalArgumentException("One-click dropship event has no pallet snapshot"); } order.setId(IdWorker.getId()); order.setOrderNo(orderNo); order.setCustomerId(customerId); order.setOrderType(orderType); + order.setDestinationType(destinationType); order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name()); order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId()); order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId()); @@ -186,7 +203,7 @@ public class LoadingCompletedConsumer { detail.setDestinationWarehouseId(item.destinationWarehouseId()); detail.setDestinationWarehouseCode(item.destinationWarehouseCode()); detail.setDestinationWarehouseName(item.destinationWarehouseName()); detail.setFbaTrackingNo(item.fbaTrackingNo()); detail.setShipmentNo(item.shipmentNo()); detail.setShippingMark(item.shippingMark()); detailMapper.insert(detail); - createCartonLabels(customerId, order.getId(), item); + createCartonLabels(customerId, order.getId(), destinationType, item); } LastlegOrderStatusHistory history=new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId()); @@ -216,6 +233,7 @@ public class LoadingCompletedConsumer { snapshot.setDestinationWarehouseId(source.destinationWarehouseId()); snapshot.setDestinationWarehouseCode(source.destinationWarehouseCode()); snapshot.setDestinationWarehouseName(source.destinationWarehouseName()); + snapshot.setDestinationType(order.getDestinationType()); snapshot.setPackageCount(source.packageCount()); snapshot.setProductQuantity(source.productQuantity()); snapshot.setPalletCount(source.palletCount()); snapshot.setGrossWeightKg(source.grossWeightKg()); snapshot.setNetWeightKg(source.netWeightKg()); snapshot.setVolumeCbm(source.volumeCbm()); @@ -243,9 +261,11 @@ public class LoadingCompletedConsumer { } } - private void createCartonLabels(String customerId, Long orderId, FirstLegLoadingPlanCompletedV1.Item item) { + private void createCartonLabels(String customerId, Long orderId, String destinationType, + FirstLegLoadingPlanCompletedV1.Item item) { + if (!"OVERSEAS".equals(destinationType)) return; List marks = item.cartonMarks() == null ? List.of() : item.cartonMarks(); - if (marks.isEmpty()) return; // 历史 V1 事件保持原有按箱数入库路径。 + if (marks.isEmpty()) return; if (marks.stream().anyMatch(mark -> mark == null || mark.isBlank()) || marks.stream().distinct().count() != marks.size()) { throw new IllegalArgumentException("装柜事件的箱唛池不可包含空值或重复箱唛"); } @@ -261,6 +281,40 @@ public class LoadingCompletedConsumer { } } + private String destinationType(FirstLegLoadingPlanCompletedV1 payload, + FirstLegLoadingPlanCompletedV1.Order order) { + String explicit = normalizeDestinationType(order.destinationType()); + if (explicit != null) return explicit; + + boolean fbaEvidence = order.items().stream().anyMatch(item -> hasText(item.fbaTrackingNo()) + || hasText(item.shipmentNo()) || startsWithFba(item.destinationWarehouseName())); + boolean overseasWarehouseIdentity = hasText(payload.warehouseId()) + && Objects.equals(payload.warehouseId(), order.destinationWarehouseId()); + if (fbaEvidence && overseasWarehouseIdentity) { + throw new IllegalArgumentException("历史尾程事件目的类型证据冲突: " + order.sourceInboundId()); + } + if (fbaEvidence) return "FBA"; + if (overseasWarehouseIdentity) return "OVERSEAS"; + throw new IllegalArgumentException("历史尾程事件缺少 destinationType 且无法安全推断: " + order.sourceInboundId()); + } + + private String normalizeDestinationType(String value) { + if (!hasText(value)) return null; + String normalized = value.trim().toUpperCase(java.util.Locale.ROOT); + if (!"FBA".equals(normalized) && !"OVERSEAS".equals(normalized)) { + throw new IllegalArgumentException("尾程事件 destinationType 必须为 FBA 或 OVERSEAS"); + } + return normalized; + } + + private boolean startsWithFba(String value) { + return hasText(value) && value.trim().toUpperCase(java.util.Locale.ROOT).startsWith("FBA"); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + private List pallets(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) { if (source.pallets() != null && !source.pallets().isEmpty()) { return source.pallets().stream().map(pallet -> new DropshipRequests.Pallet(pallet.palletNo(), diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 3c2f5c587..3b01747ad 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -23,6 +23,7 @@ CREATE TABLE IF NOT EXISTS ll_order ( order_no VARCHAR(32) NOT NULL COMMENT '尾程单号', customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型:FIRST_LEG头程订单、RELABEL_APPLICATION换标下单、WAREHOUSE_TRANSFER仓储转运、LOCAL_LOOSEBOARD本土散板、RETURN_RELABEL退货换标、ONE_CLICK_DROPSHIP一件代发', + destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码', source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统', source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键', @@ -141,6 +142,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_order ( source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID', source_order_no VARCHAR(128) NULL COMMENT '来源订单号', root_order_id VARCHAR(128) NULL COMMENT '根订单 ID', inbound_code VARCHAR(128) NULL COMMENT '入仓码', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', + destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', product_quantity INT NULL COMMENT '数量', pallet_count INT NULL COMMENT '托数', gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '装柜计划 ID', @@ -171,6 +173,10 @@ ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等 ALTER TABLE ll_order COMMENT='尾程订单主表'; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); PREPARE lastleg_order_container_booking_stmt FROM @ddl; EXECUTE lastleg_order_container_booking_stmt; DEALLOCATE PREPARE lastleg_order_container_booking_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type')); +PREPARE lastleg_order_destination_type_stmt FROM @ddl; EXECUTE lastleg_order_destination_type_stmt; DEALLOCATE PREPARE lastleg_order_destination_type_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name')); +PREPARE lastleg_inbound_destination_type_stmt FROM @ddl; EXECUTE lastleg_inbound_destination_type_stmt; DEALLOCATE PREPARE lastleg_inbound_destination_type_stmt; ALTER TABLE ll_order_detail COMMENT='尾程订单实装商品冻结明细'; ALTER TABLE ll_loading_order_status_history COMMENT='尾程订单状态变更历史'; ALTER TABLE ll_event_conflict COMMENT='事件冲突记录'; @@ -360,9 +366,11 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan ( CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', + customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', plan_id BIGINT NOT NULL COMMENT '入库计划ID', order_id BIGINT NOT NULL COMMENT '尾程订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '头程入仓码快照', + destination_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', @@ -381,7 +389,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton ( destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID', line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID', warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID', - status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、BULK_RECEIVED、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark), @@ -623,9 +631,38 @@ CREATE TABLE IF NOT EXISTS ll_inbound_pallet_merge ( SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL AFTER inbound_code')); PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户账号标识''', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE receiving_container_line_stmt FROM @ddl; EXECUTE receiving_container_line_stmt; DEALLOCATE PREPARE receiving_container_line_stmt; +UPDATE ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id SET l.customer_id=p.customer_id WHERE l.customer_id=''; +ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='destination_type'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER inbound_code')); +PREPARE receiving_container_line_stmt FROM @ddl; EXECUTE receiving_container_line_stmt; DEALLOCATE PREPARE receiving_container_line_stmt; UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id -SET l.fba_warehouse_code=o.fba_warehouse_code -WHERE l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL; +SET l.fba_warehouse_code=o.fba_warehouse_code, + l.destination_type=COALESCE(NULLIF(o.destination_type,''),CASE WHEN NULLIF(o.fba_warehouse_code,'') IS NULL THEN 'OVERSEAS' ELSE 'FBA' END) +WHERE (l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL) + OR l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS'; + +CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( + id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', + operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话账号唯一锁键', + active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', + photo_file_ids JSON NULL COMMENT '签收卸柜照片文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', + signed_at DATETIME(3) NULL COMMENT '签收时间', receiving_at DATETIME(3) 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 NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + PRIMARY KEY(id), UNIQUE KEY uk_ll_receiving_active_operator(active_operator_key), UNIQUE KEY uk_ll_receiving_active_container(active_container_key), + KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at) +) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话'; + +CREATE TABLE IF NOT EXISTS ll_receiving_container_operation ( + id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_operation(customer_id,operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录'; CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( id BIGINT NOT NULL COMMENT '主键ID', diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index eae0eca93..856aff935 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -23,7 +23,7 @@ paths: - { name: detained, in: query, schema: { type: boolean } } - { name: createdFrom, in: query, schema: { type: string, format: date-time } } - { name: createdTo, in: query, schema: { type: string, format: date-time } } - responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页 } } + responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页;列表行 destinationType 为 FBA 或 OVERSEAS } } /api/platform/v1/last-leg/dispatches: get: summary: 分页查询排车单 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 34dae31ba..d3ff15dbb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -4,18 +4,18 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com class LoadingCompletedConsumerTest { @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; @Mock InventoryCommandService inventory; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(inboundPlans.convert(anyString(),anyLong())).thenReturn(new InboundPlanResponse(1L,"LIP-1","100",1L,"MSCU1","PENDING_ASSIGNMENT",1,1,BigDecimal.ONE,List.of(),List.of(),List.of())); } - @Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),any(InventoryCommandRequests.InTransit.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } + @Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),any(InventoryCommandRequests.InTransit.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } @Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } - @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet)); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } + @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } @Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){ var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2")); var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of( new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","产品A",2,20,List.of("BOX-A1","BOX-A2")), new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-B","产品B",3,15,List.of("BOX-B1","BOX-B2","BOX-B3")))); - var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",5,35,1,BigDecimal.valueOf(2),BigDecimal.valueOf(2),BigDecimal.valueOf(2),List.of(itemA,itemB),"100","ONE_CLICK_DROPSHIP",List.of(pallet)); + var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",5,35,1,BigDecimal.valueOf(2),BigDecimal.valueOf(2),BigDecimal.valueOf(2),List.of(itemA,itemB),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); EventEnvelope event=eventForPayload(new FirstLegLoadingPlanCompletedV1("plan-multi","LP-MULTI","book-multi","CONT-MULTI","CAB-MULTI","92","Warehouse",List.of(order)),2); assertEquals(1,service.consume(event)); @@ -31,6 +31,8 @@ class LoadingCompletedConsumerTest { @Test void consumesV2LoadingEventAndKeepsDropshipSnapshot(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); + ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","OVERSEAS"); + ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","OVERSEAS"); ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0)).put("customerRemark","fragile"); ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0)) .set("cartonMarks", json.valueToTree(List.of("BOX-1","BOX-2","BOX-3","BOX-4","BOX-5"))); @@ -51,6 +53,8 @@ class LoadingCompletedConsumerTest { } @Test void rejectsV2EventWithoutSkuCartonMapping(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); + ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","OVERSEAS"); + ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","OVERSEAS"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), @@ -59,6 +63,46 @@ class LoadingCompletedConsumerTest { assertTrue(error.getMessage().contains("箱唛数量必须等于实装箱数")); verify(orders,never()).insert(any(LastlegOrder.class)); } + @Test void legacyV2FbaWithCompleteFrozenCartonsPassesTheMappingGate(){ + EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); + ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0)) + .set("cartonMarks",json.valueToTree(List.of("ABE3-BOX-1","ABE3-BOX-2","ABE3-BOX-3","ABE3-BOX-4","ABE3-BOX-5"))); + ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1).path("items").get(0)) + .set("cartonMarks",json.valueToTree(List.of("LAX9-BOX-1","LAX9-BOX-2","LAX9-BOX-3"))); + String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); + EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), + source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), + 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); + assertEquals(2,service.consume(v2)); + verify(cartons,never()).insert(any()); + } + @Test void acceptsV2FbaByPackageCountWithoutCartonFacts(){ + EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); + ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","FBA"); + ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","FBA"); + String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); + EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), + source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), + 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); + assertEquals(2,service.consume(v2)); + verify(cartons,never()).insert(any()); + } + @Test void safelyInfersLegacyOverseasDestinationFromReceivingWarehouseIdentity(){ + EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); + for(JsonNode order:node.path("orders")){ + ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("destinationWarehouseId","ow-1"); + JsonNode item=order.path("items").get(0); + ((com.fasterxml.jackson.databind.node.ObjectNode)item).putNull("fbaTrackingNo").putNull("shipmentNo").put("destinationWarehouseName","洛杉矶海外仓"); + } + ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0).path("items").get(0)).set("cartonMarks",json.valueToTree(List.of("BOX-1","BOX-2","BOX-3","BOX-4","BOX-5"))); + ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(1).path("items").get(0)).set("cartonMarks",json.valueToTree(List.of("BOX-6","BOX-7","BOX-8"))); + String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); + EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); + assertEquals(2,service.consume(v2)); + ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captured.capture()); + assertTrue(captured.getAllValues().stream().allMatch(order->"OVERSEAS".equals(order.getDestinationType()))); + verify(cartons,times(8)).insert(any()); + } private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1)),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2)))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); } private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); } } diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index 575df9e41..5d636bf86 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -20,6 +20,9 @@ test('Jenkins pipelines recognize all overseas deployable modules', async () => assert.match(service, /warehouse-overseas-service\/target\/warehouse-overseas-service-1\.0\.0-SNAPSHOT\.jar/) assert.match(controller, /EVENT_BRIDGE_REGION/) assert.match(service, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{params\.EVENT_BRIDGE_REGION\}/) + assert.match(controller, /choices: \['SELECT', 'CN', 'OVERSEAS'\]/) + assert.match(service, /choices: \['SELECT', 'CN', 'OVERSEAS'\]/) + assert.match(controller, /部署 event-bridge-service 必须明确选择 EVENT_BRIDGE_REGION=CN 或 OVERSEAS/) }) test('service map freezes unique internal ports and moves event bridge away from 9300', async () => { @@ -72,6 +75,7 @@ test('CN and overseas event bridges have explicit opposite-region consumers', as assert.match(scripts, /FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL/) assert.match(scripts, /COMPOSE_FILE="deploy\/docker-compose\.overseas\.yml"/) assert.match(scripts, /PROJECT_NAME="firstleg-\$DEPLOY_ENV-overseas"/) + assert.match(scripts, /运行容器镜像与请求镜像不一致/) assert.match(scripts, /REMOTE_ENV_NAME="\.env\.\$PROJECT_NAME"/) assert.match(scripts, /REMOTE_COMPOSE_NAME="docker-compose\.\$PROJECT_NAME\.yml"/) for (const key of [ diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java index 5cceb91f5..a5e66727a 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java @@ -3,6 +3,7 @@ package com.baiyue.warehouse.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.warehouse.dto.DropshipPalletRequests; import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.dto.WarehousePalletResolveResponse; import com.baiyue.warehouse.security.InternalServiceTokenGuard; import com.baiyue.warehouse.service.WarehouseResourceService; import jakarta.annotation.Resource; @@ -22,4 +23,12 @@ public class InternalWarehousePalletController { tokenGuard.verify(token); return ApiResponse.success(service.ensureDropshipPallet(request.customerId(), request.warehouseId(), request.businessId())); } + + @GetMapping("/resolve/{palletCode}") + public ApiResponse resolve( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable String palletCode) { + tokenGuard.verify(token); + return ApiResponse.success(service.resolvePublicPallet(palletCode)); + } } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java new file mode 100644 index 000000000..be3e2610c --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java @@ -0,0 +1,12 @@ +package com.baiyue.warehouse.dto; + +public record WarehousePalletResolveResponse( + long palletId, + String palletCode, + long warehouseId, + long locationId, + String status, + String fbaWarehouseCode, + int version +) { +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java index bfe2400f8..7603cd9e6 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java @@ -17,6 +17,8 @@ import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; import jakarta.annotation.Resource; +import java.util.List; + @Repository public class WarehouseResourceRepository { @Resource private WarehouseMapper warehouses; @@ -135,6 +137,13 @@ public class WarehouseResourceRepository { .eq(WarehousePalletEntity::getId, id)); } + public List findPublicPalletsByCode(String palletCode) { + return pallets.selectList(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, 0L) + .eq(WarehousePalletEntity::getPalletCode, palletCode) + .orderByAsc(WarehousePalletEntity::getId)); + } + public boolean palletCodeExists(long customerId, long warehouseId, String code, Long excludedId) { return pallets.selectCount(new LambdaQueryWrapper() .eq(WarehousePalletEntity::getCustomerId, customerId) diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java index a4355d2d2..ec8fef579 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java @@ -5,6 +5,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.LocationRequests; import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.dto.WarehousePalletResolveResponse; import com.baiyue.warehouse.entity.WarehouseLocationEntity; import com.baiyue.warehouse.entity.WarehousePalletEntity; import com.baiyue.warehouse.entity.WarehouseEntity; @@ -175,6 +176,21 @@ public class WarehouseResourceService { return requirePallet(customerId, warehouseId, id); } + public WarehousePalletResolveResponse resolvePublicPallet(String palletCode) { + if (!StringUtils.hasText(palletCode)) { + throw new BizException(ResultCode.BAD_REQUEST, "托盘码不能为空"); + } + var matches = repository.findPublicPalletsByCode(palletCode.trim()); + if (matches.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "托盘码不存在"); + if (matches.size() != 1) throw new BizException(ResultCode.CONFLICT, "托盘码在多个仓库重复,无法解析"); + WarehousePalletEntity pallet = matches.get(0); + if (pallet.getLocationId() == null) throw new BizException(ResultCode.CONFLICT, "托盘未绑定库位"); + if ("SCRAPPED".equals(pallet.getStatus())) throw new BizException(ResultCode.CONFLICT, "托盘已报废"); + return new WarehousePalletResolveResponse(pallet.getId(), pallet.getPalletCode(), pallet.getWarehouseId(), + pallet.getLocationId(), pallet.getStatus(), pallet.getFbaWarehouseCode(), + pallet.getVersion() == null ? 0 : pallet.getVersion()); + } + public PageResult pallets(long customerId, long warehouseId, PalletRequests.QueryRequest query) { requireWarehouse(customerId, warehouseId); From 5a4bcf130a9ce362824448c67f915ec45bd5a4ff Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 17:19:36 +0800 Subject: [PATCH 032/115] feat(ci): deploy both event bridge regions by default --- Jenkinsfile | 19 ++++++--- Jenkinsfile.service | 40 ++++++++++++++----- scripts/controller-pipeline-contract.test.mjs | 7 ++++ scripts/overseas-deployment-contract.test.mjs | 13 ++++-- ...est-environment-pipeline-contract.test.mjs | 9 +++++ 5 files changed, 69 insertions(+), 19 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index af1e3f804..876d3edd3 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -68,8 +68,8 @@ pipeline { booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)') booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)') booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)') - booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;由 EVENT_BRIDGE_REGION 选择实例)') - choice(name: 'EVENT_BRIDGE_REGION', choices: ['SELECT', 'CN', 'OVERSEAS'], description: '部署 event-bridge-service 时必须明确选择实例;SELECT 会阻止部署') + booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;默认同时部署国内和海外实例)') + choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS'], description: 'BOTH 同时部署国内和海外实例;CN/OVERSEAS 用于单区故障重跑') booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)') choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境') booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署') @@ -149,9 +149,18 @@ pipeline { } selected = selected.unique() - if (params.DEPLOY && selected.contains('event-bridge-service') - && !['CN', 'OVERSEAS'].contains(params.EVENT_BRIDGE_REGION)) { - error('部署 event-bridge-service 必须明确选择 EVENT_BRIDGE_REGION=CN 或 OVERSEAS') + if (params.DEPLOY && selected.contains('event-bridge-service')) { + String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim() + if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) { + error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS') + } + if (['BOTH', 'SELECT'].contains(bridgeRegion) + && !['development', 'test'].contains(env.RESOLVED_ENVIRONMENT)) { + error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS') + } + if (bridgeRegion == 'SELECT') { + echo '兼容旧 Jenkins 参数 SELECT:本次按 BOTH 同时部署国内和海外事件桥' + } } env.SELECTED_SERVICES = selected.join(',') env.NO_SERVICE_CHANGE = selected ? 'false' : 'true' diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 3b43dae52..1754277a4 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -38,7 +38,7 @@ pipeline { choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境') booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试') - choice(name: 'EVENT_BRIDGE_REGION', choices: ['SELECT', 'CN', 'OVERSEAS'], description: '仅 event-bridge-service 部署使用;必须由总控明确传入 CN 或 OVERSEAS') + choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS'], description: '仅 event-bridge-service 使用;BOTH 在开发/测试环境同时部署国内和海外实例') string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库基础路径,不包含服务名和标签') string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像') } @@ -93,6 +93,17 @@ pipeline { env.RESOLVED_SOURCE_COMMIT = sourceCommit env.RESOLVED_IMAGE_TAG = imageTag env.RESOLVED_IMAGE_REPOSITORY = imageRepository + + if (service == 'event-bridge-service' && params.DEPLOY) { + String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim() + if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) { + error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS') + } + if (['BOTH', 'SELECT'].contains(bridgeRegion) + && !['development', 'test'].contains(params.ENVIRONMENT)) { + error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS') + } + } } sh ''' @@ -185,16 +196,25 @@ pipeline { try { unstash 'deployment-files' echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.ENVIRONMENT}" - withEnv([ - 'TARGET_IS_LOCAL=true', - "EVENT_BRIDGE_REGION_OVERRIDE=${params.EVENT_BRIDGE_REGION}" + List bridgeRegions = env.RESOLVED_SERVICE == 'event-bridge-service' + && ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION) + ? ['CN', 'OVERSEAS'] + : [params.EVENT_BRIDGE_REGION] + withCredentials([ + file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'), + usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, + usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD') ]) { - withCredentials([ - file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'), - usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, - usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD') - ]) { - sh "sh deploy/scripts/deploy-built-service.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" + bridgeRegions.each { bridgeRegion -> + if (env.RESOLVED_SERVICE == 'event-bridge-service') { + echo "部署事件桥实例: ${bridgeRegion}" + } + withEnv([ + 'TARGET_IS_LOCAL=true', + "EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}" + ]) { + sh "sh deploy/scripts/deploy-built-service.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" + } } } } finally { diff --git a/scripts/controller-pipeline-contract.test.mjs b/scripts/controller-pipeline-contract.test.mjs index 802e7e569..221bade27 100644 --- a/scripts/controller-pipeline-contract.test.mjs +++ b/scripts/controller-pipeline-contract.test.mjs @@ -40,3 +40,10 @@ test('coordinator cannot initialize test databases', () => { assert.doesNotMatch(pipeline, /initialize-test-databases\.sh/) assert.doesNotMatch(pipeline, /测试数据库初始化/) }) + +test('coordinator defaults event bridge deployments to both non-production regions', () => { + assert.match(pipeline, /choice\(name: 'EVENT_BRIDGE_REGION', choices: \['BOTH', 'CN', 'OVERSEAS'\]/) + assert.match(pipeline, /\['BOTH', 'SELECT', 'CN', 'OVERSEAS'\]\.contains\(bridgeRegion\)/) + assert.match(pipeline, /兼容旧 Jenkins 参数 SELECT:本次按 BOTH/) + assert.match(pipeline, /事件桥 BOTH 仅支持开发或测试环境/) +}) diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index 5d636bf86..3e1b56683 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -19,10 +19,10 @@ test('Jenkins pipelines recognize all overseas deployable modules', async () => assert.match(service, /lastleg-service\/target\/lastleg-service-1\.0\.0-SNAPSHOT\.jar/) assert.match(service, /warehouse-overseas-service\/target\/warehouse-overseas-service-1\.0\.0-SNAPSHOT\.jar/) assert.match(controller, /EVENT_BRIDGE_REGION/) - assert.match(service, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{params\.EVENT_BRIDGE_REGION\}/) - assert.match(controller, /choices: \['SELECT', 'CN', 'OVERSEAS'\]/) - assert.match(service, /choices: \['SELECT', 'CN', 'OVERSEAS'\]/) - assert.match(controller, /部署 event-bridge-service 必须明确选择 EVENT_BRIDGE_REGION=CN 或 OVERSEAS/) + assert.match(service, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{bridgeRegion\}/) + assert.match(controller, /choices: \['BOTH', 'CN', 'OVERSEAS'\]/) + assert.match(service, /choices: \['BOTH', 'CN', 'OVERSEAS'\]/) + assert.match(controller, /事件桥 BOTH 仅支持开发或测试环境/) }) test('service map freezes unique internal ports and moves event bridge away from 9300', async () => { @@ -48,6 +48,11 @@ test('overseas compose is isolated from CN defaults and disables implicit schema assert.match(compose, /^ warehouse-overseas-service:\s*$/m) assert.match(compose, /^ event-bridge-service:\s*$/m) assert.match(compose, /EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312/) + assert.match(compose, /INTERNAL_SERVICE_TOKEN: \$\{INTERNAL_SERVICE_TOKEN\}/) + assert.match(compose, /LASTLEG_DB_URL: \$\{LASTLEG_DB_URL\}/) + assert.match(compose, /EVENT_BRIDGE_DB_URL: \$\{EVENT_BRIDGE_OVERSEAS_DB_URL\}/) + assert.match(compose, /EVENT_BRIDGE_DB_USERNAME: \$\{EVENT_BRIDGE_OVERSEAS_DB_USERNAME\}/) + assert.match(compose, /EVENT_BRIDGE_DB_PASSWORD: \$\{EVENT_BRIDGE_OVERSEAS_DB_PASSWORD\}/) }) test('CN and overseas event bridges have explicit opposite-region consumers', async () => { diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index c6f238ce2..7253c8914 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -155,3 +155,12 @@ test('direct deployments wait for health and automatically restore the previous assert.match(directDeployScript, /自动回滚失败/) assert.match(directDeployScript, /return 1/) }) + +test('one event bridge build deploys both regional instances in development and test', () => { + assert.match(pipeline, /choice\(name: 'EVENT_BRIDGE_REGION', choices: \['BOTH', 'CN', 'OVERSEAS'\]/) + assert.match(pipeline, /\['BOTH', 'SELECT'\]\.contains\(params\.EVENT_BRIDGE_REGION\)/) + assert.match(pipeline, /\? \['CN', 'OVERSEAS'\]/) + assert.match(pipeline, /bridgeRegions\.each \{ bridgeRegion ->/) + assert.match(pipeline, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{bridgeRegion\}/) + assert.match(pipeline, /部署事件桥实例:/) +}) From ce82916036bff7f93f1f8b11c2e0da8cbf2ce650 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 17:22:18 +0800 Subject: [PATCH 033/115] fix(lastleg): align inbound snapshot with schema --- .../service/LoadingCompletedOutboxServiceTest.java | 3 ++- .../com/baiyue/lastleg/entity/LastlegInboundOrder.java | 1 - .../baiyue/lastleg/service/LoadingCompletedConsumer.java | 2 +- .../lastleg/service/LoadingCompletedConsumerTest.java | 8 +++++++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index acc2a91ef..90ea941cb 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -94,7 +94,8 @@ class LoadingCompletedOutboxServiceTest { assertThatThrownBy(() -> service.publish(90L)) .hasMessageContaining("目的类型必须唯一且为 FBA 或 OVERSEAS"); - verify(outbox, org.mockito.Mockito.never()).insert(org.mockito.ArgumentMatchers.any()); + verify(outbox, org.mockito.Mockito.never()).insert( + org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class)); } @Test void freezesExactlyLoadedPackagesFromLabelPoolDeterministically() throws Exception { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java index 3bacefe47..7c7ce0bd6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java @@ -13,7 +13,6 @@ import java.time.LocalDateTime; @TableName("ll_inbound_order") public class LastlegInboundOrder { @TableId private Long id; - private String tenantId; private String customerId; private String sourceRegion; private String sourceInboundId; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index e48da2873..c55fcfc19 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -225,7 +225,7 @@ public class LoadingCompletedConsumer { FirstLegLoadingPlanCompletedV1.Order source, LastlegOrder order, String orderHash) { LastlegInboundOrder snapshot = new LastlegInboundOrder(); - snapshot.setId(order.getId()); snapshot.setTenantId("BAIYUE"); + snapshot.setId(order.getId()); snapshot.setCustomerId(order.getCustomerId()); snapshot.setSourceRegion(event.sourceRegion().name()); snapshot.setSourceInboundId(source.sourceInboundId()); snapshot.setRootInboundId(source.rootInboundId()); snapshot.setSourceOrderId(source.sourceOrderId()); snapshot.setSourceOrderNo(source.sourceOrderNo()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index d3ff15dbb..a8e431c6d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -4,9 +4,15 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com class LoadingCompletedConsumerTest { @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; @Mock InventoryCommandService inventory; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(inboundPlans.convert(anyString(),anyLong())).thenReturn(new InboundPlanResponse(1L,"LIP-1","100",1L,"MSCU1","PENDING_ASSIGNMENT",1,1,BigDecimal.ONE,List.of(),List.of(),List.of())); } - @Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),any(InventoryCommandRequests.InTransit.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } + @Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),any(InventoryCommandRequests.InTransit.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } @Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } + @Test void inboundSnapshotUsesCustomerIdAndHasNoPhantomTenantColumn() throws Exception { + assertThrows(NoSuchFieldException.class,()->LastlegInboundOrder.class.getDeclaredField("tenantId")); + String schema=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/db/lastleg.sql")); + String table=schema.substring(schema.indexOf("CREATE TABLE IF NOT EXISTS ll_inbound_order ("),schema.indexOf("CREATE TABLE IF NOT EXISTS ll_inbound_order_item (")); + assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id")); + } @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } @Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){ From 9d09b77442713cfcdc100c48374f2068c6cd4b37 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 17:23:05 +0800 Subject: [PATCH 034/115] fix(ci): bridge BOTH through legacy downstream choices --- Jenkinsfile | 5 ++++- Jenkinsfile.service | 2 +- scripts/controller-pipeline-contract.test.mjs | 1 + scripts/overseas-deployment-contract.test.mjs | 2 +- scripts/test-environment-pipeline-contract.test.mjs | 2 +- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 876d3edd3..e1c9c2258 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -243,7 +243,10 @@ pipeline { booleanParam(name: 'SKIP_TESTS', value: true), string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY), string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE), - string(name: 'EVENT_BRIDGE_REGION', value: params.EVENT_BRIDGE_REGION) + // 旧下游 Job 的 ChoiceParameterDefinition 尚不接受 BOTH; + // 使用兼容值 SELECT 传递,下游新流水线会将其解释为双区域部署。 + string(name: 'EVENT_BRIDGE_REGION', value: + params.EVENT_BRIDGE_REGION == 'BOTH' ? 'SELECT' : params.EVENT_BRIDGE_REGION) ] ) downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN' diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 1754277a4..fcdf1cbff 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -38,7 +38,7 @@ pipeline { choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境') booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试') - choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS'], description: '仅 event-bridge-service 使用;BOTH 在开发/测试环境同时部署国内和海外实例') + choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS', 'SELECT'], description: '仅 event-bridge-service 使用;BOTH 在开发/测试环境同时部署国内和海外实例;SELECT 为总控兼容值') string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库基础路径,不包含服务名和标签') string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像') } diff --git a/scripts/controller-pipeline-contract.test.mjs b/scripts/controller-pipeline-contract.test.mjs index 221bade27..04d4a4303 100644 --- a/scripts/controller-pipeline-contract.test.mjs +++ b/scripts/controller-pipeline-contract.test.mjs @@ -46,4 +46,5 @@ test('coordinator defaults event bridge deployments to both non-production regio assert.match(pipeline, /\['BOTH', 'SELECT', 'CN', 'OVERSEAS'\]\.contains\(bridgeRegion\)/) assert.match(pipeline, /兼容旧 Jenkins 参数 SELECT:本次按 BOTH/) assert.match(pipeline, /事件桥 BOTH 仅支持开发或测试环境/) + assert.match(pipeline, /params\.EVENT_BRIDGE_REGION == 'BOTH' \? 'SELECT' : params\.EVENT_BRIDGE_REGION/) }) diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index 3e1b56683..3577c68a7 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -21,7 +21,7 @@ test('Jenkins pipelines recognize all overseas deployable modules', async () => assert.match(controller, /EVENT_BRIDGE_REGION/) assert.match(service, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{bridgeRegion\}/) assert.match(controller, /choices: \['BOTH', 'CN', 'OVERSEAS'\]/) - assert.match(service, /choices: \['BOTH', 'CN', 'OVERSEAS'\]/) + assert.match(service, /choices: \['BOTH', 'CN', 'OVERSEAS', 'SELECT'\]/) assert.match(controller, /事件桥 BOTH 仅支持开发或测试环境/) }) diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index 7253c8914..d7897f09b 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -157,7 +157,7 @@ test('direct deployments wait for health and automatically restore the previous }) test('one event bridge build deploys both regional instances in development and test', () => { - assert.match(pipeline, /choice\(name: 'EVENT_BRIDGE_REGION', choices: \['BOTH', 'CN', 'OVERSEAS'\]/) + assert.match(pipeline, /choice\(name: 'EVENT_BRIDGE_REGION', choices: \['BOTH', 'CN', 'OVERSEAS', 'SELECT'\]/) assert.match(pipeline, /\['BOTH', 'SELECT'\]\.contains\(params\.EVENT_BRIDGE_REGION\)/) assert.match(pipeline, /\? \['CN', 'OVERSEAS'\]/) assert.match(pipeline, /bridgeRegions\.each \{ bridgeRegion ->/) From be1eb4f45ca50ab652a78fe9560f686d02818485 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 17:34:11 +0800 Subject: [PATCH 035/115] fix(ci): carry dual bridge intent through stable parameters --- Jenkinsfile | 17 ++++++++++++----- Jenkinsfile.service | 11 +++++++++-- scripts/controller-pipeline-contract.test.mjs | 4 +++- .../test-environment-pipeline-contract.test.mjs | 3 +++ 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index e1c9c2258..0660d219a 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -229,6 +229,14 @@ pipeline { String service = selectedService branches[service] = { String jobName = "${env.RESOLVED_JOB_PREFIX}${service}" + boolean deployBothBridges = service == 'event-bridge-service' + && ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION) + String downstreamImageTag = deployBothBridges + ? "${env.IMAGE_TAG}-both" + : env.IMAGE_TAG + String downstreamBridgeRegion = deployBothBridges + ? 'CN' + : params.EVENT_BRIDGE_REGION echo "触发单服务 Job: ${jobName}" def downstreamBuild = build( job: jobName, @@ -236,17 +244,16 @@ pipeline { propagate: false, parameters: [ string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT), - string(name: 'IMAGE_TAG', value: env.IMAGE_TAG), + string(name: 'IMAGE_TAG', value: downstreamImageTag), string(name: 'ENVIRONMENT', value: env.RESOLVED_ENVIRONMENT), booleanParam(name: 'DEPLOY', value: params.DEPLOY), // 总控已完成统一测试,或用户已明确选择跳过;下游仅负责编译、打包和部署。 booleanParam(name: 'SKIP_TESTS', value: true), string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY), string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE), - // 旧下游 Job 的 ChoiceParameterDefinition 尚不接受 BOTH; - // 使用兼容值 SELECT 传递,下游新流水线会将其解释为双区域部署。 - string(name: 'EVENT_BRIDGE_REGION', value: - params.EVENT_BRIDGE_REGION == 'BOTH' ? 'SELECT' : params.EVENT_BRIDGE_REGION) + // Choice 参数始终传递所有新旧 Job 都接受的单区值; + // 双区意图通过现有 IMAGE_TAG 参数携带,由下游还原真实镜像标签。 + string(name: 'EVENT_BRIDGE_REGION', value: downstreamBridgeRegion) ] ) downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN' diff --git a/Jenkinsfile.service b/Jenkinsfile.service index fcdf1cbff..a1aed8754 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -76,6 +76,11 @@ pipeline { if (!serviceModules.contains(service)) { error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}") } + boolean deployBothBridges = service == 'event-bridge-service' + && imageTag?.endsWith('-both') + if (deployBothBridges) { + imageTag = imageTag.substring(0, imageTag.length() - '-both'.length()) + } if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) { error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit') } @@ -93,13 +98,14 @@ pipeline { env.RESOLVED_SOURCE_COMMIT = sourceCommit env.RESOLVED_IMAGE_TAG = imageTag env.RESOLVED_IMAGE_REPOSITORY = imageRepository + env.DEPLOY_BOTH_EVENT_BRIDGES = deployBothBridges ? 'true' : 'false' if (service == 'event-bridge-service' && params.DEPLOY) { String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim() if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) { error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS') } - if (['BOTH', 'SELECT'].contains(bridgeRegion) + if ((deployBothBridges || ['BOTH', 'SELECT'].contains(bridgeRegion)) && !['development', 'test'].contains(params.ENVIRONMENT)) { error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS') } @@ -197,7 +203,8 @@ pipeline { unstash 'deployment-files' echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.ENVIRONMENT}" List bridgeRegions = env.RESOLVED_SERVICE == 'event-bridge-service' - && ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION) + && (env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' + || ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION)) ? ['CN', 'OVERSEAS'] : [params.EVENT_BRIDGE_REGION] withCredentials([ diff --git a/scripts/controller-pipeline-contract.test.mjs b/scripts/controller-pipeline-contract.test.mjs index 04d4a4303..65be87496 100644 --- a/scripts/controller-pipeline-contract.test.mjs +++ b/scripts/controller-pipeline-contract.test.mjs @@ -46,5 +46,7 @@ test('coordinator defaults event bridge deployments to both non-production regio assert.match(pipeline, /\['BOTH', 'SELECT', 'CN', 'OVERSEAS'\]\.contains\(bridgeRegion\)/) assert.match(pipeline, /兼容旧 Jenkins 参数 SELECT:本次按 BOTH/) assert.match(pipeline, /事件桥 BOTH 仅支持开发或测试环境/) - assert.match(pipeline, /params\.EVENT_BRIDGE_REGION == 'BOTH' \? 'SELECT' : params\.EVENT_BRIDGE_REGION/) + assert.match(pipeline, /String downstreamImageTag = deployBothBridges[\s\S]*?\? "\$\{env\.IMAGE_TAG\}-both"/) + assert.match(pipeline, /String downstreamBridgeRegion = deployBothBridges[\s\S]*?\? 'CN'/) + assert.match(pipeline, /string\(name: 'EVENT_BRIDGE_REGION', value: downstreamBridgeRegion\)/) }) diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index d7897f09b..9858d6992 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -163,4 +163,7 @@ test('one event bridge build deploys both regional instances in development and assert.match(pipeline, /bridgeRegions\.each \{ bridgeRegion ->/) assert.match(pipeline, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{bridgeRegion\}/) assert.match(pipeline, /部署事件桥实例:/) + assert.match(pipeline, /imageTag\?\.endsWith\('-both'\)/) + assert.match(pipeline, /imageTag\.substring\(0, imageTag\.length\(\) - '-both'\.length\(\)\)/) + assert.match(pipeline, /env\.DEPLOY_BOTH_EVENT_BRIDGES = deployBothBridges \? 'true' : 'false'/) }) From bb74a055625584c40b10001cacc2a6811e0856b9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 17:37:15 +0800 Subject: [PATCH 036/115] feat(lastleg): harden container receiving contract --- deploy/sql/海外区域统一增量-MySQL8.sql | 20 +++- .../ReceivingContainerController.java | 2 + .../service/InboundPdaWorkflowService.java | 4 +- .../service/InboundPlanService.java | 3 +- .../src/main/resources/db/lastleg.sql | 29 ++++- .../openapi/overseas-business-v1.yaml | 101 ++++++++++++++++++ .../pda/ReceivingContainerContractTest.java | 82 ++++++++++++++ .../ReceivingContainerControllerTest.java | 54 ++++++++++ 8 files changed, 288 insertions(+), 7 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 7adb6153f..bbce2f48d 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -338,11 +338,29 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'), - 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户账号标识''', + 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt; UPDATE ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id SET l.customer_id=p.customer_id WHERE l.customer_id=''; ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND index_name='idx_ll_inbound_line_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD KEY idx_ll_inbound_line_customer_plan(customer_id,plan_id)')); +PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='customer_id'), + 'SELECT 1', + 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; +UPDATE ll_inbound_plan_pallet v JOIN ll_inbound_plan p ON p.id=v.plan_id SET v.customer_id=p.customer_id WHERE v.customer_id=''; +ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND index_name='idx_ll_inbound_pallet_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD KEY idx_ll_inbound_pallet_customer_plan(customer_id,plan_id)')); +PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='customer_id'), + 'SELECT 1', + 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; +UPDATE ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan p ON p.id=a.plan_id SET a.customer_id=p.customer_id WHERE a.customer_id=''; +ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND index_name='idx_ll_inbound_allocation_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD KEY idx_ll_inbound_allocation_customer_plan(customer_id,plan_id)')); +PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码''', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码'' AFTER inbound_code')); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java index cfa3b3f66..47ba2aae9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -12,6 +12,7 @@ import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -27,6 +28,7 @@ import java.util.List; @RestController @RequestMapping("/api/pda/v1/last-leg/receiving-containers") @Tag(name = "Overseas PDA container receiving") +@PreAuthorize("hasAuthority('lastleg:pda:operate')") public class ReceivingContainerController { public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; @Resource private ReceivingContainerService service; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index fde44b04a..85427550c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -300,7 +300,8 @@ public class InboundPdaWorkflowService { } InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId()); if (allocation == null) { - allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); allocation.setPlanId(plan.getId()); + allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); + allocation.setCustomerId(customerId); allocation.setPlanId(plan.getId()); allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME); allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0); allocation.setActualVolumeCbm(ZERO_VOLUME); allocation.setStatus("PICKING"); allocation.setVersion(0); repository.insert(allocation); @@ -542,6 +543,7 @@ public class InboundPdaWorkflowService { if (moved == null) { moved = new InboundPlanPalletAllocation(); moved.setId(IdWorker.getId()); + moved.setCustomerId(source.getCustomerId()); moved.setPlanId(planId); moved.setLineId(source.getLineId()); moved.setPalletId(targetPalletId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 6ae72a34e..5dd754553 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -141,7 +141,8 @@ public class InboundPlanService { .eq(InboundPlanPallet::getPlanId, plan.getId())).stream().map(InboundPlanPallet::getSequenceNo) .filter(Objects::nonNull).max(Integer::compareTo).orElse(0) + 1; InboundPlanPallet pallet = new InboundPlanPallet(); - pallet.setId(IdWorker.getId()); pallet.setPlanId(plan.getId()); pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", sequence)); + pallet.setId(IdWorker.getId()); pallet.setCustomerId(plan.getCustomerId()); pallet.setPlanId(plan.getId()); + pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", sequence)); pallet.setDestinationWarehouseId(request.destinationWarehouseId()); pallet.setDestinationWarehouseCode(request.destinationWarehouseCode()); pallet.setDestinationWarehouseName(request.destinationWarehouseName()); pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM); pallet.setAllocatedVolumeCbm(BigDecimal.ZERO); pallet.setSequenceNo(sequence); pallet.setAssignmentStatus("UNASSIGNED"); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 3b01747ad..0f3c1503e 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -137,7 +137,7 @@ CREATE TABLE IF NOT EXISTS ll_event_inbox ( ) ENGINE=InnoDB COMMENT='通用事件接收幂等箱'; CREATE TABLE IF NOT EXISTS ll_inbound_order ( - id BIGINT NOT NULL COMMENT '入仓单主键', customer_id BIGINT NOT NULL COMMENT '租户 ID', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + id BIGINT NOT NULL COMMENT '入仓单主键', customer_id BIGINT NOT NULL COMMENT '客户账号标识', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', source_inbound_id VARCHAR(128) NOT NULL COMMENT '来源入仓分片 ID', root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID', source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID', source_order_no VARCHAR(128) NULL COMMENT '来源订单号', root_order_id VARCHAR(128) NULL COMMENT '根订单 ID', inbound_code VARCHAR(128) NULL COMMENT '入仓码', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', @@ -378,7 +378,8 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT '该明细分配的托盘数量', PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_line_order (plan_id, order_id), - KEY idx_ll_inbound_line_plan (plan_id) + KEY idx_ll_inbound_line_plan (plan_id), + KEY idx_ll_inbound_line_customer_plan (customer_id, plan_id) ) ENGINE=InnoDB COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton ( @@ -399,6 +400,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton ( CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet ( id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)', + customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', plan_id BIGINT NOT NULL COMMENT '入库计划ID', pallet_no VARCHAR(64) NOT NULL COMMENT '计划托盘编号', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', @@ -410,11 +412,13 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet ( PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_pallet_no (pallet_no), UNIQUE KEY uk_ll_inbound_pallet_sequence (plan_id, sequence_no), - KEY idx_ll_inbound_pallet_plan (plan_id) + KEY idx_ll_inbound_pallet_plan (plan_id), + KEY idx_ll_inbound_pallet_customer_plan (customer_id, plan_id) ) ENGINE=InnoDB COMMENT='尾程入库计划托盘表:按目的仓隔离合托'; CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation ( id BIGINT NOT NULL COMMENT '托盘分配记录ID(雪花ID)', + customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', plan_id BIGINT NOT NULL COMMENT '入库计划ID', line_id BIGINT NOT NULL COMMENT '入库计划明细ID', pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', @@ -422,6 +426,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation ( PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_allocation (line_id, pallet_id), KEY idx_ll_inbound_allocation_plan (plan_id), + KEY idx_ll_inbound_allocation_customer_plan (customer_id, plan_id), KEY idx_ll_inbound_allocation_pallet (pallet_id) ) ENGINE=InnoDB COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'; @@ -445,9 +450,11 @@ ALTER TABLE ll_inbound_plan ALTER TABLE ll_inbound_plan_line COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; ALTER TABLE ll_inbound_plan_line MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID', MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单ID', MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT '头程入仓码快照', + MODIFY COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', @@ -631,10 +638,24 @@ CREATE TABLE IF NOT EXISTS ll_inbound_pallet_merge ( SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL AFTER inbound_code')); PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户账号标识''', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); PREPARE receiving_container_line_stmt FROM @ddl; EXECUTE receiving_container_line_stmt; DEALLOCATE PREPARE receiving_container_line_stmt; UPDATE ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id SET l.customer_id=p.customer_id WHERE l.customer_id=''; ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND index_name='idx_ll_inbound_line_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD KEY idx_ll_inbound_line_customer_plan(customer_id,plan_id)')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; +UPDATE ll_inbound_plan_pallet v JOIN ll_inbound_plan p ON p.id=v.plan_id SET v.customer_id=p.customer_id WHERE v.customer_id=''; +ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND index_name='idx_ll_inbound_pallet_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD KEY idx_ll_inbound_pallet_customer_plan(customer_id,plan_id)')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; +UPDATE ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan p ON p.id=a.plan_id SET a.customer_id=p.customer_id WHERE a.customer_id=''; +ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND index_name='idx_ll_inbound_allocation_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD KEY idx_ll_inbound_allocation_customer_plan(customer_id,plan_id)')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='destination_type'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER inbound_code')); PREPARE receiving_container_line_stmt FROM @ddl; EXECUTE receiving_container_line_stmt; DEALLOCATE PREPARE receiving_container_line_stmt; UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 856aff935..a485c55f7 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -3,6 +3,82 @@ info: title: Lastleg Overseas Business API version: 1.0.0 paths: + /api/pda/v1/last-leg/receiving-containers/candidates: + get: + summary: 查询当前客户待签收柜号 + description: 权限 lastleg:pda:operate;客户与操作账号均从 PDA Token 获取,不接收 customerId。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 柜号候选及箱数进度 }, '403': { description: 缺少 PDA 操作权限 } } + /api/pda/v1/last-leg/receiving-containers/{containerNo}: + get: + summary: 查询待签收柜号详情 + description: 权限 lastleg:pda:operate;返回柜内全部入仓码、目的类型、目的仓、托数和实收进度。 + security: [{ bearerAuth: [] }] + parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 柜号详情 }, '409': { description: 当前账号已锁定其他柜号 } } + /api/pda/v1/last-leg/receiving-containers/{containerNo}/prepare: + post: + summary: 选择当前待签收柜号 + description: 权限 lastleg:pda:operate;建立 WAIT_SIGN 会话并以账号、柜号双唯一键防止切柜或多人同时处理。 + security: [{ bearerAuth: [] }] + parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } } + responses: { '200': { description: 当前柜会话 }, '409': { description: 账号或柜号已有未完成会话 } } + /api/pda/v1/last-leg/receiving-containers/current/unload-photos/files: + post: + summary: 上传当前柜签收照片 + description: 权限 lastleg:pda:operate;multipart 文件归属当前 WAIT_SIGN 会话。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } } + responses: { '200': { description: 文件标识 }, '409': { description: 当前柜已签收 } } + /api/pda/v1/last-leg/receiving-containers/current/sign: + post: + summary: 签收当前柜 + description: 权限 lastleg:pda:operate;校验非空照片归属后由 WAIT_SIGN 推进 SIGNED。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSign' } } } } + responses: { '200': { description: 已签收会话 }, '409': { description: 状态或照片校验失败 } } + /api/pda/v1/last-leg/receiving-containers/current: + get: + summary: 恢复当前账号已签柜 + description: 权限 lastleg:pda:operate;返回 SIGNED 或 RECEIVING 当前柜,不依赖 planNo。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 当前柜完整进度 }, '404': { description: 无当前柜 } } + /api/pda/v1/last-leg/receiving-containers/current/cartons/resolve: + post: + summary: 解析当前柜箱唛 + description: 权限 lastleg:pda:operate;仅 OVERSEAS 目的类型可逐箱收货,返回该箱所属入仓码完整信息。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { type: object, required: [cartonMark], properties: { cartonMark: { type: string } } } } } } + responses: { '200': { description: 箱唛与入仓码信息 }, '409': { description: 跨柜、重复箱或目的类型不符 } } + /api/pda/v1/last-leg/receiving-containers/current/pallets/resolve: + post: + summary: 解析当前柜实物托盘码 + description: 权限 lastleg:pda:operate;托盘固定从 Warehouse Overseas 公共资源作用域解析并校验实际仓与目的仓。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { type: object, required: [palletCode, inboundCode], properties: { palletCode: { type: string }, inboundCode: { type: string } } } } } } + responses: { '200': { description: 可用托盘与目的匹配结果 }, '409': { description: 仓库、目的仓或托盘状态不符 } } + /api/pda/v1/last-leg/receiving-containers/current/receipts/bulk: + post: + summary: FBA 入仓码按箱数收货 + description: 权限 lastleg:pda:operate;只允许 destinationType=FBA,OVERSEAS 在服务端硬拒绝;不接收 customerId、planNo、allocationId、warehouseId、locationId。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingBulk' } } } } + responses: { '200': { description: 本次与累计实收进度 }, '409': { description: 海外仓批量、超收或托盘不匹配 } } + /api/pda/v1/last-leg/receiving-containers/current/receipts/carton: + post: + summary: 海外仓入仓码逐箱收货 + description: 权限 lastleg:pda:operate;只允许 destinationType=OVERSEAS,拒绝跨柜、重复箱和不匹配托盘。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCarton' } } } } + responses: { '200': { description: 本次与累计实收进度 }, '409': { description: FBA逐箱、跨柜、重复箱或托盘不匹配 } } + /api/pda/v1/last-leg/receiving-containers/current/complete: + post: + summary: 完成当前柜收货并入库存 + description: 权限 lastleg:pda:operate;仅柜内每个入仓码实收总数精确相等后完成,库存、计划、尾程状态与卸柜 Outbox 同事务落地。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } } + responses: { '200': { description: COMPLETED 柜号会话 }, '409': { description: 数量未核平或状态冲突 } } /api/platform/v1/last-leg/orders: get: summary: 分页查询尾程订单 @@ -340,6 +416,31 @@ paths: responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } } components: schemas: + ReceivingAction: + type: object + required: [idempotencyKey] + properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } } + ReceivingSign: + type: object + required: [photoFileIds, idempotencyKey] + properties: + photoFileIds: { type: array, minItems: 1, uniqueItems: true, items: { type: integer, format: int64 } } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + ReceivingBulk: + type: object + required: [inboundCode, palletCode, packageCount, idempotencyKey] + properties: + inboundCode: { type: string, minLength: 1 } + palletCode: { type: string, minLength: 1 } + packageCount: { type: integer, minimum: 1 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + ReceivingCarton: + type: object + required: [cartonMark, palletCode, idempotencyKey] + properties: + cartonMark: { type: string, minLength: 1 } + palletCode: { type: string, minLength: 1 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } SmartDispatchCandidate: type: object properties: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java new file mode 100644 index 000000000..6d81dc32b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -0,0 +1,82 @@ +package com.baiyue.lastleg.inboundplan.pda; + +import com.baiyue.lastleg.inboundplan.pda.controller.ReceivingContainerController; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.io.IOException; +import java.lang.reflect.RecordComponent; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ReceivingContainerContractTest { + private static final Set FORBIDDEN_CALLER_SCOPE = Set.of( + "customerId", "planNo", "allocationId", "warehouseId", "locationId"); + + @Test + void pdaRequestsNeverAcceptServerOwnedScopeFields() { + for (Class type : ReceivingContainerRequests.class.getDeclaredClasses()) { + if (!type.isRecord()) continue; + Set fields = Arrays.stream(type.getRecordComponents()) + .map(RecordComponent::getName).collect(Collectors.toSet()); + assertTrue(java.util.Collections.disjoint(fields, FORBIDDEN_CALLER_SCOPE), type.getSimpleName()); + } + } + + @Test + void everyBrowserReachableReceivingMethodUsesOnlyPdaOperatePermission() { + PreAuthorize permission = ReceivingContainerController.class.getAnnotation(PreAuthorize.class); + assertTrue(permission != null); + assertTrue("hasAuthority('lastleg:pda:operate')".equals(permission.value())); + } + + @Test + void schemaContainsCustomerScopedPlanChildrenAndContainerSessionLocks() throws IOException { + String schema = resource("db/lastleg.sql").toLowerCase(); + String unified = java.nio.file.Files.readString( + java.nio.file.Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8).toLowerCase(); + for (String table : Set.of("ll_inbound_plan_line", "ll_inbound_plan_pallet", + "ll_inbound_plan_pallet_allocation")) { + assertTrue(tableBlock(schema, table).contains("customer_id varchar(128) not null"), table); + assertTrue(unified.contains("table_name='" + table + "' and column_name='customer_id'"), table); + assertTrue(unified.contains("update " + table), table); + } + assertFalse(schema.contains("tenant_id varchar(64)")); + assertFalse(unified.contains("column_name='tenant_id'")); + assertTrue(schema.contains("create table if not exists ll_receiving_container_session")); + assertTrue(schema.contains("unique key uk_ll_receiving_active_operator(active_operator_key)")); + assertTrue(schema.contains("unique key uk_ll_receiving_active_container(active_container_key)")); + assertTrue(schema.contains("create table if not exists ll_receiving_container_operation")); + assertTrue(schema.contains("wait_sign、signed、receiving、completed")); + } + + @Test + void openApiPublishesOnlyStableContainerPaths() throws IOException { + String yaml = resource("openapi/overseas-business-v1.yaml"); + String prefix = "/api/pda/v1/last-leg/receiving-containers"; + assertTrue(yaml.contains(prefix + "/candidates:")); + assertTrue(yaml.contains(prefix + "/current/receipts/bulk:")); + assertTrue(yaml.contains(prefix + "/current/receipts/carton:")); + assertTrue(yaml.contains(prefix + "/current/complete:")); + assertFalse(yaml.contains("/api/pda/v1/last-leg/receiving-containers/{planNo}")); + } + + private String resource(String name) throws IOException { + try (var input = getClass().getClassLoader().getResourceAsStream(name)) { + assertTrue(input != null, name); + return new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static String tableBlock(String schema, String table) { + int start = schema.indexOf("create table if not exists " + table + " ("); + int end = schema.indexOf(") engine=innodb", start); + return start < 0 || end < 0 ? "" : schema.substring(start, end); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java new file mode 100644 index 000000000..d049e9171 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.inboundplan.pda.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses; +import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ReceivingContainerControllerTest { + @Mock ReceivingContainerService service; + private ReceivingContainerController controller; + + @BeforeEach + void setUp() { + controller = new ReceivingContainerController(); + ReflectionTestUtils.setField(controller, "service", service); + } + + @Test + void candidatesDerivesCustomerAndOperatorOnlyFromPdaIdentity() { + when(service.candidates("100", "42")).thenReturn(List.of(new ReceivingContainerResponses.Candidate( + "CONT-1", "WAIT_SIGN", 2, 2, 20, 0, false))); + + controller.candidates(pda()); + + verify(service).candidates("100", "42"); + } + + @Test + void platformIdentityCannotEnterPdaReceivingApi() { + AuthenticatedUser platform = new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", + Set.of("lastleg:pda:operate")); + assertThatThrownBy(() -> controller.candidates(platform)).isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("401"); + } + + private static AuthenticatedUser pda() { + return new AuthenticatedUser(42L, 100L, 100L, 8L, "receiver", "PDA", "PDA", + Set.of("lastleg:pda:operate")); + } +} From a320198b472c7498916b75cd86432902bd132928 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 17:37:36 +0800 Subject: [PATCH 037/115] fix(ci): keep Jenkins boolean continuations parseable --- Jenkinsfile | 8 ++++---- Jenkinsfile.service | 14 +++++++------- scripts/controller-pipeline-contract.test.mjs | 1 + .../test-environment-pipeline-contract.test.mjs | 4 ++++ 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 0660d219a..b8084642e 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -154,8 +154,8 @@ pipeline { if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) { error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS') } - if (['BOTH', 'SELECT'].contains(bridgeRegion) - && !['development', 'test'].contains(env.RESOLVED_ENVIRONMENT)) { + if (['BOTH', 'SELECT'].contains(bridgeRegion) && + !['development', 'test'].contains(env.RESOLVED_ENVIRONMENT)) { error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS') } if (bridgeRegion == 'SELECT') { @@ -229,8 +229,8 @@ pipeline { String service = selectedService branches[service] = { String jobName = "${env.RESOLVED_JOB_PREFIX}${service}" - boolean deployBothBridges = service == 'event-bridge-service' - && ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION) + boolean deployBothBridges = service == 'event-bridge-service' && + ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION) String downstreamImageTag = deployBothBridges ? "${env.IMAGE_TAG}-both" : env.IMAGE_TAG diff --git a/Jenkinsfile.service b/Jenkinsfile.service index a1aed8754..72b8e5f43 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -76,8 +76,8 @@ pipeline { if (!serviceModules.contains(service)) { error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}") } - boolean deployBothBridges = service == 'event-bridge-service' - && imageTag?.endsWith('-both') + boolean deployBothBridges = service == 'event-bridge-service' && + imageTag?.endsWith('-both') if (deployBothBridges) { imageTag = imageTag.substring(0, imageTag.length() - '-both'.length()) } @@ -105,8 +105,8 @@ pipeline { if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) { error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS') } - if ((deployBothBridges || ['BOTH', 'SELECT'].contains(bridgeRegion)) - && !['development', 'test'].contains(params.ENVIRONMENT)) { + if ((deployBothBridges || ['BOTH', 'SELECT'].contains(bridgeRegion)) && + !['development', 'test'].contains(params.ENVIRONMENT)) { error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS') } } @@ -202,9 +202,9 @@ pipeline { try { unstash 'deployment-files' echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.ENVIRONMENT}" - List bridgeRegions = env.RESOLVED_SERVICE == 'event-bridge-service' - && (env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' - || ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION)) + List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' && + (env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' || + ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION))) ? ['CN', 'OVERSEAS'] : [params.EVENT_BRIDGE_REGION] withCredentials([ diff --git a/scripts/controller-pipeline-contract.test.mjs b/scripts/controller-pipeline-contract.test.mjs index 65be87496..2248b09e6 100644 --- a/scripts/controller-pipeline-contract.test.mjs +++ b/scripts/controller-pipeline-contract.test.mjs @@ -11,6 +11,7 @@ test('coordinator releases its builder while waiting for downstream jobs', () => assert.match(pipeline, /agent \{ label 'java-builder' \}/) assert.doesNotMatch(pipeline, /agent any/) assert.doesNotMatch(pipeline, /cleanWs\(/) + assert.doesNotMatch(pipeline, /^\s*(?:&&|\|\|)/m) }) test('coordinator runs selected tests once before parallel service packaging', () => { diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index 9858d6992..cb7a9566c 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -13,6 +13,10 @@ const ciMavenSettings = (await readFile(new URL('../deploy/maven/settings-ci.xml const serviceDockerfile = (await readFile(new URL('../deploy/docker/Dockerfile', import.meta.url), 'utf8')) .replaceAll('\r\n', '\n') +test('service pipeline keeps boolean operators on parseable Groovy continuation lines', () => { + assert.doesNotMatch(pipeline, /^\s*(?:&&|\|\|)/m) +}) + test('test deployments compile packages without rerunning the full unit test suite', () => { assert.match(pipeline, /params\.ENVIRONMENT == 'test' \|\| params\.SKIP_TESTS/) assert.match(pipeline, /skipTestExecution \? '-DskipTests' : ''/) From 633cec7c681b80ae00eda5bd78a79e2da87d9a37 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 17:43:03 +0800 Subject: [PATCH 038/115] fix(lastleg): disambiguate consumer test fixtures --- .../lastleg/service/LoadingCompletedConsumerTest.java | 8 ++++---- .../service/TaillegRealConsumerAcceptanceTest.java | 4 ++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index a8e431c6d..02c0f722a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -1,5 +1,5 @@ package com.baiyue.lastleg.service; -import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) class LoadingCompletedConsumerTest { @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; @Mock InventoryCommandService inventory; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; @@ -80,7 +80,7 @@ class LoadingCompletedConsumerTest { source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(2,service.consume(v2)); - verify(cartons,never()).insert(any()); + verify(cartons,never()).insert(any(InboundPlanCarton.class)); } @Test void acceptsV2FbaByPackageCountWithoutCartonFacts(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); @@ -91,7 +91,7 @@ class LoadingCompletedConsumerTest { source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(2,service.consume(v2)); - verify(cartons,never()).insert(any()); + verify(cartons,never()).insert(any(InboundPlanCarton.class)); } @Test void safelyInfersLegacyOverseasDestinationFromReceivingWarehouseIdentity(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); @@ -107,7 +107,7 @@ class LoadingCompletedConsumerTest { assertEquals(2,service.consume(v2)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captured.capture()); assertTrue(captured.getAllValues().stream().allMatch(order->"OVERSEAS".equals(order.getDestinationType()))); - verify(cartons,times(8)).insert(any()); + verify(cartons,times(8)).insert(any(InboundPlanCarton.class)); } private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1)),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2)))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); } private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java index ff1840e5f..6099cda63 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java @@ -73,6 +73,10 @@ class TaillegRealConsumerAcceptanceTest { String businessType, String businessId, String reservationKey) { return 1L; } public void scrap(String customerId, long resourceLockId) { } public void scrapPallet(String customerId, long palletId) { } + public WarehousePalletReference resolvePallet(String palletCode) { + return new WarehousePalletReference(1L, palletCode, 1L, 1L, + "AVAILABLE", null, 0); + } }; } } From 4893542ec40cf5df37e28173c64cedc29c7da16d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 17:48:08 +0800 Subject: [PATCH 039/115] 1 --- common-event-contract/AGENTS.md | 2 +- .../event/OverseasContainerUnloadedV1.java | 5 +- .../OverseasContainerUnloadedV1Test.java | 2 + deploy/sql/海外区域统一增量-MySQL8.sql | 19 ++- lastleg-service/AGENTS.md | 2 +- .../pda/dto/ReceivingContainerRequests.java | 10 +- .../gateway/HttpWarehouseResourceGateway.java | 13 ++ .../pda/gateway/WarehouseResourceGateway.java | 1 + .../service/InboundPdaWorkflowService.java | 108 ++++++++++++-- .../InboundUnloadCompletedOutboxService.java | 77 ++++++++-- .../service/ReceivingContainerService.java | 47 ++++-- .../mapper/LastlegEventOutboxMapper.java | 4 + .../src/main/resources/db/lastleg.sql | 19 ++- .../openapi/overseas-business-v1.yaml | 24 ++-- ...boundUnloadCompletedOutboxServiceTest.java | 136 ++++++++++++++++++ 15 files changed, 410 insertions(+), 59 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index 0a675effd..c35c4997c 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -16,7 +16,7 @@ | 事件类型与区域 | `EventTypes.java`、`RegionCode.java` | | 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` | | 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 | -| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` | +| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` | | IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 | | 契约说明 | `package-info.java` | | 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java` | diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java index 6841c9815..a919d58c5 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java @@ -2,7 +2,10 @@ package com.baiyue.common.event; import java.time.Instant; -/** 海外仓卸柜完成事实;国内订舱 ID 是稳定聚合标识,containerNo 仅用于核对物理柜号。 */ +/** + * 海外仓柜级卸柜完成事实;国内订舱 ID 是稳定聚合与幂等标识,containerNo 用于核对物理柜号。 + * unloadingCompletedAt 是全柜 complete 时刻,不得使用签收拍照上传时刻代替。 + */ public record OverseasContainerUnloadedV1( String sourceContainerBookingId, String containerNo, diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java index 70f843856..0942ac072 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java @@ -20,5 +20,7 @@ class OverseasContainerUnloadedV1Test { assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload); assertThat(json).contains("sourceContainerBookingId", "1001", "MSCU1234567", "unloadingPhotoUploadedAt"); + assertThat(payload.unloadingCompletedAt()).isAfter(payload.unloadingPhotoUploadedAt()); + assertThat(payload.unloadingCompletedAt()).isNotEqualTo(payload.unloadingPhotoUploadedAt()); } } diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index bbce2f48d..3c8be6dd6 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -341,7 +341,7 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt; -UPDATE ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id SET l.customer_id=p.customer_id WHERE l.customer_id=''; +UPDATE ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id SET l.customer_id=p.customer_id WHERE l.customer_id IS NULL OR l.customer_id=''; ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND index_name='idx_ll_inbound_line_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD KEY idx_ll_inbound_line_customer_plan(customer_id,plan_id)')); PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; @@ -349,7 +349,7 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; -UPDATE ll_inbound_plan_pallet v JOIN ll_inbound_plan p ON p.id=v.plan_id SET v.customer_id=p.customer_id WHERE v.customer_id=''; +UPDATE ll_inbound_plan_pallet v JOIN ll_inbound_plan p ON p.id=v.plan_id SET v.customer_id=p.customer_id WHERE v.customer_id IS NULL OR v.customer_id=''; ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND index_name='idx_ll_inbound_pallet_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD KEY idx_ll_inbound_pallet_customer_plan(customer_id,plan_id)')); PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; @@ -357,10 +357,23 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; -UPDATE ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan p ON p.id=a.plan_id SET a.customer_id=p.customer_id WHERE a.customer_id=''; +UPDATE ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan p ON p.id=a.plan_id SET a.customer_id=p.customer_id WHERE a.customer_id IS NULL OR a.customer_id=''; ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND index_name='idx_ll_inbound_allocation_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD KEY idx_ll_inbound_allocation_customer_plan(customer_id,plan_id)')); PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; +DROP PROCEDURE IF EXISTS assert_phase4_inbound_plan_customer_scope_ready; +DELIMITER $$ +CREATE PROCEDURE assert_phase4_inbound_plan_customer_scope_ready() +BEGIN + IF EXISTS (SELECT 1 FROM ll_inbound_plan_line l LEFT JOIN ll_inbound_plan p ON p.id=l.plan_id WHERE l.customer_id IS NULL OR l.customer_id='' OR p.id IS NULL OR l.customer_id<>p.customer_id) + OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet v LEFT JOIN ll_inbound_plan p ON p.id=v.plan_id WHERE v.customer_id IS NULL OR v.customer_id='' OR p.id IS NULL OR v.customer_id<>p.customer_id) + OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a LEFT JOIN ll_inbound_plan p ON p.id=a.plan_id WHERE a.customer_id IS NULL OR a.customer_id='' OR p.id IS NULL OR a.customer_id<>p.customer_id) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Inbound plan child customer scope migration is incomplete'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase4_inbound_plan_customer_scope_ready(); +DROP PROCEDURE assert_phase4_inbound_plan_customer_scope_ready; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码''', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码'' AFTER inbound_code')); diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 3ea28089c..b6d2cc413 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -24,7 +24,7 @@ | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | | PDA 入仓、实收、上架、合托、完成 | `inboundplan/pda/controller/`、`inboundplan/pda/service/InboundPdaWorkflowService.java`;入口固定为 `/api/pda/v1/last-leg/**`,不得挂入平台浏览器 API | | PDA 卸柜照片文件上传与归属校验 | `InboundPdaController.java` 的 `/unload-photos/files`、`inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO` | -| 卸柜照片与海外卸柜完成 Outbox | `inboundplan/pda/controller/InboundPdaController.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | +| 卸柜照片与海外卸柜完成 Outbox | `inboundplan/pda/controller/InboundPdaController.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | | 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | | 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,范围开关与禁用写操作见“四期开发范围路由” | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java index 191cad025..093a03cce 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java @@ -3,18 +3,20 @@ package com.baiyue.lastleg.inboundplan.pda.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; import java.util.List; public final class ReceivingContainerRequests { private ReceivingContainerRequests() { } - public record Action(@NotBlank String idempotencyKey) { } - public record Sign(@NotEmpty List<@Positive Long> photoFileIds, @NotBlank String idempotencyKey) { } + public record Action(@NotBlank @Size(max = 255) String idempotencyKey) { } + public record Sign(@NotEmpty List<@Positive Long> photoFileIds, + @NotBlank @Size(max = 255) String idempotencyKey) { } public record CartonResolve(@NotBlank String cartonMark) { } public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { } public record BulkReceive(@NotBlank String inboundCode, @NotBlank String palletCode, - @Positive int packageCount, @NotBlank String idempotencyKey) { } + @Positive int packageCount, @NotBlank @Size(max = 255) String idempotencyKey) { } public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode, - @NotBlank String idempotencyKey) { } + @NotBlank @Size(max = 255) String idempotencyKey) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java index c23317f40..c28a0ab89 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -71,6 +71,19 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { .toBodilessEntity(); } + @Override + public void release(String customerId, long resourceLockId) { + client.post() + .uri("/internal/v1/warehouse-resource-locks/{lockId}/release", resourceLockId) + .header("X-Internal-Service-Token", internalToken) + .body(Map.of("customerId", Long.parseLong(customerId))) + .retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { + throw new IllegalStateException("Warehouse rejected pallet release: HTTP " + response.getStatusCode()); + }) + .toBodilessEntity(); + } + @Override public void scrapPallet(String customerId, long palletId) { client.post() diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java index a1f5f2419..0350073c4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java @@ -4,6 +4,7 @@ public interface WarehouseResourceGateway { WarehousePalletReference ensureDropshipPallet(String customerId, long warehouseId, String businessId); long reserve(String customerId, long warehouseId, long palletId, long locationId, String businessType, String businessId, String reservationKey); + void release(String customerId, long resourceLockId); void scrap(String customerId, long resourceLockId); void scrapPallet(String customerId, long palletId); WarehousePalletReference resolvePallet(String palletCode); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index 85427550c..9321d0511 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -154,10 +154,10 @@ public class InboundPdaWorkflowService { } @Transactional - public void assignResolvedPallet(String customerId, InboundPlan plan, InboundPlanPallet pallet, + public Long assignResolvedPallet(String customerId, InboundPlan plan, InboundPlanPallet pallet, WarehouseResourceGateway.WarehousePalletReference resource) { if (pallet.getWarehouseResourceLockId() != null) { - if (Objects.equals(pallet.getWarehousePalletId(), resource.palletId())) return; + if (Objects.equals(pallet.getWarehousePalletId(), resource.palletId())) return null; throw conflict("Destination pallet is already bound to another physical pallet"); } String destinationKey = destinationKey(pallet); @@ -165,9 +165,53 @@ public class InboundPdaWorkflowService { String reservationKey = "INBOUND_CONTAINER_DESTINATION_PALLET:" + businessId + ":" + resource.palletId(); long lockId = warehouse.reserve(customerId, resource.warehouseId(), resource.palletId(), resource.locationId(), "INBOUND_CONTAINER_DESTINATION_PALLET", businessId, reservationKey); - pallet.setWarehousePalletId(resource.palletId()); pallet.setWarehouseLocationId(resource.locationId()); - pallet.setWarehouseResourceLockId(lockId); pallet.setAssignmentStatus("ASSIGNED"); - pallet.setLockedAt(LocalDateTime.now()); repository.update(pallet); + try { + pallet.setWarehousePalletId(resource.palletId()); pallet.setWarehouseLocationId(resource.locationId()); + pallet.setWarehouseResourceLockId(lockId); pallet.setAssignmentStatus("ASSIGNED"); + pallet.setLockedAt(LocalDateTime.now()); repository.update(pallet); + return lockId; + } catch (RuntimeException failure) { + compensateReservation(customerId, lockId, failure); + throw failure; + } + } + + public void validateBulkResolved(String customerId, InboundPlan plan, InboundPlanLine line, int packageCount) { + requireActiveTask(customerId, plan); + int received = repository.allocations(plan.getId()).stream() + .filter(value -> Objects.equals(value.getLineId(), line.getId())) + .mapToInt(value -> value(value.getPickedPackageCount())).sum(); + int pending = value(line.getRequiredPackageCount()) - received; + if (packageCount <= 0 || packageCount > pending) { + throw conflict("Received package count exceeds the inbound pending total"); + } + } + + public void validateCartonResolved(String customerId, InboundPlan plan, InboundPlanLine line, + InboundPlanPallet pallet, String cartonMark) { + requireActiveTask(customerId, plan); + InboundPlanCarton carton = repository.lockCarton(customerId, cartonMark); + if (carton == null || !("UNASSIGNED".equals(carton.getStatus()) || "EXPECTED".equals(carton.getStatus()))) { + throw conflict("Carton mark has already been scanned or is unavailable"); + } + InboundPlanLine resolvedLine = carton.getLineId() == null + ? repository.lineForCarton(plan.getId(), carton) : requireLine(plan.getId(), carton.getLineId()); + if (resolvedLine == null || !Objects.equals(resolvedLine.getId(), line.getId())) { + throw conflict("Carton mark belongs to another inbound code or container"); + } + if (!sameWarehouse(carton, pallet)) throw conflict("Carton and pallet destination warehouses must match"); + if (repository.scannedCartons(plan.getId(), line.getId()) >= value(line.getRequiredPackageCount())) { + throw conflict("This inbound plan line has reached its required carton count"); + } + } + + public void compensateReservation(String customerId, Long resourceLockId, RuntimeException originalFailure) { + if (resourceLockId == null) return; + try { + warehouse.release(customerId, resourceLockId); + } catch (RuntimeException releaseFailure) { + originalFailure.addSuppressed(releaseFailure); + } } @Transactional @@ -444,13 +488,42 @@ public class InboundPdaWorkflowService { @Transactional public PdaResponses.Task complete(String customerId, String planNo) { InboundPlan plan = requirePlan(customerId, planNo); + return completeContainer(customerId, List.of(plan)).get(0); + } + + /** Completes every plan of one physical container and emits exactly one container-level outbox event. */ + @Transactional + public List completeContainer(String customerId, List containerPlans) { + if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans"); + List orderedPlans = containerPlans.stream().sorted(Comparator.comparing(InboundPlan::getId)).toList(); + String containerNo = orderedPlans.get(0).getContainerNo(); + if (containerNo == null || containerNo.isBlank() || orderedPlans.stream().anyMatch(plan -> + !customerId.equals(plan.getCustomerId()) || !containerNo.equals(plan.getContainerNo()))) { + throw conflict("Inbound plans must belong to one customer and one physical container"); + } + List contexts = orderedPlans.stream().map(plan -> completionContext(customerId, plan)).toList(); + boolean anyCompleted = contexts.stream().anyMatch(context -> "COMPLETED".equals(context.task().getStatus())); + boolean allCompleted = contexts.stream().allMatch(context -> "COMPLETED".equals(context.task().getStatus())); + if (allCompleted) return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList(); + if (anyCompleted) throw conflict("Container plans are only partially completed; manual reconciliation is required"); + + LocalDateTime now = LocalDateTime.now(); + for (CompletionContext context : contexts) completePlan(customerId, context, now); + CompletionContext eventSource = contexts.get(0); + unloadCompletedOutbox.publish(eventSource.plan(), eventSource.task(), eventSource.photoFileIds()); + return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList(); + } + + private CompletionContext completionContext(String customerId, InboundPlan plan) { InboundPdaTask task = repository.lockTask(customerId, plan.getId()); - if (task == null) throw conflict("Push the inbound plan to PDA before operating"); - if ("COMPLETED".equals(task.getStatus())) return task(task, planNo); + if (task == null) throw conflict("Push every inbound plan in the container to PDA before completing"); + if ("COMPLETED".equals(task.getStatus())) { + return new CompletionContext(plan, task, List.of(), List.of(), List.of(), List.of()); + } List unloadPhotoFileIds = unloadPhotoFileIds(task); if (unloadPhotoFileIds.isEmpty() || task.getUnloadPhotoUploadedAt() == null || task.getEstimatedEmptyReturnAt() == null) { - throw conflict("Upload unload photos before completing the PDA inbound task"); + throw conflict("Upload unload photos for every inbound plan before completing the container"); } Map pickedByLine = new HashMap<>(); List allocations = repository.allocations(plan.getId()); @@ -471,9 +544,15 @@ public class InboundPdaWorkflowService { && !"ON_PALLET".equals(carton.getStatus()))) { throw conflict("All expected carton marks must be scanned onto a pallet before stock-in"); } - LocalDateTime now = LocalDateTime.now(); - stockReceipts.stockIn(customerId, plan, task, lines, allocations, repository.pallets(plan.getId()), now); - for (InboundPlanCarton carton : cartons) { + return new CompletionContext(plan, task, unloadPhotoFileIds, lines, allocations, cartons); + } + + private void completePlan(String customerId, CompletionContext context, LocalDateTime now) { + InboundPlan plan = context.plan(); + InboundPdaTask task = context.task(); + stockReceipts.stockIn(customerId, plan, task, context.lines(), context.allocations(), + repository.pallets(plan.getId()), now); + for (InboundPlanCarton carton : context.cartons()) { carton.setStatus("ON_PALLET".equals(carton.getStatus()) ? "AVAILABLE" : "BULK_RECEIVED"); carton.setReceivedAt(now); carton.setUpdatedAt(now); carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton); @@ -486,8 +565,6 @@ public class InboundPdaWorkflowService { plan.setStatus("COMPLETED"); plan.setUpdatedAt(now); repository.update(plan); - unloadCompletedOutbox.publish(plan, task, unloadPhotoFileIds); - return task(task, planNo); } @Transactional @@ -840,6 +917,11 @@ public class InboundPdaWorkflowService { allocation.setVersion(value(allocation.getVersion()) + 1); } + private record CompletionContext(InboundPlan plan, InboundPdaTask task, List photoFileIds, + List lines, + List allocations, + List cartons) { } + private static int value(Integer value) { return value == null ? 0 : value; } private static BigDecimal volume(BigDecimal value) { return value == null ? ZERO_VOLUME : value; } private static BigDecimal nonNegative(BigDecimal value) { return value.signum() < 0 ? ZERO_VOLUME : value.setScale(4, RoundingMode.HALF_UP); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java index 5a502dab5..535f62079 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java @@ -19,11 +19,13 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; +import org.springframework.dao.DuplicateKeyException; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.List; +import java.util.Objects; /** Writes the overseas-to-CN handover to the local transactional outbox. */ @Service @@ -32,31 +34,88 @@ public class InboundUnloadCompletedOutboxService { @Resource private LastlegOrderMapper orders; @Resource private ObjectMapper objectMapper; + /** 兼容单计划入仓完成;多计划同柜必须改调 publishContainerCompleted,并传柜级完成时刻。 */ public void publish(InboundPlan plan, InboundPdaTask task, List photoFileIds) { LastlegOrder sourceOrder = orders.selectById(plan.getSourceOrderId()); if (sourceOrder == null || sourceOrder.getSourceContainerBookingId() == null || sourceOrder.getSourceContainerBookingId().isBlank()) { throw new IllegalStateException("Inbound plan has no source container booking ID"); } - Instant photoUploadedAt = task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC); - var payload = new OverseasContainerUnloadedV1(sourceOrder.getSourceContainerBookingId(), - plan.getContainerNo(), photoUploadedAt, photoUploadedAt, null); + if (task.getCompletedAt() == null) { + throw new IllegalStateException("Inbound task has no completion time"); + } + if (task.getUnloadPhotoUploadedAt() == null) { + throw new IllegalStateException("Inbound task has no unload photo upload time"); + } + publishContainerCompleted(task.getCustomerId(), sourceOrder.getSourceContainerBookingId(), + plan.getContainerNo(), task.getCompletedAt().toInstant(ZoneOffset.UTC), + task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC), + Math.max(1, task.getVersion() == null ? 1 : task.getVersion())); + } + + /** + * 柜级唯一发布入口。调用方只能在同一物理柜的全部计划完成后调用一次;重复调用保留首次冻结事实。 + */ + public void publishContainerCompleted(String customerId, String sourceContainerBookingId, + String containerNo, Instant unloadingCompletedAt, + Instant unloadingPhotoUploadedAt, long aggregateVersion) { + requireText(customerId, "customerId"); + requireText(sourceContainerBookingId, "sourceContainerBookingId"); + requireText(containerNo, "containerNo"); + Objects.requireNonNull(unloadingCompletedAt, "unloadingCompletedAt"); + Objects.requireNonNull(unloadingPhotoUploadedAt, "unloadingPhotoUploadedAt"); + if (unloadingCompletedAt.isBefore(unloadingPhotoUploadedAt)) { + throw new IllegalArgumentException("unloadingCompletedAt cannot be before unloadingPhotoUploadedAt"); + } + + String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + sourceContainerBookingId; + LastlegEventOutbox existing = mapper.findByEventKey(eventKey); + if (existing != null) { + requireSameContainer(existing, customerId, sourceContainerBookingId, containerNo); + return; + } + var payload = new OverseasContainerUnloadedV1(sourceContainerBookingId, + containerNo, unloadingCompletedAt, unloadingPhotoUploadedAt, null); JsonNode payloadNode = objectMapper.valueToTree(payload); - long aggregateVersion = Math.max(1, task.getVersion() == null ? 1 : task.getVersion()); - String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + task.getId(); String traceId = EventIdentifiers.newUuid(); EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED, - 1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, task.getCustomerId(), "CONTAINER_BOOKING", - sourceOrder.getSourceContainerBookingId(), aggregateVersion, 1L, traceId, eventKey, null, payloadNode, + 1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, customerId, "CONTAINER_BOOKING", + sourceContainerBookingId, Math.max(1, aggregateVersion), 1L, traceId, eventKey, null, payloadNode, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid(); LastlegEventOutbox event = new LastlegEventOutbox(); - event.setId(IdWorker.getId()); event.setCustomerId(task.getCustomerId()); + event.setId(IdWorker.getId()); event.setCustomerId(customerId); event.setAggregateType(envelope.aggregateType()); event.setAggregateId(envelope.aggregateId()); event.setEventType(envelope.eventType()); event.setEventVersion(envelope.eventVersion()); event.setEventKey(eventKey); event.setPayloadJson(json(envelope)); event.setStatus("PENDING"); event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC)); - mapper.insert(event); + try { + mapper.insert(event); + } catch (DuplicateKeyException exception) { + LastlegEventOutbox raced = mapper.findByEventKey(eventKey); + if (raced == null) throw exception; + requireSameContainer(raced, customerId, sourceContainerBookingId, containerNo); + } + } + + private void requireSameContainer(LastlegEventOutbox existing, String customerId, + String sourceContainerBookingId, String containerNo) { + try { + EventEnvelope envelope = objectMapper.readValue(existing.getPayloadJson(), EventEnvelope.class); + OverseasContainerUnloadedV1 frozen = objectMapper.treeToValue( + envelope.payload(), OverseasContainerUnloadedV1.class); + if (!Objects.equals(existing.getCustomerId(), customerId) + || !Objects.equals(existing.getAggregateId(), sourceContainerBookingId) + || !Objects.equals(frozen.containerNo(), containerNo)) { + throw new IllegalStateException("Unload-completed event key conflicts with another container"); + } + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot read existing unload-completed event", exception); + } + } + + private void requireText(String value, String field) { + if (value == null || value.isBlank()) throw new IllegalArgumentException(field + " is required"); } private String json(Object value) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 5f92fb9d6..c34323c29 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -92,8 +92,8 @@ public class ReceivingContainerService { public ReceivingContainerResponses.Session detail(String customerId, String operatorKey, String containerNo) { List containerPlans = requirePlans(customerId, containerNo); ReceivingContainerSession current = sessions.findActiveByOperator(customerId, activeOperatorKey(customerId, operatorKey)); - if (current != null && !containerNo.equals(current.getContainerNo()) && !"WAIT_SIGN".equals(current.getStatus())) { - throw conflict("Current account is locked to another signed container"); + if (current != null && !containerNo.equals(current.getContainerNo())) { + throw conflict("Current account is locked to another selected container"); } return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView( customerId, operatorKey, containerNo), containerPlans); @@ -102,7 +102,8 @@ public class ReceivingContainerService { @Transactional public ReceivingContainerResponses.Session prepare(String customerId, String operatorKey, String operatorName, String containerNo, ReceivingContainerRequests.Action request) { - ReceivingContainerOperation replay = replay(customerId, operatorKey, "PREPARE", request.idempotencyKey(), request); + PrepareCommand command = new PrepareCommand(containerNo, request.idempotencyKey()); + ReceivingContainerOperation replay = replay(customerId, operatorKey, "PREPARE", request.idempotencyKey(), command); if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); List containerPlans = requirePlans(customerId, containerNo); String activeOperatorKey = activeOperatorKey(customerId, operatorKey); @@ -110,7 +111,7 @@ public class ReceivingContainerService { if (active != null) { if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one"); ReceivingContainerResponses.Session result = snapshot(active, containerPlans); - save(active, "PREPARE", request.idempotencyKey(), request, result); return result; + save(active, "PREPARE", request.idempotencyKey(), command, result); return result; } if (sessions.lockActiveByContainer(customerId, activeContainerKey(customerId, containerNo)) != null) { throw conflict("Container is already signed or prepared by another account"); @@ -125,7 +126,7 @@ public class ReceivingContainerService { try { sessions.insert(session); } catch (DuplicateKeyException duplicate) { throw conflict("Account or container already has an active receiving session"); } ReceivingContainerResponses.Session result = snapshot(session, containerPlans); - save(session, "PREPARE", request.idempotencyKey(), request, result); return result; + save(session, "PREPARE", request.idempotencyKey(), command, result); return result; } public ReceivingContainerResponses.Photo uploadPhoto(String customerId, String operatorKey, long operatorId, @@ -203,9 +204,16 @@ public class ReceivingContainerService { WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode()); validatePallet(ref, resource); InboundPlanPallet pallet = choosePallet(ref, resource); - workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); - int accepted = workflow.receiveBulkResolved(customerId, ref.plan(), ref.line(), pallet, - request.packageCount(), operatorKey, operatorName); + workflow.validateBulkResolved(customerId, ref.plan(), ref.line(), request.packageCount()); + Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); + final int accepted; + try { + accepted = workflow.receiveBulkResolved(customerId, ref.plan(), ref.line(), pallet, + request.packageCount(), operatorKey, operatorName); + } catch (RuntimeException failure) { + workflow.compensateReservation(customerId, resourceLockId, failure); + throw failure; + } markReceiving(session); ReceivingContainerResponses.Inbound inbound = inbound(ref); ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", @@ -232,9 +240,15 @@ public class ReceivingContainerService { WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode()); validatePallet(ref, resource); InboundPlanPallet pallet = choosePallet(ref, resource); - workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); - workflow.scanCarton(customerId, ref.plan().getPlanNo(), new PdaRequests.ScanCarton( - request.cartonMark(), pallet.getId(), request.idempotencyKey() + ":SCAN", operatorKey, operatorName)); + workflow.validateCartonResolved(customerId, ref.plan(), ref.line(), pallet, request.cartonMark()); + Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); + try { + workflow.scanCarton(customerId, ref.plan().getPlanNo(), new PdaRequests.ScanCarton( + request.cartonMark(), pallet.getId(), request.idempotencyKey() + ":SCAN", operatorKey, operatorName)); + } catch (RuntimeException failure) { + workflow.compensateReservation(customerId, resourceLockId, failure); + throw failure; + } markReceiving(session); ReceivingContainerResponses.Inbound inbound = inbound(ref); ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", @@ -252,7 +266,7 @@ public class ReceivingContainerService { List containerPlans = requirePlans(customerId, session.getContainerNo()); Totals totals = totals(containerPlans); if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container"); - for (InboundPlan plan : containerPlans) workflow.complete(customerId, plan.getPlanNo()); + workflow.completeContainer(customerId, containerPlans); LocalDateTime now = LocalDateTime.now(); session.setStatus("COMPLETED"); session.setCompletedAt(now); session.setUpdatedAt(now); session.setActiveOperatorKey(null); session.setActiveContainerKey(null); update(session); @@ -420,6 +434,7 @@ public class ReceivingContainerService { } private ReceivingContainerOperation replay(String customerId, String operatorKey, String action, String key, Object request) { + key = idempotencyKey(key); ReceivingContainerOperation value = operations.find(customerId, operatorKey, key); if (value == null) return null; if (!action.equals(value.getAction()) || !hash(request).equals(value.getRequestHash())) { @@ -429,6 +444,7 @@ public class ReceivingContainerService { } private void save(ReceivingContainerSession session, String action, String key, Object request, Object result) { + key = idempotencyKey(key); ReceivingContainerOperation value = new ReceivingContainerOperation(); value.setId(IdWorker.getId()); value.setSessionId(session.getId()); value.setCustomerId(session.getCustomerId()); value.setOperatorKey(session.getOperatorKey()); value.setAction(action); value.setIdempotencyKey(key); @@ -460,10 +476,17 @@ public class ReceivingContainerService { private static String activeOperatorKey(String customerId, String operatorKey) { return customerId + ":" + operatorKey; } private static String activeContainerKey(String customerId, String containerNo) { return customerId + ":" + containerNo; } + private static String idempotencyKey(String value) { + if (value == null || value.isBlank()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency key is required"); + value = value.trim(); + if (value.length() > 255) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency key exceeds 255 characters"); + return value; + } private static int value(Integer value) { return value == null ? 0 : value; } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } private record InboundRef(InboundPlan plan, InboundPlanLine line, LastlegOrder order) { } private record Totals(int inboundCount, int total, int received) { } + private record PrepareCommand(String containerNo, String idempotencyKey) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java index c7871d177..767551233 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java @@ -3,7 +3,11 @@ package com.baiyue.lastleg.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.entity.LastlegEventOutbox; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; @Mapper public interface LastlegEventOutboxMapper extends BaseMapper { + @Select("SELECT * FROM ll_event_outbox WHERE event_key=#{eventKey} LIMIT 1") + LastlegEventOutbox findByEventKey(@Param("eventKey") String eventKey); } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 0f3c1503e..c02cca9d0 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -640,22 +640,35 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); PREPARE receiving_container_line_stmt FROM @ddl; EXECUTE receiving_container_line_stmt; DEALLOCATE PREPARE receiving_container_line_stmt; -UPDATE ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id SET l.customer_id=p.customer_id WHERE l.customer_id=''; +UPDATE ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id SET l.customer_id=p.customer_id WHERE l.customer_id IS NULL OR l.customer_id=''; ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND index_name='idx_ll_inbound_line_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD KEY idx_ll_inbound_line_customer_plan(customer_id,plan_id)')); PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; -UPDATE ll_inbound_plan_pallet v JOIN ll_inbound_plan p ON p.id=v.plan_id SET v.customer_id=p.customer_id WHERE v.customer_id=''; +UPDATE ll_inbound_plan_pallet v JOIN ll_inbound_plan p ON p.id=v.plan_id SET v.customer_id=p.customer_id WHERE v.customer_id IS NULL OR v.customer_id=''; ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND index_name='idx_ll_inbound_pallet_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD KEY idx_ll_inbound_pallet_customer_plan(customer_id,plan_id)')); PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; -UPDATE ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan p ON p.id=a.plan_id SET a.customer_id=p.customer_id WHERE a.customer_id=''; +UPDATE ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan p ON p.id=a.plan_id SET a.customer_id=p.customer_id WHERE a.customer_id IS NULL OR a.customer_id=''; ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND index_name='idx_ll_inbound_allocation_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD KEY idx_ll_inbound_allocation_customer_plan(customer_id,plan_id)')); PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; +DROP PROCEDURE IF EXISTS assert_inbound_plan_customer_scope_ready; +DELIMITER $$ +CREATE PROCEDURE assert_inbound_plan_customer_scope_ready() +BEGIN + IF EXISTS (SELECT 1 FROM ll_inbound_plan_line l LEFT JOIN ll_inbound_plan p ON p.id=l.plan_id WHERE l.customer_id IS NULL OR l.customer_id='' OR p.id IS NULL OR l.customer_id<>p.customer_id) + OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet v LEFT JOIN ll_inbound_plan p ON p.id=v.plan_id WHERE v.customer_id IS NULL OR v.customer_id='' OR p.id IS NULL OR v.customer_id<>p.customer_id) + OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a LEFT JOIN ll_inbound_plan p ON p.id=a.plan_id WHERE a.customer_id IS NULL OR a.customer_id='' OR p.id IS NULL OR a.customer_id<>p.customer_id) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Inbound plan child customer scope migration is incomplete'; + END IF; +END$$ +DELIMITER ; +CALL assert_inbound_plan_customer_scope_ready(); +DROP PROCEDURE assert_inbound_plan_customer_scope_ready; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='destination_type'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER inbound_code')); PREPARE receiving_container_line_stmt FROM @ddl; EXECUTE receiving_container_line_stmt; DEALLOCATE PREPARE receiving_container_line_stmt; UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index a485c55f7..f3de2ced2 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -8,14 +8,14 @@ paths: summary: 查询当前客户待签收柜号 description: 权限 lastleg:pda:operate;客户与操作账号均从 PDA Token 获取,不接收 customerId。 security: [{ bearerAuth: [] }] - responses: { '200': { description: 柜号候选及箱数进度 }, '403': { description: 缺少 PDA 操作权限 } } + responses: { '200': { description: 柜号候选及箱数进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCandidateListApiResponse' } } } }, '403': { description: 缺少 PDA 操作权限 } } /api/pda/v1/last-leg/receiving-containers/{containerNo}: get: summary: 查询待签收柜号详情 description: 权限 lastleg:pda:operate;返回柜内全部入仓码、目的类型、目的仓、托数和实收进度。 security: [{ bearerAuth: [] }] parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] - responses: { '200': { description: 柜号详情 }, '409': { description: 当前账号已锁定其他柜号 } } + responses: { '200': { description: 柜号详情, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 当前账号已锁定其他柜号 } } /api/pda/v1/last-leg/receiving-containers/{containerNo}/prepare: post: summary: 选择当前待签收柜号 @@ -23,62 +23,62 @@ paths: security: [{ bearerAuth: [] }] parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } } - responses: { '200': { description: 当前柜会话 }, '409': { description: 账号或柜号已有未完成会话 } } + responses: { '200': { description: 当前柜会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 账号或柜号已有未完成会话 } } /api/pda/v1/last-leg/receiving-containers/current/unload-photos/files: post: summary: 上传当前柜签收照片 description: 权限 lastleg:pda:operate;multipart 文件归属当前 WAIT_SIGN 会话。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } } - responses: { '200': { description: 文件标识 }, '409': { description: 当前柜已签收 } } + responses: { '200': { description: 文件标识, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingPhotoApiResponse' } } } }, '409': { description: 当前柜已签收 } } /api/pda/v1/last-leg/receiving-containers/current/sign: post: summary: 签收当前柜 description: 权限 lastleg:pda:operate;校验非空照片归属后由 WAIT_SIGN 推进 SIGNED。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSign' } } } } - responses: { '200': { description: 已签收会话 }, '409': { description: 状态或照片校验失败 } } + responses: { '200': { description: 已签收会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 状态或照片校验失败 } } /api/pda/v1/last-leg/receiving-containers/current: get: summary: 恢复当前账号已签柜 - description: 权限 lastleg:pda:operate;返回 SIGNED 或 RECEIVING 当前柜,不依赖 planNo。 + description: 权限 lastleg:pda:operate;返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜,不依赖 planNo。 security: [{ bearerAuth: [] }] - responses: { '200': { description: 当前柜完整进度 }, '404': { description: 无当前柜 } } + responses: { '200': { description: 当前柜完整进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '404': { description: 无当前柜 } } /api/pda/v1/last-leg/receiving-containers/current/cartons/resolve: post: summary: 解析当前柜箱唛 description: 权限 lastleg:pda:operate;仅 OVERSEAS 目的类型可逐箱收货,返回该箱所属入仓码完整信息。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { type: object, required: [cartonMark], properties: { cartonMark: { type: string } } } } } } - responses: { '200': { description: 箱唛与入仓码信息 }, '409': { description: 跨柜、重复箱或目的类型不符 } } + responses: { '200': { description: 箱唛与入仓码信息, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCartonResolvedApiResponse' } } } }, '409': { description: 跨柜、重复箱或目的类型不符 } } /api/pda/v1/last-leg/receiving-containers/current/pallets/resolve: post: summary: 解析当前柜实物托盘码 description: 权限 lastleg:pda:operate;托盘固定从 Warehouse Overseas 公共资源作用域解析并校验实际仓与目的仓。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { type: object, required: [palletCode, inboundCode], properties: { palletCode: { type: string }, inboundCode: { type: string } } } } } } - responses: { '200': { description: 可用托盘与目的匹配结果 }, '409': { description: 仓库、目的仓或托盘状态不符 } } + responses: { '200': { description: 可用托盘与目的匹配结果, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingPalletResolvedApiResponse' } } } }, '409': { description: 仓库、目的仓或托盘状态不符 } } /api/pda/v1/last-leg/receiving-containers/current/receipts/bulk: post: summary: FBA 入仓码按箱数收货 description: 权限 lastleg:pda:operate;只允许 destinationType=FBA,OVERSEAS 在服务端硬拒绝;不接收 customerId、planNo、allocationId、warehouseId、locationId。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingBulk' } } } } - responses: { '200': { description: 本次与累计实收进度 }, '409': { description: 海外仓批量、超收或托盘不匹配 } } + responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: 海外仓批量、超收或托盘不匹配 } } /api/pda/v1/last-leg/receiving-containers/current/receipts/carton: post: summary: 海外仓入仓码逐箱收货 description: 权限 lastleg:pda:operate;只允许 destinationType=OVERSEAS,拒绝跨柜、重复箱和不匹配托盘。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCarton' } } } } - responses: { '200': { description: 本次与累计实收进度 }, '409': { description: FBA逐箱、跨柜、重复箱或托盘不匹配 } } + responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: FBA逐箱、跨柜、重复箱或托盘不匹配 } } /api/pda/v1/last-leg/receiving-containers/current/complete: post: summary: 完成当前柜收货并入库存 description: 权限 lastleg:pda:operate;仅柜内每个入仓码实收总数精确相等后完成,库存、计划、尾程状态与卸柜 Outbox 同事务落地。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } } - responses: { '200': { description: COMPLETED 柜号会话 }, '409': { description: 数量未核平或状态冲突 } } + responses: { '200': { description: COMPLETED 柜号会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 数量未核平或状态冲突 } } /api/platform/v1/last-leg/orders: get: summary: 分页查询尾程订单 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java new file mode 100644 index 000000000..4712eba02 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java @@ -0,0 +1,136 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.OverseasContainerUnloadedV1; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class InboundUnloadCompletedOutboxServiceTest { + private final LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class); + private final LastlegOrderMapper orders = mock(LastlegOrderMapper.class); + private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); + private final InboundUnloadCompletedOutboxService service = new InboundUnloadCompletedOutboxService(); + + @BeforeEach + void setup() { + ReflectionTestUtils.setField(service, "mapper", mapper); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "objectMapper", json); + } + + @Test + void freezesContainerCompletionSeparatelyFromPhotoUpload() throws Exception { + Instant photoUploadedAt = Instant.parse("2026-08-24T08:00:00Z"); + Instant containerCompletedAt = Instant.parse("2026-08-24T09:30:00Z"); + + service.publishContainerCompleted("100", "booking-1", "MSCU1234567", + containerCompletedAt, photoUploadedAt, 7); + + ArgumentCaptor captured = ArgumentCaptor.forClass(LastlegEventOutbox.class); + verify(mapper).insert(captured.capture()); + LastlegEventOutbox row = captured.getValue(); + EventEnvelope envelope = json.readValue(row.getPayloadJson(), EventEnvelope.class); + OverseasContainerUnloadedV1 payload = json.treeToValue( + envelope.payload(), OverseasContainerUnloadedV1.class); + assertThat(payload.unloadingCompletedAt()).isEqualTo(containerCompletedAt); + assertThat(payload.unloadingPhotoUploadedAt()).isEqualTo(photoUploadedAt); + assertThat(row.getEventKey()).isEqualTo(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1"); + assertThat(envelope.aggregateId()).isEqualTo("booking-1"); + } + + @Test + void legacySinglePlanEntryUsesTaskCompletionTime() throws Exception { + InboundPlan plan = new InboundPlan(); + plan.setSourceOrderId(11L); + plan.setContainerNo("MSCU1234567"); + InboundPdaTask task = new InboundPdaTask(); + task.setCustomerId("100"); + task.setCompletedAt(LocalDateTime.parse("2026-08-24T09:30:00")); + task.setUnloadPhotoUploadedAt(LocalDateTime.parse("2026-08-24T08:00:00")); + task.setVersion(3); + LastlegOrder order = new LastlegOrder(); + order.setSourceContainerBookingId("booking-1"); + when(orders.selectById(11L)).thenReturn(order); + + service.publish(plan, task, List.of(1L)); + + ArgumentCaptor captured = ArgumentCaptor.forClass(LastlegEventOutbox.class); + verify(mapper).insert(captured.capture()); + EventEnvelope envelope = json.readValue(captured.getValue().getPayloadJson(), EventEnvelope.class); + OverseasContainerUnloadedV1 payload = json.treeToValue( + envelope.payload(), OverseasContainerUnloadedV1.class); + assertThat(payload.unloadingCompletedAt()).isEqualTo(Instant.parse("2026-08-24T09:30:00Z")); + assertThat(payload.unloadingPhotoUploadedAt()).isEqualTo(Instant.parse("2026-08-24T08:00:00Z")); + } + + @Test + void repeatedContainerCompletionDoesNotPublishAnotherEvent() { + LastlegEventOutbox existing = existing("100", "booking-1", "MSCU1234567"); + when(mapper.findByEventKey(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1")) + .thenReturn(existing); + + service.publishContainerCompleted("100", "booking-1", "MSCU1234567", + Instant.parse("2026-08-24T10:00:00Z"), Instant.parse("2026-08-24T08:00:00Z"), 8); + + verify(mapper, never()).insert(any(LastlegEventOutbox.class)); + } + + @Test + void sameIdempotencyKeyCannotRepresentAnotherPhysicalContainer() { + LastlegEventOutbox existing = existing("100", "booking-1", "MSCU1234567"); + when(mapper.findByEventKey(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1")) + .thenReturn(existing); + + assertThatThrownBy(() -> service.publishContainerCompleted("100", "booking-1", "TGHU7654321", + Instant.parse("2026-08-24T10:00:00Z"), Instant.parse("2026-08-24T08:00:00Z"), 8)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("conflicts with another container"); + } + + private LastlegEventOutbox existing(String customerId, String bookingId, String containerNo) { + OverseasContainerUnloadedV1 payload = new OverseasContainerUnloadedV1(bookingId, containerNo, + Instant.parse("2026-08-24T09:30:00Z"), Instant.parse("2026-08-24T08:00:00Z"), null); + EventEnvelope envelope = new EventEnvelope("123e4567-e89b-12d3-a456-426614174000", + EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, Instant.parse("2026-08-24T09:30:01Z"), + com.baiyue.common.event.RegionCode.OVERSEAS, com.baiyue.common.event.RegionCode.CN, + customerId, "CONTAINER_BOOKING", bookingId, 7, 1L, + "123e4567-e89b-12d3-a456-426614174001", + EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + bookingId, null, + json.valueToTree(payload), "sha256:" + "0".repeat(64)); + LastlegEventOutbox row = new LastlegEventOutbox(); + row.setCustomerId(customerId); + row.setAggregateId(bookingId); + row.setPayloadJson(write(envelope)); + return row; + } + + private String write(Object value) { + try { + return json.writeValueAsString(value); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } +} From f4a1ec92de1fe5beeb138891adb68cfeddae4603 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 17:51:11 +0800 Subject: [PATCH 040/115] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/overseas-business-v1.yaml | 127 ++++++++++ .../InboundPdaContainerCompletionTest.java | 94 ++++++++ ...ReceivingContainerServiceBehaviorTest.java | 228 ++++++++++++++++++ 3 files changed, 449 insertions(+) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index f3de2ced2..17646d664 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -441,6 +441,133 @@ components: cartonMark: { type: string, minLength: 1 } palletCode: { type: string, minLength: 1 } idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + ReceivingApiResponseBase: + type: object + required: [code, message, timestamp] + properties: + code: { type: string, example: '00000' } + message: { type: string } + requestId: { type: string, nullable: true } + traceId: { type: string, nullable: true } + timestamp: { type: string, format: date-time } + ReceivingInbound: + type: object + required: [inboundCode, destinationType, plannedPalletCount, totalPackages, receivedPackages, pendingPackages] + properties: + inboundCode: { type: string } + destinationType: { type: string, enum: [FBA, OVERSEAS] } + destinationWarehouseId: { type: string, nullable: true } + destinationWarehouseCode: { type: string, nullable: true } + destinationWarehouseName: { type: string, nullable: true } + fbaWarehouseCode: { type: string, nullable: true } + plannedPalletCount: { type: integer } + totalPackages: { type: integer } + receivedPackages: { type: integer } + pendingPackages: { type: integer } + ReceivingCandidate: + type: object + required: [containerNo, status, planCount, inboundCodeCount, totalPackages, receivedPackages, currentAccount] + properties: + containerNo: { type: string } + status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] } + planCount: { type: integer } + inboundCodeCount: { type: integer } + totalPackages: { type: integer } + receivedPackages: { type: integer } + currentAccount: { type: boolean } + ReceivingSession: + type: object + required: [containerNo, status, version, photoFileIds, totalPackages, receivedPackages, pendingPackages, inbounds] + properties: + containerNo: { type: string } + status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] } + operatorName: { type: string, nullable: true } + version: { type: integer } + photoFileIds: { type: array, items: { type: integer, format: int64 } } + preparedAt: { type: string, format: date-time, nullable: true } + signedAt: { type: string, format: date-time, nullable: true } + receivingAt: { type: string, format: date-time, nullable: true } + completedAt: { type: string, format: date-time, nullable: true } + totalPackages: { type: integer } + receivedPackages: { type: integer } + pendingPackages: { type: integer } + inbounds: { type: array, items: { $ref: '#/components/schemas/ReceivingInbound' } } + ReceivingPhoto: + type: object + required: [fileId, fileName, contentType, sizeBytes] + properties: + fileId: { type: integer, format: int64 } + fileName: { type: string } + contentType: { type: string } + sizeBytes: { type: integer, format: int64 } + ReceivingCartonResolved: + allOf: + - { $ref: '#/components/schemas/ReceivingInbound' } + - type: object + required: [cartonMark] + properties: + cartonMark: { type: string } + skuCode: { type: string, nullable: true } + ReceivingPalletResolved: + type: object + required: [palletCode, warehouseId, status, inboundCode, destinationType, compatible] + properties: + palletCode: { type: string } + warehouseId: { type: integer, format: int64 } + status: { type: string } + fbaWarehouseCode: { type: string, nullable: true } + inboundCode: { type: string } + destinationType: { type: string, enum: [FBA, OVERSEAS] } + compatible: { type: boolean } + ReceivingReceipt: + type: object + required: [result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages, receivedPackages, pendingPackages, sessionStatus] + properties: + result: { type: string } + containerNo: { type: string } + inboundCode: { type: string } + destinationType: { type: string, enum: [FBA, OVERSEAS] } + palletCode: { type: string } + acceptedPackages: { type: integer } + receivedPackages: { type: integer } + pendingPackages: { type: integer } + sessionStatus: { type: string, enum: [SIGNED, RECEIVING] } + ReceivingCandidateListApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + required: [data] + properties: { data: { type: array, items: { $ref: '#/components/schemas/ReceivingCandidate' } } } + ReceivingSessionApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + required: [data] + properties: { data: { $ref: '#/components/schemas/ReceivingSession' } } + ReceivingPhotoApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + required: [data] + properties: { data: { $ref: '#/components/schemas/ReceivingPhoto' } } + ReceivingCartonResolvedApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + required: [data] + properties: { data: { $ref: '#/components/schemas/ReceivingCartonResolved' } } + ReceivingPalletResolvedApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + required: [data] + properties: { data: { $ref: '#/components/schemas/ReceivingPalletResolved' } } + ReceivingReceiptApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + required: [data] + properties: { data: { $ref: '#/components/schemas/ReceivingReceipt' } } SmartDispatchCandidate: type: object properties: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java new file mode 100644 index 000000000..9bcf5cdde --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java @@ -0,0 +1,94 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundPdaContainerCompletionTest { + @Mock PdaWorkflowRepository repository; + @Mock InboundStockReceiptService stockReceipts; + @Mock InboundUnloadCompletedOutboxService outbox; + private InboundPdaWorkflowService service; + + @BeforeEach + void setUp() { + service = new InboundPdaWorkflowService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); + ReflectionTestUtils.setField(service, "unloadCompletedOutbox", outbox); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); + } + + @Test + void twoPlansOfOneContainerStockInTogetherAndPublishOneContainerEvent() { + InboundPlan first = plan(1L, "PLAN-1"); + InboundPlan second = plan(2L, "PLAN-2"); + InboundPdaTask firstTask = task(11L, 1L); + InboundPdaTask secondTask = task(22L, 2L); + when(repository.lockTask("100", 1L)).thenReturn(firstTask); + when(repository.lockTask("100", 2L)).thenReturn(secondTask); + when(repository.lines(1L)).thenReturn(List.of(line(101L))); + when(repository.lines(2L)).thenReturn(List.of(line(202L))); + when(repository.allocations(1L)).thenReturn(List.of(allocation(101L))); + when(repository.allocations(2L)).thenReturn(List.of(allocation(202L))); + when(repository.cartons(any())).thenReturn(List.of()); + when(repository.pallets(any())).thenReturn(List.of()); + + service.completeContainer("100", List.of(second, first)); + service.completeContainer("100", List.of(first, second)); + + verify(stockReceipts, times(2)).stockIn(eq("100"), any(), any(), any(), any(), any(), any()); + verify(outbox, times(1)).publish(eq(first), eq(firstTask), eq(List.of(10L))); + verify(repository, times(2)).update(any(InboundPlan.class)); + verify(repository, times(2)).update(any(InboundPdaTask.class)); + } + + private static InboundPlan plan(long id, String planNo) { + InboundPlan value = new InboundPlan(); + value.setId(id); value.setPlanNo(planNo); value.setCustomerId("100"); value.setContainerNo("CONT-1"); + value.setStatus("RECEIVING"); + return value; + } + + private static InboundPdaTask task(long id, long planId) { + InboundPdaTask value = new InboundPdaTask(); + LocalDateTime now = LocalDateTime.of(2026, 8, 24, 10, 0); + value.setId(id); value.setPlanId(planId); value.setCustomerId("100"); value.setTaskNo("TASK-" + id); + value.setStatus("RECEIVING"); value.setUnloadPhotoFileIds("[10]"); value.setUnloadPhotoUploadedAt(now); + value.setEstimatedEmptyReturnAt(now.plusDays(2)); value.setVersion(1); + return value; + } + + private static InboundPlanLine line(long id) { + InboundPlanLine value = new InboundPlanLine(); + value.setId(id); value.setRequiredPackageCount(1); value.setDestinationType("FBA"); + return value; + } + + private static InboundPlanPalletAllocation allocation(long lineId) { + InboundPlanPalletAllocation value = new InboundPlanPalletAllocation(); + value.setLineId(lineId); value.setPickedPackageCount(1); value.setCountedPackageCount(1); + value.setActualVolumeCbm(BigDecimal.ONE); + return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java new file mode 100644 index 000000000..78237ecbc --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -0,0 +1,228 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ReceivingContainerServiceBehaviorTest { + @Mock InboundPlanMapper plans; + @Mock InboundPlanLineMapper lines; + @Mock InboundPlanPalletMapper pallets; + @Mock InboundPlanPalletAllocationMapper allocations; + @Mock InboundPlanCartonMapper cartons; + @Mock LastlegOrderMapper orders; + @Mock ReceivingContainerSessionMapper sessions; + @Mock ReceivingContainerOperationMapper operations; + @Mock WarehouseResourceGateway warehouse; + @Mock UnloadPhotoFileGateway unloadPhotos; + @Mock InboundPdaWorkflowService workflow; + private ReceivingContainerService service; + + @BeforeEach + void setUp() { + service = new ReceivingContainerService(); + ReflectionTestUtils.setField(service, "plans", plans); + ReflectionTestUtils.setField(service, "lines", lines); + ReflectionTestUtils.setField(service, "pallets", pallets); + ReflectionTestUtils.setField(service, "allocations", allocations); + ReflectionTestUtils.setField(service, "cartons", cartons); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "sessions", sessions); + ReflectionTestUtils.setField(service, "operations", operations); + ReflectionTestUtils.setField(service, "warehouse", warehouse); + ReflectionTestUtils.setField(service, "unloadPhotos", unloadPhotos); + ReflectionTestUtils.setField(service, "workflow", workflow); + ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules()); + service.initializeObjectMapper(); + } + + @Test + void prepareIdempotencyHashIncludesContainerPath() { + when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A"))); + when(lines.selectList(any())).thenReturn(List.of()); + when(sessions.insert(any())).thenReturn(1); + when(operations.insert(any())).thenReturn(1); + service.prepare("100", "7", "receiver", "CONT-A", new ReceivingContainerRequests.Action("same-key")); + ArgumentCaptor saved = ArgumentCaptor.forClass(ReceivingContainerOperation.class); + verify(operations).insert(saved.capture()); + when(operations.find("100", "7", "same-key")).thenReturn(saved.getValue()); + + assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B", + new ReceivingContainerRequests.Action("same-key"))).isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("409"); + } + + @Test + void waitSignSelectionAlreadyLocksAccountFromViewingAnotherContainer() { + when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B"))); + when(sessions.findActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); + assertThatThrownBy(() -> service.detail("100", "7", "CONT-B")) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + } + + @Test + void destinationModesAreHardRejectedBeforeWarehouseCalls() { + stubCurrent("OVERSEAS", "CONT-A"); + assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", + new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "bulk-key"))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + verifyNoInteractions(warehouse); + + org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse); + stubCurrent("FBA", "CONT-A"); + InboundPlanCarton carton = carton(1L, "BOX-1"); + when(cartons.selectOne(any())).thenReturn(carton); + assertThatThrownBy(() -> service.receiveCarton("100", "7", "receiver", + new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "carton-key"))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + verifyNoInteractions(warehouse); + } + + @Test + void crossContainerCartonAndMismatchedPalletAreRejectedBeforeReservation() { + stubCurrent("OVERSEAS", "CONT-A"); + when(cartons.selectOne(any())).thenReturn(carton(999L, "BOX-X")); + assertThatThrownBy(() -> service.receiveCarton("100", "7", "receiver", + new ReceivingContainerRequests.CartonReceive("BOX-X", "PAL-1", "cross-key"))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + verifyNoInteractions(warehouse); + + org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse, pallets, workflow); + stubCurrent("FBA", "CONT-A"); + when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(101L, "FBA-1")); + assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", + new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "pallet-key"))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); + } + + @Test + void overReceiptAndDuplicateCartonFailBeforeRemoteReservation() { + stubCurrent("FBA", "CONT-A"); + when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, "FBA-1")); + when(pallets.selectList(any())).thenReturn(List.of(planPallet())); + doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt")) + .when(workflow).validateBulkResolved(any(), any(), any(), any(Integer.class)); + assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", + new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 9, "over-key"))) + .isInstanceOf(ResponseStatusException.class); + verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); + + org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse, pallets, workflow); + stubCurrent("OVERSEAS", "CONT-A"); + when(cartons.selectOne(any())).thenReturn(carton(1L, "BOX-1")); + when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, null)); + when(pallets.selectList(any())).thenReturn(List.of(planPallet())); + doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "duplicate carton")) + .when(workflow).validateCartonResolved(any(), any(), any(), any(), any()); + assertThatThrownBy(() -> service.receiveCarton("100", "7", "receiver", + new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "duplicate-key"))) + .isInstanceOf(ResponseStatusException.class); + verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); + } + + @Test + void containerCompletionRequiresEveryInboundCodeToBalanceExactly() { + stubCurrent("FBA", "CONT-A"); + InboundPlanPalletAllocation partial = new InboundPlanPalletAllocation(); + partial.setLineId(10L); partial.setPickedPackageCount(1); + when(allocations.selectList(any())).thenReturn(List.of(partial)); + assertThatThrownBy(() -> service.complete("100", "7", new ReceivingContainerRequests.Action("complete-key"))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + verify(workflow, never()).completeContainer(any(), any()); + } + + private void stubCurrent(String destinationType, String containerNo) { + InboundPlan plan = plan(containerNo); + InboundPlanLine line = line(destinationType); + LastlegOrder order = order(destinationType); + when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session(containerNo, "SIGNED")); + when(plans.selectList(any())).thenReturn(List.of(plan)); + when(lines.selectList(any())).thenReturn(List.of(line)); + when(orders.selectById(1L)).thenReturn(order); + } + + private static InboundPlan plan(String containerNo) { + InboundPlan value = new InboundPlan(); + value.setId(2L); value.setPlanNo("PLAN-1"); value.setCustomerId("100"); value.setContainerNo(containerNo); + value.setStatus("SIGNED"); + return value; + } + + private static InboundPlanLine line(String destinationType) { + InboundPlanLine value = new InboundPlanLine(); + value.setId(10L); value.setCustomerId("100"); value.setPlanId(2L); value.setOrderId(1L); + value.setInboundCode("IN-1"); value.setDestinationType(destinationType); value.setRequiredPackageCount(2); + value.setDestinationWarehouseId("100"); value.setDestinationWarehouseCode("WH-100"); + value.setFbaWarehouseCode("FBA".equals(destinationType) ? "FBA-1" : null); + return value; + } + + private static LastlegOrder order(String destinationType) { + LastlegOrder value = new LastlegOrder(); + value.setId(1L); value.setCustomerId("100"); value.setDestinationType(destinationType); + value.setOverseasWarehouseId("100"); value.setFbaWarehouseCode("FBA".equals(destinationType) ? "FBA-1" : null); + return value; + } + + private static ReceivingContainerSession session(String containerNo, String status) { + ReceivingContainerSession value = new ReceivingContainerSession(); + value.setId(8L); value.setCustomerId("100"); value.setContainerNo(containerNo); value.setOperatorKey("7"); + value.setStatus(status); value.setVersion(0); + return value; + } + + private static InboundPlanCarton carton(long orderId, String cartonMark) { + InboundPlanCarton value = new InboundPlanCarton(); + value.setOrderId(orderId); value.setCartonMark(cartonMark); value.setStatus("UNASSIGNED"); + return value; + } + + private static InboundPlanPallet planPallet() { + InboundPlanPallet value = new InboundPlanPallet(); + value.setId(20L); value.setCustomerId("100"); value.setPlanId(2L); + value.setDestinationWarehouseId("100"); value.setDestinationWarehouseCode("WH-100"); + return value; + } + + private static WarehouseResourceGateway.WarehousePalletReference resource(long warehouseId, String fbaCode) { + return new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-1", warehouseId, 40L, + "AVAILABLE", fbaCode, 0); + } +} From 52e4b914b1033ef02ad9cfa053642ad04eca31b9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 17:56:43 +0800 Subject: [PATCH 041/115] fix(lastleg): complete receiving by container --- .../service/InboundPdaWorkflowService.java | 26 +++++++++++++-- .../service/ReceivingContainerService.java | 29 ++++++++-------- .../pda/ReceivingContainerContractTest.java | 22 +++++++++++++ .../InboundPdaContainerCompletionTest.java | 13 +++++++- ...ReceivingContainerServiceBehaviorTest.java | 33 ++++++++++++++++++- .../TaillegRealConsumerAcceptanceTest.java | 1 + 6 files changed, 106 insertions(+), 18 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index 9321d0511..dde1adef1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -14,6 +14,8 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; @@ -33,6 +35,7 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; @@ -49,6 +52,7 @@ public class InboundPdaWorkflowService { @Resource private WarehouseResourceGateway warehouse; @Resource private InboundStockReceiptService stockReceipts; @Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox; + @Resource private LastlegOrderMapper orders; @Resource private UnloadPhotoFileGateway unloadPhotoFiles; @Resource private ObjectMapper sourceObjectMapper; @@ -509,11 +513,29 @@ public class InboundPdaWorkflowService { LocalDateTime now = LocalDateTime.now(); for (CompletionContext context : contexts) completePlan(customerId, context, now); - CompletionContext eventSource = contexts.get(0); - unloadCompletedOutbox.publish(eventSource.plan(), eventSource.task(), eventSource.photoFileIds()); + String sourceContainerBookingId = sourceContainerBookingId(contexts); + LocalDateTime photoUploadedAt = contexts.stream().map(context -> context.task().getUnloadPhotoUploadedAt()) + .max(LocalDateTime::compareTo).orElseThrow(); + long aggregateVersion = contexts.stream().map(CompletionContext::task) + .map(InboundPdaTask::getVersion).filter(Objects::nonNull).mapToLong(Integer::longValue).max().orElse(1L); + unloadCompletedOutbox.publishContainerCompleted(customerId, sourceContainerBookingId, containerNo, + now.toInstant(ZoneOffset.UTC), photoUploadedAt.toInstant(ZoneOffset.UTC), aggregateVersion); return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList(); } + private String sourceContainerBookingId(List contexts) { + List bookingIds = contexts.stream().map(context -> { + LastlegOrder order = orders.selectById(context.plan().getSourceOrderId()); + if (order == null || order.getSourceContainerBookingId() == null + || order.getSourceContainerBookingId().isBlank()) { + throw new IllegalStateException("Inbound plan has no source container booking ID"); + } + return order.getSourceContainerBookingId(); + }).distinct().toList(); + if (bookingIds.size() != 1) throw conflict("Container plans reference different source container bookings"); + return bookingIds.get(0); + } + private CompletionContext completionContext(String customerId, InboundPlan plan) { InboundPdaTask task = repository.lockTask(customerId, plan.getId()); if (task == null) throw conflict("Push every inbound plan in the container to PDA before completing"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index c34323c29..c7cdf8f7a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -206,20 +206,20 @@ public class ReceivingContainerService { InboundPlanPallet pallet = choosePallet(ref, resource); workflow.validateBulkResolved(customerId, ref.plan(), ref.line(), request.packageCount()); Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); - final int accepted; try { - accepted = workflow.receiveBulkResolved(customerId, ref.plan(), ref.line(), pallet, + int accepted = workflow.receiveBulkResolved(customerId, ref.plan(), ref.line(), pallet, request.packageCount(), operatorKey, operatorName); + markReceiving(session); + ReceivingContainerResponses.Inbound inbound = inbound(ref); + ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", + session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), accepted, + inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus()); + save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result); + return result; } catch (RuntimeException failure) { workflow.compensateReservation(customerId, resourceLockId, failure); throw failure; } - markReceiving(session); - ReceivingContainerResponses.Inbound inbound = inbound(ref); - ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", - session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), accepted, - inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus()); - save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result); return result; } @Transactional @@ -245,16 +245,17 @@ public class ReceivingContainerService { try { workflow.scanCarton(customerId, ref.plan().getPlanNo(), new PdaRequests.ScanCarton( request.cartonMark(), pallet.getId(), request.idempotencyKey() + ":SCAN", operatorKey, operatorName)); + markReceiving(session); + ReceivingContainerResponses.Inbound inbound = inbound(ref); + ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", + session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), 1, + inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus()); + save(session, "CARTON_RECEIVE", request.idempotencyKey(), request, result); + return result; } catch (RuntimeException failure) { workflow.compensateReservation(customerId, resourceLockId, failure); throw failure; } - markReceiving(session); - ReceivingContainerResponses.Inbound inbound = inbound(ref); - ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", - session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), 1, - inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus()); - save(session, "CARTON_RECEIVE", request.idempotencyKey(), request, result); return result; } @Transactional diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java index 6d81dc32b..aa4e1ea67 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -4,15 +4,18 @@ import com.baiyue.lastleg.inboundplan.pda.controller.ReceivingContainerControlle import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; +import jakarta.validation.constraints.Size; import java.io.IOException; import java.lang.reflect.RecordComponent; import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.List; import java.util.Set; import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; class ReceivingContainerContractTest { @@ -27,6 +30,13 @@ class ReceivingContainerContractTest { .map(RecordComponent::getName).collect(Collectors.toSet()); assertTrue(java.util.Collections.disjoint(fields, FORBIDDEN_CALLER_SCOPE), type.getSimpleName()); } + for (Class type : Set.of(ReceivingContainerRequests.Action.class, ReceivingContainerRequests.Sign.class, + ReceivingContainerRequests.BulkReceive.class, ReceivingContainerRequests.CartonReceive.class)) { + RecordComponent key = Arrays.stream(type.getRecordComponents()) + .filter(component -> "idempotencyKey".equals(component.getName())).findFirst().orElseThrow(); + Size size = key.getAccessor().getAnnotation(Size.class); + assertTrue(size != null && size.max() == 255, type.getSimpleName()); + } } @Test @@ -54,6 +64,10 @@ class ReceivingContainerContractTest { assertTrue(schema.contains("unique key uk_ll_receiving_active_container(active_container_key)")); assertTrue(schema.contains("create table if not exists ll_receiving_container_operation")); assertTrue(schema.contains("wait_sign、signed、receiving、completed")); + assertTrue(unified.contains("customer_id is null or l.customer_id=''")); + assertTrue(unified.contains("customer_id is null or v.customer_id=''")); + assertTrue(unified.contains("customer_id is null or a.customer_id=''")); + assertTrue(unified.contains("signal sqlstate '45000'")); } @Test @@ -65,6 +79,13 @@ class ReceivingContainerContractTest { assertTrue(yaml.contains(prefix + "/current/receipts/carton:")); assertTrue(yaml.contains(prefix + "/current/complete:")); assertFalse(yaml.contains("/api/pda/v1/last-leg/receiving-containers/{planNo}")); + String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:")); + List successResponses = receivingPaths.lines().map(String::trim) + .filter(line -> line.startsWith("responses: { '200':")).toList(); + assertEquals(11, successResponses.size()); + assertTrue(successResponses.stream().allMatch(line -> + line.contains("content: { application/json: { schema: { $ref: '#/components/schemas/Receiving"))); + assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜")); } private String resource(String name) throws IOException { @@ -79,4 +100,5 @@ class ReceivingContainerContractTest { int end = schema.indexOf(") engine=innodb", start); return start < 0 || end < 0 ? "" : schema.substring(start, end); } + } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java index 9bcf5cdde..50844ce90 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java @@ -5,6 +5,8 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -15,9 +17,11 @@ import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -28,6 +32,7 @@ class InboundPdaContainerCompletionTest { @Mock PdaWorkflowRepository repository; @Mock InboundStockReceiptService stockReceipts; @Mock InboundUnloadCompletedOutboxService outbox; + @Mock LastlegOrderMapper orders; private InboundPdaWorkflowService service; @BeforeEach @@ -36,6 +41,7 @@ class InboundPdaContainerCompletionTest { ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); ReflectionTestUtils.setField(service, "unloadCompletedOutbox", outbox); + ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); } @@ -53,12 +59,16 @@ class InboundPdaContainerCompletionTest { when(repository.allocations(2L)).thenReturn(List.of(allocation(202L))); when(repository.cartons(any())).thenReturn(List.of()); when(repository.pallets(any())).thenReturn(List.of()); + LastlegOrder source = new LastlegOrder(); source.setSourceContainerBookingId("BOOKING-1"); + when(orders.selectById(101L)).thenReturn(source); + when(orders.selectById(102L)).thenReturn(source); service.completeContainer("100", List.of(second, first)); service.completeContainer("100", List.of(first, second)); verify(stockReceipts, times(2)).stockIn(eq("100"), any(), any(), any(), any(), any(), any()); - verify(outbox, times(1)).publish(eq(first), eq(firstTask), eq(List.of(10L))); + verify(outbox, times(1)).publishContainerCompleted(eq("100"), eq("BOOKING-1"), eq("CONT-1"), + any(Instant.class), any(Instant.class), anyLong()); verify(repository, times(2)).update(any(InboundPlan.class)); verify(repository, times(2)).update(any(InboundPdaTask.class)); } @@ -66,6 +76,7 @@ class InboundPdaContainerCompletionTest { private static InboundPlan plan(long id, String planNo) { InboundPlan value = new InboundPlan(); value.setId(id); value.setPlanNo(planNo); value.setCustomerId("100"); value.setContainerNo("CONT-1"); + value.setSourceOrderId(id == 1L ? 101L : 102L); value.setStatus("RECEIVING"); return value; } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 78237ecbc..6fa1bee5b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -33,6 +33,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -96,6 +97,21 @@ class ReceivingContainerServiceBehaviorTest { .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); } + @Test + void prepareEnforcesBothActiveAccountAndActiveContainerUniqueness() { + when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B"))); + when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); + assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B", + new ReceivingContainerRequests.Action("account-lock"))).isInstanceOf(ResponseStatusException.class); + verify(sessions, never()).insert(any()); + + org.mockito.Mockito.reset(sessions, operations); + when(sessions.lockActiveByContainer("100", "100:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN")); + assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B", + new ReceivingContainerRequests.Action("container-lock"))).isInstanceOf(ResponseStatusException.class); + verify(sessions, never()).insert(any()); + } + @Test void destinationModesAreHardRejectedBeforeWarehouseCalls() { stubCurrent("OVERSEAS", "CONT-A"); @@ -138,7 +154,7 @@ class ReceivingContainerServiceBehaviorTest { when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, "FBA-1")); when(pallets.selectList(any())).thenReturn(List.of(planPallet())); doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt")) - .when(workflow).validateBulkResolved(any(), any(), any(), any(Integer.class)); + .when(workflow).validateBulkResolved(any(), any(), any(), anyInt()); assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 9, "over-key"))) .isInstanceOf(ResponseStatusException.class); @@ -168,6 +184,21 @@ class ReceivingContainerServiceBehaviorTest { verify(workflow, never()).completeContainer(any(), any()); } + @Test + void localFailureAfterRemoteReservationTriggersReleaseCompensation() { + stubCurrent("FBA", "CONT-A"); + when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, "FBA-1")); + when(pallets.selectList(any())).thenReturn(List.of(planPallet())); + when(workflow.assignResolvedPallet(any(), any(), any(), any())).thenReturn(55L); + ResponseStatusException failure = new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "race"); + doThrow(failure).when(workflow).receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any()); + + assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", + new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "compensate-key"))) + .isSameAs(failure); + verify(workflow).compensateReservation("100", 55L, failure); + } + private void stubCurrent(String destinationType, String containerNo) { InboundPlan plan = plan(containerNo); InboundPlanLine line = line(destinationType); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java index 6099cda63..c3df43abc 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java @@ -71,6 +71,7 @@ class TaillegRealConsumerAcceptanceTest { } public long reserve(String customerId, long warehouseId, long palletId, long locationId, String businessType, String businessId, String reservationKey) { return 1L; } + public void release(String customerId, long resourceLockId) { } public void scrap(String customerId, long resourceLockId) { } public void scrapPallet(String customerId, long palletId) { } public WarehousePalletReference resolvePallet(String palletCode) { From 4f2abf2894530708c3ae1260c9fa7bbffe19d7dc Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 18:10:31 +0800 Subject: [PATCH 042/115] test(lastleg): type receiving mapper inserts --- .../service/ReceivingContainerServiceBehaviorTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 6fa1bee5b..c25531ae2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -77,8 +77,8 @@ class ReceivingContainerServiceBehaviorTest { void prepareIdempotencyHashIncludesContainerPath() { when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A"))); when(lines.selectList(any())).thenReturn(List.of()); - when(sessions.insert(any())).thenReturn(1); - when(operations.insert(any())).thenReturn(1); + when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1); + when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); service.prepare("100", "7", "receiver", "CONT-A", new ReceivingContainerRequests.Action("same-key")); ArgumentCaptor saved = ArgumentCaptor.forClass(ReceivingContainerOperation.class); verify(operations).insert(saved.capture()); @@ -103,13 +103,13 @@ class ReceivingContainerServiceBehaviorTest { when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B", new ReceivingContainerRequests.Action("account-lock"))).isInstanceOf(ResponseStatusException.class); - verify(sessions, never()).insert(any()); + verify(sessions, never()).insert(any(ReceivingContainerSession.class)); org.mockito.Mockito.reset(sessions, operations); when(sessions.lockActiveByContainer("100", "100:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN")); assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B", new ReceivingContainerRequests.Action("container-lock"))).isInstanceOf(ResponseStatusException.class); - verify(sessions, never()).insert(any()); + verify(sessions, never()).insert(any(ReceivingContainerSession.class)); } @Test From 111500d0107f2e2e7666460abb975203e6869377 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 18:17:44 +0800 Subject: [PATCH 043/115] test(lastleg): align receiving completion fixtures --- .../service/InboundPdaContainerCompletionTest.java | 5 ++++- .../pda/service/InboundPdaWorkflowServiceTest.java | 11 ++++++++++- .../baiyue/lastleg/relabel/RelabelContractTest.java | 5 ++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java index 50844ce90..30e890846 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java @@ -92,12 +92,15 @@ class InboundPdaContainerCompletionTest { private static InboundPlanLine line(long id) { InboundPlanLine value = new InboundPlanLine(); - value.setId(id); value.setRequiredPackageCount(1); value.setDestinationType("FBA"); + value.setId(id); value.setOrderId(2000L + id); value.setInboundCode("IN-" + id); + value.setRequiredPackageCount(1); value.setDestinationType("FBA"); return value; } private static InboundPlanPalletAllocation allocation(long lineId) { InboundPlanPalletAllocation value = new InboundPlanPalletAllocation(); + value.setId(1000L + lineId); + value.setPlanId(lineId == 101L ? 1L : 2L); value.setPalletId(3000L + lineId); value.setLineId(lineId); value.setPickedPackageCount(1); value.setCountedPackageCount(1); value.setActualVolumeCbm(BigDecimal.ONE); return value; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java index dc7127c2d..3a7267150 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java @@ -12,6 +12,8 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -47,6 +49,7 @@ class InboundPdaWorkflowServiceTest { @Mock WarehouseResourceGateway warehouse; @Mock InboundStockReceiptService stockReceipts; @Mock InboundUnloadCompletedOutboxService unloadCompletedOutbox; + @Mock LastlegOrderMapper orders; @Mock UnloadPhotoFileGateway unloadPhotoFiles; private InboundPdaWorkflowService service; @@ -68,6 +71,7 @@ class InboundPdaWorkflowServiceTest { ReflectionTestUtils.setField(service, "warehouse", warehouse); ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); ReflectionTestUtils.setField(service, "unloadCompletedOutbox", unloadCompletedOutbox); + ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "unloadPhotoFiles", unloadPhotoFiles); ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules()); service.initializeObjectMapper(); @@ -82,6 +86,9 @@ class InboundPdaWorkflowServiceTest { source = allocation(32L, sourceLine.getId(), sourcePallet.getId(), 2, 2, 2); otherWarehouse = allocation(33L, sourceLine.getId(), otherWarehousePallet.getId(), 2, 2, 2); lenient().when(repository.lockPlan("100", "LIP1")).thenReturn(plan); + LastlegOrder sourceOrder = new LastlegOrder(); + sourceOrder.setId(1001L); sourceOrder.setSourceContainerBookingId("BOOKING-1"); + lenient().when(orders.selectById(1001L)).thenReturn(sourceOrder); } @Test @@ -436,7 +443,8 @@ class InboundPdaWorkflowServiceTest { verify(repository).update(plan); verify(stockReceipts).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(targetLine)), eq(List.of(target, moved)), any(), any()); - verify(unloadCompletedOutbox).publish(eq(plan), eq(task), eq(List.of(101L))); + verify(unloadCompletedOutbox).publishContainerCompleted(eq("100"), eq("BOOKING-1"), eq("CONT-1"), + any(), any(), anyLong()); } @Test @@ -497,6 +505,7 @@ class InboundPdaWorkflowServiceTest { value.setCustomerId("100"); value.setPlanNo("LIP1"); value.setContainerNo("CONT-1"); + value.setSourceOrderId(1001L); value.setStatus("ASSIGNED"); return value; } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java index 40138a939..82af72c70 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java @@ -51,6 +51,9 @@ class RelabelContractTest { assertTrue(openApi.contains("platform:overseas-warehouse:relabel-order:status")); String relabelPaths = openApi.substring(openApi.indexOf("/api/platform/v1/last-leg/relabel-applications:"), openApi.indexOf("components:")); - assertFalse(relabelPaths.contains("name: customerId")); + assertTrue(relabelPaths.contains("name: customerId, in: query, required: true")); + String relabelCreate = openApi.substring(openApi.indexOf(" RelabelCreate:"), + openApi.indexOf(" RelabelItem:")); + assertFalse(relabelCreate.contains("customerId")); } } From 360115de35c796706d8cf4d4207ccba90e9b6975 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 18:18:08 +0800 Subject: [PATCH 044/115] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E9=A2=84=E8=A3=85=E5=90=8E=E9=87=8D=E6=96=B0=E5=88=86=E9=85=8D?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../firstleg/service/SmartLoadingService.java | 10 ++++++-- .../split/service/SplitPreloadService.java | 19 ++++++++++++++- .../service/SmartLoadingServiceTest.java | 6 ++++- .../service/SplitPreloadServiceTest.java | 23 +++++++++++++++++++ 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 7fa07edcb..fefa398ca 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -136,8 +136,14 @@ public class SmartLoadingService { if (candidate == null) throw conflict("入库货物不存在或尚未完成入库"); requireSameChannel(booking, candidate); requireSameCustomerScope(booking, candidate); - splitPreloadService.ensurePendingForLoadingAllocation(candidate.accountId(), candidate.inboundId(), - operatorId, operatorName); + var task = loadingPickService.mutationSnapshot(planId); + if (task.taskId() != null && "LOADING".equals(task.taskStatus())) { + splitPreloadService.ensureForRunningLoadingAllocation(candidate.accountId(), candidate.inboundId(), + operatorId, operatorName); + } else { + splitPreloadService.ensurePendingForLoadingAllocation(candidate.accountId(), candidate.inboundId(), + operatorId, operatorName); + } OrderCargoClient.CargoBinding cargo = loadCargo(candidate); LoadingPlanItemEntity existing = repository.activeItem(planId, candidate.accountId(), request.inboundDetailId()); int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java index 70146cf38..778d1ce6d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java @@ -68,11 +68,28 @@ public class SplitPreloadService { @Transactional public void ensurePendingForLoadingAllocation(long tenantId, long inboundId, long operatorId, String operatorName) { + ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName, false); + } + + /** + * A running PDA task may restore or add an unpicked allocation after the plan-level preload + * confirmation. CONFIRMING remains immutable; CONFIRMED is accepted because the caller owns + * the active PDA task lock and will synchronise the restored loading item immediately. + */ + @Transactional + public void ensureForRunningLoadingAllocation(long tenantId, long inboundId, long operatorId, + String operatorName) { + ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName, true); + } + + private void ensureLoadingAllocationLifecycle(long tenantId, long inboundId, long operatorId, + String operatorName, boolean allowConfirmed) { InboundSnapshot inbound = requireInbound(tenantId, inboundId); requireWaitingSplit(inbound, "仅待分柜订单可以建立智能装柜预装计划"); PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId); if (plan != null) { - if (!PreloadStatus.PENDING.equals(plan.getStatus())) { + if (!PreloadStatus.PENDING.equals(plan.getStatus()) + && !(allowConfirmed && PreloadStatus.CONFIRMED.equals(plan.getStatus()))) { throw new BizException(ResultCode.CONFLICT, "预装计划已进入确认流程,不能修改智能装柜分配"); } return; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index a86f7b75f..5869f9f9c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -358,6 +358,8 @@ class SmartLoadingServiceTest { when(repository.items(1)).thenReturn(List.of()); when(repository.candidate(100, 10, 20)).thenReturn(candidate(10)); when(repository.activeItem(1L, 9L, 20L)).thenReturn(null); + when(loadingPickService.mutationSnapshot(1L)).thenReturn( + new LoadingPickService.MutationSnapshot(77L, "LOADING", 6, Map.of())); when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(ApiResponse.success( new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 1, BigDecimal.ONE, BigDecimal.ONE, null, null, null, null, null, null))); @@ -365,12 +367,14 @@ class SmartLoadingServiceTest { Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-loading"), 7, "u"); assertThat(result.status()).isEqualTo("LOADING"); - var order = inOrder(loadingPickService, repository, loadingModelBridge); + var order = inOrder(loadingPickService, splitPreloadService, repository, loadingModelBridge); order.verify(loadingPickService).lockMutable(1L); + order.verify(splitPreloadService).ensureForRunningLoadingAllocation(9L, 10L, 7L, "u"); order.verify(repository).touch(1, 5, 7, "u"); order.verify(loadingModelBridge).bindIncrementalAllocation(1L, 9L, 10L, 7L, "u", "assign-loading"); order.verify(loadingPickService).sync(eq(1L), any(LoadingPlanItemEntity.class)); + verify(splitPreloadService, never()).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java index 267aaaf36..ff1839740 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java @@ -80,4 +80,27 @@ class SplitPreloadServiceTest { verify(repository, never()).createPlan(any()); } + @Test + void runningPdaAllocationMayReuseConfirmedPreload() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3)); + PreloadPlanEntity plan = new PreloadPlanEntity(); + plan.setStatus("CONFIRMED"); + when(repository.findPlan(10, 20)).thenReturn(plan); + + service.ensureForRunningLoadingAllocation(10, 20, 7, "admin"); + + verify(repository, never()).createPlan(any()); + } + + @Test + void runningPdaAllocationStillRejectsConfirmingPreload() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3)); + PreloadPlanEntity plan = new PreloadPlanEntity(); + plan.setStatus("CONFIRMING"); + when(repository.findPlan(10, 20)).thenReturn(plan); + + assertThatThrownBy(() -> service.ensureForRunningLoadingAllocation(10, 20, 7, "admin")) + .hasMessageContaining("已进入确认流程"); + } + } From 7e080622f3281c2f98bf75ff92fd8a2534143c8d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 19:15:15 +0800 Subject: [PATCH 045/115] fix(ci): isolate event bridge region parameter --- Jenkinsfile | 2 +- scripts/controller-pipeline-contract.test.mjs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Jenkinsfile b/Jenkinsfile index b8084642e..a06ba1017 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -236,7 +236,7 @@ pipeline { : env.IMAGE_TAG String downstreamBridgeRegion = deployBothBridges ? 'CN' - : params.EVENT_BRIDGE_REGION + : (service == 'event-bridge-service' ? params.EVENT_BRIDGE_REGION : 'CN') echo "触发单服务 Job: ${jobName}" def downstreamBuild = build( job: jobName, diff --git a/scripts/controller-pipeline-contract.test.mjs b/scripts/controller-pipeline-contract.test.mjs index 2248b09e6..9ee3dd23c 100644 --- a/scripts/controller-pipeline-contract.test.mjs +++ b/scripts/controller-pipeline-contract.test.mjs @@ -49,5 +49,6 @@ test('coordinator defaults event bridge deployments to both non-production regio assert.match(pipeline, /事件桥 BOTH 仅支持开发或测试环境/) assert.match(pipeline, /String downstreamImageTag = deployBothBridges[\s\S]*?\? "\$\{env\.IMAGE_TAG\}-both"/) assert.match(pipeline, /String downstreamBridgeRegion = deployBothBridges[\s\S]*?\? 'CN'/) + assert.match(pipeline, /service == 'event-bridge-service' \? params\.EVENT_BRIDGE_REGION : 'CN'/) assert.match(pipeline, /string\(name: 'EVENT_BRIDGE_REGION', value: downstreamBridgeRegion\)/) }) From 551560efda7bb28da47e3f8720b9598dfc93c693 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 19:23:20 +0800 Subject: [PATCH 046/115] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pda/dto/ReceivingContainerRequests.java | 9 ++++-- .../pda/dto/ReceivingContainerResponses.java | 11 ++++++- .../pda/dto/ReceivingPhotoRole.java | 7 +++++ .../pda/entity/ReceivingContainerSession.java | 2 +- .../pda/entity/ReceivingSkuReceipt.java | 30 +++++++++++++++++++ .../pda/mapper/ReceivingSkuReceiptMapper.java | 23 ++++++++++++++ 6 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingPhotoRole.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingSkuReceipt.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java index 093a03cce..9747c976f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java @@ -1,7 +1,9 @@ package com.baiyue.lastleg.inboundplan.pda.dto; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; @@ -11,11 +13,14 @@ public final class ReceivingContainerRequests { private ReceivingContainerRequests() { } public record Action(@NotBlank @Size(max = 255) String idempotencyKey) { } - public record Sign(@NotEmpty List<@Positive Long> photoFileIds, + public record Sign(@Size(min = 3, max = 3) List<@Valid SignedPhoto> photos, @NotBlank @Size(max = 255) String idempotencyKey) { } + public record SignedPhoto(@NotNull ReceivingPhotoRole role, @Positive long fileId) { } public record CartonResolve(@NotBlank String cartonMark) { } + public record SkuResolve(@NotBlank @Size(max = 128) String skuCode) { } public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { } - public record BulkReceive(@NotBlank String inboundCode, @NotBlank String palletCode, + public record BulkReceive(@NotBlank String inboundCode, @NotBlank @Size(max = 128) String skuCode, + @NotBlank String palletCode, @Positive int packageCount, @NotBlank @Size(max = 255) String idempotencyKey) { } public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode, @NotBlank @Size(max = 255) String idempotencyKey) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java index 7a1acf4cb..385113cf1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java @@ -7,14 +7,17 @@ public final class ReceivingContainerResponses { private ReceivingContainerResponses() { } public record Candidate(String containerNo, String status, int planCount, int inboundCodeCount, + List destinationCodes, int plannedPalletCount, int totalPackages, int receivedPackages, boolean currentAccount) { } public record Session(String containerNo, String status, String operatorName, int version, - List photoFileIds, LocalDateTime preparedAt, LocalDateTime signedAt, + List photos, LocalDateTime preparedAt, LocalDateTime signedAt, LocalDateTime receivingAt, LocalDateTime completedAt, int totalPackages, int receivedPackages, int pendingPackages, List inbounds) { } + public record SignedPhoto(ReceivingPhotoRole role, long fileId) { } + public record Inbound(String inboundCode, String destinationType, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String fbaWarehouseCode, int plannedPalletCount, int totalPackages, @@ -26,6 +29,12 @@ public final class ReceivingContainerResponses { String fbaWarehouseCode, int plannedPalletCount, int totalPackages, int receivedPackages, int pendingPackages) { } + public record SkuResolved(String skuCode, String productName, String inboundCode, + String destinationType, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + String fbaWarehouseCode, int plannedPalletCount, int totalPackages, + int receivedPackages, int pendingPackages) { } + public record PalletResolved(String palletCode, long warehouseId, String status, String fbaWarehouseCode, String inboundCode, String destinationType, boolean compatible) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingPhotoRole.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingPhotoRole.java new file mode 100644 index 000000000..211113644 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingPhotoRole.java @@ -0,0 +1,7 @@ +package com.baiyue.lastleg.inboundplan.pda.dto; + +public enum ReceivingPhotoRole { + LICENSE_PLATE, + CONTAINER_SEAL, + DOOR_OPEN +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java index e2362227a..68a074b0b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java @@ -20,7 +20,7 @@ public class ReceivingContainerSession { private String activeOperatorKey; private String activeContainerKey; private String status; - private String photoFileIds; + private String signPhotos; private LocalDateTime preparedAt; private LocalDateTime signedAt; private LocalDateTime receivingAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingSkuReceipt.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingSkuReceipt.java new file mode 100644 index 000000000..91ab80bf0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingSkuReceipt.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.inboundplan.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.Version; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_receiving_sku_receipt") +public class ReceivingSkuReceipt { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private Long sessionId; + private Long planId; + private Long lineId; + private Long palletId; + private Long orderId; + private String inboundCode; + private String skuCode; + private String productName; + private Integer receivedPackages; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + @Version + private Integer version; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java new file mode 100644 index 000000000..6e444f8e1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface ReceivingSkuReceiptMapper extends BaseMapper { + @Select("SELECT * FROM ll_receiving_sku_receipt WHERE customer_id=#{customerId} " + + "AND line_id=#{lineId} AND sku_code=#{skuCode} ORDER BY id FOR UPDATE") + List lockLineSku(@Param("customerId") String customerId, + @Param("lineId") Long lineId, + @Param("skuCode") String skuCode); + + @Select("SELECT * FROM ll_receiving_sku_receipt WHERE customer_id=#{customerId} " + + "AND plan_id=#{planId} ORDER BY line_id,pallet_id,sku_code,id") + List listPlan(@Param("customerId") String customerId, + @Param("planId") Long planId); +} From 5c54785d7717f0c15a2de5ec8e92427ac3f21165 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 19:28:32 +0800 Subject: [PATCH 047/115] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/海外区域统一增量-MySQL8.sql | 24 ++- .../ReceivingContainerController.java | 8 + .../pda/mapper/ReceivingSkuReceiptMapper.java | 6 + .../service/InboundStockReceiptService.java | 54 ++++++ .../service/ReceivingContainerService.java | 174 ++++++++++++++++-- .../src/main/resources/db/lastleg.sql | 16 +- 6 files changed, 266 insertions(+), 16 deletions(-) diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 3c8be6dd6..3c07f7500 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -390,13 +390,35 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话账号唯一锁键', active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', - photo_file_ids JSON NULL COMMENT '签收卸柜照片文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间', + sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间', receiving_at DATETIME(3) 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 NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id), UNIQUE KEY uk_ll_receiving_active_operator(active_operator_key), UNIQUE KEY uk_ll_receiving_active_container(active_container_key), KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at) ) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='sign_photos'), + 'ALTER TABLE ll_receiving_container_session MODIFY COLUMN sign_photos JSON NULL COMMENT ''签收卸柜照片角色与文件ID列表''', + 'ALTER TABLE ll_receiving_container_session ADD COLUMN sign_photos JSON NULL COMMENT ''签收卸柜照片角色与文件ID列表'' AFTER status')); +PREPARE phase4_receiving_sign_photos_stmt FROM @ddl; EXECUTE phase4_receiving_sign_photos_stmt; DEALLOCATE PREPARE phase4_receiving_sign_photos_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='photo_file_ids'), + 'ALTER TABLE ll_receiving_container_session DROP COLUMN photo_file_ids', 'SELECT 1')); +PREPARE phase4_receiving_drop_raw_photos_stmt FROM @ddl; EXECUTE phase4_receiving_drop_raw_photos_stmt; DEALLOCATE PREPARE phase4_receiving_drop_raw_photos_stmt; + +CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt ( + id BIGINT NOT NULL COMMENT 'FBA SKU实收进度ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', plan_id BIGINT NOT NULL COMMENT '入仓计划ID', + line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码快照', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', product_name VARCHAR(255) NULL COMMENT '产品名称快照', + received_packages INT NOT NULL COMMENT 'SKU实收箱数', 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 NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code), + KEY idx_ll_receiving_sku_session (customer_id,session_id,line_id), + KEY idx_ll_receiving_sku_plan (customer_id,plan_id,line_id) +) ENGINE=InnoDB COMMENT='海外PDA柜号FBA SKU实收进度'; + CREATE TABLE IF NOT EXISTS ll_receiving_container_operation ( id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java index 47ba2aae9..fa2816d4f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -86,6 +86,14 @@ public class ReceivingContainerController { return ApiResponse.success(service.resolveCarton(identity.customerId(), identity.operatorKey(), request)); } + @PostMapping("/current/skus/resolve") + public ApiResponse resolveSku( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.SkuResolve request) { + Identity identity = identity(user); + return ApiResponse.success(service.resolveSku(identity.customerId(), identity.operatorKey(), request)); + } + @PostMapping("/current/pallets/resolve") public ApiResponse resolvePallet( @AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java index 6e444f8e1..fa28ae74e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java @@ -16,6 +16,12 @@ public interface ReceivingSkuReceiptMapper extends BaseMapper listLineSku(@Param("customerId") String customerId, + @Param("lineId") Long lineId, + @Param("skuCode") String skuCode); + @Select("SELECT * FROM ll_receiving_sku_receipt WHERE customer_id=#{customerId} " + "AND plan_id=#{planId} ORDER BY line_id,pallet_id,sku_code,id") List listPlan(@Param("customerId") String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java index 5bb25ffc9..f236833d4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -9,7 +9,9 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt; import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; @@ -38,6 +40,7 @@ public class InboundStockReceiptService { @Resource private LastlegOrderMapper orders; @Resource private LastlegOrderDetailMapper orderDetails; @Resource private InboundStockReceiptMapper receipts; + @Resource private ReceivingSkuReceiptMapper receivingSkuReceipts; @Resource private InventoryCommandService inventory; @Resource private DropshipOrderService dropshipOrders; @Resource private LastlegOrderStatusHistoryMapper orderHistories; @@ -56,6 +59,9 @@ public class InboundStockReceiptService { .filter(allocation -> value(allocation.getPickedPackageCount()) > 0) .sorted(Comparator.comparing(InboundPlanPalletAllocation::getId)) .collect(Collectors.groupingBy(InboundPlanPalletAllocation::getLineId)); + Map> skuReceiptsByLine = receivingSkuReceipts + .listPlan(customerId, plan.getId()).stream() + .collect(Collectors.groupingBy(ReceivingSkuReceipt::getLineId)); Set expectedAllocationIds = allocationsByLine.values().stream().flatMap(List::stream) .map(InboundPlanPalletAllocation::getId).collect(Collectors.toSet()); @@ -82,6 +88,12 @@ public class InboundStockReceiptService { if (pickedPackages != value(line.getRequiredPackageCount())) { throw conflict("PDA picked packages must equal the inbound order package total"); } + List exactSkuReceipts = skuReceiptsByLine.getOrDefault(line.getId(), List.of()); + if (!exactSkuReceipts.isEmpty()) { + created.addAll(persistExactSkuReceipts(customerId, plan, task, line, lineAllocations, + palletById, order, exactSkuReceipts, pickedPackages, receivedAt)); + continue; + } List skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType()) ? List.of() : skuRemainders(order.getId(), pickedPackages); for (InboundPlanPalletAllocation allocation : lineAllocations) { @@ -104,6 +116,48 @@ public class InboundStockReceiptService { completeOrders(customerId, plan, orderById, receivedAt); } + private List persistExactSkuReceipts(String customerId, InboundPlan plan, + InboundPdaTask task, InboundPlanLine line, List lineAllocations, + Map palletById, LastlegOrder order, + List exactReceipts, long pickedPackages, LocalDateTime receivedAt) { + long actualPackages = exactReceipts.stream().mapToLong(value -> value(value.getReceivedPackages())).sum(); + if (actualPackages != pickedPackages) throw conflict("FBA SKU receipts must equal the picked package total"); + + Map expectedBySku = skuRemainders(order.getId(), pickedPackages).stream() + .collect(Collectors.toMap(value -> value.skuCode, value -> value.remaining, Long::sum, + java.util.LinkedHashMap::new)); + Map actualBySku = exactReceipts.stream().collect(Collectors.groupingBy( + value -> value.getSkuCode().toUpperCase(java.util.Locale.ROOT), java.util.LinkedHashMap::new, + Collectors.summingLong(value -> value(value.getReceivedPackages())))); + Map normalizedExpected = expectedBySku.entrySet().stream().collect(Collectors.toMap( + value -> value.getKey().toUpperCase(java.util.Locale.ROOT), Map.Entry::getValue, Long::sum, + java.util.LinkedHashMap::new)); + if (!actualBySku.equals(normalizedExpected)) { + throw conflict("FBA SKU receipts must exactly match the frozen SKU package snapshots"); + } + + Map allocationByPallet = lineAllocations.stream() + .collect(Collectors.toMap(InboundPlanPalletAllocation::getPalletId, value -> value)); + Map> receiptsByPallet = exactReceipts.stream() + .collect(Collectors.groupingBy(ReceivingSkuReceipt::getPalletId)); + List created = new java.util.ArrayList<>(); + for (Map.Entry> entry : receiptsByPallet.entrySet()) { + InboundPlanPalletAllocation allocation = allocationByPallet.get(entry.getKey()); + if (allocation == null) throw conflict("FBA SKU receipt pallet has no matching allocation"); + long palletPackages = entry.getValue().stream().mapToLong(value -> value(value.getReceivedPackages())).sum(); + if (palletPackages != value(allocation.getPickedPackageCount())) { + throw conflict("FBA SKU receipt pallet total must match the allocation total"); + } + InboundPlanPallet pallet = requirePallet(palletById, allocation); + for (ReceivingSkuReceipt exact : entry.getValue()) { + created.add(persist(customerId, plan, task, line, allocation, pallet, order, + new SkuSnapshot(exact.getSkuCode(), exact.getProductName(), null, + value(exact.getReceivedPackages())), receivedAt)); + } + } + return created; + } + private void completeOrders(String customerId, InboundPlan plan, Map orderById, LocalDateTime completedAt) { for (LastlegOrder snapshot : orderById.values()) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index c7cdf8f7a..124ddec0d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.pda.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; @@ -18,10 +19,13 @@ import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses; import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation; import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt; import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper; import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; @@ -47,6 +51,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; @Service public class ReceivingContainerService { @@ -56,8 +62,10 @@ public class ReceivingContainerService { @Resource private InboundPlanPalletAllocationMapper allocations; @Resource private InboundPlanCartonMapper cartons; @Resource private LastlegOrderMapper orders; + @Resource private LastlegOrderDetailMapper orderDetails; @Resource private ReceivingContainerSessionMapper sessions; @Resource private ReceivingContainerOperationMapper operations; + @Resource private ReceivingSkuReceiptMapper skuReceipts; @Resource private WarehouseResourceGateway warehouse; @Resource private UnloadPhotoFileGateway unloadPhotos; @Resource private InboundPdaWorkflowService workflow; @@ -85,7 +93,8 @@ public class ReceivingContainerService { Totals totals = totals(entry.getValue()); boolean active = current != null && entry.getKey().equals(current.getContainerNo()); return new ReceivingContainerResponses.Candidate(entry.getKey(), active ? current.getStatus() : "WAIT_SIGN", - entry.getValue().size(), totals.inboundCount(), totals.total(), totals.received(), active); + entry.getValue().size(), totals.inboundCount(), totals.destinationCodes(), totals.plannedPalletCount(), + totals.total(), totals.received(), active); }).toList(); } @@ -144,14 +153,14 @@ public class ReceivingContainerService { if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true); if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed"); - List photos = request.photoFileIds().stream().distinct().sorted().toList(); - if (photos.size() != request.photoFileIds().size()) throw conflict("Unload photo file IDs cannot be duplicated"); - photos.forEach(fileId -> unloadPhotos.validate(customerId, session.getId(), fileId)); + List photos = validateSignPhotos(request); + List photoFileIds = photos.stream().map(ReceivingContainerResponses.SignedPhoto::fileId).toList(); + photoFileIds.forEach(fileId -> unloadPhotos.validate(customerId, session.getId(), fileId)); LocalDateTime now = LocalDateTime.now(); - session.setPhotoFileIds(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now); + session.setSignPhotos(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now); session.setOperatorName(operatorName); session.setUpdatedAt(now); update(session); for (InboundPlan plan : requirePlans(customerId, session.getContainerNo())) { - workflow.ensureSignedTask(customerId, plan, operatorKey, operatorName, photos, now); + workflow.ensureSignedTask(customerId, plan, operatorKey, operatorName, photoFileIds, now); } ReceivingContainerResponses.Session result = snapshot(session, requirePlans(customerId, session.getContainerNo())); save(session, "SIGN", request.idempotencyKey(), request, result); return result; @@ -181,6 +190,19 @@ public class ReceivingContainerService { inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages()); } + public ReceivingContainerResponses.SkuResolved resolveSku(String customerId, String operatorKey, + ReceivingContainerRequests.SkuResolve request) { + ReceivingContainerSession session = requireSigned(customerId, operatorKey); + List matches = refs(customerId, session.getContainerNo()).stream() + .filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))) + .map(ref -> skuRef(ref, request.skuCode())) + .filter(Objects::nonNull) + .toList(); + if (matches.isEmpty()) throw notFound("SKU was not found in the current FBA container"); + if (matches.size() != 1) throw conflict("SKU belongs to multiple inbound codes in the current container"); + return skuResponse(matches.get(0)); + } + public ReceivingContainerResponses.PalletResolved resolvePallet(String customerId, String operatorKey, ReceivingContainerRequests.PalletResolve request) { ReceivingContainerSession session = requireSigned(customerId, operatorKey); @@ -201,14 +223,17 @@ public class ReceivingContainerService { if (!"FBA".equals(destinationType(ref.line(), ref.order()))) { throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden"); } + SkuRef sku = requireSku(ref, request.skuCode()); + validateSkuPending(customerId, sku, request.packageCount()); + workflow.validateBulkResolved(customerId, ref.plan(), ref.line(), request.packageCount()); WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode()); validatePallet(ref, resource); InboundPlanPallet pallet = choosePallet(ref, resource); - workflow.validateBulkResolved(customerId, ref.plan(), ref.line(), request.packageCount()); Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); try { int accepted = workflow.receiveBulkResolved(customerId, ref.plan(), ref.line(), pallet, request.packageCount(), operatorKey, operatorName); + recordSkuReceipt(session, sku, pallet, accepted); markReceiving(session); ReceivingContainerResponses.Inbound inbound = inbound(ref); ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", @@ -267,6 +292,7 @@ public class ReceivingContainerService { List containerPlans = requirePlans(customerId, session.getContainerNo()); Totals totals = totals(containerPlans); if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container"); + validateFbaSkuBalance(customerId, session.getContainerNo()); workflow.completeContainer(customerId, containerPlans); LocalDateTime now = LocalDateTime.now(); session.setStatus("COMPLETED"); session.setCompletedAt(now); session.setUpdatedAt(now); @@ -383,7 +409,7 @@ public class ReceivingContainerService { int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(); int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum(); return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getStatus(), session.getOperatorName(), - value(session.getVersion()), photoIds(session), session.getPreparedAt(), session.getSignedAt(), + value(session.getVersion()), signPhotos(session), session.getPreparedAt(), session.getSignedAt(), session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), inbounds); } @@ -403,10 +429,125 @@ public class ReceivingContainerService { private Totals totals(List containerPlans) { List values = refs(containerPlans.get(0).getCustomerId(), containerPlans.get(0).getContainerNo()).stream().map(this::inbound).toList(); - return new Totals(values.size(), values.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(), + List destinationCodes = values.stream().map(this::destinationCode) + .filter(Objects::nonNull).distinct().sorted().toList(); + return new Totals(values.size(), destinationCodes, + values.stream().mapToInt(ReceivingContainerResponses.Inbound::plannedPalletCount).sum(), + values.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(), values.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum()); } + private String destinationCode(ReceivingContainerResponses.Inbound inbound) { + return trim("FBA".equals(inbound.destinationType()) ? inbound.fbaWarehouseCode() + : inbound.destinationWarehouseCode()); + } + + private List validateSignPhotos(ReceivingContainerRequests.Sign request) { + if (request.photos() == null || request.photos().size() != 3) { + throw conflict("Exactly three typed unload photos are required"); + } + if (request.photos().stream().anyMatch(Objects::isNull)) throw conflict("Unload photo cannot be null"); + Set roles = request.photos().stream() + .map(ReceivingContainerRequests.SignedPhoto::role).collect(Collectors.toSet()); + if (roles.size() != 3 || roles.contains(null)) throw conflict("Each unload photo role is required exactly once"); + Set fileIds = request.photos().stream().map(ReceivingContainerRequests.SignedPhoto::fileId) + .boxed().collect(Collectors.toSet()); + if (fileIds.size() != 3 || fileIds.stream().anyMatch(value -> value == null || value <= 0)) { + throw conflict("Unload photo file IDs must be positive and unique"); + } + return request.photos().stream() + .sorted(Comparator.comparing(value -> value.role().ordinal())) + .map(value -> new ReceivingContainerResponses.SignedPhoto(value.role(), value.fileId())).toList(); + } + + private SkuRef requireSku(InboundRef ref, String skuCode) { + SkuRef result = skuRef(ref, skuCode); + if (result == null) throw notFound("SKU is not part of the selected inbound code"); + return result; + } + + private SkuRef skuRef(InboundRef ref, String skuCode) { + String requested = trim(skuCode); + if (requested == null) return null; + List details = orderDetails.selectList(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, ref.order().getCustomerId()) + .eq(LastlegOrderDetail::getOrderId, ref.order().getId()).orderByAsc(LastlegOrderDetail::getId)); + List matched = details.stream() + .filter(detail -> requested.equalsIgnoreCase(trim(detail.getSkuCode()))).toList(); + int expected = matched.stream().mapToInt(detail -> value(detail.getPackageCount())).sum(); + if (expected <= 0) return null; + String canonicalSku = matched.stream().map(LastlegOrderDetail::getSkuCode).map(ReceivingContainerService::trim) + .filter(Objects::nonNull).findFirst().orElse(requested); + String productName = matched.stream().map(detail -> first(detail.getProductNameZh(), detail.getProductNameEn())) + .filter(Objects::nonNull).findFirst().orElse(null); + return new SkuRef(ref, canonicalSku, productName, expected); + } + + private ReceivingContainerResponses.SkuResolved skuResponse(SkuRef sku) { + ReceivingContainerResponses.Inbound inbound = inbound(sku.ref()); + int received = receivedSkuPackages(sku.ref().order().getCustomerId(), sku.ref().line().getId(), sku.skuCode()); + return new ReceivingContainerResponses.SkuResolved(sku.skuCode(), sku.productName(), inbound.inboundCode(), + inbound.destinationType(), inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(), + inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), inbound.plannedPalletCount(), + sku.expectedPackages(), received, Math.max(0, sku.expectedPackages() - received)); + } + + private void validateSkuPending(String customerId, SkuRef sku, int packageCount) { + int received = receivedSkuPackages(customerId, sku.ref().line().getId(), sku.skuCode()); + if (packageCount <= 0 || packageCount > sku.expectedPackages() - received) { + throw conflict("Received package count exceeds the SKU pending total"); + } + } + + private int receivedSkuPackages(String customerId, Long lineId, String skuCode) { + return skuReceipts.listLineSku(customerId, lineId, skuCode).stream() + .mapToInt(value -> value(value.getReceivedPackages())).sum(); + } + + private void recordSkuReceipt(ReceivingContainerSession session, SkuRef sku, InboundPlanPallet pallet, int packages) { + List existing = skuReceipts.lockLineSku(session.getCustomerId(), + sku.ref().line().getId(), sku.skuCode()); + ReceivingSkuReceipt receipt = existing.stream() + .filter(value -> Objects.equals(value.getPalletId(), pallet.getId())).findFirst().orElse(null); + LocalDateTime now = LocalDateTime.now(); + if (receipt == null) { + receipt = new ReceivingSkuReceipt(); receipt.setId(IdWorker.getId()); + receipt.setCustomerId(session.getCustomerId()); receipt.setSessionId(session.getId()); + receipt.setPlanId(sku.ref().plan().getId()); receipt.setLineId(sku.ref().line().getId()); + receipt.setPalletId(pallet.getId()); receipt.setOrderId(sku.ref().order().getId()); + receipt.setInboundCode(sku.ref().line().getInboundCode()); receipt.setSkuCode(sku.skuCode()); + receipt.setProductName(sku.productName()); receipt.setReceivedPackages(packages); + receipt.setCreatedAt(now); receipt.setUpdatedAt(now); receipt.setVersion(0); + skuReceipts.insert(receipt); + } else { + receipt.setReceivedPackages(value(receipt.getReceivedPackages()) + packages); + receipt.setUpdatedAt(now); + if (skuReceipts.updateById(receipt) != 1) throw conflict("SKU receiving progress changed concurrently"); + } + } + + private void validateFbaSkuBalance(String customerId, String containerNo) { + for (InboundRef ref : refs(customerId, containerNo)) { + if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue; + List details = orderDetails.selectList(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, customerId).eq(LastlegOrderDetail::getOrderId, ref.order().getId())); + Set skuCodes = details.stream().map(LastlegOrderDetail::getSkuCode) + .map(ReceivingContainerService::trim).filter(Objects::nonNull) + .collect(Collectors.toCollection(java.util.LinkedHashSet::new)); + int expected = 0; + int received = 0; + for (String skuCode : skuCodes) { + SkuRef sku = requireSku(ref, skuCode); + int skuReceived = receivedSkuPackages(customerId, ref.line().getId(), sku.skuCode()); + if (skuReceived != sku.expectedPackages()) throw conflict("Every FBA SKU must be fully received before completing the container"); + expected += sku.expectedPackages(); received += skuReceived; + } + if (skuCodes.isEmpty() || expected != value(ref.line().getRequiredPackageCount()) || received != expected) { + throw conflict("FBA SKU package snapshots must exactly match the inbound code total"); + } + } + } + private String destinationType(InboundPlanLine line, LastlegOrder order) { String value = line.getDestinationType(); if (value == null || value.isBlank()) value = order.getDestinationType(); @@ -453,10 +594,11 @@ public class ReceivingContainerService { value.setCreatedAt(LocalDateTime.now()); operations.insert(value); } - private List photoIds(ReceivingContainerSession session) { - if (session.getPhotoFileIds() == null || session.getPhotoFileIds().isBlank()) return List.of(); - try { return objectMapper.readValue(session.getPhotoFileIds(), new TypeReference>() { }); } - catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read receiving photo IDs", exception); } + private List signPhotos(ReceivingContainerSession session) { + if (session.getSignPhotos() == null || session.getSignPhotos().isBlank()) return List.of(); + try { return objectMapper.readValue(session.getSignPhotos(), + new TypeReference>() { }); } + catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read signed receiving photos", exception); } } private String hash(Object value) { @@ -484,10 +626,14 @@ public class ReceivingContainerService { return value; } private static int value(Integer value) { return value == null ? 0 : value; } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String first(String first, String second) { String value = trim(first); return value == null ? trim(second) : value; } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } private record InboundRef(InboundPlan plan, InboundPlanLine line, LastlegOrder order) { } - private record Totals(int inboundCount, int total, int received) { } + private record SkuRef(InboundRef ref, String skuCode, String productName, int expectedPackages) { } + private record Totals(int inboundCount, List destinationCodes, int plannedPalletCount, + int total, int received) { } private record PrepareCommand(String containerNo, String idempotencyKey) { } } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index c02cca9d0..430e98bf9 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -682,7 +682,7 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话账号唯一锁键', active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', - photo_file_ids JSON NULL COMMENT '签收卸柜照片文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', + sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间', receiving_at DATETIME(3) 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 NOT NULL DEFAULT 0 COMMENT '乐观锁版本', @@ -690,6 +690,20 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at) ) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话'; +CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt ( + id BIGINT NOT NULL COMMENT 'FBA SKU实收进度ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', plan_id BIGINT NOT NULL COMMENT '入仓计划ID', + line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码快照', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', product_name VARCHAR(255) NULL COMMENT '产品名称快照', + received_packages INT NOT NULL COMMENT 'SKU实收箱数', 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 NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code), + KEY idx_ll_receiving_sku_session (customer_id,session_id,line_id), + KEY idx_ll_receiving_sku_plan (customer_id,plan_id,line_id) +) ENGINE=InnoDB COMMENT='海外PDA柜号FBA SKU实收进度'; + CREATE TABLE IF NOT EXISTS ll_receiving_container_operation ( id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', From 89bd746dbd822d41583eab354839ad60adc733de Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 19:29:39 +0800 Subject: [PATCH 048/115] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openapi/overseas-business-v1.yaml | 44 ++++++++++++++++--- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 17646d664..46c384e62 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -34,7 +34,7 @@ paths: /api/pda/v1/last-leg/receiving-containers/current/sign: post: summary: 签收当前柜 - description: 权限 lastleg:pda:operate;校验非空照片归属后由 WAIT_SIGN 推进 SIGNED。 + description: 权限 lastleg:pda:operate;精确校验车牌、封签、开柜三类照片各一次及文件归属后由 WAIT_SIGN 推进 SIGNED。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSign' } } } } responses: { '200': { description: 已签收会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 状态或照片校验失败 } } @@ -51,6 +51,13 @@ paths: security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { type: object, required: [cartonMark], properties: { cartonMark: { type: string } } } } } } responses: { '200': { description: 箱唛与入仓码信息, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCartonResolvedApiResponse' } } } }, '409': { description: 跨柜、重复箱或目的类型不符 } } + /api/pda/v1/last-leg/receiving-containers/current/skus/resolve: + post: + summary: 解析当前柜 FBA SKU + description: 权限 lastleg:pda:operate;仅解析当前已签柜内唯一归属一个入仓码的 FBA SKU,返回冻结箱数与实收进度。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { type: object, required: [skuCode], properties: { skuCode: { type: string, minLength: 1, maxLength: 128 } } } } } } + responses: { '200': { description: SKU与入仓码信息, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSkuResolvedApiResponse' } } } }, '404': { description: A0404:SKU不在当前FBA柜内 }, '409': { description: A0409:SKU跨多个入仓码导致归属不唯一 } } /api/pda/v1/last-leg/receiving-containers/current/pallets/resolve: post: summary: 解析当前柜实物托盘码 @@ -422,15 +429,22 @@ components: properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } } ReceivingSign: type: object - required: [photoFileIds, idempotencyKey] + required: [photos, idempotencyKey] properties: - photoFileIds: { type: array, minItems: 1, uniqueItems: true, items: { type: integer, format: int64 } } + photos: { type: array, minItems: 3, maxItems: 3, items: { $ref: '#/components/schemas/ReceivingSignedPhoto' } } idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + ReceivingSignedPhoto: + type: object + required: [role, fileId] + properties: + role: { type: string, enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN] } + fileId: { type: integer, format: int64, minimum: 1 } ReceivingBulk: type: object - required: [inboundCode, palletCode, packageCount, idempotencyKey] + required: [inboundCode, skuCode, palletCode, packageCount, idempotencyKey] properties: inboundCode: { type: string, minLength: 1 } + skuCode: { type: string, minLength: 1, maxLength: 128 } palletCode: { type: string, minLength: 1 } packageCount: { type: integer, minimum: 1 } idempotencyKey: { type: string, minLength: 1, maxLength: 255 } @@ -466,24 +480,26 @@ components: pendingPackages: { type: integer } ReceivingCandidate: type: object - required: [containerNo, status, planCount, inboundCodeCount, totalPackages, receivedPackages, currentAccount] + required: [containerNo, status, planCount, inboundCodeCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, currentAccount] properties: containerNo: { type: string } status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] } planCount: { type: integer } inboundCodeCount: { type: integer } + destinationCodes: { type: array, uniqueItems: true, items: { type: string } } + plannedPalletCount: { type: integer } totalPackages: { type: integer } receivedPackages: { type: integer } currentAccount: { type: boolean } ReceivingSession: type: object - required: [containerNo, status, version, photoFileIds, totalPackages, receivedPackages, pendingPackages, inbounds] + required: [containerNo, status, version, photos, totalPackages, receivedPackages, pendingPackages, inbounds] properties: containerNo: { type: string } status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] } operatorName: { type: string, nullable: true } version: { type: integer } - photoFileIds: { type: array, items: { type: integer, format: int64 } } + photos: { type: array, maxItems: 3, items: { $ref: '#/components/schemas/ReceivingSignedPhoto' } } preparedAt: { type: string, format: date-time, nullable: true } signedAt: { type: string, format: date-time, nullable: true } receivingAt: { type: string, format: date-time, nullable: true } @@ -508,6 +524,14 @@ components: properties: cartonMark: { type: string } skuCode: { type: string, nullable: true } + ReceivingSkuResolved: + allOf: + - { $ref: '#/components/schemas/ReceivingInbound' } + - type: object + required: [skuCode] + properties: + skuCode: { type: string } + productName: { type: string, nullable: true } ReceivingPalletResolved: type: object required: [palletCode, warehouseId, status, inboundCode, destinationType, compatible] @@ -556,6 +580,12 @@ components: - type: object required: [data] properties: { data: { $ref: '#/components/schemas/ReceivingCartonResolved' } } + ReceivingSkuResolvedApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + required: [data] + properties: { data: { $ref: '#/components/schemas/ReceivingSkuResolved' } } ReceivingPalletResolvedApiResponse: allOf: - { $ref: '#/components/schemas/ReceivingApiResponseBase' } From 2f986cf5e3f884a18f7eea4cf92b14fc256e9bb8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 19:35:01 +0800 Subject: [PATCH 049/115] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pda/dto/ReceivingContainerRequests.java | 3 +- .../service/ReceivingContainerService.java | 1 + .../pda/ReceivingContainerContractTest.java | 11 ++- .../ReceivingContainerControllerTest.java | 2 +- .../InboundStockReceiptServiceTest.java | 35 ++++++++++ ...ReceivingContainerServiceBehaviorTest.java | 68 +++++++++++++++++-- 6 files changed, 112 insertions(+), 8 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java index 9747c976f..4b3e93b8b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java @@ -2,7 +2,6 @@ package com.baiyue.lastleg.inboundplan.pda.dto; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; @@ -13,7 +12,7 @@ public final class ReceivingContainerRequests { private ReceivingContainerRequests() { } public record Action(@NotBlank @Size(max = 255) String idempotencyKey) { } - public record Sign(@Size(min = 3, max = 3) List<@Valid SignedPhoto> photos, + public record Sign(@NotNull @Size(min = 3, max = 3) List<@Valid SignedPhoto> photos, @NotBlank @Size(max = 255) String idempotencyKey) { } public record SignedPhoto(@NotNull ReceivingPhotoRole role, @Positive long fileId) { } public record CartonResolve(@NotBlank String cartonMark) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 124ddec0d..7262e4677 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -533,6 +533,7 @@ public class ReceivingContainerService { .eq(LastlegOrderDetail::getCustomerId, customerId).eq(LastlegOrderDetail::getOrderId, ref.order().getId())); Set skuCodes = details.stream().map(LastlegOrderDetail::getSkuCode) .map(ReceivingContainerService::trim).filter(Objects::nonNull) + .map(value -> value.toUpperCase(java.util.Locale.ROOT)) .collect(Collectors.toCollection(java.util.LinkedHashSet::new)); int expected = 0; int received = 0; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java index aa4e1ea67..5736a0949 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -63,6 +63,12 @@ class ReceivingContainerContractTest { assertTrue(schema.contains("unique key uk_ll_receiving_active_operator(active_operator_key)")); assertTrue(schema.contains("unique key uk_ll_receiving_active_container(active_container_key)")); assertTrue(schema.contains("create table if not exists ll_receiving_container_operation")); + assertTrue(schema.contains("create table if not exists ll_receiving_sku_receipt")); + assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)")); + assertTrue(schema.contains("sign_photos json")); + assertFalse(schema.contains("photo_file_ids json")); + assertTrue(unified.contains("create table if not exists ll_receiving_sku_receipt")); + assertTrue(unified.contains("column_name='sign_photos'")); assertTrue(schema.contains("wait_sign、signed、receiving、completed")); assertTrue(unified.contains("customer_id is null or l.customer_id=''")); assertTrue(unified.contains("customer_id is null or v.customer_id=''")); @@ -76,16 +82,19 @@ class ReceivingContainerContractTest { String prefix = "/api/pda/v1/last-leg/receiving-containers"; assertTrue(yaml.contains(prefix + "/candidates:")); assertTrue(yaml.contains(prefix + "/current/receipts/bulk:")); + assertTrue(yaml.contains(prefix + "/current/skus/resolve:")); assertTrue(yaml.contains(prefix + "/current/receipts/carton:")); assertTrue(yaml.contains(prefix + "/current/complete:")); assertFalse(yaml.contains("/api/pda/v1/last-leg/receiving-containers/{planNo}")); String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:")); List successResponses = receivingPaths.lines().map(String::trim) .filter(line -> line.startsWith("responses: { '200':")).toList(); - assertEquals(11, successResponses.size()); + assertEquals(12, successResponses.size()); assertTrue(successResponses.stream().allMatch(line -> line.contains("content: { application/json: { schema: { $ref: '#/components/schemas/Receiving"))); assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜")); + assertTrue(receivingPaths.contains("LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN")); + assertFalse(receivingPaths.contains("photoFileIds")); } private String resource(String name) throws IOException { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java index d049e9171..c9c3d0fb7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java @@ -32,7 +32,7 @@ class ReceivingContainerControllerTest { @Test void candidatesDerivesCustomerAndOperatorOnlyFromPdaIdentity() { when(service.candidates("100", "42")).thenReturn(List.of(new ReceivingContainerResponses.Candidate( - "CONT-1", "WAIT_SIGN", 2, 2, 20, 0, false))); + "CONT-1", "WAIT_SIGN", 2, 2, List.of("ONT8"), 4, 20, 0, false))); controller.candidates(pda()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index 1e28bfc5b..7bde9af07 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -8,7 +8,9 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt; import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; @@ -45,6 +47,7 @@ class InboundStockReceiptServiceTest { @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper orderDetails; @Mock InboundStockReceiptMapper receipts; + @Mock ReceivingSkuReceiptMapper receivingSkuReceipts; @Mock InventoryCommandService inventory; @Mock DropshipOrderService dropshipOrders; @Mock LastlegOrderStatusHistoryMapper orderHistories; @@ -56,10 +59,12 @@ class InboundStockReceiptServiceTest { ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "orderDetails", orderDetails); ReflectionTestUtils.setField(service, "receipts", receipts); + ReflectionTestUtils.setField(service, "receivingSkuReceipts", receivingSkuReceipts); ReflectionTestUtils.setField(service, "inventory", inventory); ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders); ReflectionTestUtils.setField(service, "orderHistories", orderHistories); when(receipts.listTask("100", 2L)).thenReturn(List.of()); + when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of()); } @Test @@ -108,6 +113,30 @@ class InboundStockReceiptServiceTest { verify(receipts, times(2)).insert(any(InboundStockReceipt.class)); } + @Test + void exactFbaSkuReceiptsPreserveSkuToPalletAttribution() { + Fixture fixture = fixture(10, 5, 5, 10); + fixture.detail.setPackageCount(5); + LastlegOrderDetail second = detail(202L, "SKU-2", 5, 7); + when(orderDetails.selectList(any())).thenReturn(List.of(fixture.detail, second)); + InboundPlanPallet secondPallet = pallet(22L, 2002L); + InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5); + ReceivingSkuReceipt first = skuReceipt(12L, 21L, "SKU-1", 5); + ReceivingSkuReceipt secondReceipt = skuReceipt(12L, 22L, "SKU-2", 5); + when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(first, secondReceipt)); + when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); + when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class))) + .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false)); + + service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line), + List.of(fixture.allocation, secondAllocation), List.of(fixture.pallet, secondPallet), LocalDateTime.now()); + + ArgumentCaptor requests = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class); + verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), requests.capture()); + assertEquals(List.of("SKU-1:2001", "SKU-2:2002"), requests.getAllValues().stream() + .map(value -> value.skuCode() + ":" + value.warehousePalletId()).toList()); + } + @Test void conservesSingleSkuPackagesAndQuantityAcrossPallets() { Fixture fixture = fixture(5, 2, 2, 11); @@ -216,6 +245,12 @@ class InboundStockReceiptServiceTest { allocation.setPlannedPackageCount(picked); allocation.setPickedPackageCount(picked); return allocation; } + private ReceivingSkuReceipt skuReceipt(long lineId, long palletId, String skuCode, int packages) { + ReceivingSkuReceipt receipt = new ReceivingSkuReceipt(); receipt.setLineId(lineId); receipt.setPalletId(palletId); + receipt.setSkuCode(skuCode); receipt.setProductName(skuCode + " name"); receipt.setReceivedPackages(packages); + return receipt; + } + private record Fixture(InboundPlan plan, InboundPdaTask task, InboundPlanLine line, LastlegOrder order, LastlegOrderDetail detail, InboundPlanPalletAllocation allocation, InboundPlanPallet pallet) {} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index c25531ae2..a66a742d8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.inboundplan.pda.service; import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; @@ -12,12 +13,15 @@ import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole; import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation; import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper; import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; @@ -48,8 +52,10 @@ class ReceivingContainerServiceBehaviorTest { @Mock InboundPlanPalletAllocationMapper allocations; @Mock InboundPlanCartonMapper cartons; @Mock LastlegOrderMapper orders; + @Mock LastlegOrderDetailMapper orderDetails; @Mock ReceivingContainerSessionMapper sessions; @Mock ReceivingContainerOperationMapper operations; + @Mock ReceivingSkuReceiptMapper skuReceipts; @Mock WarehouseResourceGateway warehouse; @Mock UnloadPhotoFileGateway unloadPhotos; @Mock InboundPdaWorkflowService workflow; @@ -64,8 +70,10 @@ class ReceivingContainerServiceBehaviorTest { ReflectionTestUtils.setField(service, "allocations", allocations); ReflectionTestUtils.setField(service, "cartons", cartons); ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "orderDetails", orderDetails); ReflectionTestUtils.setField(service, "sessions", sessions); ReflectionTestUtils.setField(service, "operations", operations); + ReflectionTestUtils.setField(service, "skuReceipts", skuReceipts); ReflectionTestUtils.setField(service, "warehouse", warehouse); ReflectionTestUtils.setField(service, "unloadPhotos", unloadPhotos); ReflectionTestUtils.setField(service, "workflow", workflow); @@ -116,7 +124,7 @@ class ReceivingContainerServiceBehaviorTest { void destinationModesAreHardRejectedBeforeWarehouseCalls() { stubCurrent("OVERSEAS", "CONT-A"); assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", - new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "bulk-key"))) + new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "bulk-key"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); @@ -141,9 +149,10 @@ class ReceivingContainerServiceBehaviorTest { org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse, pallets, workflow); stubCurrent("FBA", "CONT-A"); + stubSku(); when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(101L, "FBA-1")); assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", - new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "pallet-key"))) + new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "pallet-key"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); } @@ -151,12 +160,13 @@ class ReceivingContainerServiceBehaviorTest { @Test void overReceiptAndDuplicateCartonFailBeforeRemoteReservation() { stubCurrent("FBA", "CONT-A"); + stubSku(); when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, "FBA-1")); when(pallets.selectList(any())).thenReturn(List.of(planPallet())); doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt")) .when(workflow).validateBulkResolved(any(), any(), any(), anyInt()); assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", - new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 9, "over-key"))) + new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 9, "over-key"))) .isInstanceOf(ResponseStatusException.class); verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); @@ -176,6 +186,7 @@ class ReceivingContainerServiceBehaviorTest { @Test void containerCompletionRequiresEveryInboundCodeToBalanceExactly() { stubCurrent("FBA", "CONT-A"); + stubSku(); InboundPlanPalletAllocation partial = new InboundPlanPalletAllocation(); partial.setLineId(10L); partial.setPickedPackageCount(1); when(allocations.selectList(any())).thenReturn(List.of(partial)); @@ -194,11 +205,48 @@ class ReceivingContainerServiceBehaviorTest { doThrow(failure).when(workflow).receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any()); assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", - new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "compensate-key"))) + new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "compensate-key"))) .isSameAs(failure); verify(workflow).compensateReservation("100", 55L, failure); } + @Test + void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() { + when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); + when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A"))); + when(lines.selectList(any())).thenReturn(List.of()); + when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1); + when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); + ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of( + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L), + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L), + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 2L)), "sign-key"); + + var response = service.sign("100", "7", "receiver", request); + + org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("role") + .containsExactly(ReceivingPhotoRole.LICENSE_PLATE, ReceivingPhotoRole.CONTAINER_SEAL, + ReceivingPhotoRole.DOOR_OPEN); + verify(unloadPhotos).validate("100", 8L, 1L); + verify(unloadPhotos).validate("100", 8L, 2L); + verify(unloadPhotos).validate("100", 8L, 3L); + } + + @Test + void bulkRejectsSkuOverReceiptBeforeWarehouseLookup() { + stubCurrent("FBA", "CONT-A"); + stubSku(); + com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt received = + new com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt(); + received.setReceivedPackages(2); + when(skuReceipts.listLineSku("100", 10L, "SKU-1")).thenReturn(List.of(received)); + + assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", + new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "sku-over"))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + verifyNoInteractions(warehouse); + } + private void stubCurrent(String destinationType, String containerNo) { InboundPlan plan = plan(containerNo); InboundPlanLine line = line(destinationType); @@ -209,6 +257,11 @@ class ReceivingContainerServiceBehaviorTest { when(orders.selectById(1L)).thenReturn(order); } + private void stubSku() { + when(orderDetails.selectList(any())).thenReturn(List.of(detail("SKU-1", 2))); + when(skuReceipts.listLineSku("100", 10L, "SKU-1")).thenReturn(List.of()); + } + private static InboundPlan plan(String containerNo) { InboundPlan value = new InboundPlan(); value.setId(2L); value.setPlanNo("PLAN-1"); value.setCustomerId("100"); value.setContainerNo(containerNo); @@ -232,6 +285,13 @@ class ReceivingContainerServiceBehaviorTest { return value; } + private static LastlegOrderDetail detail(String skuCode, int packages) { + LastlegOrderDetail value = new LastlegOrderDetail(); + value.setId(11L); value.setCustomerId("100"); value.setOrderId(1L); + value.setSkuCode(skuCode); value.setProductNameZh("产品一"); value.setPackageCount(packages); + return value; + } + private static ReceivingContainerSession session(String containerNo, String status) { ReceivingContainerSession value = new ReceivingContainerSession(); value.setId(8L); value.setCustomerId("100"); value.setContainerNo(containerNo); value.setOperatorKey("7"); From 1e4b216afac052b925ba0a9a9010dd0a0db8e3fb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 19:40:46 +0800 Subject: [PATCH 050/115] test(events): add cross-region HTTP acceptance gates --- scripts/admin-test-env-acceptance.mjs | 10 +- scripts/admin-test-env-modules/README.md | 3 +- ...ase4-cross-region-events-contract.test.mjs | 96 ++++++++++++ .../phase4-cross-region-events.mjs | 148 ++++++++++++++++++ ...se4-overseas-master-data-contract.test.mjs | 127 +++++++++++---- .../phase4-overseas-master-data.mjs | 144 ++++++++++++++--- scripts/admin-test-env-modules/system.mjs | 15 ++ ...verseas-acceptance-entry-contract.test.mjs | 1 + 8 files changed, 489 insertions(+), 55 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase4-cross-region-events.mjs diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 7f3de861f..0afc3b1f3 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -13,7 +13,7 @@ const DEFAULT_GATEWAY = 'http://8.163.30.128:9000' const DEFAULT_OVERSEAS_GATEWAY = 'http://8.163.30.128:9300' const DEFAULT_USERNAME = 'platform_admin' const DEFAULT_PASSWORD = 'ChangeMe123!' -const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-pda', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] +const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-pda', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] export function normalizeModuleOrder(modules) { const normalized = [...modules] @@ -65,6 +65,14 @@ export function normalizeModuleOrder(modules) { && !normalized.includes('merchant-qualification-fixture')) { normalized.splice(normalized.indexOf('phase4-overseas-business'), 0, 'merchant-qualification-fixture') } + if (normalized.includes('phase4-cross-region-events')) { + const prerequisites = ['system', 'merchant-qualification-fixture', 'orders', 'inbound-measurement', + 'phase3-container-loading', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate', + 'phase3-loading-finalize', 'phase3-customs', 'phase4-overseas-pda'] + const withoutPrerequisites = normalized.filter((module) => !prerequisites.includes(module)) + withoutPrerequisites.splice(withoutPrerequisites.indexOf('phase4-cross-region-events'), 0, ...prerequisites) + normalized.splice(0, normalized.length, ...withoutPrerequisites) + } const financeChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full'] const firstFinanceIndex = normalized.findIndex((name) => financeChain.includes(name)) if (firstFinanceIndex >= 0) { diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index c576b622e..7ea762f81 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -56,6 +56,7 @@ export async function run(context) { | Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` | | 四期海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` | | 四期海外 PDA 入仓 HTTP 生命周期 | `phase4-overseas-pda.mjs`(自动选择未转换尾程单、创建目标仓/库位/托盘并完成入库;完成事实保留审计) | `phase4-overseas-pda-contract.test.mjs` | +| 三四期跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` | | 四期海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` | | 四期海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` | | 四期船节点安全冒烟 | `phase4-carrier-nodes.mjs`(standalone 对历史任务只读;仅复用当前 marker 的 `phase3CarrierScheduleQuery` 时补录预计事实) | `phase4-carrier-nodes-contract.test.mjs` | @@ -66,7 +67,7 @@ export async function run(context) { 四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;独立 PDA 模块只调用 `/api/pda/v1/last-leg/inbound-plans/**` 完成入仓,不调用出库、派送或内部接口;船节点禁止手工创建任务。 ```powershell -node scripts/admin-test-env-acceptance.mjs --gateway --overseas-gateway --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes +node scripts/admin-test-env-acceptance.mjs --gateway --overseas-gateway --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes ``` 只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.mjs` 不代表总入口已注册。新增或改名 HTTP 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。 diff --git a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs new file mode 100644 index 000000000..0c324fa98 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' +import { run } from './phase4-cross-region-events.mjs' + +const source = readFileSync(new URL('./phase4-cross-region-events.mjs', import.meta.url), 'utf8') +const system = readFileSync(new URL('./system.mjs', import.meta.url), 'utf8') + +function response(path, data, http = 200, code = '200') { + return { method: 'GET', path, http, body: { code, data }, requestId: 'request-1', traceId: 'trace-1' } +} + +function context(resources = {}) { + const steps = [] + const gaps = [] + const calls = [] + return { + marker: 'MARKER-1', token: 'domestic-token', overseasToken: 'overseas-token', + createdResources: resources, steps, gaps, calls, + addCoverageGap(module, scenario, name, reason, category, metadata) { + gaps.push({ module, scenario, name, reason, category, ...metadata }) + }, + addStep(module, name, value, passed, evidence, options) { + steps.push({ module, name, passed: Boolean(passed), evidence, options }) + return Boolean(passed) + }, + assert(module, name, value, passed, evidence, options) { + return this.addStep(module, name, value, passed, evidence, options) + }, + async requestOverseas(method, path, body, headers, token) { + calls.push({ region: 'OVERSEAS', method, path, token: token || this.overseasToken }) + if (token === this.token) return response(path, null, 403, 'A0403') + if (path.includes('/last-leg/orders/9001')) { + return response(path, { order: { id: '9001', containerNo: 'MSCU1234567' } }) + } + return response(path, { records: [{ id: '7001', containerNo: 'MSCU1234567' }] }) + }, + async request(method, path, body, token) { + calls.push({ region: 'CN', method, path, token: token || this.token }) + if (token === this.overseasToken) return response(path, null, 403, 'A0403') + if (path.includes('/overview')) return response(path, { container: { + id: '3001', containerNo: 'MSCU1234567', unloadingCompletedAt: '2026-08-24T10:00:00Z', + unloadingPhotoUploadedAt: '2026-08-24T09:00:00Z', estimatedEmptyReportedAt: '2026-08-26T09:00:00Z', + actualEmptyReportedAt: null, emptyReportSourceVersion: 7, + } }) + return response(path, { records: [{ id: '3001', containerNo: 'MSCU1234567' }] }) + }, + } +} + +test('entry orders marker-owned prerequisites before cross-region acceptance', () => { + const modules = normalizeModuleOrder(['phase4-cross-region-events']) + const cross = modules.indexOf('phase4-cross-region-events') + for (const prerequisite of ['system', 'phase3-loading-finalize', 'phase4-overseas-pda']) { + assert.ok(modules.indexOf(prerequisite) >= 0 && modules.indexOf(prerequisite) < cross, prerequisite) + } +}) + +test('contract covers dual audience, canonical 403, both event directions and diagnostics', () => { + for (const token of [ + 'context.token)', 'context.overseasToken)', 'cn-overseas-event-not-delivered', + 'overseas-cn-event-not-delivered', 'requestId=', 'traceId=', 'pollRequests=', + '装柜完成事件重试保持建单幂等', '海外柜完成事件重试保持回写幂等', + ]) assert.ok(source.includes(token), token) + assert.match(system, /缺少canonical海外订单权限必须403/) + assert.match(system, /restrictedOverseasToken/) +}) + +test('full observable cross-region flow performs requests and passes', async () => { + const ctx = context({ + phase3Loading: { marker: 'MARKER-1', finalizeSucceeded: true, containerBookingId: '3001', + containerNo: 'MSCU1234567', loadingPlanId: '4001' }, + phase4OverseasPda: { marker: 'MARKER-1', finalStatus: 'COMPLETED', customerId: '100', orderId: '9001' }, + }) + + await run(ctx) + + assert.ok(ctx.calls.length >= 8) + assert.equal(ctx.steps.every((step) => step.passed), true) + assert.equal(ctx.gaps.length, 0) + assert.ok(ctx.calls.some((call) => call.region === 'CN' && call.token === 'overseas-token')) + assert.ok(ctx.calls.some((call) => call.region === 'OVERSEAS' && call.token === 'domestic-token')) +}) + +test('missing marker fixture is a blocking coverage gap after real security request', async () => { + const ctx = context() + + await assert.rejects(() => run(ctx), /blocking coverage gap/) + + assert.ok(ctx.calls.length > 0) + assert.equal(ctx.gaps.length, 1) + assert.equal(ctx.gaps[0].deploymentBlocker, true) + assert.equal(ctx.steps.some((step) => !step.passed && step.options?.deploymentBlocker), true) +}) diff --git a/scripts/admin-test-env-modules/phase4-cross-region-events.mjs b/scripts/admin-test-env-modules/phase4-cross-region-events.mjs new file mode 100644 index 000000000..96eca9fc9 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-cross-region-events.mjs @@ -0,0 +1,148 @@ +const MODULE = 'phase4-cross-region-events' +const OPTIONS = { scenario: 'phase4-cross-region-events', deploymentBlocker: true } + +function id(value) { return value == null ? '' : String(value) } +function records(data) { return Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : [] } +function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' } +function denied(response) { + return response?.http === 401 || response?.http === 403 + || ['401', '403', 'A0401', 'A0403'].includes(String(response?.body?.code)) +} +function traced(response) { return Boolean(response?.requestId) && Boolean(response?.traceId) } +function evidence(response, extra = '') { + return `${extra}${extra ? '; ' : ''}HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +function blockingGap(context, name, reason, category, response = null) { + context.addCoverageGap(MODULE, OPTIONS.scenario, name, reason, category, { deploymentBlocker: true }) + context.addStep(MODULE, name, response, false, reason, OPTIONS) + throw new Error(`blocking coverage gap: ${name}: ${reason}`) +} +async function poll(request, matches, attempts = 12) { + let last = null + for (let index = 0; index < attempts; index += 1) { + last = await request() + if (matches(last)) return { response: last, attempts: index + 1 } + if (index + 1 < attempts) await new Promise((resolve) => setTimeout(resolve, 1_000)) + } + return { response: last, attempts } +} +function sameIds(left, right) { + const ids = (value) => records(value).map((item) => id(item?.id)).filter(Boolean).sort() + return JSON.stringify(ids(left)) === JSON.stringify(ids(right)) +} + +export async function run(context) { + if (!context.token || !context.overseasToken) { + blockingGap(context, '双Token前置', '国内或海外访问令牌缺失,不能执行跨区安全门禁', 'missing-dual-token') + } + + const domesticTokenAgainstOverseas = await context.requestOverseas('GET', + '/api/platform/v1/last-leg/orders?page=1&size=1', undefined, {}, context.token) + context.assert(MODULE, '国内Token不能访问海外Audience', domesticTokenAgainstOverseas, + denied(domesticTokenAgainstOverseas) && traced(domesticTokenAgainstOverseas), + evidence(domesticTokenAgainstOverseas), OPTIONS) + + const phase3 = context.createdResources?.phase3Loading + if (phase3?.marker !== context.marker || !phase3?.finalizeSucceeded + || !phase3?.containerBookingId || !phase3?.containerNo || !phase3?.loadingPlanId) { + blockingGap(context, '国内装柜完成事件前置', + '缺少本轮marker的LOADED装柜事实;必须先完成三期装柜链,禁止借用历史事件', + 'missing-marker-loading-completed') + } + + const overseasTokenAgainstDomestic = await context.request('GET', + `/api/platform/v1/containers/${encodeURIComponent(id(phase3.containerBookingId))}`, + undefined, context.overseasToken) + context.assert(MODULE, '海外Token不能访问国内Audience', overseasTokenAgainstDomestic, + denied(overseasTokenAgainstDomestic) && traced(overseasTokenAgainstDomestic), + evidence(overseasTokenAgainstDomestic), OPTIONS) + + const taillegPath = `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(phase3.containerNo)}` + const delivered = await poll( + () => context.requestOverseas('GET', taillegPath), + (response) => success(response) && records(response.body?.data) + .some((item) => item?.containerNo === phase3.containerNo)) + const deliveredRows = records(delivered.response?.body?.data) + .filter((item) => item?.containerNo === phase3.containerNo) + context.assert(MODULE, '国内装柜完成经Bridge生成Lastleg订单', delivered.response, + deliveredRows.length > 0 && traced(delivered.response), + evidence(delivered.response, `pollRequests=${delivered.attempts}, containerNo=${phase3.containerNo}, orders=${deliveredRows.length}`), OPTIONS) + if (deliveredRows.length === 0) { + blockingGap(context, '国内到海外事件闭环', + `本轮柜号${phase3.containerNo}在轮询后仍无Lastleg订单;需结合requestId/traceId检查Bridge重试与下游诊断`, + 'cn-overseas-event-not-delivered', delivered.response) + } + + const deliveredAgain = await context.requestOverseas('GET', taillegPath) + context.assert(MODULE, '装柜完成事件重试保持建单幂等', deliveredAgain, + success(deliveredAgain) && sameIds(delivered.response.body?.data, deliveredAgain.body?.data) + && traced(deliveredAgain), + evidence(deliveredAgain, `firstOrders=${deliveredRows.map((item) => id(item.id)).join(',')}`), OPTIONS) + + const overseasPda = context.createdResources?.phase4OverseasPda + if (overseasPda?.marker !== context.marker || overseasPda?.finalStatus !== 'COMPLETED' + || !overseasPda?.customerId || !overseasPda?.orderId) { + blockingGap(context, '海外柜完成事件前置', + '缺少本轮marker的海外PDA COMPLETED柜事实;必须先完成四期海外PDA闭环', + 'missing-marker-overseas-container-completed') + } + + const orderDetailPath = `/api/platform/v1/last-leg/orders/${encodeURIComponent(overseasPda.orderId)}?customerId=${encodeURIComponent(overseasPda.customerId)}` + const orderDetail = await context.requestOverseas('GET', orderDetailPath) + const order = orderDetail.body?.data?.order || orderDetail.body?.data + const completedContainerNo = order?.containerNo || '' + context.assert(MODULE, '读取海外完成柜来源尾程单', orderDetail, + success(orderDetail) && Boolean(completedContainerNo) && traced(orderDetail), + evidence(orderDetail, `orderId=${overseasPda.orderId}, containerNo=${completedContainerNo || 'missing'}`), OPTIONS) + if (!completedContainerNo) { + blockingGap(context, '海外完成柜来源身份', '尾程订单未返回containerNo,无法核对国内回写', + 'missing-overseas-container-identity', orderDetail) + } + + const containerPagePath = `/api/platform/v1/containers?containerNo=${encodeURIComponent(completedContainerNo)}&page=1&size=20` + const containerPage = await context.request('GET', containerPagePath) + const domesticMatches = records(containerPage.body?.data) + .filter((item) => item?.containerNo === completedContainerNo) + context.assert(MODULE, '物理柜号唯一定位国内柜聚合', containerPage, + success(containerPage) && domesticMatches.length === 1 && traced(containerPage), + evidence(containerPage, `containerNo=${completedContainerNo}, matches=${domesticMatches.length}`), OPTIONS) + if (domesticMatches.length !== 1) { + blockingGap(context, '海外到国内柜身份映射', + `物理柜号${completedContainerNo}匹配国内柜数量为${domesticMatches.length},无法验证回写`, + 'domestic-container-identity-not-unique', containerPage) + } + + const overviewPath = `/api/platform/v1/containers/${encodeURIComponent(id(domesticMatches[0].id))}/overview` + const writtenBack = await poll( + () => context.request('GET', overviewPath), + (response) => { + const container = response?.body?.data?.container + return success(response) && Boolean(container?.unloadingCompletedAt) + && Boolean(container?.unloadingPhotoUploadedAt) && Boolean(container?.estimatedEmptyReportedAt) + }) + const snapshot = writtenBack.response?.body?.data?.container || {} + const completedAt = Date.parse(snapshot.unloadingCompletedAt) + const uploadedAt = Date.parse(snapshot.unloadingPhotoUploadedAt) + const estimatedAt = Date.parse(snapshot.estimatedEmptyReportedAt) + const validTimeline = Number.isFinite(completedAt) && Number.isFinite(uploadedAt) + && Number.isFinite(estimatedAt) && completedAt >= uploadedAt + && estimatedAt - uploadedAt === 2 * 24 * 60 * 60 * 1_000 + context.assert(MODULE, '海外柜完成经Bridge回写Firstleg时间事实', writtenBack.response, + validTimeline && traced(writtenBack.response), + evidence(writtenBack.response, + `pollRequests=${writtenBack.attempts}, completed=${snapshot.unloadingCompletedAt || 'missing'}, photo=${snapshot.unloadingPhotoUploadedAt || 'missing'}, estimatedEmpty=${snapshot.estimatedEmptyReportedAt || 'missing'}`), OPTIONS) + if (!validTimeline) { + blockingGap(context, '海外到国内事件闭环', + `柜号${completedContainerNo}轮询后仍无有效卸柜完成/拍照/预计报空回写;需检查Bridge重试与诊断证据`, + 'overseas-cn-event-not-delivered', writtenBack.response) + } + + const writtenBackAgain = await context.request('GET', overviewPath) + const replaySnapshot = writtenBackAgain.body?.data?.container || {} + const frozen = ['unloadingCompletedAt', 'unloadingPhotoUploadedAt', 'estimatedEmptyReportedAt', + 'actualEmptyReportedAt', 'emptyReportSourceVersion'] + context.assert(MODULE, '海外柜完成事件重试保持回写幂等', writtenBackAgain, + success(writtenBackAgain) && frozen.every((field) => (replaySnapshot[field] ?? null) === (snapshot[field] ?? null)) + && traced(writtenBackAgain), + evidence(writtenBackAgain, `containerId=${id(domesticMatches[0].id)}, sourceVersion=${snapshot.emptyReportSourceVersion ?? 'missing'}`), OPTIONS) +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs index a83d35208..7ffc77066 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs @@ -4,10 +4,16 @@ import test from 'node:test' import { run } from './phase4-overseas-master-data.mjs' const source = readFileSync(new URL('./phase4-overseas-master-data.mjs', import.meta.url), 'utf8') +const controllerSource = [ + '../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteController.java', + '../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateController.java', + '../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleController.java', +].map((path) => readFileSync(new URL(path, import.meta.url), 'utf8')).join('\n') const DOMESTIC_BASE = '/api/platform/v1/warehouse-admin' const OVERSEAS_BASE = '/api/platform/v1/last-leg/master-data' const ok = (data) => ({ http: 200, body: { code: '200', message: 'success', data }, requestId: 'req-p4', traceId: 'trace-p4' }) const conflict = () => ({ http: 200, body: { code: 'A0409', message: 'record version conflict', data: null }, requestId: 'req-conflict' }) +const forbidden = () => ({ http: 403, body: { code: 'A0403', message: 'Access denied', data: null }, requestId: 'req-forbidden' }) function detailQuote(state) { return { @@ -22,21 +28,25 @@ function vehicleWire(state) { return { ...state.vehicle, mileageKm: String(state.vehicle.mileageKm), recordVersion: String(state.vehicle.recordVersion) } } -function fakeContext() { +function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) { const calls = [] const coverageGaps = [] + const steps = [] const marker = 'ADMIN-API-20260821123456789' - const state = { warehouse: null, quote: null, exchange: null, vehicle: null } + const state = { warehouse: null, quote: null, exchange: null, vehicle: null, optionReads: 0, role: null, user: null } const context = { - marker, calls, coverageGaps, createdResources: {}, + marker, calls, coverageGaps, steps, createdResources: {}, + async findAvailablePhone() { return '13800138000' }, apiSuccess(_module, name, response) { assert.equal(response?.http, 200, `${name}: HTTP`) assert.equal(String(response?.body?.code), '200', `${name}: code`) return response.body.data }, - assert(_module, name, _response, condition, evidence) { - assert.equal(Boolean(condition), true, `${name}: ${evidence}`) - return true + assert(_module, name, _response, condition, evidence, options = {}) { + const passed = Boolean(condition) + steps.push({ name, passed, deploymentBlocker: Boolean(options.deploymentBlocker && !passed), evidence }) + if (!passed && !softAssertions) assert.equal(passed, true, `${name}: ${evidence}`) + return passed }, expectConflict(_module, name, response) { assert.equal(response.http, 200, `${name}: HTTP`) @@ -64,12 +74,40 @@ function fakeContext() { state.warehouse.status = 'INACTIVE' return ok(null) } + if (method === 'POST' && path === '/api/platform/v1/iam/roles') { + state.role = { id: '940000000000000001', ...body, status: 'ACTIVE' } + return ok({ ...state.role }) + } + if (method === 'POST' && path === '/api/platform/v1/iam/users') { + state.user = { membershipId: '950000000000000001', ...body, status: 'ACTIVE' } + return ok({ ...state.user }) + } + if (method === 'POST' && path === '/api/auth/login') { + return ok({ accessToken: 'deny-domestic-token', overseasAccessToken: 'deny-overseas-token', overseasExpiresIn: 900 }) + } + if (method === 'PUT' && path === '/api/platform/v1/iam/users/950000000000000001/status') { + state.user.status = body.status + return ok(null) + } + if (method === 'PUT' && path === '/api/platform/v1/iam/roles/940000000000000001/status') { + state.role.status = body.status + return ok(null) + } assert.fail(`unexpected domestic call ${method} ${path}`) }, - async requestOverseas(method, path, body) { - calls.push({ gateway: 'overseas', method, path, body }) + async requestOverseas(method, path, body, extraHeaders = {}, token = 'admin-overseas-token') { + calls.push({ gateway: 'overseas', method, path, body, extraHeaders, tokenKind: token === 'deny-overseas-token' ? 'denied' : 'admin' }) assert.notEqual(method, 'DELETE') + if (token === 'deny-overseas-token') return forbidden() if (method === 'GET' && path === `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) { + state.optionReads += 1 + if (!state.warehouse) return ok([]) + return ok([{ warehouseId: '910000000000000001', warehouseCode: state.warehouse.warehouseCode, + warehouseName: state.warehouse.warehouseName, countryCode: 'US' }]) + } + if (method === 'GET' && path === `${OVERSEAS_BASE}/overseas-quotes/warehouse-options?refresh=true`) { + state.optionReads += 1 + if (refreshEmpty) return ok([]) return ok([{ warehouseId: '910000000000000001', warehouseCode: state.warehouse.warehouseCode, warehouseName: state.warehouse.warehouseName, countryCode: 'US' }]) } @@ -81,6 +119,10 @@ function fakeContext() { } return ok(detailQuote(state.quote)) } + if (method === 'GET' && path.startsWith(`${OVERSEAS_BASE}/overseas-quotes?page=1&size=20&keyword=`)) { + return ok({ records: state.quote ? [{ ...state.quote.summary }] : [], page: 1, size: 20, + total: state.quote ? 1 : 0, pages: state.quote ? 1 : 0 }) + } if (path === `${OVERSEAS_BASE}/overseas-quotes/920000000000000001`) { if (method === 'GET') return ok(detailQuote(state.quote)) if (method === 'PUT') { @@ -100,7 +142,10 @@ function fakeContext() { return ok(detailQuote(state.quote)) } if (method === 'GET' && path.startsWith(`${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=`)) { - return ok({ records: [], page: 1, size: 20, total: 0, pages: 0 }) + const matches = state.exchange && path.includes(`monthFrom=${state.exchange.month}`) + && (!path.includes('status=') || path.includes(`status=${state.exchange.status}`)) + return ok({ records: matches ? [{ ...state.exchange }] : [], page: 1, size: 20, + total: matches ? 1 : 0, pages: matches ? 1 : 0 }) } if (method === 'POST' && path === `${OVERSEAS_BASE}/exchange-rates`) { state.exchange = { ...body, baseCurrency: 'USD', quoteCurrency: 'CNY', status: 'ACTIVE', recordVersion: 0 } @@ -139,13 +184,21 @@ function fakeContext() { state.vehicle = { ...state.vehicle, status: body.status, recordVersion: state.vehicle.recordVersion + 1 } return ok(vehicleWire(state)) } + if (method === 'GET' && path.startsWith(`${OVERSEAS_BASE}/vehicles?page=1&size=20&keyword=`)) { + return ok({ records: state.vehicle ? [vehicleWire(state)] : [], page: 1, size: 20, + total: state.vehicle ? 1 : 0, pages: state.vehicle ? 1 : 0 }) + } assert.fail(`unexpected overseas call ${method} ${path}`) }, async requestOverseasBinary(method, path) { calls.push({ gateway: 'overseas-binary', method, path }) assert.equal(method, 'GET') assert.match(path, /\/(?:overseas-quotes|exchange-rates|vehicles)\/export\?/) - return { method, path, http: 200, body: null, bytes: new TextEncoder().encode('id,status\n1,ACTIVE\n'), + let csv = '' + if (path.includes('/overseas-quotes/')) csv = `报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间\r\n${state.quote.summary.id},${state.quote.summary.name},${state.quote.summary.warehouseCode},${state.quote.summary.warehouseName},${state.quote.summary.providerName},USD,2098-01-01,2098-12-31,V1,ACTIVE,2026-08-21\r\n` + else if (path.includes('/exchange-rates/')) csv = `月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间\r\n${state.exchange.month},USD,CNY,${state.exchange.usdToCnyAverage},${state.exchange.status},admin,2026-08-21\r\n` + else csv = `车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n${state.vehicle.id},${state.vehicle.plateNumber},${state.vehicle.model},18.75,101,Carrier,123,OVERSEAS,OWNED,${state.vehicle.status},2026-08-21\r\n` + return { method, path, http: 200, body: null, bytes: new TextEncoder().encode(`\uFEFF${csv}`), contentType: 'text/csv;charset=UTF-8', requestId: 'req-csv' } }, } @@ -160,13 +213,20 @@ test('静态锁定真实双Gateway生命周期、CSV与安全边界', () => { assert.match(source, /context\.requestOverseas\('PATCH'/) assert.match(source, /context\.requestOverseasBinary\('GET'/) assert.match(source, /context\.expectConflict/) + assert.match(source, /warehouse-options\?refresh=true/) + assert.match(source, /空候选不负缓存30分钟/) + assert.match(source, /30分钟正缓存与刷新快照一致/) + assert.match(source, /无quote:list权限访问海外报价被拒绝/) + assert.match(source, /表头与marker内容/) assert.match(source, /finally\s*\{/) assert.match(source, /context\.createdResources\.phase4OverseasMasterData/) - assert.match(source, /context\.addCoverageGap/) + assert.doesNotMatch(source, /context\.addCoverageGap/) assert.match(source, /2090 \+ Math\.floor/) assert.doesNotMatch(source, /\/internal\/|overseas-partners|https?:\/\/|fetch\(|process\.env|request(?:Overseas)?\('DELETE'/) for (const endpoint of ['overseas-quotes/warehouse-options', 'overseas-quotes/export', 'exchange-rates/export', 'vehicles/export']) assert.ok(source.includes(endpoint)) + assert.doesNotMatch(controllerSource, /Idempotency-Key|IdempotencyKey/) + assert.doesNotMatch(source, /Idempotency-Key/) }) test('marker资源完成创建、详情、编辑、冲突、状态、CSV并全部停用', async () => { @@ -186,30 +246,39 @@ test('marker资源完成创建、详情、编辑、冲突、状态、CSV并全 assert.equal(state.quote.summary.status, 'INACTIVE') assert.equal(state.exchange.status, 'INACTIVE') assert.equal(state.vehicle.status, 'INACTIVE') + assert.equal(state.role.status, 'DISABLED') + assert.equal(state.user.status, 'DISABLED') + assert.equal(state.optionReads, 4) assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary').length, 3) assert.equal(context.calls.filter((call) => call.method === 'DELETE').length, 0) - assert.equal(context.calls.filter((call) => call.method === 'PUT').length, 7) + assert.equal(context.calls.filter((call) => call.method === 'PUT').length, 9) + assert.equal(context.calls.filter((call) => call.tokenKind === 'denied').length, 1) + assert.equal(context.coverageGaps.length, 0) }) -test('候选空缓存登记覆盖缺口并继续汇率车辆,finally停用国内合作仓', async () => { - const { context, state } = fakeContext() - const original = context.requestOverseas - context.requestOverseas = async (method, path, body) => { - if (method === 'GET' && path.endsWith('/overseas-quotes/warehouse-options')) return ok([]) - return original(method, path, body) - } +test('创建前空候选不形成假PASS,刷新后本轮合作仓进入报价完整生命周期', async () => { + const { context } = fakeContext() + await run(context) + const optionCalls = context.calls.filter((call) => call.path.includes('warehouse-options')) + assert.equal(optionCalls.length, 4) + assert.equal(optionCalls[0].path.endsWith('warehouse-options'), true) + assert.equal(optionCalls[1].path.endsWith('warehouse-options'), true) + assert.equal(optionCalls[2].path.endsWith('warehouse-options?refresh=true'), true) + assert.equal(optionCalls[3].path.endsWith('warehouse-options'), true) + assert.equal(context.createdResources.phase4OverseasMasterData.quote.id, '920000000000000001') + assert.equal(context.coverageGaps.length, 0) +}) + +test('refresh后仍为空时报价前置明确deployment blocker且不产生coverageGap假PASS', async () => { + const { context, state } = fakeContext({ softAssertions: true, refreshEmpty: true }) await run(context) - assert.equal(state.warehouse.status, 'INACTIVE') assert.equal(state.quote, null) + assert.equal(state.warehouse.status, 'INACTIVE') assert.equal(state.exchange.status, 'INACTIVE') assert.equal(state.vehicle.status, 'INACTIVE') - assert.equal(context.createdResources.phase4OverseasMasterData.quote.skipped, true) - assert.equal(context.createdResources.phase4OverseasMasterData.partnerWarehouse.finalStatus, 'INACTIVE') - assert.equal(context.createdResources.phase4OverseasMasterData.exchangeRate.finalStatus, 'INACTIVE') - assert.equal(context.createdResources.phase4OverseasMasterData.vehicle.finalStatus, 'INACTIVE') - assert.equal(context.coverageGaps.length, 1) - assert.equal(context.coverageGaps[0].name, '海外报价完整写生命周期') - assert.equal(context.coverageGaps[0].category, 'deployment-prerequisite') - assert.match(context.coverageGaps[0].reason, /30分钟空缓存/) - assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary').length, 2) + assert.equal(context.coverageGaps.length, 0) + assert.equal(context.steps.some((step) => step.name === '刷新后新建合作仓立即可见' + && !step.passed && step.deploymentBlocker), true) + assert.equal(context.steps.some((step) => step.name === '海外报价生命周期执行' + && !step.passed && step.deploymentBlocker), true) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs index 8e181bae6..3d8c8f90e 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs @@ -14,13 +14,26 @@ function requireData(context, name, response) { return value } -function assertBinaryCsv(context, name, response) { +function assertBinaryCsv(context, name, response, expectedHeader, expectedTokens) { + const text = response?.bytes instanceof Uint8Array ? new TextDecoder().decode(response.bytes).replace(/^\uFEFF/, '') : '' + const header = text.split(/\r?\n/, 1)[0] context.assert(MODULE, name, response, response?.http === 200 && String(response.contentType || '').toLowerCase().startsWith('text/csv') && response.bytes instanceof Uint8Array && response.bytes.length > 0, `HTTP=${response?.http ?? 0}, contentType=${response?.contentType || 'missing'}, bytes=${response?.bytes?.length || 0}`, BLOCKER) + context.assert(MODULE, `${name}表头与marker内容`, response, + header === expectedHeader && expectedTokens.every((token) => text.includes(String(token))), + `header=${header || 'missing'}, expectedTokens=${expectedTokens.join('|')}`, BLOCKER) +} + +const optionIds = (values) => Array.isArray(values) ? values.map((item) => asId(item?.warehouseId)).filter(Boolean) : [] + +function assertForbidden(context, name, response) { + context.assert(MODULE, name, response, + response?.http === 403 || (response?.http === 200 && ['403', 'A0403'].includes(String(response?.body?.code))), + `HTTP=${response?.http ?? 0}, code=${response?.body?.code ?? 'missing'}`, BLOCKER) } async function safeCleanup(context, name, action) { @@ -97,6 +110,16 @@ async function createAndVerifyPartnerWarehouse(context, resource) { BLOCKER) } +async function captureWarehouseOptionsBeforeCreate(context, resource) { + const response = await context.requestOverseas('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) + const options = requireData(context, '创建合作仓前查询报价候选缓存基线', response) + context.assert(MODULE, '合作仓候选基线为规范数组', response, + Array.isArray(options) && options.every((item) => asId(item?.warehouseId) + && item.warehouseCode && item.warehouseName && /^[A-Z]{2}$/.test(item.countryCode)), + `count=${Array.isArray(options) ? options.length : 'invalid'}`, BLOCKER) + resource.partnerWarehouseOptionsBefore = optionIds(options) +} + function quoteCreatePayload(context, warehouseId) { return { name: `${context.marker} 海外报价`, warehouseId, @@ -127,27 +150,25 @@ async function quoteLifecycle(context, resource) { Array.isArray(options) && options.every((item) => asId(item.warehouseId) && item.warehouseCode && item.warehouseName && item.countryCode), `count=${Array.isArray(options) ? options.length : 'invalid'}`, BLOCKER) - if (Array.isArray(options) && options.length === 0) { - resource.quote = { - skipped: true, - reason: 'warehouse-options-empty-cache', - finalStatus: null, - } - context.addCoverageGap(MODULE, BLOCKER.scenario, '海外报价完整写生命周期', - 'warehouse-options 返回空数组;已创建的 OVERSEAS_PARTNER 可能尚未穿透服务端30分钟空缓存。本轮跳过报价且不猜测 warehouseId,继续验收汇率与车辆。', - 'deployment-prerequisite', { - marker: context.marker, - partnerWarehouseId: resource.partnerWarehouse?.id || null, - http: optionsResponse?.http ?? null, - code: optionsResponse?.body?.code ?? null, - requestId: optionsResponse?.requestId ?? null, - traceId: optionsResponse?.traceId ?? null, - }) - return false + const createdId = resource.partnerWarehouse?.id + if (resource.partnerWarehouseOptionsBefore?.length === 0) { + context.assert(MODULE, '空候选不负缓存30分钟', optionsResponse, + optionIds(options).includes(createdId), `createdId=${createdId}, ids=${optionIds(options).join(',') || 'empty'}`, BLOCKER) } - const createdOption = options.find((item) => asId(item.warehouseId) === resource.partnerWarehouse?.id) - const option = createdOption || options[0] - if (!option) throw new Error('海外报价接口未返回可用合作仓候选') + + const refreshResponse = await context.requestOverseas('GET', + `${OVERSEAS_BASE}/overseas-quotes/warehouse-options?refresh=true`) + const refreshed = requireData(context, '强制刷新海外报价合作仓候选', refreshResponse) + const option = Array.isArray(refreshed) ? refreshed.find((item) => asId(item.warehouseId) === createdId) : null + context.assert(MODULE, '刷新后新建合作仓立即可见', refreshResponse, Boolean(option), + `createdId=${createdId}, ids=${optionIds(refreshed).join(',') || 'empty'}`, BLOCKER) + if (!option) throw new Error(`warehouse-options refresh=true 未返回本轮合作仓 ${createdId}`) + + const cachedResponse = await context.requestOverseas('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) + const cached = requireData(context, '刷新后读取30分钟正缓存', cachedResponse) + context.assert(MODULE, '30分钟正缓存与刷新快照一致', cachedResponse, + JSON.stringify(cached) === JSON.stringify(refreshed), + `refreshed=${optionIds(refreshed).join(',')}, cached=${optionIds(cached).join(',')}`, BLOCKER) const payload = quoteCreatePayload(context, asId(option.warehouseId)) const createResponse = await context.requestOverseas('POST', `${OVERSEAS_BASE}/overseas-quotes`, payload) @@ -164,6 +185,13 @@ async function quoteLifecycle(context, resource) { && Array.isArray(detail?.warehouseFees) && detail.warehouseFees.length > 0, `id=${asId(detail?.summary?.id) || 'missing'}, version=${detail?.summary?.recordVersion ?? 'missing'}`, BLOCKER) + const listResponse = await context.requestOverseas('GET', + `${OVERSEAS_BASE}/overseas-quotes?page=1&size=20&keyword=${encodeURIComponent(context.marker)}`) + const page = requireData(context, '分页查询marker海外报价', listResponse) + context.assert(MODULE, '报价列表包含本轮长ID与recordVersion', listResponse, + Array.isArray(page?.records) && page.records.some((item) => asId(item.id) === id && recordVersion(item.recordVersion)), + `id=${id}, total=${page?.total ?? 'missing'}`, BLOCKER) + const staleVersion = Number(detail.summary.recordVersion) const updatePayload = quoteUpdatePayload({ ...payload, transitPrices: payload.transitPrices }, context, staleVersion) const updateResponse = await context.requestOverseas('PUT', path, updatePayload) @@ -191,7 +219,9 @@ async function quoteLifecycle(context, resource) { detail.summary.status === 'ACTIVE', `status=${detail.summary.status}`, BLOCKER) const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/overseas-quotes/export?keyword=${encodeURIComponent(context.marker)}`) - assertBinaryCsv(context, '导出海外报价CSV', csv) + assertBinaryCsv(context, '导出海外报价CSV', csv, + '报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间', + [id, context.marker]) return true } @@ -252,9 +282,17 @@ async function exchangeLifecycle(context, resource) { context.assert(MODULE, '汇率状态切换为INACTIVE', statusResponse, rate.status === 'INACTIVE', `status=${rate.status}`, BLOCKER) + const listResponse = await context.requestOverseas('GET', + `${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=${month}&monthTo=${month}&status=INACTIVE`) + const page = requireData(context, '分页查询marker月汇率', listResponse) + context.assert(MODULE, '汇率列表包含本轮月份与recordVersion', listResponse, + Array.isArray(page?.records) && page.records.some((item) => item.month === month && recordVersion(item.recordVersion)), + `month=${month}, total=${page?.total ?? 'missing'}`, BLOCKER) + const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/exchange-rates/export?monthFrom=${month}&monthTo=${month}`) - assertBinaryCsv(context, '导出marker月汇率CSV', csv) + assertBinaryCsv(context, '导出marker月汇率CSV', csv, + '月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间', [month, 'INACTIVE']) } function vehiclePayload(context, edited = false) { @@ -316,8 +354,48 @@ async function vehicleLifecycle(context, resource) { context.assert(MODULE, '车辆状态切换为MAINTENANCE', statusResponse, vehicle.status === 'MAINTENANCE', `status=${vehicle.status}`, BLOCKER) + const listResponse = await context.requestOverseas('GET', + `${OVERSEAS_BASE}/vehicles?page=1&size=20&keyword=${encodeURIComponent(createPayload.plateNumber)}`) + const page = requireData(context, '分页查询marker车辆', listResponse) + context.assert(MODULE, '车辆列表包含本轮长ID与recordVersion', listResponse, + Array.isArray(page?.records) && page.records.some((item) => asId(item.id) === id && recordVersion(item.recordVersion)), + `id=${id}, total=${page?.total ?? 'missing'}`, BLOCKER) + const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/vehicles/export?keyword=${encodeURIComponent(createPayload.plateNumber)}`) - assertBinaryCsv(context, '导出marker车辆CSV', csv) + assertBinaryCsv(context, '导出marker车辆CSV', csv, + '车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间', + [id, createPayload.plateNumber, 'MAINTENANCE']) +} + +async function verifyCanonicalPermissionDenial(context, resource) { + const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase() + const roleResponse = await context.request('POST', '/api/platform/v1/iam/roles', { + roleCode: `P4_MD_DENY_${suffix}`.slice(0, 64), roleName: `四期主数据拒绝-${suffix}`, description: context.marker, + }) + const role = requireData(context, '创建无主数据权限角色', roleResponse) + const roleId = asId(role?.id) + if (!roleId) throw new Error('无主数据权限角色未返回ID') + resource.permissionProbe = { roleId, membershipId: null, roleStatus: 'ACTIVE', userStatus: null } + + const phone = await context.findAvailablePhone(MODULE) + const username = `p4_md_deny_${suffix}`.slice(0, 64).toLowerCase() + const password = `P4md${suffix.slice(-8)}A1!` + const userResponse = await context.request('POST', '/api/platform/v1/iam/users', { + username, displayName: `四期主数据拒绝-${suffix}`, phone, + email: `${username}@example.com`, tenantId: '0', roleIds: [roleId], password, status: 'ACTIVE', + }) + const user = requireData(context, '创建无主数据权限用户', userResponse) + const membershipId = asId(user?.membershipId) + if (!membershipId) throw new Error('无主数据权限用户未返回membershipId') + resource.permissionProbe.membershipId = membershipId + resource.permissionProbe.userStatus = 'ACTIVE' + + const loginResponse = await context.request('POST', '/api/auth/login', { username, password }, '') + const login = requireData(context, '无主数据权限用户登录', loginResponse) + if (!login?.overseasAccessToken) throw new Error('无主数据权限用户未签发海外Token') + const denied = await context.requestOverseas('GET', + `${OVERSEAS_BASE}/overseas-quotes?page=1&size=1`, undefined, {}, login.overseasAccessToken) + assertForbidden(context, '无quote:list权限访问海外报价被拒绝', denied) } async function cleanupPartnerWarehouse(context, resource) { @@ -355,16 +433,33 @@ async function cleanupVersioned(context, resource, key, path, extract) { } } +async function cleanupPermissionProbe(context, resource) { + const probe = resource.permissionProbe + if (!probe) return + if (probe.membershipId && probe.userStatus !== 'DISABLED') { + const response = await context.request('PUT', `/api/platform/v1/iam/users/${probe.membershipId}/status`, { status: 'DISABLED' }) + context.apiSuccess(MODULE, '无权限探针用户finally停用', response, `membershipId=${probe.membershipId}`, BLOCKER) + if (success(response)) probe.userStatus = 'DISABLED' + } + if (probe.roleId && probe.roleStatus !== 'DISABLED') { + const response = await context.request('PUT', `/api/platform/v1/iam/roles/${probe.roleId}/status`, { status: 'DISABLED' }) + context.apiSuccess(MODULE, '无权限探针角色finally停用', response, `roleId=${probe.roleId}`, BLOCKER) + if (success(response)) probe.roleStatus = 'DISABLED' + } +} + export async function run(context) { const resource = { marker: context.marker } context.createdResources.phase4OverseasMasterData = resource try { await continueLifecycle(context, '海外报价', async () => { + await captureWarehouseOptionsBeforeCreate(context, resource) await createAndVerifyPartnerWarehouse(context, resource) await quoteLifecycle(context, resource) }) await continueLifecycle(context, '月度汇率', () => exchangeLifecycle(context, resource)) await continueLifecycle(context, '车辆主数据', () => vehicleLifecycle(context, resource)) + await continueLifecycle(context, 'canonical权限拒绝', () => verifyCanonicalPermissionDenial(context, resource)) } finally { await safeCleanup(context, '海外报价', async () => { if (!resource.quote?.id) return @@ -382,5 +477,6 @@ export async function run(context) { await cleanupVersioned(context, resource, 'vehicle', path, (value) => value) }) await safeCleanup(context, '合作仓', () => cleanupPartnerWarehouse(context, resource)) + await safeCleanup(context, 'canonical权限探针', () => cleanupPermissionProbe(context, resource)) } } diff --git a/scripts/admin-test-env-modules/system.mjs b/scripts/admin-test-env-modules/system.mjs index 7a1c877b6..a76a33c2a 100644 --- a/scripts/admin-test-env-modules/system.mjs +++ b/scripts/admin-test-env-modules/system.mjs @@ -146,6 +146,21 @@ export async function run(context) { const resetLoginResponse = await context.request('POST', '/api/auth/login', { username, password: resetPassword }, '') const resetLoginPassed = resetLoginResponse.http === 200 && String(resetLoginResponse.body?.code) === '200' && Boolean(resetLoginResponse.body?.data?.accessToken) context.addStep(module, '内部用户使用重置密码登录', resetLoginResponse, resetLoginPassed, `username=${username}`) + if (context.options.modules.includes('phase4-cross-region-events')) { + const restrictedOverseasToken = resetLoginResponse.body?.data?.overseasAccessToken || '' + context.assert(module, '受限角色登录同时签发海外Token', resetLoginResponse, + Boolean(restrictedOverseasToken), `tokenPresent=${Boolean(restrictedOverseasToken)}`, + { scenario: 'canonical-overseas-permission-denial', deploymentBlocker: true }) + if (restrictedOverseasToken) { + const denied = await context.requestOverseas('GET', '/api/platform/v1/last-leg/orders?page=1&size=1', + undefined, {}, restrictedOverseasToken) + context.assert(module, '缺少canonical海外订单权限必须403', denied, + (denied.http === 403 || ['403', 'A0403'].includes(String(denied.body?.code))) + && Boolean(denied.requestId) && Boolean(denied.traceId), + `HTTP=${denied.http}, code=${denied.body?.code ?? 'missing'}, requestId=${denied.requestId || 'missing'}, traceId=${denied.traceId || 'missing'}`, + { scenario: 'canonical-overseas-permission-denial', deploymentBlocker: true }) + } + } const disableUserResponse = await context.request('PUT', `/api/platform/v1/iam/users/${membershipId}/status`, { status: 'DISABLED' }) const disabledUser = context.apiSuccess(module, '停用平台内部用户', disableUserResponse) diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index 058b61d42..aefb917c2 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -10,6 +10,7 @@ test('phase-four HTTP acceptance modules are registered in the Admin acceptance for (const module of [ 'phase4-overseas-warehouse', 'phase4-overseas-pda', + 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', From bffe7d3b23be22bf2e6aa45baa1acbb53f28d4a0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 19:41:16 +0800 Subject: [PATCH 051/115] test: strengthen overseas warehouse PDA contracts --- .../openapi/overseas-business-v1.yaml | 2 +- .../InboundStockReceiptServiceTest.java | 4 +- ...ReceivingContainerServiceBehaviorTest.java | 16 +- ...hase4-overseas-warehouse-contract.test.mjs | 58 +++++-- .../phase4-overseas-warehouse.mjs | 143 ++++++++++++++++-- 5 files changed, 192 insertions(+), 31 deletions(-) diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 46c384e62..ed8be5397 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -459,7 +459,7 @@ components: type: object required: [code, message, timestamp] properties: - code: { type: string, example: '00000' } + code: { type: string, example: '200' } message: { type: string } requestId: { type: string, nullable: true } traceId: { type: string, nullable: true } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index 7bde9af07..3f1f93bb2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -133,8 +133,8 @@ class InboundStockReceiptServiceTest { ArgumentCaptor requests = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class); verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), requests.capture()); - assertEquals(List.of("SKU-1:2001", "SKU-2:2002"), requests.getAllValues().stream() - .map(value -> value.skuCode() + ":" + value.warehousePalletId()).toList()); + assertEquals(java.util.Set.of("SKU-1:2001", "SKU-2:2002"), requests.getAllValues().stream() + .map(value -> value.skuCode() + ":" + value.warehousePalletId()).collect(java.util.stream.Collectors.toSet())); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index a66a742d8..0ebcaab05 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -166,7 +166,7 @@ class ReceivingContainerServiceBehaviorTest { doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt")) .when(workflow).validateBulkResolved(any(), any(), any(), anyInt()); assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", - new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 9, "over-key"))) + new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 2, "over-key"))) .isInstanceOf(ResponseStatusException.class); verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); @@ -186,7 +186,6 @@ class ReceivingContainerServiceBehaviorTest { @Test void containerCompletionRequiresEveryInboundCodeToBalanceExactly() { stubCurrent("FBA", "CONT-A"); - stubSku(); InboundPlanPalletAllocation partial = new InboundPlanPalletAllocation(); partial.setLineId(10L); partial.setPickedPackageCount(1); when(allocations.selectList(any())).thenReturn(List.of(partial)); @@ -232,6 +231,19 @@ class ReceivingContainerServiceBehaviorTest { verify(unloadPhotos).validate("100", 8L, 3L); } + @Test + void signRejectsDuplicateRolesBeforeFileValidation() { + when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); + ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of( + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L), + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 2L), + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-sign"); + + assertThatThrownBy(() -> service.sign("100", "7", "receiver", request)) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + verifyNoInteractions(unloadPhotos); + } + @Test void bulkRejectsSkuOverReceiptBeforeWarehouseLookup() { stubCurrent("FBA", "CONT-A"); diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs index e25c7dd6a..24e9c3b61 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs @@ -12,7 +12,7 @@ const source = fs.readFileSync(path.join(here, 'phase4-overseas-warehouse.mjs'), function response(data, code = '200') { return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, requestId: 'request-test', traceId: 'trace-test' } } function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } } function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehouseRecords = [{ id: '20001', status: 'ACTIVE', warehouseCode: 'LAX', warehouseName: '洛杉矶仓' }] } = {}) { - const calls = []; const steps = []; const gaps = []; let createdPallet = null + const calls = []; const steps = []; const gaps = []; let createdPallet = null; let createdLocation = null; let createdRelabel = null const context = { context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps, async request(method, requestPath) { calls.push({ channel: 'domestic', method, path: requestPath }); return response(page(tenants)) }, @@ -20,11 +20,23 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou calls.push({ channel: 'overseas', method, path: requestPath, body }) if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001') if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null) + if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/locations')) { createdLocation = { id: '72002', customerId: '0', warehouseId: '20001', ...body }; return response('72002') } + if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return response(null) } + if (method === 'DELETE' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = null; return response(null) } if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', version: 0, ...body }; return response('71001') } if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) { if (Number(body.version) !== Number(createdPallet.version)) return response(null, 'A0409') createdPallet = { ...createdPallet, ...body, version: createdPallet.version + 1 }; return response(null) } + if (method === 'POST' && requestPath.startsWith('/api/platform/v1/last-leg/relabel-applications?')) { + if (createdRelabel && body.remark !== createdRelabel.remark) return response(null, 'A0409') + if (!createdRelabel) createdRelabel = { ...body, relabelNo: 'FBR-TEST', status: 'PENDING_PROCESSING' } + return response({ application: createdRelabel, items: body.items, history: [] }) + } + if (method === 'PUT' && requestPath.endsWith('/relabel-applications/FBR-TEST/status')) { + createdRelabel = { ...createdRelabel, status: body.status } + return response({ application: createdRelabel, items: createdRelabel.items, history: [] }) + } if (requestPath.startsWith('/api/platform/v1/last-leg/orders?')) return response(page([{ id: '30001', orderNo: 'LL-1', currentStatus: 'PENDING_ASSIGNMENT' }])) if (requestPath.includes('/last-leg/orders/30001?')) return response({ order: { id: '30001' }, cargo: [], statusHistory: [] }) if (requestPath.startsWith('/api/platform/v1/last-leg/relabel-applications?')) return response(page([{ id: '40001', relabelNo: 'RL-1', status: 'PENDING_PROCESSING' }])) @@ -37,6 +49,9 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou if (requestPath.includes('/inventory/ledger?')) return response(page([{ id: '62001', customerId: '10001', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-21T10:00:00' }])) if (requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords)) if (requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '10001', status: 'ACTIVE' } }) + if (requestPath.endsWith('/warehouse-admin/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', zoneCode: 'A', locationCode: 'A-01', locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE' }])) + if (requestPath.endsWith('/warehouse-admin/20001/locations/72001')) return response({ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', status: 'ACTIVE' }) + if (requestPath.endsWith('/warehouse-admin/20001/locations/72002')) return response(createdLocation) if (requestPath.endsWith('/warehouse-admin/20001/pallets?page=1&size=20')) return response(page([{ id: '70001', customerId: '0', warehouseId: '20001', palletCode: 'PLT-1', status: 'IDLE' }])) if (requestPath.endsWith('/warehouse-admin/20001/pallets/70001')) return response({ id: '70001', customerId: '0', warehouseId: '20001' }) if (requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) return response(createdPallet) @@ -59,16 +74,18 @@ test('模块只依赖注入请求,不读取环境或自行联网', () => { assert.doesNotMatch(source, /process\.env|fetch\(|requestBinary/) }) -test('既有仓库场景只写本轮托盘生命周期', () => { +test('既有仓库场景只写本轮库位托盘与换标生命周期', () => { const context = contextFixture() return run(context).then(() => { assert.ok(context.calls.length >= 12) - assert.ok(context.calls.every((call) => ['GET', 'POST', 'PUT'].includes(call.method))) + assert.ok(context.calls.every((call) => ['GET', 'POST', 'PUT', 'DELETE'].includes(call.method))) assert.equal(context.calls.filter((call) => call.channel === 'domestic').length, 1) assert.ok(context.calls.filter((call) => call.channel === 'overseas').every((call) => call.path.startsWith('/api/platform/v1/'))) const writes = context.calls.filter((call) => call.method !== 'GET') - assert.deepEqual(writes.map((call) => call.method), ['POST', 'PUT', 'PUT', 'PUT']) - assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/pallets'))) + assert.ok(writes.some((call) => call.method === 'DELETE' && call.path.includes('/locations/72002'))) + assert.ok(writes.some((call) => call.path.includes('/warehouse-admin/20001/pallets'))) + assert.ok(writes.some((call) => call.path.includes('/relabel-applications'))) + assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/') || call.path.includes('/relabel-applications'))) assert.ok(context.steps.every((step) => step.passed), context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n')) }) }) @@ -76,21 +93,29 @@ test('既有仓库场景只写本轮托盘生命周期', () => { test('覆盖冻结的海外只读列表与有记录才查详情', async () => { const context = contextFixture() await run(context) - for (const pathToken of ['/last-leg/orders?', '/relabel-applications?', '/inbound-plans?', '/inventory/summary?', '/inventory/balances?', '/inventory/ledger?', '/warehouse-admin?', '/pallets?']) { + for (const pathToken of ['/last-leg/orders?', '/relabel-applications?', '/inbound-plans?', '/inventory/summary?', '/inventory/balances?', '/inventory/ledger?', '/warehouse-admin?', '/locations?', '/pallets?']) { assert.ok(context.calls.some((call) => call.channel === 'overseas' && call.path.includes(pathToken)), pathToken) } for (const detailToken of ['/orders/30001?', '/relabel-applications/RL-1?', '/inbound-plans/IP-1?', '/balances/60001/positions?', '/warehouse-admin/20001?', '/pallets/70001']) { assert.ok(context.calls.some((call) => call.path.includes(detailToken)), detailToken) } - assert.deepEqual(context.context.phase4OverseasWarehouse, { customerId: '10001', warehouseId: '20001', palletId: '71001' }) - assert.deepEqual(context.createdResources.phase4OverseasWarehouse, { marker: 'ACC-PHASE4-TEST', customerId: '10001', warehouseId: '20001', palletId: '71001', terminalStatus: 'SCRAPPED' }) + for (const call of context.calls.filter((item) => item.path.includes('/inventory/summary?') || item.path.includes('/inventory/balances/export?') || item.path.includes('/inventory/ledger/export?'))) { + const params = new URL(`http://acceptance${call.path}`).searchParams + assert.equal(params.has('page'), false, call.path) + assert.equal(params.has('size'), false, call.path) + } + assert.deepEqual(context.context.phase4OverseasWarehouse, { customerId: '10001', warehouseId: '20001', palletId: '71001', relabelNo: 'FBR-TEST' }) + assert.deepEqual(context.createdResources.phase4OverseasWarehouse, { + marker: 'ACC-PHASE4-TEST', customerId: '10001', warehouseId: '20001', palletId: '71001', terminalStatus: 'SCRAPPED', + locationId: '72002', locationTerminalStatus: 'DELETED', relabelNo: 'FBR-TEST', relabelTerminalStatus: 'COMPLETED', + }) }) -test('缺少客户时记录缺口,缺少仓库时通过正式接口创建并停用fixture', async () => { +test('缺少客户时明确失败,缺少仓库时通过正式接口创建并停用fixture', async () => { const noCustomer = contextFixture({ tenants: [] }) await run(noCustomer) - assert.equal(noCustomer.gaps.length, 1) - assert.match(noCustomer.gaps[0][3], /requestId=request-test.*traceId=trace-test/) + assert.equal(noCustomer.gaps.length, 0) + assert.ok(noCustomer.steps.some((step) => step.name === '海外仓验收必须取得ACTIVE客户' && !step.passed)) assert.equal(noCustomer.calls.filter((call) => call.channel === 'overseas').length, 0) const noWarehouse = contextFixture({ warehouseRecords: [] }) @@ -104,14 +129,17 @@ test('缺少客户时记录缺口,缺少仓库时通过正式接口创建并 test('合同覆盖库存CSV并明确排除暂缓、离仓、PDA与内部路径', () => { assert.match(source, /inventory\/balances\/export/) assert.match(source, /inventory\/ledger\/export/) - assert.match(source, /inventory\/summary[^\n]+customerId, page: 1, size: 200/) - assert.match(source, /inventory\/balances\/export[^\n]+customerId, page: 1, size: 200/) - assert.match(source, /inventory\/ledger\/export[^\n]+customerId, page: 1, size: 200/) + assert.match(source, /inventory\/summary[^\n]+\{ customerId \}/) + assert.match(source, /inventory\/balances\/export[^\n]+\{ customerId \}/) + assert.match(source, /inventory\/ledger\/export[^\n]+\{ customerId \}/) for (const forbidden of ['/cartons', '/products', '/outbound', '/dispatch', '/delivery', '/pda', '/internal']) assert.equal(source.includes(forbidden), false, forbidden) assert.match(source, /requestId=/) assert.match(source, /traceId=/) assert.match(source, /longId\(plan\.sourceOrderId\)/) assert.match(source, /id\(pallet\.customerId\) === '0'/) - assert.match(source, /换标创建与状态生命周期/) + assert.match(source, /创建本轮换标单/) + assert.match(source, /换标单同幂等键不同请求返回A0409/) + assert.match(source, /创建本轮公共库位/) assert.match(source, /公共托盘旧版本编辑返回冲突/) + assert.doesNotMatch(source, /deploymentBlocker|addCoverageGap|coverageGap/) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs index 39b76d93e..0d64db727 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs @@ -1,6 +1,6 @@ const MODULE = 'phase4-overseas-warehouse' const SCENARIO = 'phase4-overseas-warehouse' -const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const OPTIONS = { scenario: SCENARIO } function id(value) { return value == null ? '' : String(value) } function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 } @@ -52,8 +52,8 @@ function assertPage(context, name, result) { context.assert(MODULE, `${name}分页结构正确`, result.response, pageValid(result.data), `records=${result.data?.records?.length ?? 'missing'}, page=${result.data?.page ?? 'missing'}, size=${result.data?.size ?? 'missing'}, total=${result.data?.total ?? 'missing'}; ${trace(result.response)}`, OPTIONS) } -function coverageGap(context, name, reason, category = 'prerequisite') { - context.addCoverageGap(MODULE, SCENARIO, name, reason, category) +function businessConflict(response) { + return response?.http === 409 || (response?.http === 200 && String(response?.body?.code) === 'A0409') } export async function run(context) { @@ -63,7 +63,8 @@ export async function run(context) { const customer = records(tenantsResult.data).find((item) => longId(item?.id) && item?.status === 'ACTIVE') const customerId = id(customer?.id) if (!customerId) { - coverageGap(context, '海外只读接口显式客户范围', `IAM ACTIVE 客户列表未返回可用 customerId;${trace(tenantsResult.response)}`) + context.assert(MODULE, '海外仓验收必须取得ACTIVE客户', tenantsResult.response, false, + `IAM ACTIVE 客户列表未返回可用 customerId;${trace(tenantsResult.response)}`, OPTIONS) return } @@ -108,7 +109,7 @@ export async function run(context) { `listNo=${plan.planNo}, detailNo=${detail.data?.planNo || 'missing'}, sourceOrderId=${id(detail.data?.sourceOrderId) || 'missing'}; ${trace(detail.response)}`, OPTIONS) } - const summary = await overseasGet(context, '查询库存汇总', query('/api/platform/v1/last-leg/inventory/summary', { customerId, page: 1, size: 200 })) + const summary = await overseasGet(context, '查询库存汇总(不传分页参数)', query('/api/platform/v1/last-leg/inventory/summary', { customerId })) context.assert(MODULE, '库存汇总关键数量非负', summary.response, ['inboundOrderCount', 'palletCount', 'totalPackages', 'availablePackages', 'holdPackages', 'inTransitPackages', 'receivingPackages', 'procurementPackages'] .every((field) => nonNegative(summary.data?.[field])), @@ -136,11 +137,8 @@ export async function run(context) { && Number.isFinite(Number(ledgerRow.beforePackages)) && Number.isFinite(Number(ledgerRow.deltaPackages)) && Number.isFinite(Number(ledgerRow.afterPackages)) && nonBlank(ledgerRow.occurredAt), `id=${id(ledgerRow.id) || 'missing'}, type=${ledgerRow.businessType || 'missing'}, delta=${ledgerRow.deltaPackages ?? 'missing'}; ${trace(ledger.response)}`, OPTIONS) - await overseasCsv(context, '下载SKU库存余额CSV', query('/api/platform/v1/last-leg/inventory/balances/export', { customerId, page: 1, size: 200 })) - await overseasCsv(context, '下载进销存流水CSV', query('/api/platform/v1/last-leg/inventory/ledger/export', { customerId, page: 1, size: 200 })) - - coverageGap(context, '换标创建与状态生命周期', - '当前 Admin 换标新增/审核仍使用不可提交的契约提示弹框,前端未真实接入写 API;验收模块不越过页面范围直接创建换标单', 'frontend-scope') + await overseasCsv(context, '下载SKU库存余额CSV(不传分页参数)', query('/api/platform/v1/last-leg/inventory/balances/export', { customerId })) + await overseasCsv(context, '下载进销存流水CSV(不传分页参数)', query('/api/platform/v1/last-leg/inventory/ledger/export', { customerId })) let warehouse = await findWarehouse(context, records(tenantsResult.data), customerId) let ownedWarehouse = false @@ -152,6 +150,19 @@ export async function run(context) { const warehouseId = id(warehouse.id) context.createdResources.phase4OverseasWarehouse.customerId = customerId context.createdResources.phase4OverseasWarehouse.warehouseId = warehouseId + const locations = await overseasGet(context, '查询公共库位列表', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { page: 1, size: 20 })) + assertPage(context, '公共库位列表', locations) + const location = records(locations.data)[0] + if (location) { + context.assert(MODULE, '公共库位关键字段正确', locations.response, + longId(location.id) && id(location.warehouseId) === warehouseId && id(location.customerId) === '0' + && nonBlank(location.locationCode) && nonBlank(location.status), + `id=${id(location.id) || 'missing'}, customerId=${id(location.customerId) || 'missing'}, status=${location.status || 'missing'}; ${trace(locations.response)}`, OPTIONS) + const detail = await overseasGet(context, '查询公共库位详情', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(id(location.id))}`) + context.assert(MODULE, '公共库位详情对应列表记录', detail.response, + id(detail.data?.id) === id(location.id) && id(detail.data?.warehouseId) === warehouseId && id(detail.data?.customerId) === '0', + `listId=${id(location.id)}, detailId=${id(detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS) + } const pallets = await overseasGet(context, '查询公共托盘列表', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { page: 1, size: 20 })) assertPage(context, '公共托盘列表', pallets) const pallet = records(pallets.data)[0] @@ -167,13 +178,123 @@ export async function run(context) { } try { + await verifyLocationLifecycle(context, warehouseId) await verifyPalletLifecycle(context, warehouseId) - context.context.phase4OverseasWarehouse = { customerId, warehouseId, palletId: context.createdResources.phase4OverseasWarehouse.palletId } + await verifyRelabelLifecycle(context, customerId, warehouseId) + context.context.phase4OverseasWarehouse = { + customerId, warehouseId, + palletId: context.createdResources.phase4OverseasWarehouse.palletId, + relabelNo: context.createdResources.phase4OverseasWarehouse.relabelNo, + } } finally { if (ownedWarehouse) await retireWarehouseFixture(context, customerId, warehouseId) } } +async function verifyLocationLifecycle(context, warehouseId) { + const marker = String(context.marker || '').replace(/[^A-Za-z0-9_-]/g, '').slice(-40).toUpperCase() || 'PHASE4' + const locationCode = `ACC-${marker}`.slice(0, 64) + let locationId = '' + try { + const created = await overseasMutation(context, 'POST', '创建本轮公共库位', + `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { + zoneCode: 'ACC', locationCode, locationType: 'STORAGE', maxLoadKg: 1000, + status: 'ACTIVE', remark: context.marker, + }) + locationId = id(created.data) + context.createdResources.phase4OverseasWarehouse.locationId = locationId || null + context.assert(MODULE, '公共库位创建返回长ID', created.response, longId(locationId), + `locationId=${locationId || 'missing'}, marker=${context.marker}; ${trace(created.response)}`, OPTIONS) + if (!locationId) return + const detailPath = `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}` + const detail = await overseasGet(context, '查询本轮公共库位详情', detailPath) + context.assert(MODULE, '本轮公共库位创建事实正确', detail.response, + id(detail.data?.id) === locationId && id(detail.data?.customerId) === '0' + && detail.data?.locationCode === locationCode && detail.data?.remark === context.marker, + `locationId=${id(detail.data?.id) || 'missing'}, code=${detail.data?.locationCode || 'missing'}; ${trace(detail.response)}`, OPTIONS) + const updated = await overseasMutation(context, 'PUT', '编辑本轮公共库位', detailPath, { + zoneCode: 'ACC', locationCode, locationType: 'STORAGE', maxLoadKg: 1200, + status: 'ACTIVE', remark: `${context.marker}-edited`, + }) + context.assert(MODULE, '公共库位编辑成功', updated.response, updated.data === null, + `locationId=${locationId}; ${trace(updated.response)}`, OPTIONS) + const after = await overseasGet(context, '回查公共库位编辑结果', detailPath) + context.assert(MODULE, '公共库位编辑字段已生效', after.response, + Number(after.data?.maxLoadKg) === 1200 && after.data?.remark === `${context.marker}-edited`, + `maxLoadKg=${after.data?.maxLoadKg ?? 'missing'}, remark=${after.data?.remark || 'missing'}; ${trace(after.response)}`, OPTIONS) + } finally { + if (locationId) { + const removed = await overseasMutation(context, 'DELETE', '删除本轮公共库位', + `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`) + context.createdResources.phase4OverseasWarehouse.locationTerminalStatus = removed.data === null ? 'DELETED' : null + context.assert(MODULE, '本轮公共库位已删除收口', removed.response, removed.data === null, + `locationId=${locationId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.locationTerminalStatus || 'missing'}; ${trace(removed.response)}`, OPTIONS) + } + } +} + +async function verifyRelabelLifecycle(context, customerId, warehouseId) { + const marker = String(context.marker || 'PHASE4') + const idempotencyKey = `${marker}:RELABEL`.slice(0, 255) + const skuSuffix = marker.replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase() || 'PHASE4' + const payload = { + customerOrderNo: `${marker}-RELABEL`, overseasWarehouseId: warehouseId, + labelDeadline: new Date(Date.now() + 86400000).toISOString().slice(0, 19), + contactName: 'Phase4 QA', contactPhone: '+1-213-555-0199', remark: marker, idempotencyKey, + items: [{ skuCode: `OLD-${skuSuffix}`.slice(0, 128), productNameZh: `${marker} 换标商品`, + totalPackageCount: 2, notifiedPackageCount: 2, shipmentNo: `SHIP-${skuSuffix}`.slice(0, 128), + newSkuCode: `NEW-${skuSuffix}`.slice(0, 128), labelRequirement: marker }], + } + let relabelNo = '' + let terminalStatus = '' + try { + const created = await overseasMutation(context, 'POST', '创建本轮换标单', + query('/api/platform/v1/last-leg/relabel-applications', { customerId }), payload) + relabelNo = id(created.data?.application?.relabelNo) + context.createdResources.phase4OverseasWarehouse.relabelNo = relabelNo || null + context.assert(MODULE, '换标单创建事实正确', created.response, + nonBlank(relabelNo) && created.data?.application?.customerOrderNo === payload.customerOrderNo + && created.data?.application?.status === 'PENDING_PROCESSING' + && Array.isArray(created.data?.items) && created.data.items.length === 1, + `relabelNo=${relabelNo || 'missing'}, status=${created.data?.application?.status || 'missing'}; ${trace(created.response)}`, OPTIONS) + if (!relabelNo) return + + const replay = await overseasMutation(context, 'POST', '换标单幂等精确重放', + query('/api/platform/v1/last-leg/relabel-applications', { customerId }), payload) + context.assert(MODULE, '换标单精确重放返回同一单号', replay.response, + replay.data?.application?.relabelNo === relabelNo, + `expected=${relabelNo}, actual=${replay.data?.application?.relabelNo || 'missing'}; ${trace(replay.response)}`, OPTIONS) + + const conflict = await context.requestOverseas('POST', + query('/api/platform/v1/last-leg/relabel-applications', { customerId }), { ...payload, remark: `${marker}-DIFFERENT` }) + context.addStep(MODULE, '换标单同幂等键不同请求返回A0409', conflict, businessConflict(conflict), trace(conflict), OPTIONS) + + const processing = await overseasMutation(context, 'PUT', '换标单进入处理中', + `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/status`, + { status: 'PROCESSING', remark: marker }) + context.assert(MODULE, '换标单状态推进为PROCESSING', processing.response, + processing.data?.application?.status === 'PROCESSING', + `status=${processing.data?.application?.status || 'missing'}; ${trace(processing.response)}`, OPTIONS) + const completed = await overseasMutation(context, 'PUT', '完成本轮换标单', + `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/status`, + { status: 'COMPLETED', remark: `${marker}-completed` }) + terminalStatus = completed.data?.application?.status || '' + context.createdResources.phase4OverseasWarehouse.relabelTerminalStatus = terminalStatus || null + context.assert(MODULE, '本轮换标单已完成收口', completed.response, terminalStatus === 'COMPLETED', + `relabelNo=${relabelNo}, status=${terminalStatus || 'missing'}; ${trace(completed.response)}`, OPTIONS) + } finally { + if (relabelNo && terminalStatus !== 'COMPLETED') { + const cancelled = await context.requestOverseas('PUT', + `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/status`, + { status: 'CANCELLED', remark: `${marker}-acceptance-cleanup` }) + const status = cancelled?.body?.data?.application?.status + context.createdResources.phase4OverseasWarehouse.relabelTerminalStatus = status || null + context.addStep(MODULE, '异常路径换标单取消收口', cancelled, + cancelled?.http === 200 && String(cancelled?.body?.code) === '200' && status === 'CANCELLED', trace(cancelled), OPTIONS) + } + } +} + async function createWarehouseFixture(context, customerId) { const suffix = String(context.marker || '').replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase() const payload = { From ac806704bec15aa58f4185c6217904e8816bbe7f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 19:42:20 +0800 Subject: [PATCH 052/115] chore(test): keep master-data changes out of event gate --- ...se4-overseas-master-data-contract.test.mjs | 127 ++++----------- .../phase4-overseas-master-data.mjs | 144 +++--------------- 2 files changed, 53 insertions(+), 218 deletions(-) diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs index 7ffc77066..a83d35208 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs @@ -4,16 +4,10 @@ import test from 'node:test' import { run } from './phase4-overseas-master-data.mjs' const source = readFileSync(new URL('./phase4-overseas-master-data.mjs', import.meta.url), 'utf8') -const controllerSource = [ - '../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteController.java', - '../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateController.java', - '../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleController.java', -].map((path) => readFileSync(new URL(path, import.meta.url), 'utf8')).join('\n') const DOMESTIC_BASE = '/api/platform/v1/warehouse-admin' const OVERSEAS_BASE = '/api/platform/v1/last-leg/master-data' const ok = (data) => ({ http: 200, body: { code: '200', message: 'success', data }, requestId: 'req-p4', traceId: 'trace-p4' }) const conflict = () => ({ http: 200, body: { code: 'A0409', message: 'record version conflict', data: null }, requestId: 'req-conflict' }) -const forbidden = () => ({ http: 403, body: { code: 'A0403', message: 'Access denied', data: null }, requestId: 'req-forbidden' }) function detailQuote(state) { return { @@ -28,25 +22,21 @@ function vehicleWire(state) { return { ...state.vehicle, mileageKm: String(state.vehicle.mileageKm), recordVersion: String(state.vehicle.recordVersion) } } -function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) { +function fakeContext() { const calls = [] const coverageGaps = [] - const steps = [] const marker = 'ADMIN-API-20260821123456789' - const state = { warehouse: null, quote: null, exchange: null, vehicle: null, optionReads: 0, role: null, user: null } + const state = { warehouse: null, quote: null, exchange: null, vehicle: null } const context = { - marker, calls, coverageGaps, steps, createdResources: {}, - async findAvailablePhone() { return '13800138000' }, + marker, calls, coverageGaps, createdResources: {}, apiSuccess(_module, name, response) { assert.equal(response?.http, 200, `${name}: HTTP`) assert.equal(String(response?.body?.code), '200', `${name}: code`) return response.body.data }, - assert(_module, name, _response, condition, evidence, options = {}) { - const passed = Boolean(condition) - steps.push({ name, passed, deploymentBlocker: Boolean(options.deploymentBlocker && !passed), evidence }) - if (!passed && !softAssertions) assert.equal(passed, true, `${name}: ${evidence}`) - return passed + assert(_module, name, _response, condition, evidence) { + assert.equal(Boolean(condition), true, `${name}: ${evidence}`) + return true }, expectConflict(_module, name, response) { assert.equal(response.http, 200, `${name}: HTTP`) @@ -74,40 +64,12 @@ function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) { state.warehouse.status = 'INACTIVE' return ok(null) } - if (method === 'POST' && path === '/api/platform/v1/iam/roles') { - state.role = { id: '940000000000000001', ...body, status: 'ACTIVE' } - return ok({ ...state.role }) - } - if (method === 'POST' && path === '/api/platform/v1/iam/users') { - state.user = { membershipId: '950000000000000001', ...body, status: 'ACTIVE' } - return ok({ ...state.user }) - } - if (method === 'POST' && path === '/api/auth/login') { - return ok({ accessToken: 'deny-domestic-token', overseasAccessToken: 'deny-overseas-token', overseasExpiresIn: 900 }) - } - if (method === 'PUT' && path === '/api/platform/v1/iam/users/950000000000000001/status') { - state.user.status = body.status - return ok(null) - } - if (method === 'PUT' && path === '/api/platform/v1/iam/roles/940000000000000001/status') { - state.role.status = body.status - return ok(null) - } assert.fail(`unexpected domestic call ${method} ${path}`) }, - async requestOverseas(method, path, body, extraHeaders = {}, token = 'admin-overseas-token') { - calls.push({ gateway: 'overseas', method, path, body, extraHeaders, tokenKind: token === 'deny-overseas-token' ? 'denied' : 'admin' }) + async requestOverseas(method, path, body) { + calls.push({ gateway: 'overseas', method, path, body }) assert.notEqual(method, 'DELETE') - if (token === 'deny-overseas-token') return forbidden() if (method === 'GET' && path === `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) { - state.optionReads += 1 - if (!state.warehouse) return ok([]) - return ok([{ warehouseId: '910000000000000001', warehouseCode: state.warehouse.warehouseCode, - warehouseName: state.warehouse.warehouseName, countryCode: 'US' }]) - } - if (method === 'GET' && path === `${OVERSEAS_BASE}/overseas-quotes/warehouse-options?refresh=true`) { - state.optionReads += 1 - if (refreshEmpty) return ok([]) return ok([{ warehouseId: '910000000000000001', warehouseCode: state.warehouse.warehouseCode, warehouseName: state.warehouse.warehouseName, countryCode: 'US' }]) } @@ -119,10 +81,6 @@ function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) { } return ok(detailQuote(state.quote)) } - if (method === 'GET' && path.startsWith(`${OVERSEAS_BASE}/overseas-quotes?page=1&size=20&keyword=`)) { - return ok({ records: state.quote ? [{ ...state.quote.summary }] : [], page: 1, size: 20, - total: state.quote ? 1 : 0, pages: state.quote ? 1 : 0 }) - } if (path === `${OVERSEAS_BASE}/overseas-quotes/920000000000000001`) { if (method === 'GET') return ok(detailQuote(state.quote)) if (method === 'PUT') { @@ -142,10 +100,7 @@ function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) { return ok(detailQuote(state.quote)) } if (method === 'GET' && path.startsWith(`${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=`)) { - const matches = state.exchange && path.includes(`monthFrom=${state.exchange.month}`) - && (!path.includes('status=') || path.includes(`status=${state.exchange.status}`)) - return ok({ records: matches ? [{ ...state.exchange }] : [], page: 1, size: 20, - total: matches ? 1 : 0, pages: matches ? 1 : 0 }) + return ok({ records: [], page: 1, size: 20, total: 0, pages: 0 }) } if (method === 'POST' && path === `${OVERSEAS_BASE}/exchange-rates`) { state.exchange = { ...body, baseCurrency: 'USD', quoteCurrency: 'CNY', status: 'ACTIVE', recordVersion: 0 } @@ -184,21 +139,13 @@ function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) { state.vehicle = { ...state.vehicle, status: body.status, recordVersion: state.vehicle.recordVersion + 1 } return ok(vehicleWire(state)) } - if (method === 'GET' && path.startsWith(`${OVERSEAS_BASE}/vehicles?page=1&size=20&keyword=`)) { - return ok({ records: state.vehicle ? [vehicleWire(state)] : [], page: 1, size: 20, - total: state.vehicle ? 1 : 0, pages: state.vehicle ? 1 : 0 }) - } assert.fail(`unexpected overseas call ${method} ${path}`) }, async requestOverseasBinary(method, path) { calls.push({ gateway: 'overseas-binary', method, path }) assert.equal(method, 'GET') assert.match(path, /\/(?:overseas-quotes|exchange-rates|vehicles)\/export\?/) - let csv = '' - if (path.includes('/overseas-quotes/')) csv = `报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间\r\n${state.quote.summary.id},${state.quote.summary.name},${state.quote.summary.warehouseCode},${state.quote.summary.warehouseName},${state.quote.summary.providerName},USD,2098-01-01,2098-12-31,V1,ACTIVE,2026-08-21\r\n` - else if (path.includes('/exchange-rates/')) csv = `月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间\r\n${state.exchange.month},USD,CNY,${state.exchange.usdToCnyAverage},${state.exchange.status},admin,2026-08-21\r\n` - else csv = `车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n${state.vehicle.id},${state.vehicle.plateNumber},${state.vehicle.model},18.75,101,Carrier,123,OVERSEAS,OWNED,${state.vehicle.status},2026-08-21\r\n` - return { method, path, http: 200, body: null, bytes: new TextEncoder().encode(`\uFEFF${csv}`), + return { method, path, http: 200, body: null, bytes: new TextEncoder().encode('id,status\n1,ACTIVE\n'), contentType: 'text/csv;charset=UTF-8', requestId: 'req-csv' } }, } @@ -213,20 +160,13 @@ test('静态锁定真实双Gateway生命周期、CSV与安全边界', () => { assert.match(source, /context\.requestOverseas\('PATCH'/) assert.match(source, /context\.requestOverseasBinary\('GET'/) assert.match(source, /context\.expectConflict/) - assert.match(source, /warehouse-options\?refresh=true/) - assert.match(source, /空候选不负缓存30分钟/) - assert.match(source, /30分钟正缓存与刷新快照一致/) - assert.match(source, /无quote:list权限访问海外报价被拒绝/) - assert.match(source, /表头与marker内容/) assert.match(source, /finally\s*\{/) assert.match(source, /context\.createdResources\.phase4OverseasMasterData/) - assert.doesNotMatch(source, /context\.addCoverageGap/) + assert.match(source, /context\.addCoverageGap/) assert.match(source, /2090 \+ Math\.floor/) assert.doesNotMatch(source, /\/internal\/|overseas-partners|https?:\/\/|fetch\(|process\.env|request(?:Overseas)?\('DELETE'/) for (const endpoint of ['overseas-quotes/warehouse-options', 'overseas-quotes/export', 'exchange-rates/export', 'vehicles/export']) assert.ok(source.includes(endpoint)) - assert.doesNotMatch(controllerSource, /Idempotency-Key|IdempotencyKey/) - assert.doesNotMatch(source, /Idempotency-Key/) }) test('marker资源完成创建、详情、编辑、冲突、状态、CSV并全部停用', async () => { @@ -246,39 +186,30 @@ test('marker资源完成创建、详情、编辑、冲突、状态、CSV并全 assert.equal(state.quote.summary.status, 'INACTIVE') assert.equal(state.exchange.status, 'INACTIVE') assert.equal(state.vehicle.status, 'INACTIVE') - assert.equal(state.role.status, 'DISABLED') - assert.equal(state.user.status, 'DISABLED') - assert.equal(state.optionReads, 4) assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary').length, 3) assert.equal(context.calls.filter((call) => call.method === 'DELETE').length, 0) - assert.equal(context.calls.filter((call) => call.method === 'PUT').length, 9) - assert.equal(context.calls.filter((call) => call.tokenKind === 'denied').length, 1) - assert.equal(context.coverageGaps.length, 0) + assert.equal(context.calls.filter((call) => call.method === 'PUT').length, 7) }) -test('创建前空候选不形成假PASS,刷新后本轮合作仓进入报价完整生命周期', async () => { - const { context } = fakeContext() +test('候选空缓存登记覆盖缺口并继续汇率车辆,finally停用国内合作仓', async () => { + const { context, state } = fakeContext() + const original = context.requestOverseas + context.requestOverseas = async (method, path, body) => { + if (method === 'GET' && path.endsWith('/overseas-quotes/warehouse-options')) return ok([]) + return original(method, path, body) + } await run(context) - const optionCalls = context.calls.filter((call) => call.path.includes('warehouse-options')) - assert.equal(optionCalls.length, 4) - assert.equal(optionCalls[0].path.endsWith('warehouse-options'), true) - assert.equal(optionCalls[1].path.endsWith('warehouse-options'), true) - assert.equal(optionCalls[2].path.endsWith('warehouse-options?refresh=true'), true) - assert.equal(optionCalls[3].path.endsWith('warehouse-options'), true) - assert.equal(context.createdResources.phase4OverseasMasterData.quote.id, '920000000000000001') - assert.equal(context.coverageGaps.length, 0) -}) - -test('refresh后仍为空时报价前置明确deployment blocker且不产生coverageGap假PASS', async () => { - const { context, state } = fakeContext({ softAssertions: true, refreshEmpty: true }) - await run(context) - assert.equal(state.quote, null) assert.equal(state.warehouse.status, 'INACTIVE') + assert.equal(state.quote, null) assert.equal(state.exchange.status, 'INACTIVE') assert.equal(state.vehicle.status, 'INACTIVE') - assert.equal(context.coverageGaps.length, 0) - assert.equal(context.steps.some((step) => step.name === '刷新后新建合作仓立即可见' - && !step.passed && step.deploymentBlocker), true) - assert.equal(context.steps.some((step) => step.name === '海外报价生命周期执行' - && !step.passed && step.deploymentBlocker), true) + assert.equal(context.createdResources.phase4OverseasMasterData.quote.skipped, true) + assert.equal(context.createdResources.phase4OverseasMasterData.partnerWarehouse.finalStatus, 'INACTIVE') + assert.equal(context.createdResources.phase4OverseasMasterData.exchangeRate.finalStatus, 'INACTIVE') + assert.equal(context.createdResources.phase4OverseasMasterData.vehicle.finalStatus, 'INACTIVE') + assert.equal(context.coverageGaps.length, 1) + assert.equal(context.coverageGaps[0].name, '海外报价完整写生命周期') + assert.equal(context.coverageGaps[0].category, 'deployment-prerequisite') + assert.match(context.coverageGaps[0].reason, /30分钟空缓存/) + assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary').length, 2) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs index 3d8c8f90e..8e181bae6 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs @@ -14,26 +14,13 @@ function requireData(context, name, response) { return value } -function assertBinaryCsv(context, name, response, expectedHeader, expectedTokens) { - const text = response?.bytes instanceof Uint8Array ? new TextDecoder().decode(response.bytes).replace(/^\uFEFF/, '') : '' - const header = text.split(/\r?\n/, 1)[0] +function assertBinaryCsv(context, name, response) { context.assert(MODULE, name, response, response?.http === 200 && String(response.contentType || '').toLowerCase().startsWith('text/csv') && response.bytes instanceof Uint8Array && response.bytes.length > 0, `HTTP=${response?.http ?? 0}, contentType=${response?.contentType || 'missing'}, bytes=${response?.bytes?.length || 0}`, BLOCKER) - context.assert(MODULE, `${name}表头与marker内容`, response, - header === expectedHeader && expectedTokens.every((token) => text.includes(String(token))), - `header=${header || 'missing'}, expectedTokens=${expectedTokens.join('|')}`, BLOCKER) -} - -const optionIds = (values) => Array.isArray(values) ? values.map((item) => asId(item?.warehouseId)).filter(Boolean) : [] - -function assertForbidden(context, name, response) { - context.assert(MODULE, name, response, - response?.http === 403 || (response?.http === 200 && ['403', 'A0403'].includes(String(response?.body?.code))), - `HTTP=${response?.http ?? 0}, code=${response?.body?.code ?? 'missing'}`, BLOCKER) } async function safeCleanup(context, name, action) { @@ -110,16 +97,6 @@ async function createAndVerifyPartnerWarehouse(context, resource) { BLOCKER) } -async function captureWarehouseOptionsBeforeCreate(context, resource) { - const response = await context.requestOverseas('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) - const options = requireData(context, '创建合作仓前查询报价候选缓存基线', response) - context.assert(MODULE, '合作仓候选基线为规范数组', response, - Array.isArray(options) && options.every((item) => asId(item?.warehouseId) - && item.warehouseCode && item.warehouseName && /^[A-Z]{2}$/.test(item.countryCode)), - `count=${Array.isArray(options) ? options.length : 'invalid'}`, BLOCKER) - resource.partnerWarehouseOptionsBefore = optionIds(options) -} - function quoteCreatePayload(context, warehouseId) { return { name: `${context.marker} 海外报价`, warehouseId, @@ -150,25 +127,27 @@ async function quoteLifecycle(context, resource) { Array.isArray(options) && options.every((item) => asId(item.warehouseId) && item.warehouseCode && item.warehouseName && item.countryCode), `count=${Array.isArray(options) ? options.length : 'invalid'}`, BLOCKER) - const createdId = resource.partnerWarehouse?.id - if (resource.partnerWarehouseOptionsBefore?.length === 0) { - context.assert(MODULE, '空候选不负缓存30分钟', optionsResponse, - optionIds(options).includes(createdId), `createdId=${createdId}, ids=${optionIds(options).join(',') || 'empty'}`, BLOCKER) + if (Array.isArray(options) && options.length === 0) { + resource.quote = { + skipped: true, + reason: 'warehouse-options-empty-cache', + finalStatus: null, + } + context.addCoverageGap(MODULE, BLOCKER.scenario, '海外报价完整写生命周期', + 'warehouse-options 返回空数组;已创建的 OVERSEAS_PARTNER 可能尚未穿透服务端30分钟空缓存。本轮跳过报价且不猜测 warehouseId,继续验收汇率与车辆。', + 'deployment-prerequisite', { + marker: context.marker, + partnerWarehouseId: resource.partnerWarehouse?.id || null, + http: optionsResponse?.http ?? null, + code: optionsResponse?.body?.code ?? null, + requestId: optionsResponse?.requestId ?? null, + traceId: optionsResponse?.traceId ?? null, + }) + return false } - - const refreshResponse = await context.requestOverseas('GET', - `${OVERSEAS_BASE}/overseas-quotes/warehouse-options?refresh=true`) - const refreshed = requireData(context, '强制刷新海外报价合作仓候选', refreshResponse) - const option = Array.isArray(refreshed) ? refreshed.find((item) => asId(item.warehouseId) === createdId) : null - context.assert(MODULE, '刷新后新建合作仓立即可见', refreshResponse, Boolean(option), - `createdId=${createdId}, ids=${optionIds(refreshed).join(',') || 'empty'}`, BLOCKER) - if (!option) throw new Error(`warehouse-options refresh=true 未返回本轮合作仓 ${createdId}`) - - const cachedResponse = await context.requestOverseas('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) - const cached = requireData(context, '刷新后读取30分钟正缓存', cachedResponse) - context.assert(MODULE, '30分钟正缓存与刷新快照一致', cachedResponse, - JSON.stringify(cached) === JSON.stringify(refreshed), - `refreshed=${optionIds(refreshed).join(',')}, cached=${optionIds(cached).join(',')}`, BLOCKER) + const createdOption = options.find((item) => asId(item.warehouseId) === resource.partnerWarehouse?.id) + const option = createdOption || options[0] + if (!option) throw new Error('海外报价接口未返回可用合作仓候选') const payload = quoteCreatePayload(context, asId(option.warehouseId)) const createResponse = await context.requestOverseas('POST', `${OVERSEAS_BASE}/overseas-quotes`, payload) @@ -185,13 +164,6 @@ async function quoteLifecycle(context, resource) { && Array.isArray(detail?.warehouseFees) && detail.warehouseFees.length > 0, `id=${asId(detail?.summary?.id) || 'missing'}, version=${detail?.summary?.recordVersion ?? 'missing'}`, BLOCKER) - const listResponse = await context.requestOverseas('GET', - `${OVERSEAS_BASE}/overseas-quotes?page=1&size=20&keyword=${encodeURIComponent(context.marker)}`) - const page = requireData(context, '分页查询marker海外报价', listResponse) - context.assert(MODULE, '报价列表包含本轮长ID与recordVersion', listResponse, - Array.isArray(page?.records) && page.records.some((item) => asId(item.id) === id && recordVersion(item.recordVersion)), - `id=${id}, total=${page?.total ?? 'missing'}`, BLOCKER) - const staleVersion = Number(detail.summary.recordVersion) const updatePayload = quoteUpdatePayload({ ...payload, transitPrices: payload.transitPrices }, context, staleVersion) const updateResponse = await context.requestOverseas('PUT', path, updatePayload) @@ -219,9 +191,7 @@ async function quoteLifecycle(context, resource) { detail.summary.status === 'ACTIVE', `status=${detail.summary.status}`, BLOCKER) const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/overseas-quotes/export?keyword=${encodeURIComponent(context.marker)}`) - assertBinaryCsv(context, '导出海外报价CSV', csv, - '报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间', - [id, context.marker]) + assertBinaryCsv(context, '导出海外报价CSV', csv) return true } @@ -282,17 +252,9 @@ async function exchangeLifecycle(context, resource) { context.assert(MODULE, '汇率状态切换为INACTIVE', statusResponse, rate.status === 'INACTIVE', `status=${rate.status}`, BLOCKER) - const listResponse = await context.requestOverseas('GET', - `${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=${month}&monthTo=${month}&status=INACTIVE`) - const page = requireData(context, '分页查询marker月汇率', listResponse) - context.assert(MODULE, '汇率列表包含本轮月份与recordVersion', listResponse, - Array.isArray(page?.records) && page.records.some((item) => item.month === month && recordVersion(item.recordVersion)), - `month=${month}, total=${page?.total ?? 'missing'}`, BLOCKER) - const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/exchange-rates/export?monthFrom=${month}&monthTo=${month}`) - assertBinaryCsv(context, '导出marker月汇率CSV', csv, - '月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间', [month, 'INACTIVE']) + assertBinaryCsv(context, '导出marker月汇率CSV', csv) } function vehiclePayload(context, edited = false) { @@ -354,48 +316,8 @@ async function vehicleLifecycle(context, resource) { context.assert(MODULE, '车辆状态切换为MAINTENANCE', statusResponse, vehicle.status === 'MAINTENANCE', `status=${vehicle.status}`, BLOCKER) - const listResponse = await context.requestOverseas('GET', - `${OVERSEAS_BASE}/vehicles?page=1&size=20&keyword=${encodeURIComponent(createPayload.plateNumber)}`) - const page = requireData(context, '分页查询marker车辆', listResponse) - context.assert(MODULE, '车辆列表包含本轮长ID与recordVersion', listResponse, - Array.isArray(page?.records) && page.records.some((item) => asId(item.id) === id && recordVersion(item.recordVersion)), - `id=${id}, total=${page?.total ?? 'missing'}`, BLOCKER) - const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/vehicles/export?keyword=${encodeURIComponent(createPayload.plateNumber)}`) - assertBinaryCsv(context, '导出marker车辆CSV', csv, - '车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间', - [id, createPayload.plateNumber, 'MAINTENANCE']) -} - -async function verifyCanonicalPermissionDenial(context, resource) { - const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase() - const roleResponse = await context.request('POST', '/api/platform/v1/iam/roles', { - roleCode: `P4_MD_DENY_${suffix}`.slice(0, 64), roleName: `四期主数据拒绝-${suffix}`, description: context.marker, - }) - const role = requireData(context, '创建无主数据权限角色', roleResponse) - const roleId = asId(role?.id) - if (!roleId) throw new Error('无主数据权限角色未返回ID') - resource.permissionProbe = { roleId, membershipId: null, roleStatus: 'ACTIVE', userStatus: null } - - const phone = await context.findAvailablePhone(MODULE) - const username = `p4_md_deny_${suffix}`.slice(0, 64).toLowerCase() - const password = `P4md${suffix.slice(-8)}A1!` - const userResponse = await context.request('POST', '/api/platform/v1/iam/users', { - username, displayName: `四期主数据拒绝-${suffix}`, phone, - email: `${username}@example.com`, tenantId: '0', roleIds: [roleId], password, status: 'ACTIVE', - }) - const user = requireData(context, '创建无主数据权限用户', userResponse) - const membershipId = asId(user?.membershipId) - if (!membershipId) throw new Error('无主数据权限用户未返回membershipId') - resource.permissionProbe.membershipId = membershipId - resource.permissionProbe.userStatus = 'ACTIVE' - - const loginResponse = await context.request('POST', '/api/auth/login', { username, password }, '') - const login = requireData(context, '无主数据权限用户登录', loginResponse) - if (!login?.overseasAccessToken) throw new Error('无主数据权限用户未签发海外Token') - const denied = await context.requestOverseas('GET', - `${OVERSEAS_BASE}/overseas-quotes?page=1&size=1`, undefined, {}, login.overseasAccessToken) - assertForbidden(context, '无quote:list权限访问海外报价被拒绝', denied) + assertBinaryCsv(context, '导出marker车辆CSV', csv) } async function cleanupPartnerWarehouse(context, resource) { @@ -433,33 +355,16 @@ async function cleanupVersioned(context, resource, key, path, extract) { } } -async function cleanupPermissionProbe(context, resource) { - const probe = resource.permissionProbe - if (!probe) return - if (probe.membershipId && probe.userStatus !== 'DISABLED') { - const response = await context.request('PUT', `/api/platform/v1/iam/users/${probe.membershipId}/status`, { status: 'DISABLED' }) - context.apiSuccess(MODULE, '无权限探针用户finally停用', response, `membershipId=${probe.membershipId}`, BLOCKER) - if (success(response)) probe.userStatus = 'DISABLED' - } - if (probe.roleId && probe.roleStatus !== 'DISABLED') { - const response = await context.request('PUT', `/api/platform/v1/iam/roles/${probe.roleId}/status`, { status: 'DISABLED' }) - context.apiSuccess(MODULE, '无权限探针角色finally停用', response, `roleId=${probe.roleId}`, BLOCKER) - if (success(response)) probe.roleStatus = 'DISABLED' - } -} - export async function run(context) { const resource = { marker: context.marker } context.createdResources.phase4OverseasMasterData = resource try { await continueLifecycle(context, '海外报价', async () => { - await captureWarehouseOptionsBeforeCreate(context, resource) await createAndVerifyPartnerWarehouse(context, resource) await quoteLifecycle(context, resource) }) await continueLifecycle(context, '月度汇率', () => exchangeLifecycle(context, resource)) await continueLifecycle(context, '车辆主数据', () => vehicleLifecycle(context, resource)) - await continueLifecycle(context, 'canonical权限拒绝', () => verifyCanonicalPermissionDenial(context, resource)) } finally { await safeCleanup(context, '海外报价', async () => { if (!resource.quote?.id) return @@ -477,6 +382,5 @@ export async function run(context) { await cleanupVersioned(context, resource, 'vehicle', path, (value) => value) }) await safeCleanup(context, '合作仓', () => cleanupPartnerWarehouse(context, resource)) - await safeCleanup(context, 'canonical权限探针', () => cleanupPermissionProbe(context, resource)) } } From a811c2ca068347a5a12fd9f47378d3b14ffdda5a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 19:44:28 +0800 Subject: [PATCH 053/115] test: harden carrier node HTTP acceptance --- .../phase3-carrier-schedule-queries.mjs | 192 ++++-------- .../phase3-carrier-schedule-queries.test.mjs | 60 ++-- .../phase4-carrier-nodes-contract.test.mjs | 295 +++++++++++------- .../phase4-carrier-nodes.mjs | 217 +++++++++---- 4 files changed, 422 insertions(+), 342 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs index 0e4c67a6c..d9d46f29c 100644 --- a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs @@ -1,17 +1,11 @@ const MODULE = 'phase3-carrier-schedule-queries' const OPTIONS = { scenario: 'carrier-schedule-query-policy', deploymentBlocker: true } const BASE = '/api/platform/v1/carrier-schedule-queries' -const OCEAN = '/api/platform/v1/ocean-bookings' const AUTOMATED_CARRIERS = Object.freeze(['MATSON', 'SML', 'ZIM', 'EMC', 'HEDE']) -import { resolveOceanBookingMaster, oceanBookingDraft } from './ocean-booking-fixture.mjs' function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } function data(response) { return codeOf(response) === '200' ? response.body.data : null } -function localIso(value) { return value.toISOString().slice(0, 19) } -function step(context, name, response, passed, evidence) { - context.addStep(MODULE, name, response, passed, evidence, { ...OPTIONS, deploymentBlocker: !passed }) -} async function request(context, method, path, body, headers = {}) { try { const response = await fetch(`${context.options.gateway}${path}`, { @@ -32,153 +26,87 @@ async function request(context, method, path, body, headers = {}) { } } +export function currentOutboundFixture(context) { + const loading = context?.createdResources?.phase3Loading + const outbound = context?.createdResources?.phase3OutboundDispatch + const marker = context?.marker + const matches = loading?.marker === marker && outbound?.marker === marker + && id(loading?.containerBookingId) && id(loading?.containerBookingId) === id(outbound?.containerBookingId) + && id(loading?.oceanBookingId) && id(loading?.containerNo) + && outbound?.confirmedStatus === 'WAITING_FOR_LOADING' + return matches ? { + marker, customerId: id(outbound.tenantId || loading.tenantId), oceanBookingId: id(loading.oceanBookingId), + containerBookingId: id(loading.containerBookingId), trackingNo: id(loading.containerNo), + } : null +} + export function ownsCarrierQuery(resource, marker) { - return Boolean(resource?.ownedByModule && resource?.queryId && resource?.marker === marker) + return Boolean(resource?.ownedByModule && resource?.queryId && resource?.marker === marker + && resource?.triggerSource === 'OUTBOUND_CONFIRM') } -export function markerTrackingNo(marker, explicit = process.env.ADMIN_TEST_CARRIER_CONTAINER_NO) { - const configured = String(explicit || '').trim().toUpperCase() - if (configured) return configured - let hash = 0x811c9dc5 - for (const char of String(marker || 'carrier-marker')) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193) - return `TSTU${String(hash >>> 0).padStart(10, '0').slice(-7)}` +function blocker(context, name, evidence) { + context.addStep(MODULE, name, null, false, evidence, OPTIONS) } -function oceanDraft(context, suffix, master) { - return { - ...oceanBookingDraft(context, suffix, master), - departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', - destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', - remark: context.marker, - parties: [ - { partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, - { partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` }, - { partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` }, - ], - } -} - -async function ensureOcean(context, suffix, master) { - const existing = context.createdResources?.oceanBooking - if (existing?.id && existing.marker === context.marker && id(existing.customerId) === id(master.customerId)) { - return { id: id(existing.id), containerBookingId: null } - } - const response = await request(context, 'POST', OCEAN, { expectedVersion: 0, data: oceanDraft(context, suffix, master) }, - { 'Idempotency-Key': `${context.marker}-carrier-query-ocean`.slice(0, 128) }) - const detail = context.apiSuccess(MODULE, '创建本轮船司查询隔离订舱', response, '', OPTIONS) - return { id: id(detail?.oceanBooking?.id), containerBookingId: null } -} - -function nodeSnapshot(detail) { - return (Array.isArray(detail?.nodes) ? detail.nodes : []).map((item) => ({ - id: id(item.id), providerCode: item.providerCode, providerEventId: item.providerEventId, - standardNode: item.standardNode, observedAt: item.observedAt, estimatedAt: item.estimatedAt, actualAt: item.actualAt, - })) -} +function validLongId(value) { return typeof value === 'string' && /^\d{16,}$/.test(value) } export async function run(context) { context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO官方API自动查询', 'COSCO Shipping Open Platform 待正式凭据;当前返回MANUAL_REQUIRED,不逆向网页WAF', 'external-third-party') - const suffix = context.marker.replace(/\D/g, '').slice(-10) const carriersResponse = await request(context, 'GET', `${BASE}/carriers`) - const carriers = context.apiSuccess(MODULE, '六家船司查询策略入口已部署', carriersResponse, '', OPTIONS) + const carriers = context.apiSuccess(MODULE, '查询六家船司自动能力', carriersResponse, '', OPTIONS) const byCode = new Map((Array.isArray(carriers) ? carriers : []).map((item) => [item.carrierCode, item])) context.assert(MODULE, '五家自动Provider与COSCO覆盖缺口精确', carriersResponse, ['MATSON', 'SML', 'ZIM', 'COSCO', 'EMC', 'HEDE'].every((code) => byCode.has(code)) && AUTOMATED_CARRIERS.every((code) => byCode.get(code)?.automated === true && byCode.get(code)?.coverageGap === false) && byCode.get('COSCO')?.automated === false && byCode.get('COSCO')?.coverageGap === true - && byCode.get('COSCO')?.accessMode === 'OFFICIAL_API' - && byCode.get('COSCO')?.coverageGapCode === 'OFFICIAL_API_CREDENTIALS_REQUIRED' - && [...byCode.values()].every((item) => item.officialUrl?.startsWith('http')), + && byCode.get('COSCO')?.coverageGapCode === 'OFFICIAL_API_CREDENTIALS_REQUIRED', `automated=${AUTOMATED_CARRIERS.filter((code) => byCode.get(code)?.automated).join(',') || 'none'}`, OPTIONS) - const master = await resolveOceanBookingMaster(context, MODULE) - if (!master) return - const ocean = await ensureOcean(context, suffix, master) - if (!ocean.id) return - const trackingNo = markerTrackingNo(context.marker) - const createBody = { - expectedVersion: 0, customerId: master.customerId, oceanBookingId: ocean.id, - containerBookingId: ocean.containerBookingId, carrierCode: 'ZIM', trackingType: 'CONTAINER_NO', - trackingNo, remark: context.marker, + const fixture = currentOutboundFixture(context) + if (!fixture) { + blocker(context, '缺少本轮phase3出仓fixture,禁止借历史Query验收', + `marker=${context.marker}, loading=${context.createdResources?.phase3Loading?.marker || 'missing'}, outbound=${context.createdResources?.phase3OutboundDispatch?.marker || 'missing'}`) + return } - const createKey = `${context.marker}-carrier-query-create`.slice(0, 128) - const createResponse = await request(context, 'POST', BASE, createBody, { 'Idempotency-Key': createKey }) - const created = context.apiSuccess(MODULE, '创建本轮marker专属船司查询任务', createResponse, '', OPTIONS) - const queryId = id(created?.query?.query?.id) - context.assert(MODULE, '本轮查询任务使用ZIM柜号契约', createResponse, - Boolean(queryId) && created?.query?.query?.status === 'READY' - && created?.query?.query?.carrierCode === 'ZIM' - && created?.query?.query?.trackingType === 'CONTAINER_NO' - && created?.query?.query?.trackingNo === trackingNo, - `queryId=${queryId || 'missing'}, trackingNo=${created?.query?.query?.trackingNo || 'missing'}`, OPTIONS) - if (!queryId) return + + const selector = new URLSearchParams({ + customerId: fixture.customerId, oceanBookingId: fixture.oceanBookingId, + containerBookingId: fixture.containerBookingId, keyword: fixture.trackingNo, page: '1', size: '20', + }) + const pageResponse = await request(context, 'GET', `${BASE}?${selector}`) + const page = context.apiSuccess(MODULE, '查询本轮出仓自动创建的船司任务', pageResponse, '', OPTIONS) + const matches = (Array.isArray(page?.records) ? page.records : []).filter((item) => { + const task = item?.query + return id(task?.customerId) === fixture.customerId && id(task?.oceanBookingId) === fixture.oceanBookingId + && id(task?.containerBookingId) === fixture.containerBookingId && task?.trackingNo === fixture.trackingNo + && task?.triggerSource === 'OUTBOUND_CONFIRM' + }) + context.assert(MODULE, '本轮出仓范围唯一命中CarrierScheduleQuery', pageResponse, + matches.length === 1, `matches=${matches.length}, total=${page?.total ?? 'missing'}`, OPTIONS) + if (matches.length !== 1) return + + const task = matches[0].query + const queryId = id(task.id) + const detailResponse = await request(context, 'GET', `${BASE}/${encodeURIComponent(queryId)}`) + const detail = context.apiSuccess(MODULE, '回查本轮出仓船节点详情', detailResponse, `queryId=${queryId}`, OPTIONS) + const departed = (detail?.nodes || []).filter((item) => item.standardNode === 'DEPARTED_WAREHOUSE' + && item.actualAt != null && item.estimatedAt == null) + context.assert(MODULE, '出仓自动任务携带实际DEPARTED_WAREHOUSE事实与长ID', detailResponse, + id(detail?.query?.query?.id) === queryId && validLongId(detail?.query?.query?.id) + && departed.length >= 1 && departed.every((item) => validLongId(item.id) + && item.providerCode === 'FIRSTLEG' && item.providerEventId && item.providerNodeCode + && typeof item.rawPayloadHash === 'string' && /^[0-9a-f]{64}$/.test(item.rawPayloadHash)), + `queryId=${queryId}, departed=${departed.length}`, OPTIONS) + context.createdResources.phase3CarrierScheduleQuery = { - queryId, marker: context.marker, ownedByModule: true, customerId: id(master.customerId), - oceanBookingId: ocean.id, trackingNo, + queryId, marker: context.marker, ownedByModule: true, triggerSource: 'OUTBOUND_CONFIRM', + customerId: fixture.customerId, oceanBookingId: fixture.oceanBookingId, + containerBookingId: fixture.containerBookingId, trackingNo: fixture.trackingNo, + version: Number(data(detailResponse)?.query?.query?.version), } - - const replay = await request(context, 'POST', BASE, createBody, { 'Idempotency-Key': createKey }) - context.assert(MODULE, '创建任务幂等重放返回同一marker资源', replay, - id(data(replay)?.query?.query?.id) === queryId, - `first=${queryId}, replay=${id(data(replay)?.query?.query?.id) || 'missing'}`, OPTIONS) - - const unknownCarrier = await request(context, 'POST', BASE, - { ...createBody, carrierCode: 'UNKNOWN', trackingNo: `${trackingNo}U` }, - { 'Idempotency-Key': `${context.marker}-carrier-unknown`.slice(0, 128) }) - step(context, '非法船司代码被拒绝', unknownCarrier, - unknownCarrier.http === 200 && ['A0400', '400'].includes(codeOf(unknownCarrier)), - `HTTP=${unknownCarrier.http}, code=${codeOf(unknownCarrier)}`) - - const initialTask = created.query.query - const observedAt = localIso(new Date()) - const estimatedAt = localIso(new Date(Date.now() + 30 * 24 * 60 * 60_000)) - const manualBody = { - expectedVersion: Number(initialTask.version), standardNode: 'ARRIVED_DESTINATION_PORT', - observedAt, estimatedAt, actualAt: null, locationCode: 'USLAX', locationName: 'Los Angeles', - reason: `本轮marker预计节点验收 ${context.marker}`, - } - const manualKey = `${context.marker}-carrier-manual-node`.slice(0, 128) - const manual = await request(context, 'POST', `${BASE}/${queryId}/manual-nodes`, manualBody, - { 'Idempotency-Key': manualKey }) - const manualDetail = context.apiSuccess(MODULE, '仅向本轮marker任务补录预计节点', manual, '', OPTIONS) - const manualTask = manualDetail?.query?.query - context.assert(MODULE, '预计事实可见、版本前进且实际进度零推进', manual, - id(manualTask?.id) === queryId && Number(manualTask?.version) > Number(initialTask.version) - && manualTask?.status === initialTask.status - && manualTask?.latestActualNode === initialTask.latestActualNode - && manualTask?.latestActualAt === initialTask.latestActualAt - && manualDetail?.nodes?.some((item) => item.providerCode === 'MANUAL' - && item.standardNode === 'ARRIVED_DESTINATION_PORT' && item.estimatedAt === estimatedAt && item.actualAt == null), - `version=${manualTask?.version ?? 'missing'}, latest=${manualTask?.latestActualNode || 'none'}`, OPTIONS) - - const replayManual = await request(context, 'POST', `${BASE}/${queryId}/manual-nodes`, manualBody, - { 'Idempotency-Key': manualKey }) - const replayDetail = context.apiSuccess(MODULE, '人工节点同key同payload幂等重放', replayManual, '', OPTIONS) - context.assert(MODULE, '人工节点重放版本和事实集合不漂移', replayManual, - Number(replayDetail?.query?.query?.version) === Number(manualTask?.version) - && JSON.stringify(nodeSnapshot(replayDetail)) === JSON.stringify(nodeSnapshot(manualDetail)), - `version=${replayDetail?.query?.query?.version ?? 'missing'}, nodes=${replayDetail?.nodes?.length ?? 'missing'}`, OPTIONS) - - const stale = await request(context, 'POST', `${BASE}/${queryId}/manual-nodes`, manualBody, - { 'Idempotency-Key': `${context.marker}-carrier-manual-stale`.slice(0, 128) }) - step(context, '旧version配合新幂等键被A0409拒绝', stale, - stale.http === 200 && ['A0409', '409'].includes(codeOf(stale)), - `HTTP=${stale.http}, code=${codeOf(stale)}`) - - const detailResponse = await request(context, 'GET', `${BASE}/${queryId}`) - const detail = context.apiSuccess(MODULE, '回查本轮marker任务零漂移', detailResponse, '', OPTIONS) - context.assert(MODULE, '并发拒绝后版本、实际进度和事实集合零漂移', detailResponse, - Number(detail?.query?.query?.version) === Number(manualTask?.version) - && detail?.query?.query?.latestActualNode === manualTask?.latestActualNode - && detail?.query?.query?.latestActualAt === manualTask?.latestActualAt - && JSON.stringify(nodeSnapshot(detail)) === JSON.stringify(nodeSnapshot(manualDetail)) - && !Object.hasOwn(detail || {}, 'attempts') - && detail?.histories?.some((item) => item.action === 'CREATE') - && detail?.histories?.some((item) => item.action === 'MANUAL_NODE'), - `version=${detail?.query?.query?.version ?? 'missing'}, nodes=${detail?.nodes?.length ?? 'missing'}`, OPTIONS) - context.createdResources.phase3CarrierScheduleQuery.version = Number(manualTask?.version) } diff --git a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs index 1b4587d7e..1917c0ccb 100644 --- a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs @@ -4,39 +4,49 @@ import path from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' -import { markerTrackingNo, ownsCarrierQuery } from './phase3-carrier-schedule-queries.mjs' +import { currentOutboundFixture, ownsCarrierQuery } from './phase3-carrier-schedule-queries.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'phase3-carrier-schedule-queries.mjs'), 'utf8') -test('marker ownership requires explicit module ownership and the exact current marker', () => { - assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'M', ownedByModule: true }, 'M'), true) - assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'OLD', ownedByModule: true }, 'M'), false) - assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'M', ownedByModule: false }, 'M'), false) - assert.equal(ownsCarrierQuery({ marker: 'M', ownedByModule: true }, 'M'), false) +function context(marker = 'RUN') { + return { marker, createdResources: { + phase3Loading: { marker, tenantId: '10001', oceanBookingId: '20001', containerBookingId: '30001', containerNo: 'TSTU1234567' }, + phase3OutboundDispatch: { marker, tenantId: '10001', containerBookingId: '30001', confirmedStatus: 'WAITING_FOR_LOADING' }, + } } +} + +test('outbound fixture requires exact current marker and matching loading/outbound container', () => { + assert.deepEqual(currentOutboundFixture(context()), { + marker: 'RUN', customerId: '10001', oceanBookingId: '20001', containerBookingId: '30001', trackingNo: 'TSTU1234567', + }) + const old = context(); old.createdResources.phase3OutboundDispatch.marker = 'OLD' + assert.equal(currentOutboundFixture(old), null) + const mismatch = context(); mismatch.createdResources.phase3OutboundDispatch.containerBookingId = 'OTHER' + assert.equal(currentOutboundFixture(mismatch), null) }) -test('default tracking number is deterministic marker data while explicit samples are opt-in', () => { - const first = markerTrackingNo('RUN-20260821-001', '') - const second = markerTrackingNo('RUN-20260821-001', '') - assert.equal(first, second) - assert.match(first, /^TSTU\d{7}$/) - assert.equal(markerTrackingNo('RUN', ' zcsu-test-only '), 'ZCSU-TEST-ONLY') +test('carrier query ownership requires explicit outbound-confirm marker registration', () => { + assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'RUN', ownedByModule: true, + triggerSource: 'OUTBOUND_CONFIRM' }, 'RUN'), true) + assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'RUN', ownedByModule: true, + triggerSource: 'MANUAL' }, 'RUN'), false) + assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'OLD', ownedByModule: true, + triggerSource: 'OUTBOUND_CONFIRM' }, 'RUN'), false) }) -test('phase3 contract creates and mutates only its marker-owned container query', () => { - assert.match(source, /carrierCode:\s*'ZIM',\s*trackingType:\s*'CONTAINER_NO'/) - assert.match(source, /phase3CarrierScheduleQuery\s*=\s*\{[\s\S]*queryId,[\s\S]*marker:\s*context\.marker,[\s\S]*ownedByModule:\s*true/) - assert.match(source, /\/manual-nodes/) - assert.match(source, /'Idempotency-Key'/) - assert.match(source, /expectedVersion:\s*Number\(initialTask\.version\)/) - assert.match(source, /actualAt:\s*null/) - assert.match(source, /A0409/) - assert.match(source, /nodeSnapshot\(detail\)[\s\S]*nodeSnapshot\(manualDetail\)/) - assert.doesNotMatch(source, /\/manual-results/) - assert.doesNotMatch(source, /\/attempts|\/scheduler|\/internal|\/pda/) - assert.doesNotMatch(source, /BILL_OF_LADING/) - assert.doesNotMatch(source, /createManualSchedule|portScheduleId|\/api\/platform\/v1\/port-schedules/) +test('phase3 script only discovers the current outbound task and blocks missing prerequisites', () => { + assert.match(source, /phase3Loading/) + assert.match(source, /phase3OutboundDispatch/) + assert.match(source, /缺少本轮phase3出仓fixture,禁止借历史Query验收/) + assert.match(source, /deploymentBlocker:\s*true/) + assert.match(source, /triggerSource === 'OUTBOUND_CONFIRM'/) + assert.match(source, /DEPARTED_WAREHOUSE/) + assert.match(source, /providerEventId/) + assert.match(source, /\^\\d\{16,/) + assert.doesNotMatch(source, /request\(context,\s*'POST',\s*BASE/) + assert.doesNotMatch(source, /manual-nodes|manual-results|BILL_OF_LADING/) + assert.doesNotMatch(source, /records\[0\]/) }) test('carrier capability assertion freezes five automated providers and COSCO only gap', () => { diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index 69c645794..7f64e8e53 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -4,145 +4,202 @@ import path from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' -import { EXCEPTION_CODES, QUERY_STATUSES, STANDARD_NODES, markerOwnedCarrierQuery, run } from './phase4-carrier-nodes.mjs' +import { EXCEPTION_CODES, NODE_SEQUENCES, QUERY_STATUSES, STANDARD_NODES, + manualNodePayload, markerOwnedCarrierQuery, run } from './phase4-carrier-nodes.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'phase4-carrier-nodes.mjs'), 'utf8') +const BASE = '/api/platform/v1/carrier-schedule-queries' +const QUERY_ID = '9007199254740993001' +const CUSTOMER_ID = '9007199254740993002' +const OCEAN_ID = '9007199254740993003' +const CONTAINER_ID = '9007199254740993004' +const MARKER = 'T-PHASE4' -function response(data) { return { http: 200, body: { code: '200', data } } } -function contextFor(routes, createdResources = {}) { - const calls = [] - return { - calls, - marker: 'T-PHASE4', token: 'token', createdResources, - request: async (method, requestPath, body, _token, headers = {}) => { - calls.push(method === 'POST' ? { method, path: requestPath, body, headers } : { method, path: requestPath }) - assert.ok(['GET', 'POST'].includes(method)) - assert.ok(Object.hasOwn(routes, requestPath), `unexpected path: ${requestPath}`) - return response(routes[requestPath]) - }, - apiSuccess: (_module, _name, result) => { - assert.equal(result.http, 200) - assert.equal(result.body.code, '200') - return result.body.data - }, - assert: (_module, name, _result, condition, evidence) => { - assert.ok(condition, `${name}: ${evidence}`) - }, - } +function ok(data, requestId = 'req-ok') { + return { http: 200, body: { code: '200', data, requestId }, requestId, traceId: 'trace-ok' } } +function conflict(requestId) { + return { http: 200, body: { code: 'A0409', message: 'conflict', requestId }, requestId, traceId: 'trace-conflict' } +} +function clone(value) { return structuredClone(value) } -const task = { - id: '9007199254740993', queryNo: 'CSQ9007199254740993', customerId: '10001', - oceanBookingId: '20001', containerBookingId: '30001', carrierCode: 'COSCO', - trackingType: 'CONTAINER_NO', trackingNo: 'CSNU1234567', status: 'STOPPED', - latestActualNode: 'PICKED_UP', latestActualAt: '2026-08-20T12:00:00', version: 5, - lastSuccessAt: '2026-08-20T12:00:00', sourceUpdatedAt: '2026-08-20T12:00:00', - expiresAt: null, stoppedAt: null, stopReason: null, -} -const node = { - id: '40001', providerCode: 'COSCO', providerEventId: 'event-40001', providerNodeCode: 'OUTGATE', standardNode: 'PICKED_UP', - nodeSequence: 90, observedAt: '2026-08-20T12:00:00', estimatedAt: null, - actualAt: '2026-08-20T11:55:00', locationCode: 'USLAX', locationName: 'Los Angeles', - sourceReference: null, rawPayloadHash: 'a'.repeat(64), createdAt: '2026-08-20T12:00:01', -} -const manualNode = { ...node, id: '40002', providerCode: 'MANUAL', providerEventId: 'manual-40002', - providerNodeCode: 'PICKED_UP', estimatedAt: '2026-08-20T12:00:00', actualAt: null, rawPayloadHash: 'b'.repeat(64) } -const exception = { id: '45001', providerCode: 'MATSON_PUBLIC_TRACKING', providerEventId: 'hold-1', - providerStatus: 'CONTAINER ON HOLD - Customs Clearance', exceptionCode: 'CUSTOMS_HOLD', active: true, - observedAt: '2026-08-20T12:00:00', locationCode: 'USLAX', locationName: 'Long Beach', - sourceReference: 'MATSON:9974749', rawPayloadHash: 'c'.repeat(64), createdAt: '2026-08-20T12:00:01' } -const history = { - id: '50001', action: 'STOP', fromVersion: 4, toVersion: 5, - reason: null, operatorId: null, operatorName: '系统', createdAt: '2026-08-20T12:01:00', -} -const carriers = [{ - carrierCode: 'COSCO', carrierName: 'COSCO', officialUrl: 'https://example.invalid', - accessMode: 'INTERNAL_PROVIDER', automated: true, coverageGap: false, -}] - -test('module only permits the documented manual-node write and forbids internal execution endpoints', () => { - assert.match(source, /context\.request\('GET'/) - assert.doesNotMatch(source, /\bfetch\s*\(/) - assert.match(source, /context\.request\('POST',[\s\S]*\/manual-nodes/) - assert.match(source, /'Idempotency-Key'/) - for (const method of ['PUT', 'PATCH', 'DELETE']) { - assert.doesNotMatch(source, new RegExp(`context\\.request\\(['"]${method}['"]`)) - } - for (const endpoint of ['/scheduler', '/attempts', '/manual-results', '/internal', '/pda']) { - assert.doesNotMatch(source, new RegExp(endpoint.replace('/', '\\/'), 'i')) - } -}) - -test('canonical task node and exception sets are locked to the phase-4 contract', () => { +test('canonical states, ten ordered nodes, exception codes and complete payload are frozen', () => { assert.deepEqual(QUERY_STATUSES, ['READY', 'MANUAL_REQUIRED', 'STOPPED']) assert.deepEqual(STANDARD_NODES, [ 'DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT', 'IN_TRANSIT', 'ARRIVED_DESTINATION_PORT', 'DISCHARGED_FROM_VESSEL', 'PICKUP_READY', 'PICKED_UP', 'EMPTY_RETURNED', ]) + assert.deepEqual(Object.values(NODE_SEQUENCES), [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]) assert.deepEqual(EXCEPTION_CODES, ['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD']) + assert.deepEqual(Object.keys(manualNodePayload({ expectedVersion: 7, standardNode: 'PICKED_UP', + observedAt: '2026-08-24T12:00:00', actualAt: '2026-08-24T11:59:00', marker: MARKER })), + ['expectedVersion', 'standardNode', 'observedAt', 'estimatedAt', 'actualAt', + 'locationCode', 'locationName', 'reason']) }) -test('marker ownership requires explicit module ownership, query id and exact current marker', () => { - assert.equal(markerOwnedCarrierQuery({ marker: 'M', createdResources: {} }), null) - assert.equal(markerOwnedCarrierQuery({ marker: 'M', createdResources: { - phase3CarrierScheduleQuery: { queryId: '1', marker: 'OLD', ownedByModule: true }, +test('module exposes no scheduler, attempt, internal or arbitrary stop endpoint', () => { + assert.match(source, /\/manual-nodes/) + assert.match(source, /'Idempotency-Key'/) + assert.doesNotMatch(source, /\/manual-results|\/attempts|\/scheduler|\/internal|\/pda|\/stop/) + assert.doesNotMatch(source, /\bfetch\s*\(/) + for (const method of ['PUT', 'PATCH', 'DELETE']) { + assert.doesNotMatch(source, new RegExp(`context\\.request\\(['"]${method}['"]`)) + } +}) + +test('ownership requires current marker, long query id and outbound-confirm source', () => { + const valid = { queryId: QUERY_ID, marker: MARKER, ownedByModule: true, + triggerSource: 'OUTBOUND_CONFIRM' } + assert.equal(markerOwnedCarrierQuery({ marker: MARKER, + createdResources: { phase3CarrierScheduleQuery: valid } }).queryId, QUERY_ID) + assert.equal(markerOwnedCarrierQuery({ marker: 'OLD', + createdResources: { phase3CarrierScheduleQuery: valid } }), null) + assert.equal(markerOwnedCarrierQuery({ marker: MARKER, createdResources: { + phase3CarrierScheduleQuery: { ...valid, queryId: '1' }, } }), null) - assert.equal(markerOwnedCarrierQuery({ marker: 'M', createdResources: { - phase3CarrierScheduleQuery: { queryId: '1', marker: 'M', ownedByModule: false }, + assert.equal(markerOwnedCarrierQuery({ marker: MARKER, createdResources: { + phase3CarrierScheduleQuery: { ...valid, triggerSource: 'MANUAL' }, } }), null) - assert.equal(markerOwnedCarrierQuery({ marker: 'M', createdResources: { - phase3CarrierScheduleQuery: { queryId: '1', marker: 'M', ownedByModule: true }, - } }).queryId, '1') }) -test('empty page validates read-only pagination without detail calls', async () => { - const context = contextFor({ - '/api/platform/v1/carrier-schedule-queries/carriers': carriers, - '/api/platform/v1/carrier-schedule-queries?page=1&size=20': { records: [], page: 1, size: 20, total: 0, pages: 0 }, - }) - await run(context) - assert.deepEqual(context.calls, [ - { method: 'GET', path: '/api/platform/v1/carrier-schedule-queries/carriers' }, - { method: 'GET', path: '/api/platform/v1/carrier-schedule-queries?page=1&size=20' }, - ]) -}) - -test('standalone existing historical task is read-only and never receives manual nodes', async () => { - const base = '/api/platform/v1/carrier-schedule-queries' - const context = contextFor({ - [`${base}/carriers`]: carriers, - [`${base}?page=1&size=20`]: { records: [{ query: task, expired: false }], page: 1, size: 20, total: 1, pages: 1 }, - [`${base}/${task.id}`]: { query: { query: task, expired: false }, nodes: [node], exceptions: [exception], histories: [history] }, - [`${base}/${task.id}/history`]: [history], - }) - await run(context) - assert.deepEqual(context.calls.map((item) => item.method), ['GET', 'GET', 'GET', 'GET']) - assert.equal(context.calls.some((item) => item.method === 'POST'), false) - assert.equal(context.calls.at(-1).path, `${base}/${task.id}/history`) -}) - -test('phase4 writes only the exact current-marker query registered by phase3', async () => { - const base = '/api/platform/v1/carrier-schedule-queries' - const ownedTask = { ...task, id: '9007199254740994', queryNo: 'CSQ9007199254740994', - status: 'READY', trackingNo: 'TSTU1234567', version: 6 } - const resource = { queryId: ownedTask.id, marker: 'T-PHASE4', ownedByModule: true, trackingNo: ownedTask.trackingNo } - const context = contextFor({ - [`${base}/carriers`]: carriers, - [`${base}?page=1&size=20`]: { records: [{ query: task, expired: false }], page: 1, size: 20, total: 1, pages: 1 }, - [`${base}/${ownedTask.id}`]: { query: { query: ownedTask, expired: false }, nodes: [node], exceptions: [exception], histories: [history] }, - [`${base}/${ownedTask.id}/manual-nodes`]: { query: { query: { ...ownedTask, version: 7 }, expired: false }, - nodes: [node, manualNode], exceptions: [exception], histories: [history] }, - [`${base}/${ownedTask.id}/history`]: [history], - }, { phase3CarrierScheduleQuery: resource }) +test('missing phase3 outbound query is an explicit deployment blocker and performs no write', async () => { + const calls = []; const steps = [] + const context = { + marker: MARKER, token: 'token', createdResources: {}, calls, + request: async (method, requestPath) => { + calls.push({ method, path: requestPath }) + if (requestPath === `${BASE}/carriers`) return ok([]) + if (requestPath === `${BASE}?page=1&size=20`) return ok({ records: [], page: 1, size: 20, total: 0, pages: 0 }) + throw new Error(`unexpected path ${requestPath}`) + }, + apiSuccess: (_module, _name, response) => response.body.data, + assert: (_module, name, _response, condition, evidence) => assert.ok(condition, `${name}: ${evidence}`), + addStep: (_module, name, _response, passed, evidence, options) => steps.push({ name, passed, evidence, options }), + } await run(context) - assert.deepEqual(context.calls.map((item) => item.method), ['GET', 'GET', 'GET', 'POST', 'GET']) - const write = context.calls.find((item) => item.method === 'POST') - assert.equal(write.path, `${base}/${ownedTask.id}/manual-nodes`) - assert.equal(write.body.expectedVersion, ownedTask.version) - assert.equal(write.body.actualAt, null) - assert.equal(write.headers['Idempotency-Key'], `T-PHASE4-carrier-manual-${ownedTask.id}`) + assert.deepEqual(calls.map((item) => item.method), ['GET', 'GET']) + assert.equal(steps.length, 1) + assert.equal(steps[0].passed, false) + assert.equal(steps[0].options.deploymentBlocker, true) + assert.match(steps[0].name, /禁止借历史任务写节点/) +}) + +function markerOwnedContext() { + let nextId = 9007199254740994000n + const calls = []; const steps = []; const idempotency = new Map() + const task = { + id: QUERY_ID, queryNo: `CSQ${QUERY_ID}`, customerId: CUSTOMER_ID, oceanBookingId: OCEAN_ID, + containerBookingId: CONTAINER_ID, carrierCode: 'ZIM', trackingType: 'CONTAINER_NO', + trackingNo: 'TSTU1234567', status: 'READY', triggerSource: 'OUTBOUND_CONFIRM', + latestActualNode: 'DEPARTED_WAREHOUSE', latestActualAt: '2026-08-24T08:00:00', version: 0, + lastSuccessAt: null, sourceUpdatedAt: '2026-08-24T08:00:00', expiresAt: null, + stoppedAt: null, stopReason: null, + } + const nodes = [{ + id: String(nextId++), providerCode: 'FIRSTLEG', providerEventId: 'OUTBOUND_CONFIRM', + providerNodeCode: 'OUTBOUND_CONFIRM', standardNode: 'DEPARTED_WAREHOUSE', nodeSequence: 10, + observedAt: '2026-08-24T08:00:00', estimatedAt: null, actualAt: '2026-08-24T08:00:00', + locationCode: null, locationName: null, sourceReference: 'OUTBOUND_CONFIRM', + rawPayloadHash: 'a'.repeat(64), createdAt: '2026-08-24T08:00:01', + }] + const exceptions = [{ + id: String(nextId++), providerCode: 'MATSON_PUBLIC_TRACKING', providerEventId: 'hold-1', + providerStatus: 'CONTAINER ON HOLD - Customs Clearance', exceptionCode: 'CUSTOMS_HOLD', active: true, + observedAt: '2026-08-24T08:01:00', locationCode: 'USLAX', locationName: 'Long Beach', + sourceReference: 'MATSON:MASKED', rawPayloadHash: 'b'.repeat(64), createdAt: '2026-08-24T08:01:01', + }] + const histories = [{ + id: String(nextId++), action: 'AUTO_CREATE', fromVersion: null, toVersion: 0, + reason: '国内确认出仓自动创建', operatorId: CUSTOMER_ID, operatorName: 'fixture', + createdAt: '2026-08-24T08:00:01', + }] + const detail = () => ({ query: { query: clone(task), expired: true }, + nodes: clone(nodes), exceptions: clone(exceptions), histories: clone(histories) }) + const context = { + marker: MARKER, token: 'token', calls, steps, + createdResources: { phase3CarrierScheduleQuery: { + queryId: QUERY_ID, marker: MARKER, ownedByModule: true, triggerSource: 'OUTBOUND_CONFIRM', + customerId: CUSTOMER_ID, oceanBookingId: OCEAN_ID, containerBookingId: CONTAINER_ID, + trackingNo: task.trackingNo, version: 0, + } }, + request: async (method, requestPath, body, _token, headers = {}) => { + calls.push(method === 'POST' ? { method, path: requestPath, body: clone(body), headers: clone(headers) } + : { method, path: requestPath }) + if (method === 'GET' && requestPath === `${BASE}/carriers`) return ok([{ + carrierCode: 'ZIM', carrierName: 'ZIM', officialUrl: 'https://api.zim.com/', + accessMode: 'OFFICIAL_API', automated: true, coverageGap: false, + }]) + if (method === 'GET' && requestPath === `${BASE}?page=1&size=20`) return ok({ records: [{ + query: { ...task, id: '9007199254740993999', trackingNo: 'HISTORICAL000' }, expired: false, + }], page: 1, size: 20, total: 2, pages: 1 }) + if (method === 'GET' && requestPath === `${BASE}/${QUERY_ID}`) return ok(detail()) + if (method === 'GET' && requestPath === `${BASE}/${QUERY_ID}/history`) return ok(clone(histories)) + if (method !== 'POST' || requestPath !== `${BASE}/${QUERY_ID}/manual-nodes`) { + throw new Error(`unexpected ${method} ${requestPath}`) + } + const key = headers['Idempotency-Key']; const signature = JSON.stringify(body) + if (idempotency.has(key)) { + const previous = idempotency.get(key) + return previous.signature === signature ? ok(clone(previous.detail), `req-replay-${key}`) + : conflict(`req-hash-${key}`) + } + if (Number(body.expectedVersion) !== Number(task.version)) return conflict(`req-version-${key}`) + const providerEventId = `manual-${String(nextId)}` + nodes.push({ + id: String(nextId++), providerCode: 'MANUAL', providerEventId, providerNodeCode: body.standardNode, + standardNode: body.standardNode, nodeSequence: NODE_SEQUENCES[body.standardNode], + observedAt: body.observedAt, estimatedAt: body.estimatedAt, actualAt: body.actualAt, + locationCode: body.locationCode, locationName: body.locationName, sourceReference: `MANUAL:${CUSTOMER_ID}`, + rawPayloadHash: 'c'.repeat(64), createdAt: body.observedAt, + }) + task.version += 1 + if (body.actualAt != null) { + task.latestActualNode = body.standardNode; task.latestActualAt = body.actualAt + if (body.standardNode === 'EMPTY_RETURNED') { + task.status = 'STOPPED'; task.stopReason = '已还柜'; task.stoppedAt = body.observedAt + } else task.status = 'READY' + } + histories.push({ id: String(nextId++), action: 'MANUAL_NODE', fromVersion: task.version - 1, + toVersion: task.version, reason: body.reason, operatorId: CUSTOMER_ID, operatorName: 'fixture', + createdAt: body.observedAt }) + const saved = detail(); idempotency.set(key, { signature, detail: clone(saved) }) + return ok(saved, `req-${key}`) + }, + apiSuccess: (_module, _name, response) => { + assert.equal(response.http, 200); assert.equal(response.body.code, '200'); return response.body.data + }, + assert: (_module, name, _response, condition, evidence) => assert.ok(condition, `${name}: ${evidence}`), + addStep: (_module, name, response, passed, evidence, options) => { + steps.push({ name, passed, evidence, options, requestId: response?.requestId || null }) + assert.ok(passed, `${name}: ${evidence}`) + }, + } + return { context, task, nodes, exceptions, histories, steps } +} + +test('marker-owned chain covers idempotency conflicts, all actual nodes, exception DTO and empty-return stop', async () => { + const fixture = markerOwnedContext() + + await run(fixture.context) + + const posts = fixture.context.calls.filter((item) => item.method === 'POST') + assert.equal(posts.length, 14) + assert.ok(posts.every((item) => item.path === `${BASE}/${QUERY_ID}/manual-nodes`)) + assert.ok(posts.every((item) => item.headers['Idempotency-Key'])) + assert.ok(posts.every((item) => Object.keys(item.body).length === 8)) + assert.equal(fixture.steps.filter((item) => /A0409/.test(item.name)).length, 2) + assert.equal(fixture.task.status, 'STOPPED') + assert.equal(fixture.task.latestActualNode, 'EMPTY_RETURNED') + assert.equal(fixture.task.stopReason, '已还柜') + const actualNodes = new Set(fixture.nodes.filter((item) => item.actualAt != null).map((item) => item.standardNode)) + assert.deepEqual([...actualNodes].sort(), [...STANDARD_NODES].sort()) + assert.equal(fixture.exceptions[0].exceptionCode, 'CUSTOMS_HOLD') + assert.ok(fixture.nodes.every((item) => /^\d{16,}$/.test(item.id))) + assert.equal(fixture.context.calls.some((item) => /attempts|scheduler|internal|\/stop/.test(item.path)), false) + const firstPageQuery = fixture.context.calls.find((item) => item.path === `${BASE}?page=1&size=20`) + assert.ok(firstPageQuery) }) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index a9c17d7de..07e8202eb 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -9,24 +9,37 @@ export const STANDARD_NODES = Object.freeze([ 'PICKED_UP', 'EMPTY_RETURNED', ]) export const EXCEPTION_CODES = Object.freeze(['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD']) +export const NODE_SEQUENCES = Object.freeze(Object.fromEntries(STANDARD_NODES.map((node, index) => [node, (index + 1) * 10]))) function id(value) { return value == null ? '' : String(value) } function number(value) { return Number.isFinite(Number(value)) } function nullableText(value) { return value == null || typeof value === 'string' } -function nullableId(value) { return value == null || Boolean(id(value)) } +function nullableId(value) { return value == null || validLongId(value) } function nullableIso(value) { return value == null || (typeof value === 'string' && !Number.isNaN(Date.parse(value))) } +function validLongId(value) { return typeof value === 'string' && /^\d{16,}$/.test(value) } +function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +function rejected(response) { return response?.http === 200 && ['A0409', '409'].includes(codeOf(response)) } +function localIso(value) { return value.toISOString().slice(0, 19) } export function markerOwnedCarrierQuery(context) { const resource = context?.createdResources?.phase3CarrierScheduleQuery - return resource?.ownedByModule === true && Boolean(id(resource.queryId)) && resource.marker === context?.marker - ? resource : null + return resource?.ownedByModule === true && validLongId(resource.queryId) + && resource.marker === context?.marker && resource.triggerSource === 'OUTBOUND_CONFIRM' ? resource : null +} + +export function manualNodePayload({ expectedVersion, standardNode, observedAt, estimatedAt = null, + actualAt = null, marker, index = 0 }) { + return { + expectedVersion, standardNode, observedAt, estimatedAt, actualAt, + locationCode: 'USLAX', locationName: 'Los Angeles', + reason: `${marker} manual node ${String(index).padStart(2, '0')} ${standardNode}`, + } } function validTask(task) { - return Boolean(id(task?.id) && task?.queryNo && id(task?.customerId) && id(task?.oceanBookingId)) - && QUERY_STATUSES.includes(task?.status) - && nullableId(task?.containerBookingId) - && nullableText(task?.latestActualNode) + return validLongId(task?.id) && Boolean(task?.queryNo) && validLongId(task?.customerId) + && validLongId(task?.oceanBookingId) && QUERY_STATUSES.includes(task?.status) + && nullableId(task?.containerBookingId) && nullableText(task?.latestActualNode) && (task?.latestActualNode == null || STANDARD_NODES.includes(task.latestActualNode)) && number(task?.version) && ['latestActualAt', 'lastSuccessAt', 'sourceUpdatedAt', 'expiresAt', 'stoppedAt'] @@ -35,9 +48,8 @@ function validTask(task) { } function validNode(node) { - return Boolean(id(node?.id) && node?.providerCode && node?.providerEventId && node?.providerNodeCode) - && STANDARD_NODES.includes(node?.standardNode) - && number(node?.nodeSequence) && Number(node.nodeSequence) > 0 + return validLongId(node?.id) && Boolean(node?.providerCode && node?.providerEventId && node?.providerNodeCode) + && STANDARD_NODES.includes(node?.standardNode) && Number(node?.nodeSequence) === NODE_SEQUENCES[node.standardNode] && nullableIso(node?.observedAt) && node?.observedAt != null && nullableIso(node?.estimatedAt) && nullableIso(node?.actualAt) && nullableText(node?.locationCode) && nullableText(node?.locationName) @@ -46,7 +58,7 @@ function validNode(node) { } function validException(value) { - return Boolean(id(value?.id) && value?.providerCode && value?.providerEventId && value?.providerStatus) + return validLongId(value?.id) && Boolean(value?.providerCode && value?.providerEventId && value?.providerStatus) && EXCEPTION_CODES.includes(value?.exceptionCode) && typeof value?.active === 'boolean' && nullableIso(value?.observedAt) && value?.observedAt != null && nullableText(value?.locationCode) && nullableText(value?.locationName) @@ -55,17 +67,42 @@ function validException(value) { } function validHistory(item) { - return Boolean(id(item?.id) && item?.action) + return validLongId(item?.id) && Boolean(item?.action) && (item?.fromVersion == null || number(item.fromVersion)) && (item?.toVersion == null || number(item.toVersion)) && nullableText(item?.reason) && nullableId(item?.operatorId) && nullableText(item?.operatorName) && nullableIso(item?.createdAt) } +function nodeSnapshot(detail) { + return (Array.isArray(detail?.nodes) ? detail.nodes : []).map((item) => ({ + id: id(item.id), providerEventId: item.providerEventId, standardNode: item.standardNode, + observedAt: item.observedAt, estimatedAt: item.estimatedAt, actualAt: item.actualAt, + })) +} + +function assertDetail(context, response, detail, name) { + const nodes = Array.isArray(detail?.nodes) ? detail.nodes : [] + const exceptions = Array.isArray(detail?.exceptions) ? detail.exceptions : [] + const histories = Array.isArray(detail?.histories) ? detail.histories : [] + context.assert(MODULE, name, response, + validTask(detail?.query?.query) && typeof detail?.query?.expired === 'boolean' + && Array.isArray(detail?.nodes) && nodes.every(validNode) + && Array.isArray(detail?.exceptions) && exceptions.every(validException) + && Array.isArray(detail?.histories) && histories.every(validHistory) + && !Object.hasOwn(detail || {}, 'attempts'), + `status=${detail?.query?.query?.status || 'missing'}, nodes=${nodes.length}, exceptions=${exceptions.length}, histories=${histories.length}`, + OPTIONS) +} + +function blocker(context, name, evidence, response = null) { + context.addStep(MODULE, name, response, false, evidence, OPTIONS) +} + export async function run(context) { const carriersResponse = await context.request('GET', `${BASE}/carriers`) const carriers = context.apiSuccess(MODULE, '查询船司只读辅助信息', carriersResponse, '', OPTIONS) - context.assert(MODULE, '船司辅助信息保持公开只读描述结构', carriersResponse, + context.assert(MODULE, '船司辅助信息结构稳定', carriersResponse, Array.isArray(carriers) && carriers.every((item) => item?.carrierCode && item?.carrierName && nullableText(item?.officialUrl) && nullableText(item?.accessMode) && typeof item?.automated === 'boolean' && typeof item?.coverageGap === 'boolean'), @@ -74,68 +111,116 @@ export async function run(context) { const pageResponse = await context.request('GET', `${BASE}?page=1&size=20`) const page = context.apiSuccess(MODULE, '查询四期船节点任务分页', pageResponse, '', OPTIONS) const records = Array.isArray(page?.records) ? page.records : [] - context.assert(MODULE, '船节点分页结构稳定', pageResponse, + context.assert(MODULE, '船节点分页与长ID结构稳定', pageResponse, Array.isArray(page?.records) && ['page', 'size', 'total', 'pages'].every((field) => number(page?.[field])) && records.every((item) => typeof item?.expired === 'boolean' && validTask(item?.query)), `records=${records.length}, total=${page?.total ?? 'missing'}`, OPTIONS) - const ownedResource = markerOwnedCarrierQuery(context) - if (records.length === 0 && !ownedResource) { - context.assert(MODULE, '无真实任务时返回稳定分页空态', pageResponse, - Number(page?.total) === 0 && Number(page?.pages) === 0, - `records=0, total=${page?.total ?? 'missing'}, pages=${page?.pages ?? 'missing'}`, OPTIONS) + const owned = markerOwnedCarrierQuery(context) + if (!owned) { + blocker(context, '缺少本轮phase3出仓CarrierScheduleQuery,禁止借历史任务写节点', + `marker=${context.marker}, registered=${context.createdResources?.phase3CarrierScheduleQuery?.marker || 'missing'}`, pageResponse) return } - const queryId = ownedResource ? id(ownedResource.queryId) : id(records[0]?.query?.id) + const queryId = id(owned.queryId) const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`) - const detail = context.apiSuccess(MODULE, '查询真实船节点任务详情', detailResponse, `queryId=${queryId}`, OPTIONS) - const nodes = Array.isArray(detail?.nodes) ? detail.nodes : [] - const exceptions = Array.isArray(detail?.exceptions) ? detail.exceptions : [] - const histories = Array.isArray(detail?.histories) ? detail.histories : [] - context.assert(MODULE, '详情返回Task、标准节点与业务历史', detailResponse, - validTask(detail?.query?.query) && typeof detail?.query?.expired === 'boolean' - && Array.isArray(detail?.nodes) && nodes.every(validNode) - && Array.isArray(detail?.exceptions) && exceptions.every(validException) - && Array.isArray(detail?.histories) && histories.every(validHistory), - `status=${detail?.query?.query?.status || 'missing'}, nodes=${nodes.length}, exceptions=${exceptions.length}, histories=${histories.length}`, OPTIONS) - - const task = detail.query.query - if (!ownedResource) { - context.assert(MODULE, 'standalone无本轮marker资源时严格只读并跳过人工写', detailResponse, - true, `readOnlyQueryId=${queryId}`, OPTIONS) - const historyResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}/history`) - const history = context.apiSuccess(MODULE, '只读查询历史任务历史', historyResponse, `queryId=${queryId}`, OPTIONS) - context.assert(MODULE, 'standalone历史详情只读且结构稳定', historyResponse, - Array.isArray(history) && history.every(validHistory), - `history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS) - return - } - const ownershipMatches = id(task?.id) === queryId && task?.trackingNo === ownedResource.trackingNo - context.assert(MODULE, 'phase4写入目标严格匹配本轮phase3登记', detailResponse, - ownershipMatches, `registered=${queryId}, actual=${id(task?.id) || 'missing'}`, OPTIONS) + let detail = context.apiSuccess(MODULE, '查询本轮marker-owned船节点详情', detailResponse, `queryId=${queryId}`, OPTIONS) + assertDetail(context, detailResponse, detail, '详情返回Task、十节点DTO、异常与历史结构') + const initialTask = detail?.query?.query + const ownershipMatches = id(initialTask?.id) === queryId && initialTask?.trackingNo === owned.trackingNo + && id(initialTask?.oceanBookingId) === id(owned.oceanBookingId) + && id(initialTask?.containerBookingId) === id(owned.containerBookingId) + && initialTask?.triggerSource === 'OUTBOUND_CONFIRM' + context.assert(MODULE, '写入目标严格匹配本轮phase3出仓登记', detailResponse, + ownershipMatches, `registered=${queryId}, actual=${id(initialTask?.id) || 'missing'}`, OPTIONS) if (!ownershipMatches) return - const observedAt = task.latestActualAt || '2026-08-21T00:00:00' - const manualResponse = await context.request('POST', `${BASE}/${encodeURIComponent(queryId)}/manual-nodes`, { - expectedVersion: task.version, - standardNode: task.latestActualNode || 'DEPARTED_WAREHOUSE', - observedAt, - estimatedAt: observedAt, - actualAt: null, - locationCode: null, - locationName: null, - reason: '四期船节点人工预计事实验收', - }, context.token, { 'Idempotency-Key': `${context.marker || 'phase4'}-carrier-manual-${queryId}`.slice(0, 128) }) - const manualDetail = context.apiSuccess(MODULE, '人工补录预计节点事实', manualResponse, `queryId=${queryId}`, OPTIONS) - context.assert(MODULE, '人工预计事实可见且不改变任务操作态', manualResponse, - QUERY_STATUSES.includes(manualDetail?.query?.query?.status) - && manualDetail?.nodes?.some((item) => item?.providerCode === 'MANUAL' - && item?.estimatedAt != null && item?.actualAt == null), - `status=${manualDetail?.query?.query?.status || 'missing'}, nodes=${manualDetail?.nodes?.length ?? 'missing'}`, OPTIONS) - const historyResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}/history`) - const history = context.apiSuccess(MODULE, '查询真实船节点任务历史', historyResponse, `queryId=${queryId}`, OPTIONS) - context.assert(MODULE, '独立历史端点返回可审计操作者与nullable字段', historyResponse, - Array.isArray(history) && history.every(validHistory), + const observedAt = localIso(new Date()) + const estimatedAt = localIso(new Date(Date.now() + 30 * 24 * 60 * 60_000)) + const estimatedBody = manualNodePayload({ expectedVersion: Number(initialTask.version), + standardNode: 'ARRIVED_DESTINATION_PORT', observedAt, estimatedAt, marker: context.marker }) + const estimatedKey = `${context.marker}-carrier-estimated`.slice(0, 128) + const estimatedResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, estimatedBody, + context.token, { 'Idempotency-Key': estimatedKey }) + const estimatedDetail = context.apiSuccess(MODULE, '完整payload补录预计到港事实', estimatedResponse, + `queryId=${queryId}`, OPTIONS) + assertDetail(context, estimatedResponse, estimatedDetail, '预计事实响应保持完整DTO') + context.assert(MODULE, '预计节点递增版本但不推进latestActual', estimatedResponse, + Number(estimatedDetail?.query?.query?.version) > Number(initialTask.version) + && estimatedDetail?.query?.query?.status !== 'STOPPED' + && estimatedDetail?.query?.query?.latestActualNode === initialTask.latestActualNode + && estimatedDetail?.query?.query?.latestActualAt === initialTask.latestActualAt + && estimatedDetail?.nodes?.some((item) => item.providerCode === 'MANUAL' + && item.standardNode === 'ARRIVED_DESTINATION_PORT' && item.estimatedAt === estimatedAt && item.actualAt == null), + `version=${estimatedDetail?.query?.query?.version ?? 'missing'}, latest=${estimatedDetail?.query?.query?.latestActualNode || 'none'}`, + OPTIONS) + + const replayResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, estimatedBody, + context.token, { 'Idempotency-Key': estimatedKey }) + const replayDetail = context.apiSuccess(MODULE, '同Idempotency-Key同payload重放', replayResponse, '', OPTIONS) + context.assert(MODULE, '重放版本和节点集合零漂移', replayResponse, + Number(replayDetail?.query?.query?.version) === Number(estimatedDetail?.query?.query?.version) + && JSON.stringify(nodeSnapshot(replayDetail)) === JSON.stringify(nodeSnapshot(estimatedDetail)), + `version=${replayDetail?.query?.query?.version ?? 'missing'}, nodes=${replayDetail?.nodes?.length ?? 'missing'}`, OPTIONS) + + const hashConflict = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, + { ...estimatedBody, reason: `${estimatedBody.reason} altered` }, context.token, + { 'Idempotency-Key': estimatedKey }) + context.addStep(MODULE, '同Idempotency-Key异payload返回A0409', hashConflict, rejected(hashConflict), + `HTTP=${hashConflict.http}, code=${codeOf(hashConflict)}`, OPTIONS) + + const staleResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, estimatedBody, + context.token, { 'Idempotency-Key': `${context.marker}-carrier-stale-version`.slice(0, 128) }) + context.addStep(MODULE, '旧expectedVersion配合新key返回A0409', staleResponse, rejected(staleResponse), + `HTTP=${staleResponse.http}, code=${codeOf(staleResponse)}`, OPTIONS) + + const zeroDriftResponse = await context.request('GET', `${BASE}/${queryId}`) + detail = context.apiSuccess(MODULE, '冲突请求后回查零漂移', zeroDriftResponse, '', OPTIONS) + context.assert(MODULE, '两类A0409后版本与节点集合零漂移', zeroDriftResponse, + Number(detail?.query?.query?.version) === Number(estimatedDetail?.query?.query?.version) + && JSON.stringify(nodeSnapshot(detail)) === JSON.stringify(nodeSnapshot(estimatedDetail)), + `version=${detail?.query?.query?.version ?? 'missing'}, nodes=${detail?.nodes?.length ?? 'missing'}`, OPTIONS) + + let version = Number(detail.query.query.version) + const baseActual = Date.now() + 60 * 60_000 + for (let index = 0; index < STANDARD_NODES.length; index += 1) { + const standardNode = STANDARD_NODES[index] + const actualAt = localIso(new Date(baseActual + index * 60_000)) + const body = manualNodePayload({ expectedVersion: version, standardNode, + observedAt: localIso(new Date()), actualAt, marker: context.marker, index: index + 1 }) + const key = `${context.marker}-carrier-actual-${String(index + 1).padStart(2, '0')}`.slice(0, 128) + const response = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, body, + context.token, { 'Idempotency-Key': key }) + const updated = context.apiSuccess(MODULE, `补录实际节点${standardNode}`, response, `queryId=${queryId}`, OPTIONS) + assertDetail(context, response, updated, `实际节点${standardNode}响应DTO完整`) + const updatedTask = updated?.query?.query + const isEmptyReturned = standardNode === 'EMPTY_RETURNED' + context.assert(MODULE, `${standardNode}实际事实单调推进且停止语义正确`, response, + updatedTask?.latestActualNode === standardNode && updatedTask?.latestActualAt === actualAt + && (isEmptyReturned + ? updatedTask.status === 'STOPPED' && updatedTask.stopReason === '已还柜' && updatedTask.stoppedAt != null + : updatedTask.status !== 'STOPPED') + && updated?.nodes?.some((item) => item.providerCode === 'MANUAL' + && item.standardNode === standardNode && item.actualAt === actualAt && item.estimatedAt == null + && item.providerEventId && item.providerNodeCode && item.sourceReference + && /^[0-9a-f]{64}$/.test(item.rawPayloadHash)), + `status=${updatedTask?.status || 'missing'}, latest=${updatedTask?.latestActualNode || 'none'}`, OPTIONS) + version = Number(updatedTask.version) + detail = updated + } + + const finalTask = detail.query.query + const actualNodes = new Set(detail.nodes.filter((item) => item.actualAt != null).map((item) => item.standardNode)) + context.assert(MODULE, '最终详情覆盖十标准实际节点且只有EMPTY_RETURNED触发停止', detailResponse, + STANDARD_NODES.every((node) => actualNodes.has(node)) && finalTask.status === 'STOPPED' + && finalTask.latestActualNode === 'EMPTY_RETURNED' && finalTask.stopReason === '已还柜', + `actualNodes=${[...actualNodes].join(',')}, status=${finalTask.status}`, OPTIONS) + + const historyResponse = await context.request('GET', `${BASE}/${queryId}/history`) + const history = context.apiSuccess(MODULE, '查询本轮任务不可变历史', historyResponse, `queryId=${queryId}`, OPTIONS) + context.assert(MODULE, '历史只含业务动作且人工节点完整留痕', historyResponse, + Array.isArray(history) && history.every(validHistory) + && history.filter((item) => item.action === 'MANUAL_NODE').length >= STANDARD_NODES.length + 1, `history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS) } From 9e0f41e78b53acc97ec83ebdca71aab5513ce9f6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 19:45:22 +0800 Subject: [PATCH 054/115] test: strengthen phase4 overseas business acceptance --- ...phase4-overseas-business-contract.test.mjs | 292 ++++++--- .../phase4-overseas-business.mjs | 576 ++++++++++++------ 2 files changed, 626 insertions(+), 242 deletions(-) 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 3e41dea1b..f7bee94cc 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 @@ -7,107 +7,263 @@ import { run } from './phase4-overseas-business.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'phase4-overseas-business.mjs'), 'utf8') -function response(data, code = '200') { return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, requestId: 'request-test', traceId: 'trace-test' } } +function response(data, code = '200') { + return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, + requestId: `request-${code}`, traceId: `trace-${code}` } +} function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } } +function blankToNull(value) { return typeof value === 'string' && value.trim() === '' ? null : value } -function fixture() { - const calls = []; const steps = []; const gaps = [] +function fixture(options = {}) { + const calls = []; const steps = []; const gaps = []; const blockers = [] + const marker = 'HTTP-20260824-001' const state = { - truck: null, - looseboard: null, - purchase: { id: '30001', orderNo: 'OP1', customerId: '10001', purchaseWarehouseId: '20001', purchaseCategory: '家居', productName: '置物架', purchaseQuantity: 2, inboundQuantity: 0, inventoryQuantity: 0, status: 'PENDING_REVIEW', version: 0 }, + warehouseStatus: 'ACTIVE', purchase: null, purchaseHash: '', truck: null, truckHash: '', looseboard: null, looseboardHash: '', + purchaseHistories: [], truckHistories: [], looseboardHistories: [], } - const marker = 'HTTP-20260821-001' + const activeCustomers = options.activeCustomers ?? [{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' }] + const warehouseOptions = options.warehouseOptions ?? [ + { warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', countryCode: 'US' }, + ] + const credentials = options.credentials === false ? {} : { username: 'merchant', password: 'secret' } + + function purchaseDetail() { + return { summary: state.purchase, productUrl: state.purchase?.productUrl ?? null, + domesticCarrier: state.purchase?.domesticCarrier ?? null, domesticTrackingNo: state.purchase?.domesticTrackingNo ?? null, + estimatedArrivalAt: state.purchase?.estimatedArrivalAt ?? null, remark: state.purchase?.remark ?? null, + histories: state.purchaseHistories } + } + function truckDetail() { return { summary: state.truck, remark: state.truck?.remark ?? null, histories: state.truckHistories } } + function looseboardDetail() { return { summary: state.looseboard, remark: state.looseboard?.remark ?? null, histories: state.looseboardHistories } } + const context = { - marker, calls, steps, gaps, - context: { merchantQualificationFixture: { tenantId: '10001', username: 'merchant', password: 'secret' } }, - createdResources: { merchantQualificationFixture: { tenantId: '10001', username: 'merchant', marker } }, + marker, calls, steps, gaps, blockers, + context: { merchantQualificationFixture: credentials }, + createdResources: { merchantQualificationFixture: { tenantId: '10001', marker } }, async request(method, requestPath, body) { calls.push({ channel: 'domestic', method, path: requestPath, body }) - if (requestPath.includes('/iam/tenants?')) return response(page([{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' }])) + if (requestPath.includes('/iam/tenants?')) return response(page(activeCustomers)) if (requestPath === '/api/auth/login') return response({ overseasAccessToken: 'merchant-overseas-token' }) if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001') - if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null) + if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) { + state.warehouseStatus = 'INACTIVE'; return response(null) + } throw new Error(`unexpected domestic ${method} ${requestPath}`) }, async requestOverseas(method, requestPath, body, headers, token) { - calls.push({ channel: 'overseas', method, path: requestPath, body }) + calls.push({ channel: 'overseas', method, path: requestPath, body, tokenPresent: Boolean(token) }) + if (requestPath === '/api/merchant/v1/last-leg/overseas-procurements' && method === 'POST') { assert.equal(token, 'merchant-overseas-token') - state.purchase = { ...state.purchase, customerName: body.customerName, remark: body.remark } - return response({ summary: state.purchase }) + const hash = JSON.stringify(body) + if (state.purchase) return hash === state.purchaseHash ? response(purchaseDetail()) : response(null, 'A0409') + state.purchaseHash = hash + state.purchase = { id: '30001', orderNo: 'OP1', customerId: '10001', customerName: body.customerName, + purchaseWarehouseId: null, purchaseWarehouseName: null, purchaseCategory: body.purchaseCategory, + productName: body.productName, productSpecification: body.productSpecification, productUrl: body.productUrl, + purchaseQuantity: body.purchaseQuantity, inboundQuantity: 0, inventoryQuantity: 0, + status: 'PENDING_REVIEW', version: 0, remark: body.remark } + state.purchaseHistories.push({ operationType: 'CREATE', fromStatus: null, toStatus: 'PENDING_REVIEW', remark: marker }) + return response(purchaseDetail()) + } + if (requestPath.includes('/customer-options')) return response(state.purchase + ? [{ customerId: '10001', customerName: '客户甲' }] : []) + if (requestPath.includes('/warehouse-options')) return response(warehouseOptions) + if (requestPath.includes('/overseas-procurements?') && method === 'GET') return response(page(state.purchase ? [state.purchase] : [])) + if (requestPath.includes('/overseas-procurements/OP1/review?') && method === 'POST') { + if (state.purchase.status !== 'PENDING_REVIEW') return response(null, 'A0409') + const next = body.decision === 'REJECT' ? 'REJECTED' : 'APPROVED' + state.purchase = { ...state.purchase, status: next, version: state.purchase.version + 1 } + state.purchaseHistories.push({ operationType: 'REVIEW', fromStatus: 'PENDING_REVIEW', toStatus: next, remark: body.remark }) + return response(purchaseDetail()) + } + if (requestPath.includes('/overseas-procurements/OP1?') && method === 'GET') return response(purchaseDetail()) + if (requestPath.includes('/overseas-procurements/OP1?') && method === 'PATCH') { + if (Number(body.version) !== state.purchase.version) return response(null, 'A0409') + const patch = { ...body }; delete patch.version + for (const key of ['productSpecification', 'productUrl', 'domesticCarrier', 'domesticTrackingNo', 'remark']) { + if (Object.hasOwn(patch, key)) patch[key] = blankToNull(patch[key]) + } + if (Object.hasOwn(patch, 'purchaseWarehouseId') && blankToNull(patch.purchaseWarehouseId) == null) { + patch.purchaseWarehouseId = null; patch.purchaseWarehouseName = null + } + state.purchase = { ...state.purchase, ...patch, version: state.purchase.version + 1 } + state.purchaseHistories.push({ operationType: 'UPDATE', fromStatus: state.purchase.status, + toStatus: state.purchase.status, changeDetail: JSON.stringify({ marker }) }) + return response(purchaseDetail()) + } + + if (requestPath.includes('/us-truck-orders?') && method === 'POST') { + const hash = JSON.stringify(body) + if (state.truck) return hash === state.truckHash ? response(truckDetail()) : response(null, 'A0409') + state.truckHash = hash + state.truck = { id: '40001', orderNo: 'TK1', customerId: '10001', status: 'PENDING_REVIEW', version: 0, ...body } + state.truckHistories.push({ fromStatus: null, toStatus: 'PENDING_REVIEW', remark: marker }) + return response(truckDetail()) + } + if (requestPath.includes('/us-truck-orders?') && method === 'GET') return response(page(state.truck ? [state.truck] : [])) + if (requestPath.includes('/us-truck-orders/TK1/status?') && method === 'PATCH') { + if (Number(body.version) !== state.truck.version) return response(null, 'A0409') + const allowed = (state.truck.status === 'PENDING_REVIEW' && ['WAITING_INBOUND', 'CANCELLED'].includes(body.status)) + || (state.truck.status === 'WAITING_INBOUND' && body.status === 'CANCELLED') + if (!allowed) return response(null, 'A0409') + const from = state.truck.status + state.truck = { ...state.truck, status: body.status, version: state.truck.version + 1 } + state.truckHistories.push({ fromStatus: from, toStatus: body.status, remark: body.remark }) + return response(truckDetail()) + } + if (requestPath.includes('/us-truck-orders/TK1?') && method === 'GET') return response(truckDetail()) + if (requestPath.includes('/us-truck-orders/TK1?') && method === 'PATCH') { + if (Number(body.version) !== state.truck.version) return response(null, 'A0409') + state.truck = { ...state.truck, ...body, version: state.truck.version + 1 } + state.truckHistories.push({ fromStatus: state.truck.status, toStatus: state.truck.status, remark: marker }) + return response(truckDetail()) + } + + if (requestPath.includes('/local-looseboard-orders?') && method === 'POST') { + const hash = JSON.stringify(body) + if (state.looseboard) return hash === state.looseboardHash ? response(looseboardDetail()) : response(null, 'A0409') + state.looseboardHash = hash + state.looseboard = { id: '50001', orderNo: 'LB1', customerId: '10001', status: 'PENDING_ACCEPTANCE', version: 0, ...body } + state.looseboardHistories.push({ operationType: 'CREATE', fromStatus: null, toStatus: 'PENDING_ACCEPTANCE', remark: marker }) + return response(looseboardDetail()) + } + if (requestPath.includes('/local-looseboard-orders?') && method === 'GET') return response(page(state.looseboard ? [state.looseboard] : [])) + if (requestPath.includes('/local-looseboard-orders/LB1/status?') && method === 'PATCH') { + if (Number(body.version) !== state.looseboard.version) return response(null, 'A0409') + const allowed = (state.looseboard.status === 'PENDING_ACCEPTANCE' && ['DISPATCHED', 'CANCELLED'].includes(body.status)) + || (state.looseboard.status === 'DISPATCHED' && body.status === 'CANCELLED') + if (!allowed) return response(null, 'A0409') + const from = state.looseboard.status + state.looseboard = { ...state.looseboard, status: body.status, version: state.looseboard.version + 1 } + state.looseboardHistories.push({ operationType: 'STATUS', fromStatus: from, toStatus: body.status, remark: body.remark }) + return response(looseboardDetail()) + } + if (requestPath.includes('/local-looseboard-orders/LB1?') && method === 'GET') return response(looseboardDetail()) + if (requestPath.includes('/local-looseboard-orders/LB1?') && method === 'PUT') { + if (Number(body.version) !== state.looseboard.version) return response(null, 'A0409') + state.looseboard = { ...state.looseboard, ...body, version: state.looseboard.version + 1 } + state.looseboardHistories.push({ operationType: 'UPDATE', fromStatus: state.looseboard.status, + toStatus: state.looseboard.status, changeDetail: JSON.stringify({ marker }) }) + return response(looseboardDetail()) } - if (requestPath.includes('/customer-options')) return response([{ customerId: '10001', customerName: '客户甲' }]) - if (requestPath.includes('/warehouse-options')) return response([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', countryCode: 'US' }]) - if (requestPath.includes('/overseas-procurements?') && method === 'GET') return response(page([state.purchase])) - if (requestPath.includes('/overseas-procurements/OP1?') && method === 'GET') return response({ summary: state.purchase, remark: marker }) - if (requestPath.includes('/overseas-procurements/OP1?') && method === 'PATCH') { state.purchase = { ...state.purchase, ...body, version: state.purchase.version + 1 }; return response({ summary: state.purchase }) } - if (requestPath.includes('/overseas-procurements/OP1/review?') && method === 'POST') { state.purchase = { ...state.purchase, status: 'REJECTED', version: state.purchase.version + 1 }; return response({ summary: state.purchase }) } - if (requestPath.includes('/us-truck-orders?') && method === 'POST') { state.truck = { id: '40001', orderNo: 'TK1', customerId: '10001', status: 'PENDING_REVIEW', version: 0, ...body }; return response({ summary: state.truck }) } - if (requestPath.includes('/us-truck-orders/TK1/status?') && method === 'PATCH') { if (Number(body.version) !== state.truck.version) return response(null, 'A0409'); state.truck = { ...state.truck, status: body.status, version: state.truck.version + 1 }; return response({ summary: state.truck }) } - if (requestPath.includes('/us-truck-orders/TK1?') && method === 'GET') return response({ summary: state.truck }) - if (requestPath.includes('/us-truck-orders/TK1?') && method === 'PATCH') { if (Number(body.version) !== state.truck.version) return response(null, 'A0409'); state.truck = { ...state.truck, ...body, version: state.truck.version + 1 }; return response({ summary: state.truck }) } - if (requestPath.includes('/local-looseboard-orders?') && method === 'POST') { state.looseboard = { id: '50001', orderNo: 'LB1', customerId: '10001', status: 'PENDING_ACCEPTANCE', version: 0, ...body }; return response({ summary: state.looseboard }) } - if (requestPath.includes('/local-looseboard-orders/LB1/status?') && method === 'PATCH') { if (Number(body.version) !== state.looseboard.version) return response(null, 'A0409'); state.looseboard = { ...state.looseboard, status: body.status, version: state.looseboard.version + 1 }; return response({ summary: state.looseboard }) } - if (requestPath.includes('/local-looseboard-orders/LB1?') && method === 'GET') return response({ summary: state.looseboard }) - if (requestPath.includes('/local-looseboard-orders/LB1?') && method === 'PUT') { if (Number(body.version) !== state.looseboard.version) return response(null, 'A0409'); state.looseboard = { ...state.looseboard, ...body, version: state.looseboard.version + 1 }; return response({ summary: state.looseboard }) } throw new Error(`unexpected ${method} ${requestPath}`) }, - apiSuccess(module, name, result, evidence, options) { const passed = result.http === 200 && String(result.body?.code) === '200'; steps.push({ module, name, passed, evidence, options }); return passed ? result.body.data : null }, - assert(module, name, result, passed, evidence, options) { steps.push({ module, name, passed: Boolean(passed), result, evidence, options }) }, - addStep(module, name, result, passed, evidence, options) { steps.push({ module, name, passed: Boolean(passed), result, evidence, options }) }, + apiSuccess(module, name, result, evidence, stepOptions) { + const passed = result.http === 200 && String(result.body?.code) === '200' + this.addStep(module, name, result, passed, evidence, stepOptions) + return passed ? result.body.data : null + }, + assert(module, name, result, passed, evidence, stepOptions) { + this.addStep(module, name, result, Boolean(passed), evidence, stepOptions) + }, + addStep(module, name, result, passed, evidence, stepOptions = {}) { + const step = { module, name, passed: Boolean(passed), result, evidence, options: stepOptions, + requestId: result?.requestId || null, traceId: result?.traceId || null } + steps.push(step) + if (stepOptions.deploymentBlocker && !step.passed) blockers.push(step) + }, addCoverageGap(...args) { gaps.push(args) }, } return context } -test('module is context injected and excludes forbidden business surfaces', () => { +test('module is context injected, blocks missing prerequisites and excludes forbidden surfaces', () => { assert.match(source, /export async function run\(context\)/) assert.doesNotMatch(source, /https?:\/\/|process\.env|fetch\(/) - for (const forbidden of ['/outbounds', '/receive-pallets', '/outbound', '/delivery', '/dispatch', '/export']) assert.equal(source.includes(forbidden), false, forbidden) + assert.doesNotMatch(source, /addCoverageGap|coverageGap\(/) + assert.match(source, /deploymentBlocker: true/) + for (const forbidden of ['/outbounds', '/receive-pallets', '/outbound', '/delivery', '/dispatch', '/export']) { + assert.equal(source.includes(forbidden), false, forbidden) + } assert.doesNotMatch(source, /requestOverseas\('DELETE'/) }) -test('marker truck and looseboard cover create detail edit stale conflict and finally cancel', async () => { +test('complete procurement truck and looseboard lifecycles pass with legal marker terminals', async () => { const context = fixture() await run(context) - assert.equal(context.steps.every((step) => step.passed), true, context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n')) - assert.ok(context.calls.some((call) => call.method === 'POST' && call.path === '/api/merchant/v1/last-leg/overseas-procurements')) - assert.ok(context.calls.some((call) => call.method === 'POST' && call.path === '/api/platform/v1/last-leg/us-truck-orders?customerId=10001')) - assert.ok(context.calls.some((call) => call.method === 'POST' && call.path === '/api/platform/v1/last-leg/local-looseboard-orders?customerId=10001')) - assert.equal(context.calls.filter((call) => call.method === 'PATCH' && call.path.includes('/us-truck-orders/')).length, 3) - assert.equal(context.calls.filter((call) => call.method === 'PUT' && call.path.includes('/local-looseboard-orders/')).length, 2) - assert.equal(context.calls.filter((call) => call.method === 'PATCH' && call.path.includes('/local-looseboard-orders/')).length, 1) - const statusCalls = context.calls.filter((call) => call.channel === 'overseas' && call.path.includes('/status?')) - assert.deepEqual(statusCalls.map((call) => [call.method, call.path, call.body.status]), [ - ['PATCH', '/api/platform/v1/last-leg/us-truck-orders/TK1/status?customerId=10001', 'CANCELLED'], - ['PATCH', '/api/platform/v1/last-leg/local-looseboard-orders/LB1/status?customerId=10001', 'CANCELLED'], - ]) - assert.ok(statusCalls.every((call) => Number(call.body.version) === 1)) - assert.equal(context.createdResources.phase4OverseasBusiness.truck.marker, context.marker) - assert.equal(context.createdResources.phase4OverseasBusiness.looseboard.marker, context.marker) - assert.equal(context.createdResources.phase4OverseasBusiness.truck.finalStatus, 'CANCELLED') - assert.equal(context.createdResources.phase4OverseasBusiness.looseboard.finalStatus, 'CANCELLED') - assert.equal(context.createdResources.phase4OverseasBusiness.truck.retained, false) - assert.equal(context.createdResources.phase4OverseasBusiness.looseboard.retained, false) - assert.equal(context.gaps.filter((gap) => gap[4]?.includes('retained-marker-test-data')).length, 0) - assert.ok(context.gaps.every((gap) => !/requestId=missing|traceId=missing/.test(gap[3]))) + assert.equal(context.steps.every((step) => step.passed), true, + context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n')) + assert.equal(context.gaps.length, 0) + assert.equal(context.blockers.length, 0) + + const procurementWrites = context.calls.filter((call) => call.path.includes('/overseas-procurements') && call.method !== 'GET') + assert.deepEqual(procurementWrites.map((call) => call.method), + ['POST', 'POST', 'POST', 'PATCH', 'PATCH', 'PATCH', 'PATCH', 'POST', 'POST']) + assert.equal(procurementWrites.filter((call) => call.method === 'PATCH').length, 4) + assert.ok(procurementWrites.some((call) => call.method === 'PATCH' && call.body.productSpecification === null && call.body.productUrl === '')) + + const truckCalls = context.calls.filter((call) => call.path.includes('/us-truck-orders')) + assert.equal(truckCalls.filter((call) => call.method === 'POST').length, 3) + assert.equal(truckCalls.filter((call) => call.method === 'GET' && call.path.includes('page=1')).length, 1) + assert.equal(truckCalls.filter((call) => call.method === 'PATCH' && call.path.includes('/status?')).length, 3) + + const looseboardCalls = context.calls.filter((call) => call.path.includes('/local-looseboard-orders')) + assert.equal(looseboardCalls.filter((call) => call.method === 'POST').length, 3) + assert.equal(looseboardCalls.filter((call) => call.method === 'GET' && call.path.includes('page=1')).length, 1) + assert.equal(looseboardCalls.filter((call) => call.method === 'PUT').length, 2) + assert.equal(looseboardCalls.filter((call) => call.method === 'PATCH' && call.path.includes('/status?')).length, 3) + + const registry = context.createdResources.phase4OverseasBusiness + assert.equal(registry.purchase.finalStatus, 'REJECTED') + assert.equal(registry.purchase.retained, false) + assert.equal(registry.truck.finalStatus, 'CANCELLED') + assert.equal(registry.truck.retained, false) + assert.equal(registry.looseboard.finalStatus, 'CANCELLED') + assert.equal(registry.looseboard.retained, false) + assert.equal(registry.partnerWarehouse.finalStatus, 'INACTIVE') + assert.equal(registry.partnerWarehouse.retained, false) }) -test('marker procurement is created through merchant then edited and rejected by platform', async () => { +test('old versions and reverse transitions are exact A0409 conflicts with trace evidence', async () => { const context = fixture() await run(context) - const purchaseWrites = context.calls.filter((call) => call.path.includes('/overseas-procurements') && call.method !== 'GET') - assert.deepEqual(purchaseWrites.map((call) => call.method), ['POST', 'PATCH', 'POST']) - assert.match(purchaseWrites[1].body.productSpecification, new RegExp(context.marker)) - assert.equal(context.createdResources.phase4OverseasBusiness.purchase.marker, context.marker) - assert.equal(context.createdResources.phase4OverseasBusiness.purchase.finalStatus, 'REJECTED') - assert.equal(context.createdResources.phase4OverseasBusiness.purchase.retained, false) + const conflictSteps = context.steps.filter((step) => /A0409|异载荷|逆向/.test(step.name)) + assert.ok(conflictSteps.length >= 7) + assert.ok(conflictSteps.every((step) => step.passed)) + assert.ok(conflictSteps.every((step) => step.requestId && step.traceId)) + assert.ok(conflictSteps.filter((step) => step.name.includes('旧 version')).every((step) => step.result.body.code === 'A0409')) + assert.ok(context.steps.filter((step) => /流水/.test(step.name)).every((step) => step.passed)) }) -test('contract locks real candidates, idempotency, version conflicts and trace evidence', () => { - for (const token of ['/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE', '/overseas-procurements/customer-options?limit=200', - '/overseas-procurements/warehouse-options', '/api/merchant/v1/last-leg/overseas-procurements', - "decision: 'REJECT'", 'idempotencyKey', 'phase4-truck', 'phase4-looseboard', - '旧 version', '/status', "status: 'CANCELLED'", 'finally', 'requestId=', 'traceId=', 'createdResources.phase4OverseasBusiness']) assert.ok(source.includes(token), token) +test('missing merchant fixture is an explicit deployment blocker and never a coverage gap', async () => { + const context = fixture({ credentials: false }) + await run(context) + assert.equal(context.gaps.length, 0) + assert.ok(context.blockers.some((step) => step.name === '采购客户登录前置完整')) + assert.equal(context.calls.some((call) => call.path.includes('/overseas-procurements')), false) + assert.equal(context.calls.some((call) => call.path.includes('/us-truck-orders')), false) + assert.equal(context.calls.some((call) => call.path.includes('/local-looseboard-orders')), false) +}) + +test('fixture customer must itself be active and cannot borrow another customer identity', async () => { + const context = fixture({ activeCustomers: [{ id: '10002', tenantName: '其他客户', status: 'ACTIVE' }] }) + await run(context) + assert.equal(context.gaps.length, 0) + assert.ok(context.blockers.some((step) => step.name === '本轮资格客户属于 ACTIVE 候选')) + assert.equal(context.calls.some((call) => call.path === '/api/auth/login'), false) + assert.equal(context.calls.some((call) => call.path.includes('/overseas-procurements')), false) +}) + +test('candidate blocker still rejects partial procurement and retires partner warehouse', async () => { + const context = fixture({ warehouseOptions: [] }) + await run(context) + assert.equal(context.gaps.length, 0) + assert.ok(context.blockers.some((step) => step.name === '采购仓候选存在 ACTIVE 仓')) + const registry = context.createdResources.phase4OverseasBusiness + assert.equal(registry.purchase.finalStatus, 'REJECTED') + assert.equal(registry.purchase.retained, false) + assert.equal(registry.partnerWarehouse.finalStatus, 'INACTIVE') + assert.equal(registry.partnerWarehouse.retained, false) + assert.equal(context.calls.some((call) => call.path.includes('/us-truck-orders')), false) + assert.equal(context.calls.some((call) => call.path.includes('/local-looseboard-orders')), false) +}) + +test('contract locks candidates three-state idempotency histories legal terminals and trace evidence', () => { + for (const token of ['/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE', + '/overseas-procurements/customer-options?limit=200', '/overseas-procurements/warehouse-options', + '/api/merchant/v1/last-leg/overseas-procurements', '显式 null 与空串清空', '字段缺失不修改', + '同幂等键同载荷精确重放', '同幂等键异载荷返回冲突', '旧 version 返回 A0409', + '状态正向推进', '状态逆向回退返回冲突', '流水覆盖', "status: 'CANCELLED'", + 'requestId=', 'traceId=', 'createdResources.phase4OverseasBusiness']) assert.ok(source.includes(token), token) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-business.mjs b/scripts/admin-test-env-modules/phase4-overseas-business.mjs index 21fab5d74..5f6075550 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business.mjs @@ -9,60 +9,48 @@ function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${c function records(page) { return Array.isArray(page?.records) ? page.records : [] } function query(path, params) { const search = new URLSearchParams(); for (const [key, value] of Object.entries(params)) if (value !== '' && value != null) search.set(key, String(value)); return `${path}?${search}` } function summary(data) { return data?.summary && typeof data.summary === 'object' ? data.summary : data } +function detailValue(data, field) { return data && Object.hasOwn(data, field) ? data[field] : summary(data)?.[field] } function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response).endsWith('409')) } +function versionConflict(response) { return response?.http === 200 && codeOf(response) === 'A0409' } function longId(value) { return /^[1-9]\d*$/.test(id(value)) } +function nonEmpty(value) { return typeof value === 'string' && value.trim().length > 0 } function pageValid(page) { return Array.isArray(page?.records) && Number(page?.page) >= 1 && Number(page?.size) >= 1 && Number(page?.total) >= page.records.length && Number(page?.pages) >= 0 } -function coverageGap(context, name, reason, category = 'prerequisite') { context.addCoverageGap(MODULE, SCENARIO, name, reason, category) } function ownedRegistry(context) { context.createdResources.phase4OverseasBusiness ??= { marker: context.marker }; return context.createdResources.phase4OverseasBusiness } +function blocker(context, name, response, reason) { + context.addStep(MODULE, name, response || null, false, `${reason}; ${trace(response)}`, OPTIONS) + return null +} + +function expectedConflict(context, name, response, exactVersion = false) { + const passed = exactVersion ? versionConflict(response) : conflict(response) + context.addStep(MODULE, name, response, passed, trace(response), OPTIONS) + return passed +} + async function domesticGet(context, name, path) { const response = await context.request('GET', path) const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) return { response, data } } + async function domestic(context, method, name, path, body) { const response = await context.request(method, path, body) const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) return { response, data } } + async function overseas(context, method, name, path, body) { const response = await context.requestOverseas(method, path, body) const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) return { response, data } } + function assertPage(context, name, result) { context.assert(MODULE, `${name}分页结构正确`, result.response, pageValid(result.data), `records=${result.data?.records?.length ?? 'missing'}, page=${result.data?.page ?? 'missing'}, size=${result.data?.size ?? 'missing'}, total=${result.data?.total ?? 'missing'}; ${trace(result.response)}`, OPTIONS) } -async function prerequisites(context) { - const tenants = await domesticGet(context, '查询 ACTIVE 客户候选', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE') - assertPage(context, 'ACTIVE 客户候选', tenants) - const activeCustomers = records(tenants.data).filter((item) => longId(item?.id) && item?.status === 'ACTIVE') - if (!activeCustomers.length) { - coverageGap(context, '海外业务写验收 ACTIVE 客户前置', `IAM 未返回真实 ACTIVE 客户;${trace(tenants.response)}`, 'missing-active-customer') - return null - } - const fixture = context.createdResources?.merchantQualificationFixture - const fixtureCustomerId = id(fixture?.tenantId) - const activeCustomer = activeCustomers.find((item) => id(item?.id) === fixtureCustomerId) || activeCustomers[0] - const partnerWarehouse = await createPartnerWarehouse(context) - const purchase = await createMerchantProcurement(context, activeCustomer) - const customerOptions = await overseas(context, 'GET', '查询采购客户候选', `${BASE}/overseas-procurements/customer-options?limit=200`) - const warehouseOptions = await overseas(context, 'GET', '查询 ACTIVE 采购仓候选', `${BASE}/overseas-procurements/warehouse-options`) - context.assert(MODULE, '采购客户候选结构正确', customerOptions.response, - Array.isArray(customerOptions.data) && customerOptions.data.every((item) => longId(item?.customerId) && String(item?.customerName || '').trim()), - `options=${customerOptions.data?.length ?? 'missing'}; ${trace(customerOptions.response)}`, OPTIONS) - context.assert(MODULE, '采购仓候选结构正确', warehouseOptions.response, - Array.isArray(warehouseOptions.data) && warehouseOptions.data.every((item) => longId(item?.warehouseId) && String(item?.warehouseName || '').trim()), - `options=${warehouseOptions.data?.length ?? 'missing'}; ${trace(warehouseOptions.response)}`, OPTIONS) - const activeIds = new Set(activeCustomers.map((item) => id(item.id))) - const procurementCustomer = Array.isArray(customerOptions.data) - ? customerOptions.data.find((item) => activeIds.has(id(item?.customerId))) : null - const warehouse = Array.isArray(warehouseOptions.data) ? warehouseOptions.data.find((item) => longId(item?.warehouseId)) : null - return { activeCustomer, procurementCustomer, warehouse, customerOptions, warehouseOptions, partnerWarehouse, purchase } -} - function partnerWarehousePayload(context) { const suffix = String(context.marker || '').replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase() return { @@ -78,24 +66,19 @@ function partnerWarehousePayload(context) { } async function createPartnerWarehouse(context) { - const payload = partnerWarehousePayload(context) - const created = await domestic(context, 'POST', '创建本轮采购合作仓', '/api/platform/v1/warehouse-admin', payload) + const created = await domestic(context, 'POST', '创建本轮采购合作仓', '/api/platform/v1/warehouse-admin', partnerWarehousePayload(context)) const warehouseId = id(created.data) - context.assert(MODULE, '采购合作仓创建返回长ID', created.response, longId(warehouseId), + const valid = longId(warehouseId) + context.assert(MODULE, '采购合作仓创建返回长ID', created.response, valid, `warehouseId=${warehouseId || 'missing'}; ${trace(created.response)}`, OPTIONS) - return warehouseId ? { id: warehouseId, finalStatus: 'ACTIVE' } : null + if (!valid) return null + const resource = { marker: context.marker, id: warehouseId, finalStatus: 'ACTIVE', retained: true } + ownedRegistry(context).partnerWarehouse = resource + return resource } -async function createMerchantProcurement(context, activeCustomer) { - const username = context.context?.merchantQualificationFixture?.username - const password = context.context?.merchantQualificationFixture?.password - const login = await context.request('POST', '/api/auth/login', { username, password }, '') - const auth = context.apiSuccess(MODULE, '采购客户账号登录', login, trace(login), OPTIONS) - const overseasToken = auth?.overseasAccessToken - context.assert(MODULE, '采购客户登录返回海外Token', login, nonEmpty(overseasToken), - `customerId=${id(activeCustomer?.id) || 'missing'}, tokenPresent=${Boolean(overseasToken)}; ${trace(login)}`, OPTIONS) - if (!overseasToken) return null - const payload = { +function procurementPayload(context, activeCustomer) { + return { customerName: activeCustomer?.tenantName || activeCustomer?.name || `Customer-${id(activeCustomer?.id)}`, purchaseCategory: 'OFFICE_SUPPLIES', productName: `${context.marker} 采购商品`, productSpecification: 'Phase4 acceptance', productUrl: null, purchaseQuantity: 10, @@ -103,59 +86,186 @@ async function createMerchantProcurement(context, activeCustomer) { contactName: '采购验收联系人', contactPhone: '13800000002', remark: context.marker, idempotencyKey: `${context.marker}-phase4-procurement`.slice(0, 255), } - const response = await context.requestOverseas('POST', '/api/merchant/v1/last-leg/overseas-procurements', payload, {}, overseasToken) - const detail = context.apiSuccess(MODULE, '创建本轮客户采购订单', response, trace(response), OPTIONS) - const value = summary(detail) - context.assert(MODULE, '客户采购订单创建为待审核且归属正确', response, - nonEmpty(value?.orderNo) && id(value?.customerId) === id(activeCustomer?.id) && value?.status === 'PENDING_REVIEW', - `orderNo=${id(value?.orderNo) || 'missing'}, customerId=${id(value?.customerId) || 'missing'}, status=${value?.status || 'missing'}; ${trace(response)}`, OPTIONS) - const purchase = value?.orderNo ? { marker: context.marker, orderNo: id(value.orderNo), customerId: id(value.customerId), version: Number(value.version), retained: true } : null - if (purchase) ownedRegistry(context).purchase = purchase - return purchase } -function nonEmpty(value) { return typeof value === 'string' && value.trim().length > 0 } +async function loginMerchant(context, activeCustomer) { + const fixture = context.context?.merchantQualificationFixture + if (!nonEmpty(fixture?.username) || !nonEmpty(fixture?.password)) { + return blocker(context, '采购客户登录前置完整', null, + `缺少 marker=${context.marker} 的客户账号凭据,禁止使用历史客户或降级为 coverageGap`) + } + const login = await context.request('POST', '/api/auth/login', { username: fixture.username, password: fixture.password }, '') + const auth = context.apiSuccess(MODULE, '采购客户账号登录', login, trace(login), OPTIONS) + const overseasToken = auth?.overseasAccessToken + context.assert(MODULE, '采购客户登录返回海外Token', login, nonEmpty(overseasToken), + `customerId=${id(activeCustomer?.id) || 'missing'}, tokenPresent=${Boolean(overseasToken)}; ${trace(login)}`, OPTIONS) + return nonEmpty(overseasToken) ? overseasToken : null +} -function markerOwnedPurchase(context) { - const candidates = [context.createdResources?.phase4OverseasBusiness?.purchase, context.createdResources?.overseasProcurement] - return candidates.find((item) => item?.marker === context.marker && id(item?.orderNo) && longId(item?.customerId)) || null +async function createMerchantProcurement(context, activeCustomer, overseasToken) { + const path = '/api/merchant/v1/last-leg/overseas-procurements' + const payload = procurementPayload(context, activeCustomer) + const response = await context.requestOverseas('POST', path, payload, {}, overseasToken) + const detail = context.apiSuccess(MODULE, '创建本轮客户采购订单', response, trace(response), OPTIONS) + const value = summary(detail) + const resource = nonEmpty(value?.orderNo) && longId(value?.customerId) + ? { marker: context.marker, orderNo: id(value.orderNo), customerId: id(value.customerId), + version: Number(value.version), finalStatus: value.status, retained: true } + : null + if (resource) ownedRegistry(context).purchase = resource + const valid = nonEmpty(value?.orderNo) && id(value?.customerId) === id(activeCustomer?.id) + && value?.status === 'PENDING_REVIEW' && Number(value?.version) === 0 + context.assert(MODULE, '客户采购订单创建为待审核且归属正确', response, valid, + `orderNo=${id(value?.orderNo) || 'missing'}, customerId=${id(value?.customerId) || 'missing'}, status=${value?.status || 'missing'}, version=${value?.version ?? 'missing'}; ${trace(response)}`, OPTIONS) + if (!valid) return null + + const replayResponse = await context.requestOverseas('POST', path, payload, {}, overseasToken) + const replayDetail = context.apiSuccess(MODULE, '采购创建同幂等键同载荷精确重放', replayResponse, trace(replayResponse), OPTIONS) + const replay = summary(replayDetail) + context.assert(MODULE, '采购幂等重放复用订单且版本不漂移', replayResponse, + replay?.orderNo === value.orderNo && Number(replay?.version) === Number(value.version), + `orderNo=${id(replay?.orderNo) || 'missing'}, version=${replay?.version ?? 'missing'}; ${trace(replayResponse)}`, OPTIONS) + + const mismatch = await context.requestOverseas('POST', path, + { ...payload, productName: `${payload.productName}-DIFFERENT` }, {}, overseasToken) + expectedConflict(context, '采购创建同幂等键异载荷返回冲突', mismatch) + + return { resource, payload } +} + +async function prerequisites(context) { + const tenants = await domesticGet(context, '查询 ACTIVE 客户候选', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE') + assertPage(context, 'ACTIVE 客户候选', tenants) + const activeCustomers = records(tenants.data).filter((item) => longId(item?.id) && item?.status === 'ACTIVE') + if (!activeCustomers.length) return blocker(context, '海外业务写验收 ACTIVE 客户前置', tenants.response, + 'IAM 未返回真实 ACTIVE 客户,禁止跳过完整生命周期') + const fixtureCustomerId = id(context.createdResources?.merchantQualificationFixture?.tenantId) + const activeCustomer = activeCustomers.find((item) => id(item?.id) === fixtureCustomerId) + if (!longId(fixtureCustomerId) || !activeCustomer) return blocker(context, '本轮资格客户属于 ACTIVE 候选', tenants.response, + `marker fixture 客户 ${fixtureCustomerId || 'missing'} 不在 ACTIVE 候选中,禁止借用其他客户身份`) + const overseasToken = await loginMerchant(context, activeCustomer) + if (!overseasToken) return null + const partnerWarehouse = await createPartnerWarehouse(context) + if (!partnerWarehouse) return null + const purchase = await createMerchantProcurement(context, activeCustomer, overseasToken) + if (!purchase) return null + + const customerOptions = await overseas(context, 'GET', '查询采购客户候选', `${BASE}/overseas-procurements/customer-options?limit=200`) + const warehouseOptions = await overseas(context, 'GET', '查询 ACTIVE 采购仓候选', `${BASE}/overseas-procurements/warehouse-options`) + context.assert(MODULE, '采购客户候选结构正确', customerOptions.response, + Array.isArray(customerOptions.data) && customerOptions.data.every((item) => longId(item?.customerId) && nonEmpty(item?.customerName)), + `options=${customerOptions.data?.length ?? 'missing'}; ${trace(customerOptions.response)}`, OPTIONS) + context.assert(MODULE, '采购仓候选结构正确', warehouseOptions.response, + Array.isArray(warehouseOptions.data) && warehouseOptions.data.every((item) => longId(item?.warehouseId) && nonEmpty(item?.warehouseName)), + `options=${warehouseOptions.data?.length ?? 'missing'}; ${trace(warehouseOptions.response)}`, OPTIONS) + const procurementCustomer = Array.isArray(customerOptions.data) + ? customerOptions.data.find((item) => id(item?.customerId) === id(activeCustomer.id)) : null + const warehouse = Array.isArray(warehouseOptions.data) + ? warehouseOptions.data.find((item) => id(item?.warehouseId) === partnerWarehouse.id) + || warehouseOptions.data.find((item) => longId(item?.warehouseId)) : null + if (!procurementCustomer) return blocker(context, '采购客户候选包含本轮客户', customerOptions.response, + `本轮客户 ${id(activeCustomer.id)} 未进入采购客户候选,禁止继续写验收`) + if (!warehouse) return blocker(context, '采购仓候选存在 ACTIVE 仓', warehouseOptions.response, + '采购仓候选为空,禁止跳过采购三态清空') + return { activeCustomer, procurementCustomer, warehouse, customerOptions, warehouseOptions, + partnerWarehouse, purchase, overseasToken } +} + +function assertProcurementHistory(context, result) { + const histories = Array.isArray(result.data?.histories) ? result.data.histories : [] + const operations = new Set(histories.map((item) => item?.operationType)) + context.assert(MODULE, '采购流水覆盖创建编辑与审核', result.response, + ['CREATE', 'UPDATE', 'REVIEW'].every((type) => operations.has(type)) + && histories.some((item) => String(item?.remark || item?.changeDetail || '').includes(context.marker)), + `historyCount=${histories.length}, operations=${[...operations].join(',') || 'missing'}; ${trace(result.response)}`, OPTIONS) } async function procurementFlow(context, prerequisite) { - if (!prerequisite.procurementCustomer || !prerequisite.warehouse) { - coverageGap(context, '采购真实候选与列表前置', `ACTIVE 客户与采购客户候选无交集或无 ACTIVE 采购仓;customerOptions=${prerequisite.customerOptions.data?.length ?? 0}, warehouseOptions=${prerequisite.warehouseOptions.data?.length ?? 0};customer=${trace(prerequisite.customerOptions.response)};warehouse=${trace(prerequisite.warehouseOptions.response)}`, 'missing-procurement-candidate') - return - } const customerId = id(prerequisite.procurementCustomer.customerId) const warehouseId = id(prerequisite.warehouse.warehouseId) - const list = await overseas(context, 'GET', '按真实客户查询采购订单', query(`${BASE}/overseas-procurements`, { page: 1, size: 20, customerId })) + const owned = prerequisite.purchase.resource + const list = await overseas(context, 'GET', '按本轮客户查询采购订单', query(`${BASE}/overseas-procurements`, + { page: 1, size: 20, customerId, orderNo: owned.orderNo })) assertPage(context, '海外采购订单', list) - const owned = markerOwnedPurchase(context) - const row = records(list.data).find((item) => owned && item?.orderNo === owned.orderNo && id(item?.customerId) === id(owned.customerId)) - if (!row) { - coverageGap(context, '采购 PATCH 本轮资源', `平台无采购 create,列表中没有 marker=${context.marker} 的本轮自有采购单;禁止修改历史客户数据;${trace(list.response)}`, 'missing-marker-owned-procurement') - return - } - const detailPath = query(`${BASE}/overseas-procurements/${encodeURIComponent(row.orderNo)}`, { customerId: id(row.customerId) }) - const detail = await overseas(context, 'GET', '查询本轮自有采购订单详情', detailPath) - const current = summary(detail.data) - const update = await overseas(context, 'PATCH', '编辑本轮自有采购订单', detailPath, - { version: Number(current?.version), productSpecification: `${context.marker}-procurement`, - purchaseWarehouseId: warehouseId, purchaseWarehouseName: prerequisite.warehouse.warehouseName, - remark: context.marker }) - const updated = summary(update.data) - context.assert(MODULE, '本轮采购编辑回显 marker 且版本递增', update.response, - updated?.productSpecification === `${context.marker}-procurement` && Number(updated?.version) === Number(current?.version) + 1, - `orderNo=${id(updated?.orderNo) || 'missing'}, version=${updated?.version ?? 'missing'}; ${trace(update.response)}`, OPTIONS) - ownedRegistry(context).purchase = { marker: context.marker, orderNo: id(updated?.orderNo), customerId: id(updated?.customerId), version: Number(updated?.version), retained: true } - const reviewed = await overseas(context, 'POST', '驳回本轮采购订单并安全收口', - query(`${BASE}/overseas-procurements/${encodeURIComponent(row.orderNo)}/review`, { customerId }), - { decision: 'REJECT', remark: `${context.marker}-acceptance-cleanup` }) - const reviewedSummary = summary(reviewed.data) - context.assert(MODULE, '本轮采购订单终态为已驳回', reviewed.response, - reviewedSummary?.status === 'REJECTED' && Number(reviewedSummary?.version) === Number(updated?.version) + 1, - `orderNo=${id(reviewedSummary?.orderNo) || 'missing'}, status=${reviewedSummary?.status || 'missing'}, version=${reviewedSummary?.version ?? 'missing'}; ${trace(reviewed.response)}`, OPTIONS) - ownedRegistry(context).purchase = { marker: context.marker, orderNo: id(reviewedSummary?.orderNo), customerId, version: Number(reviewedSummary?.version), finalStatus: reviewedSummary?.status, retained: reviewedSummary?.status !== 'REJECTED' } + const row = records(list.data).find((item) => item?.orderNo === owned.orderNo && id(item?.customerId) === customerId) + context.assert(MODULE, '采购列表包含本轮 marker 订单', list.response, Boolean(row), + `orderNo=${owned.orderNo}, customerId=${customerId}; ${trace(list.response)}`, OPTIONS) + if (!row) return blocker(context, '采购列表详情前置完整', list.response, + `列表缺少 marker=${context.marker} 的本轮采购订单`) + + const path = query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}`, { customerId }) + const detail = await overseas(context, 'GET', '查询本轮采购订单详情', path) + const before = summary(detail.data) + context.assert(MODULE, '采购详情归属和初始版本正确', detail.response, + before?.orderNo === owned.orderNo && id(before?.customerId) === customerId && Number(before?.version) === 0, + `orderNo=${id(before?.orderNo) || 'missing'}, version=${before?.version ?? 'missing'}; ${trace(detail.response)}`, OPTIONS) + + const setResult = await overseas(context, 'PATCH', '采购可空字段设置非空值', path, + { version: Number(before?.version), productSpecification: `${context.marker}-SPEC`, + productUrl: `phase4-product-${context.marker}`, purchaseWarehouseId: warehouseId, + purchaseWarehouseName: prerequisite.warehouse.warehouseName, domesticCarrier: `${context.marker}-CARRIER`, + domesticTrackingNo: `${context.marker}-TRACK`, estimatedArrivalAt: '2026-12-31T12:00:00', remark: context.marker }) + const setSummary = summary(setResult.data) + context.assert(MODULE, '采购三态第一态设置值并递增版本', setResult.response, + setSummary?.productSpecification === `${context.marker}-SPEC` + && detailValue(setResult.data, 'productUrl')?.includes(context.marker) + && id(setSummary?.purchaseWarehouseId) === warehouseId + && Number(setSummary?.version) === Number(before?.version) + 1, + `version=${setSummary?.version ?? 'missing'}, warehouseId=${id(setSummary?.purchaseWarehouseId) || 'missing'}; ${trace(setResult.response)}`, OPTIONS) + + const omittedResult = await overseas(context, 'PATCH', '采购可空字段缺失保持原值', path, + { version: Number(setSummary?.version), domesticCarrier: `${context.marker}-CARRIER-2` }) + const omittedSummary = summary(omittedResult.data) + context.assert(MODULE, '采购三态第二态字段缺失不修改', omittedResult.response, + omittedSummary?.productSpecification === setSummary?.productSpecification + && detailValue(omittedResult.data, 'productUrl') === detailValue(setResult.data, 'productUrl') + && id(omittedSummary?.purchaseWarehouseId) === warehouseId + && Number(omittedSummary?.version) === Number(setSummary?.version) + 1, + `version=${omittedSummary?.version ?? 'missing'}, specification=${omittedSummary?.productSpecification || 'missing'}; ${trace(omittedResult.response)}`, OPTIONS) + + const clearedResult = await overseas(context, 'PATCH', '采购可空字段显式 null 与空串清空', path, + { version: Number(omittedSummary?.version), productSpecification: null, productUrl: '', purchaseWarehouseId: null, + purchaseWarehouseName: null, domesticCarrier: null, domesticTrackingNo: '', estimatedArrivalAt: null, remark: null }) + const clearedSummary = summary(clearedResult.data) + context.assert(MODULE, '采购三态第三态显式清空全部生效', clearedResult.response, + clearedSummary?.productSpecification == null && detailValue(clearedResult.data, 'productUrl') == null + && clearedSummary?.purchaseWarehouseId == null && clearedSummary?.purchaseWarehouseName == null + && detailValue(clearedResult.data, 'domesticCarrier') == null + && detailValue(clearedResult.data, 'domesticTrackingNo') == null + && detailValue(clearedResult.data, 'estimatedArrivalAt') == null + && Number(clearedSummary?.version) === Number(omittedSummary?.version) + 1, + `version=${clearedSummary?.version ?? 'missing'}, specification=${clearedSummary?.productSpecification ?? 'null'}, warehouseId=${clearedSummary?.purchaseWarehouseId ?? 'null'}; ${trace(clearedResult.response)}`, OPTIONS) + + const stale = await context.requestOverseas('PATCH', path, + { version: Number(setSummary?.version), productSpecification: 'STALE-DO-NOT-APPLY' }) + expectedConflict(context, '采购旧 version 返回 A0409', stale, true) + const afterConflict = await overseas(context, 'GET', '采购冲突后零漂移回查', path) + const afterConflictSummary = summary(afterConflict.data) + context.assert(MODULE, '采购旧 version 冲突未造成部分更新', afterConflict.response, + afterConflictSummary?.productSpecification == null + && Number(afterConflictSummary?.version) === Number(clearedSummary?.version), + `version=${afterConflictSummary?.version ?? 'missing'}, specification=${afterConflictSummary?.productSpecification ?? 'null'}; ${trace(afterConflict.response)}`, OPTIONS) + + const rejected = await overseas(context, 'POST', '驳回本轮采购订单并进入合法终态', + query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), + { decision: 'REJECT', remark: `${context.marker}-REJECTED` }) + const rejectedSummary = summary(rejected.data) + context.assert(MODULE, '采购审核正向驳回成功', rejected.response, + rejectedSummary?.status === 'REJECTED' && Number(rejectedSummary?.version) === Number(clearedSummary?.version) + 1, + `status=${rejectedSummary?.status || 'missing'}, version=${rejectedSummary?.version ?? 'missing'}; ${trace(rejected.response)}`, OPTIONS) + const reverseReview = await context.requestOverseas('POST', + query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), + { decision: 'APPROVE', remark: 'REVERSE-DO-NOT-APPLY' }) + expectedConflict(context, '采购审核终态逆向返回冲突', reverseReview) + + const final = await overseas(context, 'GET', '采购最终状态与流水回查', path) + const finalSummary = summary(final.data) + context.assert(MODULE, '采购 marker 资源最终保持合法终态', final.response, + finalSummary?.status === 'REJECTED' && Number(finalSummary?.version) === Number(rejectedSummary?.version), + `status=${finalSummary?.status || 'missing'}, version=${finalSummary?.version ?? 'missing'}; ${trace(final.response)}`, OPTIONS) + assertProcurementHistory(context, final) + Object.assign(owned, { version: Number(finalSummary?.version), finalStatus: finalSummary?.status, + retained: finalSummary?.status !== 'REJECTED' }) } function truckPayload(context) { @@ -170,72 +280,6 @@ function truckPayload(context) { } } -async function cancelMarkerOrder(context, resource, base, cancellableStatus, label) { - if (!resource || resource.marker !== context.marker || !resource.orderNo || !longId(resource.customerId)) return - const detailPath = query(`${base}/${encodeURIComponent(resource.orderNo)}`, { customerId: resource.customerId }) - const current = await overseas(context, 'GET', `清理前回查 marker ${label}订单`, detailPath) - const currentSummary = summary(current.data) - if (currentSummary?.status === 'CANCELLED') { - resource.finalStatus = 'CANCELLED'; resource.version = Number(currentSummary.version); resource.retained = false - return - } - if (currentSummary?.status !== cancellableStatus) { - resource.finalStatus = currentSummary?.status || resource.finalStatus; resource.version = Number(currentSummary?.version); resource.retained = true - coverageGap(context, `${label}测试数据最终处置`, `本轮 marker 资源 ${resource.orderNo} 当前状态 ${resource.finalStatus || 'missing'} 不允许安全取消;${trace(current.response)}`, 'unsafe-marker-terminal-state') - return - } - const statusPath = query(`${base}/${encodeURIComponent(resource.orderNo)}/status`, { customerId: resource.customerId }) - const cancelled = await overseas(context, 'PATCH', `取消 marker ${label}订单`, statusPath, - { version: Number(currentSummary.version), status: 'CANCELLED', remark: `${context.marker}-acceptance-cleanup` }) - const cancelledSummary = summary(cancelled.data) - const verified = await overseas(context, 'GET', `回查 marker ${label}订单最终状态`, detailPath) - const finalSummary = summary(verified.data) - const passed = cancelledSummary?.status === 'CANCELLED' && finalSummary?.status === 'CANCELLED' - && Number(finalSummary?.version) === Number(cancelledSummary?.version) - context.assert(MODULE, `${label}本轮测试数据最终取消`, verified.response, passed, - `orderNo=${resource.orderNo}, status=${finalSummary?.status || 'missing'}, version=${finalSummary?.version ?? 'missing'}; statusCall=${trace(cancelled.response)}; verify=${trace(verified.response)}`, OPTIONS) - resource.finalStatus = finalSummary?.status || cancelledSummary?.status || resource.finalStatus - resource.version = Number(finalSummary?.version ?? cancelledSummary?.version) - resource.retained = !passed - if (!passed) coverageGap(context, `${label}测试数据最终处置`, `本轮 marker 资源 ${resource.orderNo} 取消失败并保留;statusCall=${trace(cancelled.response)};verify=${trace(verified.response)}`, 'cleanup-failed-retained-marker-test-data') -} - -async function truckFlow(context, customerId) { - const base = `${BASE}/us-truck-orders` - const payload = truckPayload(context) - const created = await overseas(context, 'POST', '创建 marker 本土卡车订单', query(base, { customerId }), payload) - const createdSummary = summary(created.data) - const orderNo = id(createdSummary?.orderNo) - context.assert(MODULE, '卡车创建返回本轮业务键和待审核状态', created.response, - Boolean(orderNo) && id(createdSummary?.customerId) === customerId && createdSummary?.status === 'PENDING_REVIEW' && Number(createdSummary?.version) === 0, - `orderNo=${orderNo || 'missing'}, customerId=${id(createdSummary?.customerId) || 'missing'}, status=${createdSummary?.status || 'missing'}; ${trace(created.response)}`, OPTIONS) - if (!orderNo) return - const registry = ownedRegistry(context) - registry.truck = { marker: context.marker, orderNo, customerId, id: id(createdSummary?.id), finalStatus: createdSummary.status, retained: true } - const path = query(`${base}/${encodeURIComponent(orderNo)}`, { customerId }) - try { - const detail = await overseas(context, 'GET', '查询 marker 本土卡车详情', path) - const before = summary(detail.data) - const editedRemark = `${context.marker}-truck-edited` - const edited = await overseas(context, 'PATCH', '编辑 marker 本土卡车订单', path, - { version: Number(before?.version), destination: `${payload.destination}-EDIT`, remark: editedRemark }) - const after = summary(edited.data) - context.assert(MODULE, '卡车编辑版本递增且普通字段回显', edited.response, - after?.destination === `${payload.destination}-EDIT` && Number(after?.version) === Number(before?.version) + 1 && after?.status === 'PENDING_REVIEW', - `version=${after?.version ?? 'missing'}, destination=${after?.destination || 'missing'}; ${trace(edited.response)}`, OPTIONS) - const stale = await context.requestOverseas('PATCH', path, { version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' }) - context.addStep(MODULE, '卡车旧 version 编辑返回冲突', stale, conflict(stale), trace(stale), OPTIONS) - const final = await overseas(context, 'GET', '冲突后回查 marker 本土卡车订单', path) - const finalSummary = summary(final.data) - context.assert(MODULE, '卡车冲突未造成部分更新', final.response, - finalSummary?.destination === after?.destination && Number(finalSummary?.version) === Number(after?.version), - `destination=${finalSummary?.destination || 'missing'}, version=${finalSummary?.version ?? 'missing'}; ${trace(final.response)}`, OPTIONS) - registry.truck.version = Number(finalSummary?.version); registry.truck.finalStatus = finalSummary?.status - } finally { - await cancelMarkerOrder(context, registry.truck, base, 'PENDING_REVIEW', '卡车') - } -} - function looseboardPayload(context) { const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16) return { @@ -248,56 +292,240 @@ function looseboardPayload(context) { } } +async function assertCreateIdempotency(context, base, payload, customerId, label) { + const path = query(base, { customerId }) + const created = await overseas(context, 'POST', `创建 marker ${label}订单`, path, payload) + const value = summary(created.data) + if (!nonEmpty(value?.orderNo)) return { created, value, path } + const replay = await overseas(context, 'POST', `${label}创建同幂等键同载荷精确重放`, path, payload) + const replayValue = summary(replay.data) + context.assert(MODULE, `${label}幂等重放复用订单且版本不漂移`, replay.response, + replayValue?.orderNo === value.orderNo && Number(replayValue?.version) === Number(value.version), + `orderNo=${id(replayValue?.orderNo) || 'missing'}, version=${replayValue?.version ?? 'missing'}; ${trace(replay.response)}`, OPTIONS) + const mismatch = await context.requestOverseas('POST', path, + { ...payload, destination: `${payload.destination}-DIFFERENT` }) + expectedConflict(context, `${label}创建同幂等键异载荷返回冲突`, mismatch) + return { created, value, path } +} + +async function cancelMarkerOrder(context, resource, base, cancellableStatuses, label) { + if (!resource || resource.marker !== context.marker || !resource.orderNo || !longId(resource.customerId)) { + blocker(context, `${label}最终处置资源归属完整`, null, + `缺少 marker=${context.marker} 的 ${label} 资源登记,无法证明合法终态`) + return + } + const detailPath = query(`${base}/${encodeURIComponent(resource.orderNo)}`, { customerId: resource.customerId }) + const current = await overseas(context, 'GET', `清理前回查 marker ${label}订单`, detailPath) + const currentSummary = summary(current.data) + if (currentSummary?.status === 'CANCELLED') { + resource.finalStatus = 'CANCELLED'; resource.version = Number(currentSummary.version); resource.retained = false + return + } + if (!cancellableStatuses.includes(currentSummary?.status)) { + resource.finalStatus = currentSummary?.status || resource.finalStatus; resource.version = Number(currentSummary?.version); resource.retained = true + blocker(context, `${label}测试数据最终处置`, current.response, + `本轮 marker 资源 ${resource.orderNo} 当前状态 ${resource.finalStatus || 'missing'} 不允许安全取消`) + return + } + const statusPath = query(`${base}/${encodeURIComponent(resource.orderNo)}/status`, { customerId: resource.customerId }) + const cancelled = await overseas(context, 'PATCH', `取消 marker ${label}订单`, statusPath, + { version: Number(currentSummary.version), status: 'CANCELLED', remark: `${context.marker}-acceptance-cleanup` }) + const cancelledSummary = summary(cancelled.data) + const verified = await overseas(context, 'GET', `回查 marker ${label}订单最终状态`, detailPath) + const finalSummary = summary(verified.data) + const passed = cancelledSummary?.status === 'CANCELLED' && finalSummary?.status === 'CANCELLED' + && Number(finalSummary?.version) === Number(cancelledSummary?.version) + context.assert(MODULE, `${label}本轮测试数据最终合法取消`, verified.response, passed, + `orderNo=${resource.orderNo}, status=${finalSummary?.status || 'missing'}, version=${finalSummary?.version ?? 'missing'}; statusCall=${trace(cancelled.response)}; verify=${trace(verified.response)}`, OPTIONS) + resource.finalStatus = finalSummary?.status || cancelledSummary?.status || resource.finalStatus + resource.version = Number(finalSummary?.version ?? cancelledSummary?.version) + resource.retained = !passed +} + +async function truckFlow(context, customerId) { + const base = `${BASE}/us-truck-orders` + const payload = truckPayload(context) + const creation = await assertCreateIdempotency(context, base, payload, customerId, '卡车') + const created = creation.created; const createdSummary = creation.value + const orderNo = id(createdSummary?.orderNo) + context.assert(MODULE, '卡车创建返回本轮业务键和待审核状态', created.response, + Boolean(orderNo) && id(createdSummary?.customerId) === customerId + && createdSummary?.status === 'PENDING_REVIEW' && Number(createdSummary?.version) === 0, + `orderNo=${orderNo || 'missing'}, customerId=${id(createdSummary?.customerId) || 'missing'}, status=${createdSummary?.status || 'missing'}; ${trace(created.response)}`, OPTIONS) + if (!orderNo) return blocker(context, '卡车完整生命周期创建前置', created.response, '卡车创建未返回订单号') + const registry = ownedRegistry(context) + registry.truck = { marker: context.marker, orderNo, customerId, id: id(createdSummary?.id), finalStatus: createdSummary.status, retained: true } + const path = query(`${base}/${encodeURIComponent(orderNo)}`, { customerId }) + try { + const list = await overseas(context, 'GET', '按本轮客户与单号查询卡车列表', query(base, + { page: 1, size: 20, customerId, orderNo })) + assertPage(context, '本土卡车订单', list) + context.assert(MODULE, '卡车列表包含本轮 marker 订单', list.response, + records(list.data).some((item) => item?.orderNo === orderNo && id(item?.customerId) === customerId), + `orderNo=${orderNo}; ${trace(list.response)}`, OPTIONS) + const detail = await overseas(context, 'GET', '查询 marker 本土卡车详情', path) + const before = summary(detail.data) + const edited = await overseas(context, 'PATCH', '编辑 marker 本土卡车订单', path, + { version: Number(before?.version), destination: `${payload.destination}-EDIT`, remark: `${context.marker}-truck-edited` }) + const after = summary(edited.data) + context.assert(MODULE, '卡车编辑版本递增且普通字段回显', edited.response, + after?.destination === `${payload.destination}-EDIT` && Number(after?.version) === Number(before?.version) + 1 + && after?.status === 'PENDING_REVIEW', + `version=${after?.version ?? 'missing'}, destination=${after?.destination || 'missing'}; ${trace(edited.response)}`, OPTIONS) + const stale = await context.requestOverseas('PATCH', path, + { version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' }) + expectedConflict(context, '卡车旧 version 编辑返回 A0409', stale, true) + const afterStale = await overseas(context, 'GET', '卡车旧 version 冲突后零漂移回查', path) + const afterStaleSummary = summary(afterStale.data) + context.assert(MODULE, '卡车冲突未造成部分更新', afterStale.response, + afterStaleSummary?.destination === after?.destination && Number(afterStaleSummary?.version) === Number(after?.version), + `destination=${afterStaleSummary?.destination || 'missing'}, version=${afterStaleSummary?.version ?? 'missing'}; ${trace(afterStale.response)}`, OPTIONS) + + const statusPath = query(`${base}/${encodeURIComponent(orderNo)}/status`, { customerId }) + const forwarded = await overseas(context, 'PATCH', '卡车状态正向推进到待入仓', statusPath, + { version: Number(afterStaleSummary?.version), status: 'WAITING_INBOUND', remark: `${context.marker}-FORWARD` }) + const forwardedSummary = summary(forwarded.data) + context.assert(MODULE, '卡车状态正向推进成功', forwarded.response, + forwardedSummary?.status === 'WAITING_INBOUND' + && Number(forwardedSummary?.version) === Number(afterStaleSummary?.version) + 1, + `status=${forwardedSummary?.status || 'missing'}, version=${forwardedSummary?.version ?? 'missing'}; ${trace(forwarded.response)}`, OPTIONS) + const reversed = await context.requestOverseas('PATCH', statusPath, + { version: Number(forwardedSummary?.version), status: 'PENDING_REVIEW', remark: 'REVERSE-DO-NOT-APPLY' }) + expectedConflict(context, '卡车状态逆向回退返回冲突', reversed) + const history = await overseas(context, 'GET', '卡车状态冲突后流水回查', path) + const historySummary = summary(history.data) + const histories = Array.isArray(history.data?.histories) ? history.data.histories : [] + context.assert(MODULE, '卡车流水覆盖创建编辑与正向状态', history.response, + historySummary?.status === 'WAITING_INBOUND' && histories.length >= 3 + && histories.some((item) => String(item?.remark || '').includes(context.marker)), + `status=${historySummary?.status || 'missing'}, historyCount=${histories.length}; ${trace(history.response)}`, OPTIONS) + Object.assign(registry.truck, { version: Number(historySummary?.version), finalStatus: historySummary?.status }) + } finally { + await cancelMarkerOrder(context, registry.truck, base, ['PENDING_REVIEW', 'WAITING_INBOUND'], '卡车') + } +} + async function looseboardFlow(context, customerId) { const base = `${BASE}/local-looseboard-orders` const payload = looseboardPayload(context) - const created = await overseas(context, 'POST', '创建 marker 本土散板订单', query(base, { customerId }), payload) - const createdSummary = summary(created.data) + const creation = await assertCreateIdempotency(context, base, payload, customerId, '散板') + const created = creation.created; const createdSummary = creation.value const orderNo = id(createdSummary?.orderNo) context.assert(MODULE, '散板创建返回本轮业务键和待接单状态', created.response, - Boolean(orderNo) && id(createdSummary?.customerId) === customerId && createdSummary?.status === 'PENDING_ACCEPTANCE' && Number(createdSummary?.version) === 0, + Boolean(orderNo) && id(createdSummary?.customerId) === customerId + && createdSummary?.status === 'PENDING_ACCEPTANCE' && Number(createdSummary?.version) === 0, `orderNo=${orderNo || 'missing'}, customerId=${id(createdSummary?.customerId) || 'missing'}, status=${createdSummary?.status || 'missing'}; ${trace(created.response)}`, OPTIONS) - if (!orderNo) return + if (!orderNo) return blocker(context, '散板完整生命周期创建前置', created.response, '散板创建未返回订单号') const registry = ownedRegistry(context) registry.looseboard = { marker: context.marker, orderNo, customerId, id: id(createdSummary?.id), finalStatus: createdSummary.status, retained: true } const path = query(`${base}/${encodeURIComponent(orderNo)}`, { customerId }) try { + const list = await overseas(context, 'GET', '按本轮客户与单号查询散板列表', query(base, + { page: 1, size: 20, customerId, orderNo })) + assertPage(context, '本土散板订单', list) + context.assert(MODULE, '散板列表包含本轮 marker 订单', list.response, + records(list.data).some((item) => item?.orderNo === orderNo && id(item?.customerId) === customerId), + `orderNo=${orderNo}; ${trace(list.response)}`, OPTIONS) const detail = await overseas(context, 'GET', '查询 marker 本土散板详情', path) const before = summary(detail.data) const { idempotencyKey: _key, ...editable } = payload const edited = await overseas(context, 'PUT', '编辑 marker 本土散板订单', path, - { ...editable, version: Number(before?.version), destination: `${payload.destination}-EDIT`, remark: `${context.marker}-looseboard-edited` }) + { ...editable, version: Number(before?.version), destination: `${payload.destination}-EDIT`, + remark: `${context.marker}-looseboard-edited` }) const after = summary(edited.data) context.assert(MODULE, '散板编辑版本递增且普通字段回显', edited.response, - after?.destination === `${payload.destination}-EDIT` && Number(after?.version) === Number(before?.version) + 1 && after?.status === 'PENDING_ACCEPTANCE', + after?.destination === `${payload.destination}-EDIT` && Number(after?.version) === Number(before?.version) + 1 + && after?.status === 'PENDING_ACCEPTANCE', `version=${after?.version ?? 'missing'}, destination=${after?.destination || 'missing'}; ${trace(edited.response)}`, OPTIONS) const stale = await context.requestOverseas('PUT', path, { ...editable, version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' }) - context.addStep(MODULE, '散板旧 version 编辑返回冲突', stale, conflict(stale), trace(stale), OPTIONS) - const final = await overseas(context, 'GET', '冲突后回查 marker 本土散板订单', path) - const finalSummary = summary(final.data) - context.assert(MODULE, '散板冲突未造成部分更新', final.response, - finalSummary?.destination === after?.destination && Number(finalSummary?.version) === Number(after?.version), - `destination=${finalSummary?.destination || 'missing'}, version=${finalSummary?.version ?? 'missing'}; ${trace(final.response)}`, OPTIONS) - registry.looseboard.version = Number(finalSummary?.version); registry.looseboard.finalStatus = finalSummary?.status + expectedConflict(context, '散板旧 version 编辑返回 A0409', stale, true) + const afterStale = await overseas(context, 'GET', '散板旧 version 冲突后零漂移回查', path) + const afterStaleSummary = summary(afterStale.data) + context.assert(MODULE, '散板冲突未造成部分更新', afterStale.response, + afterStaleSummary?.destination === after?.destination && Number(afterStaleSummary?.version) === Number(after?.version), + `destination=${afterStaleSummary?.destination || 'missing'}, version=${afterStaleSummary?.version ?? 'missing'}; ${trace(afterStale.response)}`, OPTIONS) + + const statusPath = query(`${base}/${encodeURIComponent(orderNo)}/status`, { customerId }) + const forwarded = await overseas(context, 'PATCH', '散板状态正向推进到已派车', statusPath, + { version: Number(afterStaleSummary?.version), status: 'DISPATCHED', remark: `${context.marker}-FORWARD` }) + const forwardedSummary = summary(forwarded.data) + context.assert(MODULE, '散板状态正向推进成功', forwarded.response, + forwardedSummary?.status === 'DISPATCHED' + && Number(forwardedSummary?.version) === Number(afterStaleSummary?.version) + 1, + `status=${forwardedSummary?.status || 'missing'}, version=${forwardedSummary?.version ?? 'missing'}; ${trace(forwarded.response)}`, OPTIONS) + const reversed = await context.requestOverseas('PATCH', statusPath, + { version: Number(forwardedSummary?.version), status: 'PENDING_ACCEPTANCE', remark: 'REVERSE-DO-NOT-APPLY' }) + expectedConflict(context, '散板状态逆向回退返回冲突', reversed) + const history = await overseas(context, 'GET', '散板状态冲突后流水回查', path) + const historySummary = summary(history.data) + const histories = Array.isArray(history.data?.histories) ? history.data.histories : [] + const operations = new Set(histories.map((item) => item?.operationType)) + context.assert(MODULE, '散板流水覆盖创建编辑与正向状态', history.response, + historySummary?.status === 'DISPATCHED' && ['CREATE', 'UPDATE', 'STATUS'].every((type) => operations.has(type)) + && histories.some((item) => String(item?.remark || item?.changeDetail || '').includes(context.marker)), + `status=${historySummary?.status || 'missing'}, historyCount=${histories.length}, operations=${[...operations].join(',') || 'missing'}; ${trace(history.response)}`, OPTIONS) + Object.assign(registry.looseboard, { version: Number(historySummary?.version), finalStatus: historySummary?.status }) } finally { - await cancelMarkerOrder(context, registry.looseboard, base, 'PENDING_ACCEPTANCE', '散板') + await cancelMarkerOrder(context, registry.looseboard, base, ['PENDING_ACCEPTANCE', 'DISPATCHED'], '散板') } } +async function retirePartnerWarehouse(context, resource) { + if (!resource?.id) return + const retired = await context.request('PATCH', + `/api/platform/v1/warehouse-admin/${encodeURIComponent(resource.id)}/status?tenantId=0&status=INACTIVE`) + const passed = retired?.http === 200 && codeOf(retired) === '200' + context.addStep(MODULE, '本轮采购合作仓已停用收口', retired, passed, trace(retired), OPTIONS) + resource.finalStatus = passed ? 'INACTIVE' : resource.finalStatus + resource.retained = !passed +} + +async function finalizePendingPurchase(context, resource) { + if (!resource?.orderNo || !longId(resource?.customerId) + || ['REJECTED', 'APPROVED'].includes(resource.finalStatus)) return + const path = query(`${BASE}/overseas-procurements/${encodeURIComponent(resource.orderNo)}`, + { customerId: resource.customerId }) + const current = await overseas(context, 'GET', '采购最终处置前回查本轮订单', path) + const currentSummary = summary(current.data) + if (['REJECTED', 'APPROVED'].includes(currentSummary?.status)) { + resource.finalStatus = currentSummary.status; resource.version = Number(currentSummary.version); resource.retained = false + return + } + if (currentSummary?.status !== 'PENDING_REVIEW') { + blocker(context, '采购测试数据最终处置', current.response, + `本轮 marker 资源 ${resource.orderNo} 当前状态 ${currentSummary?.status || 'missing'} 无法安全驳回`) + resource.finalStatus = currentSummary?.status || resource.finalStatus; resource.retained = true + return + } + const rejected = await overseas(context, 'POST', '驳回未完成的本轮采购订单', + query(`${BASE}/overseas-procurements/${encodeURIComponent(resource.orderNo)}/review`, + { customerId: resource.customerId }), + { decision: 'REJECT', remark: `${context.marker}-prerequisite-cleanup` }) + const rejectedSummary = summary(rejected.data) + const verified = await overseas(context, 'GET', '回查未完成采购订单最终状态', path) + const finalSummary = summary(verified.data) + const passed = rejectedSummary?.status === 'REJECTED' && finalSummary?.status === 'REJECTED' + && Number(finalSummary?.version) === Number(rejectedSummary?.version) + context.assert(MODULE, '采购本轮测试数据最终合法驳回', verified.response, passed, + `orderNo=${resource.orderNo}, status=${finalSummary?.status || 'missing'}, version=${finalSummary?.version ?? 'missing'}; review=${trace(rejected.response)}; verify=${trace(verified.response)}`, OPTIONS) + resource.finalStatus = finalSummary?.status || rejectedSummary?.status || resource.finalStatus + resource.version = Number(finalSummary?.version ?? rejectedSummary?.version) + resource.retained = !passed +} + export async function run(context) { - const prerequisite = await prerequisites(context) - if (!prerequisite) return + let prerequisite = null try { + prerequisite = await prerequisites(context) + if (!prerequisite) return await procurementFlow(context, prerequisite) const customerId = id(prerequisite.activeCustomer.id) await truckFlow(context, customerId) await looseboardFlow(context, customerId) } finally { - if (prerequisite.partnerWarehouse?.id) { - const retired = await context.request('PATCH', `/api/platform/v1/warehouse-admin/${encodeURIComponent(prerequisite.partnerWarehouse.id)}/status?tenantId=0&status=INACTIVE`) - context.addStep(MODULE, '本轮采购合作仓已停用收口', retired, - retired?.http === 200 && codeOf(retired) === '200', trace(retired), OPTIONS) - } + const registry = context.createdResources.phase4OverseasBusiness + await finalizePendingPurchase(context, registry?.purchase) + await retirePartnerWarehouse(context, registry?.partnerWarehouse) } } From c93fdc44d52883f00b8cfba293c575b62fa97da1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 19:58:35 +0800 Subject: [PATCH 055/115] 1 --- scripts/admin-test-env-acceptance.mjs | 127 ++++-- scripts/admin-test-env-modules/README.md | 4 +- .../inbound-measurement.mjs | 3 + scripts/admin-test-env-modules/orders.mjs | 50 ++- .../phase3-dispatch-gate.mjs | 3 + .../phase3-evidence-gate.mjs | 5 + .../phase3-http-fail-closed-contract.test.mjs | 50 +++ ...e3-loading-dynamic-final-contract.test.mjs | 2 +- .../phase3-loading-finalize.mjs | 52 +++ .../phase3-loading-model-bridge.mjs | 3 + .../admin-test-env-modules/phase3-loading.mjs | 10 +- .../phase3-outbound-aggregate.mjs | 5 + .../phase3-outbound-dispatch.mjs | 37 ++ .../phase3-pda-convergence.mjs | 23 +- ...-pda-pending-consistency-contract.test.mjs | 3 + ...-phase4-producer-fixture-contract.test.mjs | 67 +++ .../phase3-producer-chain-contract.test.mjs | 11 + .../phase3-waiting-for-loading.mjs | 3 + ...se4-overseas-master-data-contract.test.mjs | 127 ++++-- .../phase4-overseas-master-data.mjs | 144 +++++- .../phase4-overseas-pda-contract.test.mjs | 88 ++-- .../phase4-overseas-pda.mjs | 424 ++++++++++-------- ...verseas-acceptance-entry-contract.test.mjs | 37 +- 23 files changed, 965 insertions(+), 313 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 0afc3b1f3..4bd6fb8ad 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -1,18 +1,10 @@ #!/usr/bin/env node -// Test environment administrator / 测试环境管理员 -// Username: platform_admin -// Password: ChangeMe123! - import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import process from 'node:process' -const DEFAULT_GATEWAY = 'http://8.163.30.128:9000' -const DEFAULT_OVERSEAS_GATEWAY = 'http://8.163.30.128:9300' -const DEFAULT_USERNAME = 'platform_admin' -const DEFAULT_PASSWORD = 'ChangeMe123!' const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-pda', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] export function normalizeModuleOrder(modules) { @@ -65,6 +57,13 @@ export function normalizeModuleOrder(modules) { && !normalized.includes('merchant-qualification-fixture')) { normalized.splice(normalized.indexOf('phase4-overseas-business'), 0, 'merchant-qualification-fixture') } + if (normalized.includes('phase4-overseas-pda')) { + const prerequisites = ['merchant-qualification-fixture', 'phase3-loading', 'phase3-pda-convergence', + 'phase3-evidence-gate', 'phase3-loading-finalize'] + const targetIndex = normalized.indexOf('phase4-overseas-pda') + const missing = prerequisites.filter((name) => !normalized.includes(name)) + if (missing.length > 0) normalized.splice(targetIndex, 0, ...missing) + } if (normalized.includes('phase4-cross-region-events')) { const prerequisites = ['system', 'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate', @@ -130,7 +129,7 @@ export function normalizeModuleOrder(modules) { } const DEFAULT_MODULES = normalizeModuleOrder(CONFIGURED_MODULES) -const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG' +const WEIGHT_IMAGE_PATH = process.env.ADMIN_TEST_WEIGHT_IMAGE_PATH || '' const CUSTOMER_FIELDS = [ 'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative', 'contactName', 'contactPhone', 'contactEmail', 'contactWechat', @@ -148,21 +147,20 @@ const BOX_DEFAULTS = [ function usage() { console.log(`后台管理测试环境 HTTP API 验收 -测试管理员: ${DEFAULT_USERNAME} / ${DEFAULT_PASSWORD} - 用法: node scripts/admin-test-env-acceptance.mjs [options] 选项: - --gateway Gateway 地址,默认 ${DEFAULT_GATEWAY} - --overseas-gateway 海外 Gateway 地址,默认 ${DEFAULT_OVERSEAS_GATEWAY} - --username 管理员账号,默认 ${DEFAULT_USERNAME} - --password 管理员密码,默认使用上方测试密码 + --gateway 必填,国内 Gateway 地址 + --overseas-gateway 四期海外模块必填,海外 Gateway 地址 + --username 必填,管理员账号 + --password 必填,管理员密码 --modules 逗号分隔,默认 ${DEFAULT_MODULES.join(',')} --output JSON 报告路径,默认 reports/ --help 显示帮助 -环境变量覆盖: ADMIN_TEST_GATEWAY, ADMIN_OVERSEAS_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD`) +也可使用环境变量: ADMIN_TEST_GATEWAY, ADMIN_OVERSEAS_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD, +ADMIN_TEST_WEIGHT_IMAGE_PATH`) } export function parseArguments(argv) { @@ -182,15 +180,26 @@ export function parseArguments(argv) { } } return { - gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || DEFAULT_GATEWAY).replace(/\/$/, ''), - overseasGateway: (values.get('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY || DEFAULT_OVERSEAS_GATEWAY).replace(/\/$/, ''), - username: values.get('username') || process.env.ADMIN_TEST_USERNAME || DEFAULT_USERNAME, - password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || DEFAULT_PASSWORD, + gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, ''), + overseasGateway: (values.get('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY || '').replace(/\/$/, ''), + username: values.get('username') || process.env.ADMIN_TEST_USERNAME || '', + password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || '', modules: normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean)), output: values.get('output') || '', } } +export function validateExecutionOptions(options) { + for (const [name, value] of [['gateway', options.gateway], ['username', options.username], ['password', options.password]]) { + if (typeof value !== 'string' || value.trim() === '') throw new Error(`缺少必填执行参数: ${name}`) + } + if (options.modules.some((module) => module.startsWith('phase4-overseas-')) + && (!options.overseasGateway || options.overseasGateway.trim() === '')) { + throw new Error('四期海外模块缺少必填执行参数: overseas-gateway') + } + return options +} + function own(value, field) { return value !== null && typeof value === 'object' && Object.hasOwn(value, field) } function hasFields(value, fields) { return fields.every((field) => own(value, field)) } function numberEquals(actual, expected) { return Math.abs(Number(actual) - Number(expected)) < 0.0005 } @@ -383,7 +392,13 @@ export class AcceptanceRun { } addCoverageGap(module, scenario, name, reason, category = 'not-implemented', metadata = {}) { - this.coverageGaps.push({ module, scenario, name, reason, category, ...metadata }) + const nonBlocking = new Set(['device', 'external-third-party', 'visual']) + const blocking = metadata.blocking ?? !nonBlocking.has(category) + this.coverageGaps.push({ module, scenario, name, reason, category, blocking, ...metadata }) + if (blocking) { + this.addStep(module, `覆盖阻断:${name}`, null, false, reason, + { scenario, deploymentBlocker: true }) + } } apiSuccess(module, name, response, evidence = '', options = {}) { @@ -757,9 +772,9 @@ export class AcceptanceRun { } } - async preparePhase3LoadingFixture() { + async preparePhase3LoadingFixture(suffix = '') { const originalMarker = this.marker - const fixtureMarker = `${originalMarker}-LOAD` + const fixtureMarker = `${originalMarker}-LOAD${suffix}` const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement'] const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound', 'phase3LoadingRequireSharedOverflow', 'phase3LoadingIncrementalPackageCount'] @@ -798,7 +813,7 @@ export class AcceptanceRun { internalRemark: fixtureOrder.internalRemark, cargoRemark: fixtureOrder.cargoRemark, } : { marker: originalMarker, sourceMarker: fixtureMarker, failed: true } - const incrementalMarker = `${originalMarker}-LOAD-INC` + const incrementalMarker = `${originalMarker}-LOAD${suffix}-INC` this.marker = incrementalMarker this.context.phase3LoadingRequireSharedOverflow = false this.context.phase3LoadingIncrementalPackageCount = 2 @@ -834,6 +849,59 @@ export class AcceptanceRun { } } + async runPhase4ProducerVariant(label, destinationType) { + const matrixMarker = this.marker + const variantMarker = `${matrixMarker}-P4-${label}` + const contextKeys = ['orderDestinationType', 'phase4ProducerMatrixMarker'] + const createdKeys = ['phase3Loading', 'phase3LoadingCompletedProducer'] + const contextSnapshot = new Map(contextKeys.map((key) => [key, { + present: Object.hasOwn(this.context, key), value: this.context[key], + }])) + const createdSnapshot = new Map(createdKeys.map((key) => [key, { + present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key], + }])) + let restoreFixture = null + this.marker = variantMarker + this.context.orderDestinationType = destinationType + this.context.phase4ProducerMatrixMarker = matrixMarker + try { + restoreFixture = await this.preparePhase3LoadingFixture() + const loading = await import(new URL('./admin-test-env-modules/phase3-loading.mjs', import.meta.url)) + await this.executeModule(`phase3-loading-phase4-${label}`, () => loading.run(this)) + const finalize = await import(new URL('./admin-test-env-modules/phase3-loading-finalize.mjs', import.meta.url)) + await this.executeModule(`phase3-loading-finalize-phase4-${label}`, () => finalize.run(this)) + } finally { + if (restoreFixture) restoreFixture() + this.marker = matrixMarker + for (const [key, snapshot] of contextSnapshot) { + if (snapshot.present) this.context[key] = snapshot.value + else delete this.context[key] + } + for (const [key, snapshot] of createdSnapshot) { + if (snapshot.present) this.createdResources[key] = snapshot.value + else delete this.createdResources[key] + } + } + } + + async ensurePhase4PdaProducerMatrix() { + const readiness = () => this.createdResources.phase4InboundProducerFixtures?.readiness || {} + if (Number(readiness().fbaContainerCount || 0) < 1) { + await this.runPhase4ProducerVariant('FBA-PRIMARY', 'FBA') + } + if (Number(readiness().overseasContainerCount || 0) < 1) { + await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS') + } + if (Number(readiness().distinctContainerCount || 0) < 3) { + await this.runPhase4ProducerVariant('SECONDARY', 'FBA') + } + const matrix = this.createdResources.phase4InboundProducerFixtures + this.assert('phase4-overseas-pda-fixture', '本轮三期跨区生产矩阵已就绪', null, + matrix?.marker === this.marker && matrix?.readiness?.readyForPhase4Pda === true, + `marker=${matrix?.marker || 'missing'}, FBA=${matrix?.readiness?.fbaContainerCount ?? 0}, OVERSEAS=${matrix?.readiness?.overseasContainerCount ?? 0}, containers=${matrix?.readiness?.distinctContainerCount ?? 0}`, + { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) + } + async disableAvailableLocationsForA0(warehouseId) { const module = 'phase3-loading-fixture' const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true } @@ -912,6 +980,15 @@ export class AcceptanceRun { continue } let restoreFixture = null + if (module === 'phase4-overseas-pda') { + try { + await this.ensurePhase4PdaProducerMatrix() + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + this.addStep('phase4-overseas-pda-fixture', '创建FBA/OVERSEAS/第二柜三期生产矩阵', null, + false, reason, { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) + } + } const ocrIndex = this.options.modules.indexOf('inbound-weight-ocr') if (module === 'inbound-measurement' && ocrIndex >= 0 && ocrIndex < moduleIndex) { try { @@ -1001,7 +1078,7 @@ export class AcceptanceRun { } async function main() { - const options = parseArguments(process.argv.slice(2)) + const options = validateExecutionOptions(parseArguments(process.argv.slice(2))) const timestamp = new Date().toISOString().replace(/\D/g, '').slice(0, 14) const outputPath = resolve(options.output || `reports/admin-test-env-acceptance-${timestamp}.json`) const report = await new AcceptanceRun(options).run() diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 7ea762f81..80a224f18 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -55,7 +55,7 @@ export async function run(context) { | 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` | | Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` | | 四期海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` | -| 四期海外 PDA 入仓 HTTP 生命周期 | `phase4-overseas-pda.mjs`(自动选择未转换尾程单、创建目标仓/库位/托盘并完成入库;完成事实保留审计) | `phase4-overseas-pda-contract.test.mjs` | +| 四期海外 PDA 柜号收货 HTTP 生命周期 | `phase4-overseas-pda.mjs`(客户海外 Token 按柜号执行 candidates/detail/prepare、三角色照片签收、FBA SKU 箱数或 OVERSEAS 逐箱实收、完成与幂等/冲突边界;真实柜、箱唛或托盘前置缺失时 fail-closed) | `phase4-overseas-pda-contract.test.mjs` | | 三四期跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` | | 四期海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` | | 四期海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` | @@ -64,7 +64,7 @@ export async function run(context) { 表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。 -四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;独立 PDA 模块只调用 `/api/pda/v1/last-leg/inbound-plans/**` 完成入仓,不调用出库、派送或内部接口;船节点禁止手工创建任务。 +四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;独立 PDA 模块使用本轮客户账号的海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID,也不调用出库、派送或内部接口。收货占用的托盘作为库存事实保留,无法自动取得真实柜、箱唛或托盘时必须形成 coverage gap 和部署阻断,不能按空场景通过;船节点禁止手工创建任务。 ```powershell node scripts/admin-test-env-acceptance.mjs --gateway --overseas-gateway --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index c19b7765f..e3707c25e 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -491,6 +491,9 @@ export async function run(context) { boxCode, finalStatus: finalMeasurement?.measurementStatus || null, processStatus: finalMeasurement?.processStatus || null, palletCount: weightEvidence.count, actualGrossWeightKg: weightEvidence.grossWeightKg, + destinationType: context.createdResources?.orders?.destinationType || null, + destinationWarehouseId: context.createdResources?.orders?.destinationWarehouseId || null, + destinationWarehouseCode: context.createdResources?.orders?.destinationWarehouseCode || null, } finishScenario(context, start) } diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index a7a7c3fd8..14ff86e4b 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -20,6 +20,10 @@ function pageOk(value) { return Array.isArray(value?.records) && Number.isFinite function success(response) { return Boolean(response) && response.http === 200 && String(response.body?.code) === '200' } function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) } +export function requestedOrderDestinationType(context) { + return String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase() === 'OVERSEAS' + ? 'OVERSEAS' : 'FBA' +} export function approvedInboundReady(orderDetail, taskResponse, tenantId, orderId) { const summary = orderDetail?.summary const task = success(taskResponse) ? taskResponse.body?.data : null @@ -33,12 +37,12 @@ function tomorrow(days = 30) { return date.toISOString().slice(0, 10) } -function pricingEstimateRequest(receiving, channel, pricingTaxMode, destination) { +function pricingEstimateRequest(receiving, channel, pricingTaxMode, destination, destinationType = 'FBA') { return { originWarehouseId: receiving.id, channelCode: channel.channelCode, destinationCode: destination.warehouseCode, - destinationType: 'FBA', + destinationType, destinationWarehouseId: destination.id, destinationName: destination.warehouseName, pricingTaxMode, @@ -56,7 +60,7 @@ function estimateMatches(response, candidate) { } export async function findPricedOrderRoute({ receiving, destinations, channels, estimate, - concurrency = PRICING_PROBE_CONCURRENCY }) { + concurrency = PRICING_PROBE_CONCURRENCY, destinationType = 'FBA' }) { const candidates = [] for (const channel of channels) { if (!channel?.priceBookVersionId || !channel?.channelCode || !Array.isArray(channel.pricingTaxModes)) continue @@ -65,7 +69,7 @@ export async function findPricedOrderRoute({ receiving, destinations, channels, for (const destination of destinations) { if (!destination?.id || !destination?.warehouseCode) continue candidates.push({ channel, pricingTaxMode, destination, - request: pricingEstimateRequest(receiving, channel, pricingTaxMode, destination) }) + request: pricingEstimateRequest(receiving, channel, pricingTaxMode, destination, destinationType) }) } } } @@ -98,7 +102,7 @@ export async function findPricedOrderRoute({ receiving, destinations, channels, } export async function findPricedOrderRouteAcrossOrigins({ receivings, destinations, loadChannels, estimate, - concurrency = PRICING_PROBE_CONCURRENCY }) { + concurrency = PRICING_PROBE_CONCURRENCY, destinationType = 'FBA' }) { const originFailures = [] let attempts = 0 let response = null @@ -115,7 +119,8 @@ export async function findPricedOrderRouteAcrossOrigins({ receivings, destinatio traceId: channelResponse?.traceId ?? 'missing', count: channels.length }) continue } - const result = await findPricedOrderRoute({ receiving, destinations, channels: priceableChannels, estimate, concurrency }) + const result = await findPricedOrderRoute({ receiving, destinations, channels: priceableChannels, + estimate, concurrency, destinationType }) attempts += result.attempts response = result.response || response if (result.priced) return { receiving, ...result, originFailures } @@ -196,19 +201,21 @@ async function assertProductImageDownload(context, merchantToken, fileId) { } async function loadMasterData(context, merchantToken) { + const destinationType = requestedOrderDestinationType(context) const domesticResponse = await request(context, 'GET', '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', undefined, merchantToken) const domesticPage = context.apiSuccess(MODULE, 'Client 查询国内收货仓', domesticResponse) context.assert(MODULE, '存在可用国内收货仓', domesticResponse, records(domesticPage).length > 0, `count=${records(domesticPage).length}`) - const fbaResponse = await request(context, 'GET', - '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA', undefined, merchantToken) - const fbaPage = context.apiSuccess(MODULE, 'Client 查询 FBA 目的仓', fbaResponse) - context.assert(MODULE, '存在可用 FBA 目的仓', fbaResponse, records(fbaPage).length > 0, - `count=${records(fbaPage).length}`) + const destinationResponse = await request(context, 'GET', + `/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`, + undefined, merchantToken) + const destinationPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓`, destinationResponse) + context.assert(MODULE, `存在可用 ${destinationType} 目的仓`, destinationResponse, + records(destinationPage).length > 0, `count=${records(destinationPage).length}`) const receivings = records(domesticPage).filter((warehouse) => warehouse?.status === 'ACTIVE') - const destinations = records(fbaPage) + const destinations = records(destinationPage).filter((warehouse) => warehouse?.status === 'ACTIVE') if (receivings.length === 0 || destinations.length === 0) throw new Error('缺少创建测试订单所需的收货仓或目的仓') const pricingResult = await findPricedOrderRouteAcrossOrigins({ @@ -217,6 +224,7 @@ async function loadMasterData(context, merchantToken) { `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`, undefined, merchantToken), estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken), + destinationType, }) const priced = pricingResult.priced const receiving = pricingResult.receiving @@ -225,7 +233,7 @@ async function loadMasterData(context, merchantToken) { ? `origin=${receiving.id}, priceBookVersionId=${priced.channel.priceBookVersionId}, channel=${priced.channel.channelCode}, pricingTaxMode=${priced.pricingTaxMode}, destination=${priced.destination.warehouseCode}, attempts=${pricingResult.attempts}` : originPricingFailureEvidence(pricingResult), CONTRACT_OPTIONS) - if (!priced) throw new Error('当前 ACTIVE 国内收货仓、渠道、报价税制和FBA目的仓没有可计价组合') + if (!priced) throw new Error(`当前 ACTIVE 国内收货仓、渠道、报价税制和 ${destinationType} 目的仓没有可计价组合`) const partnerResponse = await request(context, 'GET', '/api/merchant/v1/partners/candidates?type=SHIPPER', undefined, merchantToken) @@ -234,7 +242,7 @@ async function loadMasterData(context, merchantToken) { ? partners.find((item) => item.status === 'ACTIVE' && item.types?.includes?.('SHIPPER')) : null return { receiving, destination: priced.destination, channel: priced.channel, - pricingTaxMode: priced.pricingTaxMode, partner } + pricingTaxMode: priced.pricingTaxMode, destinationType, partner } } function buildPayload(context, profile, master, productImageFileId) { @@ -254,9 +262,10 @@ function buildPayload(context, profile, master, productImageFileId) { channelCode: master.channel.channelCode, channelName: master.channel.channelName, carrierId: null, carrierName: null, taxMode: 'BUY_DECLARATION', pricingTaxMode: master.pricingTaxMode, containerMode: 'LCL', - customsInspection: false, destinationType: 'FBA', - amazonWarehouseCode: master.destination.warehouseCode, - shipmentId, referenceId, + customsInspection: false, destinationType: master.destinationType, + amazonWarehouseCode: master.destinationType === 'FBA' ? master.destination.warehouseCode : null, + shipmentId: master.destinationType === 'FBA' ? shipmentId : null, + referenceId: master.destinationType === 'FBA' ? referenceId : null, sender: { companyName, contactName: '订单验收联系人', phone: '13800138000', email: 'order-acceptance@example.com', countryCode: 'CN', province: '广东省', @@ -279,7 +288,9 @@ function buildPayload(context, profile, master, productImageFileId) { material: '塑料 / Plastic', usage: '接口验收 / API acceptance', model: `MODEL-${box.boxType}`, brand: 'TEST-NO-LOGO', productLengthCm: 12 + index, productWidthCm: 8 + index, productHeightCm: 6 + index, - shipmentId, referenceId, upcNo: `${Date.now()}${index}`.slice(-12), + shipmentId: master.destinationType === 'FBA' ? shipmentId : null, + referenceId: master.destinationType === 'FBA' ? referenceId : null, + upcNo: `${Date.now()}${index}`.slice(-12), productImageFileId, dangerousGoods: false, cargoRemark: `${context.marker} boxType=${box.boxType}`, } @@ -387,6 +398,9 @@ export async function run(context) { marker: context.marker, tenantId, orderId, productImageFileId, boxTypes: BOXES.map((box) => box.boxType), inboundId: null, receivingWarehouseId: id(master.receiving.id), + destinationType: master.destinationType, + destinationWarehouseId: id(master.destination.id), + destinationWarehouseCode: master.destination.warehouseCode, } const replayResponse = await request(context, 'POST', createPath, payload, merchantToken, diff --git a/scripts/admin-test-env-modules/phase3-dispatch-gate.mjs b/scripts/admin-test-env-modules/phase3-dispatch-gate.mjs index 2b85a49fb..2dcc9aada 100644 --- a/scripts/admin-test-env-modules/phase3-dispatch-gate.mjs +++ b/scripts/admin-test-env-modules/phase3-dispatch-gate.mjs @@ -5,6 +5,9 @@ const BASE = '/api/platform/v1/first-leg/outbound-dispatches' export async function run(context) { const fixture = context.createdResources?.phase3OutboundDispatch if (!fixture?.inboundId || !fixture?.containerBookingId || fixture.marker !== context.marker) { + context.addStep(MODULE, '取得同 marker 的国内出仓门禁前置', null, false, + `inboundId=${fixture?.inboundId || 'missing'}, containerBookingId=${fixture?.containerBookingId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`, + OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, '订舱/拖车/报关/PDA/五类证据统一硬门禁', '本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture') return diff --git a/scripts/admin-test-env-modules/phase3-evidence-gate.mjs b/scripts/admin-test-env-modules/phase3-evidence-gate.mjs index f7085d98d..9639671a9 100644 --- a/scripts/admin-test-env-modules/phase3-evidence-gate.mjs +++ b/scripts/admin-test-env-modules/phase3-evidence-gate.mjs @@ -14,6 +14,9 @@ export async function run(context) { const warehouseId = id(fixture?.warehouseId) if (!tenantId || !inboundId || !taskId || !loadingPlanId || !containerBookingId || !warehouseId || fixture.marker !== context.marker) { + context.addStep(MODULE, '取得同 marker 的五证据门禁前置', null, false, + `tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, taskId=${taskId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`, + OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, '五类证据多图追加、作废审计和有效证据门禁', '本轮未取得带 taskId/inboundId/loadingPlanId/containerBookingId 的 phase3-loading marker 隔离证据 fixture,未使用历史资源', 'fixture') return @@ -44,6 +47,8 @@ export async function run(context) { evidenceReadable, `processStatus=${inbound?.summary?.processStatus || 'missing'}, taskWorkflow=${task?.workflowStatus || 'missing'}`, OPTIONS) if (!evidenceReadable) { + context.addStep(MODULE, '装柜证据读取前置必须由服务端进入 LOADING', inboundResponse, false, + `processStatus=${inbound?.summary?.processStatus || 'missing'};不能以 AWAITING_REVIEW 或 coverageGap 代替装柜阶段事实`, OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, '服务端装柜阶段前不请求证据', `taskId=${taskId}, inboundId=${inboundId}, loadingPlanId=${loadingPlanId}, containerBookingId=${containerBookingId}, processStatus=${inbound?.summary?.processStatus || 'missing'};AWAITING_REVIEW 不作为证据开放条件`, 'deployment-blocker') return diff --git a/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs new file mode 100644 index 000000000..9f2107dbf --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const read = (name) => readFileSync(resolve(here, name), 'utf8') + +test('phase3 downstream modules turn missing current-marker prerequisites into deployment blockers', () => { + const expectations = new Map([ + ['phase3-loading-model-bridge.mjs', '取得同 marker 的预装与柜次桥接前置'], + ['phase3-pda-convergence.mjs', '取得同 marker 的 PDA 收敛前置'], + ['phase3-evidence-gate.mjs', '取得同 marker 的五证据门禁前置'], + ['phase3-outbound-dispatch.mjs', '取得同 marker 的 LOADED 国内出仓前置'], + ['phase3-outbound-aggregate.mjs', '取得同 marker 的出仓主聚合前置'], + ['phase3-dispatch-gate.mjs', '取得同 marker 的国内出仓门禁前置'], + ['phase3-waiting-for-loading.mjs', '取得同 marker 的 WAITING_FOR_LOADING 前置'], + ]) + for (const [file, step] of expectations) { + const source = read(file) + assert.match(source, /deploymentBlocker: true/, file) + assert.match(source, new RegExp(`addStep\\(MODULE, '${step}'[\\s\\S]{0,500}false`), file) + assert.match(source, /fixtureMarker=.*runMarker=/, file) + } +}) + +test('phase3 inventory mutation and downstream gates cannot pass as coverage gaps', () => { + const loading = read('phase3-loading.mjs') + const convergence = read('phase3-pda-convergence.mjs') + const dispatch = read('phase3-outbound-dispatch.mjs') + const aggregate = read('phase3-outbound-aggregate.mjs') + assert.match(loading, /自动制造库存变化并验证旧 snapshot token A0409[\s\S]{0,300}false/) + assert.match(convergence, /PDA 真实库存变化后旧 token A0409 与刷新重试[\s\S]{0,300}false/) + assert.match(dispatch, /国内出仓八项门禁必须在同轮全部通过[\s\S]{0,300}false/) + assert.match(aggregate, /统一时间线必须包含报关与三期船期历史[\s\S]{0,300}false/) +}) + +test('outbound lifecycle checks replay, hash conflict, stale version and final reread', () => { + const source = read('phase3-outbound-dispatch.mjs') + for (const step of [ + 'confirm-dispatch 幂等重放', + '相同 Idempotency-Key 不同请求拒绝', + 'reverse-dispatch 幂等重放', + 'reverse-dispatch 同键异参返回 A0409', + '撤销后旧版本再次确认返回 A0409', + '负向与幂等验证后回查出仓聚合', + '出仓聚合保持 READY_FOR_DISPATCH、LOADED 和确认撤销历史', + ]) assert.match(source, new RegExp(step)) +}) diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index d08a32442..c48a31309 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -79,7 +79,7 @@ test('HTTP chain refreshes after stale inventory and keeps one stable PDA identi }) test('downstream finalizer uses the frozen truck-coupled complete contract', () => { - assert.match(convergence, /summary\?\.status === 'LOADING'/) + assert.match(convergence, /loadingPlan\?\.status === 'LOADING'/) assert.match(convergence, /!operations\.some\(\(item\) => item\.actionType === 'COMPLETE_LOADING'\)/) assert.match(customs, /\['AWAITING_REVIEW', 'LOADED'\]/) assert.match(customs, /ensureReleasedOceanBooking/) diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 4a81d6c8a..0c7b0f567 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -27,6 +27,35 @@ export function buildFinalizeResource(fixture, finalData, completeResponse) { } } +export function collectPhase4InboundProducerFixture(previous, producer) { + const existing = previous?.marker === producer?.marker && Array.isArray(previous?.containers) + ? previous.containers : [] + const containers = [...existing.filter((item) => item.eventKey !== producer.eventKey), producer] + const byDestinationType = Object.fromEntries(['FBA', 'OVERSEAS'].map((destinationType) => [ + destinationType, + containers.filter((item) => item.destinationType === destinationType), + ])) + const distinctContainerCount = new Set(containers.map((item) => item.containerNo).filter(Boolean)).size + return { + marker: producer.marker, + containers, + byDestinationType, + readiness: { + fbaContainerCount: byDestinationType.FBA.length, + overseasContainerCount: byDestinationType.OVERSEAS.length, + distinctContainerCount, + readyForPhase4Pda: byDestinationType.FBA.length >= 1 + && byDestinationType.OVERSEAS.length >= 1 + && distinctContainerCount >= 3, + }, + requiredEntryOrchestration: [ + 'FBA: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', + 'OVERSEAS-PRIMARY: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', + 'SECONDARY(FBA|OVERSEAS): orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', + ], + } +} + async function request(context, method, path, body, headers = {}) { try { const response = await fetch(`${context.options.gateway}${path}`, { @@ -323,4 +352,27 @@ export async function run(context) { settlementMode, settledOrderId: id(loadedSlice?.childOrderId) || orderId, } + const producer = { + marker: context.context?.phase4ProducerMatrixMarker || context.marker, + eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED', + eventVersion: 2, + eventKey: `FIRSTLEG_LOADING_COMPLETED:${loadingPlanId}:${tenantId}:2`, + resourceKey: `${context.marker}:${fixture.destinationType || 'UNKNOWN'}:${fixture.containerNo || containerBookingId}`, + tenantId, + aggregateType: 'LOADING_PLAN', + aggregateId: loadingPlanId, + loadingPlanId, + containerBookingId, + sourceOrderId: orderId, + sourceInboundId: inboundId, + destinationType: fixture.destinationType || null, + destinationWarehouseId: id(fixture.destinationWarehouseId), + destinationWarehouseCode: fixture.destinationWarehouseCode || null, + containerNo: fixture.containerNo || null, + sourceStatus: 'LOADED', + verification: 'PRODUCED_IN_COMPLETE_TRANSACTION_PENDING_BRIDGE_CONSUMER_HTTP', + } + context.createdResources.phase3LoadingCompletedProducer = producer + context.createdResources.phase4InboundProducerFixtures = collectPhase4InboundProducerFixture( + context.createdResources.phase4InboundProducerFixtures, producer) } diff --git a/scripts/admin-test-env-modules/phase3-loading-model-bridge.mjs b/scripts/admin-test-env-modules/phase3-loading-model-bridge.mjs index a749c8b50..bbe1da56d 100644 --- a/scripts/admin-test-env-modules/phase3-loading-model-bridge.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-model-bridge.mjs @@ -32,6 +32,9 @@ export async function run(context) { const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId) const loadingPlanId = id(fixture?.loadingPlanId) if (!tenantId || !inboundId || !loadingPlanId || fixture?.marker !== context.marker) { + context.addStep(MODULE, '取得同 marker 的预装与柜次桥接前置', null, false, + `tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`, + OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, '入库单级预装与柜次级智能装柜串联', '本轮未取得同一 marker 的预装/柜次计划关联 fixture,未使用历史资源', 'fixture', { activation: { state: 'pending-fixture', assertions: ['双向关联可查', '双版本冲突原子回滚', '终态门禁一致'] } }) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 42923a2f0..0b64243f8 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -101,9 +101,10 @@ export async function run(context) { const source = fixture(context) const tenantId = id(source?.tenantId) const inboundId = id(source?.inboundId) - if (!tenantId || !inboundId) { + if (!tenantId || !inboundId || source?.marker !== context.marker) { context.addStep(MODULE, '取得本轮 WAIT_SPLIT 隔离入库单', null, false, - '未找到 orders/inbound-measurement 本轮隔离 fixture,未使用历史入库单', OPTIONS) + `未找到 orders/inbound-measurement 本轮隔离 fixture,未使用历史入库单;fixtureMarker=${source?.marker || 'missing'}, runMarker=${context.marker}`, + OPTIONS) return } @@ -697,6 +698,8 @@ export async function run(context) { effectiveBatchPayload = { ...batchPayload, inventorySnapshotToken: refreshedInventory.snapshotToken } } } else { + context.addStep(MODULE, '自动制造库存变化并验证旧 snapshot token A0409', null, false, + `本轮 ${inventoryInbound?.resources?.length ?? 0} 条占用没有可安全换绑的 A0 临时占用;不得以 coverageGap 作为通过`, OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 真实库存变化后的旧快照冲突与刷新重试', `inventory-snapshot 已真实返回 taskId=${canonicalTaskId}、warehouseId=${warehouseId} 并验证稳定 token/version=${inventorySnapshotVerified};本轮 ${inventoryInbound?.resources?.length ?? 0} 条占用均为普通库位,当前唯一平台代理库存变更入口只允许 A0 临时占用换绑,不能安全改动普通占用或调用内部 Warehouse 接口`, 'fixture') @@ -1159,6 +1162,9 @@ export async function run(context) { context.createdResources.phase3Loading = { tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId, + destinationType: initial?.summary?.destinationType || source?.destinationType || null, + destinationWarehouseId: id(initial?.summary?.destinationWarehouseId || source?.destinationWarehouseId), + destinationWarehouseCode: initial?.summary?.destinationCode || source?.destinationWarehouseCode || null, evidenceIds: evidenceItems.map((item) => id(item.id)), voidedEvidenceId: id(voidTarget?.id), evidenceTypes, sealNo: `SEAL-${suffix}`, missingTypeVoidedEvidenceId: id(missingTypeTarget?.id), replacementEvidenceFileId: replacementFileId, diff --git a/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs b/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs index 833587cb3..aee6aa3d9 100644 --- a/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs +++ b/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs @@ -16,6 +16,9 @@ function ordered(items) { export async function run(context) { const fixture = context.createdResources?.phase3OutboundDispatch if (!fixture?.inboundId || !fixture?.containerBookingId || fixture.marker !== context.marker) { + context.addStep(MODULE, '取得同 marker 的出仓主聚合前置', null, false, + `inboundId=${fixture?.inboundId || 'missing'}, containerBookingId=${fixture?.containerBookingId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`, + OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, '出仓单/柜次主聚合与整体时间线', '本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture') return @@ -49,4 +52,6 @@ export async function run(context) { context.addCoverageGap(MODULE, OPTIONS.scenario, '报关与船期历史并入统一时间线', '当前 OutboundDispatchReadMapper 时间线仅聚合入库、装柜计划、PDA、证据、订舱、拖车和出仓历史,未公开报关与船期历史投影', 'contract-gap') + context.addStep(MODULE, '统一时间线必须包含报关与三期船期历史', response, false, + '当前服务端聚合未公开 CUSTOMS 与 SAILING_SCHEDULE 时间线,不能以 coverageGap 计为自动验收通过', OPTIONS) } diff --git a/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs b/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs index 7a83ff4df..30d553185 100644 --- a/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs @@ -36,6 +36,9 @@ export async function run(context) { const containerBookingId = id(fixture?.containerBookingId) if (!inboundId || !containerBookingId || !['COMPLETED', 'LOADED'].includes(fixture?.finalStatus) || fixture?.marker !== context.marker) { + context.addStep(MODULE, '取得同 marker 的 LOADED 国内出仓前置', null, false, + `inboundId=${inboundId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, finalStatus=${fixture?.finalStatus || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`, + OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, '国内出仓主聚合真实闭环', '本轮未取得 marker 隔离的柜次装柜 fixture,未使用历史资源', 'fixture') return @@ -85,6 +88,9 @@ export async function run(context) { { 'Idempotency-Key': confirmKey }) context.addStep(MODULE, '门禁未通过时 confirm-dispatch fail-closed', blocked, rejected(blocked), `HTTP=${blocked.http}, code=${codeOf(blocked)}`, OPTIONS) + context.addStep(MODULE, '国内出仓八项门禁必须在同轮全部通过', gateResponse, false, + `failed=${gateItems.filter((item) => !item.passed).map((item) => `${item.code}:${item.actual || 'missing'}`).join(',') || 'gate.passed=false'}`, + OPTIONS) for (const item of gateItems.filter((value) => !value.passed)) { context.addCoverageGap(MODULE, OPTIONS.scenario, item.name || item.code, `${item.code}: actual=${item.actual || 'missing'}, requirement=${item.requirement || 'missing'}`, @@ -124,6 +130,37 @@ export async function run(context) { reversed?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH' && reversed?.aggregate?.inboundStatus === 'LOADED' && reversed?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'REVERSE_DISPATCH'), `dispatch=${reversed?.aggregate?.dispatchStatus || 'missing'}, inbound=${reversed?.aggregate?.inboundStatus || 'missing'}`, OPTIONS) + const reverseReplayResponse = await request(context, 'POST', `${BASE}/reverse-dispatch`, { + inboundId, containerBookingId, expectedVersion: Number(confirmed.aggregate.version), remark: context.marker, + }, { 'Idempotency-Key': reverseKey }) + const reverseReplay = context.apiSuccess(MODULE, 'reverse-dispatch 幂等重放', reverseReplayResponse, '', OPTIONS) + context.assert(MODULE, '撤销重放保持 READY_FOR_DISPATCH 与同一版本', reverseReplayResponse, + reverseReplay?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH' + && Number(reverseReplay?.aggregate?.version) === Number(reversed?.aggregate?.version), + `dispatch=${reverseReplay?.aggregate?.dispatchStatus || 'missing'}, version=${reverseReplay?.aggregate?.version ?? 'missing'}`, OPTIONS) + + const reverseHashConflict = await request(context, 'POST', `${BASE}/reverse-dispatch`, { + inboundId, containerBookingId, expectedVersion: Number(confirmed.aggregate.version), remark: 'forged', + }, { 'Idempotency-Key': reverseKey }) + context.addStep(MODULE, 'reverse-dispatch 同键异参返回 A0409', reverseHashConflict, + reverseHashConflict.http === 200 && codeOf(reverseHashConflict) === 'A0409', + `HTTP=${reverseHashConflict.http}, code=${codeOf(reverseHashConflict)}`, OPTIONS) + + const staleConfirmResponse = await request(context, 'POST', `${BASE}/confirm-dispatch`, actionBody, + { 'Idempotency-Key': `${context.marker}-outbound-confirm-stale`.slice(0, 128) }) + context.addStep(MODULE, '撤销后旧版本再次确认返回 A0409', staleConfirmResponse, + staleConfirmResponse.http === 200 && codeOf(staleConfirmResponse) === 'A0409', + `HTTP=${staleConfirmResponse.http}, code=${codeOf(staleConfirmResponse)}`, OPTIONS) + + const finalDetailResponse = await request(context, 'GET', `${BASE}/detail?${selector}`) + const finalDetail = context.apiSuccess(MODULE, '负向与幂等验证后回查出仓聚合', finalDetailResponse, '', OPTIONS) + context.assert(MODULE, '出仓聚合保持 READY_FOR_DISPATCH、LOADED 和确认撤销历史', finalDetailResponse, + finalDetail?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH' + && finalDetail?.aggregate?.inboundStatus === 'LOADED' + && Number(finalDetail?.aggregate?.version) === Number(reversed?.aggregate?.version) + && finalDetail?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'CONFIRM_DISPATCH') + && finalDetail?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'REVERSE_DISPATCH'), + `dispatch=${finalDetail?.aggregate?.dispatchStatus || 'missing'}, inbound=${finalDetail?.aggregate?.inboundStatus || 'missing'}, version=${finalDetail?.aggregate?.version ?? 'missing'}`, OPTIONS) if (reversed?.aggregate) { context.createdResources.phase3OutboundDispatch = { marker: context.marker, inboundId, containerBookingId, diff --git a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs index f7e7e6232..40a7d541c 100644 --- a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs @@ -27,6 +27,9 @@ export async function run(context) { const warehouseId = id(fixture?.warehouseId); const loadingPlanId = id(fixture?.loadingPlanId) if (!tenantId || !inboundId || !taskId || !containerNo || !warehouseId || !loadingPlanId || fixture?.marker !== context.marker) { + context.addStep(MODULE, '取得同 marker 的 PDA 收敛前置', null, false, + `tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, taskId=${taskId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`, + OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 柜次拣货与入库单汇总拣货收敛', '本轮未取得 marker 隔离的柜次 PDA 与入库汇总 fixture,未使用历史资源', 'fixture') return @@ -76,6 +79,10 @@ export async function run(context) { const operationsResponse = await request(context, 'GET', `${taskPath}/operations`) const operations = context.apiSuccess(MODULE, '查询柜次 PDA 不可变操作历史', operationsResponse, '', OPTIONS) + const batchOperations = Array.isArray(operations) + ? operations.filter((item) => item.actionType === 'BATCH' + && Boolean(item.operatorId) && Boolean(item.createdAt)) : [] + const expectedBatchCount = fixture?.dynamicIncrementVerified === true ? 2 : 1 context.assert(MODULE, '最终完成前保留领取操作且不伪造复核或完成历史', operationsResponse, Array.isArray(operations) && operations.some((item) => item.actionType === 'CLAIM' && Boolean(item.operatorId) && Boolean(item.createdAt)) @@ -84,9 +91,9 @@ export async function run(context) { `actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS) context.assert(MODULE, 'PDA 原子批次完成审计包含 BATCH 操作', operationsResponse, - Array.isArray(operations) && operations.filter((item) => item.actionType === 'BATCH' - && Boolean(item.operatorId) && Boolean(item.createdAt)).length >= 1, - `actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS) + batchOperations.length === expectedBatchCount + && new Set(batchOperations.map((item) => item.operationId)).size === expectedBatchCount, + `expectedBatchCount=${expectedBatchCount}, actualBatchCount=${batchOperations.length}, operationIds=${batchOperations.map((item) => item.operationId || 'missing').join(',')}`, OPTIONS) const forged = await request(context, 'POST', `/api/platform/v1/inbounds/${inboundId}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`, { expectedVersion: Number(projection?.version), expectedCount: 999, pickedCount: 999, remark: 'forged' }, @@ -95,21 +102,31 @@ export async function run(context) { `HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS) if (fixture?.atomicBatchVerified !== true) { + context.addStep(MODULE, 'PDA 原子批次成功且同键重放零新增', null, false, + 'phase3-loading 未发布 atomicBatchVerified=true;不能以 coverageGap 代替真实批次与幂等断言', OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 原子批量成功与批次幂等闭环', '原子 batch 端点已部署,但本轮合法整批提交或同键重放未通过真实 HTTP 断言;保留真实失败证据', 'deployment-blocker') } if (fixture?.inventorySnapshotVerified !== true) { + context.addStep(MODULE, 'PDA 实时库存快照身份与合法 token 提交', null, false, + 'phase3-loading 未发布 inventorySnapshotVerified=true;不能以 coverageGap 代替真实库存快照断言', OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 实时库存快照身份、稳定性与合法 token 提交', 'inventory-snapshot 端点已公开,但本轮未完成归属、稳定 token/version、数量及合法 batch 的联合真实 HTTP 验证', 'deployment-blocker') } if (fixture?.inventoryMutationVerified !== true && !context.coverageGaps.some((gap) => gap.name === 'PDA 真实库存变化后的旧快照冲突与刷新重试')) { + context.addStep(MODULE, 'PDA 真实库存变化后旧 token A0409 与刷新重试', null, false, + '本轮未取得 inventoryMutationVerified=true;无法安全制造库存变化时必须作为部署阻断失败', OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 真实库存变化后的旧快照冲突与刷新重试', 'inventory-snapshot 已公开;本轮没有可通过平台代理安全变更的 A0 临时占用,不能用普通库位改写或内部 Warehouse 接口伪造库存变化', 'fixture') } + if (fixture?.dynamicIncrementVerified !== true) { + context.addStep(MODULE, 'LOADING 增量重开、刷新续拣与已拣不可删减', null, false, + 'phase3-loading 未发布 dynamicIncrementVerified=true;不能跳过增量重开、旧 token A0409、刷新续拣和已拣项保护', OPTIONS) + } context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA Android/HBuilderX 真机完整闭环', '原子 batch 真实 HTTP 契约已自动化;扫码、弱网恢复、在线草稿和设备交互仍需 Android/HBuilderX 真机验收', 'device') diff --git a/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs b/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs index bc7df2e3a..653a53ab0 100644 --- a/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs @@ -25,6 +25,9 @@ test('AWAITING_REVIEW derives zero client actions and never needs a repeated bat assert.match(convergence, /AWAITING_REVIEW 已全拣任务 actions 为空且不再提交 batch/) assert.match(convergence, /AWAITING_REVIEW 可进入装柜取证只读入口/) assert.match(convergence, /filter\(\(item\) => item\.actionType === 'BATCH'/) + assert.match(convergence, /expectedBatchCount = fixture\?\.dynamicIncrementVerified === true \? 2 : 1/) + assert.match(convergence, /new Set\(batchOperations\.map\(\(item\) => item\.operationId\)\)\.size === expectedBatchCount/) + assert.match(convergence, /LOADING 增量重开、刷新续拣与已拣不可删减/) assert.doesNotMatch(convergence, /pendingActions[\s\S]{0,240}request\(context, 'POST'/) }) diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs new file mode 100644 index 000000000..25c535b2a --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +import { requestedOrderDestinationType } from './orders.mjs' +import { collectPhase4InboundProducerFixture } from './phase3-loading-finalize.mjs' + +const here = dirname(fileURLToPath(import.meta.url)) +const read = (name) => readFileSync(resolve(here, name), 'utf8') + +test('order fixture defaults to FBA and accepts only the explicit OVERSEAS variant', () => { + assert.equal(requestedOrderDestinationType({ context: {} }), 'FBA') + assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'overseas' } }), 'OVERSEAS') + assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'PRIVATE' } }), 'FBA') + const orders = read('orders.mjs') + assert.match(orders, /warehouseType=\$\{encodeURIComponent\(destinationType\)\}/) + assert.match(orders, /destinationType: master\.destinationType/) + assert.match(orders, /destinationType,\s*\n\s*\}\)/) + assert.doesNotMatch(orders, /const fbaResponse =/) +}) + +test('producer collector requires one FBA, one OVERSEAS and a third distinct container', () => { + const common = { marker: 'RUN-1', eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED', sourceStatus: 'LOADED' } + const fba = { ...common, eventKey: 'FBA-EVENT', destinationType: 'FBA', containerNo: 'FBA-001' } + const overseasPrimary = { ...common, eventKey: 'OS-EVENT-1', destinationType: 'OVERSEAS', containerNo: 'OS-001' } + const secondary = { ...common, eventKey: 'FBA-EVENT-2', destinationType: 'FBA', containerNo: 'FBA-002' } + const one = collectPhase4InboundProducerFixture(null, fba) + const two = collectPhase4InboundProducerFixture(one, overseasPrimary) + const ready = collectPhase4InboundProducerFixture(two, secondary) + assert.equal(one.readiness.readyForPhase4Pda, false) + assert.equal(two.readiness.readyForPhase4Pda, false) + assert.equal(ready.readiness.readyForPhase4Pda, true) + assert.equal(ready.readiness.fbaContainerCount, 2) + assert.equal(ready.readiness.overseasContainerCount, 1) + assert.equal(ready.readiness.distinctContainerCount, 3) + assert.equal(ready.byDestinationType.FBA[1].eventKey, 'FBA-EVENT-2') +}) + +test('same event key is idempotently replaced and another marker starts a new matrix', () => { + const first = collectPhase4InboundProducerFixture(null, + { marker: 'RUN-1', eventKey: 'EVENT-1', destinationType: 'FBA', containerNo: 'FBA-001' }) + const replay = collectPhase4InboundProducerFixture(first, + { marker: 'RUN-1', eventKey: 'EVENT-1', destinationType: 'FBA', containerNo: 'FBA-001' }) + assert.equal(replay.containers.length, 1) + const nextRun = collectPhase4InboundProducerFixture(replay, + { marker: 'RUN-2', eventKey: 'EVENT-2', destinationType: 'OVERSEAS', containerNo: 'OS-002' }) + assert.equal(nextRun.containers.length, 1) + assert.equal(nextRun.marker, 'RUN-2') + assert.deepEqual(nextRun.requiredEntryOrchestration, [ + 'FBA: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', + 'OVERSEAS-PRIMARY: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', + 'SECONDARY(FBA|OVERSEAS): orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', + ]) +}) + +test('measurement and loading pass destination identity into the LOADED producer key', () => { + const measurement = read('inbound-measurement.mjs') + const loading = read('phase3-loading.mjs') + const finalize = read('phase3-loading-finalize.mjs') + assert.match(measurement, /destinationType: context\.createdResources\?\.orders\?\.destinationType/) + assert.match(loading, /destinationType: initial\?\.summary\?\.destinationType \|\| source\?\.destinationType/) + assert.match(finalize, /destinationType: fixture\.destinationType \|\| null/) + assert.match(finalize, /resourceKey: `\$\{context\.marker\}:\$\{fixture\.destinationType \|\| 'UNKNOWN'\}:\$\{fixture\.containerNo \|\| containerBookingId\}`/) + assert.match(finalize, /phase4InboundProducerFixtures = collectPhase4InboundProducerFixture/) +}) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 6aa4e44af..b087ab7bb 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -627,3 +627,14 @@ test('loaded boundary acceptance proves evidence and transport immutability with assert.match(finalize, /afterHistorySignature === historySignature/) assert.match(finalize, /afterPlanSignature === immutablePlanSignature/) }) + +test('finalize publishes a current-marker producer correlation for cross-region verification', () => { + const finalize = source('./phase3-loading-finalize.mjs') + assert.match(finalize, /const producer = \{/) + assert.match(finalize, /createdResources\.phase3LoadingCompletedProducer = producer/) + assert.match(finalize, /marker: context\.context\?\.phase4ProducerMatrixMarker \|\| context\.marker/) + assert.match(finalize, /eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED'/) + assert.match(finalize, /eventKey: `FIRSTLEG_LOADING_COMPLETED:\$\{loadingPlanId\}:\$\{tenantId\}:2`/) + assert.match(finalize, /sourceStatus: 'LOADED'/) + assert.match(finalize, /PRODUCED_IN_COMPLETE_TRANSACTION_PENDING_BRIDGE_CONSUMER_HTTP/) +}) diff --git a/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs b/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs index 59b488555..21789ecc5 100644 --- a/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs @@ -5,6 +5,9 @@ const BASE = '/api/platform/v1/first-leg/outbound-dispatches' export async function run(context) { const fixture = context.createdResources?.phase3OutboundDispatch if (!fixture?.inboundId || !fixture?.containerBookingId || fixture.marker !== context.marker) { + context.addStep(MODULE, '取得同 marker 的 WAITING_FOR_LOADING 前置', null, false, + `inboundId=${fixture?.inboundId || 'missing'}, containerBookingId=${fixture?.containerBookingId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`, + OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, 'WAITING_FOR_LOADING 状态与历史', '本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture') return diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs index a83d35208..7ffc77066 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs @@ -4,10 +4,16 @@ import test from 'node:test' import { run } from './phase4-overseas-master-data.mjs' const source = readFileSync(new URL('./phase4-overseas-master-data.mjs', import.meta.url), 'utf8') +const controllerSource = [ + '../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteController.java', + '../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateController.java', + '../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleController.java', +].map((path) => readFileSync(new URL(path, import.meta.url), 'utf8')).join('\n') const DOMESTIC_BASE = '/api/platform/v1/warehouse-admin' const OVERSEAS_BASE = '/api/platform/v1/last-leg/master-data' const ok = (data) => ({ http: 200, body: { code: '200', message: 'success', data }, requestId: 'req-p4', traceId: 'trace-p4' }) const conflict = () => ({ http: 200, body: { code: 'A0409', message: 'record version conflict', data: null }, requestId: 'req-conflict' }) +const forbidden = () => ({ http: 403, body: { code: 'A0403', message: 'Access denied', data: null }, requestId: 'req-forbidden' }) function detailQuote(state) { return { @@ -22,21 +28,25 @@ function vehicleWire(state) { return { ...state.vehicle, mileageKm: String(state.vehicle.mileageKm), recordVersion: String(state.vehicle.recordVersion) } } -function fakeContext() { +function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) { const calls = [] const coverageGaps = [] + const steps = [] const marker = 'ADMIN-API-20260821123456789' - const state = { warehouse: null, quote: null, exchange: null, vehicle: null } + const state = { warehouse: null, quote: null, exchange: null, vehicle: null, optionReads: 0, role: null, user: null } const context = { - marker, calls, coverageGaps, createdResources: {}, + marker, calls, coverageGaps, steps, createdResources: {}, + async findAvailablePhone() { return '13800138000' }, apiSuccess(_module, name, response) { assert.equal(response?.http, 200, `${name}: HTTP`) assert.equal(String(response?.body?.code), '200', `${name}: code`) return response.body.data }, - assert(_module, name, _response, condition, evidence) { - assert.equal(Boolean(condition), true, `${name}: ${evidence}`) - return true + assert(_module, name, _response, condition, evidence, options = {}) { + const passed = Boolean(condition) + steps.push({ name, passed, deploymentBlocker: Boolean(options.deploymentBlocker && !passed), evidence }) + if (!passed && !softAssertions) assert.equal(passed, true, `${name}: ${evidence}`) + return passed }, expectConflict(_module, name, response) { assert.equal(response.http, 200, `${name}: HTTP`) @@ -64,12 +74,40 @@ function fakeContext() { state.warehouse.status = 'INACTIVE' return ok(null) } + if (method === 'POST' && path === '/api/platform/v1/iam/roles') { + state.role = { id: '940000000000000001', ...body, status: 'ACTIVE' } + return ok({ ...state.role }) + } + if (method === 'POST' && path === '/api/platform/v1/iam/users') { + state.user = { membershipId: '950000000000000001', ...body, status: 'ACTIVE' } + return ok({ ...state.user }) + } + if (method === 'POST' && path === '/api/auth/login') { + return ok({ accessToken: 'deny-domestic-token', overseasAccessToken: 'deny-overseas-token', overseasExpiresIn: 900 }) + } + if (method === 'PUT' && path === '/api/platform/v1/iam/users/950000000000000001/status') { + state.user.status = body.status + return ok(null) + } + if (method === 'PUT' && path === '/api/platform/v1/iam/roles/940000000000000001/status') { + state.role.status = body.status + return ok(null) + } assert.fail(`unexpected domestic call ${method} ${path}`) }, - async requestOverseas(method, path, body) { - calls.push({ gateway: 'overseas', method, path, body }) + async requestOverseas(method, path, body, extraHeaders = {}, token = 'admin-overseas-token') { + calls.push({ gateway: 'overseas', method, path, body, extraHeaders, tokenKind: token === 'deny-overseas-token' ? 'denied' : 'admin' }) assert.notEqual(method, 'DELETE') + if (token === 'deny-overseas-token') return forbidden() if (method === 'GET' && path === `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) { + state.optionReads += 1 + if (!state.warehouse) return ok([]) + return ok([{ warehouseId: '910000000000000001', warehouseCode: state.warehouse.warehouseCode, + warehouseName: state.warehouse.warehouseName, countryCode: 'US' }]) + } + if (method === 'GET' && path === `${OVERSEAS_BASE}/overseas-quotes/warehouse-options?refresh=true`) { + state.optionReads += 1 + if (refreshEmpty) return ok([]) return ok([{ warehouseId: '910000000000000001', warehouseCode: state.warehouse.warehouseCode, warehouseName: state.warehouse.warehouseName, countryCode: 'US' }]) } @@ -81,6 +119,10 @@ function fakeContext() { } return ok(detailQuote(state.quote)) } + if (method === 'GET' && path.startsWith(`${OVERSEAS_BASE}/overseas-quotes?page=1&size=20&keyword=`)) { + return ok({ records: state.quote ? [{ ...state.quote.summary }] : [], page: 1, size: 20, + total: state.quote ? 1 : 0, pages: state.quote ? 1 : 0 }) + } if (path === `${OVERSEAS_BASE}/overseas-quotes/920000000000000001`) { if (method === 'GET') return ok(detailQuote(state.quote)) if (method === 'PUT') { @@ -100,7 +142,10 @@ function fakeContext() { return ok(detailQuote(state.quote)) } if (method === 'GET' && path.startsWith(`${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=`)) { - return ok({ records: [], page: 1, size: 20, total: 0, pages: 0 }) + const matches = state.exchange && path.includes(`monthFrom=${state.exchange.month}`) + && (!path.includes('status=') || path.includes(`status=${state.exchange.status}`)) + return ok({ records: matches ? [{ ...state.exchange }] : [], page: 1, size: 20, + total: matches ? 1 : 0, pages: matches ? 1 : 0 }) } if (method === 'POST' && path === `${OVERSEAS_BASE}/exchange-rates`) { state.exchange = { ...body, baseCurrency: 'USD', quoteCurrency: 'CNY', status: 'ACTIVE', recordVersion: 0 } @@ -139,13 +184,21 @@ function fakeContext() { state.vehicle = { ...state.vehicle, status: body.status, recordVersion: state.vehicle.recordVersion + 1 } return ok(vehicleWire(state)) } + if (method === 'GET' && path.startsWith(`${OVERSEAS_BASE}/vehicles?page=1&size=20&keyword=`)) { + return ok({ records: state.vehicle ? [vehicleWire(state)] : [], page: 1, size: 20, + total: state.vehicle ? 1 : 0, pages: state.vehicle ? 1 : 0 }) + } assert.fail(`unexpected overseas call ${method} ${path}`) }, async requestOverseasBinary(method, path) { calls.push({ gateway: 'overseas-binary', method, path }) assert.equal(method, 'GET') assert.match(path, /\/(?:overseas-quotes|exchange-rates|vehicles)\/export\?/) - return { method, path, http: 200, body: null, bytes: new TextEncoder().encode('id,status\n1,ACTIVE\n'), + let csv = '' + if (path.includes('/overseas-quotes/')) csv = `报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间\r\n${state.quote.summary.id},${state.quote.summary.name},${state.quote.summary.warehouseCode},${state.quote.summary.warehouseName},${state.quote.summary.providerName},USD,2098-01-01,2098-12-31,V1,ACTIVE,2026-08-21\r\n` + else if (path.includes('/exchange-rates/')) csv = `月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间\r\n${state.exchange.month},USD,CNY,${state.exchange.usdToCnyAverage},${state.exchange.status},admin,2026-08-21\r\n` + else csv = `车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n${state.vehicle.id},${state.vehicle.plateNumber},${state.vehicle.model},18.75,101,Carrier,123,OVERSEAS,OWNED,${state.vehicle.status},2026-08-21\r\n` + return { method, path, http: 200, body: null, bytes: new TextEncoder().encode(`\uFEFF${csv}`), contentType: 'text/csv;charset=UTF-8', requestId: 'req-csv' } }, } @@ -160,13 +213,20 @@ test('静态锁定真实双Gateway生命周期、CSV与安全边界', () => { assert.match(source, /context\.requestOverseas\('PATCH'/) assert.match(source, /context\.requestOverseasBinary\('GET'/) assert.match(source, /context\.expectConflict/) + assert.match(source, /warehouse-options\?refresh=true/) + assert.match(source, /空候选不负缓存30分钟/) + assert.match(source, /30分钟正缓存与刷新快照一致/) + assert.match(source, /无quote:list权限访问海外报价被拒绝/) + assert.match(source, /表头与marker内容/) assert.match(source, /finally\s*\{/) assert.match(source, /context\.createdResources\.phase4OverseasMasterData/) - assert.match(source, /context\.addCoverageGap/) + assert.doesNotMatch(source, /context\.addCoverageGap/) assert.match(source, /2090 \+ Math\.floor/) assert.doesNotMatch(source, /\/internal\/|overseas-partners|https?:\/\/|fetch\(|process\.env|request(?:Overseas)?\('DELETE'/) for (const endpoint of ['overseas-quotes/warehouse-options', 'overseas-quotes/export', 'exchange-rates/export', 'vehicles/export']) assert.ok(source.includes(endpoint)) + assert.doesNotMatch(controllerSource, /Idempotency-Key|IdempotencyKey/) + assert.doesNotMatch(source, /Idempotency-Key/) }) test('marker资源完成创建、详情、编辑、冲突、状态、CSV并全部停用', async () => { @@ -186,30 +246,39 @@ test('marker资源完成创建、详情、编辑、冲突、状态、CSV并全 assert.equal(state.quote.summary.status, 'INACTIVE') assert.equal(state.exchange.status, 'INACTIVE') assert.equal(state.vehicle.status, 'INACTIVE') + assert.equal(state.role.status, 'DISABLED') + assert.equal(state.user.status, 'DISABLED') + assert.equal(state.optionReads, 4) assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary').length, 3) assert.equal(context.calls.filter((call) => call.method === 'DELETE').length, 0) - assert.equal(context.calls.filter((call) => call.method === 'PUT').length, 7) + assert.equal(context.calls.filter((call) => call.method === 'PUT').length, 9) + assert.equal(context.calls.filter((call) => call.tokenKind === 'denied').length, 1) + assert.equal(context.coverageGaps.length, 0) }) -test('候选空缓存登记覆盖缺口并继续汇率车辆,finally停用国内合作仓', async () => { - const { context, state } = fakeContext() - const original = context.requestOverseas - context.requestOverseas = async (method, path, body) => { - if (method === 'GET' && path.endsWith('/overseas-quotes/warehouse-options')) return ok([]) - return original(method, path, body) - } +test('创建前空候选不形成假PASS,刷新后本轮合作仓进入报价完整生命周期', async () => { + const { context } = fakeContext() + await run(context) + const optionCalls = context.calls.filter((call) => call.path.includes('warehouse-options')) + assert.equal(optionCalls.length, 4) + assert.equal(optionCalls[0].path.endsWith('warehouse-options'), true) + assert.equal(optionCalls[1].path.endsWith('warehouse-options'), true) + assert.equal(optionCalls[2].path.endsWith('warehouse-options?refresh=true'), true) + assert.equal(optionCalls[3].path.endsWith('warehouse-options'), true) + assert.equal(context.createdResources.phase4OverseasMasterData.quote.id, '920000000000000001') + assert.equal(context.coverageGaps.length, 0) +}) + +test('refresh后仍为空时报价前置明确deployment blocker且不产生coverageGap假PASS', async () => { + const { context, state } = fakeContext({ softAssertions: true, refreshEmpty: true }) await run(context) - assert.equal(state.warehouse.status, 'INACTIVE') assert.equal(state.quote, null) + assert.equal(state.warehouse.status, 'INACTIVE') assert.equal(state.exchange.status, 'INACTIVE') assert.equal(state.vehicle.status, 'INACTIVE') - assert.equal(context.createdResources.phase4OverseasMasterData.quote.skipped, true) - assert.equal(context.createdResources.phase4OverseasMasterData.partnerWarehouse.finalStatus, 'INACTIVE') - assert.equal(context.createdResources.phase4OverseasMasterData.exchangeRate.finalStatus, 'INACTIVE') - assert.equal(context.createdResources.phase4OverseasMasterData.vehicle.finalStatus, 'INACTIVE') - assert.equal(context.coverageGaps.length, 1) - assert.equal(context.coverageGaps[0].name, '海外报价完整写生命周期') - assert.equal(context.coverageGaps[0].category, 'deployment-prerequisite') - assert.match(context.coverageGaps[0].reason, /30分钟空缓存/) - assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary').length, 2) + assert.equal(context.coverageGaps.length, 0) + assert.equal(context.steps.some((step) => step.name === '刷新后新建合作仓立即可见' + && !step.passed && step.deploymentBlocker), true) + assert.equal(context.steps.some((step) => step.name === '海外报价生命周期执行' + && !step.passed && step.deploymentBlocker), true) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs index 8e181bae6..3d8c8f90e 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs @@ -14,13 +14,26 @@ function requireData(context, name, response) { return value } -function assertBinaryCsv(context, name, response) { +function assertBinaryCsv(context, name, response, expectedHeader, expectedTokens) { + const text = response?.bytes instanceof Uint8Array ? new TextDecoder().decode(response.bytes).replace(/^\uFEFF/, '') : '' + const header = text.split(/\r?\n/, 1)[0] context.assert(MODULE, name, response, response?.http === 200 && String(response.contentType || '').toLowerCase().startsWith('text/csv') && response.bytes instanceof Uint8Array && response.bytes.length > 0, `HTTP=${response?.http ?? 0}, contentType=${response?.contentType || 'missing'}, bytes=${response?.bytes?.length || 0}`, BLOCKER) + context.assert(MODULE, `${name}表头与marker内容`, response, + header === expectedHeader && expectedTokens.every((token) => text.includes(String(token))), + `header=${header || 'missing'}, expectedTokens=${expectedTokens.join('|')}`, BLOCKER) +} + +const optionIds = (values) => Array.isArray(values) ? values.map((item) => asId(item?.warehouseId)).filter(Boolean) : [] + +function assertForbidden(context, name, response) { + context.assert(MODULE, name, response, + response?.http === 403 || (response?.http === 200 && ['403', 'A0403'].includes(String(response?.body?.code))), + `HTTP=${response?.http ?? 0}, code=${response?.body?.code ?? 'missing'}`, BLOCKER) } async function safeCleanup(context, name, action) { @@ -97,6 +110,16 @@ async function createAndVerifyPartnerWarehouse(context, resource) { BLOCKER) } +async function captureWarehouseOptionsBeforeCreate(context, resource) { + const response = await context.requestOverseas('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) + const options = requireData(context, '创建合作仓前查询报价候选缓存基线', response) + context.assert(MODULE, '合作仓候选基线为规范数组', response, + Array.isArray(options) && options.every((item) => asId(item?.warehouseId) + && item.warehouseCode && item.warehouseName && /^[A-Z]{2}$/.test(item.countryCode)), + `count=${Array.isArray(options) ? options.length : 'invalid'}`, BLOCKER) + resource.partnerWarehouseOptionsBefore = optionIds(options) +} + function quoteCreatePayload(context, warehouseId) { return { name: `${context.marker} 海外报价`, warehouseId, @@ -127,27 +150,25 @@ async function quoteLifecycle(context, resource) { Array.isArray(options) && options.every((item) => asId(item.warehouseId) && item.warehouseCode && item.warehouseName && item.countryCode), `count=${Array.isArray(options) ? options.length : 'invalid'}`, BLOCKER) - if (Array.isArray(options) && options.length === 0) { - resource.quote = { - skipped: true, - reason: 'warehouse-options-empty-cache', - finalStatus: null, - } - context.addCoverageGap(MODULE, BLOCKER.scenario, '海外报价完整写生命周期', - 'warehouse-options 返回空数组;已创建的 OVERSEAS_PARTNER 可能尚未穿透服务端30分钟空缓存。本轮跳过报价且不猜测 warehouseId,继续验收汇率与车辆。', - 'deployment-prerequisite', { - marker: context.marker, - partnerWarehouseId: resource.partnerWarehouse?.id || null, - http: optionsResponse?.http ?? null, - code: optionsResponse?.body?.code ?? null, - requestId: optionsResponse?.requestId ?? null, - traceId: optionsResponse?.traceId ?? null, - }) - return false + const createdId = resource.partnerWarehouse?.id + if (resource.partnerWarehouseOptionsBefore?.length === 0) { + context.assert(MODULE, '空候选不负缓存30分钟', optionsResponse, + optionIds(options).includes(createdId), `createdId=${createdId}, ids=${optionIds(options).join(',') || 'empty'}`, BLOCKER) } - const createdOption = options.find((item) => asId(item.warehouseId) === resource.partnerWarehouse?.id) - const option = createdOption || options[0] - if (!option) throw new Error('海外报价接口未返回可用合作仓候选') + + const refreshResponse = await context.requestOverseas('GET', + `${OVERSEAS_BASE}/overseas-quotes/warehouse-options?refresh=true`) + const refreshed = requireData(context, '强制刷新海外报价合作仓候选', refreshResponse) + const option = Array.isArray(refreshed) ? refreshed.find((item) => asId(item.warehouseId) === createdId) : null + context.assert(MODULE, '刷新后新建合作仓立即可见', refreshResponse, Boolean(option), + `createdId=${createdId}, ids=${optionIds(refreshed).join(',') || 'empty'}`, BLOCKER) + if (!option) throw new Error(`warehouse-options refresh=true 未返回本轮合作仓 ${createdId}`) + + const cachedResponse = await context.requestOverseas('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) + const cached = requireData(context, '刷新后读取30分钟正缓存', cachedResponse) + context.assert(MODULE, '30分钟正缓存与刷新快照一致', cachedResponse, + JSON.stringify(cached) === JSON.stringify(refreshed), + `refreshed=${optionIds(refreshed).join(',')}, cached=${optionIds(cached).join(',')}`, BLOCKER) const payload = quoteCreatePayload(context, asId(option.warehouseId)) const createResponse = await context.requestOverseas('POST', `${OVERSEAS_BASE}/overseas-quotes`, payload) @@ -164,6 +185,13 @@ async function quoteLifecycle(context, resource) { && Array.isArray(detail?.warehouseFees) && detail.warehouseFees.length > 0, `id=${asId(detail?.summary?.id) || 'missing'}, version=${detail?.summary?.recordVersion ?? 'missing'}`, BLOCKER) + const listResponse = await context.requestOverseas('GET', + `${OVERSEAS_BASE}/overseas-quotes?page=1&size=20&keyword=${encodeURIComponent(context.marker)}`) + const page = requireData(context, '分页查询marker海外报价', listResponse) + context.assert(MODULE, '报价列表包含本轮长ID与recordVersion', listResponse, + Array.isArray(page?.records) && page.records.some((item) => asId(item.id) === id && recordVersion(item.recordVersion)), + `id=${id}, total=${page?.total ?? 'missing'}`, BLOCKER) + const staleVersion = Number(detail.summary.recordVersion) const updatePayload = quoteUpdatePayload({ ...payload, transitPrices: payload.transitPrices }, context, staleVersion) const updateResponse = await context.requestOverseas('PUT', path, updatePayload) @@ -191,7 +219,9 @@ async function quoteLifecycle(context, resource) { detail.summary.status === 'ACTIVE', `status=${detail.summary.status}`, BLOCKER) const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/overseas-quotes/export?keyword=${encodeURIComponent(context.marker)}`) - assertBinaryCsv(context, '导出海外报价CSV', csv) + assertBinaryCsv(context, '导出海外报价CSV', csv, + '报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间', + [id, context.marker]) return true } @@ -252,9 +282,17 @@ async function exchangeLifecycle(context, resource) { context.assert(MODULE, '汇率状态切换为INACTIVE', statusResponse, rate.status === 'INACTIVE', `status=${rate.status}`, BLOCKER) + const listResponse = await context.requestOverseas('GET', + `${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=${month}&monthTo=${month}&status=INACTIVE`) + const page = requireData(context, '分页查询marker月汇率', listResponse) + context.assert(MODULE, '汇率列表包含本轮月份与recordVersion', listResponse, + Array.isArray(page?.records) && page.records.some((item) => item.month === month && recordVersion(item.recordVersion)), + `month=${month}, total=${page?.total ?? 'missing'}`, BLOCKER) + const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/exchange-rates/export?monthFrom=${month}&monthTo=${month}`) - assertBinaryCsv(context, '导出marker月汇率CSV', csv) + assertBinaryCsv(context, '导出marker月汇率CSV', csv, + '月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间', [month, 'INACTIVE']) } function vehiclePayload(context, edited = false) { @@ -316,8 +354,48 @@ async function vehicleLifecycle(context, resource) { context.assert(MODULE, '车辆状态切换为MAINTENANCE', statusResponse, vehicle.status === 'MAINTENANCE', `status=${vehicle.status}`, BLOCKER) + const listResponse = await context.requestOverseas('GET', + `${OVERSEAS_BASE}/vehicles?page=1&size=20&keyword=${encodeURIComponent(createPayload.plateNumber)}`) + const page = requireData(context, '分页查询marker车辆', listResponse) + context.assert(MODULE, '车辆列表包含本轮长ID与recordVersion', listResponse, + Array.isArray(page?.records) && page.records.some((item) => asId(item.id) === id && recordVersion(item.recordVersion)), + `id=${id}, total=${page?.total ?? 'missing'}`, BLOCKER) + const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/vehicles/export?keyword=${encodeURIComponent(createPayload.plateNumber)}`) - assertBinaryCsv(context, '导出marker车辆CSV', csv) + assertBinaryCsv(context, '导出marker车辆CSV', csv, + '车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间', + [id, createPayload.plateNumber, 'MAINTENANCE']) +} + +async function verifyCanonicalPermissionDenial(context, resource) { + const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase() + const roleResponse = await context.request('POST', '/api/platform/v1/iam/roles', { + roleCode: `P4_MD_DENY_${suffix}`.slice(0, 64), roleName: `四期主数据拒绝-${suffix}`, description: context.marker, + }) + const role = requireData(context, '创建无主数据权限角色', roleResponse) + const roleId = asId(role?.id) + if (!roleId) throw new Error('无主数据权限角色未返回ID') + resource.permissionProbe = { roleId, membershipId: null, roleStatus: 'ACTIVE', userStatus: null } + + const phone = await context.findAvailablePhone(MODULE) + const username = `p4_md_deny_${suffix}`.slice(0, 64).toLowerCase() + const password = `P4md${suffix.slice(-8)}A1!` + const userResponse = await context.request('POST', '/api/platform/v1/iam/users', { + username, displayName: `四期主数据拒绝-${suffix}`, phone, + email: `${username}@example.com`, tenantId: '0', roleIds: [roleId], password, status: 'ACTIVE', + }) + const user = requireData(context, '创建无主数据权限用户', userResponse) + const membershipId = asId(user?.membershipId) + if (!membershipId) throw new Error('无主数据权限用户未返回membershipId') + resource.permissionProbe.membershipId = membershipId + resource.permissionProbe.userStatus = 'ACTIVE' + + const loginResponse = await context.request('POST', '/api/auth/login', { username, password }, '') + const login = requireData(context, '无主数据权限用户登录', loginResponse) + if (!login?.overseasAccessToken) throw new Error('无主数据权限用户未签发海外Token') + const denied = await context.requestOverseas('GET', + `${OVERSEAS_BASE}/overseas-quotes?page=1&size=1`, undefined, {}, login.overseasAccessToken) + assertForbidden(context, '无quote:list权限访问海外报价被拒绝', denied) } async function cleanupPartnerWarehouse(context, resource) { @@ -355,16 +433,33 @@ async function cleanupVersioned(context, resource, key, path, extract) { } } +async function cleanupPermissionProbe(context, resource) { + const probe = resource.permissionProbe + if (!probe) return + if (probe.membershipId && probe.userStatus !== 'DISABLED') { + const response = await context.request('PUT', `/api/platform/v1/iam/users/${probe.membershipId}/status`, { status: 'DISABLED' }) + context.apiSuccess(MODULE, '无权限探针用户finally停用', response, `membershipId=${probe.membershipId}`, BLOCKER) + if (success(response)) probe.userStatus = 'DISABLED' + } + if (probe.roleId && probe.roleStatus !== 'DISABLED') { + const response = await context.request('PUT', `/api/platform/v1/iam/roles/${probe.roleId}/status`, { status: 'DISABLED' }) + context.apiSuccess(MODULE, '无权限探针角色finally停用', response, `roleId=${probe.roleId}`, BLOCKER) + if (success(response)) probe.roleStatus = 'DISABLED' + } +} + export async function run(context) { const resource = { marker: context.marker } context.createdResources.phase4OverseasMasterData = resource try { await continueLifecycle(context, '海外报价', async () => { + await captureWarehouseOptionsBeforeCreate(context, resource) await createAndVerifyPartnerWarehouse(context, resource) await quoteLifecycle(context, resource) }) await continueLifecycle(context, '月度汇率', () => exchangeLifecycle(context, resource)) await continueLifecycle(context, '车辆主数据', () => vehicleLifecycle(context, resource)) + await continueLifecycle(context, 'canonical权限拒绝', () => verifyCanonicalPermissionDenial(context, resource)) } finally { await safeCleanup(context, '海外报价', async () => { if (!resource.quote?.id) return @@ -382,5 +477,6 @@ export async function run(context) { await cleanupVersioned(context, resource, 'vehicle', path, (value) => value) }) await safeCleanup(context, '合作仓', () => cleanupPartnerWarehouse(context, resource)) + await safeCleanup(context, 'canonical权限探针', () => cleanupPermissionProbe(context, resource)) } } diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index f144909b3..e614ef63e 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -9,41 +9,79 @@ import { run } from './phase4-overseas-pda.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda.mjs'), 'utf8') -test('PDA module uses only injected development acceptance requests', () => { +test('PDA module is injected, token-scoped, traceable, and contains no direct network or secret source', () => { assert.match(source, /export async function run\(context\)/) - assert.match(source, /context\.requestOverseas\(/) - assert.match(source, /context\.requestOverseasMultipart\(/) - assert.doesNotMatch(source, /https?:\/\//) - assert.doesNotMatch(source, /process\.env|fetch\(/) -}) - -test('PDA module covers the complete receiving lifecycle and excludes outbound domains', () => { - for (const token of ['/destination', '/inbound-plans/convert', '/pallets/', '/push', '/cartons/scan', '/allocations/', - '/count', '/pick', '/unload-photos/files', '/unload-photos', '/complete', '/inventory/balances']) { - assert.ok(source.includes(token), token) - } - for (const forbidden of ['/outbound', '/dispatch', '/delivery', '/internal/']) assert.equal(source.includes(forbidden), false, forbidden) + assert.match(source, /requestOverseas\(/) + assert.match(source, /requestOverseasMultipart\(/) assert.match(source, /requestId=/) assert.match(source, /traceId=/) - assert.match(source, /PENDING_ASSIGNMENT/) - assert.match(source, /nonBlank\(item\?\.containerNo\)/) - assert.match(source, /expectedCartons/) + assert.doesNotMatch(source, /https?:\/\//) + assert.doesNotMatch(source, /fetch\(|process\.env/) + assert.doesNotMatch(source, /planNo|allocationId/) }) -test('missing source order is reported as a fixture prerequisite without business writes', async () => { +test('PDA module covers the container-number receiving contract and excludes outbound domains', () => { + for (const token of ['/candidates', '/prepare', '/current/unload-photos/files', '/current/sign', + '/current/skus/resolve', '/current/cartons/resolve', '/current/pallets/resolve', + '/current/receipts/bulk', '/current/receipts/carton', '/current/complete']) assert.ok(source.includes(token), token) + for (const role of ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN']) assert.ok(source.includes(role), role) + for (const forbidden of ['/outbound', '/dispatch', '/delivery', '/internal/']) assert.equal(source.includes(forbidden), false, forbidden) + assert.match(source, /同键同载荷重放/) + assert.match(source, /同键异载荷冲突/) + assert.match(source, /跨柜切换锁返回409/) + assert.match(source, /超收被拒绝/) + assert.match(source, /重复箱新键被拒绝/) + assert.match(source, /模式硬拒绝/) + assert.match(source, /current返回404正常空态/) + assert.match(source, /安全扫描被拒绝/) + assert.match(source, /retained as acceptance evidence/) +}) + +test('missing customer fixture is a deployment blocker and performs no HTTP request', async () => { const calls = []; const gaps = []; const steps = [] const context = { - marker: 'PDA-CONTRACT', createdResources: {}, - async request(method, requestPath) { calls.push({ channel: 'domestic', method, requestPath }); return { http: 200, body: { code: '200', data: { records: [], page: 1, size: 200, total: 0, pages: 0 } } } }, - async requestOverseas(method, requestPath) { calls.push({ channel: 'overseas', method, requestPath }); throw new Error('overseas should not be called without customers') }, + marker: 'PDA-CONTRACT', context: {}, createdResources: {}, + async request(...args) { calls.push(args); throw new Error('must not request without credentials') }, + async requestOverseas(...args) { calls.push(args); throw new Error('must not request without credentials') }, + async requestOverseasMultipart(...args) { calls.push(args); throw new Error('must not request without credentials') }, addStep(...args) { steps.push(args) }, - assert(...args) { steps.push(args) }, + assert(module, name, response, passed, evidence, options) { steps.push({ module, name, passed, evidence, options }) }, addCoverageGap(...args) { gaps.push(args) }, } await run(context) - assert.equal(calls.length, 1) - assert.equal(calls[0].method, 'GET') + assert.equal(calls.length, 0) assert.equal(gaps.length, 1) - assert.match(gaps[0][3], /三期装柜完成事件链/) - assert.deepEqual(context.createdResources.phase4OverseasPda.finalStatus, null) + assert.equal(gaps[0][5].deploymentBlocker, true) + assert.equal(steps.length, 1) + assert.equal(steps[0].passed, false) + assert.equal(steps[0].options.deploymentBlocker, true) +}) + +test('empty candidate response cannot become a zero-business-request pass', async () => { + const calls = []; const gaps = []; const steps = [] + const response = (data, requestPath, method = 'GET') => ({ method, path: requestPath, http: 200, + body: { code: '200', data }, requestId: 'req-contract', traceId: 'trace-contract' }) + const context = { + marker: 'PDA-CONTRACT', overseasToken: '', createdResources: {}, + context: { merchantQualificationFixture: { tenantId: '9', username: 'merchant', password: 'generated' } }, + async request(method, requestPath) { + calls.push({ channel: 'domestic', method, requestPath }) + return response({ overseasAccessToken: 'pda-token' }, requestPath, method) + }, + async requestOverseas(method, requestPath) { + calls.push({ channel: 'overseas', method, requestPath }) + if (requestPath.endsWith('/current')) return { method, path: requestPath, http: 200, + body: { code: 'A0404', message: 'not found' }, requestId: 'req-current', traceId: 'trace-current' } + return response([], requestPath, method) + }, + addStep(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed, evidence, options }) }, + assert(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed, evidence, options }) }, + addCoverageGap(...args) { gaps.push(args) }, + } + await run(context) + assert.ok(calls.some((call) => call.requestPath.endsWith('/candidates'))) + assert.ok(steps.some((step) => step.name.includes('正常空态') && step.passed)) + assert.ok(steps.some((step) => step.name.includes('PDA柜号候选覆盖前置') && !step.passed && step.options.deploymentBlocker)) + assert.equal(gaps.length, 1) + assert.equal(context.createdResources.phase4OverseasPda.finalStatus, null) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index d312975c3..4a66ab7e1 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -1,212 +1,270 @@ const MODULE = 'phase4-overseas-pda' -const SCENARIO = 'phase4-overseas-pda-inbound-lifecycle' +const SCENARIO = 'phase4-overseas-pda-container-receiving' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const PHOTO_ROLES = ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN'] +const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')) function id(value) { return value == null ? '' : String(value) } function longId(value) { return /^\d+$/.test(id(value)) && id(value) !== '0' } function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 } -function records(page) { return Array.isArray(page?.records) ? page.records : [] } -function query(path, params) { - const search = new URLSearchParams() - for (const [key, value] of Object.entries(params)) if (value !== undefined && value !== null && value !== '') search.set(key, String(value)) - return `${path}?${search}` -} -function trace(response) { - return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` -} function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' } +function code(response) { return String(response?.body?.code ?? '') } +function traced(response) { return nonBlank(response?.requestId) && nonBlank(response?.traceId) } +function conflict(response) { return (response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response)))) && traced(response) } +function conflictMessage(response, pattern) { return conflict(response) && pattern.test(String(response?.body?.message || '')) } +function notFound(response) { return (response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(code(response)))) && traced(response) } +function forbidden(response) { return [401, 403].includes(response?.http) || (response?.http === 200 && ['A0401', 'A0403'].includes(code(response))) } +function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` } function key(context, action, suffix = '') { return `${context.marker}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) } +function markerSuffix(context) { return String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() } -async function domesticGet(context, name, path) { - const response = await context.request('GET', path) - const passed = success(response) - context.addStep(MODULE, name, response, passed, trace(response), OPTIONS) - return passed ? response.body.data : null -} -async function overseas(context, method, name, path, body = undefined) { - const response = await context.requestOverseas(method, path, body) - const passed = success(response) - context.addStep(MODULE, name, response, passed, trace(response), OPTIONS) - return { response, data: passed ? response.body.data : null } +function blockerGap(context, name, reason, category = 'fixture-unavailable', response = null) { + context.addCoverageGap(MODULE, SCENARIO, name, reason, category, { deploymentBlocker: true }) + context.assert(MODULE, `${name}覆盖前置`, response, false, reason, OPTIONS) } -async function findUnplannedOrder(context) { - const tenants = records(await domesticGet(context, '查询PDA可用客户', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE')) - for (const tenant of tenants) { - const customerId = id(tenant?.id) - if (!longId(customerId)) continue - const plans = await overseas(context, 'GET', `查询客户${customerId}既有入仓计划`, query('/api/platform/v1/last-leg/inbound-plans', { customerId, page: 1, size: 200 })) - if (!success(plans.response)) continue - const planned = new Set(records(plans.data).map((item) => id(item?.sourceOrderId)).filter(Boolean)) - const orders = await overseas(context, 'GET', `查询客户${customerId}待规划尾程单`, query('/api/platform/v1/last-leg/orders', { customerId, currentStatus: 'PENDING_ASSIGNMENT', page: 1, size: 200 })) - if (!success(orders.response)) continue - const order = records(orders.data).find((item) => longId(item?.id) && nonBlank(item?.containerNo) - && !planned.has(id(item.id))) - if (order) return { customerId, order } +async function pda(context, token, method, name, path, body = undefined) { + const response = await context.requestOverseas(method, path, body, {}, token) + context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS) + return { response, data: success(response) ? response.body?.data : null } +} + +async function expected(context, token, method, name, path, body, predicate) { + const response = await context.requestOverseas(method, path, body, {}, token) + context.addStep(MODULE, name, response, predicate(response), trace(response), OPTIONS) + return response +} + +async function loginPda(context) { + const fixture = context.context?.merchantQualificationFixture + if (!fixture?.username || !fixture?.password) { + blockerGap(context, 'PDA客户账号', '缺少 merchant-qualification-fixture 生成的本轮客户账号;请保留默认全量前置模块') + return null } - return null + const response = await context.request('POST', '/api/auth/login', { username: fixture.username, password: fixture.password }, '') + const data = success(response) ? response.body?.data : null + context.addStep(MODULE, '本轮客户登录并取得海外PDA令牌', response, + success(response) && nonBlank(data?.overseasAccessToken), + `overseasTokenPresent=${nonBlank(data?.overseasAccessToken)}; ${trace(response)}`, OPTIONS) + return nonBlank(data?.overseasAccessToken) ? data.overseasAccessToken : null } -async function createWarehouse(context, customerId) { - const suffix = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase() - const warehouseCode = `PDA-${suffix}`.slice(0, 50) - const created = await overseas(context, 'POST', '创建PDA入仓目标海外仓', '/api/platform/v1/warehouse-admin', { - customerId, warehouseCode, warehouseName: `${context.marker} PDA入仓仓`, warehouseType: 'OVERSEAS', - countryCode: 'US', timezone: 'America/Los_Angeles', contactName: 'Phase4 PDA', - contactPhone: '+1-213-555-0199', contactEmail: null, status: 'ACTIVE', remark: `${context.marker}-pda-inbound`, - address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles', - district: 'Commerce', postalCode: '90040', addressLine1: '502 Phase Four PDA Avenue', addressLine2: null, - latitude: null, longitude: null }, - capabilities: [{ capabilityCode: 'STORAGE', capabilityName: 'Storage', enabled: true, cutoffTime: null, - description: `${context.marker} PDA inbound acceptance` }], +async function createPallet(context, inbound, index) { + const warehouseId = id(inbound?.destinationWarehouseId) + if (!longId(warehouseId)) return null + const suffix = markerSuffix(context) + const location = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { + zoneCode: `PDA-${index + 1}`, locationCode: `PDA-${suffix}-${index + 1}`.slice(0, 64), + locationType: 'STORAGE', maxLoadKg: 5000, status: 'IDLE', remark: `${context.marker}-receiving`, }) - const warehouseId = id(created.data) - context.assert(MODULE, 'PDA目标海外仓返回长ID', created.response, longId(warehouseId), - `warehouseId=${warehouseId || 'missing'}; ${trace(created.response)}`, OPTIONS) - return { warehouseId, warehouseCode, warehouseName: `${context.marker} PDA入仓仓` } + context.addStep(MODULE, `创建当前柜实收库位${index + 1}`, location, success(location), trace(location), OPTIONS) + const locationId = id(location?.body?.data) + if (!longId(locationId)) return null + context.createdResources.phase4OverseasPda.locationIds.push(locationId) + const palletCode = `PDA-${suffix}-${index + 1}`.slice(0, 128) + const pallet = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { + palletCode, palletType: '木托盘', capacityCbm: 10, unitPrice: 0, status: 'IDLE', locationId, + remark: `${context.marker}-receiving-retained`, + fbaWarehouseCode: inbound.destinationType === 'FBA' ? inbound.fbaWarehouseCode : null, + }) + context.addStep(MODULE, `创建当前柜实收托盘${index + 1}`, pallet, success(pallet), trace(pallet), OPTIONS) + const palletId = id(pallet?.body?.data) + return longId(palletId) ? { warehouseId, locationId, palletId, palletCode, retained: true } : null } -async function createResource(context, warehouseId, index, fbaWarehouseCode) { - const suffix = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-14).toUpperCase() - const location = await overseas(context, 'POST', `创建PDA库位${index + 1}`, - `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { - zoneCode: `PDA-${index + 1}`, locationCode: `PDA-${suffix}-${index + 1}`.slice(0, 64), - locationType: 'STORAGE', maxLoadKg: 5000, status: 'IDLE', remark: context.marker, - }) - const locationId = id(location.data) - context.assert(MODULE, `PDA库位${index + 1}返回长ID`, location.response, longId(locationId), - `locationId=${locationId || 'missing'}; ${trace(location.response)}`, OPTIONS) - const pallet = await overseas(context, 'POST', `创建PDA实物托盘${index + 1}`, - `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { - palletCode: `PDA-${suffix}-${index + 1}`.slice(0, 128), palletType: '木托盘', capacityCbm: 2, - unitPrice: 0, status: 'IDLE', locationId: longId(locationId) ? locationId : undefined, - remark: context.marker, fbaWarehouseCode: fbaWarehouseCode || null, - }) - const palletId = id(pallet.data) - context.assert(MODULE, `PDA实物托盘${index + 1}返回长ID`, pallet.response, longId(palletId), - `palletId=${palletId || 'missing'}; ${trace(pallet.response)}`, OPTIONS) - return { locationId, palletId } -} - -async function operateExpectedCartons(context, customerId, planNo, task) { - for (const [index, carton] of (task.expectedCartons || []).entries()) { - if (carton.status === 'ON_PALLET' || carton.status === 'AVAILABLE') continue - const scanned = await overseas(context, 'POST', `扫描国内冻结箱唛${index + 1}`, - query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/cartons/scan`, { customerId }), { - cartonMark: carton.cartonMark, planPalletId: id(carton.planPalletId), - idempotencyKey: key(context, 'scan', String(index + 1)), operatorId: 'phase4-http', operatorName: '四期HTTP验收', - }) - context.assert(MODULE, `箱唛${index + 1}已绑定托盘`, scanned.response, - scanned.data?.result === 'SCANNED' && scanned.data?.cartonMark === carton.cartonMark, - `cartonMark=${carton.cartonMark || 'missing'}, result=${scanned.data?.result || 'missing'}; ${trace(scanned.response)}`, OPTIONS) +async function uploadPhotos(context, token) { + const malicious = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', { + name: `${context.marker}-forged.png`, type: 'image/png', content: Uint8Array.from(Buffer.from('')), + }, token) + context.addStep(MODULE, '伪造图片内容安全扫描被拒绝', malicious, !success(malicious) && traced(malicious), trace(malicious), OPTIONS) + const photos = [] + for (const role of PHOTO_ROLES) { + const response = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', { + name: `${context.marker}-${role.toLowerCase()}.png`, type: 'image/png', content: PNG, + }, token) + const fileId = id(response?.body?.data?.fileId) + context.addStep(MODULE, `上传${role}真实签收照片`, response, success(response) && longId(fileId), + `role=${role}, fileId=${fileId || 'missing'}; ${trace(response)}`, OPTIONS) + if (!longId(fileId)) return null + photos.push({ role, fileId }) } + return photos } -async function operateLegacyAllocations(context, customerId, planNo, task) { - for (const [index, allocation] of (task.allocations || []).entries()) { - const remaining = Number(allocation.plannedPackageCount || 0) - Number(allocation.pickedPackageCount || 0) - if (remaining <= 0) continue - await overseas(context, 'POST', `清点入仓分配${index + 1}`, - query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/allocations/${encodeURIComponent(id(allocation.allocationId))}/count`, { customerId }), { - packageCount: Number(allocation.plannedPackageCount), idempotencyKey: key(context, 'count', String(index + 1)), - operatorId: 'phase4-http', operatorName: '四期HTTP验收', - }) - await overseas(context, 'POST', `实收入仓分配${index + 1}`, - query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/allocations/${encodeURIComponent(id(allocation.allocationId))}/pick`, { customerId }), { - packageCount: remaining, idempotencyKey: key(context, 'pick', String(index + 1)), - operatorId: 'phase4-http', operatorName: '四期HTTP验收', - }) +function skuFixtures(context) { + const boxes = context.createdResources?.orders?.boxTypes + const orchestration = context.createdResources?.orchestrationFixtures || {} + const markers = [context.marker, orchestration.phase3Loading?.sourceMarker, + orchestration.phase3LoadingIncremental?.sourceMarker].filter(nonBlank) + return Array.isArray(boxes) + ? [...new Set(markers.flatMap((marker) => boxes.map((boxType) => `SKU-${boxType}-${marker}`.slice(0, 64))))] + : [] +} + +async function receiveFba(context, token, inbound, pallet) { + const skus = skuFixtures(context) + if (!skus.length) { + blockerGap(context, `FBA入仓码${inbound.inboundCode} SKU`, '本轮订单 fixture 未交接冻结 SKU,无法安全猜测实收 SKU') + return false } + let accepted = 0 + for (const [index, skuCode] of skus.entries()) { + const resolvedResponse = await context.requestOverseas('POST', + '/api/pda/v1/last-leg/receiving-containers/current/skus/resolve', { skuCode }, {}, token) + const resolvable = success(resolvedResponse) || notFound(resolvedResponse) + context.addStep(MODULE, `解析FBA SKU候选 ${index + 1}`, resolvedResponse, resolvable, trace(resolvedResponse), OPTIONS) + const resolved = { response: resolvedResponse, data: success(resolvedResponse) ? resolvedResponse.body?.data : null } + if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode || resolved.data?.destinationType !== 'FBA') continue + const packageCount = skuCode.includes('-LOAD-INC') ? 2 : 1 + const body = { inboundCode: inbound.inboundCode, skuCode, palletCode: pallet.palletCode, + packageCount, idempotencyKey: key(context, 'fba-bulk', skuCode) } + const first = await pda(context, token, 'POST', `FBA SKU ${skuCode}按箱数实收`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) + if (!success(first.response)) return false + accepted += Number(first.data?.acceptedPackages || 0) + const replay = await pda(context, token, 'POST', `FBA SKU ${skuCode}同键同载荷重放`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) + context.assert(MODULE, `FBA SKU ${skuCode}幂等结果一致`, replay.response, + success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) + await expected(context, token, 'POST', `FBA SKU ${skuCode}同键异载荷冲突`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', + { ...body, packageCount: packageCount + 1 }, conflict) + await expected(context, token, 'POST', `FBA SKU ${skuCode}超收被拒绝`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', + { ...body, packageCount: Number(inbound.totalPackages) + 1, idempotencyKey: key(context, 'fba-over', skuCode) }, conflict) + } + if (accepted <= 0) { + blockerGap(context, `FBA入仓码${inbound.inboundCode}实收`, `候选 SKU 均未唯一解析到当前入仓码;skuCount=${skus.length}`) + return false + } + return true +} + +async function receiveOverseas(context, token, inbound, pallet) { + const cartonMark = context.createdResources?.inboundMeasurement?.boxCode + if (!nonBlank(cartonMark)) { + blockerGap(context, `OVERSEAS入仓码${inbound.inboundCode}逐箱`, '缺少本轮冻结箱唛,逐箱实收不能使用伪造箱号') + return false + } + const resolved = await pda(context, token, 'POST', '解析OVERSEAS冻结箱唛', '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }) + if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode) { + blockerGap(context, `OVERSEAS入仓码${inbound.inboundCode}箱唛归属`, `冻结箱唛未解析到目标入仓码;cartonMark=${cartonMark}`, 'fixture-mismatch', resolved.response) + return false + } + const body = { cartonMark, palletCode: pallet.palletCode, idempotencyKey: key(context, 'overseas-carton') } + const first = await pda(context, token, 'POST', 'OVERSEAS逐箱实收', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body) + if (!success(first.response)) return false + const replay = await pda(context, token, 'POST', 'OVERSEAS逐箱同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body) + context.assert(MODULE, 'OVERSEAS逐箱幂等结果一致', replay.response, + success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) + await expected(context, token, 'POST', 'OVERSEAS逐箱同键异载荷冲突', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', + { ...body, palletCode: `${pallet.palletCode}-MUTATED` }, conflict) + await expected(context, token, 'POST', 'OVERSEAS重复箱新键被拒绝', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', + { ...body, idempotencyKey: key(context, 'overseas-duplicate') }, conflict) + return true } export async function run(context) { - context.createdResources.phase4OverseasPda = { marker: context.marker, customerId: null, orderId: null, - planNo: null, warehouseId: null, taskId: null, finalStatus: null, retained: true } - const source = await findUnplannedOrder(context) - if (!source) { - context.addCoverageGap(MODULE, SCENARIO, 'PDA入仓来源尾程单', - '所有ACTIVE客户均无尚未转换、状态为PENDING_ASSIGNMENT且具有柜号的尾程单;请先运行三期装柜完成事件链', 'fixture-unavailable') + context.createdResources.phase4OverseasPda = { + marker: context.marker, containerNo: null, customerId: context.context?.merchantQualificationFixture?.tenantId || null, + palletIds: [], locationIds: [], finalStatus: null, + cleanup: 'Pallets and locations used by completed inventory are retained as acceptance evidence', + } + const token = await loginPda(context) + if (!token) return + if (context.overseasToken) { + const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken) + context.addStep(MODULE, '平台海外令牌不能冒充客户PDA账号', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) + } + const before = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token) + const noCurrent = notFound(before) + context.addStep(MODULE, '无当前柜返回404属于正常空态', before, noCurrent || success(before), + `${noCurrent ? 'empty=true' : `resumeStatus=${before?.body?.data?.status || 'missing'}`}; ${trace(before)}`, OPTIONS) + if (success(before)) { + blockerGap(context, '可重复的完整柜号验收', `账号已有未完成柜 ${before.body?.data?.containerNo || 'unknown'},本轮不覆盖或篡改遗留现场会话`, 'dirty-fixture', before) return } - const { customerId, order } = source - const warehouse = await createWarehouse(context, customerId) - if (!longId(warehouse.warehouseId)) return - Object.assign(context.createdResources.phase4OverseasPda, { customerId, orderId: id(order.id), warehouseId: warehouse.warehouseId }) - - const transferred = await overseas(context, 'PUT', '尾程单指定本轮PDA目标仓', - query(`/api/platform/v1/last-leg/orders/${encodeURIComponent(id(order.id))}/destination`, { customerId }), { - warehouseId: warehouse.warehouseId, warehouseCode: warehouse.warehouseCode, - warehouseName: warehouse.warehouseName, remark: `${context.marker} PDA lifecycle`, - }) - if (!success(transferred.response)) return - const converted = await overseas(context, 'POST', '尾程单转换入仓计划', '/api/platform/v1/last-leg/inbound-plans/convert', { - orderId: id(order.id), customerId, - }) - const plan = converted.data - const planNo = plan?.planNo || '' - context.assert(MODULE, '入仓计划转换返回完整作业结构', converted.response, - Boolean(planNo) && Array.isArray(plan?.pallets) && plan.pallets.length > 0 && Array.isArray(plan?.allocations) && plan.allocations.length > 0, - `planNo=${planNo || 'missing'}, pallets=${plan?.pallets?.length ?? 'missing'}, allocations=${plan?.allocations?.length ?? 'missing'}; ${trace(converted.response)}`, OPTIONS) - if (!planNo || !Array.isArray(plan?.pallets) || !plan.pallets.length) return - context.createdResources.phase4OverseasPda.planNo = planNo - - for (const [index, planPallet] of plan.pallets.entries()) { - const resource = await createResource(context, warehouse.warehouseId, index, planPallet.destinationWarehouseCode) - if (!longId(resource.locationId) || !longId(resource.palletId)) return - const assigned = await overseas(context, 'POST', `分配计划托盘${index + 1}`, - query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/pallets/${encodeURIComponent(id(planPallet.id))}/assign`, { customerId }), { - warehouseId: warehouse.warehouseId, palletId: resource.palletId, locationId: resource.locationId, - idempotencyKey: key(context, 'assign', String(index + 1)), - }) - if (!success(assigned.response)) return + const candidates = await pda(context, token, 'GET', '查询当前客户待收柜号', '/api/pda/v1/last-leg/receiving-containers/candidates') + if (!Array.isArray(candidates.data) || candidates.data.length === 0) { + blockerGap(context, 'PDA柜号候选', `当前客户没有由三期装柜完成事件生成的待收入仓计划;${trace(candidates.response)}`, 'producer-chain', candidates.response) + return } - - const pushed = await overseas(context, 'POST', '推送PDA入仓任务', - query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/push`, { customerId }), { - idempotencyKey: key(context, 'push'), operatorId: 'phase4-http', operatorName: '四期HTTP验收', - }) - const task = pushed.data - context.assert(MODULE, 'PDA任务包含托盘、分配和箱唛指令', pushed.response, - longId(task?.taskId) && Array.isArray(task?.pallets) && task.pallets.length === plan.pallets.length - && Array.isArray(task?.allocations) && task.allocations.length > 0 && Array.isArray(task?.expectedCartons), - `taskId=${id(task?.taskId) || 'missing'}, pallets=${task?.pallets?.length ?? 'missing'}, allocations=${task?.allocations?.length ?? 'missing'}, cartons=${task?.expectedCartons?.length ?? 'missing'}; ${trace(pushed.response)}`, OPTIONS) - if (!longId(task?.taskId)) return - context.createdResources.phase4OverseasPda.taskId = id(task.taskId) - - if ((task.expectedCartons || []).length > 0) await operateExpectedCartons(context, customerId, planNo, task) - else await operateLegacyAllocations(context, customerId, planNo, task) - - const photo = await context.requestOverseasMultipart('POST', - query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/unload-photos/files`, { customerId }), { - name: `${context.marker}-unload.png`, type: 'image/png', - content: Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')), - }) - context.addStep(MODULE, '上传真实卸柜照片文件', photo, success(photo), trace(photo), OPTIONS) - const fileId = id(photo?.body?.data?.fileId) - context.assert(MODULE, '卸柜照片返回长文件ID', photo, longId(fileId), `fileId=${fileId || 'missing'}; ${trace(photo)}`, OPTIONS) - if (!longId(fileId)) return - const bound = await overseas(context, 'POST', '绑定卸柜照片并生成预计报空时间', - query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/unload-photos`, { customerId }), { - fileIds: [fileId], idempotencyKey: key(context, 'photos'), operatorId: 'phase4-http', operatorName: '四期HTTP验收', - }) - context.assert(MODULE, '照片凭证含上传时间和预计报空时间', bound.response, - bound.data?.unloadPhotoFileIds?.map(id).includes(fileId) && Boolean(bound.data?.unloadPhotoUploadedAt) && Boolean(bound.data?.estimatedEmptyReturnAt), - `fileIds=${bound.data?.unloadPhotoFileIds?.map(id).join(',') || 'missing'}, estimated=${bound.data?.estimatedEmptyReturnAt || 'missing'}; ${trace(bound.response)}`, OPTIONS) - - const completed = await overseas(context, 'POST', '完成PDA入仓并写库存', - query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/complete`, { customerId })) - context.assert(MODULE, 'PDA任务进入COMPLETED终态', completed.response, - completed.data?.status === 'COMPLETED' && Boolean(completed.data?.completedAt), - `status=${completed.data?.status || 'missing'}, completedAt=${completed.data?.completedAt || 'missing'}; ${trace(completed.response)}`, OPTIONS) + const preferredContainer = context.createdResources?.phase3Loading?.containerNo + const candidate = candidates.data.find((item) => item.containerNo === preferredContainer) || candidates.data[0] + const containerNo = id(candidate?.containerNo) + context.assert(MODULE, '柜号候选来自本轮生产链且包含进度', candidates.response, + nonBlank(containerNo) && Number(candidate?.totalPackages) > 0 && Number(candidate?.receivedPackages) >= 0, + `containerNo=${containerNo || 'missing'}, total=${candidate?.totalPackages ?? 'missing'}; ${trace(candidates.response)}`, OPTIONS) + if (!containerNo) return + context.createdResources.phase4OverseasPda.containerNo = containerNo + const detail = await pda(context, token, 'GET', '查询柜号完整入仓码详情', `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}`) + if (!Array.isArray(detail.data?.inbounds) || detail.data.inbounds.length === 0) return + const prepareBody = { idempotencyKey: key(context, 'prepare') } + const prepared = await pda(context, token, 'POST', '选择柜号并建立WAIT_SIGN会话', + `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, prepareBody) + if (!success(prepared.response)) return + const prepareReplay = await pda(context, token, 'POST', '选择柜号同键重放', + `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, prepareBody) + context.assert(MODULE, '选择柜号幂等结果一致', prepareReplay.response, + success(prepareReplay.response) && prepareReplay.data?.containerNo === containerNo && prepareReplay.data?.status === 'WAIT_SIGN', trace(prepareReplay.response), OPTIONS) + const other = candidates.data.find((item) => item.containerNo !== containerNo) + if (other) await expected(context, token, 'GET', '当前账号跨柜切换锁返回409', + `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(other.containerNo)}`, undefined, conflict) + else blockerGap(context, '跨柜锁409', '当前客户只有一个待收柜,无法在不伪造柜数据的前提下验证跨柜锁') + const photos = await uploadPhotos(context, token) + if (!photos) return + const signBody = { photos, idempotencyKey: key(context, 'sign') } + const signed = await pda(context, token, 'POST', '绑定车牌封签开柜三角色照片并签收', '/api/pda/v1/last-leg/receiving-containers/current/sign', signBody) + if (!success(signed.response)) return + const signReplay = await pda(context, token, 'POST', '签收同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/sign', signBody) + context.assert(MODULE, '三角色照片各一次且签收幂等', signReplay.response, + success(signReplay.response) && signReplay.data?.status === 'SIGNED' + && PHOTO_ROLES.every((role) => signReplay.data?.photos?.filter((item) => item.role === role).length === 1), trace(signReplay.response), OPTIONS) + await expected(context, token, 'POST', '签收同键异载荷冲突', '/api/pda/v1/last-leg/receiving-containers/current/sign', + { ...signBody, photos: [...photos].reverse() }, conflict) + const current = await pda(context, token, 'GET', '签收后按账号恢复当前柜', '/api/pda/v1/last-leg/receiving-containers/current') + if (!success(current.response)) return + const pallets = new Map() + for (const [index, inbound] of current.data.inbounds.entries()) { + const pallet = await createPallet(context, inbound, index) + if (!pallet) { + blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}实物托盘`, + `无法在目的仓 ${inbound.destinationWarehouseId || 'missing'} 创建可解析真实托盘`, 'warehouse-resource') + continue + } + pallets.set(inbound.inboundCode, pallet) + context.createdResources.phase4OverseasPda.palletIds.push(pallet.palletId) + const resolved = await pda(context, token, 'POST', `解析${inbound.destinationType}实物托盘`, + '/api/pda/v1/last-leg/receiving-containers/current/pallets/resolve', { palletCode: pallet.palletCode, inboundCode: inbound.inboundCode }) + context.assert(MODULE, `${inbound.destinationType}托盘目的模式匹配`, resolved.response, + success(resolved.response) && resolved.data?.compatible === true && resolved.data?.destinationType === inbound.destinationType, trace(resolved.response), OPTIONS) + } + const fbaInbounds = current.data.inbounds.filter((item) => item.destinationType === 'FBA') + const overseasInbound = current.data.inbounds.find((item) => item.destinationType === 'OVERSEAS') + if (fbaInbounds.length && fbaInbounds.every((item) => pallets.has(item.inboundCode))) { + const firstFba = fbaInbounds[0] + await expected(context, token, 'POST', 'FBA模式硬拒绝逐箱收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', { + cartonMark: context.createdResources?.inboundMeasurement?.boxCode || 'MODE-REJECT-FBA', + palletCode: pallets.get(firstFba.inboundCode).palletCode, idempotencyKey: key(context, 'fba-carton-forbidden'), + }, (response) => conflictMessage(response, /FBA.*bulk|bulk.*FBA/i)) + for (const inbound of fbaInbounds) await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode)) + } else blockerGap(context, 'FBA SKU按箱数链', '当前柜没有带真实托盘的 FBA 入仓码') + if (overseasInbound && pallets.has(overseasInbound.inboundCode)) { + const pallet = pallets.get(overseasInbound.inboundCode) + await expected(context, token, 'POST', 'OVERSEAS模式硬拒绝批量收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { + inboundCode: overseasInbound.inboundCode, skuCode: 'MODE-REJECT', palletCode: pallet.palletCode, + packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'), + }, (response) => conflictMessage(response, /OVERSEAS.*carton|bulk.*forbidden/i)) + await receiveOverseas(context, token, overseasInbound, pallet) + } else blockerGap(context, 'OVERSEAS逐箱链', '当前柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码') + const refreshed = await pda(context, token, 'GET', '回查柜内逐入仓码实收进度', '/api/pda/v1/last-leg/receiving-containers/current') + if (!success(refreshed.response) || Number(refreshed.data?.pendingPackages) !== 0) { + blockerGap(context, '完成当前柜', `柜内实收尚未精确核平;pendingPackages=${refreshed.data?.pendingPackages ?? 'missing'}`, 'receiving-incomplete', refreshed.response) + return + } + const completeBody = { idempotencyKey: key(context, 'complete') } + const completed = await pda(context, token, 'POST', '完成当前柜并写入库存与卸柜事件', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody) + if (!success(completed.response)) return + const completeReplay = await pda(context, token, 'POST', '完成当前柜同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody) + context.assert(MODULE, '完成幂等重放仍返回COMPLETED', completeReplay.response, + success(completeReplay.response) && completeReplay.data?.status === 'COMPLETED', trace(completeReplay.response), OPTIONS) + const after = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token) + context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS) context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null - - const planAfter = await overseas(context, 'GET', '回查入仓计划完成状态', - query(`/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}`, { customerId })) - context.assert(MODULE, '入仓计划与PDA任务同步完成', planAfter.response, planAfter.data?.status === 'COMPLETED', - `planStatus=${planAfter.data?.status || 'missing'}; ${trace(planAfter.response)}`, OPTIONS) - const balances = await overseas(context, 'GET', '回查PDA入仓SKU库存', - query('/api/platform/v1/last-leg/inventory/balances', { customerId, page: 1, size: 200 })) - context.assert(MODULE, 'PDA完成后生成SKU库存余额', balances.response, - records(balances.data).some((item) => Number(item?.totalPackages) > 0 && id(item?.customerId) === customerId), - `balances=${records(balances.data).length}; ${trace(balances.response)}`, OPTIONS) } diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index aefb917c2..f4e1db7f6 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' -import { parseArguments } from './admin-test-env-acceptance.mjs' +import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' const source = readFileSync(resolve('scripts/admin-test-env-acceptance.mjs'), 'utf8') @@ -33,3 +33,38 @@ test('entry accepts a separate overseas Gateway without exposing token state', ( assert.match(source, /async requestOverseasMultipart\(/) assert.match(source, /forbiddenValues = \[this\.options\.password, this\.token, this\.overseasToken\]/) }) + +test('entry has no built-in environment address or password and requires explicit execution options', () => { + assert.doesNotMatch(source, /8\.163\.|ChangeMe123|DEFAULT_PASSWORD|DEFAULT_GATEWAY/) + assert.throws(() => validateExecutionOptions(parseArguments(['--modules', 'phase4-overseas-pda'])), + /缺少必填执行参数/) + const options = parseArguments([ + '--gateway', 'http://localhost:9000', '--overseas-gateway', 'http://localhost:9300', + '--username', 'contract-user', '--password', 'contract-password', '--modules', 'phase4-overseas-pda', + ]) + assert.equal(validateExecutionOptions(options), options) +}) + +test('core coverage gaps fail closed while device and external-provider gaps remain report-only', () => { + const run = new AcceptanceRun({ gateway: 'http://localhost:9000', overseasGateway: 'http://localhost:9300', + username: 'contract-user', password: 'contract-password', modules: [], output: '' }) + run.addCoverageGap('phase4', 'core', 'fixture', 'missing fixture', 'fixture') + assert.equal(run.steps.at(-1)?.passed, false) + assert.equal(run.steps.at(-1)?.deploymentBlocker, true) + const stepCount = run.steps.length + run.addCoverageGap('phase3', 'device', 'Android', 'manual device validation', 'device') + run.addCoverageGap('phase3', 'provider', 'captcha', 'external captcha', 'external-third-party') + assert.equal(run.steps.length, stepCount) +}) + +test('PDA entry expands the real phase-three producer chain and builds an FBA/OVERSEAS/second-container matrix', () => { + const modules = parseArguments(['--modules', 'phase4-overseas-pda']).modules + for (const prerequisite of ['merchant-qualification-fixture', 'phase3-loading', 'phase3-pda-convergence', + 'phase3-evidence-gate', 'phase3-loading-finalize']) { + assert.ok(modules.indexOf(prerequisite) >= 0 && modules.indexOf(prerequisite) < modules.indexOf('phase4-overseas-pda')) + } + assert.match(source, /async ensurePhase4PdaProducerMatrix\(\)/) + assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS'\)/) + assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA'\)/) + assert.match(source, /readyForPhase4Pda === true/) +}) From 5edc2fc1bd600c7e627c0dc55f494baf0864d5b0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 20:14:39 +0800 Subject: [PATCH 056/115] fix(lastleg): restore overseas receiving build --- .../pda/service/ReceivingContainerService.java | 2 +- .../pda/ReceivingContainerContractTest.java | 7 ++++--- .../service/InboundStockReceiptServiceTest.java | 2 +- .../ReceivingContainerServiceBehaviorTest.java | 14 ++++++++++++++ 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 7262e4677..4c6682853 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -451,7 +451,7 @@ public class ReceivingContainerService { .map(ReceivingContainerRequests.SignedPhoto::role).collect(Collectors.toSet()); if (roles.size() != 3 || roles.contains(null)) throw conflict("Each unload photo role is required exactly once"); Set fileIds = request.photos().stream().map(ReceivingContainerRequests.SignedPhoto::fileId) - .boxed().collect(Collectors.toSet()); + .collect(Collectors.toSet()); if (fileIds.size() != 3 || fileIds.stream().anyMatch(value -> value == null || value <= 0)) { throw conflict("Unload photo file IDs must be positive and unique"); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java index 5736a0949..d49cf48e4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -65,8 +65,9 @@ class ReceivingContainerContractTest { assertTrue(schema.contains("create table if not exists ll_receiving_container_operation")); assertTrue(schema.contains("create table if not exists ll_receiving_sku_receipt")); assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)")); - assertTrue(schema.contains("sign_photos json")); - assertFalse(schema.contains("photo_file_ids json")); + String receivingSession = tableBlock(schema, "ll_receiving_container_session"); + assertTrue(receivingSession.contains("sign_photos json")); + assertFalse(receivingSession.contains("photo_file_ids json")); assertTrue(unified.contains("create table if not exists ll_receiving_sku_receipt")); assertTrue(unified.contains("column_name='sign_photos'")); assertTrue(schema.contains("wait_sign、signed、receiving、completed")); @@ -93,7 +94,7 @@ class ReceivingContainerContractTest { assertTrue(successResponses.stream().allMatch(line -> line.contains("content: { application/json: { schema: { $ref: '#/components/schemas/Receiving"))); assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜")); - assertTrue(receivingPaths.contains("LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN")); + assertTrue(yaml.contains("enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN]")); assertFalse(receivingPaths.contains("photoFileIds")); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index 3f1f93bb2..d856e2e58 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -134,7 +134,7 @@ class InboundStockReceiptServiceTest { ArgumentCaptor requests = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class); verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), requests.capture()); assertEquals(java.util.Set.of("SKU-1:2001", "SKU-2:2002"), requests.getAllValues().stream() - .map(value -> value.skuCode() + ":" + value.warehousePalletId()).collect(java.util.stream.Collectors.toSet())); + .map(value -> value.skuCode() + ":" + value.palletId()).collect(java.util.stream.Collectors.toSet())); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 0ebcaab05..498415847 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -197,6 +197,7 @@ class ReceivingContainerServiceBehaviorTest { @Test void localFailureAfterRemoteReservationTriggersReleaseCompensation() { stubCurrent("FBA", "CONT-A"); + stubSku(); when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, "FBA-1")); when(pallets.selectList(any())).thenReturn(List.of(planPallet())); when(workflow.assignResolvedPallet(any(), any(), any(), any())).thenReturn(55L); @@ -244,6 +245,19 @@ class ReceivingContainerServiceBehaviorTest { verifyNoInteractions(unloadPhotos); } + @Test + void signRejectsDuplicateFileIdsBeforeFileValidation() { + when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); + ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of( + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L), + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 1L), + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-files"); + + assertThatThrownBy(() -> service.sign("100", "7", "receiver", request)) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + verifyNoInteractions(unloadPhotos); + } + @Test void bulkRejectsSkuOverReceiptBeforeWarehouseLookup() { stubCurrent("FBA", "CONT-A"); From 005c3f54e22e85512604eda8da40074c33ff5b73 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 20:14:40 +0800 Subject: [PATCH 057/115] test: align full acceptance contracts --- deploy/sql/README.md | 2 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 3 +-- .../customer-system-contract.test.mjs | 2 +- .../domestic-container-contract.test.mjs | 3 ++- .../finance-contract.test.mjs | 3 ++- .../finance-extended-fees-contract.test.mjs | 5 ++-- .../initialization-baseline-contract.test.mjs | 23 ++++++++++--------- .../manual-state-matrix-contract.test.mjs | 2 +- ...nt-qualification-fixture-contract.test.mjs | 3 ++- ...e3-loading-dynamic-final-contract.test.mjs | 2 +- 10 files changed, 26 insertions(+), 22 deletions(-) diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 0946cd9df..b81a56b25 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -9,7 +9,7 @@ - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 - 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。 - 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。 -- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`,不会写入固定密码账号、演示仓库、演示快递公司或演示订单;正式平台公共仓库主数据已内联到固定入口。 +- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`;仅在开发/测试基线账号缺失时创建最小登录账号,重复执行不覆盖现有密码。脚本不写入演示订单;正式平台公共仓库主数据已内联到固定入口。 - `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。 - 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。 - IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index ccc7e9bf0..f29c7ed49 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -244,7 +244,7 @@ EXECUTE auth_password_change_required_stmt; DEALLOCATE PREPARE auth_password_change_required_stmt; -- 开发/测试环境最小登录基线:一个平台系统管理员和一个 IT 测试客户管理员。 --- 初始密码统一为 ChangeMe123!;只保存 BCrypt 哈希,不写入明文。 +-- 缺失账号首次创建时写入开发基线 BCrypt 哈希;重复执行统一增量绝不覆盖现有密码。 INSERT INTO auth_account ( id, username, password_hash, display_name, account_status, failed_login_count, token_version, password_change_required, default_tenant_id, @@ -255,7 +255,6 @@ INSERT INTO auth_account ( (1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', 'IT 测试客户管理员', 'ACTIVE', 0, 1, 0, 10001, 10001, 3000002, 'MERCHANT', 0) ON DUPLICATE KEY UPDATE - password_hash = VALUES(password_hash), display_name = VALUES(display_name), account_status = 'ACTIVE', failed_login_count = 0, diff --git a/scripts/admin-test-env-modules/customer-system-contract.test.mjs b/scripts/admin-test-env-modules/customer-system-contract.test.mjs index f1d57e6a2..64527c588 100644 --- a/scripts/admin-test-env-modules/customer-system-contract.test.mjs +++ b/scripts/admin-test-env-modules/customer-system-contract.test.mjs @@ -58,7 +58,7 @@ test('客户详情经营统计模块登记三项独立正式接口与精度字 test('后端统计契约测试覆盖客户详情已确认边界口径', () => { for (const marker of ["order_role = 'master'", 'submitted_at is not null', 'monthstart', 'nextmonthstart', 'doesNotContain\\\("status ="\\\)']) assert.match(orderStatisticsTest, new RegExp(marker, 'i')) for (const marker of ['VOIDED', 'totalReceivable', 'receivedAmount', 'outstandingReceivable']) assert.match(financeStatisticsTest, new RegExp(marker, 'i')) - for (const marker of ['actual_sailing_at', 'actual_warehouse_arrival_at', 'CANCELLED', 'FAILED', '20\\*=1', '40\\*=2', '45\\*=2.25', '2.25', 'unknown']) assert.match(transitStatisticsTest, new RegExp(marker, 'i')) + for (const marker of ['actual_sailing_at', 'actual_warehouse_arrival_at', 'CANCELLED', 'NOT EXISTS', '20\\*=1', '40\\*=2', '45\\*=2.25', '2.25', 'unknown']) assert.match(transitStatisticsTest, new RegExp(marker, 'i')) assert.match(orderStatisticsTest, /tenant_id|tenantSubmittedStatistics/) assert.match(financeStatisticsTest, /customerId|customer_id/) assert.match(transitStatisticsTest, /customerId|customer_id/) diff --git a/scripts/admin-test-env-modules/domestic-container-contract.test.mjs b/scripts/admin-test-env-modules/domestic-container-contract.test.mjs index 33561fdcc..510f78a85 100644 --- a/scripts/admin-test-env-modules/domestic-container-contract.test.mjs +++ b/scripts/admin-test-env-modules/domestic-container-contract.test.mjs @@ -16,7 +16,8 @@ test('国内整柜模块仅由总编排注入 context 后执行', () => { }) test('正式全量验收默认注册国内整柜模块', () => { - assert.match(acceptance, /DEFAULT_MODULES\s*=\s*\[[^\]]*['"]domestic-container['"]/s) + assert.match(acceptance, /CONFIGURED_MODULES\s*=\s*\[[^\]]*['"]domestic-container['"]/s) + assert.match(acceptance, /DEFAULT_MODULES\s*=\s*normalizeModuleOrder\(CONFIGURED_MODULES\)/) }) test('覆盖国内仓、待单、完成入库、部分和全部出库及列表详情流水', () => { diff --git a/scripts/admin-test-env-modules/finance-contract.test.mjs b/scripts/admin-test-env-modules/finance-contract.test.mjs index 29d60e523..8580fde43 100644 --- a/scripts/admin-test-env-modules/finance-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-contract.test.mjs @@ -9,7 +9,8 @@ const acceptance = fs.readFileSync(path.join(here, '..', 'admin-test-env-accepta const finance = fs.readFileSync(path.join(here, 'finance.mjs'), 'utf8') test('正式全量验收默认包含 Finance 模块', () => { - assert.match(acceptance, /DEFAULT_MODULES\s*=\s*\[[\s\S]*['"]finance['"]/) + assert.match(acceptance, /CONFIGURED_MODULES\s*=\s*\[[\s\S]*['"]finance['"]/) + assert.match(acceptance, /DEFAULT_MODULES\s*=\s*normalizeModuleOrder\(CONFIGURED_MODULES\)/) }) test('验收请求器支持并透传额外请求头', () => { diff --git a/scripts/admin-test-env-modules/finance-extended-fees-contract.test.mjs b/scripts/admin-test-env-modules/finance-extended-fees-contract.test.mjs index f07805750..6eb694b53 100644 --- a/scripts/admin-test-env-modules/finance-extended-fees-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-extended-fees-contract.test.mjs @@ -46,7 +46,8 @@ test('writes carry marker idempotency and include replay and stale-version confl assert.match(source, /错误 sourceVersion 调整产生并发版本冲突/) }) -test('uses existing ACTIVE Warehouse supplier and never creates one', () => { - assert.match(source, /\/api\/platform\/v1\/suppliers\?page=1&size=200&status=ACTIVE&category=BOOKING/) +test('uses the fee item supplier category to select an existing ACTIVE supplier and never creates one', () => { + assert.match(source, /\/api\/platform\/v1\/suppliers\?page=1&size=200&status=ACTIVE\$\{suffix\}/) + assert.match(source, /const supplier = await loadActiveSupplier\(context, payableItem\?\.supplierCategory \|\| null\)/) assert.doesNotMatch(source, /\/api\/platform\/v1\/partners/) }) diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 2b30d9a18..746730c2a 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -12,6 +12,10 @@ test('统一初始化 SQL 保留最小登录与客户主体', () => { assert.equal((sql.match(/'platform_admin'/g) || []).length, 1) assert.equal((sql.match(/'merchant_admin'/g) || []).length, 1) assert.match(sql, /\(10001, 'IT_TEST', 'IT 测试客户'/) + const accountSeed = sql.match(/INSERT INTO auth_account \([\s\S]*?deleted = 0;/)?.[0] + assert.ok(accountSeed, 'missing auth account seed') + assert.doesNotMatch(accountSeed, /ON DUPLICATE KEY UPDATE[\s\S]*?password_hash\s*=/, + 're-running the unified SQL must not reset an existing account password') }) test('IT 测试客户初始化后具备已审核的必填资质和下单资格', () => { @@ -34,25 +38,22 @@ test('统一初始化 SQL 提供人工验收所需公共仓储主数据', () => assert.doesNotMatch(palletSeed, /\bstatus\b|'AVAILABLE'/) }) -test('三期权限迁移允许脚本历史版本写入的旧权限并在随后收敛', () => { +test('三期权限迁移删除退役权限并只保留 canonical 权限', () => { for (const source of [sql, oceanBookingSchema]) { - assert.match(source, /id = 5102141[\s\S]*?ocean-booking:view/) - assert.match(source, /id = 5102147[\s\S]*?ocean-booking:cancel[\s\S]*?ocean-booking:edit/) - assert.match(source, /id = 5102148[\s\S]*?ocean-booking:change[\s\S]*?ocean-booking:delete/) - assert.match(source, /SET permission_code='platform:firstleg:ocean-booking:view',status='INACTIVE',deleted=1/) - assert.match(source, /UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102141/) - assert.match(source, /DELETE FROM iam_permission WHERE id IN \(5102143,5102144,5102145\)/) + assert.match(source, /DELETE FROM iam_permission WHERE id IN \(5102141,5102143,5102144,5102145\)/) + assert.match(source, /5102147[\s\S]*?platform:firstleg:ocean-booking:edit/) + assert.match(source, /5102148[\s\S]*?platform:firstleg:ocean-booking:delete/) + assert.doesNotMatch(source, /platform:firstleg:ocean-booking:(view|cancel|change)/) assert.ok( - source.indexOf('DELETE FROM iam_permission WHERE id IN (5102143,5102144,5102145)') < + source.indexOf('DELETE FROM iam_permission WHERE id IN (5102141,5102143,5102144,5102145)') < source.indexOf('(5102147,0,\'PLATFORM\',\'BUTTON\',5102140,\'platform:firstleg:ocean-booking:edit\''), 'legacy ocean-booking edit permission must be removed before assigning the code to 5102147', ) } for (const source of [sql, truckAppointmentSchema]) { - assert.match(source, /id = 5102151[\s\S]*?truck-appointment:view/) - assert.match(source, /SET permission_code='platform:firstleg:truck-appointment:view',status='INACTIVE',deleted=1/) - assert.match(source, /UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102151/) + assert.match(source, /DELETE FROM iam_permission WHERE id=5102151/) + assert.doesNotMatch(source, /platform:firstleg:truck-appointment:view[',]/) } assert.match(sql, /status='ACTIVE' AND deleted=0[\s\S]*?id BETWEEN 5102140 AND 5102149/) diff --git a/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs index 096592364..5f7455aa8 100644 --- a/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs @@ -35,9 +35,9 @@ test('focused splitting and manual groups resolve producers before consumers wit 'merchant-qualification-fixture', 'phase3-container-loading', 'phase3-loading', - 'phase3-order-splitting', 'ocean-bookings', 'phase3-loading-finalize', + 'phase3-order-splitting', 'phase3-customs', 'manual-state-matrix', ] diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index 4da2e138d..8d080ea93 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -6,9 +6,10 @@ const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', impo const fixture = readFileSync(new URL('./merchant-qualification-fixture.mjs', import.meta.url), 'utf8') test('默认全量验收先准备已审核商户再运行订单链路', () => { - const modules = acceptance.match(/const DEFAULT_MODULES = \[([^\n]+)\]/)?.[1] || '' + const modules = acceptance.match(/const CONFIGURED_MODULES = \[([^\n]+)\]/)?.[1] || '' assert.match(modules, /^'merchant-qualification-fixture', 'customers'/) assert.ok(modules.indexOf("'merchant-qualification-fixture'") < modules.indexOf("'orders'")) + assert.match(acceptance, /DEFAULT_MODULES\s*=\s*normalizeModuleOrder\(CONFIGURED_MODULES\)/) }) test('商户夹具通过正式 File 和 IAM 审核流程建立下单资格', () => { diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index c48a31309..8e604f641 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -41,7 +41,7 @@ test('OpenAPI freezes LOADING incremental plan capabilities and optimistic versi }) test('default fixture publishes a second marker-owned inbound for the same-plan increment', () => { - assert.match(entry, /const incrementalMarker = `\$\{originalMarker\}-LOAD-INC`/) + assert.match(entry, /const incrementalMarker = `\$\{originalMarker\}-LOAD\$\{suffix\}-INC`/) assert.match(entry, /orders-phase3-loading-incremental-fixture/) assert.match(entry, /inbound-measurement-phase3-loading-incremental-fixture/) assert.match(entry, /phase3LoadingIncrementalPackageCount = 2/) From 3607bebbfd85b05b6e7c9a4d68896f0b61025e7f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 21:00:22 +0800 Subject: [PATCH 058/115] fix: close phase4 overseas acceptance gaps --- deploy/sql/头程数据库统一增量-MySQL8.sql | 40 +++++- deploy/sql/海外区域统一增量-MySQL8.sql | 64 ++++++++- .../gateway/config/GatewaySecurityConfig.java | 8 +- .../filter/CorrelationIdWebFilter.java | 52 ++++++++ .../GatewaySecurityErrorContractTest.java | 30 +++++ .../main/resources/db/lastleg-permissions.sql | 39 +++++- .../resources/db/phase3-warehouse-roles.sql | 1 + ...erseasPhase4PermissionSqlContractTest.java | 20 +++ .../WarehouseRoleSqlContractTest.java | 1 + lastleg-service/AGENTS.md | 1 + .../ReceivingContainerController.java | 35 ++--- .../ReceivingContainerOperationMapper.java | 7 +- .../ReceivingContainerSessionMapper.java | 21 ++- .../service/ReceivingContainerService.java | 126 +++++++++++------- .../inventory/entity/InventoryLedger.java | 4 + .../entity/OverseasProcurementOrder.java | 22 +-- .../src/main/resources/db/lastleg.sql | 8 +- .../openapi/overseas-business-v1.yaml | 6 +- .../pda/ReceivingContainerContractTest.java | 12 +- .../ReceivingContainerControllerTest.java | 29 ++-- ...ReceivingContainerServiceBehaviorTest.java | 98 ++++++++++---- .../InventorySchemaContractTest.java | 10 +- .../OverseasProcurementServiceTest.java | 14 ++ scripts/admin-test-env-acceptance.mjs | 58 +++++++- scripts/admin-test-env-modules/orders.mjs | 3 +- .../phase3-loading-marker-contract.test.mjs | 15 +++ .../admin-test-env-modules/phase3-loading.mjs | 9 +- .../phase3-warehouse-rbac.mjs | 7 +- ...ase4-cross-region-events-contract.test.mjs | 19 +++ .../phase4-overseas-pda-contract.test.mjs | 48 +++++-- .../phase4-overseas-pda.mjs | 94 ++++++++++--- ...hase4-overseas-warehouse-contract.test.mjs | 18 ++- .../phase4-overseas-warehouse.mjs | 24 +++- .../MerchantWarehouseController.java | 2 +- .../PlatformWarehouseController.java | 3 +- .../warehouse/dto/WarehouseRequests.java | 3 + .../repository/WarehouseRepository.java | 2 + .../MerchantWarehouseControllerTest.java | 36 +++++ .../PlatformWarehouseControllerTest.java | 23 +++- .../repository/WarehouseRepositoryTest.java | 42 ++++++ .../WarehouseApplicationServiceTest.java | 4 +- ...house-public-master-data-contract.test.mjs | 5 +- 42 files changed, 860 insertions(+), 203 deletions(-) create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java create mode 100644 gateway-overseas/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorContractTest.java create mode 100644 scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/MerchantWarehouseControllerTest.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/repository/WarehouseRepositoryTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index f29c7ed49..9e282cfd7 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -10908,6 +10908,7 @@ DELETE role_permission WHERE role.tenant_id = 0 AND role.app_code = 'PLATFORM' AND role.role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY') + AND NOT (role.role_code = 'WAREHOUSE_PICKER' AND permission.permission_code = 'lastleg:pda:operate') AND expected.permission_code IS NULL; INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) @@ -16265,10 +16266,47 @@ INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permissio SELECT 0, role.id, permission.id, NOW(3) FROM baiyue_iam.iam_role role JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' - AND permission.id BETWEEN 5107000 AND 5107299 + AND permission.id BETWEEN 5107000 AND 5107299 AND permission.permission_code<>'lastleg:pda:operate' WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; +DELETE role_permission FROM baiyue_iam.iam_role_permission role_permission +JOIN baiyue_iam.iam_role role ON role.id=role_permission.role_id AND role.tenant_id=role_permission.tenant_id +JOIN baiyue_iam.iam_permission permission ON permission.id=role_permission.permission_id AND permission.tenant_id=role_permission.tenant_id +WHERE permission.permission_code='lastleg:pda:operate' + AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER'); + +INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM baiyue_iam.iam_role role +JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code='lastleg:pda:operate' +WHERE role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + +DROP PROCEDURE IF EXISTS baiyue_iam.assert_overseas_pda_picker_permission; +DELIMITER $$ +CREATE PROCEDURE baiyue_iam.assert_overseas_pda_picker_permission() +BEGIN + IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission grant_row + JOIN baiyue_iam.iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id + JOIN baiyue_iam.iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id + WHERE permission.permission_code='lastleg:pda:operate' + AND role.id=4000005 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:WAREHOUSE_PICKER未精确授权'; + END IF; + IF EXISTS (SELECT 1 FROM baiyue_iam.iam_role_permission grant_row + JOIN baiyue_iam.iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id + JOIN baiyue_iam.iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id + WHERE permission.permission_code='lastleg:pda:operate' + AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:存在非仓库拣货员授权'; + END IF; +END$$ +DELIMITER ; +CALL baiyue_iam.assert_overseas_pda_picker_permission(); +DROP PROCEDURE baiyue_iam.assert_overseas_pda_picker_permission; + INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, role.id, permission.id, NOW(3) FROM baiyue_iam.iam_role role diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 3c07f7500..c02a02b7f 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -387,9 +387,9 @@ SET l.destination_type=COALESCE(NULLIF(o.destination_type,''),CASE WHEN NULLIF(l WHERE l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS'; CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( - id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', - operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话账号唯一锁键', - active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', + id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键', + active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间', receiving_at DATETIME(3) 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 NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id), @@ -405,6 +405,31 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 'ALTER TABLE ll_receiving_container_session DROP COLUMN photo_file_ids', 'SELECT 1')); PREPARE phase4_receiving_drop_raw_photos_stmt FROM @ddl; EXECUTE phase4_receiving_drop_raw_photos_stmt; DEALLOCATE PREPARE phase4_receiving_drop_raw_photos_stmt; +DROP PROCEDURE IF EXISTS assert_receiving_global_locks_migratable; +DELIMITER $$ +CREATE PROCEDURE assert_receiving_global_locks_migratable() +BEGIN + IF EXISTS (SELECT operator_key FROM ll_receiving_container_session + WHERE active_operator_key IS NOT NULL GROUP BY operator_key HAVING COUNT(*) > 1) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局账号锁迁移失败:同一账号存在多个未完成柜'; + END IF; + IF EXISTS (SELECT UPPER(TRIM(container_no)) FROM ll_receiving_container_session + WHERE active_container_key IS NOT NULL GROUP BY UPPER(TRIM(container_no)) HAVING COUNT(*) > 1) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局柜号锁迁移失败:同一柜存在多个未完成会话'; + END IF; +END$$ +DELIMITER ; +CALL assert_receiving_global_locks_migratable(); +DROP PROCEDURE assert_receiving_global_locks_migratable; +UPDATE ll_receiving_container_session +SET active_operator_key=CONCAT('OPERATOR:',operator_key), + active_container_key=CONCAT('CONTAINER:',UPPER(TRIM(container_no))) +WHERE active_operator_key IS NOT NULL OR active_container_key IS NOT NULL; +ALTER TABLE ll_receiving_container_session + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', + MODIFY COLUMN active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键', + MODIFY COLUMN active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键'; + CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt ( id BIGINT NOT NULL COMMENT 'FBA SKU实收进度ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', plan_id BIGINT NOT NULL COMMENT '入仓计划ID', @@ -424,8 +449,39 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_operation ( operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), - UNIQUE KEY uk_ll_receiving_operation(customer_id,operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at) + UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at) ) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录'; +DROP PROCEDURE IF EXISTS assert_receiving_operation_global_key_migratable; +DELIMITER $$ +CREATE PROCEDURE assert_receiving_operation_global_key_migratable() +BEGIN + IF EXISTS (SELECT operator_key,idempotency_key FROM ll_receiving_container_operation + GROUP BY operator_key,idempotency_key HAVING COUNT(*) > 1) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局幂等键迁移失败:同一操作账号存在重复幂等键'; + END IF; +END$$ +DELIMITER ; +CALL assert_receiving_operation_global_key_migratable(); +DROP PROCEDURE assert_receiving_operation_global_key_migratable; +SET @receiving_operation_global_index = ( + SELECT COUNT(*) FROM ( + SELECT index_name FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_operation' + AND index_name='uk_ll_receiving_operation' + GROUP BY index_name + HAVING GROUP_CONCAT(column_name ORDER BY seq_in_index)= 'operator_key,idempotency_key' + ) ready_index +); +SET @receiving_operation_named_index = ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_operation' + AND index_name='uk_ll_receiving_operation' +); +SET @ddl = IF(@receiving_operation_global_index=1, 'SELECT 1', + IF(@receiving_operation_named_index>0, + 'ALTER TABLE ll_receiving_container_operation DROP INDEX uk_ll_receiving_operation, ADD UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key)', + 'ALTER TABLE ll_receiving_container_operation ADD UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key)')); +PREPARE phase4_receiving_operation_index_stmt FROM @ddl; EXECUTE phase4_receiving_operation_index_stmt; DEALLOCATE PREPARE phase4_receiving_operation_index_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', 'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name')); diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java index d75af294b..d82f31a65 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java @@ -1,5 +1,6 @@ package com.baiyue.gateway.config; +import com.baiyue.gateway.filter.CorrelationIdWebFilter; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -66,11 +67,16 @@ public class GatewaySecurityConfig { ObjectMapper mapper, HttpStatus status, String code, String message) { exchange.getResponse().setStatusCode(status); exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); + String requestId = CorrelationIdWebFilter.ensureRequestId(exchange); + String traceId = CorrelationIdWebFilter.ensureTraceId(exchange); + exchange.getResponse().getHeaders().set(CorrelationIdWebFilter.REQUEST_HEADER, requestId); + exchange.getResponse().getHeaders().set(CorrelationIdWebFilter.TRACE_HEADER, traceId); Map body = new LinkedHashMap<>(); body.put("code", code); body.put("message", message); body.put("data", null); - body.put("requestId", exchange.getRequest().getId()); + body.put("requestId", requestId); + body.put("traceId", traceId); body.put("timestamp", Instant.now().toString()); try { byte[] bytes = mapper.writeValueAsBytes(body); diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java new file mode 100644 index 000000000..f25941cec --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java @@ -0,0 +1,52 @@ +package com.baiyue.gateway.filter; + +import org.springframework.core.Ordered; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +import java.util.UUID; + +/** Ensures overseas security failures carry the same correlation contract as normal API responses. */ +@Component +public class CorrelationIdWebFilter implements WebFilter, Ordered { + public static final String REQUEST_HEADER = "X-Request-Id"; + public static final String TRACE_HEADER = "X-Trace-Id"; + private static final String REQUEST_ATTRIBUTE = CorrelationIdWebFilter.class.getName() + ".requestId"; + private static final String TRACE_ATTRIBUTE = CorrelationIdWebFilter.class.getName() + ".traceId"; + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + exchange.getResponse().getHeaders().set(REQUEST_HEADER, ensureRequestId(exchange)); + exchange.getResponse().getHeaders().set(TRACE_HEADER, ensureTraceId(exchange)); + return chain.filter(exchange); + } + + public static String ensureRequestId(ServerWebExchange exchange) { + return (String) exchange.getAttributes().computeIfAbsent(REQUEST_ATTRIBUTE, + ignored -> accepted(exchange.getRequest().getHeaders(), REQUEST_HEADER)); + } + + public static String ensureTraceId(ServerWebExchange exchange) { + return (String) exchange.getAttributes().computeIfAbsent(TRACE_ATTRIBUTE, + ignored -> accepted(exchange.getRequest().getHeaders(), TRACE_HEADER)); + } + + private static String accepted(HttpHeaders headers, String name) { + String incoming = headers.getFirst(name); + return valid(incoming) ? incoming : UUID.randomUUID().toString(); + } + + private static boolean valid(String value) { + return value != null && !value.isBlank() && value.length() >= 16 && value.length() <= 128 + && value.chars().allMatch(character -> character >= 0x21 && character <= 0x7e); + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE; + } +} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorContractTest.java new file mode 100644 index 000000000..6b1cea992 --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorContractTest.java @@ -0,0 +1,30 @@ +package com.baiyue.gateway.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class GatewaySecurityErrorContractTest { + @Test + void audienceAndAuthenticationFailuresExposeRequestAndTraceCorrelation() throws Exception { + var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/api/platform/v1/test") + .header("X-Request-Id", "request-1234567890") + .header("X-Trace-Id", "trace-123456789012") + .build()); + + GatewaySecurityConfig.writeError(exchange, new ObjectMapper(), HttpStatus.UNAUTHORIZED, + "A0401", "未登录或令牌已失效").block(); + + var body = new ObjectMapper().readTree(exchange.getResponse().getBodyAsString().block()); + assertEquals("request-1234567890", body.path("requestId").asText()); + assertEquals("trace-123456789012", body.path("traceId").asText()); + assertEquals(body.path("requestId").asText(), exchange.getResponse().getHeaders().getFirst("X-Request-Id")); + assertEquals(body.path("traceId").asText(), exchange.getResponse().getHeaders().getFirst("X-Trace-Id")); + assertFalse(body.path("timestamp").asText().isBlank()); + } +} diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index f8aebcd23..cf6287b13 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -72,10 +72,47 @@ INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, creat SELECT 0, role.id, permission.id, NOW(3) FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' - AND permission.id BETWEEN 5107000 AND 5107299 + AND permission.id BETWEEN 5107000 AND 5107299 AND permission.permission_code<>'lastleg:pda:operate' WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; +DELETE role_permission FROM iam_role_permission role_permission +JOIN iam_role role ON role.id=role_permission.role_id AND role.tenant_id=role_permission.tenant_id +JOIN iam_permission permission ON permission.id=role_permission.permission_id AND permission.tenant_id=role_permission.tenant_id +WHERE permission.permission_code='lastleg:pda:operate' + AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER'); + +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM iam_role role +JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code='lastleg:pda:operate' +WHERE role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + +DROP PROCEDURE IF EXISTS assert_overseas_pda_picker_permission; +DELIMITER $$ +CREATE PROCEDURE assert_overseas_pda_picker_permission() +BEGIN + IF (SELECT COUNT(*) FROM iam_role_permission grant_row + JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id + JOIN iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id + WHERE permission.permission_code='lastleg:pda:operate' + AND role.id=4000005 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:WAREHOUSE_PICKER未精确授权'; + END IF; + IF EXISTS (SELECT 1 FROM iam_role_permission grant_row + JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id + JOIN iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id + WHERE permission.permission_code='lastleg:pda:operate' + AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:存在非仓库拣货员授权'; + END IF; +END$$ +DELIMITER ; +CALL assert_overseas_pda_picker_permission(); +DROP PROCEDURE assert_overseas_pda_picker_permission; + INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, role.id, permission.id, NOW(3) FROM iam_role role diff --git a/iam/src/main/resources/db/phase3-warehouse-roles.sql b/iam/src/main/resources/db/phase3-warehouse-roles.sql index 75bf068e1..5f39d36cf 100644 --- a/iam/src/main/resources/db/phase3-warehouse-roles.sql +++ b/iam/src/main/resources/db/phase3-warehouse-roles.sql @@ -140,6 +140,7 @@ DELETE role_permission WHERE role.tenant_id = 0 AND role.app_code = 'PLATFORM' AND role.role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY') + AND NOT (role.role_code = 'WAREHOUSE_PICKER' AND permission.permission_code = 'lastleg:pda:operate') AND expected.permission_code IS NULL; INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index 01b84837d..776ec6854 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -40,6 +40,7 @@ class OverseasPhase4PermissionSqlContractTest { Set catalog = codes(sql); assertThat(sql).contains(REQUIRED.toArray(String[]::new)); assertThat(sql).contains("'lastleg:pda:operate'"); + assertPdaPickerGrant(sql); assertThat(catalog).hasSize(53).noneMatch(code -> code.endsWith(":view")); assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1"); assertThat(sql).doesNotContain(":carton:", ":product:", ":outbound:", ":delivery:"); @@ -55,6 +56,7 @@ class OverseasPhase4PermissionSqlContractTest { .getContentAsString(StandardCharsets.UTF_8)); assertThat(codes(sql)).containsAll(moduleCatalog); assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission", "'lastleg:pda:operate'"); + assertPdaPickerGrant(sql); assertCustomAdminGrant(sql); } @@ -89,6 +91,24 @@ class OverseasPhase4PermissionSqlContractTest { .containsExactlyInAnyOrderElementsOf(CUSTOM_ADMIN_PERMISSIONS); } + private static void assertPdaPickerGrant(String sql) { + assertThat(sql).contains( + "permission.permission_code<>'lastleg:pda:operate'", + "role.id=4000005", + "assert_overseas_pda_picker_permission", + "存在非仓库拣货员授权", + "role.role_code='WAREHOUSE_PICKER'", + "permission.permission_code='lastleg:pda:operate'", + "role.role_code='PLATFORM_ADMIN'", + "DELETE role_permission FROM"); + int picker = sql.indexOf("role.role_code='WAREHOUSE_PICKER'"); + int grantStart = sql.lastIndexOf("INSERT IGNORE INTO", picker); + int grantEnd = sql.indexOf(';', picker); + assertThat(grantStart).isGreaterThanOrEqualTo(0); + assertThat(grantEnd).isGreaterThan(picker); + assertThat(sql.substring(grantStart, grantEnd)).contains("lastleg:pda:operate"); + } + private static String repositoryFile(String relativePath) throws Exception { Path path = Path.of(relativePath); if (!Files.exists(path)) path = Path.of("..").resolve(relativePath); diff --git a/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java index 9cb8406a9..36786af3e 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java @@ -45,6 +45,7 @@ class WarehouseRoleSqlContractTest { .contains("依赖的三期平台权限尚未完整部署") .contains("DELETE role_permission") .contains("expected.permission_code IS NULL") + .contains("role.role_code = 'WAREHOUSE_PICKER' AND permission.permission_code = 'lastleg:pda:operate'") .contains("最小权限授权未完整写入") .contains("grant_row.tenant_id = 0) <> 19") .contains("(6000004, 4000004, 0, 'TENANT', NULL)") diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index b6d2cc413..99b6a8795 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -73,6 +73,7 @@ - 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 - 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 - PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。 +- 海外 PDA 柜号签收只允许平台 `WAREHOUSE_PICKER` 使用 `lastleg:pda:operate`;PDA 不接收 `customerId`,候选跨客户读取,选柜时从柜内计划唯一解析并冻结真实客户。未完成会话按平台操作账号和柜号分别全局唯一,后续照片、扫码、实收、库存与完成操作只使用会话客户范围;平台管理员和客户账号不得进入该作业入口。 ## 查询与租户约束 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java index fa2816d4f..ce144e37c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -34,11 +34,11 @@ public class ReceivingContainerController { @Resource private ReceivingContainerService service; @GetMapping("/candidates") - @Operation(summary = "List pending receiving containers for the authenticated customer") + @Operation(summary = "List pending receiving containers across customers for the authenticated warehouse picker") public ApiResponse> candidates( @AuthenticationPrincipal AuthenticatedUser user) { Identity identity = identity(user); - return ApiResponse.success(service.candidates(identity.customerId(), identity.operatorKey())); + return ApiResponse.success(service.candidates(identity.operatorKey())); } @GetMapping("/{containerNo}") @@ -46,14 +46,14 @@ public class ReceivingContainerController { public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String containerNo) { Identity identity = identity(user); - return ApiResponse.success(service.detail(identity.customerId(), identity.operatorKey(), containerNo)); + return ApiResponse.success(service.detail(identity.operatorKey(), containerNo)); } @PostMapping("/{containerNo}/prepare") public ApiResponse prepare(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) { Identity identity = identity(user); - return ApiResponse.success(service.prepare(identity.customerId(), identity.operatorKey(), identity.operatorName(), + return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(), containerNo, request)); } @@ -62,20 +62,20 @@ public class ReceivingContainerController { @RequestPart("file") MultipartFile file) { Identity identity = identity(user); if (user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "PDA user ID is required"); - return ApiResponse.success(service.uploadPhoto(identity.customerId(), identity.operatorKey(), user.userId(), file)); + return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file)); } @PostMapping("/current/sign") public ApiResponse sign(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.Sign request) { Identity identity = identity(user); - return ApiResponse.success(service.sign(identity.customerId(), identity.operatorKey(), identity.operatorName(), request)); + return ApiResponse.success(service.sign(identity.operatorKey(), identity.operatorName(), request)); } @GetMapping("/current") public ApiResponse current(@AuthenticationPrincipal AuthenticatedUser user) { Identity identity = identity(user); - return ApiResponse.success(service.current(identity.customerId(), identity.operatorKey())); + return ApiResponse.success(service.current(identity.operatorKey())); } @PostMapping("/current/cartons/resolve") @@ -83,7 +83,7 @@ public class ReceivingContainerController { @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.CartonResolve request) { Identity identity = identity(user); - return ApiResponse.success(service.resolveCarton(identity.customerId(), identity.operatorKey(), request)); + return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request)); } @PostMapping("/current/skus/resolve") @@ -91,7 +91,7 @@ public class ReceivingContainerController { @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.SkuResolve request) { Identity identity = identity(user); - return ApiResponse.success(service.resolveSku(identity.customerId(), identity.operatorKey(), request)); + return ApiResponse.success(service.resolveSku(identity.operatorKey(), request)); } @PostMapping("/current/pallets/resolve") @@ -99,41 +99,42 @@ public class ReceivingContainerController { @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.PalletResolve request) { Identity identity = identity(user); - return ApiResponse.success(service.resolvePallet(identity.customerId(), identity.operatorKey(), request)); + return ApiResponse.success(service.resolvePallet(identity.operatorKey(), request)); } @PostMapping("/current/receipts/bulk") public ApiResponse receiveBulk(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.BulkReceive request) { Identity identity = identity(user); - return ApiResponse.success(service.receiveBulk(identity.customerId(), identity.operatorKey(), identity.operatorName(), request)); + return ApiResponse.success(service.receiveBulk(identity.operatorKey(), identity.operatorName(), request)); } @PostMapping("/current/receipts/carton") public ApiResponse receiveCarton(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.CartonReceive request) { Identity identity = identity(user); - return ApiResponse.success(service.receiveCarton(identity.customerId(), identity.operatorKey(), identity.operatorName(), request)); + return ApiResponse.success(service.receiveCarton(identity.operatorKey(), identity.operatorName(), request)); } @PostMapping("/current/complete") public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.Action request) { Identity identity = identity(user); - return ApiResponse.success(service.complete(identity.customerId(), identity.operatorKey(), request)); + return ApiResponse.success(service.complete(identity.operatorKey(), request)); } private static Identity identity(AuthenticatedUser user) { - if (user == null || user.tenantId() == null || user.tenantId() <= 0 || user.userId() == null) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated PDA customer identity is required"); + if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0 + || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated platform warehouse PDA identity is required"); } if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required"); } String operator = String.valueOf(user.userId()); - return new Identity(String.valueOf(user.tenantId()), operator, + return new Identity(operator, user.username() == null || user.username().isBlank() ? operator : user.username()); } - private record Identity(String customerId, String operatorKey, String operatorName) { } + private record Identity(String operatorKey, String operatorName) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java index c07beb72f..ca291794b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java @@ -8,9 +8,8 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface ReceivingContainerOperationMapper extends BaseMapper { - @Select("SELECT * FROM ll_receiving_container_operation WHERE customer_id=#{customerId} " + - "AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1") - ReceivingContainerOperation find(@Param("customerId") String customerId, - @Param("operatorKey") String operatorKey, + @Select("SELECT * FROM ll_receiving_container_operation " + + "WHERE operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1") + ReceivingContainerOperation find(@Param("operatorKey") String operatorKey, @Param("key") String key); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java index 49485e0f6..b50e7eabf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java @@ -8,18 +8,15 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface ReceivingContainerSessionMapper extends BaseMapper { - @Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " + - "AND active_operator_key=#{activeOperatorKey} LIMIT 1 FOR UPDATE") - ReceivingContainerSession lockActiveByOperator(@Param("customerId") String customerId, - @Param("activeOperatorKey") String activeOperatorKey); + @Select("SELECT * FROM ll_receiving_container_session " + + "WHERE active_operator_key=#{activeOperatorKey} LIMIT 1 FOR UPDATE") + ReceivingContainerSession lockActiveByOperator(@Param("activeOperatorKey") String activeOperatorKey); - @Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " + - "AND active_operator_key=#{activeOperatorKey} LIMIT 1") - ReceivingContainerSession findActiveByOperator(@Param("customerId") String customerId, - @Param("activeOperatorKey") String activeOperatorKey); + @Select("SELECT * FROM ll_receiving_container_session " + + "WHERE active_operator_key=#{activeOperatorKey} LIMIT 1") + ReceivingContainerSession findActiveByOperator(@Param("activeOperatorKey") String activeOperatorKey); - @Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " + - "AND active_container_key=#{activeContainerKey} LIMIT 1 FOR UPDATE") - ReceivingContainerSession lockActiveByContainer(@Param("customerId") String customerId, - @Param("activeContainerKey") String activeContainerKey); + @Select("SELECT * FROM ll_receiving_container_session " + + "WHERE active_container_key=#{activeContainerKey} LIMIT 1 FOR UPDATE") + ReceivingContainerSession lockActiveByContainer(@Param("activeContainerKey") String activeContainerKey); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 4c6682853..1f616904a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -80,16 +80,17 @@ public class ReceivingContainerService { .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); } - public List candidates(String customerId, String operatorKey) { - ReceivingContainerSession current = sessions.findActiveByOperator(customerId, activeOperatorKey(customerId, operatorKey)); + public List candidates(String operatorKey) { + ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); Map> grouped = new LinkedHashMap<>(); - plans.selectList(new LambdaQueryWrapper().eq(InboundPlan::getCustomerId, customerId) + plans.selectList(new LambdaQueryWrapper() .ne(InboundPlan::getStatus, "COMPLETED").orderByAsc(InboundPlan::getCreatedAt)).forEach(plan -> { if (plan.getContainerNo() != null && !plan.getContainerNo().isBlank()) { grouped.computeIfAbsent(plan.getContainerNo(), ignored -> new ArrayList<>()).add(plan); } }); return grouped.entrySet().stream().map(entry -> { + requireSingleCustomer(entry.getValue(), entry.getKey()); Totals totals = totals(entry.getValue()); boolean active = current != null && entry.getKey().equals(current.getContainerNo()); return new ReceivingContainerResponses.Candidate(entry.getKey(), active ? current.getStatus() : "WAIT_SIGN", @@ -98,9 +99,10 @@ public class ReceivingContainerService { }).toList(); } - public ReceivingContainerResponses.Session detail(String customerId, String operatorKey, String containerNo) { - List containerPlans = requirePlans(customerId, containerNo); - ReceivingContainerSession current = sessions.findActiveByOperator(customerId, activeOperatorKey(customerId, operatorKey)); + public ReceivingContainerResponses.Session detail(String operatorKey, String containerNo) { + List containerPlans = requirePlans(containerNo); + String customerId = requireSingleCustomer(containerPlans, containerNo); + ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); if (current != null && !containerNo.equals(current.getContainerNo())) { throw conflict("Current account is locked to another selected container"); } @@ -109,27 +111,28 @@ public class ReceivingContainerService { } @Transactional - public ReceivingContainerResponses.Session prepare(String customerId, String operatorKey, String operatorName, - String containerNo, ReceivingContainerRequests.Action request) { + public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, String containerNo, + ReceivingContainerRequests.Action request) { PrepareCommand command = new PrepareCommand(containerNo, request.idempotencyKey()); - ReceivingContainerOperation replay = replay(customerId, operatorKey, "PREPARE", request.idempotencyKey(), command); + ReceivingContainerOperation replay = replay(operatorKey, "PREPARE", request.idempotencyKey(), command); if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); - List containerPlans = requirePlans(customerId, containerNo); - String activeOperatorKey = activeOperatorKey(customerId, operatorKey); - ReceivingContainerSession active = sessions.lockActiveByOperator(customerId, activeOperatorKey); + List containerPlans = requirePlans(containerNo); + String customerId = requireSingleCustomer(containerPlans, containerNo); + String activeOperatorKey = activeOperatorKey(operatorKey); + ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey); if (active != null) { if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one"); ReceivingContainerResponses.Session result = snapshot(active, containerPlans); save(active, "PREPARE", request.idempotencyKey(), command, result); return result; } - if (sessions.lockActiveByContainer(customerId, activeContainerKey(customerId, containerNo)) != null) { + if (sessions.lockActiveByContainer(activeContainerKey(containerNo)) != null) { throw conflict("Container is already signed or prepared by another account"); } LocalDateTime now = LocalDateTime.now(); ReceivingContainerSession session = new ReceivingContainerSession(); session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo); session.setOperatorKey(operatorKey); session.setOperatorName(operatorName); - session.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(customerId, containerNo)); + session.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(containerNo)); session.setStatus("WAIT_SIGN"); session.setPreparedAt(now); session.setCreatedAt(now); session.setUpdatedAt(now); session.setVersion(0); try { sessions.insert(session); } @@ -138,20 +141,21 @@ public class ReceivingContainerService { save(session, "PREPARE", request.idempotencyKey(), command, result); return result; } - public ReceivingContainerResponses.Photo uploadPhoto(String customerId, String operatorKey, long operatorId, - MultipartFile file) { - ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true); + public ReceivingContainerResponses.Photo uploadPhoto(String operatorKey, long operatorId, MultipartFile file) { + ReceivingContainerSession session = requireCurrent(operatorKey, true); + String customerId = session.getCustomerId(); if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Unload photos can only be uploaded before signing"); UnloadPhotoFileGateway.UploadedFile uploaded = unloadPhotos.upload(customerId, session.getId(), operatorId, file); return new ReceivingContainerResponses.Photo(uploaded.fileId(), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes()); } @Transactional - public ReceivingContainerResponses.Session sign(String customerId, String operatorKey, String operatorName, + public ReceivingContainerResponses.Session sign(String operatorKey, String operatorName, ReceivingContainerRequests.Sign request) { - ReceivingContainerOperation replay = replay(customerId, operatorKey, "SIGN", request.idempotencyKey(), request); + ReceivingContainerOperation replay = replay(operatorKey, "SIGN", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); - ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true); + ReceivingContainerSession session = requireCurrent(operatorKey, true); + String customerId = session.getCustomerId(); if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed"); List photos = validateSignPhotos(request); List photoFileIds = photos.stream().map(ReceivingContainerResponses.SignedPhoto::fileId).toList(); @@ -166,14 +170,15 @@ public class ReceivingContainerService { save(session, "SIGN", request.idempotencyKey(), request, result); return result; } - public ReceivingContainerResponses.Session current(String customerId, String operatorKey) { - ReceivingContainerSession session = requireCurrent(customerId, operatorKey, false); - return snapshot(session, requirePlans(customerId, session.getContainerNo())); + public ReceivingContainerResponses.Session current(String operatorKey) { + ReceivingContainerSession session = requireCurrent(operatorKey, false); + return snapshot(session, requirePlans(session.getCustomerId(), session.getContainerNo())); } - public ReceivingContainerResponses.CartonResolved resolveCarton(String customerId, String operatorKey, + public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey, ReceivingContainerRequests.CartonResolve request) { - ReceivingContainerSession session = requireSigned(customerId, operatorKey); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark())); if (carton == null) throw notFound("Carton mark was not found"); @@ -190,9 +195,10 @@ public class ReceivingContainerService { inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages()); } - public ReceivingContainerResponses.SkuResolved resolveSku(String customerId, String operatorKey, + public ReceivingContainerResponses.SkuResolved resolveSku(String operatorKey, ReceivingContainerRequests.SkuResolve request) { - ReceivingContainerSession session = requireSigned(customerId, operatorKey); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); List matches = refs(customerId, session.getContainerNo()).stream() .filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))) .map(ref -> skuRef(ref, request.skuCode())) @@ -203,9 +209,10 @@ public class ReceivingContainerService { return skuResponse(matches.get(0)); } - public ReceivingContainerResponses.PalletResolved resolvePallet(String customerId, String operatorKey, + public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey, ReceivingContainerRequests.PalletResolve request) { - ReceivingContainerSession session = requireSigned(customerId, operatorKey); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(request.palletCode()); validatePallet(ref, pallet); @@ -214,11 +221,12 @@ public class ReceivingContainerService { } @Transactional - public ReceivingContainerResponses.Receipt receiveBulk(String customerId, String operatorKey, String operatorName, + public ReceivingContainerResponses.Receipt receiveBulk(String operatorKey, String operatorName, ReceivingContainerRequests.BulkReceive request) { - ReceivingContainerOperation replay = replay(customerId, operatorKey, "BULK_RECEIVE", request.idempotencyKey(), request); + ReceivingContainerOperation replay = replay(operatorKey, "BULK_RECEIVE", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); - ReceivingContainerSession session = requireSigned(customerId, operatorKey); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); if (!"FBA".equals(destinationType(ref.line(), ref.order()))) { throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden"); @@ -248,11 +256,12 @@ public class ReceivingContainerService { } @Transactional - public ReceivingContainerResponses.Receipt receiveCarton(String customerId, String operatorKey, String operatorName, + public ReceivingContainerResponses.Receipt receiveCarton(String operatorKey, String operatorName, ReceivingContainerRequests.CartonReceive request) { - ReceivingContainerOperation replay = replay(customerId, operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request); + ReceivingContainerOperation replay = replay(operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); - ReceivingContainerSession session = requireSigned(customerId, operatorKey); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark())); if (carton == null) throw notFound("Carton mark was not found"); @@ -284,11 +293,12 @@ public class ReceivingContainerService { } @Transactional - public ReceivingContainerResponses.Session complete(String customerId, String operatorKey, + public ReceivingContainerResponses.Session complete(String operatorKey, ReceivingContainerRequests.Action request) { - ReceivingContainerOperation replay = replay(customerId, operatorKey, "COMPLETE", request.idempotencyKey(), request); + ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); - ReceivingContainerSession session = requireSigned(customerId, operatorKey); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); List containerPlans = requirePlans(customerId, session.getContainerNo()); Totals totals = totals(containerPlans); if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container"); @@ -301,22 +311,33 @@ public class ReceivingContainerService { save(session, "COMPLETE", request.idempotencyKey(), request, result); return result; } - private ReceivingContainerSession requireCurrent(String customerId, String operatorKey, boolean lock) { - String key = activeOperatorKey(customerId, operatorKey); - ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(customerId, key) - : sessions.findActiveByOperator(customerId, key); + private ReceivingContainerSession requireCurrent(String operatorKey, boolean lock) { + String key = activeOperatorKey(operatorKey); + ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(key) + : sessions.findActiveByOperator(key); if (session == null) throw notFound("Current receiving container session was not found"); return session; } - private ReceivingContainerSession requireSigned(String customerId, String operatorKey) { - ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true); + private ReceivingContainerSession requireSigned(String operatorKey) { + ReceivingContainerSession session = requireCurrent(operatorKey, true); if (!List.of("SIGNED", "RECEIVING").contains(session.getStatus())) { throw conflict("Sign the receiving container before receiving cargo"); } return session; } + private List requirePlans(String containerNo) { + List values = plans.selectList(new LambdaQueryWrapper() + .eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId)); + if (values.isEmpty()) throw notFound("Receiving container was not found"); + requireSingleCustomer(values, containerNo); + if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) { + throw conflict("Receiving container is already completed"); + } + return values; + } + private List requirePlans(String customerId, String containerNo) { List values = plansForContainer(customerId, containerNo); if (values.isEmpty()) throw notFound("Receiving container was not found"); @@ -334,6 +355,15 @@ public class ReceivingContainerService { return values; } + private String requireSingleCustomer(List values, String containerNo) { + Set customers = values.stream().map(InboundPlan::getCustomerId).map(ReceivingContainerService::trim) + .filter(Objects::nonNull).collect(Collectors.toCollection(java.util.LinkedHashSet::new)); + if (customers.size() != 1 || values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) { + throw conflict("Receiving container must resolve to exactly one customer: " + containerNo); + } + return customers.iterator().next(); + } + private List refs(String customerId, String containerNo) { List result = new ArrayList<>(); for (InboundPlan plan : plansForContainer(customerId, containerNo)) { @@ -576,9 +606,9 @@ public class ReceivingContainerService { return value; } - private ReceivingContainerOperation replay(String customerId, String operatorKey, String action, String key, Object request) { + private ReceivingContainerOperation replay(String operatorKey, String action, String key, Object request) { key = idempotencyKey(key); - ReceivingContainerOperation value = operations.find(customerId, operatorKey, key); + ReceivingContainerOperation value = operations.find(operatorKey, key); if (value == null) return null; if (!action.equals(value.getAction()) || !hash(request).equals(value.getRequestHash())) { throw conflict("Idempotency key was already used with a different request"); @@ -618,8 +648,8 @@ public class ReceivingContainerService { catch (JsonProcessingException exception) { throw new IllegalStateException(exception); } } - private static String activeOperatorKey(String customerId, String operatorKey) { return customerId + ":" + operatorKey; } - private static String activeContainerKey(String customerId, String containerNo) { return customerId + ":" + containerNo; } + private static String activeOperatorKey(String operatorKey) { return "OPERATOR:" + operatorKey; } + private static String activeContainerKey(String containerNo) { return "CONTAINER:" + containerNo.trim().toUpperCase(java.util.Locale.ROOT); } private static String idempotencyKey(String value) { if (value == null || value.isBlank()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency key is required"); value = value.trim(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java index 0e9dc4d95..4ffeff2cd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.inventory.entity; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; @@ -40,8 +41,11 @@ public class InventoryLedger { private Long deltaPackages; private Long afterPackages; /** Quantity snapshot is retained even though current PDA operations are carton-count based. */ + @TableField(exist = false) private Long beforeQuantity; + @TableField(exist = false) private Long deltaQuantity; + @TableField(exist = false) private Long afterQuantity; private String operatorId; private String remark; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java index 200b99a67..7d5d34abc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java @@ -1,6 +1,8 @@ package com.baiyue.lastleg.procurement.entity; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; @@ -17,24 +19,24 @@ public class OverseasProcurementOrder { private String orderNo; private String purchaseCategory; private String productName; - private String productSpecification; - private String productUrl; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String productSpecification; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String productUrl; private Long purchaseQuantity; private BigDecimal budgetUnitPrice; private String currency; private String receivingAddress; private String contactName; private String contactPhone; - private String purchaseWarehouseId; - private String purchaseWarehouseName; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseId; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseName; private Long inboundQuantity; - private String domesticCarrier; - private String domesticTrackingNo; - private String overseasCarrier; - private String overseasTrackingNo; - private LocalDateTime estimatedArrivalAt; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticCarrier; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticTrackingNo; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String overseasCarrier; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String overseasTrackingNo; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime estimatedArrivalAt; private String status; - private String remark; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark; private String idempotencyKey; private String requestHash; private String createdBy; diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 430e98bf9..dd71d6b9a 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -678,10 +678,10 @@ WHERE (l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL) OR l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS'; CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( - id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', - operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话账号唯一锁键', - active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', + operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键', + active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间', receiving_at DATETIME(3) NULL COMMENT '首次收货时间', completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -709,7 +709,7 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_operation ( operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), - UNIQUE KEY uk_ll_receiving_operation(customer_id,operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at) + UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at) ) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录'; CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index ed8be5397..e7492edbf 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -6,20 +6,20 @@ paths: /api/pda/v1/last-leg/receiving-containers/candidates: get: summary: 查询当前客户待签收柜号 - description: 权限 lastleg:pda:operate;客户与操作账号均从 PDA Token 获取,不接收 customerId。 + description: 权限 lastleg:pda:operate;仅 PLATFORM WAREHOUSE_PICKER 可用,操作账号从 Token 获取,客户由所选柜业务事实唯一解析,不接收 customerId。 security: [{ bearerAuth: [] }] responses: { '200': { description: 柜号候选及箱数进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCandidateListApiResponse' } } } }, '403': { description: 缺少 PDA 操作权限 } } /api/pda/v1/last-leg/receiving-containers/{containerNo}: get: summary: 查询待签收柜号详情 - description: 权限 lastleg:pda:operate;返回柜内全部入仓码、目的类型、目的仓、托数和实收进度。 + description: 权限 lastleg:pda:operate;按柜号唯一解析客户并返回柜内全部入仓码、目的类型、目的仓、托数和实收进度。 security: [{ bearerAuth: [] }] parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] responses: { '200': { description: 柜号详情, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 当前账号已锁定其他柜号 } } /api/pda/v1/last-leg/receiving-containers/{containerNo}/prepare: post: summary: 选择当前待签收柜号 - description: 权限 lastleg:pda:operate;建立 WAIT_SIGN 会话并以账号、柜号双唯一键防止切柜或多人同时处理。 + description: 权限 lastleg:pda:operate;建立 WAIT_SIGN 会话并以平台操作账号、柜号全局双唯一键防止切柜或多人同时处理。 security: [{ bearerAuth: [] }] parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java index d49cf48e4..7e0a35e3e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -47,7 +47,7 @@ class ReceivingContainerContractTest { } @Test - void schemaContainsCustomerScopedPlanChildrenAndContainerSessionLocks() throws IOException { + void schemaContainsCustomerScopedPlanChildrenAndGlobalPlatformPickerLocks() throws IOException { String schema = resource("db/lastleg.sql").toLowerCase(); String unified = java.nio.file.Files.readString( java.nio.file.Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8).toLowerCase(); @@ -62,7 +62,10 @@ class ReceivingContainerContractTest { assertTrue(schema.contains("create table if not exists ll_receiving_container_session")); assertTrue(schema.contains("unique key uk_ll_receiving_active_operator(active_operator_key)")); assertTrue(schema.contains("unique key uk_ll_receiving_active_container(active_container_key)")); + assertTrue(schema.contains("平台操作账号全局唯一锁键")); + assertTrue(schema.contains("柜号全局唯一锁键")); assertTrue(schema.contains("create table if not exists ll_receiving_container_operation")); + assertTrue(schema.contains("unique key uk_ll_receiving_operation(operator_key,idempotency_key)")); assertTrue(schema.contains("create table if not exists ll_receiving_sku_receipt")); assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)")); String receivingSession = tableBlock(schema, "ll_receiving_container_session"); @@ -75,6 +78,11 @@ class ReceivingContainerContractTest { assertTrue(unified.contains("customer_id is null or v.customer_id=''")); assertTrue(unified.contains("customer_id is null or a.customer_id=''")); assertTrue(unified.contains("signal sqlstate '45000'")); + assertTrue(unified.contains("assert_receiving_global_locks_migratable")); + assertTrue(unified.contains("concat('operator:',operator_key)")); + assertTrue(unified.contains("concat('container:',upper(trim(container_no)))")); + assertTrue(unified.contains("assert_receiving_operation_global_key_migratable")); + assertTrue(unified.contains("group_concat(column_name order by seq_in_index)= 'operator_key,idempotency_key'")); } @Test @@ -94,6 +102,8 @@ class ReceivingContainerContractTest { assertTrue(successResponses.stream().allMatch(line -> line.contains("content: { application/json: { schema: { $ref: '#/components/schemas/Receiving"))); assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜")); + assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用")); + assertFalse(receivingPaths.contains("客户与操作账号均从 PDA Token 获取")); assertTrue(yaml.contains("enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN]")); assertFalse(receivingPaths.contains("photoFileIds")); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java index c9c3d0fb7..dd7fa8bef 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java @@ -30,25 +30,32 @@ class ReceivingContainerControllerTest { } @Test - void candidatesDerivesCustomerAndOperatorOnlyFromPdaIdentity() { - when(service.candidates("100", "42")).thenReturn(List.of(new ReceivingContainerResponses.Candidate( + void candidatesDerivesOperatorFromPlatformWarehousePickerIdentity() { + when(service.candidates("42")).thenReturn(List.of(new ReceivingContainerResponses.Candidate( "CONT-1", "WAIT_SIGN", 2, 2, List.of("ONT8"), 4, 20, 0, false))); - controller.candidates(pda()); + controller.candidates(picker()); - verify(service).candidates("100", "42"); + verify(service).candidates("42"); } @Test - void platformIdentityCannotEnterPdaReceivingApi() { - AuthenticatedUser platform = new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", - Set.of("lastleg:pda:operate")); - assertThatThrownBy(() -> controller.candidates(platform)).isInstanceOf(ResponseStatusException.class) + void merchantIdentityCannotEnterPdaReceivingApiEvenWithForgedAuthority() { + AuthenticatedUser merchant = new AuthenticatedUser(7L, 100L, 100L, 8L, "merchant", "MERCHANT", "WEB", + Set.of(ReceivingContainerController.OPERATE_PERMISSION)); + assertThatThrownBy(() -> controller.candidates(merchant)).isInstanceOf(ResponseStatusException.class) .hasMessageContaining("401"); } - private static AuthenticatedUser pda() { - return new AuthenticatedUser(42L, 100L, 100L, 8L, "receiver", "PDA", "PDA", - Set.of("lastleg:pda:operate")); + @Test + void platformIdentityWithoutPickerAuthorityIsForbidden() { + AuthenticatedUser platform = new AuthenticatedUser(7L, 0L, 0L, 8L, "admin", "PLATFORM", "WEB", Set.of()); + assertThatThrownBy(() -> controller.candidates(platform)).isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("403"); + } + + private static AuthenticatedUser picker() { + return new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA", + Set.of(ReceivingContainerController.OPERATE_PERMISSION)); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 498415847..44646e54d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -35,6 +35,7 @@ import org.springframework.web.server.ResponseStatusException; import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; @@ -87,35 +88,56 @@ class ReceivingContainerServiceBehaviorTest { when(lines.selectList(any())).thenReturn(List.of()); when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1); when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); - service.prepare("100", "7", "receiver", "CONT-A", new ReceivingContainerRequests.Action("same-key")); + service.prepare("7", "receiver", "CONT-A", new ReceivingContainerRequests.Action("same-key")); + ArgumentCaptor selected = ArgumentCaptor.forClass(ReceivingContainerSession.class); + verify(sessions).insert(selected.capture()); + assertThat(selected.getValue().getCustomerId()).isEqualTo("100"); + assertThat(selected.getValue().getActiveOperatorKey()).isEqualTo("OPERATOR:7"); + assertThat(selected.getValue().getActiveContainerKey()).isEqualTo("CONTAINER:CONT-A"); ArgumentCaptor saved = ArgumentCaptor.forClass(ReceivingContainerOperation.class); verify(operations).insert(saved.capture()); - when(operations.find("100", "7", "same-key")).thenReturn(saved.getValue()); + when(operations.find("7", "same-key")).thenReturn(saved.getValue()); - assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B", + assertThatThrownBy(() -> service.prepare("7", "receiver", "CONT-B", new ReceivingContainerRequests.Action("same-key"))).isInstanceOf(ResponseStatusException.class) .hasMessageContaining("409"); } + @Test + void pickerCandidatesSpanCustomersButEachContainerMustResolveOneCustomer() { + InboundPlan first = plan("CONT-A"); + InboundPlan second = plan("CONT-B"); second.setId(3L); second.setCustomerId("200"); + when(plans.selectList(any())).thenReturn(List.of(first, second), List.of(first), List.of(second)); + when(lines.selectList(any())).thenReturn(List.of()); + + assertThat(service.candidates("7")).extracting("containerNo").containsExactly("CONT-A", "CONT-B"); + + InboundPlan ambiguous = plan("CONT-A"); ambiguous.setId(4L); ambiguous.setCustomerId("200"); + org.mockito.Mockito.reset(plans, sessions); + when(plans.selectList(any())).thenReturn(List.of(first, ambiguous)); + assertThatThrownBy(() -> service.candidates("7")).isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("exactly one customer"); + } + @Test void waitSignSelectionAlreadyLocksAccountFromViewingAnotherContainer() { when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B"))); - when(sessions.findActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); - assertThatThrownBy(() -> service.detail("100", "7", "CONT-B")) + when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); + assertThatThrownBy(() -> service.detail("7", "CONT-B")) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); } @Test void prepareEnforcesBothActiveAccountAndActiveContainerUniqueness() { when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B"))); - when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); - assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B", + when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); + assertThatThrownBy(() -> service.prepare("7", "receiver", "CONT-B", new ReceivingContainerRequests.Action("account-lock"))).isInstanceOf(ResponseStatusException.class); verify(sessions, never()).insert(any(ReceivingContainerSession.class)); org.mockito.Mockito.reset(sessions, operations); - when(sessions.lockActiveByContainer("100", "100:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN")); - assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B", + when(sessions.lockActiveByContainer("CONTAINER:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN")); + assertThatThrownBy(() -> service.prepare("7", "receiver", "CONT-B", new ReceivingContainerRequests.Action("container-lock"))).isInstanceOf(ResponseStatusException.class); verify(sessions, never()).insert(any(ReceivingContainerSession.class)); } @@ -123,7 +145,7 @@ class ReceivingContainerServiceBehaviorTest { @Test void destinationModesAreHardRejectedBeforeWarehouseCalls() { stubCurrent("OVERSEAS", "CONT-A"); - assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", + assertThatThrownBy(() -> service.receiveBulk("7", "receiver", new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "bulk-key"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); @@ -132,7 +154,7 @@ class ReceivingContainerServiceBehaviorTest { stubCurrent("FBA", "CONT-A"); InboundPlanCarton carton = carton(1L, "BOX-1"); when(cartons.selectOne(any())).thenReturn(carton); - assertThatThrownBy(() -> service.receiveCarton("100", "7", "receiver", + assertThatThrownBy(() -> service.receiveCarton("7", "receiver", new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "carton-key"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); @@ -142,7 +164,7 @@ class ReceivingContainerServiceBehaviorTest { void crossContainerCartonAndMismatchedPalletAreRejectedBeforeReservation() { stubCurrent("OVERSEAS", "CONT-A"); when(cartons.selectOne(any())).thenReturn(carton(999L, "BOX-X")); - assertThatThrownBy(() -> service.receiveCarton("100", "7", "receiver", + assertThatThrownBy(() -> service.receiveCarton("7", "receiver", new ReceivingContainerRequests.CartonReceive("BOX-X", "PAL-1", "cross-key"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); @@ -151,7 +173,7 @@ class ReceivingContainerServiceBehaviorTest { stubCurrent("FBA", "CONT-A"); stubSku(); when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(101L, "FBA-1")); - assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", + assertThatThrownBy(() -> service.receiveBulk("7", "receiver", new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "pallet-key"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); @@ -165,7 +187,7 @@ class ReceivingContainerServiceBehaviorTest { when(pallets.selectList(any())).thenReturn(List.of(planPallet())); doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt")) .when(workflow).validateBulkResolved(any(), any(), any(), anyInt()); - assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", + assertThatThrownBy(() -> service.receiveBulk("7", "receiver", new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 2, "over-key"))) .isInstanceOf(ResponseStatusException.class); verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); @@ -177,7 +199,7 @@ class ReceivingContainerServiceBehaviorTest { when(pallets.selectList(any())).thenReturn(List.of(planPallet())); doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "duplicate carton")) .when(workflow).validateCartonResolved(any(), any(), any(), any(), any()); - assertThatThrownBy(() -> service.receiveCarton("100", "7", "receiver", + assertThatThrownBy(() -> service.receiveCarton("7", "receiver", new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "duplicate-key"))) .isInstanceOf(ResponseStatusException.class); verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); @@ -189,11 +211,24 @@ class ReceivingContainerServiceBehaviorTest { InboundPlanPalletAllocation partial = new InboundPlanPalletAllocation(); partial.setLineId(10L); partial.setPickedPackageCount(1); when(allocations.selectList(any())).thenReturn(List.of(partial)); - assertThatThrownBy(() -> service.complete("100", "7", new ReceivingContainerRequests.Action("complete-key"))) + assertThatThrownBy(() -> service.complete("7", new ReceivingContainerRequests.Action("complete-key"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verify(workflow, never()).completeContainer(any(), any()); } + @Test + void completedRequestReplaysWithoutAnActiveCustomerSession() { + ReceivingContainerOperation operation = new ReceivingContainerOperation(); + operation.setOperatorKey("7"); operation.setAction("COMPLETE"); operation.setIdempotencyKey("complete-replay"); + operation.setRequestHash(hash(new ReceivingContainerRequests.Action("complete-replay"))); + operation.setResultJson("{\"containerNo\":\"CONT-A\",\"status\":\"COMPLETED\",\"operatorName\":\"receiver\",\"version\":1,\"photos\":[],\"totalPackages\":2,\"receivedPackages\":2,\"pendingPackages\":0,\"inbounds\":[]}"); + when(operations.find("7", "complete-replay")).thenReturn(operation); + + assertThat(service.complete("7", new ReceivingContainerRequests.Action("complete-replay")).status()) + .isEqualTo("COMPLETED"); + verifyNoInteractions(sessions); + } + @Test void localFailureAfterRemoteReservationTriggersReleaseCompensation() { stubCurrent("FBA", "CONT-A"); @@ -204,7 +239,7 @@ class ReceivingContainerServiceBehaviorTest { ResponseStatusException failure = new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "race"); doThrow(failure).when(workflow).receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any()); - assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", + assertThatThrownBy(() -> service.receiveBulk("7", "receiver", new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "compensate-key"))) .isSameAs(failure); verify(workflow).compensateReservation("100", 55L, failure); @@ -212,7 +247,7 @@ class ReceivingContainerServiceBehaviorTest { @Test void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() { - when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); + when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A"))); when(lines.selectList(any())).thenReturn(List.of()); when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1); @@ -222,7 +257,7 @@ class ReceivingContainerServiceBehaviorTest { new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L), new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 2L)), "sign-key"); - var response = service.sign("100", "7", "receiver", request); + var response = service.sign("7", "receiver", request); org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("role") .containsExactly(ReceivingPhotoRole.LICENSE_PLATE, ReceivingPhotoRole.CONTAINER_SEAL, @@ -234,26 +269,26 @@ class ReceivingContainerServiceBehaviorTest { @Test void signRejectsDuplicateRolesBeforeFileValidation() { - when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); + when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of( new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L), new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 2L), new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-sign"); - assertThatThrownBy(() -> service.sign("100", "7", "receiver", request)) + assertThatThrownBy(() -> service.sign("7", "receiver", request)) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(unloadPhotos); } @Test void signRejectsDuplicateFileIdsBeforeFileValidation() { - when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); + when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of( new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L), new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 1L), new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-files"); - assertThatThrownBy(() -> service.sign("100", "7", "receiver", request)) + assertThatThrownBy(() -> service.sign("7", "receiver", request)) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(unloadPhotos); } @@ -267,7 +302,7 @@ class ReceivingContainerServiceBehaviorTest { received.setReceivedPackages(2); when(skuReceipts.listLineSku("100", 10L, "SKU-1")).thenReturn(List.of(received)); - assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", + assertThatThrownBy(() -> service.receiveBulk("7", "receiver", new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "sku-over"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); @@ -277,7 +312,7 @@ class ReceivingContainerServiceBehaviorTest { InboundPlan plan = plan(containerNo); InboundPlanLine line = line(destinationType); LastlegOrder order = order(destinationType); - when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session(containerNo, "SIGNED")); + when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session(containerNo, "SIGNED")); when(plans.selectList(any())).thenReturn(List.of(plan)); when(lines.selectList(any())).thenReturn(List.of(line)); when(orders.selectById(1L)).thenReturn(order); @@ -342,4 +377,17 @@ class ReceivingContainerServiceBehaviorTest { return new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-1", warehouseId, 40L, "AVAILABLE", fbaCode, 0); } + + private String hash(Object value) { + try { + String json = new ObjectMapper().findAndRegisterModules().copy() + .configure(com.fasterxml.jackson.databind.MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true) + .configure(com.fasterxml.jackson.databind.SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true) + .writeValueAsString(value); + return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(json.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } catch (Exception exception) { + throw new AssertionError(exception); + } + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java index 2586fae27..d35865d93 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java @@ -12,6 +12,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -108,9 +109,12 @@ class InventorySchemaContractTest { assertThrowsNoField(InventoryPosition.class, "productQuantity"); assertNotNull(InventoryLedger.class.getDeclaredField("skuCode")); assertNotNull(InventoryLedger.class.getDeclaredField("cartonMark")); - assertNotNull(InventoryLedger.class.getDeclaredField("beforeQuantity")); - assertNotNull(InventoryLedger.class.getDeclaredField("deltaQuantity")); - assertNotNull(InventoryLedger.class.getDeclaredField("afterQuantity")); + for (String field : List.of("beforeQuantity", "deltaQuantity", "afterQuantity")) { + var annotation = InventoryLedger.class.getDeclaredField(field) + .getAnnotation(com.baomidou.mybatisplus.annotation.TableField.class); + assertNotNull(annotation, field); + assertFalse(annotation.exist(), field); + } } private static void assertThrowsNoField(Class type, String fieldName) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java index f60b35062..9e41c94e8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java @@ -1,5 +1,7 @@ package com.baiyue.lastleg.procurement.service; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests; import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; @@ -21,6 +23,7 @@ import java.util.List; import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -155,4 +158,15 @@ class OverseasProcurementServiceTest { order.setUpdatedAt(order.getCreatedAt()); return order; } + + @Test + void nullablePatchFieldsArePersistedWhenExplicitlyCleared() throws Exception { + for (String field : List.of("productSpecification", "productUrl", "purchaseWarehouseId", + "purchaseWarehouseName", "domesticCarrier", "domesticTrackingNo", "overseasCarrier", + "overseasTrackingNo", "estimatedArrivalAt", "remark")) { + TableField annotation = OverseasProcurementOrder.class.getDeclaredField(field).getAnnotation(TableField.class); + assertNotNull(annotation, field); + assertEquals(FieldStrategy.ALWAYS, annotation.updateStrategy(), field); + } + } } diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 4bd6fb8ad..756c1a027 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -7,6 +7,49 @@ import process from 'node:process' const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-pda', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] +const MODULE_DEPENDENCIES = new Map([ + ['orders', ['merchant-qualification-fixture']], + ['inbound-measurement', ['orders']], + ['phase3-container-loading', ['inbound-measurement']], + ['phase3-loading', ['phase3-container-loading']], + ['phase3-container-overview', ['phase3-loading']], + ['phase3-loading-model-bridge', ['phase3-loading']], + ['phase3-pda-convergence', ['phase3-loading']], + ['phase3-evidence-gate', ['phase3-pda-convergence']], + ['ocean-bookings', ['phase3-loading']], + ['truck-appointments', ['merchant-qualification-fixture']], + ['service-center-flow', ['ocean-bookings', 'truck-appointments']], + ['phase3-loading-finalize', ['phase3-loading', 'phase3-evidence-gate']], + ['phase3-customs', ['phase3-loading-finalize']], + ['phase3-port-schedules', ['ocean-bookings']], + ['phase3-outbound-dispatch', ['phase3-customs']], + ['phase3-outbound-aggregate', ['phase3-outbound-dispatch']], + ['phase3-dispatch-gate', ['phase3-outbound-dispatch']], + ['phase3-waiting-for-loading', ['phase3-outbound-dispatch']], + ['phase3-carrier-schedule-queries', ['phase3-outbound-dispatch']], + ['phase3-order-splitting', ['phase3-loading-finalize']], + ['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings']], + ['phase4-overseas-pda', ['phase3-loading-finalize']], + ['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda']], + ['phase4-carrier-nodes', ['phase3-carrier-schedule-queries']], +]) + +function stableTopologicalOrder(modules) { + const selected = new Set(modules) + const pending = [...modules] + const result = [] + const completed = new Set() + while (pending.length > 0) { + const readyIndex = pending.findIndex((module) => (MODULE_DEPENDENCIES.get(module) || []) + .every((dependency) => !selected.has(dependency) || completed.has(dependency))) + if (readyIndex < 0) throw new Error(`HTTP 验收模块依赖存在循环: ${pending.join(',')}`) + const [module] = pending.splice(readyIndex, 1) + result.push(module) + completed.add(module) + } + return result +} + export function normalizeModuleOrder(modules) { const normalized = [...modules] const manualGroups = ['order-inbound-status-fixtures', 'manual-state-loading-pda', 'manual-state-ocean-trucking'] @@ -68,9 +111,9 @@ export function normalizeModuleOrder(modules) { const prerequisites = ['system', 'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-loading-finalize', 'phase3-customs', 'phase4-overseas-pda'] - const withoutPrerequisites = normalized.filter((module) => !prerequisites.includes(module)) - withoutPrerequisites.splice(withoutPrerequisites.indexOf('phase4-cross-region-events'), 0, ...prerequisites) - normalized.splice(0, normalized.length, ...withoutPrerequisites) + const targetIndex = normalized.indexOf('phase4-cross-region-events') + const missing = prerequisites.filter((name) => !normalized.includes(name)) + if (missing.length > 0) normalized.splice(targetIndex, 0, ...missing) } const financeChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full'] const firstFinanceIndex = normalized.findIndex((name) => financeChain.includes(name)) @@ -122,10 +165,11 @@ export function normalizeModuleOrder(modules) { } const loadingIndex = normalized.indexOf('phase3-loading') const overviewIndex = normalized.indexOf('phase3-container-overview') - if (loadingIndex < 0 || overviewIndex < 0 || overviewIndex > loadingIndex) return normalized - normalized.splice(overviewIndex, 1) - normalized.splice(normalized.indexOf('phase3-loading') + 1, 0, 'phase3-container-overview') - return normalized + if (loadingIndex >= 0 && overviewIndex >= 0 && overviewIndex < loadingIndex) { + normalized.splice(overviewIndex, 1) + normalized.splice(normalized.indexOf('phase3-loading') + 1, 0, 'phase3-container-overview') + } + return stableTopologicalOrder(normalized) } const DEFAULT_MODULES = normalizeModuleOrder(CONFIGURED_MODULES) diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index 14ff86e4b..ebce176a2 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -315,7 +315,8 @@ function assertCargoContract(context, response, cargoes, payload) { && Number(actual?.productLengthCm) === expected.productLengthCm && Number(actual?.productWidthCm) === expected.productWidthCm && Number(actual?.productHeightCm) === expected.productHeightCm - && actual?.shipmentId === expected.shipmentId && actual?.referenceId === expected.referenceId + && (actual?.shipmentId ?? null) === (expected.shipmentId ?? null) + && (actual?.referenceId ?? null) === (expected.referenceId ?? null) && actual?.upcNo === expected.upcNo && actual?.cargoRemark.includes(context.marker) }) context.assert(MODULE, '四箱型产品字段及图片契约完整回显', response, complete, diff --git a/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs new file mode 100644 index 000000000..5c4da9dbd --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { markerSuffix } from './phase3-loading.mjs' + +test('phase3 loading fixture identifiers are deterministic and distinct across marker variants', () => { + const base = 'ADMIN-API-20260824123024536' + const primary = markerSuffix(`${base}-P4-OVERSEAS-PRIMARY`) + const secondary = markerSuffix(`${base}-P4-SECONDARY`) + + assert.equal(primary, markerSuffix(`${base}-P4-OVERSEAS-PRIMARY`)) + assert.notEqual(primary, secondary) + assert.match(primary, /^\d{8}-[0-9A-F]{8}$/) + assert.ok(`P3B-${primary}`.length <= 64) +}) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 0b64243f8..61b36ae98 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1,4 +1,5 @@ import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs' +import { createHash } from 'node:crypto' const MODULE = 'phase3-loading' const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true } @@ -6,6 +7,12 @@ const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' function id(value) { return value == null ? '' : String(value) } +export function markerSuffix(marker) { + const value = String(marker || '') + const digits = value.replace(/\D/g, '').slice(-8).padStart(8, '0') + const digest = createHash('sha256').update(value).digest('hex').slice(0, 8).toUpperCase() + return `${digits}-${digest}` +} function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } function responseEvidence(response) { return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` @@ -119,7 +126,7 @@ export async function run(context) { if (initialStatus !== 'WAIT_SPLIT' || !Number.isInteger(initialVersion)) return const base = `/api/platform/v1/inbounds/${inboundId}` - const suffix = context.marker.replace(/\D/g, '').slice(-10) + const suffix = markerSuffix(context.marker) const warehouseId = id(initial?.summary?.warehouseId) const containerNo = `P3B-${suffix}`.slice(0, 64) const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading' diff --git a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs index cc496ce32..eb586afb5 100644 --- a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs +++ b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs @@ -33,10 +33,11 @@ export async function run(context) { const roleCodes = (roleCode) => new Set((warehouseRoles.get(roleCode)?.permissionIds || []) .map((permissionId) => permissionById.get(id(permissionId))).filter(Boolean)) const pickerCodes = roleCodes('WAREHOUSE_PICKER') - context.assert(MODULE, '拣货员只具备PDA查询、作业和现场拍照权限', rolesResponse, - pickerCodes.size === 2 + context.assert(MODULE, '拣货员只具备国内PDA查询作业与海外PDA入仓操作权限', rolesResponse, + pickerCodes.size === 3 && pickerCodes.has('platform:firstleg:pda:view') - && pickerCodes.has('platform:firstleg:pda:work'), + && pickerCodes.has('platform:firstleg:pda:work') + && pickerCodes.has('lastleg:pda:operate'), `permissions=${[...pickerCodes].sort().join(',')}`, OPTIONS) const operatorCodes = roleCodes('WAREHOUSE_OPERATOR') diff --git a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs index 0c324fa98..2dab0d4e4 100644 --- a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs @@ -58,6 +58,25 @@ test('entry orders marker-owned prerequisites before cross-region acceptance', ( } }) +test('cross-region normalization keeps existing producers ahead of every phase-three consumer', () => { + const requested = [ + 'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading', + 'phase3-loading-model-bridge', 'phase3-loading', 'ocean-bookings', 'phase3-loading-finalize', + 'phase3-customs', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', + 'phase4-overseas-pda', 'phase4-cross-region-events', + ] + const modules = normalizeModuleOrder(requested) + for (const [producer, consumer] of [ + ['merchant-qualification-fixture', 'orders'], ['orders', 'inbound-measurement'], + ['inbound-measurement', 'phase3-container-loading'], ['phase3-container-loading', 'phase3-loading'], + ['phase3-loading', 'phase3-loading-model-bridge'], ['phase3-loading', 'ocean-bookings'], + ['phase3-loading-finalize', 'phase3-customs'], ['phase3-customs', 'phase3-outbound-dispatch'], + ['phase3-outbound-dispatch', 'phase3-outbound-aggregate'], + ['phase4-overseas-pda', 'phase4-cross-region-events'], + ]) assert.ok(modules.indexOf(producer) < modules.indexOf(consumer), `${producer} -> ${consumer}`) + assert.deepEqual(normalizeModuleOrder(modules), modules) +}) + test('contract covers dual audience, canonical 403, both event directions and diagnostics', () => { for (const token of [ 'context.token)', 'context.overseasToken)', 'cn-overseas-event-not-delivered', diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index e614ef63e..284dbe9b6 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -29,6 +29,9 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /同键同载荷重放/) assert.match(source, /同键异载荷冲突/) assert.match(source, /跨柜切换锁返回409/) + assert.match(source, /第二仓库操作员抢占同柜返回409/) + assert.match(source, /WAREHOUSE_PICKER/) + assert.match(source, /lastleg:pda:operate/) assert.match(source, /超收被拒绝/) assert.match(source, /重复箱新键被拒绝/) assert.match(source, /模式硬拒绝/) @@ -37,43 +40,60 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /retained as acceptance evidence/) }) -test('missing customer fixture is a deployment blocker and performs no HTTP request', async () => { +test('missing WAREHOUSE_PICKER overseas authority is a deployment blocker', async () => { const calls = []; const gaps = []; const steps = [] const context = { marker: 'PDA-CONTRACT', context: {}, createdResources: {}, - async request(...args) { calls.push(args); throw new Error('must not request without credentials') }, + async request(method, requestPath) { + calls.push({ method, requestPath }) + return { method, path: requestPath, http: 200, body: { code: '200', data: [] }, + requestId: 'request-contract-1', traceId: 'trace-contract-0001' } + }, async requestOverseas(...args) { calls.push(args); throw new Error('must not request without credentials') }, async requestOverseasMultipart(...args) { calls.push(args); throw new Error('must not request without credentials') }, + apiSuccess(module, name, response) { return response.body.data }, addStep(...args) { steps.push(args) }, assert(module, name, response, passed, evidence, options) { steps.push({ module, name, passed, evidence, options }) }, addCoverageGap(...args) { gaps.push(args) }, } await run(context) - assert.equal(calls.length, 0) - assert.equal(gaps.length, 1) - assert.equal(gaps[0][5].deploymentBlocker, true) - assert.equal(steps.length, 1) - assert.equal(steps[0].passed, false) - assert.equal(steps[0].options.deploymentBlocker, true) + assert.ok(calls.some((call) => call.requestPath === '/api/platform/v1/iam/roles')) + assert.ok(calls.some((call) => call.requestPath === '/api/platform/v1/iam/permissions')) + assert.ok(gaps.some((gap) => gap[2] === '海外PDA仓库操作员权限')) + assert.ok(steps.some((step) => step?.passed === false && step?.options?.deploymentBlocker)) }) test('empty candidate response cannot become a zero-business-request pass', async () => { const calls = []; const gaps = []; const steps = [] const response = (data, requestPath, method = 'GET') => ({ method, path: requestPath, http: 200, body: { code: '200', data }, requestId: 'req-contract', traceId: 'trace-contract' }) + let userSequence = 0 + const roleId = '4000005'; const permissionId = '5107015' const context = { - marker: 'PDA-CONTRACT', overseasToken: '', createdResources: {}, - context: { merchantQualificationFixture: { tenantId: '9', username: 'merchant', password: 'generated' } }, - async request(method, requestPath) { + marker: 'PDA-CONTRACT', overseasToken: '', createdResources: {}, context: {}, + async findAvailablePhone() { userSequence += 1; return `1750000000${userSequence}` }, + async request(method, requestPath, body) { calls.push({ channel: 'domestic', method, requestPath }) - return response({ overseasAccessToken: 'pda-token' }, requestPath, method) + if (method === 'GET' && requestPath.endsWith('/roles')) return response([ + { id: roleId, roleCode: 'WAREHOUSE_PICKER', status: 'ACTIVE', permissionIds: [permissionId] }, + ], requestPath, method) + if (method === 'GET' && requestPath.endsWith('/permissions')) return response([ + { id: permissionId, permissionCode: 'lastleg:pda:operate' }, + ], requestPath, method) + if (method === 'POST' && requestPath.endsWith('/users')) return response({ + membershipId: String(9000 + userSequence), username: body.username, tenantId: '0', roleIds: [roleId], status: 'ACTIVE', + }, requestPath, method) + if (method === 'POST' && requestPath === '/api/auth/login') return response({ overseasAccessToken: `pda-token-${userSequence}` }, requestPath, method) + if (method === 'PUT' && /\/users\/\d+\/status$/.test(requestPath)) return response({ status: 'DISABLED' }, requestPath, method) + throw new Error(`unexpected domestic request ${method} ${requestPath}`) }, - async requestOverseas(method, requestPath) { + async requestOverseas(method, requestPath, body, headers, token) { calls.push({ channel: 'overseas', method, requestPath }) if (requestPath.endsWith('/current')) return { method, path: requestPath, http: 200, body: { code: 'A0404', message: 'not found' }, requestId: 'req-current', traceId: 'trace-current' } return response([], requestPath, method) }, + apiSuccess(module, name, result) { return result.body.data }, addStep(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed, evidence, options }) }, assert(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed, evidence, options }) }, addCoverageGap(...args) { gaps.push(args) }, @@ -84,4 +104,6 @@ test('empty candidate response cannot become a zero-business-request pass', asyn assert.ok(steps.some((step) => step.name.includes('PDA柜号候选覆盖前置') && !step.passed && step.options.deploymentBlocker)) assert.equal(gaps.length, 1) assert.equal(context.createdResources.phase4OverseasPda.finalStatus, null) + assert.equal(context.createdResources.phase4OverseasPda.pickerMembershipIds.length, 2) + assert.equal(calls.filter((call) => /\/users\/\d+\/status$/.test(call.requestPath)).length, 2) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 4a66ab7e1..607920643 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -35,20 +35,58 @@ async function expected(context, token, method, name, path, body, predicate) { return response } -async function loginPda(context) { +async function loginMerchantForNegativeProbe(context) { const fixture = context.context?.merchantQualificationFixture - if (!fixture?.username || !fixture?.password) { - blockerGap(context, 'PDA客户账号', '缺少 merchant-qualification-fixture 生成的本轮客户账号;请保留默认全量前置模块') - return null - } + if (!fixture?.username || !fixture?.password) return null const response = await context.request('POST', '/api/auth/login', { username: fixture.username, password: fixture.password }, '') const data = success(response) ? response.body?.data : null - context.addStep(MODULE, '本轮客户登录并取得海外PDA令牌', response, + context.addStep(MODULE, '本轮客户登录取得海外令牌用于PDA越权反证', response, success(response) && nonBlank(data?.overseasAccessToken), `overseasTokenPresent=${nonBlank(data?.overseasAccessToken)}; ${trace(response)}`, OPTIONS) return nonBlank(data?.overseasAccessToken) ? data.overseasAccessToken : null } +async function createPicker(context, index) { + const rolesResponse = await context.request('GET', '/api/platform/v1/iam/roles') + const roles = context.apiSuccess(MODULE, `查询海外PDA仓库操作员角色${index}`, rolesResponse, '', OPTIONS) + const role = (Array.isArray(roles) ? roles : []).find((item) => item?.roleCode === 'WAREHOUSE_PICKER' && item?.status === 'ACTIVE') + const permissionsResponse = await context.request('GET', '/api/platform/v1/iam/permissions') + const permissions = context.apiSuccess(MODULE, `查询海外PDA权限资源${index}`, permissionsResponse, '', OPTIONS) + const operate = (Array.isArray(permissions) ? permissions : []).find((item) => item?.permissionCode === 'lastleg:pda:operate') + const assigned = Boolean(role && operate && (role.permissionIds || []).map(id).includes(id(operate.id))) + context.assert(MODULE, `WAREHOUSE_PICKER精确具备海外PDA权限${index}`, rolesResponse, assigned, + `roleId=${id(role?.id) || 'missing'}, permissionId=${id(operate?.id) || 'missing'}`, OPTIONS) + if (!assigned) { + blockerGap(context, '海外PDA仓库操作员权限', 'WAREHOUSE_PICKER未获得lastleg:pda:operate;必须先执行最新国内统一增量SQL并部署IAM') + return null + } + const suffix = markerSuffix(context) + const username = `pda_overseas_${suffix.toLowerCase()}_${index}`.slice(0, 64) + const password = `Pda${suffix.replace(/[^A-Za-z0-9]/g, '')}A${index}9` + const phone = await context.findAvailablePhone(MODULE) + const payload = { username, displayName: `海外PDA验收操作员${index}-${suffix}`, phone, + email: `${username}@example.com`, tenantId: '0', roleIds: [id(role.id)], password, status: 'ACTIVE' } + const createdResponse = await context.request('POST', '/api/platform/v1/iam/users', payload) + const created = context.apiSuccess(MODULE, `创建海外PDA仓库操作员${index}`, createdResponse, '', OPTIONS) + const membershipId = id(created?.membershipId) + context.assert(MODULE, `海外PDA操作员${index}冻结平台范围与角色`, createdResponse, + longId(membershipId) && id(created?.tenantId) === '0' && (created?.roleIds || []).map(id).includes(id(role.id)), + `membershipId=${membershipId || 'missing'}, tenantId=${id(created?.tenantId) || 'missing'}`, OPTIONS) + if (!longId(membershipId)) return null + const login = await context.request('POST', '/api/auth/login', { username, password }, '') + const token = success(login) ? login.body?.data?.overseasAccessToken : null + context.addStep(MODULE, `海外PDA操作员${index}登录取得双区域海外令牌`, login, nonBlank(token), + `overseasTokenPresent=${nonBlank(token)}; ${trace(login)}`, OPTIONS) + return { membershipId, username, token: nonBlank(token) ? token : null } +} + +async function disablePicker(context, picker, index) { + if (!longId(picker?.membershipId)) return + const response = await context.request('PUT', `/api/platform/v1/iam/users/${picker.membershipId}/status`, { status: 'DISABLED' }) + const disabled = success(response) && response.body?.data?.status === 'DISABLED' + context.addStep(MODULE, `finally停用海外PDA仓库操作员${index}`, response, disabled, trace(response), OPTIONS) +} + async function createPallet(context, inbound, index) { const warehouseId = id(inbound?.destinationWarehouseId) if (!longId(warehouseId)) return null @@ -160,17 +198,14 @@ async function receiveOverseas(context, token, inbound, pallet) { return true } -export async function run(context) { - context.createdResources.phase4OverseasPda = { - marker: context.marker, containerNo: null, customerId: context.context?.merchantQualificationFixture?.tenantId || null, - palletIds: [], locationIds: [], finalStatus: null, - cleanup: 'Pallets and locations used by completed inventory are retained as acceptance evidence', - } - const token = await loginPda(context) - if (!token) return +async function runReceiving(context, token, secondaryToken, merchantToken) { if (context.overseasToken) { const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken) - context.addStep(MODULE, '平台海外令牌不能冒充客户PDA账号', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) + context.addStep(MODULE, '平台管理员海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) + } + if (merchantToken) { + const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, merchantToken) + context.addStep(MODULE, '客户海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) } const before = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token) const noCurrent = notFound(before) @@ -203,6 +238,11 @@ export async function run(context) { `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, prepareBody) context.assert(MODULE, '选择柜号幂等结果一致', prepareReplay.response, success(prepareReplay.response) && prepareReplay.data?.containerNo === containerNo && prepareReplay.data?.status === 'WAIT_SIGN', trace(prepareReplay.response), OPTIONS) + if (secondaryToken) { + await expected(context, secondaryToken, 'POST', '第二仓库操作员抢占同柜返回409', + `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, + { idempotencyKey: key(context, 'secondary-prepare') }, conflict) + } const other = candidates.data.find((item) => item.containerNo !== containerNo) if (other) await expected(context, token, 'GET', '当前账号跨柜切换锁返回409', `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(other.containerNo)}`, undefined, conflict) @@ -268,3 +308,27 @@ export async function run(context) { context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS) context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null } + +export async function run(context) { + context.createdResources.phase4OverseasPda = { + marker: context.marker, containerNo: null, customerId: null, + palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], + cleanup: 'Pallets and locations used by completed inventory are retained as acceptance evidence', + } + const merchantToken = await loginMerchantForNegativeProbe(context) + const primary = await createPicker(context, 1) + const secondary = primary?.token ? await createPicker(context, 2) : null + for (const picker of [primary, secondary]) if (picker?.membershipId) { + context.createdResources.phase4OverseasPda.pickerMembershipIds.push(picker.membershipId) + } + try { + if (!primary?.token || !secondary?.token) { + blockerGap(context, '海外PDA双操作员账号', '未能创建并登录两个WAREHOUSE_PICKER平台账号,无法验证全局一柜锁') + return + } + await runReceiving(context, primary.token, secondary.token, merchantToken) + } finally { + await disablePicker(context, secondary, 2) + await disablePicker(context, primary, 1) + } +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs index 24e9c3b61..10d20e210 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs @@ -10,6 +10,7 @@ const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'phase4-overseas-warehouse.mjs'), 'utf8') function response(data, code = '200') { return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, requestId: 'request-test', traceId: 'trace-test' } } +function voidResponse() { return { http: 200, body: { code: '200', message: 'success' }, requestId: 'request-test', traceId: 'trace-test' } } function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } } function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehouseRecords = [{ id: '20001', status: 'ACTIVE', warehouseCode: 'LAX', warehouseName: '洛杉矶仓' }] } = {}) { const calls = []; const steps = []; const gaps = []; let createdPallet = null; let createdLocation = null; let createdRelabel = null @@ -21,12 +22,12 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001') if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null) if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/locations')) { createdLocation = { id: '72002', customerId: '0', warehouseId: '20001', ...body }; return response('72002') } - if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return response(null) } - if (method === 'DELETE' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = null; return response(null) } + if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return voidResponse() } + if (method === 'DELETE' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = null; return voidResponse() } if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', version: 0, ...body }; return response('71001') } if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) { if (Number(body.version) !== Number(createdPallet.version)) return response(null, 'A0409') - createdPallet = { ...createdPallet, ...body, version: createdPallet.version + 1 }; return response(null) + createdPallet = { ...createdPallet, ...body, version: createdPallet.version + 1 }; return voidResponse() } if (method === 'POST' && requestPath.startsWith('/api/platform/v1/last-leg/relabel-applications?')) { if (createdRelabel && body.remark !== createdRelabel.remark) return response(null, 'A0409') @@ -51,7 +52,8 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou if (requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '10001', status: 'ACTIVE' } }) if (requestPath.endsWith('/warehouse-admin/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', zoneCode: 'A', locationCode: 'A-01', locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE' }])) if (requestPath.endsWith('/warehouse-admin/20001/locations/72001')) return response({ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', status: 'ACTIVE' }) - if (requestPath.endsWith('/warehouse-admin/20001/locations/72002')) return response(createdLocation) + if (requestPath.endsWith('/warehouse-admin/20001/locations/72002')) return createdLocation == null + ? response(undefined, 'A0404') : response(createdLocation) if (requestPath.endsWith('/warehouse-admin/20001/pallets?page=1&size=20')) return response(page([{ id: '70001', customerId: '0', warehouseId: '20001', palletCode: 'PLT-1', status: 'IDLE' }])) if (requestPath.endsWith('/warehouse-admin/20001/pallets/70001')) return response({ id: '70001', customerId: '0', warehouseId: '20001' }) if (requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) return response(createdPallet) @@ -86,6 +88,14 @@ test('既有仓库场景只写本轮库位托盘与换标生命周期', () => { assert.ok(writes.some((call) => call.path.includes('/warehouse-admin/20001/pallets'))) assert.ok(writes.some((call) => call.path.includes('/relabel-applications'))) assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/') || call.path.includes('/relabel-applications'))) + for (const name of ['编辑本轮公共库位', '删除本轮公共库位', '报废本轮公共托盘']) { + const step = context.steps.find((item) => item.name === name) + assert.ok(step?.passed, name) + assert.equal(Object.hasOwn(step.result?.body || {}, 'data'), false, name) + } + const deletedReadback = context.steps.find((step) => step.name === '本轮公共库位已删除且回查不存在') + assert.equal(deletedReadback?.result?.body?.code, 'A0404') + assert.ok(deletedReadback?.passed) assert.ok(context.steps.every((step) => step.passed), context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n')) }) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs index 0d64db727..3e977f3aa 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs @@ -55,6 +55,13 @@ function assertPage(context, name, result) { function businessConflict(response) { return response?.http === 409 || (response?.http === 200 && String(response?.body?.code) === 'A0409') } +function voidMutationSucceeded(result) { + return result?.response?.http === 200 && String(result?.response?.body?.code) === '200' + && result.data == null +} +function businessNotFound(response) { + return response?.http === 404 || (response?.http === 200 && String(response?.body?.code) === 'A0404') +} export async function run(context) { context.createdResources.phase4OverseasWarehouse = { marker: context.marker, customerId: null, warehouseId: null, palletId: null, terminalStatus: null } @@ -216,7 +223,7 @@ async function verifyLocationLifecycle(context, warehouseId) { zoneCode: 'ACC', locationCode, locationType: 'STORAGE', maxLoadKg: 1200, status: 'ACTIVE', remark: `${context.marker}-edited`, }) - context.assert(MODULE, '公共库位编辑成功', updated.response, updated.data === null, + context.assert(MODULE, '公共库位编辑成功', updated.response, voidMutationSucceeded(updated), `locationId=${locationId}; ${trace(updated.response)}`, OPTIONS) const after = await overseasGet(context, '回查公共库位编辑结果', detailPath) context.assert(MODULE, '公共库位编辑字段已生效', after.response, @@ -226,9 +233,12 @@ async function verifyLocationLifecycle(context, warehouseId) { if (locationId) { const removed = await overseasMutation(context, 'DELETE', '删除本轮公共库位', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`) - context.createdResources.phase4OverseasWarehouse.locationTerminalStatus = removed.data === null ? 'DELETED' : null - context.assert(MODULE, '本轮公共库位已删除收口', removed.response, removed.data === null, - `locationId=${locationId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.locationTerminalStatus || 'missing'}; ${trace(removed.response)}`, OPTIONS) + const deletedDetail = await context.requestOverseas('GET', + `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`) + const deleted = voidMutationSucceeded(removed) && businessNotFound(deletedDetail) + context.createdResources.phase4OverseasWarehouse.locationTerminalStatus = deleted ? 'DELETED' : null + context.assert(MODULE, '本轮公共库位已删除且回查不存在', deletedDetail, deleted, + `locationId=${locationId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.locationTerminalStatus || 'missing'}; delete=${trace(removed.response)}, readback=${trace(deletedDetail)}`, OPTIONS) } } } @@ -354,7 +364,7 @@ async function verifyPalletLifecycle(context, warehouseId) { palletCode, palletType: '木托盘', capacityCbm: 2.5, unitPrice: 1, status: 'IDLE', locationId: id(detail.data?.locationId) || undefined, remark: `${context.marker}-edited`, version: Number(detail.data?.version), }) - context.assert(MODULE, '公共托盘编辑成功', updated.response, updated.data === null, + context.assert(MODULE, '公共托盘编辑成功', updated.response, voidMutationSucceeded(updated), `palletId=${palletId}; ${trace(updated.response)}`, OPTIONS) const after = await overseasGet(context, '回查公共托盘编辑结果', detailPath) context.assert(MODULE, '公共托盘编辑字段已生效', after.response, @@ -376,8 +386,8 @@ async function verifyPalletLifecycle(context, warehouseId) { '读取公共托盘报废前最新版本', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)).data?.version), }) - context.createdResources.phase4OverseasWarehouse.terminalStatus = retired.data === null ? 'SCRAPPED' : null - context.assert(MODULE, '本轮公共托盘已报废收口', retired.response, retired.data === null, + context.createdResources.phase4OverseasWarehouse.terminalStatus = voidMutationSucceeded(retired) ? 'SCRAPPED' : null + context.assert(MODULE, '本轮公共托盘已报废收口', retired.response, voidMutationSucceeded(retired), `palletId=${palletId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.terminalStatus || 'missing'}; ${trace(retired.response)}`, OPTIONS) const retiredDetail = await overseasGet(context, '回查本轮公共托盘报废状态', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`) diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java index ab4d06ec6..29d409b18 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java @@ -34,7 +34,7 @@ public class MerchantWarehouseController { @Operation( summary = "List available warehouses / 查询可用仓库", - description = "Tenant comes only from the authenticated JWT; clients cannot override it. / 租户仅从认证 JWT 获取,客户端不可覆盖。" + description = "Customer scope comes only from the authenticated JWT; any customerId query value is ignored. / 客户范围仅从认证 JWT 获取,查询中的 customerId 不生效。" ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java index 9467901d7..f934717af 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java @@ -91,7 +91,8 @@ public class PlatformWarehouseController { @Operation( summary = "List warehouses / 查询仓库", - description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST + ". Returns all warehouses with optional filters." + description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST + + ". Returns warehouses across customers, or only the optional customerId scope when supplied." ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java index 034dde3c6..7da4dc669 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java @@ -8,6 +8,7 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; import java.math.BigDecimal; @@ -94,6 +95,8 @@ public final class WarehouseRequests { @Schema(name = "WarehouseQueryRequest", description = "Warehouse page query / 仓库分页查询条件") public record QueryRequest( + @Schema(description = "Optional target customer ID for platform queries; merchant queries always use the authenticated customer / 平台查询可选目标客户 ID,商户查询始终使用认证客户", example = "10001", nullable = true) + @Positive Long customerId, @Schema(description = "Page number, starting at 1 / 页码,从 1 开始", example = "1", defaultValue = "1", minimum = "1") @Min(1) long page, @Schema(description = "Page size / 每页条数", example = "20", defaultValue = "20", minimum = "1", maximum = "200") diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java index 5e0bb2ca0..cfb36643f 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java @@ -87,6 +87,7 @@ public class WarehouseRepository { public PageResult pageAll(WarehouseRequests.QueryRequest query) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(query.customerId() != null, WarehouseEntity::getCustomerId, query.customerId()) .eq(StringUtils.hasText(query.status()), WarehouseEntity::getStatus, query.status()) .eq(StringUtils.hasText(query.countryCode()), WarehouseEntity::getCountryCode, query.countryCode()) .orderByDesc(WarehouseEntity::getCreatedAt); @@ -94,6 +95,7 @@ public class WarehouseRepository { if (StringUtils.hasText(query.capabilityCode())) { List ids = capabilities.selectList(new LambdaQueryWrapper() .select(ServiceCapabilityEntity::getWarehouseId) + .eq(query.customerId() != null, ServiceCapabilityEntity::getCustomerId, query.customerId()) .eq(ServiceCapabilityEntity::getCapabilityCode, query.capabilityCode()) .eq(ServiceCapabilityEntity::getEnabled, true)) .stream() diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/MerchantWarehouseControllerTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/MerchantWarehouseControllerTest.java new file mode 100644 index 000000000..2a5b57d42 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/MerchantWarehouseControllerTest.java @@ -0,0 +1,36 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.service.WarehouseApplicationService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Set; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class MerchantWarehouseControllerTest { + @Test + void merchantPageIgnoresRequestedCustomerAndUsesAuthenticatedCustomer() { + WarehouseApplicationService service = mock(WarehouseApplicationService.class); + MerchantWarehouseController controller = new MerchantWarehouseController(); + ReflectionTestUtils.setField(controller, "service", service); + WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest( + 99999L, 1, 20, "US", null, "ACTIVE"); + when(service.page(10001L, query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); + + controller.page(merchant(10001L), query); + + verify(service).page(10001L, query); + } + + private static AuthenticatedUser merchant(long customerId) { + return new AuthenticatedUser(1L, customerId, customerId, 1L, + "merchant", "MERCHANT", "WEB", Set.of()); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java index 1206986b8..0e4dea80f 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java @@ -5,6 +5,8 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.warehouse.dto.WarehouseRequests; import com.baiyue.warehouse.security.OverseasWarehousePermissions; import com.baiyue.warehouse.service.WarehouseApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.test.util.ReflectionTestUtils; @@ -16,6 +18,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; class PlatformWarehouseControllerTest { @Test @@ -30,11 +33,11 @@ class PlatformWarehouseControllerTest { } @Test - void platformPageQueriesAllWarehousesWithoutCustomerId() { + void platformPagePassesOptionalCustomerFilterToApplicationService() { WarehouseApplicationService service = mock(WarehouseApplicationService.class); PlatformWarehouseController controller = new PlatformWarehouseController(); ReflectionTestUtils.setField(controller, "service", service); - WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(1, 20, "ACTIVE", null, null); + WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(10001L, 1, 20, null, null, "ACTIVE"); when(service.pageAll(query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); controller.page(platform(), query); @@ -42,6 +45,22 @@ class PlatformWarehouseControllerTest { verify(service).pageAll(query); } + @Test + void platformPageOpenApiExposesOptionalCustomerScope() throws Exception { + var customerId = WarehouseRequests.QueryRequest.class.getRecordComponents()[0]; + Schema schema = WarehouseRequests.QueryRequest.class.getDeclaredMethod("customerId") + .getAnnotation(Schema.class); + Operation operation = PlatformWarehouseController.class + .getMethod("page", AuthenticatedUser.class, WarehouseRequests.QueryRequest.class) + .getAnnotation(Operation.class); + + assertEquals("customerId", customerId.getName()); + assertEquals(Long.class, customerId.getType()); + assertTrue(schema.nullable()); + assertTrue(schema.description().contains("platform")); + assertTrue(operation.description().contains("customerId")); + } + private static AuthenticatedUser platform() { return new AuthenticatedUser(1L, 0L, 0L, 1L, "operator", "PLATFORM", "WEB", Set.of()); } diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/repository/WarehouseRepositoryTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/repository/WarehouseRepositoryTest.java new file mode 100644 index 000000000..821981179 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/repository/WarehouseRepositoryTest.java @@ -0,0 +1,42 @@ +package com.baiyue.warehouse.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.mapper.ServiceCapabilityMapper; +import com.baiyue.warehouse.mapper.WarehouseMapper; +import org.junit.jupiter.api.Test; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class WarehouseRepositoryTest { + @SuppressWarnings({"unchecked", "rawtypes"}) + @Test + void platformPageAllFiltersByOptionalCustomerId() { + WarehouseMapper warehouses = mock(WarehouseMapper.class); + WarehouseRepository repository = new WarehouseRepository(); + ReflectionTestUtils.setField(repository, "warehouses", warehouses); + ReflectionTestUtils.setField(repository, "capabilities", mock(ServiceCapabilityMapper.class)); + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "warehouse-overseas-query-test"), + WarehouseEntity.class); + when(warehouses.selectPage(any(Page.class), any(LambdaQueryWrapper.class))) + .thenReturn(new Page(1, 20)); + + repository.pageAll(new WarehouseRequests.QueryRequest(10001L, 1, 20, "US", null, "ACTIVE")); + + var queryCaptor = org.mockito.ArgumentCaptor.forClass(LambdaQueryWrapper.class); + org.mockito.Mockito.verify(warehouses).selectPage(any(Page.class), queryCaptor.capture()); + LambdaQueryWrapper query = queryCaptor.getValue(); + assertThat(query.getSqlSegment()).contains("customer_id"); + assertThat(query.getParamNameValuePairs()).containsValue(10001L); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java index 4852efa8f..1bdcc2925 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java @@ -50,8 +50,8 @@ class WarehouseApplicationServiceTest { } @Test - void platformWarehousePageUsesGlobalRepositoryQuery() { - WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(1, 20, "ACTIVE", "US", null); + void platformWarehousePagePassesCustomerScopedQueryToRepository() { + WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(10001L, 1, 20, "US", null, "ACTIVE"); when(repository.pageAll(query)).thenReturn(new com.baiyue.common.core.api.PageResult<>(List.of(), 1, 20, 0, 0)); service.pageAll(query); diff --git a/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs b/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs index cfcd5a871..72214465c 100644 --- a/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs +++ b/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs @@ -111,6 +111,9 @@ assert.doesNotMatch(sql, /contact_name\s*=\s*VALUES\(contact_name\)/) assert.doesNotMatch(sql, /contact_phone\s*=\s*VALUES\(contact_phone\)/) assert.doesNotMatch(sql, /contact_email\s*=\s*VALUES\(contact_email\)/) assert.doesNotMatch(sql, /remark\s*=\s*VALUES\(remark\)/) -assert.doesNotMatch(sql, /INSERT INTO wh_service_capability/i) +assert.match(sql, /国内固定作业仓必须启用收货能力/) +assert.match(sql, /INSERT INTO wh_service_capability[\s\S]+?'RECEIVING'/i) +const fbaBlock = sql.slice(sql.indexOf('CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data')) +assert.doesNotMatch(fbaBlock, /INSERT INTO wh_service_capability/i) assert.match(sql, /FBA公共主数据地址种子ID与既有数据冲突/) assert.match(sql, /FBA公共地址不得创建平台作业能力/) From 8cc4c6f6da823fa23b3898f8a3c495c97628a231 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 21:46:19 +0800 Subject: [PATCH 059/115] fix: bind overseas receiving to shared warehouse --- .../auth/client/AuthorizationSnapshot.java | 8 ++ .../com/baiyue/auth/service/AuthService.java | 9 +- .../auth/service/OverseasTokenService.java | 9 +- .../auth/service/AuthServiceSmallJwtTest.java | 5 +- .../service/OverseasTokenServiceTest.java | 5 +- .../common/security/AuthenticatedUser.java | 12 ++- .../common/security/JwtClaimsMapper.java | 3 +- .../common/security/JwtClaimsMapperTest.java | 2 + deploy/sql/头程数据库统一增量-MySQL8.sql | 8 ++ deploy/sql/海外区域统一增量-MySQL8.sql | 55 +++++++++++ .../com/baiyue/iam/dto/AuthorizationDtos.java | 8 ++ .../iam/dto/PlatformUserManagementDtos.java | 18 +++- .../com/baiyue/iam/entity/IamMembership.java | 2 +- .../iam/mapper/AuthorizationQueryMapper.java | 4 + .../service/AuthorizationSnapshotService.java | 3 +- .../PlatformUserManagementService.java | 22 ++++- iam/src/main/resources/db/iam.sql | 1 + iam/src/main/resources/openapi/iam-v1.yaml | 3 + .../AuthorizationSnapshotServiceTest.java | 2 + .../PlatformUserManagementServiceTest.java | 18 ++++ .../baiyue/lastleg/entity/LastlegOrder.java | 2 + .../inboundplan/entity/InboundPlan.java | 1 + .../inboundplan/mapper/InboundPlanMapper.java | 7 ++ .../ReceivingContainerController.java | 38 +++++--- .../pda/dto/ReceivingContainerResponses.java | 13 ++- .../pda/entity/ReceivingContainerSession.java | 2 + .../gateway/HttpUnloadPhotoFileGateway.java | 36 +++++-- .../gateway/HttpWarehouseResourceGateway.java | 30 ++++++ .../pda/gateway/WarehouseResourceGateway.java | 6 ++ .../service/ReceivingContainerService.java | 94 ++++++++++++++++--- .../controller/InventoryQueryController.java | 29 +++--- .../query/mapper/InventoryQueryMapper.java | 2 - .../lastleg/mapper/LastlegOrderMapper.java | 7 ++ .../service/LoadingCompletedConsumer.java | 3 +- .../src/main/resources/db/lastleg.sql | 7 +- .../openapi/overseas-business-v1.yaml | 6 +- .../pda/ReceivingContainerContractTest.java | 6 ++ .../ReceivingContainerControllerTest.java | 14 ++- .../HttpUnloadPhotoFileGatewayTest.java | 56 +++++++++++ ...ReceivingContainerServiceBehaviorTest.java | 39 ++++++-- .../InventorySchemaContractTest.java | 13 +++ .../InventoryQueryControllerTest.java | 14 ++- .../service/LoadingCompletedConsumerTest.java | 2 +- scripts/admin-test-env-acceptance.mjs | 19 +++- .../manual-state-matrix-contract.test.mjs | 8 ++ .../phase3-loading-marker-contract.test.mjs | 9 ++ .../admin-test-env-modules/phase3-loading.mjs | 19 +++- .../phase3-producer-chain-contract.test.mjs | 6 +- .../phase3-warehouse-rbac.mjs | 5 +- .../phase4-carrier-nodes-contract.test.mjs | 13 +++ .../phase4-carrier-nodes.mjs | 3 +- ...ase4-cross-region-events-contract.test.mjs | 6 ++ .../phase4-overseas-pda-contract.test.mjs | 11 ++- .../phase4-overseas-pda.mjs | 64 ++++++++++--- ...ternalWarehouseResourceLockController.java | 12 +++ .../PlatformWarehouseController.java | 4 +- .../dto/SharedWarehouseReference.java | 4 + .../warehouse/dto/WarehouseRequests.java | 4 +- .../service/WarehouseApplicationService.java | 26 +++++ .../PlatformWarehouseControllerTest.java | 16 ++++ .../WarehouseApplicationServiceTest.java | 39 ++++++++ 61 files changed, 774 insertions(+), 118 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGatewayTest.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/SharedWarehouseReference.java diff --git a/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java index 47bc2000a..1061534be 100644 --- a/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java +++ b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java @@ -21,4 +21,12 @@ public class AuthorizationSnapshot { private Set roles; private Set permissions; private Long permissionVersion; + private Long overseasWarehouseId; + + public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode, + String clientType, Set roles, Set permissions, + Long permissionVersion) { + this(userId, tenantId, membershipId, appCode, clientType, roles, permissions, + permissionVersion, null); + } } diff --git a/auth/src/main/java/com/baiyue/auth/service/AuthService.java b/auth/src/main/java/com/baiyue/auth/service/AuthService.java index ec283c23e..f7cbb1ff4 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AuthService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AuthService.java @@ -111,7 +111,7 @@ public class AuthService { Instant issuedAt = Instant.now(); String jwtId = UUID.randomUUID().toString(); Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); - JwtClaimsSet claims = JwtClaimsSet.builder() + JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder() .issuer(properties.issuer()).audience(List.of(properties.audience())) .id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt) .subject(account.getId().toString()) @@ -127,8 +127,11 @@ public class AuthService { .claim("userId", account.getId()) .claim("tenantId", account.getDefaultTenantId()) .claim("appCode", account.getDefaultAppCode()) - .claim("permissionVersion", account.getTokenVersion()) - .build(); + .claim("permissionVersion", account.getTokenVersion()); + if (authorization.getOverseasWarehouseId() != null) { + claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId()); + } + JwtClaimsSet claims = claimsBuilder.build(); String accessToken = jwtEncoder.encode(JwtEncoderParameters.from( signingHeader(), claims)).getTokenValue(); diff --git a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java index 8e2945c5f..915604787 100644 --- a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java +++ b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java @@ -51,7 +51,7 @@ public class OverseasTokenService { public OverseasTokenResponse issue(AuthAccount account, AuthorizationSnapshot authorization) { Instant issuedAt = Instant.now(); Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); - JwtClaimsSet claims = JwtClaimsSet.builder() + JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder() .issuer(properties.issuer()).audience(List.of(properties.audience())) .subject(account.getId().toString()).id(UUID.randomUUID().toString()) .issuedAt(issuedAt).expiresAt(expiresAt) @@ -60,8 +60,11 @@ public class OverseasTokenService { .claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType()) .claim("roles", authorization.getRoles()) .claim("authorities", overseasAuthorities(authorization.getPermissions())) - .claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()) - .build(); + .claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()); + if (authorization.getOverseasWarehouseId() != null) { + claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId()); + } + JwtClaimsSet claims = claimsBuilder.build(); String token = encoder.encode(JwtEncoderParameters.from( JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue(); return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds()); diff --git a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java index aed82fc6f..562d09705 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java @@ -60,7 +60,7 @@ class AuthServiceSmallJwtTest { account.setDefaultAppCode("PLATFORM"); account.setTokenVersion(1); AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", - "WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list"), 1L); + "WEB", Set.of("WAREHOUSE_PICKER"), Set.of("platform:order:list"), 1L, 900L); Jwt encoded = org.mockito.Mockito.mock(Jwt.class); when(repository.findByUsername("operator")).thenReturn(account); @@ -82,12 +82,13 @@ class AuthServiceSmallJwtTest { .containsEntry("appCode", "PLATFORM") .containsEntry("permissionVersion", 1) .containsEntry("membership_id", 84L) + .containsEntry("overseas_warehouse_id", 900L) .doesNotContainKeys("roles", "authorities", "permissions", "scope"); assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api"); assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256); assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull(); verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L), - org.mockito.ArgumentMatchers.eq(Set.of("PLATFORM_ADMIN")), + org.mockito.ArgumentMatchers.eq(Set.of("WAREHOUSE_PICKER")), org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java index 20fd26464..2141df038 100644 --- a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java @@ -51,7 +51,7 @@ class OverseasTokenServiceTest { String lastlegAuthority = "lastleg:pda:operate"; String domesticAuthority = "platform:firstleg:order:list"; AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB", - Set.of("PLATFORM_ADMIN"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L); + Set.of("WAREHOUSE_PICKER"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L, 900L); Jwt encoded = org.mockito.Mockito.mock(Jwt.class); when(accounts.findById(42L)).thenReturn(account); when(authorizationLoader.load(account)).thenReturn(snapshot); @@ -71,7 +71,8 @@ class OverseasTokenServiceTest { .containsEntry("membership_id", 84L) .containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB") .containsEntry("permission_version", 7L) - .containsEntry("roles", Set.of("PLATFORM_ADMIN")) + .containsEntry("overseas_warehouse_id", 900L) + .containsEntry("roles", Set.of("WAREHOUSE_PICKER")) .containsEntry("authorities", Set.of(authority, lastlegAuthority)) .doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion"); assertThat(response.expiresIn()).isEqualTo(900); diff --git a/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java b/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java index 1f4927303..1391d548a 100644 --- a/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java +++ b/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java @@ -9,11 +9,19 @@ import java.util.Set; public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, String username, String appCode, String clientType, Set permissions, boolean impersonated, Long actorUserId, - Long actorMembershipId, boolean passwordChangeRequired) implements Principal { + Long actorMembershipId, boolean passwordChangeRequired, + Long overseasWarehouseId) implements Principal { public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, String username, String appCode, String clientType, Set permissions) { this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType, - permissions, false, null, null, false); + permissions, false, null, null, false, null); + } + public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, + String username, String appCode, String clientType, Set permissions, + boolean impersonated, Long actorUserId, Long actorMembershipId, + boolean passwordChangeRequired) { + this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType, + permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired, null); } public AuthenticatedUser { permissions = permissions == null ? Set.of() : Set.copyOf(permissions); diff --git a/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java b/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java index 84faded6a..bd5a37ced 100644 --- a/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java +++ b/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java @@ -43,7 +43,8 @@ public class JwtClaimsMapper { Boolean.TRUE.equals(jwt.getClaim("impersonated")), longClaim(jwt, "actor_user_id"), longClaim(jwt, "actor_membership_id"), - Boolean.TRUE.equals(jwt.getClaim("password_change_required"))); + Boolean.TRUE.equals(jwt.getClaim("password_change_required")), + longClaim(jwt, "overseas_warehouse_id")); List authorities = permissions.stream() .map(SimpleGrantedAuthority::new).toList(); return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities); diff --git a/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java b/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java index 3189cd48a..ce04f51be 100644 --- a/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java +++ b/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java @@ -81,6 +81,7 @@ class JwtClaimsMapperTest { .claim("actor_user_id", 42L) .claim("actor_membership_id", 43L) .claim("password_change_required", true) + .claim("overseas_warehouse_id", 900L) .build(); AuthenticatedUser user = new JwtClaimsMapper().map(jwt).getPrincipal(); @@ -88,5 +89,6 @@ class JwtClaimsMapperTest { assertThat(user.actorUserId()).isEqualTo(42L); assertThat(user.actorMembershipId()).isEqualTo(43L); assertThat(user.passwordChangeRequired()).isTrue(); + assertThat(user.overseasWarehouseId()).isEqualTo(900L); } } diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 9e282cfd7..08bbccb59 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -403,6 +403,7 @@ CREATE TABLE IF NOT EXISTS iam_membership ( user_id BIGINT NOT NULL COMMENT 'IAM用户资料ID', member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型:EMPLOYEE企业员工等', member_no VARCHAR(64) NULL COMMENT '客户企业内部成员编号', + overseas_warehouse_id BIGINT NULL COMMENT '海外仓PDA操作员绑定的唯一实际海外仓ID', status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态:ACTIVE在职、DISABLED停用、LEFT已离开', joined_at DATETIME(3) NULL COMMENT '加入客户企业时间', left_at DATETIME(3) NULL COMMENT '离开客户企业时间', @@ -417,6 +418,13 @@ CREATE TABLE IF NOT EXISTS iam_membership ( KEY idx_membership_tenant (tenant_id, status) ) ENGINE=InnoDB COMMENT='客户成员关系表:表示用户在客户企业中的成员身份,是分配企业客户端角色的主体'; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='iam_membership' AND column_name='overseas_warehouse_id'), + 'SELECT 1', 'ALTER TABLE iam_membership ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''海外仓PDA操作员绑定的唯一实际海外仓ID'' AFTER member_no')); +PREPARE iam_membership_overseas_warehouse_stmt FROM @ddl; +EXECUTE iam_membership_overseas_warehouse_stmt; +DEALLOCATE PREPARE iam_membership_overseas_warehouse_stmt; + CREATE TABLE IF NOT EXISTS iam_role ( id BIGINT NOT NULL COMMENT '角色ID', tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '角色作用域:0为平台预置模板,正数为客户ID和数据隔离键', diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index c02a02b7f..772b25e1e 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -386,8 +386,40 @@ UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id SET l.destination_type=COALESCE(NULLIF(o.destination_type,''),CASE WHEN NULLIF(l.fba_warehouse_code,'') IS NULL THEN 'OVERSEAS' ELSE 'FBA' END) WHERE l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS'; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_id'), + 'SELECT 1','ALTER TABLE ll_order ADD COLUMN source_warehouse_id VARCHAR(128) NULL COMMENT ''来源国内装柜仓ID快照'' AFTER customer_name')); +PREPARE phase4_source_warehouse_id_stmt FROM @ddl; EXECUTE phase4_source_warehouse_id_stmt; DEALLOCATE PREPARE phase4_source_warehouse_id_stmt; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_name'), + 'SELECT 1','ALTER TABLE ll_order ADD COLUMN source_warehouse_name VARCHAR(255) NULL COMMENT ''来源国内装柜仓名称快照'' AFTER source_warehouse_id')); +PREPARE phase4_source_warehouse_name_stmt FROM @ddl; EXECUTE phase4_source_warehouse_name_stmt; DEALLOCATE PREPARE phase4_source_warehouse_name_stmt; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='overseas_warehouse_id'), + 'SELECT 1','ALTER TABLE ll_inbound_plan ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''海外实际收货共享物理仓ID,由PDA认领柜号时冻结'' AFTER container_no')); +PREPARE phase4_plan_receiving_warehouse_stmt FROM @ddl; EXECUTE phase4_plan_receiving_warehouse_stmt; DEALLOCATE PREPARE phase4_plan_receiving_warehouse_stmt; + +UPDATE ll_order o JOIN ll_loading_event_inbox i ON i.event_id=o.source_event_id +SET o.source_warehouse_id=COALESCE(o.source_warehouse_id,JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId'))), + o.source_warehouse_name=COALESCE(o.source_warehouse_name,JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseName'))), + o.overseas_warehouse_id=NULL,o.overseas_warehouse_name=NULL +WHERE o.current_status<>'INBOUND_COMPLETED' + AND o.overseas_warehouse_id=JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId')); + +DROP PROCEDURE IF EXISTS assert_pending_orders_do_not_use_source_warehouse; +DELIMITER $$ +CREATE PROCEDURE assert_pending_orders_do_not_use_source_warehouse() +BEGIN + IF EXISTS(SELECT 1 FROM ll_order o JOIN ll_loading_event_inbox i ON i.event_id=o.source_event_id + WHERE o.current_status<>'INBOUND_COMPLETED' AND o.overseas_warehouse_id IS NOT NULL + AND o.overseas_warehouse_id=JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId'))) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='未完成尾程单仍将国内来源仓冒充海外实际收货仓'; + END IF; +END$$ +DELIMITER ; +CALL assert_pending_orders_do_not_use_source_warehouse(); +DROP PROCEDURE assert_pending_orders_do_not_use_source_warehouse; + CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', + overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键', active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间', @@ -397,6 +429,29 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at) ) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话'; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_id'), + 'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''操作员Token绑定并冻结的海外实际收货共享物理仓ID'' AFTER container_no')); +PREPARE phase4_session_receiving_warehouse_stmt FROM @ddl; EXECUTE phase4_session_receiving_warehouse_stmt; DEALLOCATE PREPARE phase4_session_receiving_warehouse_stmt; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_name'), + 'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''仓库服务校验后的海外实际收货共享物理仓名称快照'' AFTER overseas_warehouse_id')); +PREPARE phase4_session_receiving_warehouse_name_stmt FROM @ddl; EXECUTE phase4_session_receiving_warehouse_name_stmt; DEALLOCATE PREPARE phase4_session_receiving_warehouse_name_stmt; + +DROP PROCEDURE IF EXISTS assert_active_receiving_session_has_warehouse; +DELIMITER $$ +CREATE PROCEDURE assert_active_receiving_session_has_warehouse() +BEGIN + IF EXISTS(SELECT 1 FROM ll_receiving_container_session WHERE active_container_key IS NOT NULL + AND (overseas_warehouse_id IS NULL OR overseas_warehouse_name IS NULL OR overseas_warehouse_name='')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='存在无法安全推断海外实际收货仓的未完成PDA柜会话'; + END IF; +END$$ +DELIMITER ; +CALL assert_active_receiving_session_has_warehouse(); +DROP PROCEDURE assert_active_receiving_session_has_warehouse; +ALTER TABLE ll_receiving_container_session + MODIFY COLUMN overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', + MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照'; + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='sign_photos'), 'ALTER TABLE ll_receiving_container_session MODIFY COLUMN sign_photos JSON NULL COMMENT ''签收卸柜照片角色与文件ID列表''', 'ALTER TABLE ll_receiving_container_session ADD COLUMN sign_photos JSON NULL COMMENT ''签收卸柜照片角色与文件ID列表'' AFTER status')); diff --git a/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java index 6867ebf88..acc77d13d 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java @@ -40,5 +40,13 @@ public final class AuthorizationDtos { private Set roles; private Set permissions; private Long permissionVersion; + private Long overseasWarehouseId; + + public SnapshotResponse(Long userId, Long tenantId, Long membershipId, String appCode, + String clientType, Set roles, Set permissions, + Long permissionVersion) { + this(userId, tenantId, membershipId, appCode, clientType, roles, permissions, + permissionVersion, null); + } } } diff --git a/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java b/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java index 7c28c5dd7..79dae468b 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java @@ -20,16 +20,28 @@ public final class PlatformUserManagementDtos { @Email @Size(max = 128) String email, @NotNull Long tenantId, @NotNull List roleIds, + Long overseasWarehouseId, @NotBlank @Size(min = 8, max = 64) @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password, - @NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { } + @NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { + public CreateUserRequest(String username, String displayName, String phone, String email, + Long tenantId, List roleIds, String password, String status) { + this(username, displayName, phone, email, tenantId, roleIds, null, password, status); + } + } public record UpdateUserRequest( @NotBlank @Size(max = 64) String displayName, @NotBlank @Pattern(regexp = "^1\\d{10}$", message = "手机号格式不正确") String phone, @Email @Size(max = 128) String email, @NotNull Long tenantId, - @NotNull List roleIds) { } + @NotNull List roleIds, + Long overseasWarehouseId) { + public UpdateUserRequest(String displayName, String phone, String email, + Long tenantId, List roleIds) { + this(displayName, phone, email, tenantId, roleIds, null); + } + } public record ChangeStatusRequest(@NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { } public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64) @@ -40,7 +52,7 @@ public final class PlatformUserManagementDtos { String displayName, String phone, String email, Long tenantId, String tenantName, String memberType, String memberNo, String status, List roleIds, List roleNames, LocalDateTime lastLoginAt, - boolean protectedAccount) { } + boolean protectedAccount, Long overseasWarehouseId) { } public record TenantOption(Long id, String name, String status) { } public record RoleOption(Long id, Long tenantId, String name, String status) { } diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java b/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java index 1b045d0ac..dfdaa658a 100644 --- a/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java +++ b/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java @@ -9,6 +9,6 @@ import com.baiyue.common.core.model.BaseEntity; import com.baomidou.mybatisplus. @Getter @Setter @TableName("iam_membership") public class IamMembership extends BaseEntity { - @TableId private Long id; private Long tenantId; private Long userId; private String memberType; private String memberNo; private String status; private LocalDateTime joinedAt; private LocalDateTime leftAt; + @TableId private Long id; private Long tenantId; private Long userId; private String memberType; private String memberNo; private Long overseasWarehouseId; private String status; private LocalDateTime joinedAt; private LocalDateTime leftAt; } diff --git a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java index f38fff466..6d57e9632 100644 --- a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java +++ b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java @@ -65,4 +65,8 @@ public interface AuthorizationQueryMapper { @Select("SELECT COALESCE(MAX(permission_version),0) FROM iam_security_subject_version WHERE auth_user_id=#{userId}") long findPermissionVersion(@Param("userId") long userId); + + @Select("SELECT overseas_warehouse_id FROM iam_membership WHERE id=#{membershipId} AND tenant_id=#{tenantId} AND status='ACTIVE' AND deleted=0") + Long findOverseasWarehouseId(@Param("tenantId") long tenantId, + @Param("membershipId") long membershipId); } diff --git a/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java index 1592dbcf0..df90f8b1e 100644 --- a/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java +++ b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java @@ -47,7 +47,8 @@ public class AuthorizationSnapshotService { request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode)); return new SnapshotResponse(request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode, appCode, Set.copyOf(roles), Set.copyOf(permissions), - mapper.findPermissionVersion(request.getUserId())); + mapper.findPermissionVersion(request.getUserId()), + mapper.findOverseasWarehouseId(request.getTenantId(), request.getMembershipId())); } /** diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java index 73a8e7fe9..296100ffb 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -75,6 +75,7 @@ public class PlatformUserManagementService { long tenantId = request.tenantId(); requirePlatformTenant(tenantId); List roleIds = validateRoles(tenantId, request.roleIds()); + Long overseasWarehouseId = validateWarehouseBinding(roleIds, request.overseasWarehouseId()); long authUserId = IdWorker.getId(); long userId = IdWorker.getId(); long membershipId = IdWorker.getId(); @@ -99,6 +100,7 @@ public class PlatformUserManagementService { membership.setUserId(userId); membership.setMemberType("EMPLOYEE"); membership.setMemberNo(memberNo(tenantId, membershipId)); + membership.setOverseasWarehouseId(overseasWarehouseId); membership.setStatus(request.status()); membership.setJoinedAt(LocalDateTime.now()); membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); @@ -121,6 +123,7 @@ public class PlatformUserManagementService { long newTenantId = request.tenantId(); requirePlatformTenant(newTenantId); List roleIds = validateRoles(newTenantId, request.roleIds()); + Long overseasWarehouseId = validateWarehouseBinding(roleIds, request.overseasWarehouseId()); List oldRoleIds = members.findRoleIds(oldTenantId, membershipId); if (isProtectedPlatformAdmin(membership, oldRoleIds) && (newTenantId != PLATFORM_TENANT_ID || !containsPlatformAdminRole(roleIds))) { @@ -134,6 +137,7 @@ public class PlatformUserManagementService { boolean profileChanged = !Objects.equals(user.getDisplayName(), request.displayName().trim()); boolean scopeChanged = oldTenantId != newTenantId; boolean rolesChanged = !new java.util.HashSet<>(oldRoleIds).equals(new java.util.HashSet<>(roleIds)); + boolean warehouseChanged = !Objects.equals(membership.getOverseasWarehouseId(), overseasWarehouseId); if (profileChanged) call(() -> authClient.profile(internalToken, user.getAuthUserId(), new AuthAccountClient.ProfileRequest(request.displayName().trim()))); @@ -145,6 +149,7 @@ public class PlatformUserManagementService { user.setEmail(trimToNull(request.email())); user.setUserType(userType(newTenantId)); membership.setTenantId(newTenantId); + membership.setOverseasWarehouseId(overseasWarehouseId); if (scopeChanged && members.existsMemberNo(newTenantId, membership.getMemberNo(), membershipId)) { membership.setMemberNo(memberNo(newTenantId, membershipId)); } @@ -152,7 +157,7 @@ public class PlatformUserManagementService { members.save(membership); if (scopeChanged) members.replaceRoles(oldTenantId, membershipId, List.of()); members.replaceRoles(newTenantId, membershipId, roleIds); - if (rolesChanged && !profileChanged && !scopeChanged) { + if ((rolesChanged || warehouseChanged) && !profileChanged && !scopeChanged) { call(() -> authClient.revokeSessions(internalToken, new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(user.getAuthUserId())))); } @@ -222,7 +227,8 @@ public class PlatformUserManagementService { return new UserResponse(membership.getId(), user.getId(), user.getAuthUserId(), account.username(), user.getDisplayName(), user.getPhone(), user.getEmail(), membership.getTenantId(), tenantName, membership.getMemberType(), membership.getMemberNo(), membership.getStatus(), roleIds, - roleNames, account.lastLoginAt(), isProtectedPlatformAdmin(membership, roleIds)); + roleNames, account.lastLoginAt(), isProtectedPlatformAdmin(membership, roleIds), + membership.getOverseasWarehouseId()); } private List platformRoles() { @@ -245,6 +251,18 @@ public class PlatformUserManagementService { return roleIds; } + private Long validateWarehouseBinding(List roleIds, Long overseasWarehouseId) { + boolean picker = roleIds.stream().map(roleMapper::selectById).filter(Objects::nonNull) + .anyMatch(role -> "WAREHOUSE_PICKER".equals(role.getRoleCode())); + if (picker && (overseasWarehouseId == null || overseasWarehouseId <= 0)) { + throw new BizException(ResultCode.BAD_REQUEST, "海外仓拣货员必须绑定一个有效海外仓"); + } + if (!picker && overseasWarehouseId != null) { + throw new BizException(ResultCode.BAD_REQUEST, "仅海外仓拣货员可绑定海外仓"); + } + return overseasWarehouseId; + } + private boolean isProtectedPlatformAdmin(IamMembership membership, List roleIds) { return membership.getTenantId() == PLATFORM_TENANT_ID && containsPlatformAdminRole(roleIds); } diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql index 65b0d2654..c2dfaffb3 100644 --- a/iam/src/main/resources/db/iam.sql +++ b/iam/src/main/resources/db/iam.sql @@ -114,6 +114,7 @@ CREATE TABLE IF NOT EXISTS iam_membership ( user_id BIGINT NOT NULL COMMENT 'IAM用户资料ID', member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型:EMPLOYEE企业员工等', member_no VARCHAR(64) NULL COMMENT '客户企业内部成员编号', + overseas_warehouse_id BIGINT NULL COMMENT '海外仓PDA操作员绑定的唯一实际海外仓ID', status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态:ACTIVE在职、DISABLED停用、LEFT已离开', joined_at DATETIME(3) NULL COMMENT '加入客户企业时间', left_at DATETIME(3) NULL COMMENT '离开客户企业时间', diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 5f5b22b77..fe88bdc06 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -1279,6 +1279,7 @@ components: email: {type: string, format: email, maxLength: 128} tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0} roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} + overseasWarehouseId: {type: integer, format: int64, nullable: true, description: 选择WAREHOUSE_PICKER角色时必填且只能绑定一个实际海外仓;其他角色必须为空} password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true} status: {type: string, enum: [ACTIVE, DISABLED]} UpdatePlatformManagedUserRequest: @@ -1290,6 +1291,7 @@ components: email: {type: string, format: email, maxLength: 128} tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0} roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} + overseasWarehouseId: {type: integer, format: int64, nullable: true, description: 选择WAREHOUSE_PICKER角色时必填且只能绑定一个实际海外仓;其他角色必须为空} CreateInternalUserRequest: type: object required: [displayName] @@ -1362,6 +1364,7 @@ components: roleNames: {type: array, items: {type: string}} lastLoginAt: {type: string, format: date-time, nullable: true} protectedAccount: {type: boolean} + overseasWarehouseId: {type: string, pattern: '^\d+$', nullable: true, description: 海外仓PDA操作员绑定的唯一实际海外仓ID} CreateMemberRequest: type: object required: [username, password, displayName, memberType] diff --git a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java index 550066f8d..eeab42b78 100644 --- a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java @@ -29,6 +29,7 @@ class AuthorizationSnapshotServiceTest { .thenReturn(List.of("ADMIN", "OPERATOR")); when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")) .thenReturn(List.of("order:read", "order:create")); + when(mapper.findOverseasWarehouseId(7L, 9L)).thenReturn(900L); var result = service .load(new SnapshotRequest(42L, 7L, 9L, "merchant")); @@ -37,6 +38,7 @@ class AuthorizationSnapshotServiceTest { assertThat(result.getClientType()).isEqualTo("MERCHANT"); assertThat(result.getRoles()).containsExactlyInAnyOrder("ADMIN", "OPERATOR"); assertThat(result.getPermissions()).containsExactlyInAnyOrder("order:read", "order:create"); + assertThat(result.getOverseasWarehouseId()).isEqualTo(900L); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java index 42df71a34..8f45baf95 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -57,6 +57,7 @@ class PlatformUserManagementServiceTest { when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success( new AuthAccountClient.AccountSummary(10L, "zhangwei", "张伟", "ACTIVE", LocalDateTime.of(2026, 6, 1, 10, 30), 0L, 30L, "PLATFORM"))); + when(auth.revokeSessions(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); } @Test @@ -117,6 +118,23 @@ class PlatformUserManagementServiceTest { verify(auth, never()).scope(anyString(), anyLong(), any()); } + @Test + void warehousePickerRequiresAndPersistsSingleOverseasWarehouseBinding() { + platformRole.setRoleCode("WAREHOUSE_PICKER"); + UpdateUserRequest missing = new UpdateUserRequest("张伟", "13800138000", null, + 0L, List.of(40001L), null); + assertThrows(BizException.class, + () -> service.update(30L, missing, Set.of("platform:user:grant-role"))); + + UpdateUserRequest bound = new UpdateUserRequest("张伟", "13800138000", null, + 0L, List.of(40001L), 900L); + UserResponse response = service.update(30L, bound, Set.of("platform:user:grant-role")); + + assertEquals(900L, membership.getOverseasWarehouseId()); + assertEquals(900L, response.overseasWarehouseId()); + verify(auth).revokeSessions(eq("internal-token"), any()); + } + @Test void currentPlatformOperatorCannotDisableSelf() { assertThrows(BizException.class, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java index 57229f0e4..89234d307 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java @@ -24,6 +24,8 @@ public class LastlegOrder { private String sourceOrderNo; private String rootOrderId; private String customerName; + private String sourceWarehouseId; + private String sourceWarehouseName; private String overseasWarehouseId; private String overseasWarehouseName; private String fbaWarehouseCode; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java index ec6ccd5ad..79ffac343 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java @@ -17,6 +17,7 @@ public class InboundPlan { private String customerId; private Long sourceOrderId; private String containerNo; + private Long overseasWarehouseId; private String status; private Integer lineCount; private Integer palletCount; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java index 68bc44b2c..534969f23 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java @@ -7,6 +7,7 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; import java.util.List; @@ -47,4 +48,10 @@ public interface InboundPlanMapper extends BaseMapper { @Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND source_order_id=#{sourceOrderId} LIMIT 1") InboundPlan findBySourceOrder(@Param("customerId") String customerId, @Param("sourceOrderId") Long sourceOrderId); + + @Update("UPDATE ll_inbound_plan SET overseas_warehouse_id=#{warehouseId},updated_at=NOW(3) " + + "WHERE id=#{planId} AND customer_id=#{customerId} " + + "AND (overseas_warehouse_id IS NULL OR overseas_warehouse_id=#{warehouseId})") + int claimReceivingWarehouse(@Param("customerId") String customerId, @Param("planId") long planId, + @Param("warehouseId") long warehouseId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java index ce144e37c..6adcf7ff5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -38,7 +38,7 @@ public class ReceivingContainerController { public ApiResponse> candidates( @AuthenticationPrincipal AuthenticatedUser user) { Identity identity = identity(user); - return ApiResponse.success(service.candidates(identity.operatorKey())); + return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId())); } @GetMapping("/{containerNo}") @@ -46,21 +46,21 @@ public class ReceivingContainerController { public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String containerNo) { Identity identity = identity(user); - return ApiResponse.success(service.detail(identity.operatorKey(), containerNo)); + return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), containerNo)); } @PostMapping("/{containerNo}/prepare") public ApiResponse prepare(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) { Identity identity = identity(user); - return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(), + return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(), identity.overseasWarehouseId(), containerNo, request)); } @PostMapping(value = "/current/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user, @RequestPart("file") MultipartFile file) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); if (user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "PDA user ID is required"); return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file)); } @@ -68,13 +68,13 @@ public class ReceivingContainerController { @PostMapping("/current/sign") public ApiResponse sign(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.Sign request) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); return ApiResponse.success(service.sign(identity.operatorKey(), identity.operatorName(), request)); } @GetMapping("/current") public ApiResponse current(@AuthenticationPrincipal AuthenticatedUser user) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); return ApiResponse.success(service.current(identity.operatorKey())); } @@ -82,7 +82,7 @@ public class ReceivingContainerController { public ApiResponse resolveCarton( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.CartonResolve request) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request)); } @@ -90,7 +90,7 @@ public class ReceivingContainerController { public ApiResponse resolveSku( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.SkuResolve request) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); return ApiResponse.success(service.resolveSku(identity.operatorKey(), request)); } @@ -98,28 +98,28 @@ public class ReceivingContainerController { public ApiResponse resolvePallet( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.PalletResolve request) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); return ApiResponse.success(service.resolvePallet(identity.operatorKey(), request)); } @PostMapping("/current/receipts/bulk") public ApiResponse receiveBulk(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.BulkReceive request) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); return ApiResponse.success(service.receiveBulk(identity.operatorKey(), identity.operatorName(), request)); } @PostMapping("/current/receipts/carton") public ApiResponse receiveCarton(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.CartonReceive request) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); return ApiResponse.success(service.receiveCarton(identity.operatorKey(), identity.operatorName(), request)); } @PostMapping("/current/complete") public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.Action request) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); return ApiResponse.success(service.complete(identity.operatorKey(), request)); } @@ -131,10 +131,20 @@ public class ReceivingContainerController { if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required"); } + if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "PDA operator must be bound to one overseas warehouse"); + } String operator = String.valueOf(user.userId()); return new Identity(operator, - user.username() == null || user.username().isBlank() ? operator : user.username()); + user.username() == null || user.username().isBlank() ? operator : user.username(), + user.overseasWarehouseId()); } - private record Identity(String operatorKey, String operatorName) { } + private Identity currentIdentity(AuthenticatedUser user) { + Identity identity = identity(user); + service.requireCurrentWarehouse(identity.operatorKey(), identity.overseasWarehouseId()); + return identity; + } + + private record Identity(String operatorKey, String operatorName, long overseasWarehouseId) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java index 385113cf1..656c83a1a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java @@ -10,7 +10,8 @@ public final class ReceivingContainerResponses { List destinationCodes, int plannedPalletCount, int totalPackages, int receivedPackages, boolean currentAccount) { } - public record Session(String containerNo, String status, String operatorName, int version, + public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName, + String status, String operatorName, int version, List photos, LocalDateTime preparedAt, LocalDateTime signedAt, LocalDateTime receivingAt, LocalDateTime completedAt, int totalPackages, int receivedPackages, int pendingPackages, @@ -18,19 +19,23 @@ public final class ReceivingContainerResponses { public record SignedPhoto(ReceivingPhotoRole role, long fileId) { } - public record Inbound(String inboundCode, String destinationType, String destinationWarehouseId, + public record Inbound(String inboundCode, String destinationType, + String overseasWarehouseId, String overseasWarehouseName, + String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String fbaWarehouseCode, int plannedPalletCount, int totalPackages, int receivedPackages, int pendingPackages) { } public record CartonResolved(String cartonMark, String skuCode, String inboundCode, - String destinationType, String destinationWarehouseId, + String destinationType, String overseasWarehouseId, + String overseasWarehouseName, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String fbaWarehouseCode, int plannedPalletCount, int totalPackages, int receivedPackages, int pendingPackages) { } public record SkuResolved(String skuCode, String productName, String inboundCode, - String destinationType, String destinationWarehouseId, + String destinationType, String overseasWarehouseId, + String overseasWarehouseName, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String fbaWarehouseCode, int plannedPalletCount, int totalPackages, int receivedPackages, int pendingPackages) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java index 68a074b0b..2667cb427 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java @@ -15,6 +15,8 @@ public class ReceivingContainerSession { private Long id; private String customerId; private String containerNo; + private Long overseasWarehouseId; + private String overseasWarehouseName; private String operatorKey; private String operatorName; private String activeOperatorKey; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java index debcb721b..3195adf30 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java @@ -4,12 +4,14 @@ import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatusCode; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.client.MultipartBodyBuilder; import org.springframework.core.io.ByteArrayResource; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; import java.io.IOException; @@ -42,10 +44,14 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway { .header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", String.valueOf(operatorId)) .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() .onStatus(HttpStatusCode::isError, (request, result) -> { - throw new IllegalArgumentException("Unload photo upload failed"); + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "Unload photo upload failed"); }).body(MetadataEnvelope.class); + requireSuccess(response, "Unload photo upload failed"); Metadata data = response == null ? null : response.data(); - if (data == null || data.fileId() == null) throw new IllegalArgumentException("File service returned no photo metadata"); + if (data == null || data.fileId() == null) throw new ResponseStatusException( + HttpStatus.SERVICE_UNAVAILABLE, "File service returned no photo metadata"); return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes()); } @@ -58,22 +64,40 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway { tenantId, BUSINESS_TYPE, taskId, fileId) .header("X-Internal-Service-Token", internalToken) .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { - throw new IllegalArgumentException("Unload photo does not exist or its ownership is invalid"); + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "Unload photo does not exist or its ownership is invalid"); }).body(MetadataEnvelope.class); + requireSuccess(response, "Unload photo does not exist or its ownership is invalid"); Metadata data = response == null ? null : response.data(); if (data == null || data.fileId() == null || data.fileId() != fileId || data.tenantId() == null || data.tenantId() != tenantId || !BUSINESS_TYPE.equals(data.businessType()) || !String.valueOf(taskId).equals(data.businessId())) { - throw new IllegalArgumentException("Unload photo metadata does not match the PDA task"); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Unload photo metadata does not match the PDA task"); } } private long tenantId(String customerId) { try { return Long.parseLong(customerId); } - catch (NumberFormatException exception) { throw new IllegalArgumentException("Customer ID must be numeric", exception); } + catch (NumberFormatException exception) { throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Customer ID must be numeric", exception); } } - private record MetadataEnvelope(Metadata data) {} + private static void requireSuccess(MetadataEnvelope response, String fallback) { + if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback); + if ("200".equals(response.code())) return; + HttpStatus status = switch (String.valueOf(response.code())) { + case "A0400", "A0422" -> HttpStatus.BAD_REQUEST; + case "A0403" -> HttpStatus.FORBIDDEN; + case "A0404" -> HttpStatus.NOT_FOUND; + default -> HttpStatus.SERVICE_UNAVAILABLE; + }; + throw new ResponseStatusException(status, + response.message() == null || response.message().isBlank() ? fallback : response.message()); + } + + private record MetadataEnvelope(String code, String message, Metadata data) {} private record Metadata(Long fileId, Long tenantId, String businessType, String businessId, String fileName, String contentType, Long sizeBytes) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java index c28a0ab89..62c41435f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -2,8 +2,10 @@ package com.baiyue.lastleg.inboundplan.pda.gateway; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatusCode; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; +import org.springframework.web.server.ResponseStatusException; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; @@ -111,9 +113,37 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { response.data().fbaWarehouseCode(), response.data().version()); } + @Override + public WarehouseReference requireActiveSharedWarehouse(long warehouseId) { + WarehouseReferenceResponse response = client.get() + .uri("/internal/v1/warehouse-resource-locks/shared-warehouses/{warehouseId}", warehouseId) + .header("X-Internal-Service-Token", internalToken) + .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().value() == 404 + ? HttpStatus.NOT_FOUND : (result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE), + "Shared overseas warehouse lookup failed"); + }).body(WarehouseReferenceResponse.class); + if (response == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Shared overseas warehouse service returned no response"); + } + if ("A0404".equals(response.code())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "Active shared overseas warehouse was not found"); + } + if (!"200".equals(response.code()) || response.data() == null + || response.data().warehouseId() != warehouseId || !"ACTIVE".equals(response.data().status())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Invalid shared overseas warehouse binding"); + } + return response.data(); + } + private record WarehousePalletResponse(WarehousePalletData data) {} private record WarehousePalletData(long id, String palletCode, long warehouseId, long locationId, String status, String fbaWarehouseCode, int version) {} private record ReserveEnvelope(ReserveData data) {} private record ReserveData(long lockId) {} + private record WarehouseReferenceResponse(String code, String message, WarehouseReference data) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java index 0350073c4..1d6b66f96 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java @@ -8,6 +8,9 @@ public interface WarehouseResourceGateway { void scrap(String customerId, long resourceLockId); void scrapPallet(String customerId, long palletId); WarehousePalletReference resolvePallet(String palletCode); + default WarehouseReference requireActiveSharedWarehouse(long warehouseId) { + throw new UnsupportedOperationException("Shared warehouse resolution is not configured"); + } record WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId, String status, String fbaWarehouseCode, int version) { @@ -15,4 +18,7 @@ public interface WarehouseResourceGateway { this(palletId, palletNo, 0L, locationId, null, null, 0); } } + + record WarehouseReference(long warehouseId, String warehouseCode, String warehouseName, + String status) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 1f616904a..80710f77c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -80,8 +80,9 @@ public class ReceivingContainerService { .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); } - public List candidates(String operatorKey) { + public List candidates(String operatorKey, long overseasWarehouseId) { ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); + requireSessionWarehouse(current, overseasWarehouseId); Map> grouped = new LinkedHashMap<>(); plans.selectList(new LambdaQueryWrapper() .ne(InboundPlan::getStatus, "COMPLETED").orderByAsc(InboundPlan::getCreatedAt)).forEach(plan -> { @@ -89,7 +90,7 @@ public class ReceivingContainerService { grouped.computeIfAbsent(plan.getContainerNo(), ignored -> new ArrayList<>()).add(plan); } }); - return grouped.entrySet().stream().map(entry -> { + return grouped.entrySet().stream().filter(entry -> eligible(entry.getValue(), overseasWarehouseId)).map(entry -> { requireSingleCustomer(entry.getValue(), entry.getKey()); Totals totals = totals(entry.getValue()); boolean active = current != null && entry.getKey().equals(current.getContainerNo()); @@ -99,27 +100,39 @@ public class ReceivingContainerService { }).toList(); } - public ReceivingContainerResponses.Session detail(String operatorKey, String containerNo) { + public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) { List containerPlans = requirePlans(containerNo); String customerId = requireSingleCustomer(containerPlans, containerNo); + if (!eligible(containerPlans, overseasWarehouseId)) throw notFound("Container is assigned to another overseas warehouse"); ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); + requireSessionWarehouse(current, overseasWarehouseId); if (current != null && !containerNo.equals(current.getContainerNo())) { throw conflict("Current account is locked to another selected container"); } + WarehouseResourceGateway.WarehouseReference receivingWarehouse = + warehouse.requireActiveSharedWarehouse(overseasWarehouseId); return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView( - customerId, operatorKey, containerNo), containerPlans); + customerId, operatorKey, receivingWarehouse, containerNo), containerPlans); } @Transactional - public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, String containerNo, + public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, long overseasWarehouseId, String containerNo, ReceivingContainerRequests.Action request) { PrepareCommand command = new PrepareCommand(containerNo, request.idempotencyKey()); ReceivingContainerOperation replay = replay(operatorKey, "PREPARE", request.idempotencyKey(), command); - if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); + if (replay != null) { + ReceivingContainerResponses.Session result = read(replay, ReceivingContainerResponses.Session.class); + if (!Objects.equals(result.overseasWarehouseId(), overseasWarehouseId)) { + throw conflict("Prepared container belongs to another overseas warehouse"); + } + return result; + } List containerPlans = requirePlans(containerNo); String customerId = requireSingleCustomer(containerPlans, containerNo); + if (!eligible(containerPlans, overseasWarehouseId)) throw conflict("Container is assigned to another overseas warehouse"); String activeOperatorKey = activeOperatorKey(operatorKey); ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey); + requireSessionWarehouse(active, overseasWarehouseId); if (active != null) { if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one"); ReceivingContainerResponses.Session result = snapshot(active, containerPlans); @@ -128,9 +141,14 @@ public class ReceivingContainerService { if (sessions.lockActiveByContainer(activeContainerKey(containerNo)) != null) { throw conflict("Container is already signed or prepared by another account"); } + WarehouseResourceGateway.WarehouseReference receivingWarehouse = + warehouse.requireActiveSharedWarehouse(overseasWarehouseId); + claimReceivingWarehouse(customerId, containerPlans, receivingWarehouse); LocalDateTime now = LocalDateTime.now(); ReceivingContainerSession session = new ReceivingContainerSession(); session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo); + session.setOverseasWarehouseId(overseasWarehouseId); + session.setOverseasWarehouseName(receivingWarehouse.warehouseName()); session.setOperatorKey(operatorKey); session.setOperatorName(operatorName); session.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(containerNo)); session.setStatus("WAIT_SIGN"); session.setPreparedAt(now); session.setCreatedAt(now); session.setUpdatedAt(now); @@ -175,6 +193,10 @@ public class ReceivingContainerService { return snapshot(session, requirePlans(session.getCustomerId(), session.getContainerNo())); } + public void requireCurrentWarehouse(String operatorKey, long overseasWarehouseId) { + requireSessionWarehouse(sessions.findActiveByOperator(activeOperatorKey(operatorKey)), overseasWarehouseId); + } + public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey, ReceivingContainerRequests.CartonResolve request) { ReceivingContainerSession session = requireSigned(operatorKey); @@ -190,7 +212,8 @@ public class ReceivingContainerService { } ReceivingContainerResponses.Inbound inbound = inbound(ref); return new ReceivingContainerResponses.CartonResolved(carton.getCartonMark(), carton.getSkuCode(), - inbound.inboundCode(), inbound.destinationType(), inbound.destinationWarehouseId(), + inbound.inboundCode(), inbound.destinationType(), inbound.overseasWarehouseId(), + inbound.overseasWarehouseName(), inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages()); } @@ -403,7 +426,8 @@ public class ReceivingContainerService { long actualWarehouse; try { actualWarehouse = Long.parseLong(ref.order().getOverseasWarehouseId()); } catch (Exception exception) { throw conflict("Inbound order has no valid receiving warehouse ID"); } - if (pallet.warehouseId() != actualWarehouse) throw conflict("Pallet belongs to another overseas warehouse"); + long assignedWarehouse = ref.plan().getOverseasWarehouseId() == null ? actualWarehouse : ref.plan().getOverseasWarehouseId(); + if (pallet.warehouseId() != assignedWarehouse) throw conflict("Pallet belongs to another overseas warehouse"); String type = destinationType(ref.line(), ref.order()); if ("FBA".equals(type)) { if (ref.line().getFbaWarehouseCode() == null || ref.line().getFbaWarehouseCode().isBlank() @@ -438,7 +462,8 @@ public class ReceivingContainerService { .map(this::inbound).toList(); int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(); int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum(); - return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getStatus(), session.getOperatorName(), + return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(), + session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(), value(session.getVersion()), signPhotos(session), session.getPreparedAt(), session.getSignedAt(), session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), inbounds); } @@ -451,6 +476,8 @@ public class ReceivingContainerService { .mapToInt(value -> value(value.getPickedPackageCount())).sum(); int total = value(ref.line().getRequiredPackageCount()); return new ReceivingContainerResponses.Inbound(ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), + ref.plan().getOverseasWarehouseId() == null ? ref.order().getOverseasWarehouseId() + : String.valueOf(ref.plan().getOverseasWarehouseId()), ref.order().getOverseasWarehouseName(), ref.line().getDestinationWarehouseId(), ref.line().getDestinationWarehouseCode(), ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(), value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received)); @@ -517,7 +544,8 @@ public class ReceivingContainerService { ReceivingContainerResponses.Inbound inbound = inbound(sku.ref()); int received = receivedSkuPackages(sku.ref().order().getCustomerId(), sku.ref().line().getId(), sku.skuCode()); return new ReceivingContainerResponses.SkuResolved(sku.skuCode(), sku.productName(), inbound.inboundCode(), - inbound.destinationType(), inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(), + inbound.destinationType(), inbound.overseasWarehouseId(), inbound.overseasWarehouseName(), + inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), inbound.plannedPalletCount(), sku.expectedPackages(), received, Math.max(0, sku.expectedPackages() - received)); } @@ -600,12 +628,56 @@ public class ReceivingContainerService { if (sessions.updateById(session) != 1) throw conflict("Receiving container session changed concurrently"); } - private ReceivingContainerSession waitingView(String customerId, String operatorKey, String containerNo) { + private ReceivingContainerSession waitingView(String customerId, String operatorKey, + WarehouseResourceGateway.WarehouseReference warehouse, + String containerNo) { ReceivingContainerSession value = new ReceivingContainerSession(); value.setCustomerId(customerId); + value.setOverseasWarehouseId(warehouse.warehouseId()); + value.setOverseasWarehouseName(warehouse.warehouseName()); value.setOperatorKey(operatorKey); value.setContainerNo(containerNo); value.setStatus("WAIT_SIGN"); value.setVersion(0); return value; } + private boolean eligible(List containerPlans, long overseasWarehouseId) { + Set assigned = containerPlans.stream().map(InboundPlan::getOverseasWarehouseId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + if (assigned.size() > 1 || (!assigned.isEmpty() && containerPlans.stream() + .anyMatch(plan -> plan.getOverseasWarehouseId() == null))) { + throw conflict("Container plans have inconsistent receiving warehouse assignments"); + } + return assigned.isEmpty() || assigned.contains(overseasWarehouseId); + } + + private void claimReceivingWarehouse(String customerId, List containerPlans, + WarehouseResourceGateway.WarehouseReference warehouse) { + long overseasWarehouseId = warehouse.warehouseId(); + String warehouseId = String.valueOf(overseasWarehouseId); + for (InboundPlan plan : containerPlans) { + int claimed = plans.claimReceivingWarehouse(customerId, plan.getId(), overseasWarehouseId); + InboundPlan actual = plans.selectById(plan.getId()); + if ((claimed == 0 && actual == null) || actual == null + || !Objects.equals(actual.getOverseasWarehouseId(), overseasWarehouseId)) { + throw conflict("Container was claimed by another overseas warehouse"); + } + plan.setOverseasWarehouseId(overseasWarehouseId); + for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper() + .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) { + orders.claimReceivingWarehouse(customerId, line.getOrderId(), warehouseId, + warehouse.warehouseName()); + LastlegOrder order = orders.selectById(line.getOrderId()); + if (order == null || !warehouseId.equals(order.getOverseasWarehouseId())) { + throw conflict("Inbound order was claimed by another overseas warehouse"); + } + } + } + } + + private void requireSessionWarehouse(ReceivingContainerSession session, long overseasWarehouseId) { + if (session != null && !Objects.equals(session.getOverseasWarehouseId(), overseasWarehouseId)) { + throw conflict("Current container belongs to another overseas warehouse"); + } + } + private ReceivingContainerOperation replay(String operatorKey, String action, String key, Object request) { key = idempotencyKey(key); ReceivingContainerOperation value = operations.find(operatorKey, key); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java index f34f676e2..87226951e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java @@ -22,6 +22,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; @@ -40,9 +41,9 @@ public class InventoryQueryController { @Operation(summary = "Get inbound-order package inventory totals", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST) public ApiResponse summary( @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.BalanceQuery query) { - requirePlatform(user); - return ApiResponse.success(service.dashboard(null, query)); + return ApiResponse.success(service.dashboard(platformScope(user, customerId), query)); } @GetMapping(BASE_PATH + "/balances") @@ -50,9 +51,9 @@ public class InventoryQueryController { @Operation(summary = "Page inventory by customer, warehouse and inbound order", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST) public ApiResponse> balances( @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.BalanceQuery query) { - requirePlatform(user); - return ApiResponse.success(service.balances(null, query)); + return ApiResponse.success(service.balances(platformScope(user, customerId), query)); } @GetMapping(BASE_PATH + "/balances/{balanceId}/positions") @@ -61,9 +62,9 @@ public class InventoryQueryController { public ApiResponse> positions( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long balanceId, + @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.PositionQuery query) { - requirePlatform(user); - return ApiResponse.success(service.positions(null, balanceId, query)); + return ApiResponse.success(service.positions(platformScope(user, customerId), balanceId, query)); } @GetMapping(BASE_PATH + "/ledger") @@ -71,9 +72,9 @@ public class InventoryQueryController { @Operation(summary = "Page append-only inventory movements", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST) public ApiResponse> ledgers( @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.LedgerQuery query) { - requirePlatform(user); - return ApiResponse.success(service.ledgers(null, query)); + return ApiResponse.success(service.ledgers(platformScope(user, customerId), query)); } @GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv") @@ -81,9 +82,9 @@ public class InventoryQueryController { @Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT) public ResponseEntity exportBalances( @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.BalanceQuery query) { - requirePlatform(user); - return csv(service.exportBalances(null, query), "inventory-balances.csv"); + return csv(service.exportBalances(platformScope(user, customerId), query), "inventory-balances.csv"); } @GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv") @@ -91,9 +92,9 @@ public class InventoryQueryController { @Operation(summary = "Export filtered inventory movements as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT) public ResponseEntity exportLedgers( @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.LedgerQuery query) { - requirePlatform(user); - return csv(service.exportLedgers(null, query), "inventory-ledger.csv"); + return csv(service.exportLedgers(platformScope(user, customerId), query), "inventory-ledger.csv"); } private static ResponseEntity csv(byte[] body, String filename) { @@ -103,12 +104,14 @@ public class InventoryQueryController { .body(body); } - private void requirePlatform(AuthenticatedUser user) { + private String platformScope(AuthenticatedUser user, String requestedCustomerId) { if (user == null || user.tenantId() == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); } if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required"); } + return requestedCustomerId == null || requestedCustomerId.isBlank() || "0".equals(requestedCustomerId.trim()) + ? null : requestedCustomerId.trim(); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java index 316eb2b8d..16c85a001 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java @@ -89,8 +89,6 @@ public interface InventoryQueryMapper { @Select("')), }, token) - context.addStep(MODULE, '伪造图片内容安全扫描被拒绝', malicious, !success(malicious) && traced(malicious), trace(malicious), OPTIONS) + context.addStep(MODULE, '伪造图片内容安全扫描返回A0400', malicious, + malicious?.http === 200 && code(malicious) === 'A0400' && traced(malicious), trace(malicious), OPTIONS) const photos = [] for (const role of PHOTO_ROLES) { const response = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', { @@ -265,7 +274,7 @@ async function runReceiving(context, token, secondaryToken, merchantToken) { const pallet = await createPallet(context, inbound, index) if (!pallet) { blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}实物托盘`, - `无法在目的仓 ${inbound.destinationWarehouseId || 'missing'} 创建可解析真实托盘`, 'warehouse-resource') + `无法在实际海外收货仓 ${inbound.overseasWarehouseId || 'missing'} 创建可解析真实托盘;目的仓 ${inbound.destinationWarehouseId || 'missing'} 不得代替物理仓`, 'warehouse-resource') continue } pallets.set(inbound.inboundCode, pallet) @@ -313,11 +322,21 @@ export async function run(context) { context.createdResources.phase4OverseasPda = { marker: context.marker, containerNo: null, customerId: null, palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], - cleanup: 'Pallets and locations used by completed inventory are retained as acceptance evidence', + overseasWarehouseId: null, overseasWarehouseName: null, + cleanup: 'Shared warehouse, pallets and locations used by completed inventory are retained as acceptance evidence', } const merchantToken = await loginMerchantForNegativeProbe(context) - const primary = await createPicker(context, 1) - const secondary = primary?.token ? await createPicker(context, 2) : null + const pickerAccess = await resolvePickerAccess(context) + if (!pickerAccess) return + const sharedWarehouse = await createSharedWarehouse(context) + if (!sharedWarehouse) { + blockerGap(context, '海外PDA共享实际收货仓', '无法通过平台正式API创建customerId=0共享物理仓') + return + } + context.createdResources.phase4OverseasPda.overseasWarehouseId = sharedWarehouse.id + context.createdResources.phase4OverseasPda.overseasWarehouseName = sharedWarehouse.name + const primary = await createPicker(context, 1, sharedWarehouse.id, pickerAccess) + const secondary = primary?.token ? await createPicker(context, 2, sharedWarehouse.id, pickerAccess) : null for (const picker of [primary, secondary]) if (picker?.membershipId) { context.createdResources.phase4OverseasPda.pickerMembershipIds.push(picker.membershipId) } @@ -332,3 +351,22 @@ export async function run(context) { await disablePicker(context, primary, 1) } } + +async function createSharedWarehouse(context) { + const suffix = markerSuffix(context) + const payload = { + customerId: 0, warehouseCode: `PDA-${suffix}`.slice(0, 50), + warehouseName: `PDA共享收货仓-${suffix}`.slice(0, 120), warehouseType: 'OVERSEAS', + countryCode: 'US', timezone: 'America/Los_Angeles', contactName: 'PDA Acceptance', + contactPhone: '+1-213-555-0198', contactEmail: `pda-${suffix.toLowerCase()}@example.com`, + status: 'ACTIVE', remark: `${context.marker}-shared-receiving`, + address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'CA', city: 'Los Angeles', + district: 'Commerce', postalCode: '90040', addressLine1: '1 Acceptance Logistics Ave' }, + capabilities: [{ capabilityCode: 'RECEIVING', capabilityName: '收货', enabled: true }], + } + const created = await context.requestOverseas('POST', '/api/platform/v1/warehouse-admin', payload) + const warehouseId = id(created?.body?.data) + context.addStep(MODULE, '创建本轮PDA共享实际收货仓', created, + success(created) && longId(warehouseId), `warehouseId=${warehouseId || 'missing'}; ${trace(created)}`, OPTIONS) + return longId(warehouseId) ? { id: warehouseId, name: payload.warehouseName } : null +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java index 1e9c74f77..13bac4df9 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java @@ -4,13 +4,16 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.warehouse.dto.WarehouseResourceLockRequests; import com.baiyue.warehouse.dto.WarehouseResourceLockResponse; import com.baiyue.warehouse.dto.WarehousePalletScrapResponse; +import com.baiyue.warehouse.dto.SharedWarehouseReference; import com.baiyue.warehouse.entity.WarehouseLocationEntity; import com.baiyue.warehouse.security.InternalServiceTokenGuard; import com.baiyue.warehouse.service.WarehouseResourceLockService; import com.baiyue.warehouse.service.WarehouseResourceService; +import com.baiyue.warehouse.service.WarehouseApplicationService; import jakarta.validation.Valid; import jakarta.annotation.Resource; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; @@ -22,6 +25,7 @@ import org.springframework.web.bind.annotation.RestController; public class InternalWarehouseResourceLockController { @Resource private WarehouseResourceLockService service; @Resource private WarehouseResourceService resourceService; + @Resource private WarehouseApplicationService warehouses; @Resource private InternalServiceTokenGuard tokenGuard; @PostMapping @@ -67,4 +71,12 @@ public class InternalWarehouseResourceLockController { return ApiResponse.success(resourceService.ensureDefaultVirtualLocation( request.customerId(), request.warehouseId())); } + + @GetMapping("/shared-warehouses/{warehouseId}") + public ApiResponse sharedWarehouse( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long warehouseId) { + tokenGuard.verify(token); + return ApiResponse.success(warehouses.activeSharedReference(warehouseId)); + } } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java index f934717af..e4b27dfbc 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java @@ -164,8 +164,8 @@ public class PlatformWarehouseController { } private static void requireTenant(Long customerId) { - if (customerId == null || customerId <= 0) { - throw new BizException(ResultCode.BAD_REQUEST, "Target tenant is required / 必须指定有效目标租户"); + if (customerId == null || customerId < 0) { + throw new BizException(ResultCode.BAD_REQUEST, "Target customer scope is required / 必须指定有效客户或平台共享范围"); } } } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/SharedWarehouseReference.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/SharedWarehouseReference.java new file mode 100644 index 000000000..aa83148fd --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/SharedWarehouseReference.java @@ -0,0 +1,4 @@ +package com.baiyue.warehouse.dto; + +public record SharedWarehouseReference(long warehouseId, String warehouseCode, + String warehouseName, String status) { } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java index 7da4dc669..d762b3a30 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java @@ -8,7 +8,7 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.Size; import java.math.BigDecimal; @@ -96,7 +96,7 @@ public final class WarehouseRequests { @Schema(name = "WarehouseQueryRequest", description = "Warehouse page query / 仓库分页查询条件") public record QueryRequest( @Schema(description = "Optional target customer ID for platform queries; merchant queries always use the authenticated customer / 平台查询可选目标客户 ID,商户查询始终使用认证客户", example = "10001", nullable = true) - @Positive Long customerId, + @PositiveOrZero Long customerId, @Schema(description = "Page number, starting at 1 / 页码,从 1 开始", example = "1", defaultValue = "1", minimum = "1") @Min(1) long page, @Schema(description = "Page size / 每页条数", example = "20", defaultValue = "20", minimum = "1", maximum = "200") diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java index 3abc3cb97..89b7ded73 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java @@ -7,6 +7,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.WarehouseDetailResponse; import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.dto.SharedWarehouseReference; import com.baiyue.warehouse.entity.ServiceCapabilityEntity; import com.baiyue.warehouse.entity.WarehouseAddressEntity; import com.baiyue.warehouse.entity.WarehouseEntity; @@ -36,6 +37,7 @@ public class WarehouseApplicationService { captureBefore = false ) public Long create(WarehouseRequests.SaveRequest request) { + validateSharedWarehouse(request); ensureCodeUnique(request.customerId(), request.warehouseCode(), null); WarehouseEntity warehouse = toEntity(request); repository.insertWarehouse(warehouse); @@ -72,6 +74,7 @@ public class WarehouseApplicationService { if (request.customerId() == null || customerId != request.customerId()) { throw new BizException(ResultCode.FORBIDDEN, "租户不匹配"); } + validateSharedWarehouse(request); WarehouseEntity old = owned(customerId, id); ensureCodeUnique(customerId, request.warehouseCode(), id); @@ -98,6 +101,29 @@ public class WarehouseApplicationService { return new WarehouseDetailResponse(warehouse, addresses, capabilities); } + public SharedWarehouseReference activeSharedReference(long warehouseId) { + WarehouseEntity warehouse = repository.findOwned(0L, warehouseId); + boolean receiving = warehouse != null && repository.findCapabilities(0L, warehouseId).stream() + .anyMatch(value -> "RECEIVING".equals(value.getCapabilityCode()) && Boolean.TRUE.equals(value.getEnabled())); + if (warehouse == null || !"ACTIVE".equals(warehouse.getStatus()) + || !"OVERSEAS".equals(warehouse.getWarehouseType()) || !receiving) { + throw new BizException(ResultCode.NOT_FOUND, "Active shared overseas warehouse not found"); + } + return new SharedWarehouseReference(warehouse.getId(), warehouse.getWarehouseCode(), + warehouse.getWarehouseName(), warehouse.getStatus()); + } + + private void validateSharedWarehouse(WarehouseRequests.SaveRequest request) { + if (request.customerId() == null || request.customerId() != 0L) return; + boolean receiving = request.capabilities() != null && request.capabilities().stream() + .anyMatch(value -> "RECEIVING".equals(value.capabilityCode()) + && !Boolean.FALSE.equals(value.enabled())); + if (!"OVERSEAS".equals(request.warehouseType()) || !receiving) { + throw new BizException(ResultCode.BAD_REQUEST, + "Shared physical warehouse must be OVERSEAS with RECEIVING enabled"); + } + } + @Transactional @BusinessAudit( event = "WAREHOUSE_STATUS_CHANGE", diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java index 0e4dea80f..ac330716f 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java @@ -61,6 +61,22 @@ class PlatformWarehouseControllerTest { assertTrue(operation.description().contains("customerId")); } + @Test + void platformCanCreateSharedWarehouseInCustomerZeroScope() { + WarehouseApplicationService service = mock(WarehouseApplicationService.class); + PlatformWarehouseController controller = new PlatformWarehouseController(); + ReflectionTestUtils.setField(controller, "service", service); + WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest( + "WAREHOUSE", "US", "CA", "Los Angeles", null, "90001", "1 Shared Ave", null, null, null); + WarehouseRequests.SaveRequest request = new WarehouseRequests.SaveRequest(0L, "SHARED-LAX", "Shared LAX", + "OVERSEAS", "US", "America/Los_Angeles", null, null, null, "ACTIVE", null, address, + List.of(new WarehouseRequests.CapabilityRequest("RECEIVING", "收货", true, null, null))); + when(service.create(request)).thenReturn(900L); + + assertEquals(900L, controller.create(platform(), request).data()); + verify(service).create(request); + } + private static AuthenticatedUser platform() { return new AuthenticatedUser(1L, 0L, 0L, 1L, "operator", "PLATFORM", "WEB", Set.of()); } diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java index 1bdcc2925..37699e071 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java @@ -3,6 +3,8 @@ package com.baiyue.warehouse.service; import com.baiyue.warehouse.dto.WarehouseRequests; import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.ServiceCapabilityEntity; +import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.repository.WarehouseRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -15,6 +17,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.verify; @@ -59,6 +62,31 @@ class WarehouseApplicationServiceTest { verify(repository).pageAll(query); } + @Test + void sharedReceivingWarehouseMustBeOverseasAndResolvesCanonicalReference() { + WarehouseRequests.SaveRequest shared = sharedRequest("OVERSEAS", true); + doAnswer(call -> { ((WarehouseEntity) call.getArgument(0)).setId(900L); return null; }) + .when(repository).insertWarehouse(any()); + + assertThat(service.create(shared)).isEqualTo(900L); + + WarehouseEntity warehouse = new WarehouseEntity(); warehouse.setId(900L); warehouse.setCustomerId(0L); + warehouse.setWarehouseCode("SHARED-LAX"); warehouse.setWarehouseName("Shared LAX"); + warehouse.setWarehouseType("OVERSEAS"); warehouse.setStatus("ACTIVE"); + ServiceCapabilityEntity receiving = new ServiceCapabilityEntity(); + receiving.setCapabilityCode("RECEIVING"); receiving.setEnabled(true); + when(repository.findOwned(0L, 900L)).thenReturn(warehouse); + when(repository.findCapabilities(0L, 900L)).thenReturn(List.of(receiving)); + + assertThat(service.activeSharedReference(900L).warehouseName()).isEqualTo("Shared LAX"); + } + + @Test + void sharedWarehouseRejectsNonOverseasOrMissingReceivingCapability() { + assertThatThrownBy(() -> service.create(sharedRequest("FBA", true))).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.create(sharedRequest("OVERSEAS", false))).isInstanceOf(BizException.class); + } + private static WarehouseRequests.SaveRequest request() { WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest( "WAREHOUSE", "US", "CA", "Los Angeles", null, "90001", @@ -67,4 +95,15 @@ class WarehouseApplicationServiceTest { "OVERSEAS", "US", "America/Los_Angeles", null, null, null, "ACTIVE", null, address, List.of()); } + + private static WarehouseRequests.SaveRequest sharedRequest(String type, boolean receiving) { + WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest( + "WAREHOUSE", "US", "CA", "Los Angeles", null, "90001", + "1 Logistics Ave", null, null, null); + List capabilities = receiving + ? List.of(new WarehouseRequests.CapabilityRequest("RECEIVING", "收货", true, null, null)) + : List.of(); + return new WarehouseRequests.SaveRequest(0L, "SHARED-LAX", "Shared LAX", type, + "US", "America/Los_Angeles", null, null, null, "ACTIVE", null, address, capabilities); + } } From 3fbb7653f7291a8488f16c768f3180d78796c84e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 22:14:41 +0800 Subject: [PATCH 060/115] fix: complete phase4 receiving acceptance chain --- deploy/sql/海外区域统一增量-MySQL8.sql | 3 ++ .../pda/entity/ReceivingContainerSession.java | 4 ++ .../service/ReceivingContainerService.java | 14 +++++- .../pda/ReceivingContainerContractTest.java | 13 ++++++ ...ReceivingContainerServiceBehaviorTest.java | 3 +- scripts/admin-test-env-acceptance.mjs | 4 +- .../phase3-loading-finalize.mjs | 1 + .../admin-test-env-modules/phase3-loading.mjs | 1 + .../phase4-carrier-nodes-contract.test.mjs | 3 +- .../phase4-overseas-pda.mjs | 44 ++++++++++++++----- ...hase4-overseas-warehouse-contract.test.mjs | 6 ++- .../phase4-overseas-warehouse.mjs | 5 ++- 12 files changed, 79 insertions(+), 22 deletions(-) diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 772b25e1e..819ed1c5e 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -461,6 +461,9 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE phase4_receiving_drop_raw_photos_stmt FROM @ddl; EXECUTE phase4_receiving_drop_raw_photos_stmt; DEALLOCATE PREPARE phase4_receiving_drop_raw_photos_stmt; DROP PROCEDURE IF EXISTS assert_receiving_global_locks_migratable; +UPDATE ll_receiving_container_session +SET active_operator_key=NULL,active_container_key=NULL +WHERE status='COMPLETED' AND (active_operator_key IS NOT NULL OR active_container_key IS NOT NULL); DELIMITER $$ CREATE PROCEDURE assert_receiving_global_locks_migratable() BEGIN diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java index 2667cb427..388bcf420 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java @@ -1,6 +1,8 @@ package com.baiyue.lastleg.inboundplan.pda.entity; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.Version; @@ -19,7 +21,9 @@ public class ReceivingContainerSession { private String overseasWarehouseName; private String operatorKey; private String operatorName; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String activeOperatorKey; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String activeContainerKey; private String status; private String signPhotos; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 80710f77c..fd8ac7df0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -201,10 +201,15 @@ public class ReceivingContainerService { ReceivingContainerRequests.CartonResolve request) { ReceivingContainerSession session = requireSigned(operatorKey); String customerId = session.getCustomerId(); + List currentRefs = refs(customerId, session.getContainerNo()); + if (currentRefs.stream().noneMatch(value -> + "OVERSEAS".equals(destinationType(value.line(), value.order())))) { + throw conflict("FBA destination must use bulk count"); + } InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark())); if (carton == null) throw notFound("Carton mark was not found"); - InboundRef ref = refs(customerId, session.getContainerNo()).stream() + InboundRef ref = currentRefs.stream() .filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst() .orElseThrow(() -> conflict("Carton mark belongs to another container")); if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { @@ -285,10 +290,15 @@ public class ReceivingContainerService { if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); ReceivingContainerSession session = requireSigned(operatorKey); String customerId = session.getCustomerId(); + List currentRefs = refs(customerId, session.getContainerNo()); + if (currentRefs.stream().noneMatch(value -> + "OVERSEAS".equals(destinationType(value.line(), value.order())))) { + throw conflict("FBA destination must use bulk count"); + } InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark())); if (carton == null) throw notFound("Carton mark was not found"); - InboundRef ref = refs(customerId, session.getContainerNo()).stream() + InboundRef ref = currentRefs.stream() .filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst() .orElseThrow(() -> conflict("Carton mark belongs to another container")); if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java index 566acf469..f90963626 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -1,7 +1,10 @@ package com.baiyue.lastleg.inboundplan.pda; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; import com.baiyue.lastleg.inboundplan.pda.controller.ReceivingContainerController; import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import jakarta.validation.constraints.Size; @@ -46,6 +49,15 @@ class ReceivingContainerContractTest { assertTrue("hasAuthority('lastleg:pda:operate')".equals(permission.value())); } + @Test + void completedSessionAlwaysPersistsClearedGlobalLockKeys() throws NoSuchFieldException { + for (String fieldName : List.of("activeOperatorKey", "activeContainerKey")) { + TableField field = ReceivingContainerSession.class.getDeclaredField(fieldName) + .getAnnotation(TableField.class); + assertTrue(field != null && field.updateStrategy() == FieldStrategy.ALWAYS, fieldName); + } + } + @Test void schemaContainsCustomerScopedPlanChildrenAndGlobalPlatformPickerLocks() throws IOException { String schema = resource("db/lastleg.sql").toLowerCase(); @@ -81,6 +93,7 @@ class ReceivingContainerContractTest { assertTrue(unified.contains("customer_id is null or a.customer_id=''")); assertTrue(unified.contains("signal sqlstate '45000'")); assertTrue(unified.contains("assert_receiving_global_locks_migratable")); + assertTrue(unified.contains("where status='completed' and (active_operator_key is not null or active_container_key is not null)")); assertTrue(unified.contains("concat('operator:',operator_key)")); assertTrue(unified.contains("concat('container:',upper(trim(container_no)))")); assertTrue(unified.contains("assert_receiving_operation_global_key_migratable")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index c4e470282..0241f0153 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -172,11 +172,10 @@ class ReceivingContainerServiceBehaviorTest { org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse); stubCurrent("FBA", "CONT-A"); - InboundPlanCarton carton = carton(1L, "BOX-1"); - when(cartons.selectOne(any())).thenReturn(carton); assertThatThrownBy(() -> service.receiveCarton("7", "receiver", new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "carton-key"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + verifyNoInteractions(cartons); verifyNoInteractions(warehouse); } diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 7b752fbd2..450762dac 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -22,7 +22,7 @@ const MODULE_DEPENDENCIES = new Map([ ['phase3-loading-finalize', ['phase3-loading', 'phase3-evidence-gate']], ['phase3-customs', ['phase3-loading-finalize']], ['phase3-port-schedules', ['ocean-bookings']], - ['phase3-outbound-dispatch', ['phase3-customs']], + ['phase3-outbound-dispatch', ['phase3-customs', 'phase3-port-schedules', 'service-center-flow']], ['phase3-outbound-aggregate', ['phase3-outbound-dispatch']], ['phase3-dispatch-gate', ['phase3-outbound-dispatch']], ['phase3-waiting-for-loading', ['phase3-outbound-dispatch']], @@ -870,6 +870,7 @@ export class AcceptanceRun { tenantId: fixture.tenantId, orderId: fixture.orderId || this.createdResources.orders?.orderId, inboundId: fixture.inboundId, + boxCode: fixture.boxCode, customerRemark: fixtureOrder.customerRemark, internalRemark: fixtureOrder.internalRemark, cargoRemark: fixtureOrder.cargoRemark, @@ -888,6 +889,7 @@ export class AcceptanceRun { tenantId: incrementalSource.tenantId, orderId: incrementalSource.orderId, inboundId: incrementalSource.inboundId, + boxCode: incrementalSource.boxCode, } : { marker: originalMarker, failed: true } this.createdResources.orchestrationFixtures = { ...(this.createdResources.orchestrationFixtures || {}), diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 0c7b0f567..e64e70553 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -365,6 +365,7 @@ export async function run(context) { containerBookingId, sourceOrderId: orderId, sourceInboundId: inboundId, + cartonMark: fixture.cartonMark || null, destinationType: fixture.destinationType || null, destinationWarehouseId: id(fixture.destinationWarehouseId), destinationWarehouseCode: fixture.destinationWarehouseCode || null, diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 80331208c..4280e8609 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1180,6 +1180,7 @@ export async function run(context) { context.createdResources.phase3Loading = { tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId, + cartonMark: source?.boxCode || source?.cartonMark || null, destinationType: initial?.summary?.destinationType || source?.destinationType || null, destinationWarehouseId: id(initial?.summary?.destinationWarehouseId || source?.destinationWarehouseId), destinationWarehouseCode: initial?.summary?.destinationCode || source?.destinationWarehouseCode || null, diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index da8d4a5c5..1fd31f443 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -29,7 +29,8 @@ test('focused carrier node execution injects the complete outbound-confirm produ const modules = normalizeModuleOrder(['phase4-carrier-nodes']) const required = ['merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate', - 'phase3-loading-finalize', 'phase3-customs', 'phase3-outbound-dispatch', + 'phase3-loading-finalize', 'phase3-customs', 'ocean-bookings', 'phase3-port-schedules', + 'truck-appointments', 'service-center-flow', 'phase3-outbound-dispatch', 'phase3-carrier-schedule-queries', 'phase4-carrier-nodes'] for (const module of required) assert.ok(modules.includes(module), module) for (let index = 1; index < required.length; index += 1) { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 94eaa6d78..18c01aa4b 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -15,8 +15,8 @@ function conflictMessage(response, pattern) { return conflict(response) && patte function notFound(response) { return (response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(code(response)))) && traced(response) } function forbidden(response) { return [401, 403].includes(response?.http) || (response?.http === 200 && ['A0401', 'A0403'].includes(code(response))) } function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` } -function key(context, action, suffix = '') { return `${context.marker}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) } -function markerSuffix(context) { return String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() } +function key(context, action, suffix = '') { return `${context.marker}-${context.pdaContainerSuffix || ''}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) } +function markerSuffix(context) { return `${context.marker}${context.pdaContainerSuffix || ''}`.replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() } function blockerGap(context, name, reason, category = 'fixture-unavailable', response = null) { context.addCoverageGap(MODULE, SCENARIO, name, reason, category, { deploymentBlocker: true }) @@ -183,8 +183,8 @@ async function receiveFba(context, token, inbound, pallet) { return true } -async function receiveOverseas(context, token, inbound, pallet) { - const cartonMark = context.createdResources?.inboundMeasurement?.boxCode +async function receiveOverseas(context, token, inbound, pallet, producerCartonMark = null) { + const cartonMark = producerCartonMark || context.createdResources?.inboundMeasurement?.boxCode if (!nonBlank(cartonMark)) { blockerGap(context, `OVERSEAS入仓码${inbound.inboundCode}逐箱`, '缺少本轮冻结箱唛,逐箱实收不能使用伪造箱号') return false @@ -207,12 +207,12 @@ async function receiveOverseas(context, token, inbound, pallet) { return true } -async function runReceiving(context, token, secondaryToken, merchantToken) { - if (context.overseasToken) { +async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true) { + if (runAuthorizationProbes && context.overseasToken) { const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken) context.addStep(MODULE, '平台管理员海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) } - if (merchantToken) { + if (runAuthorizationProbes && merchantToken) { const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, merchantToken) context.addStep(MODULE, '客户海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) } @@ -229,8 +229,16 @@ async function runReceiving(context, token, secondaryToken, merchantToken) { blockerGap(context, 'PDA柜号候选', `当前客户没有由三期装柜完成事件生成的待收入仓计划;${trace(candidates.response)}`, 'producer-chain', candidates.response) return } - const preferredContainer = context.createdResources?.phase3Loading?.containerNo - const candidate = candidates.data.find((item) => item.containerNo === preferredContainer) || candidates.data[0] + const preferredContainer = id(preferredProducer?.containerNo) + || context.createdResources?.phase3Loading?.containerNo + const candidate = preferredContainer + ? candidates.data.find((item) => item.containerNo === preferredContainer) + : candidates.data[0] + if (!candidate) { + blockerGap(context, `本轮柜号${preferredContainer || 'missing'}候选`, + '三期装柜完成生产者已落地,但海外候选尚未出现该柜;禁止回退处理历史或其他柜', 'producer-chain', candidates.response) + return + } const containerNo = id(candidate?.containerNo) context.assert(MODULE, '柜号候选来自本轮生产链且包含进度', candidates.response, nonBlank(containerNo) && Number(candidate?.totalPackages) > 0 && Number(candidate?.receivedPackages) >= 0, @@ -300,7 +308,7 @@ async function runReceiving(context, token, secondaryToken, merchantToken) { inboundCode: overseasInbound.inboundCode, skuCode: 'MODE-REJECT', palletCode: pallet.palletCode, packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'), }, (response) => conflictMessage(response, /OVERSEAS.*carton|bulk.*forbidden/i)) - await receiveOverseas(context, token, overseasInbound, pallet) + await receiveOverseas(context, token, overseasInbound, pallet, preferredProducer?.cartonMark) } else blockerGap(context, 'OVERSEAS逐箱链', '当前柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码') const refreshed = await pda(context, token, 'GET', '回查柜内逐入仓码实收进度', '/api/pda/v1/last-leg/receiving-containers/current') if (!success(refreshed.response) || Number(refreshed.data?.pendingPackages) !== 0) { @@ -321,7 +329,7 @@ async function runReceiving(context, token, secondaryToken, merchantToken) { export async function run(context) { context.createdResources.phase4OverseasPda = { marker: context.marker, containerNo: null, customerId: null, - palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], + palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], completedContainers: [], overseasWarehouseId: null, overseasWarehouseName: null, cleanup: 'Shared warehouse, pallets and locations used by completed inventory are retained as acceptance evidence', } @@ -345,8 +353,20 @@ export async function run(context) { blockerGap(context, '海外PDA双操作员账号', '未能创建并登录两个WAREHOUSE_PICKER平台账号,无法验证全局一柜锁') return } - await runReceiving(context, primary.token, secondary.token, merchantToken) + const matrix = context.createdResources?.phase4InboundProducerFixtures + const producers = [matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]] + .filter((item) => nonBlank(item?.containerNo)) + for (const [index, producer] of (producers.length ? producers : [null]).entries()) { + context.pdaContainerSuffix = producer?.containerNo || `CONTAINER-${index + 1}` + context.createdResources.phase4OverseasPda.finalStatus = null + await runReceiving(context, primary.token, secondary.token, + index === 0 ? merchantToken : null, producer, index === 0) + if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') { + context.createdResources.phase4OverseasPda.completedContainers.push(producer?.containerNo || null) + } + } } finally { + delete context.pdaContainerSuffix await disablePicker(context, secondary, 2) await disablePicker(context, primary, 1) } diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs index 10d20e210..261b0e84b 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs @@ -42,7 +42,7 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou if (requestPath.includes('/last-leg/orders/30001?')) return response({ order: { id: '30001' }, cargo: [], statusHistory: [] }) if (requestPath.startsWith('/api/platform/v1/last-leg/relabel-applications?')) return response(page([{ id: '40001', relabelNo: 'RL-1', status: 'PENDING_PROCESSING' }])) if (requestPath.includes('/relabel-applications/RL-1?')) return response({ application: { relabelNo: 'RL-1' }, items: [], history: [] }) - if (requestPath.startsWith('/api/platform/v1/last-leg/inbound-plans?')) return response(page([{ id: '50001', sourceOrderId: '30001', planNo: 'IP-1', customerId: '10001', status: 'PENDING_ASSIGNMENT' }])) + if (requestPath.startsWith('/api/platform/v1/last-leg/inbound-plans?')) return response(page([{ lineId: '50002', planId: '50001', orderId: '30001', planNo: 'IP-1', customerId: '10001', status: 'PENDING_ASSIGNMENT' }])) if (requestPath.includes('/inbound-plans/IP-1?')) return response({ planNo: 'IP-1', sourceOrderId: '30001', lines: [], pallets: [], allocations: [] }) if (requestPath.includes('/inventory/summary?')) return response({ inboundOrderCount: 1, palletCount: 1, totalPackages: 10, availablePackages: 10, holdPackages: 0, inTransitPackages: 0, receivingPackages: 0, procurementPackages: 0 }) if (requestPath.includes('/inventory/balances?')) return response(page([{ id: '60001', customerId: '10001', skuCode: 'SKU-1', totalPackages: 10, availablePackages: 10, updatedAt: '2026-08-21T10:00:00' }])) @@ -145,7 +145,9 @@ test('合同覆盖库存CSV并明确排除暂缓、离仓、PDA与内部路径', for (const forbidden of ['/cartons', '/products', '/outbound', '/dispatch', '/delivery', '/pda', '/internal']) assert.equal(source.includes(forbidden), false, forbidden) assert.match(source, /requestId=/) assert.match(source, /traceId=/) - assert.match(source, /longId\(plan\.sourceOrderId\)/) + for (const field of ['lineId', 'planId', 'orderId']) { + assert.match(source, new RegExp(`longId\\(plan\\.${field}\\)`)) + } assert.match(source, /id\(pallet\.customerId\) === '0'/) assert.match(source, /创建本轮换标单/) assert.match(source, /换标单同幂等键不同请求返回A0409/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs index 3e977f3aa..7dc90a1b3 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs @@ -107,8 +107,9 @@ export async function run(context) { const plan = records(plans.data)[0] if (plan) { context.assert(MODULE, '入仓计划关键字段正确', plans.response, - longId(plan.id) && longId(plan.sourceOrderId) && nonBlank(plan.planNo) && id(plan.customerId) === customerId && nonBlank(plan.status), - `id=${id(plan.id) || 'missing'}, sourceOrderId=${id(plan.sourceOrderId) || 'missing'}, planNo=${plan.planNo || 'missing'}, status=${plan.status || 'missing'}; ${trace(plans.response)}`, OPTIONS) + longId(plan.lineId) && longId(plan.planId) && longId(plan.orderId) + && nonBlank(plan.planNo) && id(plan.customerId) === customerId && nonBlank(plan.status), + `lineId=${id(plan.lineId) || 'missing'}, planId=${id(plan.planId) || 'missing'}, orderId=${id(plan.orderId) || 'missing'}, planNo=${plan.planNo || 'missing'}, status=${plan.status || 'missing'}; ${trace(plans.response)}`, OPTIONS) const detail = await overseasGet(context, '查询入仓计划详情', query(`/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(plan.planNo)}`, { customerId })) context.assert(MODULE, '入仓计划详情对应列表记录', detail.response, detail.data?.planNo === plan.planNo && longId(detail.data?.sourceOrderId) From 0e73a79479be2edc9f44234e0b9ec365c5adf848 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 22:32:37 +0800 Subject: [PATCH 061/115] fix: serialize phase4 inventory and outbound gates --- .../CustomsDispatchGateContributor.java | 13 ++++---- .../CustomsDispatchGateContributorTest.java | 17 +++++----- .../service/ReceivingContainerService.java | 3 +- .../service/LoadingCompletedConsumer.java | 3 +- .../pda/ReceivingContainerContractTest.java | 16 +++++++++ scripts/admin-test-env-acceptance.mjs | 2 +- ...ase4-cross-region-events-contract.test.mjs | 2 +- .../phase4-overseas-pda.mjs | 33 +++++++++++++++++-- 8 files changed, 69 insertions(+), 20 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java index 38897b09a..94eb4cc89 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.customs.service; -import com.baiyue.firstleg.entity.ContainerDocumentEntity; -import com.baiyue.firstleg.repository.LoadingListRepository; +import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; +import com.baiyue.firstleg.customs.repository.CustomsRepository; import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor; import jakarta.annotation.Resource; @@ -9,7 +9,7 @@ import org.springframework.stereotype.Component; @Component public class CustomsDispatchGateContributor implements OutboundDispatchGateContributor { - @Resource private LoadingListRepository repository; + @Resource private CustomsRepository repository; @Override public String code() { return "CUSTOMS_CLEARANCE"; } @@ -18,12 +18,13 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr if (context.tenantId() <= 0 || context.customerId() <= 0) { return gate(false, "INVALID_CUSTOMER_SCOPE"); } - ContainerDocumentEntity value = repository.document(context.containerBookingId(), "CUSTOMS_DECLARATION"); + CustomsCaseEntity value = repository.gateCase(context.tenantId(), context.orderId(), + context.inboundId(), context.containerBookingId()); if (value == null) return gate(false, "MISSING"); - return gate(value.getCompletedAt() != null, value.getCompletedAt() == null ? "UPLOADED" : "COMPLETED"); + return gate("CLEARANCE_COMPLETED".equals(value.getStatus()), value.getStatus()); } private static GateItem gate(boolean passed, String actual) { - return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "COMPLETED"); + return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "CLEARANCE_COMPLETED"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java index 594eaf569..6695ea5d8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.customs.service; -import com.baiyue.firstleg.entity.ContainerDocumentEntity; -import com.baiyue.firstleg.repository.LoadingListRepository; +import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; +import com.baiyue.firstleg.customs.repository.CustomsRepository; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; class CustomsDispatchGateContributorTest { - @Mock LoadingListRepository repository; + @Mock CustomsRepository repository; @InjectMocks CustomsDispatchGateContributor contributor; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } @@ -22,15 +22,16 @@ class CustomsDispatchGateContributorTest { .isEqualTo("INVALID_CUSTOMER_SCOPE"); } - @Test void passesOnlyCompletedSimpleDeclaration() { - ContainerDocumentEntity value = new ContainerDocumentEntity(); - when(repository.document(81L, "CUSTOMS_DECLARATION")).thenReturn(value); + @Test void passesOnlyCanonicalClearanceCompletedCase() { + CustomsCaseEntity value = new CustomsCaseEntity(); + value.setStatus("RECEIPT_AVAILABLE"); + when(repository.gateCase(10001L, 21L, 31L, 81L)).thenReturn(value); assertThat(contributor.assess(context()).passed()).isFalse(); - value.setCompletedAt(java.time.LocalDateTime.now()); + value.setStatus("CLEARANCE_COMPLETED"); assertThat(contributor.assess(context())) .satisfies(item -> { assertThat(item.passed()).isTrue(); - assertThat(item.requirement()).isEqualTo("COMPLETED"); + assertThat(item.requirement()).isEqualTo("CLEARANCE_COMPLETED"); }); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index fd8ac7df0..a49339834 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -38,6 +38,7 @@ import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Isolation; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; @@ -325,7 +326,7 @@ public class ReceivingContainerService { } } - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) public ReceivingContainerResponses.Session complete(String operatorKey, ReceivingContainerRequests.Action request) { ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 75f63228e..c0709152b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -17,6 +17,7 @@ import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Isolation; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -41,7 +42,7 @@ public class LoadingCompletedConsumer { @Resource private DropshipOrderService dropshipOrders; @Resource private InventoryCommandService inventory; - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) public int consume(EventEnvelope envelope) { envelope.requireValid(); if (!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType()) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java index f90963626..d66ef69ae 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -2,11 +2,16 @@ package com.baiyue.lastleg.inboundplan.pda; import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.TableField; +import com.baiyue.common.event.EventEnvelope; import com.baiyue.lastleg.inboundplan.pda.controller.ReceivingContainerController; import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService; +import com.baiyue.lastleg.service.LoadingCompletedConsumer; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; import jakarta.validation.constraints.Size; import java.io.IOException; @@ -58,6 +63,17 @@ class ReceivingContainerContractTest { } } + @Test + void inventoryCreatingEntryTransactionsAvoidMissingDimensionGapLockDeadlocks() throws NoSuchMethodException { + Transactional pda = ReceivingContainerService.class + .getMethod("complete", String.class, ReceivingContainerRequests.Action.class) + .getAnnotation(Transactional.class); + Transactional bridge = LoadingCompletedConsumer.class.getMethod("consume", EventEnvelope.class) + .getAnnotation(Transactional.class); + assertTrue(pda != null && pda.isolation() == Isolation.READ_COMMITTED); + assertTrue(bridge != null && bridge.isolation() == Isolation.READ_COMMITTED); + } + @Test void schemaContainsCustomerScopedPlanChildrenAndGlobalPlatformPickerLocks() throws IOException { String schema = resource("db/lastleg.sql").toLowerCase(); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 450762dac..29e680846 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -916,7 +916,7 @@ export class AcceptanceRun { const matrixMarker = this.marker const variantMarker = `${matrixMarker}-P4-${label}` const contextKeys = ['orderDestinationType', 'phase4ProducerMatrixMarker'] - const createdKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures'] + const createdKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking'] const contextSnapshot = new Map(contextKeys.map((key) => [key, { present: Object.hasOwn(this.context, key), value: this.context[key], }])) diff --git a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs index 489b9a335..7405b5832 100644 --- a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs @@ -80,7 +80,7 @@ test('cross-region normalization keeps existing producers ahead of every phase-t test('phase-four producer variants restore the primary orchestration fixture after each matrix branch', async () => { const source = await import('node:fs/promises').then((fs) => fs.readFile( new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')) - assert.match(source, /const createdKeys = \['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures'\]/) + assert.match(source, /const createdKeys = \['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking'\]/) }) test('contract covers dual audience, canonical 403, both event directions and diagnostics', () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 18c01aa4b..2da55c862 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -138,6 +138,25 @@ async function uploadPhotos(context, token) { return photos } +async function awaitProducerCandidates(context, token, producers) { + const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))] + if (!expected.length) return false + let response = null + let seen = new Set() + for (let attempt = 1; attempt <= 30; attempt += 1) { + response = await context.requestOverseas('GET', + '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, token) + const records = success(response) && Array.isArray(response.body?.data) ? response.body.data : [] + seen = new Set(records.map((item) => id(item?.containerNo)).filter(nonBlank)) + if (expected.every((containerNo) => seen.has(containerNo))) break + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + const ready = expected.every((containerNo) => seen.has(containerNo)) + context.addStep(MODULE, '三期生产矩阵全部进入海外待收候选后再开始PDA', response, ready, + `expected=${expected.join('|')}, seen=${[...seen].join('|')}; ${trace(response)}`, OPTIONS) + return ready +} + function skuFixtures(context) { const boxes = context.createdResources?.orders?.boxTypes const orchestration = context.createdResources?.orchestrationFixtures || {} @@ -301,7 +320,9 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe palletCode: pallets.get(firstFba.inboundCode).palletCode, idempotencyKey: key(context, 'fba-carton-forbidden'), }, (response) => conflictMessage(response, /FBA.*bulk|bulk.*FBA/i)) for (const inbound of fbaInbounds) await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode)) - } else blockerGap(context, 'FBA SKU按箱数链', '当前柜没有带真实托盘的 FBA 入仓码') + } else if (preferredProducer?.destinationType !== 'OVERSEAS') { + blockerGap(context, 'FBA SKU按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码') + } if (overseasInbound && pallets.has(overseasInbound.inboundCode)) { const pallet = pallets.get(overseasInbound.inboundCode) await expected(context, token, 'POST', 'OVERSEAS模式硬拒绝批量收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { @@ -309,7 +330,9 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'), }, (response) => conflictMessage(response, /OVERSEAS.*carton|bulk.*forbidden/i)) await receiveOverseas(context, token, overseasInbound, pallet, preferredProducer?.cartonMark) - } else blockerGap(context, 'OVERSEAS逐箱链', '当前柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码') + } else if (preferredProducer?.destinationType !== 'FBA') { + blockerGap(context, 'OVERSEAS逐箱链', '当前OVERSEAS柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码') + } const refreshed = await pda(context, token, 'GET', '回查柜内逐入仓码实收进度', '/api/pda/v1/last-leg/receiving-containers/current') if (!success(refreshed.response) || Number(refreshed.data?.pendingPackages) !== 0) { blockerGap(context, '完成当前柜', `柜内实收尚未精确核平;pendingPackages=${refreshed.data?.pendingPackages ?? 'missing'}`, 'receiving-incomplete', refreshed.response) @@ -356,6 +379,12 @@ export async function run(context) { const matrix = context.createdResources?.phase4InboundProducerFixtures const producers = [matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]] .filter((item) => nonBlank(item?.containerNo)) + const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers + if (allProducers.length && !(await awaitProducerCandidates(context, primary.token, allProducers))) { + blockerGap(context, '海外待收柜生产矩阵', + '三期已发布的FBA、OVERSEAS和第二柜尚未全部被海外事件消费者提交,禁止与消费事务并发入库') + return + } for (const [index, producer] of (producers.length ? producers : [null]).entries()) { context.pdaContainerSuffix = producer?.containerNo || `CONTAINER-${index + 1}` context.createdResources.phase4OverseasPda.finalStatus = null From 1e8d85c714f6cb8857c179886366e75a41423ab4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 22:51:33 +0800 Subject: [PATCH 062/115] fix: support id-only overseas destinations --- .../pda/service/InboundStockReceiptService.java | 7 +++++-- .../command/dto/InventoryCommandRequests.java | 4 ++-- .../command/service/InventoryCommandService.java | 14 ++++++++++---- .../command/InventoryCommandServiceTest.java | 16 ++++++++++++++++ .../phase3-port-schedules.mjs | 10 +++++----- .../phase3-producer-chain-contract.test.mjs | 1 + 6 files changed, 39 insertions(+), 13 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java index f236833d4..114d20195 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -188,8 +188,11 @@ public class InboundStockReceiptService { if (packages <= 0 || pallet.getWarehousePalletId() == null) { throw conflict("Stock-in requires picked packages and an assigned warehouse pallet"); } - String destinationCode = required(first(line.getDestinationWarehouseCode(), order.getFbaWarehouseCode()), - "Destination warehouse code is required for stock-in"); + String destinationCode = first(line.getDestinationWarehouseCode(), order.getFbaWarehouseCode()); + if (destinationCode == null && trim(line.getDestinationWarehouseId()) == null) { + throw conflict("Destination warehouse ID or code is required for stock-in"); + } + if (destinationCode == null) destinationCode = ""; String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId() + ":" + sku.skuCode(); String overseasWarehouseId = required(order.getOverseasWarehouseId(), "Actual overseas warehouse is required for stock-in"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java index 48e7b0a3d..2773296fa 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java @@ -17,7 +17,7 @@ public final class InventoryCommandRequests { String overseasWarehouseCode, String overseasWarehouseName, String destinationWarehouseId, - @NotBlank String destinationWarehouseCode, + String destinationWarehouseCode, String destinationWarehouseName, @NotNull Long inboundOrderId, @NotBlank String inboundOrderNo, @@ -44,7 +44,7 @@ public final class InventoryCommandRequests { String overseasWarehouseCode, String overseasWarehouseName, String destinationWarehouseId, - @NotBlank String destinationWarehouseCode, + String destinationWarehouseCode, String destinationWarehouseName, @NotNull Long inboundOrderId, @NotBlank String inboundOrderNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index cc8d917af..f42015986 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -233,8 +233,14 @@ public class InventoryCommandService { String inboundCode, String requestedSkuCode, String productNameZh, String productNameEn) { String skuCode = sku(requestedSkuCode); + String destinationId = trim(destinationWarehouseId); + String destinationCode = trim(destinationWarehouseCode); + if (destinationId == null && destinationCode == null) { + throw badRequest("Destination warehouse ID or code is required"); + } + if (destinationCode == null) destinationCode = ""; InventoryBalance balance = balances.lockDimension(customerId, overseasWarehouseId, - destinationWarehouseCode, inboundOrderId, inboundCode); + destinationCode, inboundOrderId, inboundCode); if (balance != null) { mergeSkuSnapshot(balance, skuCode); return balance; @@ -246,8 +252,8 @@ public class InventoryCommandService { balance.setOverseasWarehouseId(overseasWarehouseId.trim()); balance.setOverseasWarehouseCode(trim(overseasWarehouseCode)); balance.setOverseasWarehouseName(trim(overseasWarehouseName)); - balance.setDestinationWarehouseId(trim(destinationWarehouseId)); - balance.setDestinationWarehouseCode(destinationWarehouseCode.trim()); + balance.setDestinationWarehouseId(destinationId); + balance.setDestinationWarehouseCode(destinationCode); balance.setDestinationWarehouseName(trim(destinationWarehouseName)); balance.setInboundOrderId(inboundOrderId); balance.setInboundOrderNo(inboundOrderNo.trim()); @@ -264,7 +270,7 @@ public class InventoryCommandService { return balance; } catch (DuplicateKeyException exception) { InventoryBalance concurrent = balances.lockDimension(customerId, overseasWarehouseId, - destinationWarehouseCode, inboundOrderId, inboundCode); + destinationCode, inboundOrderId, inboundCode); if (concurrent != null) { mergeSkuSnapshot(concurrent, skuCode); return concurrent; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java index 1fa98af51..5722fc1e3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java @@ -92,6 +92,22 @@ class InventoryCommandServiceTest { verify(ledgers).insert(any(InventoryLedger.class)); } + @Test + void registersIdOnlyOverseasDestinationWithoutInventingAWarehouseCode() { + when(balances.lockDimension("100", "WH-1", "", 101L, "IN-CODE-1")).thenReturn(null); + InventoryCommandRequests.InTransit request = new InventoryCommandRequests.InTransit( + "Customer 100", "WH-1", "WH1", "Warehouse 1", "DEST-OVERSEAS", null, + "New York warehouse", 101L, "IN-1", "IN-CODE-1", "SKU-1", "Product 1", null, + 1, "ORD-1", "EVT-1", "transit-id-only", null); + + service.registerInTransit("100", "SYSTEM", request); + + ArgumentCaptor inserted = ArgumentCaptor.forClass(InventoryBalance.class); + verify(balances).insert(inserted.capture()); + assertEquals("DEST-OVERSEAS", inserted.getValue().getDestinationWarehouseId()); + assertEquals("", inserted.getValue().getDestinationWarehouseCode()); + } + @Test void stockInTransfersPackagesFromInTransitToAvailable() { InventoryBalance balance = balance(200L, 0); diff --git a/scripts/admin-test-env-modules/phase3-port-schedules.mjs b/scripts/admin-test-env-modules/phase3-port-schedules.mjs index 1611f64e5..314271727 100644 --- a/scripts/admin-test-env-modules/phase3-port-schedules.mjs +++ b/scripts/admin-test-env-modules/phase3-port-schedules.mjs @@ -56,9 +56,9 @@ function oceanDraft(context, suffix, master) { } } -async function ensureOceanBooking(context, suffix, master) { - if (context.createdResources?.oceanBooking?.id) { - const existingId = id(context.createdResources.oceanBooking.id) +async function ensureOceanBooking(context, suffix, master, preferredOceanBookingId = null) { + const existingId = id(preferredOceanBookingId || context.createdResources?.oceanBooking?.id) + if (existingId) { const response = await request(context, 'GET', `${OCEAN}/${existingId}`) const detail = context.apiSuccess(MODULE, '读取船期关联订舱真实柜次分配', response, `oceanBookingId=${existingId}`, OPTIONS) @@ -118,10 +118,10 @@ export async function run(context) { const master = await resolveOceanBookingMaster(context, MODULE) if (!master) return - const ocean = await ensureOceanBooking(context, suffix, master) - if (!ocean?.id) return const flow = context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading + const ocean = await ensureOceanBooking(context, suffix, master, flow?.oceanBookingId) + if (!ocean?.id) return const containerBookingId = ['COMPLETED', 'LOADED'].includes(flow?.finalStatus) ? id(flow.containerBookingId) : null const knownAllocation = containerBookingId diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index c5d841082..9d9038c6c 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -503,6 +503,7 @@ test('downstream container consumers prefer the phase3Loading fixture', () => { assert.doesNotMatch(serviceCenter, /链路承运商-/) assert.doesNotMatch(serviceCenter, /customerName: `服务中心链路客户-/) const schedules = source('./phase3-port-schedules.mjs') + assert.match(schedules, /ensureOceanBooking\(context, suffix, master, flow\?\.oceanBookingId\)/) assert.match(schedules, /allocatedContainerBookingIds: \(detail\.containers \|\| \[\]\)/) assert.match(schedules, /ocean\.allocatedContainerBookingIds\?\.includes\(containerBookingId\)/) assert.match(schedules, /if \(containerBookingId && !knownAllocation\)/) From bf4fd95841374a3c2fe3321b4f56b9857bdc95f5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 23:12:04 +0800 Subject: [PATCH 063/115] fix: align phase4 receiving and carrier fixtures --- .../pda/service/ReceivingContainerService.java | 9 --------- .../ReceivingContainerServiceBehaviorTest.java | 16 ++++++++++++++++ .../phase3-http-fail-closed-contract.test.mjs | 2 ++ .../admin-test-env-modules/phase3-loading.mjs | 12 +++++++++++- .../phase3-outbound-dispatch.mjs | 4 +++- .../phase3-producer-chain-contract.test.mjs | 5 +++++ 6 files changed, 37 insertions(+), 11 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index a49339834..f76eaf789 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -449,15 +449,6 @@ public class ReceivingContainerService { if (pallet.fbaWarehouseCode() != null && !pallet.fbaWarehouseCode().isBlank()) { throw conflict("FBA-designated pallet cannot receive an OVERSEAS destination carton"); } - if (ref.line().getDestinationWarehouseId() != null && !ref.line().getDestinationWarehouseId().isBlank()) { - try { - if (Long.parseLong(ref.line().getDestinationWarehouseId()) != pallet.warehouseId()) { - throw conflict("Pallet destination warehouse does not match the inbound code"); - } - } catch (NumberFormatException ignored) { - // Destination codes that are not resource IDs are still protected by the actual overseas warehouse check. - } - } } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 0241f0153..61b54e06d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -224,6 +224,22 @@ class ReceivingContainerServiceBehaviorTest { verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); } + @Test + void overseasPalletUsesActualReceivingWarehouseInsteadOfBusinessDestinationId() { + stubCurrent("OVERSEAS", "CONT-A"); + InboundPlanLine overseasLine = line("OVERSEAS"); + overseasLine.setDestinationWarehouseId("900"); + when(lines.selectList(any())).thenReturn(List.of(overseasLine)); + when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, null)); + + var resolved = service.resolvePallet("7", + new ReceivingContainerRequests.PalletResolve("PAL-1", "IN-1")); + + assertThat(resolved.compatible()).isTrue(); + assertThat(resolved.warehouseId()).isEqualTo(100L); + assertThat(resolved.destinationType()).isEqualTo("OVERSEAS"); + } + @Test void containerCompletionRequiresEveryInboundCodeToBalanceExactly() { stubCurrent("FBA", "CONT-A"); diff --git a/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs index 9f2107dbf..ae644258e 100644 --- a/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs @@ -47,4 +47,6 @@ test('outbound lifecycle checks replay, hash conflict, stale version and final r '负向与幂等验证后回查出仓聚合', '出仓聚合保持 READY_FOR_DISPATCH、LOADED 和确认撤销历史', ]) assert.match(source, new RegExp(step)) + assert.match(source, /if \(!confirmed\?\.aggregate\) return/) + assert.match(source, /hashConflict\.http === 200 && codeOf\(hashConflict\) === 'A0409'/) }) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 4280e8609..a5e5357b5 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -138,12 +138,22 @@ export async function run(context) { context.assert(MODULE, '存在可用于发布订舱的 ACTIVE 订舱代理', supplierResponse, Boolean(supplier?.id && supplier?.name), `supplierId=${id(supplier?.id) || 'missing'}`, OPTIONS) if (!supplier?.id) return + const carrierResponse = await context.request('GET', + '/api/platform/v1/carrier-schedule-queries/carriers') + const carriers = context.apiSuccess(MODULE, '读取船节点canonical船司目录', carrierResponse, '', OPTIONS) + const shippingCarrier = Array.isArray(carriers) + ? carriers.find((value) => value?.carrierCode === 'MATSON' && value?.carrierName) + : null + context.assert(MODULE, '订舱船司与订舱代理使用各自真实事实源', carrierResponse, + Boolean(shippingCarrier?.carrierName), + `carrierCode=${shippingCarrier?.carrierCode || 'missing'}, bookingAgentId=${id(supplier.id)}`, OPTIONS) + if (!shippingCarrier?.carrierName) return const bookingDraft = { customerId: tenantId, customerName: initial?.summary?.customerName, warehouseId, channelCode: initial?.summary?.channelCode, billOfLadingNo: `P3BL-${suffix}`.slice(0, 128), - shippingCompanyId: id(supplier.id), shippingCompanyName: supplier.name, + shippingCompanyId: null, shippingCompanyName: shippingCarrier.carrierName, bookingAgentId: id(supplier.id), bookingAgentName: supplier.name, vesselName: `P3VESSEL-${suffix}`.slice(0, 128), voyageNo: `P3V-${suffix}`.slice(0, 64), departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', diff --git a/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs b/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs index 30d553185..94611f91e 100644 --- a/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs @@ -107,6 +107,7 @@ export async function run(context) { && confirmed?.aggregate?.inboundStatus === 'LOADED' && Number(confirmed?.aggregate?.version) === expectedVersion + 1, `dispatch=${confirmed?.aggregate?.dispatchStatus || 'missing'}, inbound=${confirmed?.aggregate?.inboundStatus || 'missing'}, version=${confirmed?.aggregate?.version ?? 'missing'}`, OPTIONS) + if (!confirmed?.aggregate) return const replayResponse = await request(context, 'POST', `${BASE}/confirm-dispatch`, actionBody, { 'Idempotency-Key': confirmKey }) @@ -118,7 +119,8 @@ export async function run(context) { const hashConflict = await request(context, 'POST', `${BASE}/confirm-dispatch`, { ...actionBody, remark: 'forged' }, { 'Idempotency-Key': confirmKey }) - context.addStep(MODULE, '相同 Idempotency-Key 不同请求拒绝', hashConflict, rejected(hashConflict), + context.addStep(MODULE, '相同 Idempotency-Key 不同请求拒绝', hashConflict, + hashConflict.http === 200 && codeOf(hashConflict) === 'A0409', `HTTP=${hashConflict.http}, code=${codeOf(hashConflict)}`, OPTIONS) const reverseKey = `${context.marker}-outbound-reverse`.slice(0, 128) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 9d9038c6c..84aa0b25e 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -395,6 +395,11 @@ test('preload and loading versions use immediate operation baselines and exact p assert.equal((loading.match(/request\(context, 'POST', pushRequest\.path, pushRequest\.body, pushRequest\.headers\)/g) || []).length, 2) assert.match(loading, /push exact replay 对计划版本历史关联和任务零漂移/) assert.match(loading, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/) + assert.match(loading, /carrier-schedule-queries\/carriers/) + assert.match(loading, /carrierCode === 'MATSON'/) + assert.match(loading, /shippingCompanyId: null, shippingCompanyName: shippingCarrier\.carrierName/) + assert.match(loading, /bookingAgentId: id\(supplier\.id\), bookingAgentName: supplier\.name/) + assert.doesNotMatch(loading, /shippingCompanyId: id\(supplier\.id\), shippingCompanyName: supplier\.name/) }) test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING audit with operator metadata', () => { From 985d952e543899bde4c03f2c9c36017324208184 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 23:19:27 +0800 Subject: [PATCH 064/115] test: provision canonical carrier fixture --- .../admin-test-env-modules/phase3-loading.mjs | 50 +++++++++++++------ .../phase3-producer-chain-contract.test.mjs | 7 +-- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index a5e5357b5..b2b22f6ef 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -5,6 +5,7 @@ const MODULE = 'phase3-loading' const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true } const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司' function id(value) { return value == null ? '' : String(value) } export function markerSuffix(marker) { @@ -22,6 +23,27 @@ function failure(response) { || (response?.http === 200 && codeOf(response) !== '200') } +async function ensureMatsonSupplier(context, records) { + const existing = records.find((value) => value?.id && value?.name === MATSON_SUPPLIER_NAME) + if (existing?.status === 'ACTIVE') return existing + if (existing?.id) { + const activatedResponse = await context.request('PATCH', + `/api/platform/v1/suppliers/${encodeURIComponent(id(existing.id))}/status?tenantId=0`, { status: 'ACTIVE' }) + const activated = context.apiSuccess(MODULE, '启用稳定MATSON船司开发夹具', activatedResponse, + `supplierId=${id(existing.id)}`, OPTIONS) + return activated?.id ? activated : null + } + const createdResponse = await context.request('POST', '/api/platform/v1/suppliers?tenantId=0', { + name: MATSON_SUPPLIER_NAME, category: 'BOOKING', region: '开发环境', legalRepresentative: '开发联调', + contactName: '开发联调', phone: '13800000000', email: 'matson-dev-fixture@example.com', + address: '开发环境稳定船司夹具', businessLicenseNo: 'DEV-CARRIER-MATSON', licenseFileId: null, + status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: '三四期开发环境HTTP验收稳定船司夹具', + }) + const supplierId = id(context.apiSuccess(MODULE, '创建稳定MATSON船司开发夹具', createdResponse, + `name=${MATSON_SUPPLIER_NAME}`, OPTIONS)) + return supplierId ? { id: supplierId, name: MATSON_SUPPLIER_NAME, status: 'ACTIVE' } : null +} + async function request(context, method, path, body, headers = {}) { try { const response = await fetch(`${context.options.gateway}${path}`, { @@ -132,28 +154,28 @@ export async function run(context) { const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading' const pdaBase = '/api/platform/v1/first-leg/pda-loading' const supplierResponse = await context.request('GET', - '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20') - const supplierPage = context.apiSuccess(MODULE, '读取 ACTIVE 订舱代理作为运输事实源', supplierResponse, '', OPTIONS) - const supplier = supplierPage?.records?.find?.((value) => value?.id && value?.name) + '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&page=1&size=200') + const supplierPage = context.apiSuccess(MODULE, '读取订舱代理与船司供应商事实源', supplierResponse, '', OPTIONS) + const supplierRecords = Array.isArray(supplierPage?.records) ? supplierPage.records : [] + const supplier = supplierRecords.find((value) => value?.id && value?.name && value?.status === 'ACTIVE' + && value.name !== MATSON_SUPPLIER_NAME) context.assert(MODULE, '存在可用于发布订舱的 ACTIVE 订舱代理', supplierResponse, Boolean(supplier?.id && supplier?.name), `supplierId=${id(supplier?.id) || 'missing'}`, OPTIONS) if (!supplier?.id) return - const carrierResponse = await context.request('GET', - '/api/platform/v1/carrier-schedule-queries/carriers') - const carriers = context.apiSuccess(MODULE, '读取船节点canonical船司目录', carrierResponse, '', OPTIONS) - const shippingCarrier = Array.isArray(carriers) - ? carriers.find((value) => value?.carrierCode === 'MATSON' && value?.carrierName) - : null - context.assert(MODULE, '订舱船司与订舱代理使用各自真实事实源', carrierResponse, - Boolean(shippingCarrier?.carrierName), - `carrierCode=${shippingCarrier?.carrierCode || 'missing'}, bookingAgentId=${id(supplier.id)}`, OPTIONS) - if (!shippingCarrier?.carrierName) return + const shippingCarrier = await ensureMatsonSupplier(context, supplierRecords) + context.assert(MODULE, '订舱船司与订舱代理使用各自真实供应商事实', supplierResponse, + Boolean(shippingCarrier?.id && shippingCarrier?.name === MATSON_SUPPLIER_NAME), + `shippingCompanyId=${id(shippingCarrier?.id) || 'missing'}, bookingAgentId=${id(supplier.id)}`, OPTIONS) + if (!shippingCarrier?.id) return + context.createdResources.phase3CarrierSupplier = { + supplierId: id(shippingCarrier.id), name: shippingCarrier.name, status: 'ACTIVE', stableDevFixture: true, + } const bookingDraft = { customerId: tenantId, customerName: initial?.summary?.customerName, warehouseId, channelCode: initial?.summary?.channelCode, billOfLadingNo: `P3BL-${suffix}`.slice(0, 128), - shippingCompanyId: null, shippingCompanyName: shippingCarrier.carrierName, + shippingCompanyId: id(shippingCarrier.id), shippingCompanyName: shippingCarrier.name, bookingAgentId: id(supplier.id), bookingAgentName: supplier.name, vesselName: `P3VESSEL-${suffix}`.slice(0, 128), voyageNo: `P3V-${suffix}`.slice(0, 64), departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 84aa0b25e..735a08096 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -395,9 +395,10 @@ test('preload and loading versions use immediate operation baselines and exact p assert.equal((loading.match(/request\(context, 'POST', pushRequest\.path, pushRequest\.body, pushRequest\.headers\)/g) || []).length, 2) assert.match(loading, /push exact replay 对计划版本历史关联和任务零漂移/) assert.match(loading, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/) - assert.match(loading, /carrier-schedule-queries\/carriers/) - assert.match(loading, /carrierCode === 'MATSON'/) - assert.match(loading, /shippingCompanyId: null, shippingCompanyName: shippingCarrier\.carrierName/) + assert.match(loading, /MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司'/) + assert.match(loading, /category=BOOKING&page=1&size=200/) + assert.match(loading, /ensureMatsonSupplier\(context, supplierRecords\)/) + assert.match(loading, /shippingCompanyId: id\(shippingCarrier\.id\), shippingCompanyName: shippingCarrier\.name/) assert.match(loading, /bookingAgentId: id\(supplier\.id\), bookingAgentName: supplier\.name/) assert.doesNotMatch(loading, /shippingCompanyId: id\(supplier\.id\), shippingCompanyName: supplier\.name/) }) From 615ecb4ba1d54604e28bed595fec9a2487c1ebc3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 23:28:24 +0800 Subject: [PATCH 065/115] test: complete overseas carton and sailing fixtures --- .../phase3-loading-finalize.mjs | 1 + .../admin-test-env-modules/phase3-loading.mjs | 2 + .../phase3-port-schedules.mjs | 3 +- .../phase3-producer-chain-contract.test.mjs | 4 ++ .../phase4-overseas-pda-contract.test.mjs | 2 + .../phase4-overseas-pda.mjs | 61 ++++++++++++------- 6 files changed, 48 insertions(+), 25 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index e64e70553..aa7bc88c8 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -366,6 +366,7 @@ export async function run(context) { sourceOrderId: orderId, sourceInboundId: inboundId, cartonMark: fixture.cartonMark || null, + cartonMarks: Array.isArray(fixture.cartonMarks) ? fixture.cartonMarks : [], destinationType: fixture.destinationType || null, destinationWarehouseId: id(fixture.destinationWarehouseId), destinationWarehouseCode: fixture.destinationWarehouseCode || null, diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index b2b22f6ef..38c2da961 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1213,6 +1213,8 @@ export async function run(context) { context.createdResources.phase3Loading = { tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId, cartonMark: source?.boxCode || source?.cartonMark || null, + cartonMarks: [...new Set((stagedTask?.items || []).flatMap((item) => item?.boxCodes || []) + .map((value) => String(value || '').trim()).filter(Boolean))], destinationType: initial?.summary?.destinationType || source?.destinationType || null, destinationWarehouseId: id(initial?.summary?.destinationWarehouseId || source?.destinationWarehouseId), destinationWarehouseCode: initial?.summary?.destinationCode || source?.destinationWarehouseCode || null, diff --git a/scripts/admin-test-env-modules/phase3-port-schedules.mjs b/scripts/admin-test-env-modules/phase3-port-schedules.mjs index 314271727..823a79b81 100644 --- a/scripts/admin-test-env-modules/phase3-port-schedules.mjs +++ b/scripts/admin-test-env-modules/phase3-port-schedules.mjs @@ -122,8 +122,7 @@ export async function run(context) { || context.createdResources?.phase3ContainerLoading const ocean = await ensureOceanBooking(context, suffix, master, flow?.oceanBookingId) if (!ocean?.id) return - const containerBookingId = ['COMPLETED', 'LOADED'].includes(flow?.finalStatus) - ? id(flow.containerBookingId) : null + const containerBookingId = id(flow?.containerBookingId) || null const knownAllocation = containerBookingId && ocean.allocatedContainerBookingIds?.includes(containerBookingId) ? containerBookingId : null if (containerBookingId && !knownAllocation) { diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 735a08096..4d1662b28 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -401,6 +401,8 @@ test('preload and loading versions use immediate operation baselines and exact p assert.match(loading, /shippingCompanyId: id\(shippingCarrier\.id\), shippingCompanyName: shippingCarrier\.name/) assert.match(loading, /bookingAgentId: id\(supplier\.id\), bookingAgentName: supplier\.name/) assert.doesNotMatch(loading, /shippingCompanyId: id\(supplier\.id\), shippingCompanyName: supplier\.name/) + assert.match(loading, /cartonMarks: \[\.\.\.new Set\(\(stagedTask\?\.items \|\| \[\]\)\.flatMap/) + assert.match(source('./phase3-loading-finalize.mjs'), /cartonMarks: Array\.isArray\(fixture\.cartonMarks\)/) }) test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING audit with operator metadata', () => { @@ -510,6 +512,8 @@ test('downstream container consumers prefer the phase3Loading fixture', () => { assert.doesNotMatch(serviceCenter, /customerName: `服务中心链路客户-/) const schedules = source('./phase3-port-schedules.mjs') assert.match(schedules, /ensureOceanBooking\(context, suffix, master, flow\?\.oceanBookingId\)/) + assert.match(schedules, /const containerBookingId = id\(flow\?\.containerBookingId\) \|\| null/) + assert.doesNotMatch(schedules, /\['COMPLETED', 'LOADED'\]\.includes\(flow\?\.finalStatus\)/) assert.match(schedules, /allocatedContainerBookingIds: \(detail\.containers \|\| \[\]\)/) assert.match(schedules, /ocean\.allocatedContainerBookingIds\?\.includes\(containerBookingId\)/) assert.match(schedules, /if \(containerBookingId && !knownAllocation\)/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index ab045f947..07be663c7 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -43,6 +43,8 @@ test('PDA module covers the container-number receiving contract and excludes out assert.doesNotMatch(source, /const warehouseId = id\(inbound\?\.destinationWarehouseId\)/) assert.match(source, /目的仓 .* 不得代替物理仓/) assert.match(source, /retained as acceptance evidence/) + assert.match(source, /preferredProducer\?\.cartonMarks\?\.length/) + assert.match(source, /for \(const \[index, cartonMark\] of cartonMarks\.entries\(\)\)/) }) test('missing WAREHOUSE_PICKER overseas authority is a deployment blocker', async () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 2da55c862..1720315b5 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -202,27 +202,40 @@ async function receiveFba(context, token, inbound, pallet) { return true } -async function receiveOverseas(context, token, inbound, pallet, producerCartonMark = null) { - const cartonMark = producerCartonMark || context.createdResources?.inboundMeasurement?.boxCode - if (!nonBlank(cartonMark)) { - blockerGap(context, `OVERSEAS入仓码${inbound.inboundCode}逐箱`, '缺少本轮冻结箱唛,逐箱实收不能使用伪造箱号') +async function receiveOverseas(context, token, inbounds, pallets, producerCartonMarks = []) { + const cartonMarks = [...new Set((Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks]) + .map(id).filter(nonBlank))] + if (!cartonMarks.length) { + blockerGap(context, 'OVERSEAS逐箱链', '缺少本轮PDA任务冻结箱唛,逐箱实收不能使用伪造箱号') return false } - const resolved = await pda(context, token, 'POST', '解析OVERSEAS冻结箱唛', '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }) - if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode) { - blockerGap(context, `OVERSEAS入仓码${inbound.inboundCode}箱唛归属`, `冻结箱唛未解析到目标入仓码;cartonMark=${cartonMark}`, 'fixture-mismatch', resolved.response) - return false + for (const [index, cartonMark] of cartonMarks.entries()) { + const resolved = await pda(context, token, 'POST', `解析OVERSEAS冻结箱唛${index + 1}`, + '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }) + const inbound = inbounds.find((item) => item.inboundCode === resolved.data?.inboundCode) + const pallet = inbound ? pallets.get(inbound.inboundCode) : null + if (!success(resolved.response) || !inbound || !pallet) { + blockerGap(context, `OVERSEAS箱唛${cartonMark}归属`, + `冻结箱唛未解析到带真实托盘的本柜入仓码;inboundCode=${resolved.data?.inboundCode || 'missing'}`, + 'fixture-mismatch', resolved.response) + return false + } + const body = { cartonMark, palletCode: pallet.palletCode, + idempotencyKey: key(context, 'overseas-carton', cartonMark) } + const first = await pda(context, token, 'POST', `OVERSEAS逐箱实收${index + 1}`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body) + if (!success(first.response)) return false + const replay = await pda(context, token, 'POST', `OVERSEAS逐箱${index + 1}同键同载荷重放`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body) + context.assert(MODULE, `OVERSEAS逐箱${index + 1}幂等结果一致`, replay.response, + success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) + await expected(context, token, 'POST', `OVERSEAS逐箱${index + 1}同键异载荷冲突`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', + { ...body, palletCode: `${pallet.palletCode}-MUTATED` }, conflict) + await expected(context, token, 'POST', `OVERSEAS重复箱新键被拒绝${index + 1}`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', + { ...body, idempotencyKey: key(context, 'overseas-duplicate', cartonMark) }, conflict) } - const body = { cartonMark, palletCode: pallet.palletCode, idempotencyKey: key(context, 'overseas-carton') } - const first = await pda(context, token, 'POST', 'OVERSEAS逐箱实收', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body) - if (!success(first.response)) return false - const replay = await pda(context, token, 'POST', 'OVERSEAS逐箱同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body) - context.assert(MODULE, 'OVERSEAS逐箱幂等结果一致', replay.response, - success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) - await expected(context, token, 'POST', 'OVERSEAS逐箱同键异载荷冲突', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', - { ...body, palletCode: `${pallet.palletCode}-MUTATED` }, conflict) - await expected(context, token, 'POST', 'OVERSEAS重复箱新键被拒绝', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', - { ...body, idempotencyKey: key(context, 'overseas-duplicate') }, conflict) return true } @@ -312,7 +325,7 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe success(resolved.response) && resolved.data?.compatible === true && resolved.data?.destinationType === inbound.destinationType, trace(resolved.response), OPTIONS) } const fbaInbounds = current.data.inbounds.filter((item) => item.destinationType === 'FBA') - const overseasInbound = current.data.inbounds.find((item) => item.destinationType === 'OVERSEAS') + const overseasInbounds = current.data.inbounds.filter((item) => item.destinationType === 'OVERSEAS') if (fbaInbounds.length && fbaInbounds.every((item) => pallets.has(item.inboundCode))) { const firstFba = fbaInbounds[0] await expected(context, token, 'POST', 'FBA模式硬拒绝逐箱收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', { @@ -323,13 +336,15 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe } else if (preferredProducer?.destinationType !== 'OVERSEAS') { blockerGap(context, 'FBA SKU按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码') } - if (overseasInbound && pallets.has(overseasInbound.inboundCode)) { - const pallet = pallets.get(overseasInbound.inboundCode) + if (overseasInbounds.length && overseasInbounds.every((item) => pallets.has(item.inboundCode))) { + const firstOverseas = overseasInbounds[0] + const pallet = pallets.get(firstOverseas.inboundCode) await expected(context, token, 'POST', 'OVERSEAS模式硬拒绝批量收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { - inboundCode: overseasInbound.inboundCode, skuCode: 'MODE-REJECT', palletCode: pallet.palletCode, + inboundCode: firstOverseas.inboundCode, skuCode: 'MODE-REJECT', palletCode: pallet.palletCode, packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'), }, (response) => conflictMessage(response, /OVERSEAS.*carton|bulk.*forbidden/i)) - await receiveOverseas(context, token, overseasInbound, pallet, preferredProducer?.cartonMark) + await receiveOverseas(context, token, overseasInbounds, pallets, + preferredProducer?.cartonMarks?.length ? preferredProducer.cartonMarks : [preferredProducer?.cartonMark]) } else if (preferredProducer?.destinationType !== 'FBA') { blockerGap(context, 'OVERSEAS逐箱链', '当前OVERSEAS柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码') } From 058bf9f4e6979065f2ee17ac9171bad68e348419 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 23:35:26 +0800 Subject: [PATCH 066/115] test: complete phase4 cross-region handoff --- .../phase4-carrier-nodes-contract.test.mjs | 4 ++-- .../phase4-carrier-nodes.mjs | 2 +- .../phase4-overseas-pda-contract.test.mjs | 3 +++ .../phase4-overseas-pda.mjs | 23 ++++++++++++++++++- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index 1fd31f443..edce73035 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -128,7 +128,7 @@ function markerOwnedContext() { }] const histories = [{ id: String(nextId++), action: 'AUTO_CREATE', fromVersion: null, toVersion: 0, - reason: '国内确认出仓自动创建', operatorId: CUSTOMER_ID, operatorName: 'fixture', + reason: '国内确认出仓自动创建', operatorId: '1000001', operatorName: 'fixture', createdAt: '2026-08-24T08:00:01', }] const detail = () => ({ query: { query: clone(task), expired: true }, @@ -178,7 +178,7 @@ function markerOwnedContext() { } else task.status = 'READY' } histories.push({ id: String(nextId++), action: 'MANUAL_NODE', fromVersion: task.version - 1, - toVersion: task.version, reason: body.reason, operatorId: CUSTOMER_ID, operatorName: 'fixture', + toVersion: task.version, reason: body.reason, operatorId: '1000001', operatorName: 'fixture', createdAt: body.observedAt }) const saved = detail(); idempotency.set(key, { signature, detail: clone(saved) }) return ok(saved, `req-${key}`) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index a2a5ba478..c5883a15b 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -71,7 +71,7 @@ function validHistory(item) { return validLongId(item?.id) && Boolean(item?.action) && (item?.fromVersion == null || number(item.fromVersion)) && (item?.toVersion == null || number(item.toVersion)) - && nullableText(item?.reason) && nullableId(item?.operatorId) + && nullableText(item?.reason) && (item?.operatorId == null || validNumericId(item.operatorId)) && nullableText(item?.operatorName) && nullableIso(item?.createdAt) } diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 07be663c7..45e5a374e 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -45,6 +45,9 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /retained as acceptance evidence/) assert.match(source, /preferredProducer\?\.cartonMarks\?\.length/) assert.match(source, /for \(const \[index, cartonMark\] of cartonMarks\.entries\(\)\)/) + assert.match(source, /last-leg\/orders\?page=1&size=200&containerNo=/) + assert.match(source, /phase4OverseasPda\.orderId = id\(order\.id\)/) + assert.match(source, /phase4OverseasPda\.customerId = id\(order\.customerId\)/) }) test('missing WAREHOUSE_PICKER overseas authority is a deployment blocker', async () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 1720315b5..d24debae7 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -17,6 +17,7 @@ function forbidden(response) { return [401, 403].includes(response?.http) || (re function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` } function key(context, action, suffix = '') { return `${context.marker}-${context.pdaContainerSuffix || ''}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) } function markerSuffix(context) { return `${context.marker}${context.pdaContainerSuffix || ''}`.replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() } +function records(data) { return Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : [] } function blockerGap(context, name, reason, category = 'fixture-unavailable', response = null) { context.addCoverageGap(MODULE, SCENARIO, name, reason, category, { deploymentBlocker: true }) @@ -35,6 +36,23 @@ async function expected(context, token, method, name, path, body, predicate) { return response } +async function captureCompletedOrder(context, containerNo, expectedCustomerId) { + const response = await context.requestOverseas('GET', + `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`) + const candidates = records(response?.body?.data) + .filter((item) => id(item?.containerNo) === id(containerNo)) + const exactCustomer = expectedCustomerId == null ? candidates + : candidates.filter((item) => id(item?.customerId) === id(expectedCustomerId)) + const order = exactCustomer[0] || candidates[0] + const mapped = success(response) && longId(order?.id) && nonBlank(id(order?.customerId)) + context.assert(MODULE, '完成柜回查平台尾程订单并冻结跨区身份', response, mapped, + `containerNo=${containerNo}, orderId=${id(order?.id) || 'missing'}, customerId=${id(order?.customerId) || 'missing'}; ${trace(response)}`, OPTIONS) + if (mapped) { + context.createdResources.phase4OverseasPda.orderId = id(order.id) + context.createdResources.phase4OverseasPda.customerId = id(order.customerId) + } +} + async function loginMerchantForNegativeProbe(context) { const fixture = context.context?.merchantQualificationFixture if (!fixture?.username || !fixture?.password) return null @@ -362,11 +380,14 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe const after = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token) context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS) context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null + if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') { + await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId) + } } export async function run(context) { context.createdResources.phase4OverseasPda = { - marker: context.marker, containerNo: null, customerId: null, + marker: context.marker, containerNo: null, customerId: null, orderId: null, palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], completedContainers: [], overseasWarehouseId: null, overseasWarehouseName: null, cleanup: 'Shared warehouse, pallets and locations used by completed inventory are retained as acceptance evidence', From a22a284e7dd4f5f31d1bc75400c96748c2edcdaa Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 23:43:08 +0800 Subject: [PATCH 067/115] fix: route unload events to domestic bridge --- config/nacos/overseas/lastleg-service.yml | 2 +- deploy/docker-compose.overseas.yml | 7 ++++++- deploy/docker-compose.yml | 4 ++++ .../phase4-carrier-nodes-contract.test.mjs | 5 +++++ scripts/admin-test-env-modules/phase4-carrier-nodes.mjs | 5 ++++- scripts/overseas-deployment-contract.test.mjs | 6 +++++- 6 files changed, 25 insertions(+), 4 deletions(-) diff --git a/config/nacos/overseas/lastleg-service.yml b/config/nacos/overseas/lastleg-service.yml index b4e34c36d..e6241c4b6 100644 --- a/config/nacos/overseas/lastleg-service.yml +++ b/config/nacos/overseas/lastleg-service.yml @@ -21,7 +21,7 @@ baiyue: warehouse: base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://warehouse-overseas-service:9305} firstleg: - event-bridge-url: ${FIRSTLEG_EVENT_BRIDGE_URL} + event-bridge-url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}} outbox-relay: fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000} lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index e807b86e2..0c95a4301 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -58,7 +58,8 @@ services: LASTLEG_DB_URL: ${LASTLEG_DB_URL} LASTLEG_DB_USERNAME: ${LASTLEG_DB_USERNAME} LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD} - FIRSTLEG_EVENT_BRIDGE_URL: ${FIRSTLEG_EVENT_BRIDGE_URL} + CN_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_URL:-http://event-bridge-cn:9312} + FIRSTLEG_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_URL:-http://event-bridge-cn:9312} DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} FILE_SERVICE_URL: ${FILE_SERVICE_URL} WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305} @@ -89,6 +90,10 @@ services: EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_OVERSEAS_DB_USERNAME} EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_OVERSEAS_DB_PASSWORD} EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/events} + networks: + firstleg-overseas-network: + infra-network: + aliases: [event-bridge-overseas] networks: firstleg-overseas-network: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 385efc81b..e917070cf 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -245,6 +245,10 @@ services: EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events} FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events} SPRING_SQL_INIT_MODE: never + networks: + firstleg-network: + infra-network: + aliases: [event-bridge-cn] audit-service: <<: *common diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index edce73035..2b88fe588 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -53,6 +53,11 @@ test('canonical states, ten ordered nodes, exception codes and complete payload 'locationCode', 'locationName', 'reason']) }) +test('actual node timestamps advance from the frozen outbound-confirm fact instead of the runner timezone', () => { + assert.match(source, /Date\.parse\(`\$\{frozenLatestActual\}Z`\)/) + assert.match(source, /frozenLatestActualMs\) \? Date\.now\(\) : frozenLatestActualMs/) +}) + test('module exposes no scheduler, attempt, internal or arbitrary stop endpoint', () => { assert.match(source, /\/manual-nodes/) assert.match(source, /'Idempotency-Key'/) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index c5883a15b..bd0bea3a9 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -184,7 +184,10 @@ export async function run(context) { `version=${detail?.query?.query?.version ?? 'missing'}, nodes=${detail?.nodes?.length ?? 'missing'}`, OPTIONS) let version = Number(detail.query.query.version) - const baseActual = Date.now() + 60 * 60_000 + const frozenLatestActual = detail?.query?.query?.latestActualAt + const frozenLatestActualMs = frozenLatestActual == null ? Number.NaN + : Date.parse(`${frozenLatestActual}Z`) + const baseActual = (Number.isNaN(frozenLatestActualMs) ? Date.now() : frozenLatestActualMs) + 60 * 60_000 for (let index = 0; index < STANDARD_NODES.length; index += 1) { const standardNode = STANDARD_NODES[index] const actualAt = localIso(new Date(baseActual + index * 60_000)) diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index 3577c68a7..ce3a29362 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -53,6 +53,9 @@ test('overseas compose is isolated from CN defaults and disables implicit schema assert.match(compose, /EVENT_BRIDGE_DB_URL: \$\{EVENT_BRIDGE_OVERSEAS_DB_URL\}/) assert.match(compose, /EVENT_BRIDGE_DB_USERNAME: \$\{EVENT_BRIDGE_OVERSEAS_DB_USERNAME\}/) assert.match(compose, /EVENT_BRIDGE_DB_PASSWORD: \$\{EVENT_BRIDGE_OVERSEAS_DB_PASSWORD\}/) + assert.match(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_URL:-http:\/\/event-bridge-cn:9312\}/) + assert.match(compose, /FIRSTLEG_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_URL:-http:\/\/event-bridge-cn:9312\}/) + assert.match(compose, /aliases: \[event-bridge-overseas\]/) }) test('CN and overseas event bridges have explicit opposite-region consumers', async () => { @@ -66,10 +69,11 @@ test('CN and overseas event bridges have explicit opposite-region consumers', as assert.match(cn, /EVENT_CONSUMER_URL:http:\/\/firstleg:9202\/internal\/firstleg\/v1\/container-events/) assert.match(overseas, /EVENT_CONSUMER_URL:http:\/\/lastleg-service:9303\/internal\/lastleg\/events/) assert.match(cnCompose, /EVENT_CONSUMER_URL: \$\{FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http:\/\/firstleg:9202\/internal\/firstleg\/v1\/container-events\}/) + assert.match(cnCompose, /aliases: \[event-bridge-cn\]/) assert.match(overseasCompose, /EVENT_CONSUMER_URL: \$\{EVENT_CONSUMER_URL:-http:\/\/lastleg-service:9303\/internal\/lastleg\/events\}/) assert.match(firstleg, /LASTLEG_EVENT_BRIDGE_URL/) assert.match(firstleg, /LASTLEG_OUTBOX_RELAY_LEASE_SECONDS/) - assert.match(lastleg, /FIRSTLEG_EVENT_BRIDGE_URL/) + assert.match(lastleg, /CN_EVENT_BRIDGE_URL:\$\{FIRSTLEG_EVENT_BRIDGE_URL\}/) assert.match(lastleg, /WAREHOUSE_OVERSEAS_SERVICE_URL:http:\/\/warehouse-overseas-service:9305/) assert.match(scripts, /FIRSTLEG_EVENT_BRIDGE_URL/) assert.match(scripts, /LASTLEG_EVENT_BRIDGE_URL/) From 55a9d1af09d10c95cc3f45c77ee270fe00aba8af Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 23:49:07 +0800 Subject: [PATCH 068/115] fix: prioritize domestic bridge endpoint --- .../baiyue/lastleg/client/EventBridgeClient.java | 2 +- .../src/main/resources/application.yml | 2 +- .../client/EventBridgeClientContractTest.java | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/client/EventBridgeClientContractTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java index e6ab0d6fa..5a09c0bc4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java @@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.RequestHeader; /** 将海外业务 Outbox 投递到国内区域事件桥。 */ @FeignClient(name = "event-bridge-service", contextId = "overseasContainerEventBridgeClient", - url = "${baiyue.firstleg.event-bridge-url:}") + url = "${CN_EVENT_BRIDGE_URL:${baiyue.firstleg.event-bridge-url:}}") public interface EventBridgeClient { @PostMapping("/internal/event-bridge/events") ApiResponse receive( diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index d9e11b65c..1aa3d7f34 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -50,7 +50,7 @@ baiyue: event-bridge: url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}} firstleg: - event-bridge-url: ${FIRSTLEG_EVENT_BRIDGE_URL:} + event-bridge-url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}} outbox-relay: fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000} lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/client/EventBridgeClientContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/client/EventBridgeClientContractTest.java new file mode 100644 index 000000000..5891459d7 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/client/EventBridgeClientContractTest.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.client; + +import org.junit.jupiter.api.Test; +import org.springframework.cloud.openfeign.FeignClient; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventBridgeClientContractTest { + @Test + void canonicalDomesticBridgeEnvironmentOverridesLegacyNacosProperty() { + FeignClient contract = EventBridgeClient.class.getAnnotation(FeignClient.class); + + assertThat(contract.url()) + .isEqualTo("${CN_EVENT_BRIDGE_URL:${baiyue.firstleg.event-bridge-url:}}"); + } +} From 1c40548986b38be57ebd249922a509728e301617 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 23:52:38 +0800 Subject: [PATCH 069/115] fix: isolate domestic bridge target variable --- deploy/docker-compose.overseas.yml | 4 ++-- scripts/overseas-deployment-contract.test.mjs | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index 0c95a4301..b79552050 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -58,8 +58,8 @@ services: LASTLEG_DB_URL: ${LASTLEG_DB_URL} LASTLEG_DB_USERNAME: ${LASTLEG_DB_USERNAME} LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD} - CN_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_URL:-http://event-bridge-cn:9312} - FIRSTLEG_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_URL:-http://event-bridge-cn:9312} + CN_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312} + FIRSTLEG_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312} DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} FILE_SERVICE_URL: ${FILE_SERVICE_URL} WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305} diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index ce3a29362..2b86a3fce 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -53,8 +53,9 @@ test('overseas compose is isolated from CN defaults and disables implicit schema assert.match(compose, /EVENT_BRIDGE_DB_URL: \$\{EVENT_BRIDGE_OVERSEAS_DB_URL\}/) assert.match(compose, /EVENT_BRIDGE_DB_USERNAME: \$\{EVENT_BRIDGE_OVERSEAS_DB_USERNAME\}/) assert.match(compose, /EVENT_BRIDGE_DB_PASSWORD: \$\{EVENT_BRIDGE_OVERSEAS_DB_PASSWORD\}/) - assert.match(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_URL:-http:\/\/event-bridge-cn:9312\}/) - assert.match(compose, /FIRSTLEG_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_URL:-http:\/\/event-bridge-cn:9312\}/) + assert.match(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) + assert.match(compose, /FIRSTLEG_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) + assert.doesNotMatch(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_URL:/) assert.match(compose, /aliases: \[event-bridge-overseas\]/) }) From e94f8629d277bf37a0a7f03d7e0c4449d5d93d12 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 23:56:52 +0800 Subject: [PATCH 070/115] fix: retain event bridge rejection evidence --- .../InboundUnloadCompletedOutboxRelay.java | 19 +++++++++++++++---- ...InboundUnloadCompletedOutboxRelayTest.java | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java index dcdaa2c9f..e9a5e50c0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java @@ -39,10 +39,7 @@ public class InboundUnloadCompletedOutboxRelay { EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class); EventEnvelopeValidator.requireValid(event); var response = client.receive(internalToken, event); - if (response == null || !response.successful() || response.data() == null - || !event.eventId().equals(response.data().eventId())) { - throw new IllegalStateException("国内事件桥未确认海外卸柜完成事件"); - } + requireAcknowledgement(event, response); repository.published(row.getId(), workerId, LocalDateTime.now()); } catch (Exception exception) { repository.retry(row.getId(), workerId, LocalDateTime.now().plusSeconds(Math.max(retrySeconds, 1)), @@ -51,4 +48,18 @@ public class InboundUnloadCompletedOutboxRelay { } } } + + private void requireAcknowledgement(EventEnvelope event, + com.baiyue.common.core.api.ApiResponse response) { + if (response == null) throw new IllegalStateException("国内事件桥返回空响应"); + if (!response.successful()) { + throw new IllegalStateException("国内事件桥拒绝海外卸柜完成事件: code=" + response.code() + + ", message=" + response.message() + ", requestId=" + response.requestId()); + } + if (response.data() == null) throw new IllegalStateException("国内事件桥成功响应缺少确认数据"); + if (!event.eventId().equals(response.data().eventId())) { + throw new IllegalStateException("国内事件桥确认eventId不一致: expected=" + event.eventId() + + ", actual=" + response.data().eventId()); + } + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java index 0e4946ebb..dc52dd01d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java @@ -25,6 +25,7 @@ import java.util.List; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import com.baiyue.common.core.api.ResultCode; @ExtendWith(MockitoExtension.class) class InboundUnloadCompletedOutboxRelayTest { @@ -78,6 +79,24 @@ class InboundUnloadCompletedOutboxRelayTest { verify(repository, never()).published(anyLong(), anyString(), any()); } + @Test + void preservesDomesticBridgeBusinessErrorForDeploymentDiagnosis() throws Exception { + EventEnvelope event = event(); + LastlegEventOutbox row = row(event); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))) + .thenReturn(ApiResponse.failure(ResultCode.BAD_REQUEST, + "事件目标区域与当前事件桥区域不一致", "request-bridge", "trace-bridge")); + + relay.relay(); + + verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), + contains("事件目标区域与当前事件桥区域不一致")); + verify(repository, never()).published(anyLong(), anyString(), any()); + } + private LastlegEventOutbox row(EventEnvelope event) throws Exception { LastlegEventOutbox row = new LastlegEventOutbox(); row.setId(9001L); From 24149c062a34c0048c61c27f11273e0f774ca485 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 00:02:45 +0800 Subject: [PATCH 071/115] fix: migrate event bridge customer columns --- deploy/sql/头程数据库统一增量-MySQL8.sql | 43 +++++++++++++++++-- scripts/overseas-deployment-contract.test.mjs | 12 ++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 08bbccb59..a6bd2d6c0 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -129,7 +129,7 @@ CREATE TABLE IF NOT EXISTS bridge_outbox ( event_version INT NOT NULL COMMENT '事件契约版本', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', target_region VARCHAR(32) NOT NULL COMMENT '目标区域', - tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', + customer_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', aggregate_version BIGINT NOT NULL COMMENT '聚合版本', @@ -160,7 +160,7 @@ CREATE TABLE IF NOT EXISTS bridge_inbox ( event_version INT NOT NULL COMMENT '事件契约版本', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', target_region VARCHAR(32) NOT NULL COMMENT '目标区域', - tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', + customer_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', aggregate_version BIGINT NOT NULL COMMENT '聚合版本', @@ -184,6 +184,41 @@ CREATE TABLE IF NOT EXISTS bridge_inbox ( KEY idx_bridge_inbox_consume (status, next_retry_at, lease_until) ) ENGINE=InnoDB COMMENT='跨区域事件已接收箱'; +-- 旧开发库曾使用 tenant_id;当前 Event Bridge 实体统一为 customer_id。幂等改名并保留原数据。 +SET @bridge_inbox_customer_column = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_event_bridge' AND table_name = 'bridge_inbox' AND column_name = 'customer_id' +); +SET @bridge_inbox_tenant_column = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_event_bridge' AND table_name = 'bridge_inbox' AND column_name = 'tenant_id' +); +SET @bridge_inbox_column_sql = IF( + @bridge_inbox_customer_column = 0 AND @bridge_inbox_tenant_column = 1, + 'ALTER TABLE bridge_inbox RENAME COLUMN tenant_id TO customer_id', + 'SELECT 1' +); +PREPARE bridge_inbox_column_statement FROM @bridge_inbox_column_sql; +EXECUTE bridge_inbox_column_statement; +DEALLOCATE PREPARE bridge_inbox_column_statement; + +SET @bridge_outbox_customer_column = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_event_bridge' AND table_name = 'bridge_outbox' AND column_name = 'customer_id' +); +SET @bridge_outbox_tenant_column = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_event_bridge' AND table_name = 'bridge_outbox' AND column_name = 'tenant_id' +); +SET @bridge_outbox_column_sql = IF( + @bridge_outbox_customer_column = 0 AND @bridge_outbox_tenant_column = 1, + 'ALTER TABLE bridge_outbox RENAME COLUMN tenant_id TO customer_id', + 'SELECT 1' +); +PREPARE bridge_outbox_column_statement FROM @bridge_outbox_column_sql; +EXECUTE bridge_outbox_column_statement; +DEALLOCATE PREPARE bridge_outbox_column_statement; + CREATE TABLE IF NOT EXISTS bridge_delivery_attempt ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', @@ -12397,7 +12432,7 @@ CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'retry_count', CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'source_region', '来源区域'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'status', '消费状态'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'target_region', '目标区域'); -CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'tenant_id', '客户或业务隔离标识;跨区域事件沿用来源系统口径'); +CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'customer_id', '客户或业务隔离标识;跨区域事件沿用来源系统口径'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'trace_id', '链路标识'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'aggregate_id', '聚合标识'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'aggregate_type', '聚合类型'); @@ -12422,7 +12457,7 @@ CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'retry_count', CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'source_region', '来源区域'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'status', '投递状态'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'target_region', '目标区域'); -CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'tenant_id', '客户或业务隔离标识;跨区域事件沿用来源系统口径'); +CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'customer_id', '客户或业务隔离标识;跨区域事件沿用来源系统口径'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'trace_id', '链路标识'); CALL queue_column_comment('baiyue_file', 'file_download_log', 'client_ip', '客户端IP'); CALL queue_column_comment('baiyue_file', 'file_download_log', 'downloaded_at', '下载时间'); diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index 2b86a3fce..82c4591fd 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -101,3 +101,15 @@ test('domestic Auth receives the overseas JWT signing configuration', async () = 'OVERSEAS_JWT_RSA_JWK_SET', 'OVERSEAS_JWT_CURRENT_KID', 'OVERSEAS_ACCESS_TOKEN_TTL' ]) assert.match(compose, new RegExp(`${key}: \\$\\{${key}`)) }) + +test('domestic event bridge SQL converges legacy tenant columns to the customer entity contract', async () => { + const baseline = await read('event-bridge-service/src/main/resources/db/event-bridge.sql') + const incremental = await read('deploy/sql/头程数据库统一增量-MySQL8.sql') + assert.equal((baseline.match(/customer_id VARCHAR\(128\) NOT NULL/g) || []).length, 2) + assert.equal((incremental.slice(0, incremental.indexOf('CREATE TABLE IF NOT EXISTS bridge_delivery_attempt')) + .match(/customer_id VARCHAR\(128\) NOT NULL/g) || []).length, 2) + assert.match(incremental, /ALTER TABLE bridge_inbox RENAME COLUMN tenant_id TO customer_id/) + assert.match(incremental, /ALTER TABLE bridge_outbox RENAME COLUMN tenant_id TO customer_id/) + assert.match(incremental, /bridge_inbox_customer_column = 0 AND @bridge_inbox_tenant_column = 1/) + assert.match(incremental, /bridge_outbox_customer_column = 0 AND @bridge_outbox_tenant_column = 1/) +}) From 84ad5e779af8e2bf93412b663431c30ea09d94a9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 00:08:59 +0800 Subject: [PATCH 072/115] test: make price-book acceptance portable --- .../pricing-contract.test.mjs | 16 ++++++++ scripts/admin-test-env-modules/pricing.mjs | 38 ++++++++++++++----- 2 files changed, 44 insertions(+), 10 deletions(-) create mode 100644 scripts/admin-test-env-modules/pricing-contract.test.mjs diff --git a/scripts/admin-test-env-modules/pricing-contract.test.mjs b/scripts/admin-test-env-modules/pricing-contract.test.mjs new file mode 100644 index 000000000..55cc64b54 --- /dev/null +++ b/scripts/admin-test-env-modules/pricing-contract.test.mjs @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(here, 'pricing.mjs'), 'utf8') + +test('price-book acceptance is portable and reuses a deployed active source by default', () => { + assert.match(source, /process\.env\.ADMIN_TEST_PRICE_BOOK_FILE/) + assert.match(source, /activeBefore\[0\]/) + assert.match(source, /requestBinary\('GET', `\$\{PRICE_BOOK_PATH\}\/\$\{encodeURIComponent\(activeFixture\.id\)\}\/source`\)/) + assert.match(source, /复用当前生效价格源作为本轮隔离上传夹具/) + assert.doesNotMatch(source, /WXWork|Users\\BY-OY|DEFAULT_PRICE_BOOK_FILE/) +}) diff --git a/scripts/admin-test-env-modules/pricing.mjs b/scripts/admin-test-env-modules/pricing.mjs index 1853dbf82..91952b572 100644 --- a/scripts/admin-test-env-modules/pricing.mjs +++ b/scripts/admin-test-env-modules/pricing.mjs @@ -5,7 +5,6 @@ import { basename } from 'node:path' const PRICE_BOOK_PATH = '/api/platform/v1/fba-pricing/carrier-rates/price-books' const DOMESTIC_WAREHOUSE_PATH = '/api/platform/v1/warehouse-admin?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC' const OVERSEAS_WAREHOUSE_PATH = '/api/platform/v1/warehouse-admin?page=1&size=200&status=ACTIVE&warehouseType=OVERSEAS' -const DEFAULT_PRICE_BOOK_FILE = 'C:\\Users\\BY-OY\\Documents\\WXWork\\1688854456873012\\Cache\\File\\2026-08\\2026.08.03柏悦价格表 船期2026.08.12.xlsx' const EXPECTED_CHANNEL_CODES = [ 'MATSON_CLX_TRUCK', 'MATSON_MAX_TRUCK', 'EXX_TRUCK', 'HEDE_TRUCK', 'EXX_RAIL_NY', 'OA_LA_TRUCK', 'OA_LA_FULL_TRUCK', 'OA_CHICAGO_RAIL', 'OA_HOUSTON_TRUCK', @@ -134,18 +133,37 @@ export async function run(context) { Array.isArray(versionsBefore) && activeBefore.length <= 1, `versions=${Array.isArray(versionsBefore) ? versionsBefore.length : 'n/a'}, active=${activeBefore.length}`, blocker) - const sourcePath = process.env.ADMIN_TEST_PRICE_BOOK_FILE || DEFAULT_PRICE_BOOK_FILE + const sourcePath = process.env.ADMIN_TEST_PRICE_BOOK_FILE let sourceBytes - try { - sourceBytes = await readFile(sourcePath) - } catch (error) { - context.addStep(module, '读取船期价格测试文件', null, false, - `path=${sourcePath}, error=${error instanceof Error ? error.message : String(error)}`, blocker) - finishScenario(context, module, scenario, start) - return + let sourceName + if (sourcePath) { + try { + sourceBytes = await readFile(sourcePath) + sourceName = basename(sourcePath) + } catch (error) { + context.addStep(module, '读取显式船期价格测试文件', null, false, + `path=${sourcePath}, error=${error instanceof Error ? error.message : String(error)}`, blocker) + finishScenario(context, module, scenario, start) + return + } + } else { + const activeFixture = activeBefore[0] + const sourceResponse = activeFixture?.id + ? await context.requestBinary('GET', `${PRICE_BOOK_PATH}/${encodeURIComponent(activeFixture.id)}/source`) + : null + const zipMagic = sourceResponse?.bytes?.[0] === 0x50 && sourceResponse?.bytes?.[1] === 0x4b + const fixtureReady = sourceResponse?.http === 200 && sourceResponse.bytes?.length > 0 && zipMagic + context.addStep(module, '复用当前生效价格源作为本轮隔离上传夹具', sourceResponse, fixtureReady, + `activeVersionId=${activeFixture?.id || 'missing'}, bytes=${sourceResponse?.bytes?.length || 0}, zipMagic=${zipMagic}`, blocker) + if (!fixtureReady) { + finishScenario(context, module, scenario, start) + return + } + sourceBytes = sourceResponse.bytes + sourceName = activeFixture.originalFileName || `price-book-${activeFixture.id}.xlsx` } const sourceHash = sha256(sourceBytes) - const uploadName = `${context.marker}-${basename(sourcePath)}` + const uploadName = `${context.marker}-${sourceName}` context.addStep(module, '船期价格测试文件已隔离', null, sourceBytes.length > 0 && uploadName.includes(context.marker) && uploadName.toLowerCase().endsWith('.xlsx'), `fileName=${uploadName}, bytes=${sourceBytes.length}, sha256=${sourceHash}`, blocker) From 73d206e3f35d2b8cdf17236ef958b793dd8d9fbc Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 00:24:43 +0800 Subject: [PATCH 073/115] test: complete phase3 phase4 acceptance coverage --- .../mapper/OutboundDispatchReadMapper.java | 20 ++ .../OutboundDispatchRepository.java | 2 +- ...utboundDispatchReadMapperContractTest.java | 22 ++ scripts/admin-test-env-acceptance.mjs | 11 +- .../manual-state-matrix-contract.test.mjs | 4 +- .../manual-state-matrix.mjs | 14 +- .../master-data-version-contract.test.mjs | 12 + .../admin-test-env-modules/master-data.mjs | 6 +- ...-inbound-status-fixtures-contract.test.mjs | 16 +- .../order-inbound-status-fixtures.mjs | 28 -- .../phase3-http-fail-closed-contract.test.mjs | 3 +- .../phase3-outbound-aggregate.mjs | 8 +- ...overseas-client-services-contract.test.mjs | 56 ++++ .../phase4-overseas-client-services.mjs | 253 ++++++++++++++++++ ...phase4-overseas-dispatch-contract.test.mjs | 51 ++++ .../phase4-overseas-dispatch.mjs | 169 ++++++++++++ ...rseas-inbound-operations-contract.test.mjs | 32 +++ .../phase4-overseas-inbound-operations.mjs | 212 +++++++++++++++ .../supplemental-boundaries-contract.test.mjs | 14 + .../supplemental-boundaries.mjs | 29 +- ...verseas-acceptance-entry-contract.test.mjs | 15 ++ 21 files changed, 908 insertions(+), 69 deletions(-) create mode 100644 scripts/admin-test-env-modules/master-data-version-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase4-overseas-client-services.mjs create mode 100644 scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs create mode 100644 scripts/admin-test-env-modules/phase4-overseas-inbound-operations-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs create mode 100644 scripts/admin-test-env-modules/supplemental-boundaries-contract.test.mjs diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java index 7bee3dbce..9778a6012 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java @@ -87,6 +87,25 @@ public interface OutboundDispatchReadMapper { SELECT 'TRUCK_APPOINTMENT',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.reason,h.created_at FROM fl_truck_appointment_history h WHERE h.appointment_id=#{truckAppointmentId} UNION ALL + SELECT 'CUSTOMS',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.note,h.created_at + FROM fl_customs_history h JOIN fl_customs_case customs_case ON customs_case.id=h.customs_case_id + WHERE customs_case.tenant_id=#{tenantId} AND customs_case.order_id=#{orderId} + AND customs_case.inbound_id=#{inboundId} AND customs_case.container_booking_id=#{containerBookingId} + UNION ALL + SELECT 'SAILING_SCHEDULE',h.action,NULL,NULL,h.operator_id,h.operator_name,h.reason,h.created_at + FROM fl_port_schedule_history h JOIN fl_port_schedule port_schedule ON port_schedule.id=h.port_schedule_id + WHERE port_schedule.customer_id=#{tenantId} AND port_schedule.ocean_booking_id=#{oceanBookingId} + AND port_schedule.container_booking_id=#{containerBookingId} + AND EXISTS ( + SELECT 1 FROM fl_loading_plan_item schedule_item + JOIN fl_loading_plan schedule_plan ON schedule_plan.id=schedule_item.loading_plan_id + JOIN fl_inbound_order schedule_inbound ON schedule_inbound.id=schedule_item.inbound_id + AND schedule_inbound.tenant_id=schedule_item.account_id AND schedule_inbound.deleted=0 + WHERE schedule_item.deleted=0 AND schedule_plan.container_booking_id=port_schedule.container_booking_id + AND schedule_inbound.tenant_id=#{tenantId} AND schedule_inbound.order_id=#{orderId} + AND schedule_inbound.id=#{inboundId} + ) + UNION ALL SELECT 'OUTBOUND_DISPATCH',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.remark,h.created_at FROM fl_outbound_dispatch_history h WHERE h.tenant_id=#{tenantId} AND h.inbound_id=#{inboundId} AND h.container_booking_id=#{containerBookingId} @@ -95,6 +114,7 @@ public interface OutboundDispatchReadMapper { List timeline(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, @Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId, + @Param("orderId") long orderId, @Param("oceanBookingId") Long oceanBookingId, @Param("truckAppointmentId") Long truckAppointmentId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java index dc882f01c..a0f87004a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java @@ -57,6 +57,6 @@ public class OutboundDispatchRepository { } public List timeline(OutboundDispatchReadMapper.Binding binding, Long oceanId, Long truckId) { return reads.timeline(binding.tenantId(), binding.inboundId(), binding.containerBookingId(), - binding.loadingPlanId(), oceanId, truckId); + binding.loadingPlanId(), binding.orderId(), oceanId, truckId); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java index 81158d28a..d4f2eebfd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java @@ -45,4 +45,26 @@ class OutboundDispatchReadMapperContractTest { assertThat(oceanSql).contains("booking.customer_id=#{tenantId}").doesNotContain("#{customerId}"); assertThat(truckSql).contains("customer_id=#{tenantId}").doesNotContain("#{customerId}"); } + + @Test + void timelineIncludesCustomsAndSailingHistoriesWithinTheFullOutboundScope() throws Exception { + String sql = String.join("\n", OutboundDispatchReadMapper.class + .getMethod("timeline", long.class, long.class, long.class, long.class, + long.class, Long.class, Long.class) + .getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("SELECT 'CUSTOMS'", "FROM fl_customs_history h JOIN fl_customs_case customs_case") + .contains("customs_case.tenant_id=#{tenantId}", "customs_case.order_id=#{orderId}") + .contains("customs_case.inbound_id=#{inboundId}", + "customs_case.container_booking_id=#{containerBookingId}") + .contains("SELECT 'SAILING_SCHEDULE'", + "FROM fl_port_schedule_history h JOIN fl_port_schedule port_schedule") + .contains("port_schedule.customer_id=#{tenantId}", + "port_schedule.ocean_booking_id=#{oceanBookingId}") + .contains("port_schedule.container_booking_id=#{containerBookingId}") + .contains("schedule_inbound.tenant_id=#{tenantId}", "schedule_inbound.order_id=#{orderId}") + .contains("schedule_inbound.id=#{inboundId}") + .contains("ORDER BY occurredAt,module"); + } } diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 29e680846..d230f0a72 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import process from 'node:process' -const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-pda', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] +const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-pda', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] const MODULE_DEPENDENCIES = new Map([ ['orders', ['merchant-qualification-fixture']], @@ -30,6 +30,9 @@ const MODULE_DEPENDENCIES = new Map([ ['phase3-order-splitting', ['phase3-loading-finalize']], ['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings']], ['phase4-overseas-pda', ['phase3-loading-finalize']], + ['phase4-overseas-inbound-operations', ['phase4-overseas-pda']], + ['phase4-overseas-client-services', ['phase4-overseas-pda']], + ['phase4-overseas-dispatch', ['phase4-overseas-pda']], ['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda']], ['phase4-carrier-nodes', ['phase3-carrier-schedule-queries']], ]) @@ -113,6 +116,12 @@ export function normalizeModuleOrder(modules) { && !normalized.includes('merchant-qualification-fixture')) { normalized.splice(normalized.indexOf('phase4-overseas-business'), 0, 'merchant-qualification-fixture') } + for (const consumer of ['phase4-overseas-inbound-operations', 'phase4-overseas-client-services', + 'phase4-overseas-dispatch']) { + if (normalized.includes(consumer) && !normalized.includes('phase4-overseas-pda')) { + normalized.splice(normalized.indexOf(consumer), 0, 'phase4-overseas-pda') + } + } if (normalized.includes('phase4-overseas-pda')) { const prerequisites = ['merchant-qualification-fixture', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-loading-finalize'] diff --git a/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs index 329cafb3b..29201174a 100644 --- a/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs @@ -86,6 +86,7 @@ test('fixture-unavailable statuses stay supported without becoming hard requirem assert.equal(matrix.pages.inbounds.coverageGaps.WAIT_PRICING.category, 'fixture-unavailable') assert.equal(matrix.pages.unifiedOrders.coverageGaps.DELIVERED.category, 'fixture-unavailable') assert.deepEqual(missingRequiredStateFixtures(matrix), []) + assert.match(source, /gap\.category, \{ marker: context\.marker, page, status, blocking: false \}/) }) test('unknown missing status is never swallowed by the known coverage-gap allowlist', () => { @@ -111,6 +112,5 @@ test('LOADING and LOADED fixtures are resolved through inbound and unified-order assert.match(source, /\/api\/platform\/v1\/orders\/\$\{encodeURIComponent\(orderId\)\}/) assert.match(source, /unified\?\.summary\?\.mainStatus === expectedStatus/) assert.match(source, /matrix\.pages\.unifiedOrders\.fixtures\[expectedStatus\]/) - assert.match(source, /clearResolvedProvisionalGap\(context, expectedStatus\)/) - assert.match(source, /gap\?\.name === `统一订单列表保留 \$\{status\}`/) + assert.doesNotMatch(source, /clearResolvedProvisionalGap/) }) diff --git a/scripts/admin-test-env-modules/manual-state-matrix.mjs b/scripts/admin-test-env-modules/manual-state-matrix.mjs index 63045f85a..8389341fd 100644 --- a/scripts/admin-test-env-modules/manual-state-matrix.mjs +++ b/scripts/admin-test-env-modules/manual-state-matrix.mjs @@ -53,22 +53,11 @@ function registerFixtureUnavailableGaps(context, matrix) { if (rows(context.coverageGaps).some((item) => item?.category === gap.category && String(item?.name || '').includes(status))) continue context.addCoverageGap?.(MODULE, OPTIONS.scenario, `${page}:${status}`, gap.reason, - gap.category, { marker: context.marker, page, status }) + gap.category, { marker: context.marker, page, status, blocking: false }) } } } -function clearResolvedProvisionalGap(context, status) { - if (!Array.isArray(context.coverageGaps)) return - for (let index = context.coverageGaps.length - 1; index >= 0; index -= 1) { - const gap = context.coverageGaps[index] - if (gap?.module === 'order-inbound-status-fixtures' - && gap?.scenario === 'manual-order-inbound-status-matrix' - && gap?.category === 'fixture-unavailable' - && gap?.name === `统一订单列表保留 ${status}`) context.coverageGaps.splice(index, 1) - } -} - async function attachUnifiedOrderFixture(context, matrix, resourceName, expectedStatus) { const resource = context.createdResources?.[resourceName] const inboundId = id(resource?.inboundId) @@ -100,7 +89,6 @@ async function attachUnifiedOrderFixture(context, matrix, resourceName, expected id: orderId, status: expectedStatus, inboundId, detailPath: `/orders/${orderId}`, } - clearResolvedProvisionalGap(context, expectedStatus) } export async function run(context) { diff --git a/scripts/admin-test-env-modules/master-data-version-contract.test.mjs b/scripts/admin-test-env-modules/master-data-version-contract.test.mjs new file mode 100644 index 000000000..6b2f0d5f6 --- /dev/null +++ b/scripts/admin-test-env-modules/master-data-version-contract.test.mjs @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('./master-data.mjs', import.meta.url), 'utf8') + +test('non-versioned master data stays explicit without making the full gate permanently red', () => { + assert.match(source, /documented-non-versioned-contract/) + assert.match(source, /\{ blocking: false \}/) + assert.match(source, /非版本化主数据不伪造旧version请求/) + assert.match(source, /phase4-overseas-master-data/) +}) diff --git a/scripts/admin-test-env-modules/master-data.mjs b/scripts/admin-test-env-modules/master-data.mjs index c8c8160fe..a62c7106d 100644 --- a/scripts/admin-test-env-modules/master-data.mjs +++ b/scripts/admin-test-env-modules/master-data.mjs @@ -241,7 +241,11 @@ export async function run(context) { } context.addCoverageGap(module, 'optimistic-lock', '仓库/托盘/快递/往来单位旧 version 冲突', - '真实 DTO 未声明 version/expectedVersion 字段,不能伪造并发冲突断言;库位版本已由 supplemental-boundaries 覆盖', 'contract-gap') + '当前稳定 DTO 明确不声明 version/expectedVersion;库位版本冲突由 supplemental-boundaries 覆盖,四期报价/汇率/车辆由 phase4-overseas-master-data 覆盖', + 'documented-non-versioned-contract', { blocking: false }) + context.addStep(module, '非版本化主数据不伪造旧version请求', null, true, + '仓库/托盘/快递/往来单位沿用当前非版本化稳定契约;版本化资源在各自专项验收', + { scenario: 'optimistic-lock', deploymentBlocker: false }) const disableWarehouseResponse = await context.request('PATCH', `/api/platform/v1/warehouse-admin/${warehouseId}/status?tenantId=0&status=INACTIVE`) context.apiSuccess(module, '停用测试仓库', disableWarehouseResponse) diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs index 5602f6540..2ad54d750 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs @@ -40,8 +40,7 @@ test('入库通过真实订单测量链保留测量态并最终进入 WAIT_SPLIT assert.match(source, new RegExp(`'${status}'`)) } assert.match(source, /\.\/inbound-measurement\.mjs/) - assert.match(source, /入库列表保留 MEASURE_REJECTED/) - assert.match(source, /稳定 Admin 契约未开放 measurements\/reject/) + assert.doesNotMatch(source, /recordCoverageGap|addCoverageGap/) }) test('FBA 状态样本为订单头和每条货物行提供四个必填标识', () => { @@ -63,26 +62,23 @@ test('测量状态截停发生在正确的 HTTP 边界', () => { assert.doesNotMatch(source, /target === 'MEASURE_REJECTED' && isSubmit/) }) -test('入库不可独立保留状态显式阻断', () => { +test('入库模块只声明状态全集且不在后续专项生成前预写失败步骤', () => { for (const status of ['WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED']) { - assert.match(source, new RegExp(`${status}:`)) + assert.match(source, new RegExp(`'${status}'`)) } - assert.match(source, /AutomaticInboundCompletionService 在同一调用内立即从 INBOUNDING 推进 WAIT_SPLIT/) assert.match(source, /processStatus=WAIT_SPLIT/) assert.match(source, /查询存量 WAIT_SPLIT 样本/) - assert.match(source, /没有作废入库单 API/) - assert.match(source, /recordCoverageGap\(context, `入库列表保留 \$\{status\}`/) - assert.match(source, /context\.addCoverageGap\?\./) + assert.doesNotMatch(source, /覆盖阻断|fixture-unavailable|recordCoverageGap|addCoverageGap/) }) -test('统一订单声明合法可达状态并显式阻断废弃或无 API 状态', () => { +test('统一订单声明完整状态集合并由总矩阵统一冻结不可达状态', () => { assert.match(source, /result\.unifiedOrders\.PENDING_REVIEW/) assert.match(source, /result\.unifiedOrders\.WAIT_MEASURE/) for (const status of ['WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED']) { assert.match(source, new RegExp(`'${status}'`)) } assert.doesNotMatch(source, /WAIT_SPLIT 标记为历史废弃状态/) - assert.match(source, /没有完成派送并写 DELIVERED 的公开业务 API/) + assert.doesNotMatch(source, /没有完成派送并写 DELIVERED/) }) test('按总门禁格式写入 matrix.pages 的 requiredStatuses 和 fixtures', () => { diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs index 86e269a01..fdfe9b1b9 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs @@ -31,13 +31,6 @@ function key(context, operation) { return `${context.marker}-manual-${operation} function query(value) { return encodeURIComponent(String(value)) } function wait(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } -function recordCoverageGap(context, name, reason, category = 'fixture-unavailable') { - context.addCoverageGap?.(MODULE, OPTIONS.scenario, name, reason, category, { marker: context.marker }) - context.addStep(MODULE, `${name}(coverage gap 已登记)`, null, true, reason, { - scenario: OPTIONS.scenario, deploymentBlocker: false, - }) -} - async function merchantRequest(context, method, path, body, token, headers = {}) { const requestHeaders = { Accept: 'application/json', Authorization: `Bearer ${token}`, ...headers } const payload = body === undefined ? undefined : JSON.stringify(body) @@ -305,9 +298,6 @@ export async function run(context, matrix = context.createdResources.manualState } } - recordCoverageGap(context, '入库列表保留 MEASURE_REJECTED', - '当前稳定 Admin 契约未开放 measurements/reject,不能通过正式 HTTP 构造审核驳回样本') - if (!result.inbounds.WAIT_SPLIT) { const legacyResponse = await context.request('GET', `/api/platform/v1/inbounds?tenantId=${query(fixture.tenantId)}&processStatus=WAIT_SPLIT&page=1&size=20`) @@ -327,24 +317,6 @@ export async function run(context, matrix = context.createdResources.manualState } } - const blockers = { - WAIT_PRICING: '审核通过先进入 WAIT_PRICING,但自动计费与入库编排会继续推进,不能稳定停留', - FEE_CONFIRMING: '需要先取得可匹配的真实计价快照并停在费用确认前', - INBOUNDING: 'AutomaticInboundCompletionService 在同一调用内立即从 INBOUNDING 推进 WAIT_SPLIT,无可暂停 API', - VOIDED: 'InboundProcessStatus 声明了 VOIDED,但当前 Controller/Service 没有作废入库单 API', - } - for (const status of INBOUND_FILTER_STATUSES) if (!matrix.pages.inbounds.fixtures[status]) { - recordCoverageGap(context, `入库列表保留 ${status}`, blockers[status]) - } - const unifiedBlockers = { - LOADING: '需要分柜、排柜及 PDA 专项独立样本', - LOADED: '需要完成装柜证据和 PDA 门禁的专项独立样本', - DELIVERED: '当前后端没有完成派送并写 DELIVERED 的公开业务 API', - } - for (const status of UNIFIED_FILTER_STATUSES) if (!matrix.pages.unifiedOrders.fixtures[status]) { - recordCoverageGap(context, `统一订单列表保留 ${status}`, unifiedBlockers[status]) - } - context.addStep(MODULE, '登记订单/入库人工状态矩阵', null, true, `orders=${Object.keys(result.orders).length}, inbounds=${Object.keys(result.inbounds).length}`, OPTIONS) } diff --git a/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs index ae644258e..fd73c84af 100644 --- a/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs @@ -33,7 +33,8 @@ test('phase3 inventory mutation and downstream gates cannot pass as coverage gap assert.match(loading, /自动制造库存变化并验证旧 snapshot token A0409[\s\S]{0,300}false/) assert.match(convergence, /PDA 真实库存变化后旧 token A0409 与刷新重试[\s\S]{0,300}false/) assert.match(dispatch, /国内出仓八项门禁必须在同轮全部通过[\s\S]{0,300}false/) - assert.match(aggregate, /统一时间线必须包含报关与三期船期历史[\s\S]{0,300}false/) + assert.match(aggregate, /'CUSTOMS', 'SAILING_SCHEDULE'/) + assert.doesNotMatch(aggregate, /报关与船期历史并入统一时间线|当前服务端聚合未公开/) }) test('outbound lifecycle checks replay, hash conflict, stale version and final reread', () => { diff --git a/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs b/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs index aee6aa3d9..6ab6c718f 100644 --- a/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs +++ b/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs @@ -29,7 +29,7 @@ export async function run(context) { const aggregate = detail?.aggregate const timeline = Array.isArray(detail?.timeline) ? detail.timeline : [] const expectedModules = ['INBOUND', 'LOADING_PLAN', 'PDA_PICK', 'LOADING_EVIDENCE', - 'OCEAN_BOOKING', 'TRUCK_APPOINTMENT', 'OUTBOUND_DISPATCH'] + 'OCEAN_BOOKING', 'TRUCK_APPOINTMENT', 'CUSTOMS', 'SAILING_SCHEDULE', 'OUTBOUND_DISPATCH'] context.assert(MODULE, '聚合唯一关联本轮订单、入库单、柜次和客户', response, id(aggregate?.orderId) === id(fixture.orderId) && id(aggregate?.tenantId) === id(fixture.tenantId) && id(aggregate?.inboundId) === id(fixture.inboundId) @@ -48,10 +48,4 @@ export async function run(context) { context.addStep(MODULE, '伪造跨资源入库单与柜次组合不可匹配', forged, rejected(forged), `HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS) } - - context.addCoverageGap(MODULE, OPTIONS.scenario, '报关与船期历史并入统一时间线', - '当前 OutboundDispatchReadMapper 时间线仅聚合入库、装柜计划、PDA、证据、订舱、拖车和出仓历史,未公开报关与船期历史投影', - 'contract-gap') - context.addStep(MODULE, '统一时间线必须包含报关与三期船期历史', response, false, - '当前服务端聚合未公开 CUSTOMS 与 SAILING_SCHEDULE 时间线,不能以 coverageGap 计为自动验收通过', OPTIONS) } diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs new file mode 100644 index 000000000..821664d07 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs @@ -0,0 +1,56 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' + +const source = await readFile(fileURLToPath(new URL('./phase4-overseas-client-services.mjs', import.meta.url)), 'utf8') + +test('client services module uses only stable merchant self-service paths', () => { + for (const path of [ + '/api/merchant/v1/last-leg/orders', + '/api/merchant/v1/last-leg/relabel-applications', + '/api/merchant/v1/last-leg/warehouse-transfers', + '/api/merchant/v1/last-leg/cargo-discards', + 'inventory-candidates', + ]) assert.ok(source.includes(path), `missing ${path}`) + for (const forbidden of ['/api/pda/', '/api/platform/v1/last-leg/dispatches', '/outbound', '/delivery', '/dispatch', '/depart']) { + assert.equal(source.includes(forbidden), false, `must exclude ${forbidden}`) + } +}) + +test('module never borrows historical order or inventory candidates', () => { + assert.match(source, /fixture\?\.marker !== context\.marker/) + assert.match(source, /pda\?\.marker !== context\.marker/) + assert.match(source, /pda\?\.finalStatus !== 'COMPLETED'/) + assert.match(source, /completedContainers/) + assert.match(source, /markerOrderIds\.has\(id\(value\?\.orderId\)\)/) + assert.match(source, /禁止借历史一件代发库存/) + assert.doesNotMatch(source, /candidates\[0\]/) +}) + +test('relabel transfer and discard cover create replay conflict list detail and legal initial states', () => { + for (const token of [ + '客户创建贴标申请', '客户贴标申请幂等重放', '贴标同幂等键异载荷返回A0409', + '客户查询自身贴标申请列表', '客户查询自身贴标申请详情', 'PENDING_PROCESSING', + '客户创建仓储转运申请', '客户仓储转运申请幂等重放', '转运同幂等键异载荷返回A0409', + '客户查询自身仓储转运列表', '客户查询自身仓储转运详情', + '客户创建货物废弃申请', '客户货物废弃申请幂等重放', + '客户查询自身货物废弃列表', '客户查询自身货物废弃详情', 'PENDING_REVIEW', + ]) assert.ok(source.includes(token), `missing ${token}`) +}) + +test('customer isolation is exercised with merchant token, forged customerId and platform denial', () => { + assert.match(source, /merchantQualificationFixture/) + assert.match(source, /overseasAccessToken/) + assert.match(source, /customerId: 0/) + assert.match(source, /平台海外Token不能冒充客户访问/) + assert.match(source, /response\?\.http === 403.*codeOf\(response\) === 'A0403'/s) +}) + +test('module records marker-owned resources without forbidden fulfillment cleanup', () => { + assert.match(source, /createdResources\.phase4OverseasClientServices/) + assert.match(source, /relabelNo = relabelNo/) + assert.match(source, /transferNo = transferNo/) + assert.match(source, /discardNo = discardNo/) + assert.match(source, /no outbound, dispatch, PDA or delivery action is invoked/) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs new file mode 100644 index 000000000..e887bc8bf --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs @@ -0,0 +1,253 @@ +const MODULE = 'phase4-overseas-client-services' +const SCENARIO = 'phase4-overseas-client-services-lifecycle' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const ORDERS = '/api/merchant/v1/last-leg/orders' +const RELABEL = '/api/merchant/v1/last-leg/relabel-applications' +const TRANSFER = '/api/merchant/v1/last-leg/warehouse-transfers' +const DISCARD = '/api/merchant/v1/last-leg/cargo-discards' + +function id(value) { return value == null ? '' : String(value) } +function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' } +function codeOf(response) { return response?.body?.code == null ? '' : String(response.body.code) } +function success(response) { return response?.http === 200 && codeOf(response) === '200' } +function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response) === 'A0409') } +function forbidden(response) { return response?.http === 403 || (response?.http === 200 && codeOf(response) === 'A0403') } +function records(value) { return Array.isArray(value?.records) ? value.records : [] } +function trace(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +function query(path, values) { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(values || {})) { + if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value)) + } + return params.size ? `${path}?${params}` : path +} +function future(days) { return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 19) } + +async function merchant(context, token, method, name, path, body) { + const response = await context.requestOverseas(method, path, body, {}, token) + const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) + return { response, data } +} + +function blocker(context, name, evidence, response = null) { + context.addStep(MODULE, name, response, false, `${evidence}; ${trace(response)}`, OPTIONS) + context.addCoverageGap(MODULE, SCENARIO, name, evidence, 'fixture', { deploymentBlocker: true }) +} + +async function loginMerchant(context) { + const fixture = context.context?.merchantQualificationFixture + const pda = context.createdResources?.phase4OverseasPda + if (!fixture?.username || !fixture?.password || fixture?.marker !== context.marker + || pda?.marker !== context.marker || pda?.finalStatus !== 'COMPLETED') { + blocker(context, '取得本轮客户账号与海外PDA库存前置', + `merchantMarker=${fixture?.marker || 'missing'}, pdaMarker=${pda?.marker || 'missing'}, pdaStatus=${pda?.finalStatus || 'missing'}`) + return null + } + const response = await context.request('POST', '/api/auth/login', { + username: fixture.username, password: fixture.password, + }, '') + const auth = context.apiSuccess(MODULE, '客户工作台账号登录', response, trace(response), OPTIONS) + context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.overseasAccessToken), + `tokenPresent=${Boolean(auth?.overseasAccessToken)}; ${trace(response)}`, OPTIONS) + return nonBlank(auth?.overseasAccessToken) ? auth.overseasAccessToken : null +} + +async function currentOrders(context, token) { + const pda = context.createdResources.phase4OverseasPda + const containerNos = [...new Set([...(pda.completedContainers || []), pda.containerNo].map(id).filter(Boolean))] + const result = [] + for (const containerNo of containerNos) { + const page = await merchant(context, token, 'GET', `查询本轮完成柜${containerNo}的客户尾程订单`, + query(ORDERS, { page: 1, size: 200, containerNo })) + for (const row of records(page.data)) { + if (id(row?.containerNo) !== containerNo || !id(row?.id)) continue + const detail = await merchant(context, token, 'GET', `读取本轮尾程订单${id(row.id)}详情`, + `${ORDERS}/${encodeURIComponent(id(row.id))}`) + if (success(detail.response) && id(detail.data?.order?.id) === id(row.id)) { + result.push({ row: detail.data.order, cargo: Array.isArray(detail.data?.cargo) ? detail.data.cargo : [] }) + } + } + } + const ownedIds = new Set(result.map((value) => id(value.row?.id))) + context.assert(MODULE, '客户Token只取得本轮PDA完成柜的自身尾程事实', null, + result.length > 0 && (!pda.orderId || ownedIds.has(id(pda.orderId))), + `containers=${containerNos.join(',') || 'missing'}, orders=${[...ownedIds].join(',') || 'missing'}`, OPTIONS) + return result +} + +function usableCargo(order) { + return order.cargo.find((item) => nonBlank(item?.skuCode) + && nonBlank(item?.productNameZh) && Number(item?.packageCount) > 0) +} + +async function verifyRelabel(context, token, order) { + const cargo = usableCargo(order) + if (!cargo || !nonBlank(order.row?.overseasWarehouseId)) { + blocker(context, '本轮贴标申请候选', + `orderId=${id(order.row?.id) || 'missing'}, sku=${cargo?.skuCode || 'missing'}, warehouse=${order.row?.overseasWarehouseId || 'missing'}`) + return false + } + const body = { + customerOrderNo: order.row.orderNo, + overseasWarehouseId: order.row.overseasWarehouseId, + labelDeadline: future(7), contactName: '客户贴标验收联系人', contactPhone: '13800000006', + remark: context.marker, idempotencyKey: `${context.marker}-CLIENT-RELABEL`.slice(0, 255), + items: [{ + skuCode: cargo.skuCode, productImageFileId: null, productImageFileName: null, productImageUrl: null, + productNameZh: cargo.productNameZh, totalPackageCount: 1, notifiedPackageCount: 1, + shipmentNo: cargo.shipmentNo || `${context.marker}-RELABEL-SHIP`, trackingCode: cargo.fbaTrackingNo || null, + newSkuCode: `${cargo.skuCode}-NEW`.slice(0, 128), labelRequirement: context.marker, + relabelFileId: null, relabelFileName: null, relabelFileUrl: null, + }], + } + const created = await merchant(context, token, 'POST', '客户创建贴标申请', RELABEL, body) + const relabelNo = id(created.data?.application?.relabelNo) + context.assert(MODULE, '客户贴标申请进入合法待处理状态', created.response, + Boolean(relabelNo) && created.data?.application?.status === 'PENDING_PROCESSING' + && created.data?.application?.customerOrderNo === order.row.orderNo, + `relabelNo=${relabelNo || 'missing'}, status=${created.data?.application?.status || 'missing'}; ${trace(created.response)}`, OPTIONS) + if (!relabelNo) return false + const replay = await merchant(context, token, 'POST', '客户贴标申请幂等重放', RELABEL, body) + context.assert(MODULE, '贴标重放复用同一申请', replay.response, + id(replay.data?.application?.relabelNo) === relabelNo, trace(replay.response), OPTIONS) + const hashConflict = await context.requestOverseas('POST', RELABEL, { ...body, remark: `${context.marker}-DIFFERENT` }, {}, token) + context.addStep(MODULE, '贴标同幂等键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS) + const page = await merchant(context, token, 'GET', '客户查询自身贴标申请列表', + query(RELABEL, { page: 1, size: 20, relabelNo, customerId: 0 })) + context.assert(MODULE, '伪造customerId不改变贴标客户范围', page.response, + records(page.data).some((item) => id(item?.relabelNo) === relabelNo), trace(page.response), OPTIONS) + const detail = await merchant(context, token, 'GET', '客户查询自身贴标申请详情', + `${RELABEL}/${encodeURIComponent(relabelNo)}`) + context.assert(MODULE, '贴标详情保留明细与创建历史', detail.response, + id(detail.data?.application?.relabelNo) === relabelNo && detail.data?.application?.status === 'PENDING_PROCESSING' + && Array.isArray(detail.data?.items) && detail.data.items.length === 1 + && Array.isArray(detail.data?.history) && detail.data.history.some((item) => item?.toStatus === 'PENDING_PROCESSING'), + `items=${detail.data?.items?.length ?? 'missing'}, histories=${detail.data?.history?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS) + context.createdResources.phase4OverseasClientServices.relabelNo = relabelNo + return true +} + +async function verifyTransfer(context, token, orders) { + const order = orders.find((value) => nonBlank(value.row?.inboundCode) + && nonBlank(value.row?.fbaWarehouseCode) && nonBlank(value.row?.overseasWarehouseId) && usableCargo(value)) + const cargo = order ? usableCargo(order) : null + if (!order || !cargo) { + blocker(context, '本轮仓储转运申请候选', + `orders=${orders.length}, fbaOrders=${orders.filter((value) => nonBlank(value.row?.fbaWarehouseCode)).length}; 禁止借历史入仓单`) + return false + } + const packages = 1 + const volume = Number(cargo.volumeCbm) > 0 ? Number(cargo.volumeCbm) : 0.0001 + const body = { + inboundCode: order.row.inboundCode, sourceLastlegOrderIds: [id(order.row.id)], + operatingWarehouseId: order.row.overseasWarehouseId, fbaWarehouseCode: order.row.fbaWarehouseCode, + deliveryAddress: `${order.row.fbaWarehouseCode} ${context.marker}`.slice(0, 1000), + packageCount: packages, volumeCbm: volume, deliveryMethod: 'TRUCK', estimatedArrivalAt: future(10), + fbaShipmentNo: cargo.shipmentNo || `${context.marker}-TRANSFER-SHIP`, trackingNo: cargo.fbaTrackingNo || null, + fbaBoxNos: cargo.shippingMark || null, remark: context.marker, needRelabel: false, + idempotencyKey: `${context.marker}-CLIENT-TRANSFER`.slice(0, 255), + items: [{ skuCode: cargo.skuCode, productNameZh: cargo.productNameZh, + packageCount: packages, shippingMark: cargo.shippingMark || null }], + } + const created = await merchant(context, token, 'POST', '客户创建仓储转运申请', TRANSFER, body) + const transferNo = id(created.data?.transferNo) + context.assert(MODULE, '仓储转运申请进入合法待审核状态且绑定本轮来源订单', created.response, + Boolean(transferNo) && created.data?.status === 'PENDING_REVIEW' + && created.data?.sourceOrders?.some((item) => id(item?.lastlegOrderId) === id(order.row.id)), + `transferNo=${transferNo || 'missing'}, status=${created.data?.status || 'missing'}; ${trace(created.response)}`, OPTIONS) + if (!transferNo) return false + const replay = await merchant(context, token, 'POST', '客户仓储转运申请幂等重放', TRANSFER, body) + context.assert(MODULE, '转运重放复用同一申请', replay.response, + id(replay.data?.transferNo) === transferNo, trace(replay.response), OPTIONS) + const hashConflict = await context.requestOverseas('POST', TRANSFER, { ...body, remark: `${context.marker}-DIFFERENT` }, {}, token) + context.addStep(MODULE, '转运同幂等键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS) + const page = await merchant(context, token, 'GET', '客户查询自身仓储转运列表', + query(TRANSFER, { page: 1, size: 20, transferNo, customerId: 0 })) + context.assert(MODULE, '伪造customerId不改变转运客户范围', page.response, + records(page.data).some((item) => id(item?.transferNo) === transferNo), trace(page.response), OPTIONS) + const detail = await merchant(context, token, 'GET', '客户查询自身仓储转运详情', + `${TRANSFER}/${encodeURIComponent(transferNo)}`) + context.assert(MODULE, '转运详情保持待审核且不产生出库履约事实', detail.response, + id(detail.data?.transferNo) === transferNo && detail.data?.status === 'PENDING_REVIEW' + && detail.data?.outboundAt == null && Array.isArray(detail.data?.allocations) && detail.data.allocations.length === 0, + `status=${detail.data?.status || 'missing'}, allocations=${detail.data?.allocations?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS) + context.createdResources.phase4OverseasClientServices.transferNo = transferNo + return true +} + +async function verifyDiscard(context, token, markerOrders) { + const response = await context.requestOverseas('GET', `${DISCARD}/inventory-candidates`, undefined, {}, token) + const candidates = context.apiSuccess(MODULE, '客户查询货物废弃库存候选', response, trace(response), OPTIONS) + const markerOrderIds = new Set(markerOrders.map((value) => id(value.row?.id))) + const candidate = Array.isArray(candidates) ? candidates.find((value) => markerOrderIds.has(id(value?.orderId)) + && Array.isArray(value?.skus) && value.skus.some((sku) => nonBlank(sku?.skuCode) && Number(sku?.availablePackages) > 0)) : null + if (!candidate) { + blocker(context, '本轮货物废弃库存候选', + `candidateOrders=${Array.isArray(candidates) ? candidates.map((value) => id(value?.orderId)).join(',') : 'missing'}, markerOrders=${[...markerOrderIds].join(',') || 'missing'}; 禁止借历史一件代发库存`, response) + return false + } + const sku = candidate.skus.find((value) => nonBlank(value?.skuCode) && Number(value?.availablePackages) > 0) + const body = { + items: [{ orderId: id(candidate.orderId), skuCode: sku.skuCode, discardPackages: 1 }], + idempotencyKey: `${context.marker}-CLIENT-DISCARD`.slice(0, 255), remark: context.marker, + } + const created = await merchant(context, token, 'POST', '客户创建货物废弃申请', DISCARD, body) + const discardNo = id(created.data?.summary?.discardNo) + context.assert(MODULE, '废弃申请只锁定库存并进入合法待审核状态', created.response, + Boolean(discardNo) && created.data?.summary?.status === 'PENDING_REVIEW' + && Number(created.data?.summary?.totalPackages) === 1, + `discardNo=${discardNo || 'missing'}, status=${created.data?.summary?.status || 'missing'}; ${trace(created.response)}`, OPTIONS) + if (!discardNo) return false + const replay = await merchant(context, token, 'POST', '客户货物废弃申请幂等重放', DISCARD, body) + context.assert(MODULE, '废弃重放复用同一申请', replay.response, + id(replay.data?.summary?.discardNo) === discardNo, trace(replay.response), OPTIONS) + const page = await merchant(context, token, 'GET', '客户查询自身货物废弃列表', + query(DISCARD, { page: 1, size: 20, discardNo, customerId: 0 })) + context.assert(MODULE, '伪造customerId不改变废弃客户范围', page.response, + records(page.data).some((item) => id(item?.discardNo) === discardNo), trace(page.response), OPTIONS) + const detail = await merchant(context, token, 'GET', '客户查询自身货物废弃详情', + `${DISCARD}/${encodeURIComponent(discardNo)}`) + context.assert(MODULE, '废弃详情保留候选快照和创建历史且未审核扣减', detail.response, + id(detail.data?.summary?.discardNo) === discardNo && detail.data?.summary?.status === 'PENDING_REVIEW' + && Array.isArray(detail.data?.items) && detail.data.items.length === 1 + && Array.isArray(detail.data?.histories) && detail.data.histories.some((item) => item?.toStatus === 'PENDING_REVIEW'), + `items=${detail.data?.items?.length ?? 'missing'}, histories=${detail.data?.histories?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS) + context.createdResources.phase4OverseasClientServices.discardNo = discardNo + return true +} + +export async function run(context) { + context.createdResources.phase4OverseasClientServices = { + marker: context.marker, relabelNo: null, transferNo: null, discardNo: null, + finalStatuses: { relabel: null, transfer: null, discard: null }, + cleanup: 'Merchant applications remain in their legal initial review/processing states; no outbound, dispatch, PDA or delivery action is invoked', + } + const token = await loginMerchant(context) + if (!token) return + + for (const [name, path] of [['贴标', RELABEL], ['仓储转运', TRANSFER], ['货物废弃', DISCARD]]) { + const denied = await context.requestOverseas('GET', query(path, { page: 1, size: 1 })) + context.addStep(MODULE, `平台海外Token不能冒充客户访问${name}`, denied, forbidden(denied), trace(denied), OPTIONS) + } + + const orders = await currentOrders(context, token) + if (orders.length === 0) { + blocker(context, '取得本轮客户尾程与库存事实', '客户Token未查到本轮phase4 PDA完成柜,禁止借历史订单') + return + } + const relabelOrder = orders.find((value) => usableCargo(value) && nonBlank(value.row?.overseasWarehouseId)) + if (!relabelOrder) { + blocker(context, '本轮贴标订单明细', `orders=${orders.length}; 缺少真实SKU/品名/箱数或实际海外仓`) + return + } + const relabelDone = await verifyRelabel(context, token, relabelOrder) + const transferDone = await verifyTransfer(context, token, orders) + const discardDone = await verifyDiscard(context, token, orders) + context.createdResources.phase4OverseasClientServices.finalStatuses = { + relabel: relabelDone ? 'PENDING_PROCESSING' : null, + transfer: transferDone ? 'PENDING_REVIEW' : null, + discard: discardDone ? 'PENDING_REVIEW' : null, + } +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs new file mode 100644 index 000000000..541b1438d --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' + +const moduleUrl = new URL('./phase4-overseas-dispatch.mjs', import.meta.url) +const source = await readFile(fileURLToPath(moduleUrl), 'utf8') + +test('phase4 overseas dispatch module freezes only stable platform query and smart pre-arrange paths', () => { + for (const token of [ + "const BASE = '/api/platform/v1/last-leg/dispatches'", + "query(BASE, { page: 1, size: 20 })", + '`${BASE}/candidates`', '`${BASE}/drafts`', '`${BASE}/drafts/snapshot`', + '`${BASE}/draft-locks/lock`', '`${BASE}/pre-arrange`', '`${BASE}/draft-locks/release`', + '`${BASE}/${encodeURIComponent(dispatchNo)}`', + ]) assert.match(source, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) + for (const forbidden of ['/api/pda/', '/outbound', '/delivery', '/depart', '/cancel']) { + assert.equal(source.includes(forbidden), false, `must exclude ${forbidden}`) + } +}) + +test('module uses marker-owned completed PDA pallets and never borrows historical candidates', () => { + assert.match(source, /fixture\?\.marker !== context\.marker/) + assert.match(source, /fixture\?\.finalStatus !== 'COMPLETED'/) + assert.match(source, /fixture\.palletIds\.has\(id\(value\?\.warehousePalletId\)\)/) + assert.match(source, /id\(value\?\.overseasWarehouseId\) === id\(fixture\.overseasWarehouseId\)/) + assert.doesNotMatch(source, /candidates\[0\]/) +}) + +test('module proves draft idempotency, lock isolation, stale replay conflict and readback', () => { + assert.match(source, /相同草稿快照重复保存/) + assert.match(source, /同草稿重复锁定托盘幂等成功/) + assert.match(source, /其他草稿锁定同一托盘返回A0409/) + assert.match(source, /已转换正式锁的旧草稿载荷再次预排返回A0409/) + assert.match(source, /重复释放草稿幂等成功/) + assert.match(source, /排车详情按记录自身客户范围加载托盘明细/) + assert.match(source, /response\?\.http === 409.*codeOf\(response\) === 'A0409'/s) +}) + +test('module does not invent optimistic version or idempotency headers absent from DispatchRequests', () => { + assert.doesNotMatch(source, /expectedVersion|recordVersion|Idempotency-Key/) + assert.match(source, /PENDING_DEPARTURE/) + assert.match(source, /data == null/) +}) + +test('module records the marker-owned dispatch without attempting excluded cleanup actions', () => { + assert.match(source, /createdResources\.phase4OverseasDispatch/) + assert.match(source, /dispatchNo = dispatchNo/) + assert.match(source, /finalStatus = arranged\.data\?\.status/) + assert.match(source, /PENDING_DEPARTURE dispatch is retained as marker-owned acceptance evidence/) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs new file mode 100644 index 000000000..48aefddd7 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs @@ -0,0 +1,169 @@ +const MODULE = 'phase4-overseas-dispatch' +const SCENARIO = 'phase4-overseas-dispatch-lifecycle' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const BASE = '/api/platform/v1/last-leg/dispatches' + +function id(value) { return value == null ? '' : String(value) } +function records(value) { return Array.isArray(value?.records) ? value.records : [] } +function codeOf(response) { return response?.body?.code == null ? '' : String(response.body.code) } +function success(response) { return response?.http === 200 && codeOf(response) === '200' } +function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response) === 'A0409') } +function trace(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +function query(path, values) { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(values || {})) { + if (value !== null && value !== undefined && String(value) !== '') params.set(key, String(value)) + } + const encoded = params.toString() + return encoded ? `${path}?${encoded}` : path +} + +async function api(context, method, name, path, body) { + const response = await context.requestOverseas(method, path, body) + const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) + return { response, data } +} + +function blocker(context, name, evidence, response = null) { + context.addStep(MODULE, name, response, false, `${evidence}; ${trace(response)}`, OPTIONS) + context.addCoverageGap(MODULE, SCENARIO, name, evidence, 'fixture', { deploymentBlocker: true }) +} + +function markerFixture(context) { + const fixture = context.createdResources?.phase4OverseasPda + if (fixture?.marker !== context.marker || fixture?.finalStatus !== 'COMPLETED') return null + const palletIds = new Set((fixture.palletIds || []).map(id).filter(Boolean)) + if (!fixture.overseasWarehouseId || palletIds.size === 0) return null + return { ...fixture, palletIds } +} + +export async function run(context) { + context.createdResources.phase4OverseasDispatch = { + marker: context.marker, dispatchNo: null, dispatchId: null, draftToken: null, + planPalletId: null, finalStatus: null, + cleanup: 'PENDING_DEPARTURE dispatch is retained as marker-owned acceptance evidence; outbound, PDA and delivery APIs are excluded', + } + + const initialPage = await api(context, 'GET', '查询海外排车分页', query(BASE, { page: 1, size: 20 })) + context.assert(MODULE, '排车分页包络稳定', initialPage.response, + Number.isFinite(Number(initialPage.data?.page)) && Number.isFinite(Number(initialPage.data?.size)) + && Number.isFinite(Number(initialPage.data?.total)) && Array.isArray(initialPage.data?.records), + `records=${records(initialPage.data).length}; ${trace(initialPage.response)}`, OPTIONS) + if (!success(initialPage.response)) return + + const fixture = markerFixture(context) + if (!fixture) { + blocker(context, '取得本轮海外PDA已收货托盘前置', + `marker=${context.marker}, pdaMarker=${context.createdResources?.phase4OverseasPda?.marker || 'missing'}, status=${context.createdResources?.phase4OverseasPda?.finalStatus || 'missing'}`) + return + } + + const candidateResult = await api(context, 'GET', '查询本轮已收货托盘智能预排候选', + query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' })) + const candidates = Array.isArray(candidateResult.data) ? candidateResult.data : [] + const candidate = candidates.find((value) => fixture.palletIds.has(id(value?.warehousePalletId)) + && id(value?.overseasWarehouseId) === id(fixture.overseasWarehouseId) + && id(value?.planId) && id(value?.planPalletId)) + context.assert(MODULE, '候选严格命中本轮PDA库存托盘与实际海外仓', candidateResult.response, + Boolean(candidate) && candidate.sourceStage === 'RECEIVED' && Number(candidate.packageCount) > 0, + `candidate=${Boolean(candidate)}, pdaPallets=${[...fixture.palletIds].join(',')}, warehouseId=${id(fixture.overseasWarehouseId)}; ${trace(candidateResult.response)}`, OPTIONS) + if (!candidate) return + + const planId = id(candidate.planId) + const planPalletId = id(candidate.planPalletId) + const draftToken = `${context.marker}-DISPATCH-DRAFT`.slice(0, 64) + const containersJson = JSON.stringify([{ key: 'C1', capacity: Math.max(1, Number(candidate.packageCount)) }]) + const snapshotBody = { draftToken, planIds: [planId], containersJson } + context.createdResources.phase4OverseasDispatch.draftToken = draftToken + context.createdResources.phase4OverseasDispatch.planPalletId = planPalletId + + const snapshot = await api(context, 'POST', '保存智能预排草稿快照', `${BASE}/drafts/snapshot`, snapshotBody) + context.assert(MODULE, 'Void草稿快照允许data为空且请求成功', snapshot.response, + success(snapshot.response) && (snapshot.response.body?.data == null), trace(snapshot.response), OPTIONS) + if (!success(snapshot.response)) return + const snapshotReplay = await api(context, 'POST', '相同草稿快照重复保存', `${BASE}/drafts/snapshot`, snapshotBody) + if (!success(snapshotReplay.response)) return + + const draftsResult = await api(context, 'GET', '回查当前操作员智能预排草稿', `${BASE}/drafts`) + const draftRows = Array.isArray(draftsResult.data) ? draftsResult.data : [] + const matchingDrafts = draftRows.filter((value) => value?.draftToken === draftToken) + context.assert(MODULE, '同token同载荷快照只保留一份活动草稿', draftsResult.response, + matchingDrafts.length === 1 && String(matchingDrafts[0].planIdsJson || '').split(',').includes(planId) + && matchingDrafts[0].containersJson === containersJson, + `matches=${matchingDrafts.length}, planIds=${matchingDrafts[0]?.planIdsJson || 'missing'}; ${trace(draftsResult.response)}`, OPTIONS) + + const lockBody = { planPalletId, draftToken } + const locked = await api(context, 'POST', '锁定本轮预排托盘', `${BASE}/draft-locks/lock`, lockBody) + if (!success(locked.response)) return + const lockReplay = await api(context, 'POST', '同草稿重复锁定托盘幂等成功', `${BASE}/draft-locks/lock`, lockBody) + context.assert(MODULE, '重复锁定不创建冲突且Void响应可为空', lockReplay.response, + success(lockReplay.response) && lockReplay.response.body?.data == null, trace(lockReplay.response), OPTIONS) + + const visibleInDraft = await api(context, 'GET', '当前草稿仍可查看自身锁定候选', + query(`${BASE}/candidates`, { sourceStage: 'RECEIVED', draftToken })) + const visible = Array.isArray(visibleInDraft.data) && visibleInDraft.data.some((value) => id(value?.planPalletId) === planPalletId) + context.assert(MODULE, 'draftToken仅放行当前草稿自己的锁', visibleInDraft.response, visible, + `planPalletId=${planPalletId}; ${trace(visibleInDraft.response)}`, OPTIONS) + + const hiddenResult = await api(context, 'GET', '无草稿标识时隐藏已锁托盘', + query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' })) + const hidden = Array.isArray(hiddenResult.data) && !hiddenResult.data.some((value) => id(value?.planPalletId) === planPalletId) + context.assert(MODULE, '活动草稿锁阻止其他预排重复选择', hiddenResult.response, hidden, + `planPalletId=${planPalletId}; ${trace(hiddenResult.response)}`, OPTIONS) + + const foreignToken = `${context.marker}-DISPATCH-FOREIGN`.slice(0, 64) + const foreignLock = await context.requestOverseas('POST', `${BASE}/draft-locks/lock`, { + planPalletId, draftToken: foreignToken, + }) + context.addStep(MODULE, '其他草稿锁定同一托盘返回A0409', foreignLock, conflict(foreignLock), trace(foreignLock), OPTIONS) + + const preArrangeBody = { + planPalletIds: [planPalletId], route: `${context.marker}-ROUTE`, fbaAppointmentNo: null, + fbaAppointmentStatus: null, vehicleAppointmentStatus: null, draftToken, + } + const arranged = await api(context, 'POST', '将草稿锁原子转换为待出库预排', `${BASE}/pre-arrange`, preArrangeBody) + const dispatchNo = id(arranged.data?.dispatchNo) + context.assert(MODULE, '智能预排仅创建PENDING_DEPARTURE排车并冻结托盘', arranged.response, + Boolean(dispatchNo) && arranged.data?.status === 'PENDING_DEPARTURE' + && Array.isArray(arranged.data?.items) + && arranged.data.items.some((item) => id(item?.planPalletId) === planPalletId) + && Number(arranged.data?.palletCount) === 1, + `dispatchNo=${dispatchNo || 'missing'}, status=${arranged.data?.status || 'missing'}; ${trace(arranged.response)}`, OPTIONS) + if (!dispatchNo) return + context.createdResources.phase4OverseasDispatch.dispatchNo = dispatchNo + context.createdResources.phase4OverseasDispatch.dispatchId = id(arranged.data?.id) + context.createdResources.phase4OverseasDispatch.finalStatus = arranged.data?.status || null + + const duplicateArrange = await context.requestOverseas('POST', `${BASE}/pre-arrange`, preArrangeBody) + context.addStep(MODULE, '已转换正式锁的旧草稿载荷再次预排返回A0409', duplicateArrange, + conflict(duplicateArrange), trace(duplicateArrange), OPTIONS) + + const pageResult = await api(context, 'GET', '按排车单号回查跨客户分页', query(BASE, { + page: 1, size: 20, dispatchNo, + })) + const row = records(pageResult.data).find((value) => id(value?.dispatchNo) === dispatchNo) + context.assert(MODULE, '排车分页包含本轮marker预排且状态未推进', pageResult.response, + row?.status === 'PENDING_DEPARTURE' && id(row?.id) === id(arranged.data?.id), + `dispatchNo=${dispatchNo}, status=${row?.status || 'missing'}; ${trace(pageResult.response)}`, OPTIONS) + + const detailResult = await api(context, 'GET', '查询本轮排车详情', `${BASE}/${encodeURIComponent(dispatchNo)}`) + context.assert(MODULE, '排车详情按记录自身客户范围加载托盘明细', detailResult.response, + id(detailResult.data?.dispatchNo) === dispatchNo && detailResult.data?.status === 'PENDING_DEPARTURE' + && Array.isArray(detailResult.data?.items) + && detailResult.data.items.some((item) => id(item?.planPalletId) === planPalletId), + `dispatchNo=${id(detailResult.data?.dispatchNo) || 'missing'}, items=${detailResult.data?.items?.length ?? 'missing'}; ${trace(detailResult.response)}`, OPTIONS) + + const released = await api(context, 'POST', '释放已转换后的草稿资源', `${BASE}/draft-locks/release`, { draftToken }) + if (success(released.response)) { + const releaseReplay = await api(context, 'POST', '重复释放草稿幂等成功', `${BASE}/draft-locks/release`, { draftToken }) + context.assert(MODULE, '重复释放不影响正式排车锁且Void响应可为空', releaseReplay.response, + success(releaseReplay.response) && releaseReplay.response.body?.data == null, trace(releaseReplay.response), OPTIONS) + } + + const afterRelease = await api(context, 'GET', '释放后回查活动草稿', `${BASE}/drafts`) + context.assert(MODULE, '释放后当前token不再出现在活动草稿', afterRelease.response, + Array.isArray(afterRelease.data) && !afterRelease.data.some((value) => value?.draftToken === draftToken), + `draftToken=${draftToken}; ${trace(afterRelease.response)}`, OPTIONS) +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations-contract.test.mjs new file mode 100644 index 000000000..4f275d6a2 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations-contract.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(here, 'phase4-overseas-inbound-operations.mjs'), 'utf8') + +test('module covers the frozen Admin inbound-plan write surface', () => { + for (const value of ['/convert', '/pallets/supplement', '/fba-appointments/batch', + '/fba-appointments/${encodeURIComponent(appointmentNo)}']) assert.ok(source.includes(value), value) + assert.match(source, /重复转换返回同一入仓计划/) + assert.match(source, /FBA预约同单号异载荷返回A0409/) + assert.match(source, /appointmentStatus: 'CANCELLED'/) +}) + +test('module covers reversible inventory commands with idempotency and ledger proof', () => { + for (const endpoint of ['/hold', '/release', '/adjust', '/ledger']) assert.ok(source.includes(endpoint), endpoint) + assert.match(source, /PDA完成柜形成的库存/) + assert.match(source, /库存冻结同键异载荷返回A0409/) + assert.match(source, /盘点调整同键异载荷返回A0409/) + assert.match(source, /库存数量恢复基线/) + assert.match(source, /types\.filter\(\(type\) => type === 'ADJUST'\)\.length === 2/) +}) + +test('module stays inside inbound and inventory scope', () => { + for (const forbidden of ['/api/pda/', '/outbound', '/delivery', '/dispatch', '/ship', '/pick']) { + assert.equal(source.toLowerCase().includes(forbidden), false, forbidden) + } + assert.doesNotMatch(source, /\bfetch\s*\(|process\.env|https?:\/\//) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs new file mode 100644 index 000000000..87bc9f03e --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs @@ -0,0 +1,212 @@ +const MODULE = 'phase4-overseas-inbound-operations' +const OPTIONS = { scenario: 'phase4-inbound-admin-and-inventory-commands', deploymentBlocker: true } +const PLAN_BASE = '/api/platform/v1/last-leg/inbound-plans' +const INVENTORY_BASE = '/api/platform/v1/last-leg/inventory' + +function id(value) { return value == null ? '' : String(value) } +function ok(response) { return response?.http === 200 && String(response?.body?.code) === '200' } +function rows(data) { return Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : [] } +function conflict(response) { return response?.http === 200 && ['A0409', '409'].includes(String(response?.body?.code)) } +function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` } +function query(path, values) { + const params = new URLSearchParams() + for (const [name, value] of Object.entries(values || {})) if (value != null && value !== '') params.set(name, String(value)) + return `${path}?${params.toString()}` +} +function key(context, operation) { return `${context.marker}:P4-INBOUND:${operation}`.slice(0, 255) } + +async function request(context, method, name, path, body) { + const response = await context.requestOverseas(method, path, body) + context.addStep(MODULE, name, response, ok(response), trace(response), OPTIONS) + return ok(response) ? response.body?.data : null +} + +async function inboundAdminLifecycle(context) { + const matrix = context.createdResources?.phase4InboundProducerFixtures + const completed = new Set((context.createdResources?.phase4OverseasPda?.completedContainers || []).map(id)) + const producer = (matrix?.byDestinationType?.FBA || []).find((item) => !completed.has(id(item?.containerNo))) + context.assert(MODULE, '选择本轮未被PDA完成的FBA第二柜作为Admin写链夹具', null, + Boolean(producer?.containerNo && producer?.tenantId), + `containerNo=${producer?.containerNo || 'missing'}, customerId=${producer?.tenantId || 'missing'}`, OPTIONS) + if (!producer?.containerNo || !producer?.tenantId) return + + const customerId = id(producer.tenantId) + const orderPage = await request(context, 'GET', '按第二柜查询尾程订单', query('/api/platform/v1/last-leg/orders', { + page: 1, size: 200, customerId, containerNo: producer.containerNo, + })) + const order = rows(orderPage).find((item) => id(item?.customerId) === customerId + && id(item?.containerNo) === id(producer.containerNo) && item?.destinationType === 'FBA') + context.assert(MODULE, '第二柜尾程订单身份稳定', null, Boolean(order?.id), + `orderId=${id(order?.id) || 'missing'}, containerNo=${producer.containerNo}`, OPTIONS) + if (!order?.id) return + + const convertBody = { orderId: id(order.id), customerId } + const plan = await request(context, 'POST', '尾程入仓单转换为入仓计划', `${PLAN_BASE}/convert`, convertBody) + const replay = await request(context, 'POST', '重复转换返回同一入仓计划', `${PLAN_BASE}/convert`, convertBody) + context.assert(MODULE, '入仓计划转换天然幂等', null, + Boolean(plan?.plan?.planNo) && replay?.plan?.planNo === plan.plan.planNo, + `first=${plan?.plan?.planNo || 'missing'}, replay=${replay?.plan?.planNo || 'missing'}`, OPTIONS) + const planNo = plan?.plan?.planNo + const fbaLines = (plan?.lines || []).filter((line) => line?.destinationType === 'FBA' + && line?.fbaWarehouseCode && line?.destinationWarehouseId) + context.assert(MODULE, 'FBA入仓计划包含可预约明细', null, + Boolean(planNo) && fbaLines.length > 0, + `planNo=${planNo || 'missing'}, fbaLines=${fbaLines.length}`, OPTIONS) + if (!planNo || fbaLines.length === 0) return + + const sample = fbaLines[0] + const supplement = await request(context, 'POST', '为未完成FBA计划补充可打印托盘', + `${PLAN_BASE}/${encodeURIComponent(planNo)}/pallets/supplement`, { + destinationWarehouseId: id(sample.destinationWarehouseId), + destinationWarehouseCode: sample.destinationWarehouseCode, + destinationWarehouseName: sample.destinationWarehouseName, + customerId, + }) + context.assert(MODULE, '补充托盘绑定计划内目的仓且保持未分配', null, + Boolean(supplement?.id) && supplement?.assignmentStatus === 'UNASSIGNED' + && id(supplement?.destinationWarehouseId) === id(sample.destinationWarehouseId), + `palletId=${id(supplement?.id) || 'missing'}, status=${supplement?.assignmentStatus || 'missing'}`, OPTIONS) + + const suffix = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase() + const appointmentNo = `FBA-${suffix}`.slice(0, 64) + const now = Date.now() + const appointmentBody = { + customerId, lineIds: fbaLines.map((line) => id(line.id)), appointmentNo, + appointmentAt: new Date(now + 24 * 60 * 60_000).toISOString().slice(0, 19), + expectedLoadingAt: new Date(now + 26 * 60 * 60_000).toISOString().slice(0, 19), + appointmentStatus: 'PENDING', appointmentRemark: context.marker, + } + const appointment = await request(context, 'POST', '批量创建FBA预约', `${PLAN_BASE}/fba-appointments/batch`, appointmentBody) + const appointmentReplay = await request(context, 'POST', 'FBA预约同单号同载荷重放', `${PLAN_BASE}/fba-appointments/batch`, appointmentBody) + context.assert(MODULE, 'FBA预约重放返回同一聚合且明细一致', null, + id(appointmentReplay?.id) === id(appointment?.id) + && appointmentReplay?.items?.length === fbaLines.length, + `appointmentId=${id(appointment?.id) || 'missing'}, items=${appointmentReplay?.items?.length ?? 'missing'}`, OPTIONS) + const appointmentConflict = await context.requestOverseas('POST', `${PLAN_BASE}/fba-appointments/batch`, { + ...appointmentBody, appointmentRemark: `${context.marker}-different`, + }) + context.addStep(MODULE, 'FBA预约同单号异载荷返回A0409', appointmentConflict, + conflict(appointmentConflict), trace(appointmentConflict), OPTIONS) + const detail = await request(context, 'GET', '查询FBA预约详情', query( + `${PLAN_BASE}/fba-appointments/${encodeURIComponent(appointmentNo)}`, { customerId })) + context.assert(MODULE, 'FBA预约详情冻结仓码和所选明细', null, + detail?.appointmentNo === appointmentNo && detail?.fbaWarehouseCode === sample.fbaWarehouseCode + && detail?.items?.length === fbaLines.length, + `appointmentNo=${detail?.appointmentNo || 'missing'}, warehouse=${detail?.fbaWarehouseCode || 'missing'}`, OPTIONS) + const cancelled = await request(context, 'PUT', '取消本轮FBA预约收口', + `${PLAN_BASE}/fba-appointments/${encodeURIComponent(appointmentNo)}`, { + customerId, appointmentAt: appointmentBody.appointmentAt, + expectedLoadingAt: appointmentBody.expectedLoadingAt, + appointmentStatus: 'CANCELLED', appointmentRemark: `${context.marker}-cancelled`, + }) + context.assert(MODULE, 'FBA预约最终为CANCELLED', null, + cancelled?.appointmentStatus === 'CANCELLED', `status=${cancelled?.appointmentStatus || 'missing'}`, OPTIONS) + context.createdResources.phase4OverseasInboundAdmin = { + marker: context.marker, customerId, orderId: id(order.id), planNo, + supplementedPalletId: id(supplement?.id) || null, appointmentNo, + terminalStatus: cancelled?.appointmentStatus || null, + } +} + +async function inventoryCommandLifecycle(context) { + const pda = context.createdResources?.phase4OverseasPda + const customerId = id(pda?.customerId) + const palletIds = new Set((pda?.palletIds || []).map(id)) + context.assert(MODULE, '库存命令只使用本轮PDA完成柜形成的库存', null, + Boolean(customerId && pda?.finalStatus === 'COMPLETED' && palletIds.size > 0), + `customerId=${customerId || 'missing'}, pallets=${palletIds.size}, status=${pda?.finalStatus || 'missing'}`, OPTIONS) + if (!customerId || pda?.finalStatus !== 'COMPLETED' || palletIds.size === 0) return + + const balancePage = await request(context, 'GET', '查询本轮客户库存余额', query(`${INVENTORY_BASE}/balances`, { + customerId, overseasWarehouseId: pda.overseasWarehouseId, page: 1, size: 200, + })) + let selected = null + for (const balance of rows(balancePage)) { + const positions = await request(context, 'GET', `查询库存余额${id(balance.id)}库位`, query( + `${INVENTORY_BASE}/balances/${encodeURIComponent(id(balance.id))}/positions`, { customerId })) + const position = rows(positions).find((item) => palletIds.has(id(item?.palletId)) + && Number(item?.availablePackages) >= 1) + if (position) { selected = { balance, position }; break } + } + context.assert(MODULE, '定位本轮PDA托盘上的可用库存位置', null, Boolean(selected?.position?.id), + `positionId=${id(selected?.position?.id) || 'missing'}, palletId=${id(selected?.position?.palletId) || 'missing'}`, OPTIONS) + if (!selected?.position?.id) return + + const positionId = id(selected.position.id) + const baseline = { + available: Number(selected.position.availablePackages), hold: Number(selected.position.holdPackages), + allocated: Number(selected.position.allocatedPackages), picked: Number(selected.position.pickedPackages), + } + const businessOrderNo = `${context.marker}-INVENTORY` + const holdBody = { positionId, packages: 1, businessOrderNo, + idempotencyKey: key(context, 'HOLD'), remark: context.marker } + const held = await request(context, 'POST', '冻结一箱本轮PDA库存', `${INVENTORY_BASE}/hold`, holdBody) + const heldReplay = await request(context, 'POST', '库存冻结同键重放', `${INVENTORY_BASE}/hold`, holdBody) + context.assert(MODULE, '库存冻结重放不重复扣减', null, + held?.operationType === 'HOLD' && heldReplay?.operationId === held?.operationId && heldReplay?.replayed === true, + `operationId=${id(held?.operationId) || 'missing'}, replayed=${heldReplay?.replayed}`, OPTIONS) + const holdConflict = await context.requestOverseas('POST', `${INVENTORY_BASE}/hold`, { + ...holdBody, remark: `${context.marker}-different`, + }) + context.addStep(MODULE, '库存冻结同键异载荷返回A0409', holdConflict, + conflict(holdConflict), trace(holdConflict), OPTIONS) + + const releaseBody = { positionId, packages: 1, businessOrderNo, + idempotencyKey: key(context, 'RELEASE'), remark: context.marker } + const released = await request(context, 'POST', '释放一箱冻结库存', `${INVENTORY_BASE}/release`, releaseBody) + const releasedReplay = await request(context, 'POST', '库存释放同键重放', `${INVENTORY_BASE}/release`, releaseBody) + context.assert(MODULE, '库存释放重放不重复增加可用数', null, + released?.operationType === 'RELEASE' && releasedReplay?.operationId === released?.operationId + && releasedReplay?.replayed === true, + `operationId=${id(released?.operationId) || 'missing'}, replayed=${releasedReplay?.replayed}`, OPTIONS) + + const increaseBody = { positionId, packageDelta: 1, reason: 'ACCEPTANCE_COUNT_GAIN', + businessOrderNo, idempotencyKey: key(context, 'ADJUST-UP'), remark: context.marker } + const increased = await request(context, 'POST', '盘点调增一箱', `${INVENTORY_BASE}/adjust`, increaseBody) + const increaseReplay = await request(context, 'POST', '盘点调增同键重放', `${INVENTORY_BASE}/adjust`, increaseBody) + context.assert(MODULE, '盘点调增重放不重复入账', null, + increased?.operationType === 'ADJUST' && increaseReplay?.operationId === increased?.operationId + && increaseReplay?.replayed === true, + `operationId=${id(increased?.operationId) || 'missing'}, replayed=${increaseReplay?.replayed}`, OPTIONS) + const adjustConflict = await context.requestOverseas('POST', `${INVENTORY_BASE}/adjust`, { + ...increaseBody, reason: 'DIFFERENT_COUNT_REASON', + }) + context.addStep(MODULE, '盘点调整同键异载荷返回A0409', adjustConflict, + conflict(adjustConflict), trace(adjustConflict), OPTIONS) + await request(context, 'POST', '盘点调减一箱恢复基线', `${INVENTORY_BASE}/adjust`, { + positionId, packageDelta: -1, reason: 'ACCEPTANCE_COUNT_RESTORE', businessOrderNo, + idempotencyKey: key(context, 'ADJUST-DOWN'), remark: context.marker, + }) + + const finalPositions = await request(context, 'GET', '回查库存命令后的托盘位置', query( + `${INVENTORY_BASE}/balances/${encodeURIComponent(id(selected.balance.id))}/positions`, { customerId })) + const finalPosition = rows(finalPositions).find((item) => id(item?.id) === positionId) + context.assert(MODULE, '冻结释放与盘点正反向后库存数量恢复基线', null, + Number(finalPosition?.availablePackages) === baseline.available + && Number(finalPosition?.holdPackages) === baseline.hold + && Number(finalPosition?.allocatedPackages) === baseline.allocated + && Number(finalPosition?.pickedPackages) === baseline.picked, + `before=${JSON.stringify(baseline)}, after=${JSON.stringify({ available: finalPosition?.availablePackages, + hold: finalPosition?.holdPackages, allocated: finalPosition?.allocatedPackages, + picked: finalPosition?.pickedPackages })}`, OPTIONS) + const ledger = await request(context, 'GET', '按本轮业务号回查库存流水', query(`${INVENTORY_BASE}/ledger`, { + customerId, businessOrderNo, page: 1, size: 100, + })) + const types = rows(ledger).map((item) => item?.businessType) + context.assert(MODULE, '库存流水包含HOLD、RELEASE及两条ADJUST且重放零重复', null, + types.filter((type) => type === 'HOLD').length === 1 + && types.filter((type) => type === 'RELEASE').length === 1 + && types.filter((type) => type === 'ADJUST').length === 2, + `types=${types.join(',') || 'missing'}`, OPTIONS) + context.createdResources.phase4OverseasInventoryCommands = { + marker: context.marker, customerId, balanceId: id(selected.balance.id), positionId, + terminalState: 'RESTORED', + } +} + +export async function run(context) { + context.createdResources.phase4OverseasInboundAdmin = { marker: context.marker } + context.createdResources.phase4OverseasInventoryCommands = { marker: context.marker } + await inboundAdminLifecycle(context) + await inventoryCommandLifecycle(context) +} diff --git a/scripts/admin-test-env-modules/supplemental-boundaries-contract.test.mjs b/scripts/admin-test-env-modules/supplemental-boundaries-contract.test.mjs new file mode 100644 index 000000000..d80e26117 --- /dev/null +++ b/scripts/admin-test-env-modules/supplemental-boundaries-contract.test.mjs @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('./supplemental-boundaries.mjs', import.meta.url), 'utf8') + +test('occupied location deletion protection reuses only the marker-owned loading fixture', () => { + assert.match(source, /phase3Loading/) + assert.match(source, /inventoryMutationCleanup\?\.targetLocationId/) + assert.match(source, /loading\?\.marker === context\.marker/) + assert.match(source, /真实占用库位删除返回A0409且保留资源/) + assert.match(source, /占用删除冲突未造成资源丢失/) + assert.doesNotMatch(source, /no-public-fixture-api|当前公共验收 API 未提供/) +}) diff --git a/scripts/admin-test-env-modules/supplemental-boundaries.mjs b/scripts/admin-test-env-modules/supplemental-boundaries.mjs index 7ca47c5ae..a6057b65d 100644 --- a/scripts/admin-test-env-modules/supplemental-boundaries.mjs +++ b/scripts/admin-test-env-modules/supplemental-boundaries.mjs @@ -86,11 +86,30 @@ export async function run(context) { locationVersion = Number(afterData?.location?.version) } - // Public APIs do not expose a safe way to create a real occupied pallet/ - // location association without an inbound allocation fixture. Keep this as - // an explicit automation gap instead of claiming a deletion-protection pass. - context.addCoverageGap(MODULE, 'resource-occupancy', '托盘/库位真实占用删除保护', - '当前公共验收 API 未提供可安全构造并释放占用关联的独立 fixture;未操作历史资源', 'no-public-fixture-api') + // phase3-loading has already created and rebound a marker-owned live + // occupancy. Reuse that exact location to prove deletion protection without + // touching historical resources or inventing a setup-only API. + const loading = context.createdResources?.phase3Loading + const occupiedLocationId = id(loading?.inventoryMutationCleanup?.targetLocationId) + const occupiedWarehouseId = id(loading?.warehouseId) + context.assert(MODULE, '取得本轮装柜链真实占用库位', null, + Boolean(occupiedLocationId && occupiedWarehouseId && loading?.marker === context.marker), + `locationId=${occupiedLocationId || 'missing'}, warehouseId=${occupiedWarehouseId || 'missing'}`, OPTIONS) + if (occupiedLocationId && occupiedWarehouseId && loading?.marker === context.marker) { + const occupiedPath = `/api/platform/v1/locations/${occupiedLocationId}?tenantId=0&warehouseId=${occupiedWarehouseId}` + const occupiedDetail = await context.request('GET', occupiedPath) + const occupied = context.apiSuccess(MODULE, '读取本轮真实占用库位版本', occupiedDetail, '', OPTIONS) + const occupiedVersion = Number(occupied?.location?.version) + const rejectedDelete = await context.request('DELETE', `${occupiedPath}&version=${occupiedVersion}`) + context.addStep(MODULE, '真实占用库位删除返回A0409且保留资源', rejectedDelete, + rejectedDelete.http === 200 && ['A0409', '409'].includes(codeOf(rejectedDelete)), + `HTTP=${rejectedDelete.http}, code=${codeOf(rejectedDelete)}, version=${occupiedVersion}`, OPTIONS) + const occupiedAfter = await context.request('GET', occupiedPath) + const after = context.apiSuccess(MODULE, '删除冲突后回查占用库位', occupiedAfter, '', OPTIONS) + context.assert(MODULE, '占用删除冲突未造成资源丢失', occupiedAfter, + id(after?.location?.id) === occupiedLocationId, + `expected=${occupiedLocationId}, actual=${id(after?.location?.id) || 'missing'}`, OPTIONS) + } // Import boundary probes use only in-memory bytes and dry-run where supported. const bogusXlsx = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x00, 0x00]) diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index f4e1db7f6..0a1cf2a60 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -10,6 +10,9 @@ test('phase-four HTTP acceptance modules are registered in the Admin acceptance for (const module of [ 'phase4-overseas-warehouse', 'phase4-overseas-pda', + 'phase4-overseas-inbound-operations', + 'phase4-overseas-client-services', + 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', @@ -19,6 +22,18 @@ test('phase-four HTTP acceptance modules are registered in the Admin acceptance } }) +test('overseas inbound commands, client services and dispatch follow completed PDA receiving', () => { + for (const consumer of ['phase4-overseas-inbound-operations', 'phase4-overseas-client-services', + 'phase4-overseas-dispatch']) { + const modules = parseArguments(['--modules', consumer]).modules + for (const prerequisite of ['merchant-qualification-fixture', 'phase3-loading', + 'phase3-loading-finalize', 'phase4-overseas-pda']) { + assert.ok(modules.indexOf(prerequisite) >= 0 + && modules.indexOf(prerequisite) < modules.indexOf(consumer)) + } + } +}) + test('entry accepts a separate overseas Gateway without exposing token state', () => { const options = parseArguments([ '--gateway', 'http://localhost:9000/', From 85a288e4f2f26f9d87c73168d2047ed22ac51a0d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 08:12:58 +0800 Subject: [PATCH 074/115] fix: preserve overseas pallet identity --- .../gateway/HttpWarehouseResourceGateway.java | 3 +- .../HttpWarehouseResourceGatewayTest.java | 61 +++++++++++++++++++ .../phase4-overseas-client-services.mjs | 8 +-- .../phase4-overseas-inbound-operations.mjs | 10 +-- 4 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java index 62c41435f..7aff90d5b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -1,5 +1,6 @@ package com.baiyue.lastleg.inboundplan.pda.gateway; +import com.fasterxml.jackson.annotation.JsonAlias; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatusCode; import org.springframework.http.HttpStatus; @@ -141,7 +142,7 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { } private record WarehousePalletResponse(WarehousePalletData data) {} - private record WarehousePalletData(long id, String palletCode, long warehouseId, long locationId, + private record WarehousePalletData(@JsonAlias("palletId") long id, String palletCode, long warehouseId, long locationId, String status, String fbaWarehouseCode, int version) {} private record ReserveEnvelope(ReserveData data) {} private record ReserveData(long lockId) {} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java new file mode 100644 index 000000000..188ff9c41 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java @@ -0,0 +1,61 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +class HttpWarehouseResourceGatewayTest { + private HttpWarehouseResourceGateway gateway; + private MockRestServiceServer server; + + @BeforeEach + void setUp() { + RestClient.Builder builder = RestClient.builder(); + server = MockRestServiceServer.bindTo(builder).build(); + gateway = new HttpWarehouseResourceGateway(); + ReflectionTestUtils.setField(gateway, "restClientBuilder", builder); + ReflectionTestUtils.setField(gateway, "baseUrl", "http://warehouse"); + ReflectionTestUtils.setField(gateway, "internalToken", "test-token"); + gateway.initializeClient(); + } + + @Test + void resolveMapsCanonicalPalletIdInsteadOfSilentlyUsingZero() { + server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-1")) + .andRespond(withSuccess(""" + {"code":"200","data":{"palletId":2092040891261427713,"palletCode":"PAL-1", + "warehouseId":2092040869757231105,"locationId":2092040891076878337, + "status":"IDLE","fbaWarehouseCode":"ABE3","version":3}} + """, MediaType.APPLICATION_JSON)); + + WarehouseResourceGateway.WarehousePalletReference result = gateway.resolvePallet("PAL-1"); + + assertThat(result.palletId()).isEqualTo(2092040891261427713L); + assertThat(result.warehouseId()).isEqualTo(2092040869757231105L); + assertThat(result.locationId()).isEqualTo(2092040891076878337L); + server.verify(); + } + + @Test + void ensureDropshipStillMapsWarehouseEntityId() { + server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/ensure-dropship")) + .andRespond(withSuccess(""" + {"code":"200","data":{"id":2092040891261427713,"palletCode":"PAL-DROP", + "warehouseId":2092040869757231105,"locationId":2092040891076878337, + "status":"IDLE","fbaWarehouseCode":null,"version":1}} + """, MediaType.APPLICATION_JSON)); + + WarehouseResourceGateway.WarehousePalletReference result = + gateway.ensureDropshipPallet("2092040041967063041", 2092040869757231105L, "DROP-1"); + + assertThat(result.palletId()).isEqualTo(2092040891261427713L); + server.verify(); + } +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs index e887bc8bf..380254e5d 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs @@ -154,9 +154,9 @@ async function verifyTransfer(context, token, orders) { const created = await merchant(context, token, 'POST', '客户创建仓储转运申请', TRANSFER, body) const transferNo = id(created.data?.transferNo) context.assert(MODULE, '仓储转运申请进入合法待审核状态且绑定本轮来源订单', created.response, - Boolean(transferNo) && created.data?.status === 'PENDING_REVIEW' + Boolean(transferNo) && created.data?.orderStatus === 'PENDING_REVIEW' && created.data?.sourceOrders?.some((item) => id(item?.lastlegOrderId) === id(order.row.id)), - `transferNo=${transferNo || 'missing'}, status=${created.data?.status || 'missing'}; ${trace(created.response)}`, OPTIONS) + `transferNo=${transferNo || 'missing'}, status=${created.data?.orderStatus || 'missing'}; ${trace(created.response)}`, OPTIONS) if (!transferNo) return false const replay = await merchant(context, token, 'POST', '客户仓储转运申请幂等重放', TRANSFER, body) context.assert(MODULE, '转运重放复用同一申请', replay.response, @@ -170,9 +170,9 @@ async function verifyTransfer(context, token, orders) { const detail = await merchant(context, token, 'GET', '客户查询自身仓储转运详情', `${TRANSFER}/${encodeURIComponent(transferNo)}`) context.assert(MODULE, '转运详情保持待审核且不产生出库履约事实', detail.response, - id(detail.data?.transferNo) === transferNo && detail.data?.status === 'PENDING_REVIEW' + id(detail.data?.transferNo) === transferNo && detail.data?.orderStatus === 'PENDING_REVIEW' && detail.data?.outboundAt == null && Array.isArray(detail.data?.allocations) && detail.data.allocations.length === 0, - `status=${detail.data?.status || 'missing'}, allocations=${detail.data?.allocations?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS) + `status=${detail.data?.orderStatus || 'missing'}, allocations=${detail.data?.allocations?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS) context.createdResources.phase4OverseasClientServices.transferNo = transferNo return true } diff --git a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs index 87bc9f03e..a28ce8ee6 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs @@ -44,11 +44,11 @@ async function inboundAdminLifecycle(context) { const plan = await request(context, 'POST', '尾程入仓单转换为入仓计划', `${PLAN_BASE}/convert`, convertBody) const replay = await request(context, 'POST', '重复转换返回同一入仓计划', `${PLAN_BASE}/convert`, convertBody) context.assert(MODULE, '入仓计划转换天然幂等', null, - Boolean(plan?.plan?.planNo) && replay?.plan?.planNo === plan.plan.planNo, - `first=${plan?.plan?.planNo || 'missing'}, replay=${replay?.plan?.planNo || 'missing'}`, OPTIONS) - const planNo = plan?.plan?.planNo - const fbaLines = (plan?.lines || []).filter((line) => line?.destinationType === 'FBA' - && line?.fbaWarehouseCode && line?.destinationWarehouseId) + Boolean(plan?.planNo) && replay?.planNo === plan.planNo, + `first=${plan?.planNo || 'missing'}, replay=${replay?.planNo || 'missing'}`, OPTIONS) + const planNo = plan?.planNo + const fbaLines = (plan?.lines || []).filter((line) => line?.fbaWarehouseCode + && line?.destinationWarehouseId) context.assert(MODULE, 'FBA入仓计划包含可预约明细', null, Boolean(planNo) && fbaLines.length > 0, `planNo=${planNo || 'missing'}, fbaLines=${fbaLines.length}`, OPTIONS) From c218bfad6b6baa293cd7b425712b1c7969410ae6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 08:21:47 +0800 Subject: [PATCH 075/115] fix: expose warehouse transfer application status --- .../lastleg/transfer/dto/WarehouseTransferResponses.java | 1 + .../transfer/service/WarehouseTransferService.java | 1 + .../transfer/service/WarehouseTransferServiceTest.java | 5 ++++- .../phase4-overseas-client-services.mjs | 8 ++++---- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java index a081338e6..09cd8e495 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java @@ -16,6 +16,7 @@ public final class WarehouseTransferResponses { String status, LocalDateTime estimatedArrivalAt, LocalDateTime createdAt) {} public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode, Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus, + String status, boolean needRelabel, Long relabelApplicationId, String relabelNo, String relabelStatus, String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress, int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index ddafeed42..8995b02bd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -225,6 +225,7 @@ public class WarehouseTransferService { transfer.getInboundCode(), transfer.getLastlegOrderId(), transfer.getLastlegOrderNo(), tailOrder == null ? ORDER_TYPE : tailOrder.getOrderType(), tailOrder == null ? (transfer.getStatus() == null ? PENDING_REVIEW : transfer.getStatus()) : tailOrder.getCurrentStatus(), + transfer.getStatus(), Boolean.TRUE.equals(transfer.getNeedRelabel()), transfer.getRelabelApplicationId(), transfer.getRelabelNo(), relabelStatus, transfer.getOperatingWarehouseId(), transfer.getFbaWarehouseCode(), transfer.getDeliveryAddress(), diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index f4b449c62..df28e085b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -125,6 +125,7 @@ class WarehouseTransferServiceTest { verify(orders).updateById(tailOrder.getValue()); assertNotNull(tailOrder.getValue().getRelabelOrderNo()); assertEquals("PENDING_PROCESSING", tailOrder.getValue().getRelabelStatus()); + assertEquals(WarehouseTransferService.PENDING_REVIEW, result.status()); assertEquals(WarehouseTransferService.PENDING_REVIEW, result.orderStatus()); } @@ -162,6 +163,7 @@ class WarehouseTransferServiceTest { assertEquals(WarehouseTransferService.APPROVED, transfer.getStatus()); assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, tailOrder.getCurrentStatus()); + assertEquals(WarehouseTransferService.APPROVED, result.status()); assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, result.orderStatus()); verify(transfers).updateById(transfer); verify(orders).updateById(tailOrder); @@ -319,7 +321,8 @@ class WarehouseTransferServiceTest { WarehouseTransferOrder transfer = new WarehouseTransferOrder(); transfer.setId(21L); transfer.setCustomerId("TENANT-A"); transfer.setTransferNo("WTR-1"); transfer.setInboundOrderId(101L); transfer.setInboundCode("IN-1"); transfer.setLastlegOrderId(31L); - transfer.setLastlegOrderNo("LMO-TRANSFER-1"); transfer.setNeedRelabel(false); transfer.setPackageCount(5); + transfer.setLastlegOrderNo("LMO-TRANSFER-1"); transfer.setNeedRelabel(false); + transfer.setStatus(WarehouseTransferService.PENDING_REVIEW); transfer.setPackageCount(5); return transfer; } diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs index 380254e5d..e887bc8bf 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs @@ -154,9 +154,9 @@ async function verifyTransfer(context, token, orders) { const created = await merchant(context, token, 'POST', '客户创建仓储转运申请', TRANSFER, body) const transferNo = id(created.data?.transferNo) context.assert(MODULE, '仓储转运申请进入合法待审核状态且绑定本轮来源订单', created.response, - Boolean(transferNo) && created.data?.orderStatus === 'PENDING_REVIEW' + Boolean(transferNo) && created.data?.status === 'PENDING_REVIEW' && created.data?.sourceOrders?.some((item) => id(item?.lastlegOrderId) === id(order.row.id)), - `transferNo=${transferNo || 'missing'}, status=${created.data?.orderStatus || 'missing'}; ${trace(created.response)}`, OPTIONS) + `transferNo=${transferNo || 'missing'}, status=${created.data?.status || 'missing'}; ${trace(created.response)}`, OPTIONS) if (!transferNo) return false const replay = await merchant(context, token, 'POST', '客户仓储转运申请幂等重放', TRANSFER, body) context.assert(MODULE, '转运重放复用同一申请', replay.response, @@ -170,9 +170,9 @@ async function verifyTransfer(context, token, orders) { const detail = await merchant(context, token, 'GET', '客户查询自身仓储转运详情', `${TRANSFER}/${encodeURIComponent(transferNo)}`) context.assert(MODULE, '转运详情保持待审核且不产生出库履约事实', detail.response, - id(detail.data?.transferNo) === transferNo && detail.data?.orderStatus === 'PENDING_REVIEW' + id(detail.data?.transferNo) === transferNo && detail.data?.status === 'PENDING_REVIEW' && detail.data?.outboundAt == null && Array.isArray(detail.data?.allocations) && detail.data.allocations.length === 0, - `status=${detail.data?.orderStatus || 'missing'}, allocations=${detail.data?.allocations?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS) + `status=${detail.data?.status || 'missing'}, allocations=${detail.data?.allocations?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS) context.createdResources.phase4OverseasClientServices.transferNo = transferNo return true } From 4839243f217342eb89e4cf859749e21d3a3e698c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 08:40:27 +0800 Subject: [PATCH 076/115] feat: add dropship fulfillment producer chain --- deploy/sql/头程数据库统一增量-MySQL8.sql | 44 +++++++++++++++++++ .../InternalInboundOrderController.java | 8 +++- .../firstleg/dto/InboundOrderSummary.java | 3 +- .../dto/OrderApprovedInboundCommand.java | 9 ++-- .../firstleg/entity/InboundOrderEntity.java | 1 + .../mapper/InboundSplitSettlementMapper.java | 4 +- .../mapper/LoadingCompletedEventMapper.java | 4 +- .../service/InboundOrderCreationService.java | 10 +++++ .../LoadingCompletedOutboxService.java | 22 +++++++--- .../resources/db/phase2-inbound-schema.sql | 21 +++++++++ .../main/resources/openapi/firstleg-v1.yaml | 5 ++- .../UnifiedInboundOpenApiContractTest.java | 7 ++- .../FulfillmentTypeSqlContractTest.java | 40 +++++++++++++++++ .../PdaSplitSettlementSqlContractTest.java | 1 + .../InboundOrderCreationServiceTest.java | 24 ++++++++++ .../LoadingCompletedOutboxServiceTest.java | 34 +++++++++++++- .../TaillegRealProducerAcceptanceTest.java | 16 ++++--- .../service/LoadingCompletedConsumer.java | 6 ++- .../service/LoadingCompletedConsumerTest.java | 2 +- .../order/client/FirstlegInboundClient.java | 2 +- .../com/baiyue/order/dto/OrderRequests.java | 26 +++++++++-- .../com/baiyue/order/entity/OrderEntity.java | 1 + .../service/OrderApplicationService.java | 11 ++++- .../service/OrderFulfillmentSplitService.java | 1 + order/src/main/resources/db/order.sql | 21 +++++++++ .../src/main/resources/openapi/order-v1.yaml | 8 +++- ...MerchantBatchDraftOpenApiContractTest.java | 3 ++ .../dto/OrderRequestsValidationTest.java | 19 ++++++++ .../OrderApplicationServiceStateTest.java | 27 +++++++++++- scripts/admin-test-env-acceptance.mjs | 16 ++++--- scripts/admin-test-env-modules/orders.mjs | 2 + .../phase3-loading-finalize.mjs | 9 +++- .../admin-test-env-modules/phase3-loading.mjs | 1 + ...-phase4-producer-fixture-contract.test.mjs | 31 +++++++++---- .../phase4-overseas-pda.mjs | 3 +- 35 files changed, 389 insertions(+), 53 deletions(-) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/FulfillmentTypeSqlContractTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index a6bd2d6c0..483eb69ce 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -583,6 +583,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( container_mode VARCHAR(24) NULL COMMENT '装运方式:FULL_CONTAINER整柜、LCL散货拼箱', customs_inspection TINYINT(1) NULL COMMENT '是否需要商检:0否、1是', destination_type VARCHAR(24) NULL COMMENT '目的仓类型:FBA亚马逊仓、OVERSEAS海外仓、PRIVATE私人地址', + fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发', amazon_warehouse_code VARCHAR(32) NULL COMMENT 'Amazon目的仓编码', shipment_id VARCHAR(64) NULL COMMENT '订单级Amazon Shipment ID', reference_id VARCHAR(64) NULL COMMENT '订单级Amazon Reference ID', @@ -604,6 +605,26 @@ CREATE TABLE IF NOT EXISTS ord_order ( KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) ) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态'; +SET @order_fulfillment_type_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order' + AND column_name = 'fulfillment_type' + ), + 'SELECT 1', + 'ALTER TABLE baiyue_order.ord_order ADD COLUMN fulfillment_type VARCHAR(32) NULL COMMENT ''履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'' AFTER destination_type' +); +PREPARE order_fulfillment_type_stmt FROM @order_fulfillment_type_sql; +EXECUTE order_fulfillment_type_stmt; +DEALLOCATE PREPARE order_fulfillment_type_stmt; + +UPDATE baiyue_order.ord_order + SET fulfillment_type = 'STANDARD' + WHERE fulfillment_type IS NULL OR fulfillment_type = ''; +ALTER TABLE baiyue_order.ord_order + MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' + COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'; + -- 既有开发库可能仍保留草稿入仓码 NOT NULL 约束;必须先放宽再清理草稿入仓码。 SET @order_inbound_code_nullable_sql = IF( EXISTS( @@ -2531,6 +2552,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( delivery_warehouse_id BIGINT UNSIGNED NULL COMMENT '交货仓逻辑ID', delivery_warehouse_name VARCHAR(128) NULL COMMENT '交货仓名称快照', business_type VARCHAR(32) NOT NULL COMMENT '业务类型', + fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发', channel_code VARCHAR(64) NULL COMMENT '渠道编码', channel_name VARCHAR(128) NULL COMMENT '渠道名称快照', carrier_id BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID', @@ -2581,6 +2603,26 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id) ) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照'; +SET @inbound_fulfillment_type_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND column_name = 'fulfillment_type' + ), + 'SELECT 1', + 'ALTER TABLE fl_inbound_order ADD COLUMN fulfillment_type VARCHAR(32) NULL COMMENT ''履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'' AFTER business_type' +); +PREPARE inbound_fulfillment_type_stmt FROM @inbound_fulfillment_type_sql; +EXECUTE inbound_fulfillment_type_stmt; +DEALLOCATE PREPARE inbound_fulfillment_type_stmt; + +UPDATE fl_inbound_order + SET fulfillment_type = 'STANDARD' + WHERE fulfillment_type IS NULL OR fulfillment_type = ''; +ALTER TABLE fl_inbound_order + MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' + COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'; + SET @inbound_global_code_index_exists := ( SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' @@ -4323,8 +4365,10 @@ CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_id'); CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_name'); CALL unified_assert_column('baiyue_order', 'ord_order', 'pricing_tax_mode'); CALL unified_assert_column('baiyue_order', 'ord_order', 'inbound_code'); +CALL unified_assert_column('baiyue_order', 'ord_order', 'fulfillment_type'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'pricing_tax_mode'); +CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'fulfillment_type'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_measurement_item', 'external_detail_no'); CALL unified_assert_table('baiyue_firstleg', 'fl_price_channel_catalog'); CALL unified_assert_table('baiyue_firstleg', 'fl_price_origin_alias'); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java index 651269bd1..4e299343f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java @@ -59,6 +59,9 @@ public class InternalInboundOrderController { || command.warehouseId() <= 0 || command.customerName() == null || command.customerName().isBlank() || command.inboundCode() == null || command.inboundCode().isBlank() || command.inboundCode().length() > 40 + || command.fulfillmentType() == null + || (!"STANDARD".equals(command.fulfillmentType()) + && !"ONE_CLICK_DROPSHIP".equals(command.fulfillmentType())) || command.totalPackageCount() != null && command.totalPackageCount() < 1) { throw new BizException(ResultCode.BAD_REQUEST, "订单入库快照不完整"); } @@ -142,7 +145,10 @@ public class InternalInboundOrderController { || command.customerName() == null || command.customerName().isBlank() || command.warehouseId() == null || command.warehouseId() <= 0 || command.warehouseName() == null || command.warehouseName().isBlank() - || command.businessType() == null || command.businessType().isBlank()) { + || command.businessType() == null || command.businessType().isBlank() + || command.fulfillmentType() == null + || (!"STANDARD".equals(command.fulfillmentType()) + && !"ONE_CLICK_DROPSHIP".equals(command.fulfillmentType()))) { throw new BizException(ResultCode.BAD_REQUEST, "草稿预入库快照不完整"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java index f5a98f09a..e9071df30 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java @@ -17,7 +17,7 @@ public record InboundOrderSummary( Long customerId, String customerName, Long companyId, String companyName, Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName, - String businessType, String channelCode, String channelName, + String businessType, String fulfillmentType, String channelCode, String channelName, String taxMode, String pricingTaxMode, String containerMode, Boolean customsInspection, String destinationType, Long destinationWarehouseId, String destinationCode, String destinationName, Boolean holdFlag, String currentHoldReason, @@ -56,6 +56,7 @@ public record InboundOrderSummary( order.getCustomerId(), order.getCustomerName(), order.getCompanyId(), order.getCompanyName(), order.getWarehouseId(), order.getWarehouseName(), order.getDeliveryWarehouseId(), order.getDeliveryWarehouseName(), order.getBusinessType(), + order.getFulfillmentType(), order.getChannelCode(), order.getChannelName(), order.getTaxMode(), order.getPricingTaxMode(), order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(), order.getDestinationWarehouseId(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java index 92665d4bd..ece4cea5e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java @@ -33,7 +33,8 @@ public record OrderApprovedInboundCommand( String expressTrackingNo, Integer totalPackageCount, String pickupMethod, - LocalDateTime requestedPickupAt + LocalDateTime requestedPickupAt, + String fulfillmentType ) { public OrderApprovedInboundCommand( long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName, @@ -48,7 +49,7 @@ public record OrderApprovedInboundCommand( deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName, carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType, destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName, - expressTrackingNo, totalPackageCount, null, null); + expressTrackingNo, totalPackageCount, null, null, "STANDARD"); } /** 兼容尚未携带订单总箱数的历史事件。 */ public OrderApprovedInboundCommand( @@ -63,7 +64,7 @@ public record OrderApprovedInboundCommand( deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName, carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType, destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName, - expressTrackingNo, null, null, null); + expressTrackingNo, null, null, null, "STANDARD"); } /** 兼容未携带报价包税维度的历史调用方。 */ @@ -79,6 +80,6 @@ public record OrderApprovedInboundCommand( deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName, carrierId, carrierName, taxMode, null, containerMode, customsInspection, destinationType, destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName, - expressTrackingNo, null, null, null); + expressTrackingNo, null, null, null, "STANDARD"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java index 43290b179..75f165e1b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java @@ -38,6 +38,7 @@ public class InboundOrderEntity extends BaseEntity { private Long deliveryWarehouseId; private String deliveryWarehouseName; private String businessType; + private String fulfillmentType; private String channelCode; private String channelName; private Long carrierId; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java index 647c9c080..ed0190ff2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java @@ -38,7 +38,7 @@ public interface InboundSplitSettlementMapper { INSERT INTO fl_inbound_order (id,tenant_id,task_id,order_id,order_no,inbound_code,process_status, order_status_snapshot,measure_review_status,fee_status,fee_review_status,notification_status, expected_arrival_at,customer_id,customer_name,company_id,company_name,warehouse_id,warehouse_name, - delivery_warehouse_id,delivery_warehouse_name,business_type,channel_code,channel_name,carrier_id,carrier_name, + delivery_warehouse_id,delivery_warehouse_name,business_type,fulfillment_type,channel_code,channel_name,carrier_id,carrier_name, tax_mode,pricing_tax_mode,container_mode,customs_inspection,destination_type,destination_warehouse_id, destination_code,destination_name,hold_flag,current_hold_reason,total_package_count,package_count, actual_gross_weight_kg,chargeable_weight_kg,actual_volume_cbm,chargeable_volume_cbm,pallet_count,volume_weight_kg, @@ -48,7 +48,7 @@ public interface InboundSplitSettlementMapper { SELECT #{childId},tenant_id,task_id,#{childOrderId},#{childOrderNo},#{childInboundCode}, CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'WAIT_SPLIT' END,order_status_snapshot, 'APPROVED',fee_status,fee_review_status,notification_status,expected_arrival_at,customer_id,customer_name, - company_id,company_name,warehouse_id,warehouse_name,delivery_warehouse_id,delivery_warehouse_name,business_type, + company_id,company_name,warehouse_id,warehouse_name,delivery_warehouse_id,delivery_warehouse_name,business_type,fulfillment_type, channel_code,channel_name,carrier_id,carrier_name,tax_mode,pricing_tax_mode,container_mode,customs_inspection, destination_type,destination_warehouse_id,destination_code,destination_name,hold_flag,current_hold_reason, #{packageCount},#{packageCount}, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index c11e662f4..8675643a5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -19,7 +19,7 @@ public interface LoadingCompletedEventMapper { COALESCE(o.root_inbound_id, o.id) AS root_inbound_id, o.order_id AS source_order_id, o.order_no AS source_order_no, COALESCE(root_order.order_id, o.order_id) AS root_order_id, - o.inbound_code, o.business_type, o.destination_type, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code, + o.inbound_code, o.business_type, o.fulfillment_type, o.destination_type, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code, o.destination_name AS destination_warehouse_name, i.id AS loading_plan_item_id, i.inbound_detail_id AS source_inbound_detail_id, i.sku_code, i.product_name_zh, i.product_name_en, i.package_count, i.product_quantity, @@ -73,7 +73,7 @@ public interface LoadingCompletedEventMapper { private Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm; private String fbaTrackingNo; private String shipmentNo; private String shippingMark; - private String businessType; private String destinationType; + private String businessType; private String fulfillmentType; private String destinationType; private String customerRemark; private String internalRemark; private String warehouseRemark; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java index 193940b28..ac82be20b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java @@ -68,6 +68,7 @@ public class InboundOrderCreationService { inbound.setDeliveryWarehouseId(command.deliveryWarehouseId()); inbound.setDeliveryWarehouseName(command.deliveryWarehouseName()); inbound.setBusinessType(command.businessType()); + inbound.setFulfillmentType(fulfillmentType(command.fulfillmentType())); inbound.setChannelCode(command.channelCode()); inbound.setChannelName(command.channelName()); inbound.setCarrierId(command.carrierId()); @@ -160,6 +161,7 @@ public class InboundOrderCreationService { .set(InboundOrderEntity::getDeliveryWarehouseName, command.deliveryWarehouseName()) .set(InboundOrderEntity::getExpectedArrivalAt, command.expectedArrivalAt()) .set(InboundOrderEntity::getBusinessType, command.businessType()) + .set(InboundOrderEntity::getFulfillmentType, fulfillmentType(command.fulfillmentType())) .set(InboundOrderEntity::getChannelCode, command.channelCode()) .set(InboundOrderEntity::getChannelName, command.channelName()) .set(InboundOrderEntity::getDestinationType, command.destinationType()) @@ -181,6 +183,7 @@ public class InboundOrderCreationService { .set(InboundOrderEntity::getProcessStatus, "WAIT_MEASURE") .set(InboundOrderEntity::getOrderStatusSnapshot, "APPROVED") .set(InboundOrderEntity::getTotalPackageCount, command.totalPackageCount()) + .set(InboundOrderEntity::getFulfillmentType, fulfillmentType(command.fulfillmentType())) .set(InboundOrderEntity::getUpdatedAt, LocalDateTime.now()) .setSql("version = version + 1"); if (orders.update(null, update) != 1) { @@ -217,6 +220,7 @@ public class InboundOrderCreationService { inbound.setDeliveryWarehouseId(command.deliveryWarehouseId()); inbound.setDeliveryWarehouseName(command.deliveryWarehouseName()); inbound.setBusinessType(command.businessType()); + inbound.setFulfillmentType(fulfillmentType(command.fulfillmentType())); inbound.setChannelCode(command.channelCode()); inbound.setChannelName(command.channelName()); inbound.setCarrierId(command.carrierId()); @@ -241,6 +245,12 @@ public class InboundOrderCreationService { return inbound; } + private static String fulfillmentType(String value) { + if ("STANDARD".equals(value) || "ONE_CLICK_DROPSHIP".equals(value)) return value; + throw new BizException(ResultCode.BAD_REQUEST, + "履约类型必须为 STANDARD 或 ONE_CLICK_DROPSHIP"); + } + private void addHistory(long inboundId, long tenantId, String from, String to, String action, LocalDateTime now) { InboundStatusHistoryEntity history = new InboundStatusHistoryEntity(); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index f5879a22c..d2c706779 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -91,8 +91,9 @@ public class LoadingCompletedOutboxService { row.getCustomerRemark(), row.getInternalRemark(), row.getWarehouseRemark(), null, cartonMarks.getOrDefault(row.getLoadingPlanItemId(), List.of()))) .toList(); - String orderType = dropship(first.getBusinessType()) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG"; - List pallets = dropship(first.getBusinessType()) + String fulfillmentType = fulfillmentType(sorted); + String orderType = dropship(fulfillmentType) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG"; + List pallets = dropship(fulfillmentType) ? palletSnapshots(sorted, palletRows) : List.of(); if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) { throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照"); @@ -110,7 +111,7 @@ public class LoadingCompletedOutboxService { /** 国内按实装箱数从可用标签池稳定随机抽取箱唛,重复发布同一计划时结果不变。 */ private Map> cartonLabelPools(List rows) { Map> result = new java.util.LinkedHashMap<>(); - Map> byInbound = rows.stream().filter(row -> !dropship(row.getBusinessType())) + Map> byInbound = rows.stream().filter(row -> !dropship(row.getFulfillmentType())) .filter(row -> "OVERSEAS".equals(destinationType(List.of(row)))) .collect(java.util.stream.Collectors.groupingBy( row -> new LabelPoolKey(row.getAccountId(), row.getSourceInboundId()), @@ -168,6 +169,18 @@ public class LoadingCompletedOutboxService { return values.iterator().next(); } + private String fulfillmentType(List rows) { + Set values = rows.stream().map(Row::getFulfillmentType) + .map(value -> value == null || value.isBlank() ? "STANDARD" : value) + .collect(java.util.stream.Collectors.toSet()); + if (values.size() != 1 + || (!values.contains("STANDARD") && !values.contains("ONE_CLICK_DROPSHIP"))) { + throw new BizException(ResultCode.CONFLICT, + "装柜入仓单履约类型必须唯一且为 STANDARD 或 ONE_CLICK_DROPSHIP"); + } + return values.iterator().next(); + } + private record LabelPoolKey(long accountId, long inboundId) { } private List palletSnapshots(List values, List source) { @@ -223,8 +236,7 @@ public class LoadingCompletedOutboxService { } private static boolean dropship(String value) { - return value != null && ("ONE_CLICK_DROPSHIP".equalsIgnoreCase(value) - || value.toUpperCase(java.util.Locale.ROOT).contains("DROPSHIP")); + return "ONE_CLICK_DROPSHIP".equals(value); } private record DetailSnapshot(Long detailId, String skuCode, String productName, diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index df2b3f30c..902aede82 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -27,6 +27,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( delivery_warehouse_id BIGINT UNSIGNED NULL COMMENT '交货仓逻辑ID', delivery_warehouse_name VARCHAR(128) NULL COMMENT '交货仓名称快照', business_type VARCHAR(32) NOT NULL COMMENT '业务类型', + fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发', channel_code VARCHAR(64) NULL COMMENT '渠道编码', channel_name VARCHAR(128) NULL COMMENT '渠道名称快照', carrier_id BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID', @@ -77,6 +78,26 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id) ) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照'; +SET @inbound_fulfillment_type_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND column_name = 'fulfillment_type' + ), + 'SELECT 1', + 'ALTER TABLE fl_inbound_order ADD COLUMN fulfillment_type VARCHAR(32) NULL COMMENT ''履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'' AFTER business_type' +); +PREPARE inbound_fulfillment_type_stmt FROM @inbound_fulfillment_type_sql; +EXECUTE inbound_fulfillment_type_stmt; +DEALLOCATE PREPARE inbound_fulfillment_type_stmt; + +UPDATE fl_inbound_order + SET fulfillment_type = 'STANDARD' + WHERE fulfillment_type IS NULL OR fulfillment_type = ''; +ALTER TABLE fl_inbound_order + MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' + COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'; + -- 既有开发库若存在跨客户重复码则严格失败,禁止自动覆盖或改写入仓码。 SET @inbound_global_code_index_sql = IF( NOT EXISTS( diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index e893811e8..f38dfe72d 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -1122,6 +1122,7 @@ components: deliveryWarehouseId: {type: integer, format: int64, nullable: true} deliveryWarehouseName: {type: string, nullable: true} businessType: {type: string, nullable: true} + fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], nullable: true} channelCode: {type: string, nullable: true} channelName: {type: string, nullable: true} taxMode: {type: string, nullable: true} @@ -1466,7 +1467,7 @@ components: adjustmentReason: {type: string, minLength: 1, maxLength: 500, nullable: true} OrderApprovedInboundCommand: type: object - required: [tenantId, orderId, inboundCode, customerName, warehouseId] + required: [tenantId, orderId, inboundCode, customerName, warehouseId, fulfillmentType] properties: tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: 当前客户隔离 ID} orderId: {type: string, pattern: '^[1-9][0-9]*$'} @@ -1480,6 +1481,7 @@ components: deliveryWarehouseName: {type: string, nullable: true} expectedArrivalAt: {type: string, format: date-time, nullable: true} businessType: {type: string, nullable: true} + fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], description: 独立履约类型;不得复用计费 businessType} channelCode: {type: string, nullable: true} channelName: {type: string, nullable: true} carrierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 历史兼容的内部运输供应商快照} @@ -1810,6 +1812,7 @@ components: deliveryWarehouseId: {type: string, nullable: true} deliveryWarehouseName: {type: string, nullable: true} businessType: {type: string, nullable: true} + fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], nullable: true} channelCode: {type: string, nullable: true} channelName: {type: string, nullable: true} containerMode: {type: string, nullable: true} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java index d18b7a6e3..8665093a8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java @@ -37,12 +37,17 @@ class UnifiedInboundOpenApiContractTest { assertThat(stringMap(inbound.get("properties"))).containsKeys( "id", "tenantId", "orderId", "inboundCode", "processStatus", "version", "customerId", "warehouseId", "deliveryWarehouseId", "totalPackageCount", - "businessType", "channelCode", "pricingTaxMode", "containerMode", "destinationType", + "businessType", "fulfillmentType", "channelCode", "pricingTaxMode", "containerMode", "destinationType", "holdFlag", "actualGrossWeightKg", "actualNetWeightKg", "actualVolumeCbm", "volumeWeightKg", "chargeableWeightKg", "chargeableVolumeCbm", "palletCount", "inboundedAt", "requestedPickupAt", "expressCompanyCode", "expressCompanyName", "containerNo", "cabinetNo", "estimatedSailingAt", "actualSailingAt", "estimatedPortArrivalAt", "actualPortArrivalAt", "createdByName", "createdAt", "updatedAt"); + + Map approval = stringMap(schemas.get("OrderApprovedInboundCommand")); + assertThat(stringList(approval.get("required"))).contains("fulfillmentType"); + Map fulfillment = stringMap(stringMap(approval.get("properties")).get("fulfillmentType")); + assertThat(stringList(fulfillment.get("enum"))).containsExactly("STANDARD", "ONE_CLICK_DROPSHIP"); } @SuppressWarnings("unchecked") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FulfillmentTypeSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FulfillmentTypeSqlContractTest.java new file mode 100644 index 000000000..ca27c12fb --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FulfillmentTypeSqlContractTest.java @@ -0,0 +1,40 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class FulfillmentTypeSqlContractTest { + @Test + void baselinesAndUnifiedMigrationPersistIndependentFulfillmentType() throws Exception { + String order = Files.readString(resolve("order/src/main/resources/db/order.sql"), StandardCharsets.UTF_8); + String inbound = Files.readString(resolve("firstleg/src/main/resources/db/phase2-inbound-schema.sql"), StandardCharsets.UTF_8); + String unified = Files.readString(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8); + + assertFulfillmentMigration(order, "baiyue_order.ord_order"); + assertFulfillmentMigration(inbound, "fl_inbound_order"); + assertFulfillmentMigration(unified, "baiyue_order.ord_order"); + assertFulfillmentMigration(unified, "fl_inbound_order"); + assertThat(unified) + .contains("unified_assert_column('baiyue_order', 'ord_order', 'fulfillment_type')") + .contains("unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'fulfillment_type')"); + } + + private static void assertFulfillmentMigration(String sql, String table) { + assertThat(sql) + .contains("fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD'") + .contains("ADD COLUMN fulfillment_type VARCHAR(32) NULL") + .contains("SET fulfillment_type = 'STANDARD'") + .contains("MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD'") + .contains(table); + } + + private static Path resolve(String path) { + Path direct = Path.of(path); + return Files.exists(direct) ? direct : Path.of("..").resolve(path).normalize(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java index 5d0c3b22a..636087f87 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java @@ -100,6 +100,7 @@ class PdaSplitSettlementSqlContractTest { assertThat(clone) .contains("#{childOrderId},#{childOrderNo}") .contains("CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'WAIT_SPLIT' END") + .contains("business_type,fulfillment_type") .contains("actual_gross_weight_kg * #{packageCount}") .contains("actual_net_weight_kg * #{packageCount}"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java index 9f6c94123..a9583fa1b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java @@ -72,11 +72,35 @@ class InboundOrderCreationServiceTest { assertThat(inboundCaptor.getValue().getCarrierId()).isNull(); assertThat(inboundCaptor.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION"); assertThat(inboundCaptor.getValue().getPricingTaxMode()).isEqualTo("TAX_EXCLUDED"); + assertThat(inboundCaptor.getValue().getFulfillmentType()).isEqualTo("STANDARD"); assertThat(inboundCaptor.getValue().getTotalPackageCount()).isEqualTo(4); assertThat(createdId).isEqualTo(inboundCaptor.getValue().getId()); assertThat(replayedId).isEqualTo(900L); } + @Test + void persistsDropshipFulfillmentTypeWithoutChangingPricingBusinessType() { + OrderApprovedInboundCommand base = approvedCommand("PCHK2608140001"); + OrderApprovedInboundCommand command = new OrderApprovedInboundCommand( + base.tenantId(), base.orderId(), base.orderNo(), base.inboundCode(), base.customerId(), + base.customerName(), base.warehouseId(), base.warehouseName(), base.deliveryWarehouseId(), + base.deliveryWarehouseName(), base.expectedArrivalAt(), base.businessType(), base.channelCode(), + base.channelName(), base.carrierId(), base.carrierName(), base.taxMode(), base.pricingTaxMode(), + base.containerMode(), base.customsInspection(), base.destinationType(), base.destinationWarehouseId(), + base.destinationCode(), base.destinationName(), base.publicRemark(), base.expressCompanyName(), + base.expressTrackingNo(), base.totalPackageCount(), base.pickupMethod(), base.requestedPickupAt(), + "ONE_CLICK_DROPSHIP"); + when(orders.selectOne(any(Wrapper.class))).thenReturn(null); + when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); + + service.createFromApprovedOrder(command); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOrderEntity.class); + verify(orders).insert(captor.capture()); + assertThat(captor.getValue().getBusinessType()).isEqualTo("FBA"); + assertThat(captor.getValue().getFulfillmentType()).isEqualTo("ONE_CLICK_DROPSHIP"); + } + @Test void shouldRejectReplayWithDifferentInboundCode() { InboundOrderEntity existing = inbound(900L, "RK-OLD", "UNPRICED", null, null); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 90ea941cb..461aca213 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -146,9 +146,9 @@ class LoadingCompletedOutboxServiceTest { } @Test void publishesDropshipPalletSkuAndCartonSnapshots() throws Exception { - Row first = row(11, 100, 101); first.setBusinessType("ONE_CLICK_DROPSHIP"); + Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP"); first.setPackageCount(2); first.setProductQuantity(20); first.setSkuCode("SKU-A"); - Row second = row(11, 100, 102); second.setBusinessType("ONE_CLICK_DROPSHIP"); + Row second = row(11, 100, 102); second.setFulfillmentType("ONE_CLICK_DROPSHIP"); second.setPackageCount(1); second.setProductQuantity(5); second.setSkuCode("SKU-B"); when(snapshots.rows(90L)).thenReturn(List.of(first, second)); when(snapshots.palletRows(90L)).thenReturn(List.of( @@ -168,6 +168,35 @@ class LoadingCompletedOutboxServiceTest { assertThat(event.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2); } + @Test + void pricingBusinessTypeCannotTurnAStandardOrderIntoDropship() throws Exception { + Row value = row(11, 100, 101); + value.setBusinessType("ONE_CLICK_DROPSHIP"); + value.setFulfillmentType("STANDARD"); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + + service.publish(90L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox).insert(captor.capture()); + EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class); + assertThat(event.payload().at("/orders/0/orderType").asText()).isEqualTo("FIRST_LEG"); + assertThat(event.payload().at("/orders/0/pallets")).isEmpty(); + } + + @Test + void rejectsMixedFulfillmentTypesWithinOneInbound() { + Row first = row(11, 100, 101); + Row second = row(11, 100, 102); + second.setFulfillmentType("ONE_CLICK_DROPSHIP"); + when(snapshots.rows(90L)).thenReturn(List.of(first, second)); + + assertThatThrownBy(() -> service.publish(90L)) + .hasMessageContaining("履约类型必须唯一"); + verify(outbox, org.mockito.Mockito.never()).insert( + org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class)); + } + private Row row(long account,long inbound,long detail) { Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L); r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L); @@ -176,6 +205,7 @@ class LoadingCompletedOutboxServiceTest { r.setInboundCode("RK"+inbound); r.setLoadingPlanItemId(detail); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8"); r.setDestinationWarehouseName("ONT8"); r.setSourceInboundDetailId(detail); r.setSkuCode("SKU"+detail); r.setDestinationType("OVERSEAS"); + r.setFulfillmentType("STANDARD"); r.setPackageCount(1); r.setProductQuantity(10); r.setPalletCount(1); r.setGrossWeightKg(BigDecimal.ONE); r.setNetWeightKg(BigDecimal.ONE); r.setVolumeCbm(BigDecimal.ONE); return r; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java index df13829c5..e593f1aa2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java @@ -58,8 +58,8 @@ class TaillegRealProducerAcceptanceTest { BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓", "40HQ"); jdbc.update("INSERT IGNORE INTO fl_loading_plan(id,plan_no,container_booking_id,plan_status,created_by," + "updated_by,version) VALUES(?,?,?,'LOADING',1,1,0)", PLAN, MARKER + "-PLAN", BOOKING); - seedInbound(FIRST_INBOUND, 990022221L, MARKER + "-FIRST-IN", "FBA_SEA", "LOADED", "ONT8"); - seedInbound(DROPSHIP_INBOUND, 990022222L, MARKER + "-DROP-IN", "ONE_CLICK_DROPSHIP", "LOADED", "LAX9"); + seedInbound(FIRST_INBOUND, 990022221L, MARKER + "-FIRST-IN", "FBA_SEA", "STANDARD", "LOADED", "ONT8"); + seedInbound(DROPSHIP_INBOUND, 990022222L, MARKER + "-DROP-IN", "FBA_SEA", "ONE_CLICK_DROPSHIP", "LOADED", "LAX9"); seedItem(990022321L, FIRST_INBOUND, 990022421L, 990022221L, MARKER + "-FIRST-IN", "SKU-REAL-A", 3, "ONT8"); seedItem(990022322L, DROPSHIP_INBOUND, 990022422L, 990022222L, MARKER + "-DROP-IN", "SKU-REAL-D", 2, "LAX9"); for (int i = 1; i <= 3; i++) seedLabel(990022540L + i, FIRST_INBOUND, 990022221L, 990022421L, @@ -86,13 +86,15 @@ class TaillegRealProducerAcceptanceTest { id, 990022901L, planItemId, inboundId, detailId, packages, packages); } - private void seedInbound(long id, long orderId, String code, String type, String status, String destination) { + private void seedInbound(long id, long orderId, String code, String businessType, String fulfillmentType, + String status, String destination) { jdbc.update("INSERT IGNORE INTO fl_inbound_order(id,tenant_id,order_id,order_no,inbound_code,process_status," + - "order_status_snapshot,customer_name,warehouse_id,warehouse_name,business_type,destination_type,destination_warehouse_id," + + "order_status_snapshot,customer_name,warehouse_id,warehouse_name,business_type,fulfillment_type,destination_type,destination_warehouse_id," + "destination_code,destination_name,package_count,actual_volume_cbm,created_by,updated_by) " + - "VALUES(?,?,?,?,?,?, 'IN_TRANSIT',?,92,'真实验收海外仓',?,'FBA',9001,?,?,?,0.4000,1,1)", - id, CUSTOMER, orderId, MARKER + "-ORDER-" + id, code, status, "真实验收客户", type, - destination, "FBA " + destination, type.equals("ONE_CLICK_DROPSHIP") ? 2 : 3); + "VALUES(?,?,?,?,?,?, 'IN_TRANSIT',?,92,'真实验收海外仓',?,?,'FBA',9001,?,?,?,0.4000,1,1)", + id, CUSTOMER, orderId, MARKER + "-ORDER-" + id, code, status, "真实验收客户", businessType, + fulfillmentType, destination, "FBA " + destination, + fulfillmentType.equals("ONE_CLICK_DROPSHIP") ? 2 : 3); } private void seedItem(long id, long inboundId, long detailId, long orderId, String inboundCode, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index c0709152b..bbb89c9ce 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -210,9 +210,11 @@ public class LoadingCompletedConsumer { LastlegOrderStatusHistory history=new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId()); history.setRemark("头程装柜完成自动生成"); history.setCreatedAt(LocalDateTime.now()); historyMapper.insert(history); - if ("FIRST_LEG".equals(orderType)) { + if ("FIRST_LEG".equals(orderType) || "ONE_CLICK_DROPSHIP".equals(orderType)) { InboundPlanResponse plan = inboundPlans.convert(customerId, order.getId()); - registerInTransit(event, payload, source, order, plan.planNo()); + if ("FIRST_LEG".equals(orderType)) { + registerInTransit(event, payload, source, order, plan.planNo()); + } } if ("ONE_CLICK_DROPSHIP".equals(orderType)) { long warehouseId; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index dc66ad190..2646b549d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -14,7 +14,7 @@ class LoadingCompletedConsumerTest { assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id")); } @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } - @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } + @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inboundPlans).convert(eq("100"),anyLong()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } @Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){ var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2")); var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3")); diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java index de4b011fb..e61647e36 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java @@ -45,7 +45,7 @@ public interface FirstlegInboundClient { Boolean customsInspection, String destinationType, Long destinationWarehouseId, String destinationCode, String destinationName, String publicRemark, String expressCompanyName, String expressTrackingNo, Integer totalPackageCount, - String pickupMethod, LocalDateTime requestedPickupAt + String pickupMethod, LocalDateTime requestedPickupAt, String fulfillmentType ) { } } diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java index 28db86533..6786d6578 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java @@ -73,8 +73,28 @@ public final class OrderRequests { @Size(max = 64) String shipmentId, @Size(max = 64) String referenceId, @Schema(description = "旧系统入仓码;仅全局 LEGACY_MANUAL 模式接受,AUTO 模式必须留空", example = "NYEXX2608140001", maxLength = 40) - @Size(max = 40) @Pattern(regexp = "[A-Za-z0-9._:-]+") String inboundCode + @Size(max = 40) @Pattern(regexp = "[A-Za-z0-9._:-]+") String inboundCode, + @Schema(description = "Fulfillment type; omitted means STANDARD / 履约类型;省略时为普通头程", allowableValues = {"STANDARD", "ONE_CLICK_DROPSHIP"}, defaultValue = "STANDARD") + @Pattern(regexp = "STANDARD|ONE_CLICK_DROPSHIP") String fulfillmentType ) { + public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm, + String destinationCountryCode, Long destinationWarehouseId, + String destinationWarehouseName, LocalDate expectedShipDate, String customerRemark, + Party sender, Party receiver, List cargoes, String pickupMethod, + LocalDateTime requestedPickupAt, Long receivingWarehouseId, + String receivingWarehouseName, String channelCode, String channelName, + Long carrierId, String carrierName, String taxMode, String pricingTaxMode, + String containerMode, Boolean customsInspection, String destinationType, + String amazonWarehouseCode, String shipmentId, String referenceId, + String inboundCode) { + this(customerId, customerName, transportMode, tradeTerm, destinationCountryCode, + destinationWarehouseId, destinationWarehouseName, expectedShipDate, customerRemark, + sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId, + receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode, + pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode, + shipmentId, referenceId, inboundCode, null); + } + public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm, String destinationCountryCode, Long destinationWarehouseId, String destinationWarehouseName, LocalDate expectedShipDate, String customerRemark, @@ -89,7 +109,7 @@ public final class OrderRequests { sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId, receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode, - shipmentId, referenceId, null); + shipmentId, referenceId, null, null); } public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm, @@ -106,7 +126,7 @@ public final class OrderRequests { sender, receiver, cargoes, pickupMethod, null, receivingWarehouseId, receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode, - shipmentId, referenceId, null); + shipmentId, referenceId, null, null); } @AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空") diff --git a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java index 8a1e97d44..0c8fceec5 100644 --- a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java +++ b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java @@ -47,6 +47,7 @@ public class OrderEntity extends BaseEntity { private String containerMode; private Boolean customsInspection; private String destinationType; + private String fulfillmentType; private String amazonWarehouseCode; private String shipmentId; private String referenceId; diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 832ad9e86..9f2f9f841 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -462,6 +462,7 @@ public class OrderApplicationService { order.setPricingTaxMode(request.pricingTaxMode()); order.setContainerMode(request.containerMode()); order.setCustomsInspection(request.customsInspection()); order.setDestinationType(request.destinationType()); + order.setFulfillmentType(fulfillmentType(request.fulfillmentType())); order.setAmazonWarehouseCode("FBA".equals(request.destinationType()) && snapshots.destination() != null ? snapshots.destination().warehouse().warehouseCode() : request.amazonWarehouseCode()); order.setShipmentId(request.shipmentId()); order.setReferenceId(request.referenceId()); @@ -486,6 +487,13 @@ public class OrderApplicationService { } order.setInboundCode(normalized); } + + private static String fulfillmentType(String value) { + if (value == null) return "STANDARD"; + if ("STANDARD".equals(value) || "ONE_CLICK_DROPSHIP".equals(value)) return value; + throw new BizException(ResultCode.VALIDATION_FAILED, + "履约类型必须为 STANDARD 或 ONE_CLICK_DROPSHIP"); + } private boolean legacyManualMode() { return inboundCodeModeProvider.legacyManual(); } @@ -599,7 +607,8 @@ public class OrderApplicationService { order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(), order.getDestinationWarehouseId(), order.getAmazonWarehouseCode(), order.getDestinationWarehouseName(), order.getCustomerRemark(), order.getExpressCompany(), order.getExpressTrackingNo(), totalPackageCount, - order.getPickupMethod(), order.getRequestedPickupAt()); + order.getPickupMethod(), order.getRequestedPickupAt(), + order.getFulfillmentType() == null ? "STANDARD" : order.getFulfillmentType()); } private void prepareDraftInbound(OrderEntity order, OrderRequests.SaveRequest request) { FirstlegInboundClient client = firstlegProvider == null ? null : firstlegProvider.getIfAvailable(); diff --git a/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java index 524868b10..e5fffe608 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java @@ -141,6 +141,7 @@ public class OrderFulfillmentSplitService { child.setDestinationWarehouseName(root.getDestinationWarehouseName()); child.setReceivingWarehouseId(root.getReceivingWarehouseId()); child.setReceivingWarehouseName(root.getReceivingWarehouseName()); child.setChannelCode(root.getChannelCode()); child.setChannelName(root.getChannelName()); child.setDestinationType(root.getDestinationType()); + child.setFulfillmentType(root.getFulfillmentType()); child.setInboundCode(remainderInboundCode(root, sequence)); child.setCreatedBy(operatorId); child.setUpdatedBy(operatorId); if (orders.insert(child) != 1) throw new BizException(ResultCode.CONFLICT, "余量子订单创建失败"); diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index d61276e08..27d732b9a 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -35,6 +35,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( container_mode VARCHAR(24) NULL COMMENT '整柜或散货', customs_inspection TINYINT(1) NULL COMMENT '是否商检', destination_type VARCHAR(24) NULL COMMENT '目的仓类型:FBA亚马逊仓、OVERSEAS海外仓、PRIVATE私人地址', + fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发', amazon_warehouse_code VARCHAR(32) NULL COMMENT '亚马逊仓库编码', shipment_id VARCHAR(64) NULL COMMENT '订单级Amazon Shipment ID', reference_id VARCHAR(64) NULL COMMENT '订单级Amazon Reference ID', @@ -61,6 +62,26 @@ CREATE TABLE IF NOT EXISTS ord_order ( KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) ) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态'; +SET @order_fulfillment_type_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order' + AND column_name = 'fulfillment_type' + ), + 'SELECT 1', + 'ALTER TABLE baiyue_order.ord_order ADD COLUMN fulfillment_type VARCHAR(32) NULL COMMENT ''履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'' AFTER destination_type' +); +PREPARE order_fulfillment_type_stmt FROM @order_fulfillment_type_sql; +EXECUTE order_fulfillment_type_stmt; +DEALLOCATE PREPARE order_fulfillment_type_stmt; + +UPDATE baiyue_order.ord_order + SET fulfillment_type = 'STANDARD' + WHERE fulfillment_type IS NULL OR fulfillment_type = ''; +ALTER TABLE baiyue_order.ord_order + MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' + COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'; + -- 既有开发库必须先确认不存在跨客户重复码;若存在则在此处失败,不自动改写业务数据。 SET @order_global_inbound_index_sql = IF( NOT EXISTS( diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 9891be3ce..b015fdb8b 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -646,6 +646,11 @@ components: type: object required: [pricingTaxMode, cargoes] properties: + fulfillmentType: + type: string + enum: [STANDARD, ONE_CLICK_DROPSHIP] + default: STANDARD + description: 独立履约类型;省略为普通头程,不复用业务/计费 businessType inboundCode: type: string maxLength: 40 @@ -657,7 +662,8 @@ components: minItems: 1 items: {$ref: '#/components/schemas/FirstLegOrderCargo'} description: >- - 订单保存请求中的完整字段以服务端 FirstLegOrderSaveRequest 为准;pricingTaxMode + 订单保存请求中的完整字段以服务端 FirstLegOrderSaveRequest 为准;fulfillmentType + 省略时固定为 STANDARD,一键代发必须显式传 ONE_CLICK_DROPSHIP,且不复用计费 businessType;pricingTaxMode 是新建和编辑订单的必填报价维度;customerId 不接受客户端指定,服务端始终使用认证企业 ID; 发货单位和收货单位仅通过 sender/receiver 保存业务快照。历史存量订单的响应字段仍允许为空。 InboundCodePolicy: diff --git a/order/src/test/java/com/baiyue/order/controller/MerchantBatchDraftOpenApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/MerchantBatchDraftOpenApiContractTest.java index 7329bc794..e685ef60d 100644 --- a/order/src/test/java/com/baiyue/order/controller/MerchantBatchDraftOpenApiContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/MerchantBatchDraftOpenApiContractTest.java @@ -37,6 +37,9 @@ class MerchantBatchDraftOpenApiContractTest { .contains("merchant:order:create") .contains("FirstLegBatchDraftResponse") .contains("FirstLegBatchDraftResult") + .contains("fulfillmentType:") + .contains("enum: [STANDARD, ONE_CLICK_DROPSHIP]") + .contains("default: STANDARD") .contains("inboundCode:") .contains("errorCode:") .contains("errorMessage:"); diff --git a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java index e269eacce..f58d48fe2 100644 --- a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java +++ b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java @@ -54,6 +54,25 @@ class OrderRequestsValidationTest { assertThat(validator.validate(draft)).isEmpty(); } + @Test + void fulfillmentTypeIsOptionalButRejectsPricingBusinessValues() { + var standard = request(null, null, "TAX_INCLUDED"); + assertThat(validator.validate(standard)).isEmpty(); + + var invalid = new OrderRequests.SaveRequest( + standard.customerId(), standard.customerName(), standard.transportMode(), standard.tradeTerm(), + standard.destinationCountryCode(), standard.destinationWarehouseId(), standard.destinationWarehouseName(), + standard.expectedShipDate(), standard.customerRemark(), standard.sender(), standard.receiver(), + standard.cargoes(), standard.pickupMethod(), standard.requestedPickupAt(), standard.receivingWarehouseId(), + standard.receivingWarehouseName(), standard.channelCode(), standard.channelName(), standard.carrierId(), + standard.carrierName(), standard.taxMode(), standard.pricingTaxMode(), standard.containerMode(), + standard.customsInspection(), standard.destinationType(), standard.amazonWarehouseCode(), + standard.shipmentId(), standard.referenceId(), standard.inboundCode(), "FBA_SEA"); + + assertThat(validator.validate(invalid)) + .anyMatch(violation -> violation.getPropertyPath().toString().equals("fulfillmentType")); + } + @Test void validatesTheManuallyEnteredInboundCodeForSingleAndBatchCallers() { var source = request(null, null, "TAX_INCLUDED"); diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 636f7649a..be1b0dce3 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -60,7 +60,7 @@ class OrderApplicationServiceStateTest { assertThat(orderId).isNotNull(); verify(eligibility, never()).requireApproved(anyLong()); verify(repository).insertOrder(argThat(order -> order.getStatus() == OrderStatus.DRAFT - && order.getInboundCode() == null)); + && order.getInboundCode() == null && "STANDARD".equals(order.getFulfillmentType()))); } @Test @@ -372,6 +372,31 @@ class OrderApplicationServiceStateTest { assertThat(command.taxMode()).isEqualTo("BUY_DECLARATION"); assertThat(command.pricingTaxMode()).isEqualTo("TAX_EXCLUDED"); assertThat(command.totalPackageCount()).isEqualTo(4); + assertThat(command.fulfillmentType()).isEqualTo("STANDARD"); + } + + @Test + void explicitDropshipFulfillmentTypeIsPersistedAndSentIndependentlyFromPricingBusinessType() { + OrderRepository repository = mock(OrderRepository.class); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + OrderRequests.SaveRequest source = request(); + OrderRequests.SaveRequest dropship = new OrderRequests.SaveRequest( + source.customerId(), source.customerName(), source.transportMode(), source.tradeTerm(), + source.destinationCountryCode(), source.destinationWarehouseId(), source.destinationWarehouseName(), + source.expectedShipDate(), source.customerRemark(), source.sender(), source.receiver(), source.cargoes(), + source.pickupMethod(), source.requestedPickupAt(), source.receivingWarehouseId(), + source.receivingWarehouseName(), source.channelCode(), source.channelName(), source.carrierId(), + source.carrierName(), source.taxMode(), source.pricingTaxMode(), source.containerMode(), + source.customsInspection(), source.destinationType(), source.amazonWarehouseCode(), source.shipmentId(), + source.referenceId(), source.inboundCode(), "ONE_CLICK_DROPSHIP"); + when(masterData.validate(10001L, dropship)).thenReturn(snapshots()); + when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1); + + service(repository, mock(OrderEligibilityGuard.class), masterData) + .createDraft(10001L, 2L, dropship, "dropship-fulfillment"); + + verify(repository).insertOrder(argThat(order -> "ONE_CLICK_DROPSHIP".equals(order.getFulfillmentType()) + && "OVERSEAS".equals(order.getDestinationType()))); } @Test diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index d230f0a72..8f0951ff7 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -921,10 +921,10 @@ export class AcceptanceRun { } } - async runPhase4ProducerVariant(label, destinationType) { + async runPhase4ProducerVariant(label, destinationType, fulfillmentType = 'STANDARD') { const matrixMarker = this.marker const variantMarker = `${matrixMarker}-P4-${label}` - const contextKeys = ['orderDestinationType', 'phase4ProducerMatrixMarker'] + const contextKeys = ['orderDestinationType', 'orderFulfillmentType', 'phase4ProducerMatrixMarker'] const createdKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking'] const contextSnapshot = new Map(contextKeys.map((key) => [key, { present: Object.hasOwn(this.context, key), value: this.context[key], @@ -935,6 +935,7 @@ export class AcceptanceRun { let restoreFixture = null this.marker = variantMarker this.context.orderDestinationType = destinationType + this.context.orderFulfillmentType = fulfillmentType this.context.phase4ProducerMatrixMarker = matrixMarker try { restoreFixture = await this.preparePhase3LoadingFixture() @@ -958,19 +959,22 @@ export class AcceptanceRun { async ensurePhase4PdaProducerMatrix() { const readiness = () => this.createdResources.phase4InboundProducerFixtures?.readiness || {} - if (Number(readiness().fbaContainerCount || 0) < 1) { - await this.runPhase4ProducerVariant('FBA-PRIMARY', 'FBA') + if (Number(readiness().dropshipContainerCount || 0) < 1) { + await this.runPhase4ProducerVariant('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP') + } + if (Number(readiness().fbaContainerCount || 0) < 2) { + await this.runPhase4ProducerVariant('FBA-STANDARD', 'FBA') } if (Number(readiness().overseasContainerCount || 0) < 1) { await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS') } - if (Number(readiness().distinctContainerCount || 0) < 3) { + if (Number(readiness().distinctContainerCount || 0) < 4) { await this.runPhase4ProducerVariant('SECONDARY', 'FBA') } const matrix = this.createdResources.phase4InboundProducerFixtures this.assert('phase4-overseas-pda-fixture', '本轮三期跨区生产矩阵已就绪', null, matrix?.marker === this.marker && matrix?.readiness?.readyForPhase4Pda === true, - `marker=${matrix?.marker || 'missing'}, FBA=${matrix?.readiness?.fbaContainerCount ?? 0}, OVERSEAS=${matrix?.readiness?.overseasContainerCount ?? 0}, containers=${matrix?.readiness?.distinctContainerCount ?? 0}`, + `marker=${matrix?.marker || 'missing'}, FBA=${matrix?.readiness?.fbaContainerCount ?? 0}, OVERSEAS=${matrix?.readiness?.overseasContainerCount ?? 0}, DROPSHIP=${matrix?.readiness?.dropshipContainerCount ?? 0}, containers=${matrix?.readiness?.distinctContainerCount ?? 0}`, { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) } diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index ebce176a2..186aae83b 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -250,6 +250,7 @@ function buildPayload(context, profile, master, productImageFileId) { const referenceId = `REF-${context.marker}`.slice(0, 64) const companyName = profile.tenantName || `订单验收客户-${context.marker}` return { + fulfillmentType: String(context.context?.orderFulfillmentType || 'STANDARD').trim().toUpperCase(), customerId: master.partner?.id ?? null, customerName: master.partner?.partnerName ?? companyName, transportMode: 'SEA', tradeTerm: 'FOB', @@ -397,6 +398,7 @@ export async function run(context) { if (!orderId) throw new Error('Client 订单创建失败,后续 Admin 链路停止') context.createdResources.orders = { marker: context.marker, tenantId, orderId, productImageFileId, + fulfillmentType: payload.fulfillmentType, boxTypes: BOXES.map((box) => box.boxType), inboundId: null, receivingWarehouseId: id(master.receiving.id), destinationType: master.destinationType, diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index aa7bc88c8..345454daa 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -35,6 +35,7 @@ export function collectPhase4InboundProducerFixture(previous, producer) { destinationType, containers.filter((item) => item.destinationType === destinationType), ])) + const dropshipContainers = containers.filter((item) => item.fulfillmentType === 'ONE_CLICK_DROPSHIP') const distinctContainerCount = new Set(containers.map((item) => item.containerNo).filter(Boolean)).size return { marker: producer.marker, @@ -43,12 +44,15 @@ export function collectPhase4InboundProducerFixture(previous, producer) { readiness: { fbaContainerCount: byDestinationType.FBA.length, overseasContainerCount: byDestinationType.OVERSEAS.length, + dropshipContainerCount: dropshipContainers.length, distinctContainerCount, - readyForPhase4Pda: byDestinationType.FBA.length >= 1 + readyForPhase4Pda: byDestinationType.FBA.length >= 2 && byDestinationType.OVERSEAS.length >= 1 - && distinctContainerCount >= 3, + && dropshipContainers.length >= 1 + && distinctContainerCount >= 4, }, requiredEntryOrchestration: [ + 'DROPSHIP-FBA: public order fulfillmentType -> inbound-measurement -> phase3-loading -> phase3-loading-finalize -> overseas PDA', 'FBA: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', 'OVERSEAS-PRIMARY: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', 'SECONDARY(FBA|OVERSEAS): orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', @@ -367,6 +371,7 @@ export async function run(context) { sourceInboundId: inboundId, cartonMark: fixture.cartonMark || null, cartonMarks: Array.isArray(fixture.cartonMarks) ? fixture.cartonMarks : [], + fulfillmentType: fixture.fulfillmentType || 'STANDARD', destinationType: fixture.destinationType || null, destinationWarehouseId: id(fixture.destinationWarehouseId), destinationWarehouseCode: fixture.destinationWarehouseCode || null, diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 38c2da961..f1cf90df1 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1215,6 +1215,7 @@ export async function run(context) { cartonMark: source?.boxCode || source?.cartonMark || null, cartonMarks: [...new Set((stagedTask?.items || []).flatMap((item) => item?.boxCodes || []) .map((value) => String(value || '').trim()).filter(Boolean))], + fulfillmentType: source?.fulfillmentType || 'STANDARD', destinationType: initial?.summary?.destinationType || source?.destinationType || null, destinationWarehouseId: id(initial?.summary?.destinationWarehouseId || source?.destinationWarehouseId), destinationWarehouseCode: initial?.summary?.destinationCode || source?.destinationWarehouseCode || null, diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index 25c535b2a..9a7234e26 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -17,25 +17,32 @@ test('order fixture defaults to FBA and accepts only the explicit OVERSEAS varia const orders = read('orders.mjs') assert.match(orders, /warehouseType=\$\{encodeURIComponent\(destinationType\)\}/) assert.match(orders, /destinationType: master\.destinationType/) + assert.match(orders, /fulfillmentType: String\(context\.context\?\.orderFulfillmentType \|\| 'STANDARD'\)/) assert.match(orders, /destinationType,\s*\n\s*\}\)/) assert.doesNotMatch(orders, /const fbaResponse =/) }) -test('producer collector requires one FBA, one OVERSEAS and a third distinct container', () => { +test('producer collector requires dropship FBA, standard FBA, OVERSEAS and a fourth distinct container', () => { const common = { marker: 'RUN-1', eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED', sourceStatus: 'LOADED' } - const fba = { ...common, eventKey: 'FBA-EVENT', destinationType: 'FBA', containerNo: 'FBA-001' } + const dropship = { ...common, eventKey: 'DROP-EVENT', destinationType: 'FBA', + fulfillmentType: 'ONE_CLICK_DROPSHIP', containerNo: 'DROP-001' } + const fba = { ...common, eventKey: 'FBA-EVENT', destinationType: 'FBA', + fulfillmentType: 'STANDARD', containerNo: 'FBA-001' } const overseasPrimary = { ...common, eventKey: 'OS-EVENT-1', destinationType: 'OVERSEAS', containerNo: 'OS-001' } const secondary = { ...common, eventKey: 'FBA-EVENT-2', destinationType: 'FBA', containerNo: 'FBA-002' } - const one = collectPhase4InboundProducerFixture(null, fba) - const two = collectPhase4InboundProducerFixture(one, overseasPrimary) - const ready = collectPhase4InboundProducerFixture(two, secondary) + const one = collectPhase4InboundProducerFixture(null, dropship) + const two = collectPhase4InboundProducerFixture(one, fba) + const three = collectPhase4InboundProducerFixture(two, overseasPrimary) + const ready = collectPhase4InboundProducerFixture(three, secondary) assert.equal(one.readiness.readyForPhase4Pda, false) assert.equal(two.readiness.readyForPhase4Pda, false) + assert.equal(three.readiness.readyForPhase4Pda, false) assert.equal(ready.readiness.readyForPhase4Pda, true) - assert.equal(ready.readiness.fbaContainerCount, 2) + assert.equal(ready.readiness.fbaContainerCount, 3) assert.equal(ready.readiness.overseasContainerCount, 1) - assert.equal(ready.readiness.distinctContainerCount, 3) - assert.equal(ready.byDestinationType.FBA[1].eventKey, 'FBA-EVENT-2') + assert.equal(ready.readiness.dropshipContainerCount, 1) + assert.equal(ready.readiness.distinctContainerCount, 4) + assert.equal(ready.byDestinationType.FBA[2].eventKey, 'FBA-EVENT-2') }) test('same event key is idempotently replaced and another marker starts a new matrix', () => { @@ -49,6 +56,7 @@ test('same event key is idempotently replaced and another marker starts a new ma assert.equal(nextRun.containers.length, 1) assert.equal(nextRun.marker, 'RUN-2') assert.deepEqual(nextRun.requiredEntryOrchestration, [ + 'DROPSHIP-FBA: public order fulfillmentType -> inbound-measurement -> phase3-loading -> phase3-loading-finalize -> overseas PDA', 'FBA: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', 'OVERSEAS-PRIMARY: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', 'SECONDARY(FBA|OVERSEAS): orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', @@ -61,7 +69,14 @@ test('measurement and loading pass destination identity into the LOADED producer const finalize = read('phase3-loading-finalize.mjs') assert.match(measurement, /destinationType: context\.createdResources\?\.orders\?\.destinationType/) assert.match(loading, /destinationType: initial\?\.summary\?\.destinationType \|\| source\?\.destinationType/) + assert.match(loading, /fulfillmentType: source\?\.fulfillmentType \|\| 'STANDARD'/) assert.match(finalize, /destinationType: fixture\.destinationType \|\| null/) + assert.match(finalize, /fulfillmentType: fixture\.fulfillmentType \|\| 'STANDARD'/) assert.match(finalize, /resourceKey: `\$\{context\.marker\}:\$\{fixture\.destinationType \|\| 'UNKNOWN'\}:\$\{fixture\.containerNo \|\| containerBookingId\}`/) assert.match(finalize, /phase4InboundProducerFixtures = collectPhase4InboundProducerFixture/) + const entry = read('../admin-test-env-acceptance.mjs') + assert.match(entry, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP'\)/) + assert.match(entry, /orderFulfillmentType/) + const pda = read('phase4-overseas-pda.mjs') + assert.match(pda, /find\(\(item\) => item\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'\)/) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index d24debae7..6aae9e42f 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -413,7 +413,8 @@ export async function run(context) { return } const matrix = context.createdResources?.phase4InboundProducerFixtures - const producers = [matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]] + const dropship = matrix?.containers?.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP') + const producers = [dropship || matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]] .filter((item) => nonBlank(item?.containerNo)) const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers if (allProducers.length && !(await awaitProducerCandidates(context, primary.token, allProducers))) { From 29b2f080d81a0ad8bf01c76bd944497afdac571f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 09:16:00 +0800 Subject: [PATCH 077/115] test: preserve dropship producer fixture type --- scripts/admin-test-env-acceptance.mjs | 2 ++ .../phase3-phase4-producer-fixture-contract.test.mjs | 1 + 2 files changed, 3 insertions(+) diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 8f0951ff7..7425a5ad7 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -880,6 +880,7 @@ export class AcceptanceRun { orderId: fixture.orderId || this.createdResources.orders?.orderId, inboundId: fixture.inboundId, boxCode: fixture.boxCode, + fulfillmentType: fixtureOrder.fulfillmentType || 'STANDARD', customerRemark: fixtureOrder.customerRemark, internalRemark: fixtureOrder.internalRemark, cargoRemark: fixtureOrder.cargoRemark, @@ -899,6 +900,7 @@ export class AcceptanceRun { orderId: incrementalSource.orderId, inboundId: incrementalSource.inboundId, boxCode: incrementalSource.boxCode, + fulfillmentType: this.createdResources.orders?.fulfillmentType || 'STANDARD', } : { marker: originalMarker, failed: true } this.createdResources.orchestrationFixtures = { ...(this.createdResources.orchestrationFixtures || {}), diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index 9a7234e26..5c07ad16e 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -77,6 +77,7 @@ test('measurement and loading pass destination identity into the LOADED producer const entry = read('../admin-test-env-acceptance.mjs') assert.match(entry, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP'\)/) assert.match(entry, /orderFulfillmentType/) + assert.match(entry, /fulfillmentType: fixtureOrder\.fulfillmentType \|\| 'STANDARD'/) const pda = read('phase4-overseas-pda.mjs') assert.match(pda, /find\(\(item\) => item\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'\)/) }) From df4b7a1f6e382f8f7240a744af589aa4c965f817 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 09:23:28 +0800 Subject: [PATCH 078/115] test: carry frozen sku receipts into pda acceptance --- scripts/admin-test-env-acceptance.mjs | 6 +++++ scripts/admin-test-env-modules/orders.mjs | 4 +++ .../phase3-loading-finalize.mjs | 2 ++ .../admin-test-env-modules/phase3-loading.mjs | 4 +++ ...-phase4-producer-fixture-contract.test.mjs | 9 +++++++ .../phase4-overseas-pda.mjs | 25 ++++++++++++++----- 6 files changed, 44 insertions(+), 6 deletions(-) diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 7425a5ad7..753f2b0db 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -881,6 +881,8 @@ export class AcceptanceRun { inboundId: fixture.inboundId, boxCode: fixture.boxCode, fulfillmentType: fixtureOrder.fulfillmentType || 'STANDARD', + skuCodes: Array.isArray(fixtureOrder.skuCodes) ? fixtureOrder.skuCodes : [], + skuLines: Array.isArray(fixtureOrder.skuLines) ? fixtureOrder.skuLines : [], customerRemark: fixtureOrder.customerRemark, internalRemark: fixtureOrder.internalRemark, cargoRemark: fixtureOrder.cargoRemark, @@ -901,6 +903,10 @@ export class AcceptanceRun { inboundId: incrementalSource.inboundId, boxCode: incrementalSource.boxCode, fulfillmentType: this.createdResources.orders?.fulfillmentType || 'STANDARD', + skuCodes: Array.isArray(this.createdResources.orders?.skuCodes) + ? this.createdResources.orders.skuCodes : [], + skuLines: Array.isArray(this.createdResources.orders?.skuLines) + ? this.createdResources.orders.skuLines : [], } : { marker: originalMarker, failed: true } this.createdResources.orchestrationFixtures = { ...(this.createdResources.orchestrationFixtures || {}), diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index 186aae83b..3e7b83c41 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -399,6 +399,10 @@ export async function run(context) { context.createdResources.orders = { marker: context.marker, tenantId, orderId, productImageFileId, fulfillmentType: payload.fulfillmentType, + skuCodes: payload.cargoes.map((cargo) => cargo.skuNo), + skuLines: payload.cargoes.map((cargo) => ({ + skuCode: cargo.skuNo, packageCount: cargo.packageCount, + })), boxTypes: BOXES.map((box) => box.boxType), inboundId: null, receivingWarehouseId: id(master.receiving.id), destinationType: master.destinationType, diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 345454daa..c77b313ae 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -371,6 +371,8 @@ export async function run(context) { sourceInboundId: inboundId, cartonMark: fixture.cartonMark || null, cartonMarks: Array.isArray(fixture.cartonMarks) ? fixture.cartonMarks : [], + skuCodes: Array.isArray(fixture.skuCodes) ? fixture.skuCodes : [], + skuLines: Array.isArray(fixture.skuLines) ? fixture.skuLines : [], fulfillmentType: fixture.fulfillmentType || 'STANDARD', destinationType: fixture.destinationType || null, destinationWarehouseId: id(fixture.destinationWarehouseId), diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index f1cf90df1..bd977c3e0 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1216,6 +1216,10 @@ export async function run(context) { cartonMarks: [...new Set((stagedTask?.items || []).flatMap((item) => item?.boxCodes || []) .map((value) => String(value || '').trim()).filter(Boolean))], fulfillmentType: source?.fulfillmentType || 'STANDARD', + skuCodes: [...new Set([...(Array.isArray(source?.skuCodes) ? source.skuCodes : []), + ...(Array.isArray(incrementalFixture?.skuCodes) ? incrementalFixture.skuCodes : [])])], + skuLines: [...(Array.isArray(source?.skuLines) ? source.skuLines : []), + ...(Array.isArray(incrementalFixture?.skuLines) ? incrementalFixture.skuLines : [])], destinationType: initial?.summary?.destinationType || source?.destinationType || null, destinationWarehouseId: id(initial?.summary?.destinationWarehouseId || source?.destinationWarehouseId), destinationWarehouseCode: initial?.summary?.destinationCode || source?.destinationWarehouseCode || null, diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index 5c07ad16e..6574c4e2c 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -18,6 +18,8 @@ test('order fixture defaults to FBA and accepts only the explicit OVERSEAS varia assert.match(orders, /warehouseType=\$\{encodeURIComponent\(destinationType\)\}/) assert.match(orders, /destinationType: master\.destinationType/) assert.match(orders, /fulfillmentType: String\(context\.context\?\.orderFulfillmentType \|\| 'STANDARD'\)/) + assert.match(orders, /skuCodes: payload\.cargoes\.map\(\(cargo\) => cargo\.skuNo\)/) + assert.match(orders, /skuLines: payload\.cargoes\.map\(\(cargo\) => \(\{/) assert.match(orders, /destinationType,\s*\n\s*\}\)/) assert.doesNotMatch(orders, /const fbaResponse =/) }) @@ -70,14 +72,21 @@ test('measurement and loading pass destination identity into the LOADED producer assert.match(measurement, /destinationType: context\.createdResources\?\.orders\?\.destinationType/) assert.match(loading, /destinationType: initial\?\.summary\?\.destinationType \|\| source\?\.destinationType/) assert.match(loading, /fulfillmentType: source\?\.fulfillmentType \|\| 'STANDARD'/) + assert.match(loading, /Array\.isArray\(incrementalFixture\?\.skuCodes\)/) + assert.match(loading, /Array\.isArray\(incrementalFixture\?\.skuLines\)/) assert.match(finalize, /destinationType: fixture\.destinationType \|\| null/) assert.match(finalize, /fulfillmentType: fixture\.fulfillmentType \|\| 'STANDARD'/) + assert.match(finalize, /skuCodes: Array\.isArray\(fixture\.skuCodes\) \? fixture\.skuCodes : \[\]/) + assert.match(finalize, /skuLines: Array\.isArray\(fixture\.skuLines\) \? fixture\.skuLines : \[\]/) assert.match(finalize, /resourceKey: `\$\{context\.marker\}:\$\{fixture\.destinationType \|\| 'UNKNOWN'\}:\$\{fixture\.containerNo \|\| containerBookingId\}`/) assert.match(finalize, /phase4InboundProducerFixtures = collectPhase4InboundProducerFixture/) const entry = read('../admin-test-env-acceptance.mjs') assert.match(entry, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP'\)/) assert.match(entry, /orderFulfillmentType/) assert.match(entry, /fulfillmentType: fixtureOrder\.fulfillmentType \|\| 'STANDARD'/) + assert.match(entry, /skuCodes: Array\.isArray\(fixtureOrder\.skuCodes\) \? fixtureOrder\.skuCodes : \[\]/) + assert.match(entry, /skuLines: Array\.isArray\(fixtureOrder\.skuLines\) \? fixtureOrder\.skuLines : \[\]/) const pda = read('phase4-overseas-pda.mjs') assert.match(pda, /find\(\(item\) => item\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'\)/) + assert.match(pda, /skuFixtures\(context, producer\)/) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 6aae9e42f..011a96b26 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -175,31 +175,42 @@ async function awaitProducerCandidates(context, token, producers) { return ready } -function skuFixtures(context) { +function skuFixtures(context, producer) { + const frozenLines = Array.isArray(producer?.skuLines) + ? producer.skuLines + .map((line) => ({ skuCode: id(line?.skuCode), packageCount: Number(line?.packageCount) })) + .filter((line) => nonBlank(line.skuCode) && Number.isInteger(line.packageCount) && line.packageCount > 0) + : [] + if (frozenLines.length) return frozenLines + const frozen = Array.isArray(producer?.skuCodes) + ? producer.skuCodes.map(id).filter(nonBlank) + : [] + if (frozen.length) return [...new Set(frozen)].map((skuCode) => ({ skuCode, packageCount: 1 })) const boxes = context.createdResources?.orders?.boxTypes const orchestration = context.createdResources?.orchestrationFixtures || {} const markers = [context.marker, orchestration.phase3Loading?.sourceMarker, orchestration.phase3LoadingIncremental?.sourceMarker].filter(nonBlank) return Array.isArray(boxes) ? [...new Set(markers.flatMap((marker) => boxes.map((boxType) => `SKU-${boxType}-${marker}`.slice(0, 64))))] + .map((skuCode) => ({ skuCode, packageCount: 1 })) : [] } -async function receiveFba(context, token, inbound, pallet) { - const skus = skuFixtures(context) +async function receiveFba(context, token, inbound, pallet, producer) { + const skus = skuFixtures(context, producer) if (!skus.length) { blockerGap(context, `FBA入仓码${inbound.inboundCode} SKU`, '本轮订单 fixture 未交接冻结 SKU,无法安全猜测实收 SKU') return false } let accepted = 0 - for (const [index, skuCode] of skus.entries()) { + for (const [index, sku] of skus.entries()) { + const { skuCode, packageCount } = sku const resolvedResponse = await context.requestOverseas('POST', '/api/pda/v1/last-leg/receiving-containers/current/skus/resolve', { skuCode }, {}, token) const resolvable = success(resolvedResponse) || notFound(resolvedResponse) context.addStep(MODULE, `解析FBA SKU候选 ${index + 1}`, resolvedResponse, resolvable, trace(resolvedResponse), OPTIONS) const resolved = { response: resolvedResponse, data: success(resolvedResponse) ? resolvedResponse.body?.data : null } if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode || resolved.data?.destinationType !== 'FBA') continue - const packageCount = skuCode.includes('-LOAD-INC') ? 2 : 1 const body = { inboundCode: inbound.inboundCode, skuCode, palletCode: pallet.palletCode, packageCount, idempotencyKey: key(context, 'fba-bulk', skuCode) } const first = await pda(context, token, 'POST', `FBA SKU ${skuCode}按箱数实收`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) @@ -350,7 +361,9 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe cartonMark: context.createdResources?.inboundMeasurement?.boxCode || 'MODE-REJECT-FBA', palletCode: pallets.get(firstFba.inboundCode).palletCode, idempotencyKey: key(context, 'fba-carton-forbidden'), }, (response) => conflictMessage(response, /FBA.*bulk|bulk.*FBA/i)) - for (const inbound of fbaInbounds) await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode)) + for (const inbound of fbaInbounds) { + await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode), preferredProducer) + } } else if (preferredProducer?.destinationType !== 'OVERSEAS') { blockerGap(context, 'FBA SKU按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码') } From 46dd0b54eb146b8120052666bd9624b2a1a696bf Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 09:27:45 +0800 Subject: [PATCH 079/115] test: isolate pda receipt idempotency keys --- .../phase4-overseas-pda-contract.test.mjs | 2 ++ scripts/admin-test-env-modules/phase4-overseas-pda.mjs | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 45e5a374e..87c2addb0 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -34,6 +34,8 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /lastleg:pda:operate/) assert.match(source, /超收被拒绝/) assert.match(source, /重复箱新键被拒绝/) + assert.match(source, /key\(context, `fba-bulk-\$\{index\}`\)/) + assert.match(source, /key\(context, `overseas-carton-\$\{index\}`\)/) assert.match(source, /模式硬拒绝/) assert.match(source, /current返回404正常空态/) assert.match(source, /安全扫描返回A0400/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 011a96b26..9a57ec520 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -212,7 +212,7 @@ async function receiveFba(context, token, inbound, pallet, producer) { const resolved = { response: resolvedResponse, data: success(resolvedResponse) ? resolvedResponse.body?.data : null } if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode || resolved.data?.destinationType !== 'FBA') continue const body = { inboundCode: inbound.inboundCode, skuCode, palletCode: pallet.palletCode, - packageCount, idempotencyKey: key(context, 'fba-bulk', skuCode) } + packageCount, idempotencyKey: key(context, `fba-bulk-${index}`) } const first = await pda(context, token, 'POST', `FBA SKU ${skuCode}按箱数实收`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) if (!success(first.response)) return false accepted += Number(first.data?.acceptedPackages || 0) @@ -222,7 +222,7 @@ async function receiveFba(context, token, inbound, pallet, producer) { await expected(context, token, 'POST', `FBA SKU ${skuCode}同键异载荷冲突`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { ...body, packageCount: packageCount + 1 }, conflict) await expected(context, token, 'POST', `FBA SKU ${skuCode}超收被拒绝`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', - { ...body, packageCount: Number(inbound.totalPackages) + 1, idempotencyKey: key(context, 'fba-over', skuCode) }, conflict) + { ...body, packageCount: Number(inbound.totalPackages) + 1, idempotencyKey: key(context, `fba-over-${index}`) }, conflict) } if (accepted <= 0) { blockerGap(context, `FBA入仓码${inbound.inboundCode}实收`, `候选 SKU 均未唯一解析到当前入仓码;skuCount=${skus.length}`) @@ -250,7 +250,7 @@ async function receiveOverseas(context, token, inbounds, pallets, producerCarton return false } const body = { cartonMark, palletCode: pallet.palletCode, - idempotencyKey: key(context, 'overseas-carton', cartonMark) } + idempotencyKey: key(context, `overseas-carton-${index}`) } const first = await pda(context, token, 'POST', `OVERSEAS逐箱实收${index + 1}`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body) if (!success(first.response)) return false @@ -263,7 +263,7 @@ async function receiveOverseas(context, token, inbounds, pallets, producerCarton { ...body, palletCode: `${pallet.palletCode}-MUTATED` }, conflict) await expected(context, token, 'POST', `OVERSEAS重复箱新键被拒绝${index + 1}`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', - { ...body, idempotencyKey: key(context, 'overseas-duplicate', cartonMark) }, conflict) + { ...body, idempotencyKey: key(context, `overseas-duplicate-${index}`) }, conflict) } return true } From b2111213650e493a851af56bbdd3e7281239429c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 09:50:02 +0800 Subject: [PATCH 080/115] test: expose acceptance coverage metrics --- scripts/admin-test-env-acceptance.mjs | 58 ++++++++++++++-- ...-test-env-report-summary-contract.test.mjs | 68 +++++++++++++++++++ 2 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 scripts/admin-test-env-report-summary-contract.test.mjs diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 753f2b0db..51ba55c58 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -281,6 +281,56 @@ function derived(length, width, height, edge) { return { outerLength, outerWidth, outerHeight, volume: halfUp3(outerLength * outerWidth * outerHeight / 1_000_000) } } +export function buildAcceptanceSummary(steps = [], modules = [], coverageGaps = []) { + const failed = steps.filter((step) => !step.passed).length + const executedModules = modules.filter((module) => !module.skipped) + const uniqueModuleNames = new Set(executedModules.map((module) => module.name).filter(Boolean)) + const uniqueRequestKeys = new Set() + const uniqueStepNameSet = new Set() + + for (const step of steps) { + if (step.name) uniqueStepNameSet.add(step.name) + const method = step.request?.method + const path = step.request?.path + if (!method || !path) continue + uniqueRequestKeys.add(`${method}\u001f${path}\u001f${step.requestId || ''}`) + } + + const coverageGapStats = { + total: coverageGaps.length, + blocking: 0, + nonBlocking: 0, + byCategory: {}, + } + for (const gap of coverageGaps) { + const category = gap.category || 'uncategorized' + const blocking = gap.blocking === true + const categoryStats = coverageGapStats.byCategory[category] + || { total: 0, blocking: 0, nonBlocking: 0 } + categoryStats.total += 1 + if (blocking) { + coverageGapStats.blocking += 1 + categoryStats.blocking += 1 + } else { + coverageGapStats.nonBlocking += 1 + categoryStats.nonBlocking += 1 + } + coverageGapStats.byCategory[category] = categoryStats + } + + return { + total: steps.length, + passed: steps.length - failed, + failed, + moduleRuns: executedModules.length, + uniqueModules: uniqueModuleNames.size, + uniqueRequests: uniqueRequestKeys.size, + uniqueStepNames: uniqueStepNameSet.size, + skippedModules: modules.length - executedModules.length, + coverageGaps: coverageGapStats, + } +} + export class AcceptanceRun { constructor(options) { this.options = options @@ -1141,13 +1191,13 @@ export class AcceptanceRun { this.addStep('report-security', '报告敏感信息扫描', null, !sensitive, sensitive ? '检测到密码、Token、二进制/base64 或 OCR 原文字段' : '未发现密码、Token、文件二进制/base64 或 OCR 原文字段') - const failed = this.steps.filter((step) => !step.passed).length + const summary = buildAcceptanceSummary(this.steps, this.modules, this.coverageGaps) return { reportName: '后台管理测试环境 HTTP API 验收', marker: this.marker, gateway: this.options.gateway, overseasGateway: this.options.overseasGateway, generatedAt: new Date().toISOString(), startedAt: this.startedAt, - success: this.fatal === null && failed === 0, fatal: this.fatal, - summary: { total: this.steps.length, passed: this.steps.length - failed, failed }, + success: this.fatal === null && summary.failed === 0, fatal: this.fatal, + summary, modules: this.modules, scenarios: this.scenarios, deploymentBlockers: this.deploymentBlockers, coverageGaps: this.coverageGaps, createdResources: this.createdResources, @@ -1169,7 +1219,7 @@ async function main() { await mkdir(dirname(outputPath), { recursive: true }) await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8') console.log(`REPORT=${outputPath}`) - console.log(`RESULT=${report.success ? 'PASS' : 'FAIL'} total=${report.summary.total} passed=${report.summary.passed} failed=${report.summary.failed}`) + console.log(`RESULT=${report.success ? 'PASS' : 'FAIL'} total=${report.summary.total} passed=${report.summary.passed} failed=${report.summary.failed} metric=assertions moduleRuns=${report.summary.moduleRuns} uniqueModules=${report.summary.uniqueModules} uniqueRequests=${report.summary.uniqueRequests} skippedModules=${report.summary.skippedModules}`) process.exitCode = report.success ? 0 : 1 } diff --git a/scripts/admin-test-env-report-summary-contract.test.mjs b/scripts/admin-test-env-report-summary-contract.test.mjs new file mode 100644 index 000000000..1171aecce --- /dev/null +++ b/scripts/admin-test-env-report-summary-contract.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { buildAcceptanceSummary } from './admin-test-env-acceptance.mjs' + +test('report summary separates assertion, module-run and unique-request metrics', () => { + const sharedResponse = { + method: 'GET', path: '/api/platform/v1/orders?page=1', requestId: 'request-1', + } + const steps = [ + { module: 'orders', name: '订单列表成功', passed: true, + request: { method: sharedResponse.method, path: sharedResponse.path }, requestId: sharedResponse.requestId }, + { module: 'orders', name: '订单列表数据结构正确', passed: true, + request: { method: sharedResponse.method, path: sharedResponse.path }, requestId: sharedResponse.requestId }, + { module: 'orders', name: '订单列表成功', passed: false, + request: { method: 'GET', path: '/api/platform/v1/orders?page=2' }, requestId: 'request-2' }, + { module: 'report-security', name: '报告敏感信息扫描', passed: true, request: null, requestId: null }, + ] + const modules = [ + { name: 'orders' }, + { name: 'orders' }, + { name: 'finance', skipped: 'owned by finance full chain' }, + ] + + const summary = buildAcceptanceSummary(steps, modules, []) + + assert.deepEqual(summary, { + total: 4, + passed: 3, + failed: 1, + moduleRuns: 2, + uniqueModules: 1, + uniqueRequests: 2, + uniqueStepNames: 3, + skippedModules: 1, + coverageGaps: { total: 0, blocking: 0, nonBlocking: 0, byCategory: {} }, + }) +}) + +test('request evidence without requestId is deduplicated by method and path', () => { + const summary = buildAcceptanceSummary([ + { name: 'CSV 状态', passed: true, request: { method: 'GET', path: '/api/export' }, requestId: null }, + { name: 'CSV 表头', passed: true, request: { method: 'GET', path: '/api/export' }, requestId: null }, + { name: 'CSV POST', passed: true, request: { method: 'POST', path: '/api/export' }, requestId: null }, + ]) + + assert.equal(summary.uniqueRequests, 2) +}) + +test('coverage gaps are counted by category and blocking status without copying details', () => { + const summary = buildAcceptanceSummary([], [], [ + { category: 'device', blocking: false, reason: 'camera', secretEvidence: 'must-not-copy' }, + { category: 'device', blocking: true, reason: 'scanner' }, + { category: 'external-third-party', blocking: false, reason: 'captcha' }, + { blocking: true, reason: 'fixture' }, + ]) + + assert.deepEqual(summary.coverageGaps, { + total: 4, + blocking: 2, + nonBlocking: 2, + byCategory: { + device: { total: 2, blocking: 1, nonBlocking: 1 }, + 'external-third-party': { total: 1, blocking: 0, nonBlocking: 1 }, + uncategorized: { total: 1, blocking: 1, nonBlocking: 0 }, + }, + }) + assert.equal(JSON.stringify(summary).includes('must-not-copy'), false) +}) From b67217332aa565f213779b0e304fc2b7f6111beb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 09:52:49 +0800 Subject: [PATCH 081/115] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/baiyue/common/event/EventTypes.java | 2 + ...rstLegContainerShippingNodeRecordedV1.java | 41 +++++ ...egContainerShippingNodeRecordedV1Test.java | 43 +++++ .../BaiyueOpenApiAutoConfiguration.java | 8 + .../openapi/PdaOpenApiGroupContractTest.java | 17 ++ ...EventBridgeReliabilitySqlContractTest.java | 39 ++++ .../service/OverseasEventDispatcherTest.java | 54 ++++++ .../CarrierScheduleNodeOutboxService.java | 75 ++++++++ .../LoadingCompletedOutboxRepository.java | 11 +- .../service/LoadingCompletedOutboxRelay.java | 7 +- .../CarrierScheduleNodeOutboxServiceTest.java | 77 ++++++++ .../main/resources/application-overseas.yml | 1 + .../OverseasLastlegRouteContractTest.java | 11 ++ lastleg-service/AGENTS.md | 6 +- .../InboundPalletAssignmentController.java | 52 ------ .../pda/controller/InboundPdaController.java | 146 --------------- .../ReceivingContainerController.java | 50 ++--- .../pda/security/PdaWarehouseIdentity.java | 32 ++++ .../warehouse/dto/PdaWarehouseRequests.java | 15 ++ .../warehouse/dto/PdaWarehouseResponses.java | 23 +++ .../warehouse/entity/PdaOperationHistory.java | 26 +++ .../warehouse/mapper/PdaWarehouseMapper.java | 72 ++++++++ .../model/PdaInventoryProjection.java | 25 +++ .../PlatformUsTruckOrderController.java | 69 +++++++ .../trucking/dto/UsTruckOrderRequests.java | 29 ++- .../trucking/dto/UsTruckOrderResponses.java | 5 +- .../lastleg/trucking/entity/UsTruckOrder.java | 3 - .../entity/UsTruckOrderOperation.java | 22 +++ .../entity/UsTruckOrderStatusHistory.java | 2 + .../mapper/UsTruckOrderOperationMapper.java | 15 ++ .../trucking/service/UsTruckOrderService.java | 174 ++++++++++++++---- .../Phase4BrowserApiBoundaryTest.java | 18 +- ...InboundPalletAssignmentControllerTest.java | 42 ----- .../admin-test-env-modules/phase3-loading.mjs | 102 +++++++++- .../phase3-producer-chain-contract.test.mjs | 26 +++ .../PlatformWarehouseResourceController.java | 3 +- ...rehouseResourcePermissionContractTest.java | 9 +- 37 files changed, 1009 insertions(+), 343 deletions(-) create mode 100644 common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java create mode 100644 common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java create mode 100644 common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java create mode 100644 event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventBridgeReliabilitySqlContractTest.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/security/PdaWarehouseIdentity.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/entity/PdaOperationHistory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/model/PdaInventoryProjection.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderOperation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderOperationMapper.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentControllerTest.java diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java index 7c23de9c9..029056ada 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java @@ -10,6 +10,8 @@ public final class EventTypes { public static final String ORDER_CANCELLED = "OrderCancelled"; public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted"; public static final String FIRST_LEG_LOADING_PLAN_COMPLETED = "FirstLegLoadingPlanCompleted"; + public static final String FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED = + "FirstLegContainerShippingNodeRecorded"; public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded"; public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged"; public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged"; diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java new file mode 100644 index 000000000..9d1dbf684 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java @@ -0,0 +1,41 @@ +package com.baiyue.common.event; + +import java.time.Instant; + +/** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */ +public record FirstLegContainerShippingNodeRecordedV1( + String queryId, + String customerId, + String oceanBookingId, + String containerBookingId, + String containerNo, + String standardNode, + int nodeSequence, + Instant observedAt, + Instant actualAt, + String locationCode, + String locationName, + String providerCode, + String providerEventId, + String providerNodeCode, + String sourceReference, + String rawPayloadHash) { + + public FirstLegContainerShippingNodeRecordedV1 { + if (queryId == null || queryId.isBlank() || customerId == null || customerId.isBlank() + || oceanBookingId == null || oceanBookingId.isBlank() + || containerBookingId == null || containerBookingId.isBlank() + || containerNo == null || containerNo.isBlank()) { + throw new IllegalArgumentException("船节点交接业务标识不能为空"); + } + if (standardNode == null || standardNode.isBlank() || nodeSequence < 10 + || observedAt == null || actualAt == null) { + throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实"); + } + if (providerCode == null || providerCode.isBlank() || providerEventId == null + || providerEventId.isBlank() || providerNodeCode == null || providerNodeCode.isBlank() + || rawPayloadHash == null || !rawPayloadHash.matches("(?i)^[0-9a-f]{64}$")) { + throw new IllegalArgumentException("船节点交接供应商审计字段不完整"); + } + } +} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java new file mode 100644 index 000000000..3f69c7f1f --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java @@ -0,0 +1,43 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FirstLegContainerShippingNodeRecordedV1Test { + @Test + void roundTripsLongBusinessIdsAndActualPickupReadyFact() throws Exception { + var payload = payload(); + var mapper = new ObjectMapper().findAndRegisterModules(); + + var restored = mapper.readValue(mapper.writeValueAsString(payload), + FirstLegContainerShippingNodeRecordedV1.class); + + assertThat(restored).isEqualTo(payload); + assertThat(restored.containerBookingId()).isEqualTo("2088000000000000003"); + assertThat(restored.standardNode()).isEqualTo("PICKUP_READY"); + assertThat(restored.nodeSequence()).isEqualTo(80); + } + + @Test + void rejectsEstimatedOnlyOrIncompleteProviderFacts() { + assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1( + "1", "2", "3", "4", "ZCSU7881970", "PICKUP_READY", 80, + Instant.parse("2026-08-25T01:00:00Z"), null, null, null, + "ZIM", "evt-1", "Carrier Release", "ZIM:evt-1", "a".repeat(64))) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("实际时间"); + } + + private static FirstLegContainerShippingNodeRecordedV1 payload() { + return new FirstLegContainerShippingNodeRecordedV1( + "2088000000000000001", "2088000000000000002", "2088000000000000004", + "2088000000000000003", "ZCSU7881970", "PICKUP_READY", 80, + Instant.parse("2026-08-25T01:02:00Z"), Instant.parse("2026-08-25T01:00:00Z"), + "USLAX", "Los Angeles", "ZIM", "evt-1", "AVAILABLE", + "ZIM:evt-1", "a".repeat(64)); + } +} diff --git a/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java b/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java index 3a5a42502..de69a8909 100644 --- a/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java +++ b/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java @@ -75,6 +75,14 @@ public class BaiyueOpenApiAutoConfiguration { .addOperationCustomizer(correlationHeaderCustomizer).build(); } + @Bean + GroupedOpenApi pdaApi(OperationCustomizer correlationHeaderCustomizer) { + return GroupedOpenApi.builder().group("pda") + .displayName("PDA APIs / PDA 作业接口") + .pathsToMatch("/api/pda/**") + .addOperationCustomizer(correlationHeaderCustomizer).build(); + } + private Parameter header(String name, String description, boolean required) { return new HeaderParameter().name(name).description(description).required(required) .schema(new io.swagger.v3.oas.models.media.StringSchema()); diff --git a/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java b/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java new file mode 100644 index 000000000..dcf28b7df --- /dev/null +++ b/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java @@ -0,0 +1,17 @@ +package com.baiyue.common.openapi; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PdaOpenApiGroupContractTest { + @Test + void pdaGroupOnlyMatchesDeviceApis() { + BaiyueOpenApiAutoConfiguration configuration = new BaiyueOpenApiAutoConfiguration(); + var group = configuration.pdaApi(configuration.correlationHeaderCustomizer()); + + assertThat(group.getGroup()).isEqualTo("pda"); + assertThat(group.getPathsToMatch()).containsExactly("/api/pda/**"); + assertThat(group.getPathsToMatch()).noneMatch(path -> path.contains("internal")); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventBridgeReliabilitySqlContractTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventBridgeReliabilitySqlContractTest.java new file mode 100644 index 000000000..0869ea824 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventBridgeReliabilitySqlContractTest.java @@ -0,0 +1,39 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventBridgeReliabilitySqlContractTest { + @Test + void schedulingAndLeaseSqlCoverRetryDeadAndExpiredLeaseTakeover() throws Exception { + String candidates = sql(BridgeInboxMapper.class.getMethod("dispatchCandidates", int.class), Select.class); + String claim = sql(BridgeInboxMapper.class.getMethod( + "claim", String.class, String.class, long.class), Update.class); + String failed = sql(BridgeInboxMapper.class.getMethod( + "markFailed", String.class, String.class, int.class, long.class, String.class), Update.class); + String processed = sql(BridgeInboxMapper.class.getMethod( + "markProcessed", String.class, String.class), Update.class); + + assertThat(candidates).contains("status IN ('RECEIVED','RETRY')", "next_retry_at <= CURRENT_TIMESTAMP(3)", + "status = 'PROCESSING'", "lease_until < CURRENT_TIMESTAMP(3)", "ORDER BY received_at"); + assertThat(claim).contains("status='PROCESSING'", "lease_owner=#{owner}", "lease_until=DATE_ADD", + "status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)"); + assertThat(failed).contains("retry_count=retry_count+1", "status=CASE", "THEN 'DEAD' ELSE 'RETRY'", + "next_retry_at=CASE", "THEN NULL", "last_error=#{error}", "lease_owner=#{owner}"); + assertThat(processed).contains("status='PROCESSED'", "next_retry_at=NULL", "last_error=NULL", + "status='PROCESSING'", "lease_owner=#{owner}"); + } + + private String sql(Method method, Class type) { + A annotation = method.getAnnotation(type); + String[] statements = annotation instanceof Select select ? select.value() : ((Update) annotation).value(); + return String.join(" ", Arrays.asList(statements)).replaceAll("\\s+", " "); + } +} 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 67cb7b3d1..01468d95a 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 @@ -12,6 +12,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.net.URI; import java.time.Duration; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.*; @@ -103,6 +104,59 @@ class OverseasEventDispatcherTest { assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); } + @Test + void downstream503RetriesThenDeliversWithDiagnosticAttemptsInBothDirections() throws Exception { + for (String target : List.of("OVERSEAS", "CN")) { + reset(inboxMapper, attemptMapper, eventClient, firstlegEventClient); + event.setSourceRegion("CN".equals(target) ? "OVERSEAS" : "CN"); + event.setTargetRegion(target); + event.setRetryCount(0); + when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1); + when(inboxMapper.selectById("event-1")).thenReturn(event); + when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1, 2); + doAnswer(invocation -> { + event.setRetryCount(1); + event.setStatus("RETRY"); + event.setLastError(invocation.getArgument(4)); + event.setNextRetryAt(java.time.LocalDateTime.now().plusSeconds( + ((Number) invocation.getArgument(3)).longValue())); + return 1; + }).when(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), anyLong(), anyString()); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenAnswer(invocation -> { + event.setStatus("PROCESSED"); + event.setLastError(null); + event.setNextRetryAt(null); + return 1; + }); + if ("CN".equals(target)) { + when(firstlegEventClient.deliver(event)) + .thenThrow(new IllegalStateException("HTTP 503 code=S0503 requestId=req-cn traceId=trace-cn")) + .thenReturn("200"); + } else { + when(eventClient.deliver(event)) + .thenThrow(new IllegalStateException("HTTP 503 code=S0503 requestId=req-overseas traceId=trace-overseas")) + .thenReturn("200"); + } + + dispatcher.dispatchOne("event-1"); + assertThat(event.getStatus()).isEqualTo("RETRY"); + assertThat(event.getNextRetryAt()).isNotNull(); + assertThat(event.getLastError()).contains("HTTP 503", "requestId=", "traceId="); + dispatcher.dispatchOne("event-1"); + + ArgumentCaptor attempts = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); + verify(attemptMapper, times(2)).updateById(attempts.capture()); + assertThat(attempts.getAllValues()).extracting(BridgeDeliveryAttempt::getAttemptNo) + .containsExactly(1, 2); + assertThat(attempts.getAllValues()).extracting(BridgeDeliveryAttempt::getStatus) + .containsExactly("RETRY", "DELIVERED"); + assertThat(attempts.getAllValues().get(0).getErrorMessage()) + .contains("HTTP 503", "requestId=", "traceId="); + assertThat(event.getStatus()).isEqualTo("PROCESSED"); + assertThat(event.getLastError()).isNull(); + } + } + private BridgeDeliveryAttempt updatedAttempt() { ArgumentCaptor captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); verify(attemptMapper).updateById(captor.capture()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java new file mode 100644 index 000000000..5f949eece --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java @@ -0,0 +1,75 @@ +package com.baiyue.firstleg.portschedule.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventIdentifiers; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.FirstLegContainerShippingNodeRecordedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.firstleg.entity.InboundOutboxEntity; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.ZoneId; + +/** Writes actual carrier node facts to the domestic transactional Outbox. */ +@Service +public class CarrierScheduleNodeOutboxService { + @Resource private InboundOutboxMapper outbox; + @Resource private ObjectMapper objectMapper; + @Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}") + private String scheduleZone = "Asia/Shanghai"; + + public void publish(CarrierScheduleQueryEntity query, CarrierScheduleNodeFactEntity fact, + String containerNo) { + if (fact.getActualAt() == null) return; + ZoneId zone = ZoneId.of(scheduleZone); + var payload = new FirstLegContainerShippingNodeRecordedV1( + string(query.getId()), string(query.getCustomerId()), string(query.getOceanBookingId()), + string(query.getContainerBookingId()), containerNo, fact.getStandardNode(), fact.getNodeSequence(), + fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(), + fact.getLocationCode(), fact.getLocationName(), fact.getProviderCode(), fact.getProviderEventId(), + fact.getProviderNodeCode(), fact.getSourceReference(), fact.getRawPayloadHash()); + JsonNode payloadNode = objectMapper.valueToTree(payload); + String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode)); + long aggregateVersion = Math.max(0, query.getVersion() == null ? 0 : query.getVersion()); + String eventKey = "CARRIER_NODE:" + query.getId() + ':' + fact.getId(); + EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), + EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1, + fact.getActualAt().atZone(zone).toInstant(), RegionCode.CN, RegionCode.OVERSEAS, + string(query.getCustomerId()), "CONTAINER_BOOKING", string(query.getContainerBookingId()), + aggregateVersion, aggregateVersion + 1, EventIdentifiers.newUuid(), eventKey, null, + payloadNode, payloadHash).requireValid(); + + InboundOutboxEntity row = new InboundOutboxEntity(); + row.setId(IdWorker.getId()); row.setTenantId(query.getCustomerId()); + row.setAggregateType(envelope.aggregateType()); row.setAggregateId(query.getContainerBookingId()); + row.setEventType(envelope.eventType()); row.setEventKey(eventKey); row.setEventVersion(1); + row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash); + row.setPayloadJson(json(envelope)); row.setPublishStatus("PENDING"); row.setRetryCount(0); + row.setCreatedAt(LocalDateTime.now()); + try { outbox.insert(row); } catch (DuplicateKeyException ignored) { } + } + + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { + throw new BizException(ResultCode.INTERNAL_ERROR, "船节点交接事件序列化失败"); + } + } + + private static String string(Long value) { return value == null ? null : String.valueOf(value); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java index cb8935edd..89d6f427f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java @@ -13,11 +13,14 @@ import java.util.List; @Repository public class LoadingCompletedOutboxRepository { + private static final List CROSS_REGION_EVENT_TYPES = List.of( + EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, + EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED); @Resource private InboundOutboxMapper mapper; public List pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) { return mapper.selectList(new LambdaQueryWrapper() - .eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) + .in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES) .and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING") .and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt) .or().le(InboundOutboxEntity::getNextRetryAt, now)) @@ -29,7 +32,7 @@ public class LoadingCompletedOutboxRepository { public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) { return mapper.update(null, new LambdaUpdateWrapper() .eq(InboundOutboxEntity::getId, id) - .eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) + .in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES) .and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING") .or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING") .le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore))) @@ -37,7 +40,7 @@ public class LoadingCompletedOutboxRepository { .set(InboundOutboxEntity::getLockedAt, now)) == 1; } public InboundOutboxEntity claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper() - .eq(InboundOutboxEntity::getId, id).eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) + .eq(InboundOutboxEntity::getId, id).in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES) .eq(InboundOutboxEntity::getPublishStatus, "PROCESSING").eq(InboundOutboxEntity::getLockedBy, worker)); } public void published(long id, String worker, LocalDateTime now) { mapper.update(null, claimedBase(id, worker) .set(InboundOutboxEntity::getPublishStatus, "PUBLISHED").set(InboundOutboxEntity::getPublishedAt, now) @@ -48,7 +51,7 @@ public class LoadingCompletedOutboxRepository { .set(InboundOutboxEntity::getNextRetryAt, retryAt).set(InboundOutboxEntity::getLockedBy, null) .set(InboundOutboxEntity::getLockedAt, null).set(InboundOutboxEntity::getLastError, truncate(error))); } private LambdaUpdateWrapper base(long id, String status) { return new LambdaUpdateWrapper() - .eq(InboundOutboxEntity::getId,id).eq(InboundOutboxEntity::getEventType,EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) + .eq(InboundOutboxEntity::getId,id).in(InboundOutboxEntity::getEventType,CROSS_REGION_EVENT_TYPES) .eq(InboundOutboxEntity::getPublishStatus,status); } private LambdaUpdateWrapper claimedBase(long id,String worker) { return base(id,"PROCESSING").eq(InboundOutboxEntity::getLockedBy,worker); } private static String truncate(String value) { if(value==null)return "unknown"; String v=value.replaceAll("[\\r\\n\\t]+"," "); return v.length()>500?v.substring(0,500):v; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java index 30bf94202..c635fbe76 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java @@ -15,7 +15,7 @@ import org.springframework.stereotype.Component; import java.time.LocalDateTime; import java.util.UUID; -/** 至少一次投递装柜完成跨区事件;事件桥按 eventId 幂等接收。 */ +/** 至少一次投递国内头程跨区事件;事件桥按 eventId + payloadHash 幂等接收。 */ @Component @Slf4j public class LoadingCompletedOutboxRelay { @Resource private LoadingCompletedOutboxRepository repository; @@ -39,12 +39,13 @@ public class LoadingCompletedOutboxRelay { EventEnvelopeValidator.requireValid(event); var response=client.receive(internalToken,event); if(response==null || !response.successful() || response.data()==null - || !event.eventId().equals(response.data().eventId())) throw new IllegalStateException("事件桥未确认装柜完成事件"); + || !event.eventId().equals(response.data().eventId())) throw new IllegalStateException("事件桥未确认头程跨区事件"); repository.published(row.getId(),workerId,LocalDateTime.now()); } catch(Exception exception) { String error=safeError(exception); repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(Math.max(retrySeconds,1)),error); - log.warn("装柜完成事件投递失败: eventKey={}, reason={}",row.getEventKey(),error); + log.warn("头程跨区事件投递失败: eventType={}, eventKey={}, reason={}", + row.getEventType(), row.getEventKey(), error); } } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java new file mode 100644 index 000000000..27a76c2b4 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java @@ -0,0 +1,77 @@ +package com.baiyue.firstleg.portschedule.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import com.baiyue.firstleg.entity.InboundOutboxEntity; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class CarrierScheduleNodeOutboxServiceTest { + private final InboundOutboxMapper mapper = mock(InboundOutboxMapper.class); + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + private final CarrierScheduleNodeOutboxService service = new CarrierScheduleNodeOutboxService(); + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "outbox", mapper); + ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + ReflectionTestUtils.setField(service, "scheduleZone", "Asia/Shanghai"); + } + + @Test + void writesVersionedActualPickupReadyEnvelopeWithStringIds() throws Exception { + CarrierScheduleQueryEntity query = query(); + CarrierScheduleNodeFactEntity fact = fact(); + + service.publish(query, fact, "ZCSU7881970"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(mapper).insert(captor.capture()); + InboundOutboxEntity row = captor.getValue(); + EventEnvelope envelope = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class); + assertThat(row.getEventType()).isEqualTo(EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED); + assertThat(envelope.eventVersion()).isEqualTo(1); + assertThat(envelope.aggregateType()).isEqualTo("CONTAINER_BOOKING"); + assertThat(envelope.aggregateId()).isEqualTo("2088000000000000003"); + assertThat(envelope.aggregateVersion()).isEqualTo(7); + assertThat(envelope.handoverVersion()).isEqualTo(8); + assertThat(envelope.payload().path("standardNode").asText()).isEqualTo("PICKUP_READY"); + assertThat(envelope.payload().path("actualAt").asText()).isEqualTo("2026-08-25T01:00:00Z"); + } + + @Test + void estimatedOnlyFactNeverEntersCrossRegionOutbox() { + CarrierScheduleNodeFactEntity fact = fact(); fact.setActualAt(null); + service.publish(query(), fact, "ZCSU7881970"); + verify(mapper, never()).insert(org.mockito.ArgumentMatchers.any()); + } + + private static CarrierScheduleQueryEntity query() { + CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); + value.setId(2088000000000000001L); value.setCustomerId(2088000000000000002L); + value.setContainerBookingId(2088000000000000003L); value.setOceanBookingId(2088000000000000004L); + value.setVersion(7); return value; + } + + private static CarrierScheduleNodeFactEntity fact() { + CarrierScheduleNodeFactEntity value = new CarrierScheduleNodeFactEntity(); + value.setId(2088000000000000005L); value.setStandardNode("PICKUP_READY"); value.setNodeSequence(80); + value.setObservedAt(LocalDateTime.of(2026, 8, 25, 9, 2)); + value.setActualAt(LocalDateTime.of(2026, 8, 25, 9, 0)); value.setProviderCode("ZIM"); + value.setProviderEventId("evt-1"); value.setProviderNodeCode("AVAILABLE"); + value.setSourceReference("ZIM:evt-1"); value.setRawPayloadHash("a".repeat(64)); return value; + } +} diff --git a/gateway-overseas/src/main/resources/application-overseas.yml b/gateway-overseas/src/main/resources/application-overseas.yml index f7204c15d..bdcda68d8 100644 --- a/gateway-overseas/src/main/resources/application-overseas.yml +++ b/gateway-overseas/src/main/resources/application-overseas.yml @@ -60,6 +60,7 @@ springdoc: urls: - { name: lastleg-merchant, url: /lastleg-service/v3/api-docs/merchant } - { name: lastleg-platform, url: /lastleg-service/v3/api-docs/platform } + - { name: lastleg-pda, url: /lastleg-service/v3/api-docs/pda } - { name: warehouse-merchant, url: /warehouse-overseas-service/v3/api-docs/merchant } - { name: warehouse-platform, url: /warehouse-overseas-service/v3/api-docs/platform } - { name: file-platform, url: /file-service/v3/api-docs/platform } diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java index e83bb3312..345210960 100644 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java @@ -27,4 +27,15 @@ class OverseasLastlegRouteContractTest { .doesNotContain("StripPrefix", "RewritePath"); } } + + @Test + void swaggerPublishesAnIndependentLastlegPdaGroup() throws IOException { + try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) { + assertThat(input).isNotNull(); + String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + assertThat(yaml).contains("name: lastleg-pda", "url: /lastleg-service/v3/api-docs/pda") + .contains("Path=/lastleg-service/v3/api-docs/**") + .doesNotContain("event-bridge-service/v3/api-docs/pda"); + } + } } diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 99b6a8795..d2853f74d 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -22,9 +22,9 @@ | 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java` | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | -| PDA 入仓、实收、上架、合托、完成 | `inboundplan/pda/controller/`、`inboundplan/pda/service/InboundPdaWorkflowService.java`;入口固定为 `/api/pda/v1/last-leg/**`,不得挂入平台浏览器 API | -| PDA 卸柜照片文件上传与归属校验 | `InboundPdaController.java` 的 `/unload-photos/files`、`inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO` | -| 卸柜照片与海外卸柜完成 Outbox | `inboundplan/pda/controller/InboundPdaController.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | +| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,旧 planNo/allocationId Controller 已移除 | +| PDA 卸柜照片文件上传与归属校验 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO` | +| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | | 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | | 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,范围开关与禁用写操作见“四期开发范围路由” | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java deleted file mode 100644 index 387334dae..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.baiyue.lastleg.inboundplan.pda.controller; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests; -import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService; -import jakarta.validation.Valid; -import jakarta.annotation.Resource; -import org.springframework.http.HttpStatus; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.ResponseStatusException; - -@RestController -@RequestMapping("/api/pda/v1/last-leg/inbound-plans/{planNo}/pallets") -public class InboundPalletAssignmentController { - @Resource private InboundPdaWorkflowService service; - - @PostMapping("/{planPalletId}/assign") - public ApiResponse assign(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String planNo, @PathVariable long planPalletId, - @RequestParam(required = false) String customerId, - @Valid @RequestBody PdaRequests.Assign request) { - requireOperate(user); - service.assign(scope(user, customerId), planNo, planPalletId, request); - return ApiResponse.success(null); - } - - private String scope(AuthenticatedUser user, String requestedCustomer) { - if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { - if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); - } - return requestedCustomer; - } - return String.valueOf(user.tenantId()); - } - - private void requireOperate(AuthenticatedUser user) { - if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - if (user.permissions() == null || !user.permissions().contains(InboundPdaController.OPERATE_PERMISSION)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, - InboundPdaController.OPERATE_PERMISSION + " permission is required"); - } - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java deleted file mode 100644 index 29056ca92..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java +++ /dev/null @@ -1,146 +0,0 @@ -package com.baiyue.lastleg.inboundplan.pda.controller; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests; -import com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses; -import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService; -import jakarta.validation.Valid; -import jakarta.annotation.Resource; -import org.springframework.http.HttpStatus; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.bind.annotation.RequestPart; -import org.springframework.web.multipart.MultipartFile; -import org.springframework.http.MediaType; -import org.springframework.web.server.ResponseStatusException; - -import java.util.List; - -@RestController -@RequestMapping("/api/pda/v1/last-leg/inbound-plans/{planNo}") -public class InboundPdaController { - public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; - @Resource private InboundPdaWorkflowService service; - - @PostMapping("/push") - public ApiResponse push(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String planNo, - @RequestParam(required = false) String customerId, - @Valid @RequestBody PdaRequests.Push request) { - requireOperate(user); - return ApiResponse.success(service.push(scope(user, customerId), planNo, request)); - } - - @GetMapping - public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String planNo, - @RequestParam(required = false) String customerId) { - return ApiResponse.success(service.task(scope(user, customerId), planNo)); - } - - @PostMapping("/allocations/{allocationId}/count") - public ApiResponse count(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String planNo, - @PathVariable long allocationId, - @RequestParam(required = false) String customerId, - @Valid @RequestBody PdaRequests.PackageOperation request) { - requireOperate(user); - return ApiResponse.success(service.count(scope(user, customerId), planNo, allocationId, request)); - } - - @PostMapping("/allocations/{allocationId}/pick") - public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String planNo, - @PathVariable long allocationId, - @RequestParam(required = false) String customerId, - @Valid @RequestBody PdaRequests.PackageOperation request) { - requireOperate(user); - return ApiResponse.success(service.pick(scope(user, customerId), planNo, allocationId, request)); - } - - @PostMapping("/cartons/scan") - public ApiResponse scanCarton(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String planNo, - @RequestParam(required = false) String customerId, - @Valid @RequestBody PdaRequests.ScanCarton request) { - requireOperate(user); - return ApiResponse.success(service.scanCarton(scope(user, customerId), planNo, request)); - } - - @PostMapping("/cartons/remove") - public ApiResponse removeCarton(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String planNo, - @RequestParam(required = false) String customerId, - @Valid @RequestBody PdaRequests.RemoveCarton request) { - requireOperate(user); - return ApiResponse.success(service.removeCarton(scope(user, customerId), planNo, request)); - } - - @GetMapping("/allocations/{allocationId}/merge-candidates") - public ApiResponse> candidates( - @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, - @PathVariable long allocationId, @RequestParam(required = false) String customerId) { - return ApiResponse.success(service.mergeCandidates(scope(user, customerId), planNo, allocationId)); - } - - @PostMapping("/allocations/{allocationId}/merge") - public ApiResponse merge(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String planNo, - @PathVariable long allocationId, - @RequestParam(required = false) String customerId, - @Valid @RequestBody PdaRequests.ConfirmMerge request) { - requireOperate(user); - return ApiResponse.success(service.merge(scope(user, customerId), planNo, allocationId, request)); - } - - @PostMapping("/complete") - public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String planNo, - @RequestParam(required = false) String customerId) { - requireOperate(user); - return ApiResponse.success(service.complete(scope(user, customerId), planNo)); - } - - @PostMapping("/unload-photos") - public ApiResponse uploadUnloadPhotos(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String planNo, - @RequestParam(required = false) String customerId, - @Valid @RequestBody PdaRequests.UnloadPhotos request) { - requireOperate(user); - return ApiResponse.success(service.uploadUnloadPhotos(scope(user, customerId), planNo, request)); - } - - @PostMapping(value = "/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ApiResponse uploadUnloadPhotoFile( - @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, - @RequestParam(required = false) String customerId, @RequestPart("file") MultipartFile file) { - requireOperate(user); - return ApiResponse.success(service.uploadUnloadPhotoFile( - scope(user, customerId), planNo, user.userId(), file)); - } - - private String scope(AuthenticatedUser user, String requestedCustomer) { - if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { - if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); - } - return requestedCustomer; - } - return String.valueOf(user.tenantId()); - } - - private void requireOperate(AuthenticatedUser user) { - if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required"); - } - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java index 6adcf7ff5..fdb3e6d75 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -5,6 +5,7 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses; import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; @@ -30,14 +31,14 @@ import java.util.List; @Tag(name = "Overseas PDA container receiving") @PreAuthorize("hasAuthority('lastleg:pda:operate')") public class ReceivingContainerController { - public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; + public static final String OPERATE_PERMISSION = PdaWarehouseIdentity.OPERATE_PERMISSION; @Resource private ReceivingContainerService service; @GetMapping("/candidates") @Operation(summary = "List pending receiving containers across customers for the authenticated warehouse picker") public ApiResponse> candidates( @AuthenticationPrincipal AuthenticatedUser user) { - Identity identity = identity(user); + PdaWarehouseIdentity identity = identity(user); return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId())); } @@ -45,14 +46,14 @@ public class ReceivingContainerController { @Operation(summary = "Get one receiving container without exposing plan or allocation IDs") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String containerNo) { - Identity identity = identity(user); + PdaWarehouseIdentity identity = identity(user); return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), containerNo)); } @PostMapping("/{containerNo}/prepare") public ApiResponse prepare(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) { - Identity identity = identity(user); + PdaWarehouseIdentity identity = identity(user); return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(), identity.overseasWarehouseId(), containerNo, request)); } @@ -60,7 +61,7 @@ public class ReceivingContainerController { @PostMapping(value = "/current/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user, @RequestPart("file") MultipartFile file) { - Identity identity = currentIdentity(user); + PdaWarehouseIdentity identity = currentIdentity(user); if (user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "PDA user ID is required"); return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file)); } @@ -68,13 +69,13 @@ public class ReceivingContainerController { @PostMapping("/current/sign") public ApiResponse sign(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.Sign request) { - Identity identity = currentIdentity(user); + PdaWarehouseIdentity identity = currentIdentity(user); return ApiResponse.success(service.sign(identity.operatorKey(), identity.operatorName(), request)); } @GetMapping("/current") public ApiResponse current(@AuthenticationPrincipal AuthenticatedUser user) { - Identity identity = currentIdentity(user); + PdaWarehouseIdentity identity = currentIdentity(user); return ApiResponse.success(service.current(identity.operatorKey())); } @@ -82,7 +83,7 @@ public class ReceivingContainerController { public ApiResponse resolveCarton( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.CartonResolve request) { - Identity identity = currentIdentity(user); + PdaWarehouseIdentity identity = currentIdentity(user); return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request)); } @@ -90,7 +91,7 @@ public class ReceivingContainerController { public ApiResponse resolveSku( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.SkuResolve request) { - Identity identity = currentIdentity(user); + PdaWarehouseIdentity identity = currentIdentity(user); return ApiResponse.success(service.resolveSku(identity.operatorKey(), request)); } @@ -98,53 +99,38 @@ public class ReceivingContainerController { public ApiResponse resolvePallet( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.PalletResolve request) { - Identity identity = currentIdentity(user); + PdaWarehouseIdentity identity = currentIdentity(user); return ApiResponse.success(service.resolvePallet(identity.operatorKey(), request)); } @PostMapping("/current/receipts/bulk") public ApiResponse receiveBulk(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.BulkReceive request) { - Identity identity = currentIdentity(user); + PdaWarehouseIdentity identity = currentIdentity(user); return ApiResponse.success(service.receiveBulk(identity.operatorKey(), identity.operatorName(), request)); } @PostMapping("/current/receipts/carton") public ApiResponse receiveCarton(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.CartonReceive request) { - Identity identity = currentIdentity(user); + PdaWarehouseIdentity identity = currentIdentity(user); return ApiResponse.success(service.receiveCarton(identity.operatorKey(), identity.operatorName(), request)); } @PostMapping("/current/complete") public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.Action request) { - Identity identity = currentIdentity(user); + PdaWarehouseIdentity identity = currentIdentity(user); return ApiResponse.success(service.complete(identity.operatorKey(), request)); } - private static Identity identity(AuthenticatedUser user) { - if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0 - || !"PLATFORM".equals(user.appCode())) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated platform warehouse PDA identity is required"); - } - if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required"); - } - if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "PDA operator must be bound to one overseas warehouse"); - } - String operator = String.valueOf(user.userId()); - return new Identity(operator, - user.username() == null || user.username().isBlank() ? operator : user.username(), - user.overseasWarehouseId()); + private static PdaWarehouseIdentity identity(AuthenticatedUser user) { + return PdaWarehouseIdentity.require(user); } - private Identity currentIdentity(AuthenticatedUser user) { - Identity identity = identity(user); + private PdaWarehouseIdentity currentIdentity(AuthenticatedUser user) { + PdaWarehouseIdentity identity = identity(user); service.requireCurrentWarehouse(identity.operatorKey(), identity.overseasWarehouseId()); return identity; } - - private record Identity(String operatorKey, String operatorName, long overseasWarehouseId) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/security/PdaWarehouseIdentity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/security/PdaWarehouseIdentity.java new file mode 100644 index 000000000..cbbf5139b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/security/PdaWarehouseIdentity.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.pda.security; + +import com.baiyue.common.security.AuthenticatedUser; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public record PdaWarehouseIdentity(String operatorKey, String operatorName, long overseasWarehouseId) { + public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; + public static final String PICKER_ROLE = "ROLE_WAREHOUSE_PICKER"; + + public static PdaWarehouseIdentity require(AuthenticatedUser user) { + if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0 + || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, + "Authenticated platform warehouse PDA identity is required"); + } + if (!user.permissions().contains(PICKER_ROLE)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "WAREHOUSE_PICKER role is required"); + } + if (!user.permissions().contains(OPERATE_PERMISSION)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required"); + } + if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + "PDA operator must be bound to one overseas warehouse"); + } + String operator = String.valueOf(user.userId()); + return new PdaWarehouseIdentity(operator, + user.username() == null || user.username().isBlank() ? operator : user.username(), + user.overseasWarehouseId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseRequests.java new file mode 100644 index 000000000..aaeef8112 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseRequests.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.pda.warehouse.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public final class PdaWarehouseRequests { + private PdaWarehouseRequests() { } + + public record Scan(@NotBlank @Size(max = 128) String code) { } + public record HistoryQuery(@Min(1) long page, @Min(1) @Max(200) long size, String operationType) { + public HistoryQuery { if (page == 0) page = 1; if (size == 0) size = 20; } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseResponses.java new file mode 100644 index 000000000..cb5b3921e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseResponses.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.pda.warehouse.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public final class PdaWarehouseResponses { + private PdaWarehouseResponses() { } + + public record InventoryScan(String queryCode, List items) { } + public record InventoryItem(String customerId, String customerName, long balanceId, long positionId, + String skuCode, String productName, String inboundCode, String cartonMark, + long palletId, String palletCode, Long locationId, String locationCode, + long availablePackages, long holdPackages, long receivingPackages, + String status, long version) { } + public record Location(long locationId, String locationCode, String locationType, BigDecimal maxLoadKg, + String status, long occupiedPackages, int inventoryPositionCount) { } + public record Pallet(long palletId, String palletCode, String status, int version, + long locationId, String locationCode, long occupiedPackages, + int inventoryPositionCount, List inventory) { } + public record Operation(long operationId, String operationType, String businessKey, String status, + String summary, String customerId, LocalDateTime occurredAt) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/entity/PdaOperationHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/entity/PdaOperationHistory.java new file mode 100644 index 000000000..59e428c49 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/entity/PdaOperationHistory.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.pda.warehouse.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_pda_operation_history") +public class PdaOperationHistory { + @TableId(type = IdType.INPUT) + private Long id; + private Long overseasWarehouseId; + private String customerId; + private String operatorKey; + private String operationType; + private String businessKey; + private String status; + private String idempotencyKey; + private String requestHash; + private String summary; + private LocalDateTime occurredAt; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java new file mode 100644 index 000000000..0fab0336f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java @@ -0,0 +1,72 @@ +package com.baiyue.lastleg.pda.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory; +import com.baiyue.lastleg.pda.warehouse.model.PdaInventoryProjection; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface PdaWarehouseMapper extends BaseMapper { + String INVENTORY_SELECT = "SELECT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," + + "COALESCE(NULLIF(r.sku_code,''),b.sku_code) sku_code," + + "COALESCE(NULLIF(r.product_name_zh,''),b.product_name_zh) product_name_zh," + + "COALESCE(NULLIF(r.product_name_en,''),b.product_name_en) product_name_en,b.inbound_code," + + "c.carton_mark,p.pallet_id,p.pallet_no pallet_code,p.warehouse_location_id location_id," + + "p.warehouse_location_code location_code,p.available_packages,p.hold_packages,p.receiving_packages," + + "p.status,p.version FROM ll_inventory_balance b JOIN ll_inventory_position p " + + "ON p.balance_id=b.id AND p.customer_id=b.customer_id " + + "LEFT JOIN ll_inbound_stock_receipt r ON r.inventory_balance_id=b.id AND r.inventory_position_id=p.id " + + "LEFT JOIN ll_inbound_plan_carton c ON c.customer_id=b.customer_id AND c.order_id=b.inbound_order_id " + + "AND c.warehouse_pallet_id=p.pallet_id AND c.carton_mark=#{code} AND c.status='RECEIVED' "; + + @Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} " + + "AND (UPPER(b.sku_code)=UPPER(#{code}) OR UPPER(r.sku_code)=UPPER(#{code}) " + + "OR b.inbound_code=#{code} OR c.carton_mark=#{code}) " + + "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + + "ORDER BY b.customer_id,b.inbound_code,p.pallet_no,p.id") + List scanInventory(@Param("warehouseId") String warehouseId, + @Param("code") String code); + + @Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " + + "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + + "ORDER BY b.customer_id,b.inbound_code,p.id") + List inventoryByPallet(@Param("warehouseId") String warehouseId, + @Param("palletId") long palletId, + @Param("code") String ignored); + + @Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} " + + "AND (p.warehouse_location_id=#{locationId} OR p.warehouse_location_code=#{locationCode}) " + + "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + + "ORDER BY b.customer_id,b.inbound_code,p.pallet_no,p.id") + List inventoryByLocation(@Param("warehouseId") String warehouseId, + @Param("locationId") long locationId, + @Param("locationCode") String locationCode, + @Param("code") String ignored); + + @Select("") + List history( + @Param("warehouseId") long warehouseId, @Param("operatorKey") String operatorKey, + @Param("operationType") String operationType, @Param("offset") long offset, @Param("limit") long limit); + + @Select("") + long countHistory(@Param("warehouseId") long warehouseId, @Param("operatorKey") String operatorKey, + @Param("operationType") String operationType); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/model/PdaInventoryProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/model/PdaInventoryProjection.java new file mode 100644 index 000000000..a34b41d97 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/model/PdaInventoryProjection.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.pda.warehouse.model; + +import lombok.Data; + +@Data +public class PdaInventoryProjection { + private String customerId; + private String customerName; + private Long balanceId; + private Long positionId; + private String skuCode; + private String productNameZh; + private String productNameEn; + private String inboundCode; + private String cartonMark; + private Long palletId; + private String palletCode; + private Long locationId; + private String locationCode; + private Long availablePackages; + private Long holdPackages; + private Long receivingPackages; + private String status; + private Long version; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java index b1d1b1803..b1afcaa80 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java @@ -75,6 +75,15 @@ public class PlatformUsTruckOrderController { return ApiResponse.success(service.changeStatus(requiredCustomer(customerId), orderNo, operator(user), request)); } + @PostMapping("/{orderNo}/review") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String orderNo, + @Valid @RequestBody UsTruckOrderRequests.Review request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.review(requiredCustomer(customerId), orderNo, operator(user), request)); + } + @PostMapping("/{orderNo}/receive-pallets") @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") @Operation(summary = "Receive pallets for a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_STATUS) @@ -85,6 +94,66 @@ public class PlatformUsTruckOrderController { return ApiResponse.success(service.receivePallets(requiredCustomer(customerId), orderNo, operator(user), request)); } + @PostMapping("/{orderNo}/review-pallet-overage") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + public ApiResponse reviewPalletOverage( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.ReviewPalletOverage request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.reviewPalletOverage( + requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PostMapping("/{orderNo}/allocate-pallets") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + public ApiResponse allocatePallets( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.AllocatePallets request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.allocatePallets( + requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PostMapping("/{orderNo}/prepare-dispatch") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + public ApiResponse prepareDispatch( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.prepareDispatch( + requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PostMapping("/{orderNo}/dispatch") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + public ApiResponse dispatch( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.dispatch( + requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PostMapping("/{orderNo}/start-transit") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + public ApiResponse startTransit( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.startTransit( + requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PostMapping("/{orderNo}/complete") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + public ApiResponse complete( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.complete( + requiredCustomer(customerId), orderNo, operator(user), request)); + } + private void requirePlatform(AuthenticatedUser user) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java index 120364ad3..9dc8d9646 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java @@ -4,7 +4,10 @@ import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; import java.math.BigDecimal; @@ -71,7 +74,31 @@ public final class UsTruckOrderRequests { public record ChangeStatus( @NotNull @Min(0) Long version, - @NotBlank @Size(max = 32) String status, + @NotBlank @Pattern(regexp = "CANCELLED") String status, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record Review( + @NotNull @Min(0) Long version, + @NotBlank @Pattern(regexp = "APPROVE|REJECT") String decision, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record ReviewPalletOverage( + @NotNull @Min(0) Long version, + @NotBlank @Pattern(regexp = "APPROVE|REJECT") String decision, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record AllocatePallets( + @NotNull @Min(0) Long version, + @NotEmpty @Size(max = 500) List<@NotNull @Positive Long> palletIds, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record Command( + @NotNull @Min(0) Long version, + @NotBlank @Size(max = 255) String idempotencyKey, @Size(max = 1000) String remark) {} public record ReceivePallets( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java index 6169954b8..2370d6afe 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java @@ -11,13 +11,14 @@ public final class UsTruckOrderResponses { public record Summary( Long id, String customerId, String orderNo, String transportType, String origin, String destination, String containerType, int palletCount, int packageCount, - int receivedPalletCount, int allocatedPalletCount, int shippedPalletCount, + int receivedPalletCount, int allocatedPalletCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, String currency, String contactName, String contactInfo, LocalDate requestedDate, String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {} public record History( - String fromStatus, String toStatus, String operatorId, String remark, LocalDateTime createdAt) {} + String operationType, String fromStatus, String toStatus, String operatorId, + String idempotencyKey, String remark, LocalDateTime createdAt) {} public record Pallet(Long id, String palletNo, int sequenceNo, String source, String status, Long warehousePalletId, Integer packageCount, LocalDateTime receivedAt) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java index 264ab0905..6f6d5eb79 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java @@ -25,7 +25,6 @@ public class UsTruckOrder { private Integer packageCount; private Integer receivedPalletCount; private Integer allocatedPalletCount; - private Integer shippedPalletCount; private BigDecimal totalWeightKg; private BigDecimal totalVolumeCbm; private BigDecimal budgetAmount; @@ -37,8 +36,6 @@ public class UsTruckOrder { private String remark; private String idempotencyKey; private String requestHash; - private String receiptIdempotencyKey; - private String receiptRequestHash; private String createdBy; private Long version; private LocalDateTime createdAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderOperation.java new file mode 100644 index 000000000..a9bfb4087 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderOperation.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.trucking.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_us_truck_order_operation") +public class UsTruckOrderOperation { + @TableId(type = IdType.INPUT) private Long id; + private Long orderId; + private String customerId; + private String operationType; + private String idempotencyKey; + private String requestHash; + private Long resultVersion; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java index be4fee492..3f4602de5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java @@ -13,9 +13,11 @@ public class UsTruckOrderStatusHistory { @TableId(type = IdType.INPUT) private Long id; private Long orderId; private String customerId; + private String operationType; private String fromStatus; private String toStatus; private String operatorId; + private String idempotencyKey; private String remark; private LocalDateTime createdAt; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderOperationMapper.java new file mode 100644 index 000000000..92d12f53e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderOperationMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.trucking.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.trucking.entity.UsTruckOrderOperation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface UsTruckOrderOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_us_truck_order_operation WHERE customer_id=#{customerId} AND order_id=#{orderId} AND idempotency_key=#{key} LIMIT 1") + UsTruckOrderOperation findKey(@Param("customerId") String customerId, + @Param("orderId") Long orderId, + @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java index 4dd11a600..7f336fd7f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java @@ -7,9 +7,11 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests; import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses; import com.baiyue.lastleg.trucking.entity.UsTruckOrder; +import com.baiyue.lastleg.trucking.entity.UsTruckOrderOperation; import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet; import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory; import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper; +import com.baiyue.lastleg.trucking.mapper.UsTruckOrderOperationMapper; import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper; import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper; import com.baiyue.lastleg.security.OverseasPermissions; @@ -30,6 +32,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.HexFormat; +import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.function.Consumer; @@ -55,20 +58,12 @@ public class UsTruckOrderService { private static final Set STATUSES = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED); - private static final java.util.Map> TRANSITIONS = java.util.Map.ofEntries( - java.util.Map.entry(PENDING_REVIEW, Set.of(WAITING_INBOUND, REJECTED, CANCELLED)), - java.util.Map.entry(WAITING_INBOUND, Set.of(RECEIVING, CANCELLED)), - java.util.Map.entry(RECEIVING, Set.of(WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, CANCELLED)), - java.util.Map.entry(PALLET_OVERAGE_REVIEW, Set.of(WAITING_CONSOLIDATION, CANCELLED)), - java.util.Map.entry(WAITING_CONSOLIDATION, Set.of(PARTIALLY_ALLOCATED, ALLOCATED, CANCELLED)), - java.util.Map.entry(PARTIALLY_ALLOCATED, Set.of(ALLOCATED, CANCELLED)), - java.util.Map.entry(ALLOCATED, Set.of(PENDING_DISPATCH, CANCELLED)), - java.util.Map.entry(PENDING_DISPATCH, Set.of(DISPATCHED, CANCELLED)), - java.util.Map.entry(DISPATCHED, Set.of(IN_TRANSIT)), - java.util.Map.entry(IN_TRANSIT, Set.of(COMPLETED))); + private static final Set CANCELLABLE = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING, + PALLET_OVERAGE_REVIEW, WAITING_CONSOLIDATION, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH); private static final Set TRANSPORT_TYPES = Set.of("PORT_PICKUP", "WAREHOUSE_TO_WAREHOUSE", "WAREHOUSE_TO_DOOR"); @Resource private UsTruckOrderMapper orders; + @Resource private UsTruckOrderOperationMapper operations; @Resource private UsTruckOrderPalletMapper pallets; @Resource private UsTruckOrderStatusHistoryMapper histories; @Resource private ObjectMapper objectMapper; @@ -90,7 +85,7 @@ public class UsTruckOrderService { order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress())); order.setContainerType(StringUtils.hasText(request.containerType()) ? norm(request.containerType()) : null); order.setPalletCount(request.palletCount()); order.setPackageCount(value(request.packageCount())); - order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setShippedPalletCount(0); + order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm()); order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency())); order.setContactName(request.contactName().trim()); order.setContactInfo(request.contactInfo().trim()); @@ -103,7 +98,7 @@ public class UsTruckOrderService { if (concurrent != null) return replay(customerId, concurrent, hash); throw e; } - history(order, null, PENDING_REVIEW, operatorId, request.remark(), now); + history(order, "CREATE", null, PENDING_REVIEW, operatorId, key, request.remark(), now); return detail(customerId, order.getOrderNo()); } @@ -150,7 +145,7 @@ public class UsTruckOrderService { setText(request.contactName(), order::setContactName); setText(request.contactInfo(), order::setContactInfo); if (request.requestedDate() != null) order.setRequestedDate(request.requestedDate()); setPresent(request.remark(), order::setRemark); touch(order); orders.updateById(order); - history(order, order.getStatus(), order.getStatus(), operatorId, "UPDATE", LocalDateTime.now()); + history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, null, "UPDATE", LocalDateTime.now()); return detail(customerId, orderNo); } @@ -158,34 +153,37 @@ public class UsTruckOrderService { public UsTruckOrderResponses.Detail changeStatus(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.ChangeStatus request) { UsTruckOrder order = lock(customerId, orderNo); - if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); - String next = norm(request.status()); if (!STATUSES.contains(next)) throw badRequest("Unsupported truck order status"); - String from = order.getStatus(); - if (!next.equals(from)) { - if (!TRANSITIONS.getOrDefault(from, Set.of()).contains(next)) { - throw conflict("Unsupported truck order transition: " + from + " -> " + next); - } - order.setStatus(next); - if (PENDING_REVIEW.equals(from) && WAITING_INBOUND.equals(next)) createPlannedPallets(order); - touch(order); orders.updateById(order); - history(order, from, next, operatorId, request.remark(), LocalDateTime.now()); + UsTruckOrderResponses.Detail replay = replayOperation(order, "CANCEL", request.idempotencyKey(), request); + if (replay != null) return replay; + requireVersion(order, request.version()); + if (!CANCELLED.equals(norm(request.status())) || !CANCELLABLE.contains(order.getStatus())) { + throw conflict("Only cancellable truck orders can use the generic status endpoint"); } - return detail(customerId, orderNo); + return transition(order, operatorId, "CANCEL", request.idempotencyKey(), request, + order.getStatus(), CANCELLED, request.remark()); + } + + @Transactional + public UsTruckOrderResponses.Detail review(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.Review request) { + UsTruckOrder order = lock(customerId, orderNo); + UsTruckOrderResponses.Detail replay = replayOperation(order, "REVIEW", request.idempotencyKey(), request); + if (replay != null) return replay; + requireVersion(order, request.version()); + if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Only pending-review truck orders can be reviewed"); + String next = "APPROVE".equals(norm(request.decision())) ? WAITING_INBOUND : REJECTED; + if (WAITING_INBOUND.equals(next)) createPlannedPallets(order); + return transition(order, operatorId, "REVIEW", request.idempotencyKey(), request, + PENDING_REVIEW, next, request.remark()); } @Transactional public UsTruckOrderResponses.Detail receivePallets(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.ReceivePallets request) { UsTruckOrder order = lock(customerId, orderNo); - String key = request.idempotencyKey().trim(); - String requestHash = hash(request); - if (key.equals(order.getReceiptIdempotencyKey())) { - if (!requestHash.equals(order.getReceiptRequestHash())) { - throw conflict("Receipt idempotency key was reused with different pallet data"); - } - return detail(customerId, orderNo); - } - if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); + UsTruckOrderResponses.Detail replay = replayOperation(order, "RECEIVE_PALLETS", request.idempotencyKey(), request); + if (replay != null) return replay; + requireVersion(order, request.version()); if (!Set.of(WAITING_INBOUND, RECEIVING).contains(order.getStatus())) { throw conflict("Only waiting or receiving orders can receive pallets"); } @@ -220,18 +218,118 @@ public class UsTruckOrderService { } } order.setReceivedPalletCount(actual); - order.setReceiptIdempotencyKey(key); - order.setReceiptRequestHash(requestHash); String from = order.getStatus(); String next = actual > planned ? PALLET_OVERAGE_REVIEW : actual == planned ? WAITING_CONSOLIDATION : RECEIVING; order.setStatus(next); touch(order); orders.updateById(order); - history(order, from, next, operatorId, request.remark(), now); + recordOperation(order, "RECEIVE_PALLETS", request.idempotencyKey(), request, operatorId, now); + history(order, "RECEIVE_PALLETS", from, next, operatorId, request.idempotencyKey(), request.remark(), now); return detail(customerId, orderNo); } + @Transactional + public UsTruckOrderResponses.Detail reviewPalletOverage(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.ReviewPalletOverage request) { + UsTruckOrder order = lock(customerId, orderNo); + UsTruckOrderResponses.Detail replay = replayOperation(order, "REVIEW_PALLET_OVERAGE", + request.idempotencyKey(), request); + if (replay != null) return replay; + requireVersion(order, request.version()); + if (!PALLET_OVERAGE_REVIEW.equals(order.getStatus())) { + throw conflict("Only pallet-overage-review orders can be reviewed"); + } + boolean approve = "APPROVE".equals(norm(request.decision())); + List rows = pallets.listOrder(customerId, order.getId()); + LocalDateTime now = LocalDateTime.now(); + int accepted = value(order.getPalletCount()); + for (UsTruckOrderPallet pallet : rows) { + if (!"OVERAGE_PENDING_REVIEW".equals(pallet.getStatus())) continue; + pallet.setStatus(approve ? "RECEIVED" : "OVERAGE_REJECTED"); + pallet.setUpdatedAt(now); + pallets.updateById(pallet); + } + if (approve) { + accepted = value(order.getReceivedPalletCount()); + order.setPalletCount(accepted); + } else { + order.setReceivedPalletCount(accepted); + } + order.setStatus(WAITING_CONSOLIDATION); + touch(order); + orders.updateById(order); + recordOperation(order, "REVIEW_PALLET_OVERAGE", request.idempotencyKey(), request, operatorId, now); + history(order, "REVIEW_PALLET_OVERAGE", PALLET_OVERAGE_REVIEW, WAITING_CONSOLIDATION, + operatorId, request.idempotencyKey(), request.remark(), now); + return detail(customerId, orderNo); + } + + @Transactional + public UsTruckOrderResponses.Detail allocatePallets(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.AllocatePallets request) { + UsTruckOrder order = lock(customerId, orderNo); + UsTruckOrderResponses.Detail replay = replayOperation(order, "ALLOCATE_PALLETS", + request.idempotencyKey(), request); + if (replay != null) return replay; + requireVersion(order, request.version()); + if (!Set.of(WAITING_CONSOLIDATION, PARTIALLY_ALLOCATED).contains(order.getStatus())) { + throw conflict("Only waiting-consolidation or partially-allocated orders can allocate pallets"); + } + Set requested = new HashSet<>(request.palletIds()); + if (requested.size() != request.palletIds().size()) throw badRequest("palletIds must be unique"); + List rows = pallets.listOrder(customerId, order.getId()); + List selected = rows.stream().filter(row -> requested.contains(row.getId())).toList(); + if (selected.size() != requested.size() || selected.stream().anyMatch(row -> !"RECEIVED".equals(row.getStatus()))) { + throw conflict("Selected pallets must belong to the order and be received but unallocated"); + } + LocalDateTime now = LocalDateTime.now(); + for (UsTruckOrderPallet pallet : selected) { + pallet.setStatus("ALLOCATED"); + pallet.setUpdatedAt(now); + pallets.updateById(pallet); + } + int allocated = (int) rows.stream().filter(row -> "ALLOCATED".equals(row.getStatus())).count() + + selected.size(); + int accepted = value(order.getReceivedPalletCount()); + if (accepted < 1 || allocated > accepted) throw conflict("Allocated pallet count exceeds accepted pallets"); + String from = order.getStatus(); + String next = allocated == accepted ? ALLOCATED : PARTIALLY_ALLOCATED; + order.setAllocatedPalletCount(allocated); + order.setStatus(next); + touch(order); + orders.updateById(order); + recordOperation(order, "ALLOCATE_PALLETS", request.idempotencyKey(), request, operatorId, now); + history(order, "ALLOCATE_PALLETS", from, next, operatorId, + request.idempotencyKey(), request.remark(), now); + return detail(customerId, orderNo); + } + + @Transactional + public UsTruckOrderResponses.Detail prepareDispatch(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.Command request) { + return command(customerId, orderNo, operatorId, "PREPARE_DISPATCH", ALLOCATED, + PENDING_DISPATCH, request); + } + + @Transactional + public UsTruckOrderResponses.Detail dispatch(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.Command request) { + return command(customerId, orderNo, operatorId, "DISPATCH", PENDING_DISPATCH, DISPATCHED, request); + } + + @Transactional + public UsTruckOrderResponses.Detail startTransit(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.Command request) { + return command(customerId, orderNo, operatorId, "START_TRANSIT", DISPATCHED, IN_TRANSIT, request); + } + + @Transactional + public UsTruckOrderResponses.Detail complete(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.Command request) { + return command(customerId, orderNo, operatorId, "COMPLETE", IN_TRANSIT, COMPLETED, request); + } + private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = orders.lockNo(customerId, orderNo); if (o == null) throw notFound(); return o; } private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); } private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List hs) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java index 8270d8f01..850071823 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java @@ -5,8 +5,7 @@ import com.baiyue.lastleg.dispatch.controller.DispatchController; import com.baiyue.lastleg.dispatch.controller.DispatchQueryController; import com.baiyue.lastleg.dispatch.controller.SmartDispatchController; import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController; -import com.baiyue.lastleg.inboundplan.pda.controller.InboundPalletAssignmentController; -import com.baiyue.lastleg.inboundplan.pda.controller.InboundPdaController; +import com.baiyue.lastleg.inboundplan.pda.controller.ReceivingContainerController; import com.baiyue.lastleg.inventory.command.controller.InventoryCommandController; import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -26,10 +25,17 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class Phase4BrowserApiBoundaryTest { @Test - void inboundPdaAndUnloadPhotoApisUseThePdaAccessDomain() { - assertTrue(basePath(InboundPdaController.class).startsWith("/api/pda/v1/")); - assertTrue(basePath(InboundPalletAssignmentController.class).startsWith("/api/pda/v1/")); - assertFalse(basePath(InboundPdaController.class).contains("/api/platform/")); + void onlyStableContainerReceivingUsesThePdaInboundAccessDomain() throws Exception { + assertTrue(basePath(ReceivingContainerController.class).startsWith("/api/pda/v1/")); + assertFalse(basePath(ReceivingContainerController.class).contains("/api/platform/")); + Path controllers = Path.of("src/main/java/com/baiyue/lastleg/inboundplan/pda/controller"); + String source = Files.walk(controllers).filter(path -> path.toString().endsWith(".java")) + .map(path -> { + try { return Files.readString(path); } + catch (Exception exception) { throw new IllegalStateException(exception); } + }).collect(Collectors.joining("\n")); + assertFalse(source.contains("/api/pda/v1/last-leg/inbound-plans/{planNo}")); + assertFalse(source.contains("@RequestParam(required = false) String customerId")); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentControllerTest.java deleted file mode 100644 index b00867007..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentControllerTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.baiyue.lastleg.inboundplan.pda.controller; - -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests; -import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService; -import org.junit.jupiter.api.Test; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.web.server.ResponseStatusException; - -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; - -class InboundPalletAssignmentControllerTest { - private final InboundPdaWorkflowService service = mock(InboundPdaWorkflowService.class); - private final InboundPalletAssignmentController controller = controller(); - private final PdaRequests.Assign request = new PdaRequests.Assign(1L, 2L, 3L, "assign-key"); - - @Test - void assignmentRequiresPdaOperatePermissionAndExplicitPlatformCustomer() { - assertThrows(ResponseStatusException.class, - () -> controller.assign(user(Set.of()), "PLAN-1", 4L, "100", request)); - assertThrows(ResponseStatusException.class, - () -> controller.assign(user(Set.of(InboundPdaController.OPERATE_PERMISSION)), "PLAN-1", 4L, null, request)); - - controller.assign(user(Set.of(InboundPdaController.OPERATE_PERMISSION)), "PLAN-1", 4L, "100", request); - - verify(service).assign("100", "PLAN-1", 4L, request); - } - - private AuthenticatedUser user(Set permissions) { - return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "PDA", permissions); - } - - private InboundPalletAssignmentController controller() { - InboundPalletAssignmentController value = new InboundPalletAssignmentController(); - ReflectionTestUtils.setField(value, "service", service); - return value; - } -} diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index bd977c3e0..2fd8d05f9 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1029,13 +1029,111 @@ export async function run(context) { const dynamicCandidatesResponse = await context.request('GET', `${smartLoadingBase}/candidates?bookingId=${encodeURIComponent(containerBookingId)}&warehouseId=${encodeURIComponent(warehouseId)}`) const dynamicCandidates = context.apiSuccess(MODULE, '查询同柜动态增量候选', dynamicCandidatesResponse, '', OPTIONS) - const dynamicCandidate = Array.isArray(dynamicCandidates) ? dynamicCandidates.find((item) => - id(item?.inboundId) === incrementalInboundId && Number(item?.availablePackageCount) > 0) : null + const markerOwnedDynamicCandidates = Array.isArray(dynamicCandidates) ? dynamicCandidates.filter((item) => + id(item?.inboundId) === incrementalInboundId && Number(item?.availablePackageCount) > 0) : [] + const dynamicCandidate = markerOwnedDynamicCandidates[0] + const skippedExceptionCandidate = markerOwnedDynamicCandidates[1] + const partialExceptionCandidate = markerOwnedDynamicCandidates.find((item, index) => + index > 1 && Number(item?.availablePackageCount) >= 2) context.assert(MODULE, '动态增量候选保持客户、仓库和渠道隔离', dynamicCandidatesResponse, Boolean(dynamicCandidate?.inboundDetailId) && id(dynamicCandidate?.accountId) === tenantId && id(dynamicCandidate?.warehouseId) === warehouseId && dynamicCandidate?.channelCode === initial?.summary?.channelCode, `inboundId=${incrementalInboundId}, detailId=${id(dynamicCandidate?.inboundDetailId) || 'missing'}, available=${dynamicCandidate?.availablePackageCount ?? 'missing'}`, OPTIONS) + context.assert(MODULE, '本轮增量 fixture 提供独立 SKIP 与 PARTIAL 合法货物明细', dynamicCandidatesResponse, + Boolean(skippedExceptionCandidate?.inboundDetailId) && Boolean(partialExceptionCandidate?.inboundDetailId) + && id(skippedExceptionCandidate?.inboundDetailId) !== id(partialExceptionCandidate?.inboundDetailId) + && id(dynamicCandidate?.inboundDetailId) !== id(skippedExceptionCandidate?.inboundDetailId) + && id(dynamicCandidate?.inboundDetailId) !== id(partialExceptionCandidate?.inboundDetailId), + `candidateCount=${markerOwnedDynamicCandidates.length}, skipDetailId=${id(skippedExceptionCandidate?.inboundDetailId) || 'missing'}, partialDetailId=${id(partialExceptionCandidate?.inboundDetailId) || 'missing'}`, OPTIONS) + if (skippedExceptionCandidate?.inboundDetailId && partialExceptionCandidate?.inboundDetailId) { + const beforeSkipPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) + const beforeSkipPlan = context.apiSuccess(MODULE, 'SKIP 异常场景前读取计划即时版本', beforeSkipPlanResponse, '', OPTIONS) + const skipAssignmentResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/assignments`, { + inboundId: incrementalInboundId, inboundDetailId: id(skippedExceptionCandidate.inboundDetailId), packageCount: 1, + expectedVersion: Number(beforeSkipPlan?.version), operationId: `${context.marker}-exception-skip-assign`.slice(0, 128), + }) + const skipAssignedPlan = context.apiSuccess(MODULE, '加入本轮待跳拣异常明细', skipAssignmentResponse, '', OPTIONS) + const skipPlanItem = skipAssignedPlan?.items?.find?.((item) => + id(item?.inboundDetailId) === id(skippedExceptionCandidate.inboundDetailId)) + const skipTaskResponse = await context.request('GET', taskDetailPath) + const skipTask = context.apiSuccess(MODULE, '读取待跳拣异常 PDA 明细', skipTaskResponse, '', OPTIONS) + const skipTaskItem = skipTask?.items?.find?.((item) => id(item?.loadingPlanItemId) === id(skipPlanItem?.id)) + const skipInventoryResponse = await context.request('GET', + `${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`) + const skipInventory = context.apiSuccess(MODULE, '跳拣前刷新实时库存快照', skipInventoryResponse, '', OPTIONS) + const skipReason = `${context.marker}-箱唛破损待复核` + const skipBatchResponse = await request(context, 'POST', batchPath, { + warehouseId, loadingPlanId, containerBookingId, + expectedVersion: Number(skipTask?.version), inventorySnapshotToken: skipInventory?.snapshotToken, + items: [{ itemId: id(skipTaskItem?.id), action: 'SKIP', version: Number(skipTaskItem?.version), + reason: skipReason }], + }, { 'Idempotency-Key': `${context.marker}-exception-skip-batch`.slice(0, 128) }) + const skippedTask = context.apiSuccess(MODULE, 'SKIP 携带异常原因并原子解决待拣明细', skipBatchResponse, '', OPTIONS) + const skippedTaskItem = skippedTask?.items?.find?.((item) => id(item?.id) === id(skipTaskItem?.id)) + context.assert(MODULE, '合法 SKIP 保存零实拣事实并恢复 AWAITING_REVIEW', skipBatchResponse, + Boolean(skipReason.trim()) && skippedTaskItem?.itemStatus === 'SKIPPED' + && Number(skippedTaskItem?.pickedPackageCount) === 0 + && skippedTask?.workflowStatus === 'AWAITING_REVIEW' && skippedTask?.taskStatus === 'LOADING', + `itemStatus=${skippedTaskItem?.itemStatus || 'missing'}, picked=${skippedTaskItem?.pickedPackageCount ?? 'missing'}, workflow=${skippedTask?.workflowStatus || 'missing'}`, OPTIONS) + + const beforePartialPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) + const beforePartialPlan = context.apiSuccess(MODULE, 'PARTIAL 异常场景前读取计划即时版本', beforePartialPlanResponse, '', OPTIONS) + const partialAssignmentResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/assignments`, { + inboundId: incrementalInboundId, inboundDetailId: id(partialExceptionCandidate.inboundDetailId), packageCount: 2, + expectedVersion: Number(beforePartialPlan?.version), operationId: `${context.marker}-exception-partial-assign`.slice(0, 128), + }) + const partialAssignedPlan = context.apiSuccess(MODULE, '加入本轮部分拣货异常明细', partialAssignmentResponse, '', OPTIONS) + const partialPlanItem = partialAssignedPlan?.items?.find?.((item) => + id(item?.inboundDetailId) === id(partialExceptionCandidate.inboundDetailId)) + const unresolvedTaskResponse = await context.request('GET', taskDetailPath) + const unresolvedTask = context.apiSuccess(MODULE, '读取存在未解决异常的 PDA 任务', unresolvedTaskResponse, '', OPTIONS) + const unresolvedTaskItem = unresolvedTask?.items?.find?.((item) => + id(item?.loadingPlanItemId) === id(partialPlanItem?.id)) + const operationsBeforeUnresolvedCompleteResponse = await context.request('GET', + `${pdaBase}/tasks/${canonicalTaskId}/operations`) + const operationsBeforeUnresolvedComplete = context.apiSuccess(MODULE, + '未解决异常完成前读取 PDA 操作审计基线', operationsBeforeUnresolvedCompleteResponse, '', OPTIONS) + const unresolvedComplete = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`, { + expectedTaskVersion: Number(unresolvedTask?.version), + operationId: `${context.marker}-complete-with-unresolved`.slice(0, 128), + remark: `${context.marker}-must-reject-unresolved`, + }, { 'Idempotency-Key': `${context.marker}-complete-with-unresolved-key`.slice(0, 128) }) + context.addStep(MODULE, '存在未解决异常明细时禁止确认装柜完成', unresolvedComplete, + unresolvedComplete.http === 200 && codeOf(unresolvedComplete) === 'A0409' + && String(unresolvedComplete.body?.message || '').includes('尚未处理'), + responseEvidence(unresolvedComplete), OPTIONS) + const afterUnresolvedCompleteTaskResponse = await context.request('GET', taskDetailPath) + const afterUnresolvedCompleteTask = context.apiSuccess(MODULE, + '未解决异常完成失败后回查 PDA 任务', afterUnresolvedCompleteTaskResponse, '', OPTIONS) + const operationsAfterUnresolvedCompleteResponse = await context.request('GET', + `${pdaBase}/tasks/${canonicalTaskId}/operations`) + const operationsAfterUnresolvedComplete = context.apiSuccess(MODULE, + '未解决异常完成失败后回查 PDA 操作审计', operationsAfterUnresolvedCompleteResponse, '', OPTIONS) + context.assert(MODULE, '未解决异常完成失败后 task/items/operation 全部零写入', afterUnresolvedCompleteTaskResponse, + Number(afterUnresolvedCompleteTask?.version) === Number(unresolvedTask?.version) + && taskItemsSignature(afterUnresolvedCompleteTask) === taskItemsSignature(unresolvedTask) + && operationsSignature(operationsAfterUnresolvedComplete) === operationsSignature(operationsBeforeUnresolvedComplete), + `taskVersion=${afterUnresolvedCompleteTask?.version ?? 'missing'}, operations=${operationsAfterUnresolvedComplete?.length ?? 'missing'}`, OPTIONS) + + const partialInventoryResponse = await context.request('GET', + `${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`) + const partialInventory = context.apiSuccess(MODULE, '部分拣货前刷新实时库存快照', partialInventoryResponse, '', OPTIONS) + const partialBatchResponse = await request(context, 'POST', batchPath, { + warehouseId, loadingPlanId, containerBookingId, + expectedVersion: Number(afterUnresolvedCompleteTask?.version), + inventorySnapshotToken: partialInventory?.snapshotToken, + items: [{ itemId: id(unresolvedTaskItem?.id), action: 'PARTIAL', finalPickedPackageCount: 1, + releaseMode: 'MANUAL', locationIds: [], version: Number(unresolvedTaskItem?.version) }], + }, { 'Idempotency-Key': `${context.marker}-exception-partial-batch`.slice(0, 128) }) + const partialTask = context.apiSuccess(MODULE, '合法 PARTIAL 解决未完成明细', partialBatchResponse, '', OPTIONS) + const partialTaskItem = partialTask?.items?.find?.((item) => id(item?.id) === id(unresolvedTaskItem?.id)) + context.assert(MODULE, '部分拣货数量守恒并恢复最终可确认状态', partialBatchResponse, + partialTaskItem?.itemStatus === 'PARTIAL' && Number(partialTaskItem?.plannedPackageCount) === 2 + && Number(partialTaskItem?.pickedPackageCount) === 1 + && partialTask?.workflowStatus === 'AWAITING_REVIEW' && partialTask?.taskStatus === 'LOADING', + `itemStatus=${partialTaskItem?.itemStatus || 'missing'}, planned=${partialTaskItem?.plannedPackageCount ?? 'missing'}, picked=${partialTaskItem?.pickedPackageCount ?? 'missing'}, workflow=${partialTask?.workflowStatus || 'missing'}`, OPTIONS) + } if (dynamicCandidate?.inboundDetailId) { const planBeforeIncrementResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) const planBeforeIncrement = context.apiSuccess(MODULE, '读取 LOADING 动态增量前计划', planBeforeIncrementResponse, '', OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 4d1662b28..d54cf72d4 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -458,6 +458,32 @@ test('phase3 loading proves stale inventory zero-write and defers final completi assert.doesNotMatch(loading, /complete-before-downstream/) }) +test('phase3 loading resolves legal SKIP and PARTIAL exceptions before downstream completion', () => { + const loading = source('./phase3-loading.mjs') + assertOrder(loading, [ + '本轮增量 fixture 提供独立 SKIP 与 PARTIAL 合法货物明细', + '加入本轮待跳拣异常明细', + 'SKIP 携带异常原因并原子解决待拣明细', + '合法 SKIP 保存零实拣事实并恢复 AWAITING_REVIEW', + '加入本轮部分拣货异常明细', + '未解决异常完成前读取 PDA 操作审计基线', + '存在未解决异常明细时禁止确认装柜完成', + '未解决异常完成失败后 task/items/operation 全部零写入', + '合法 PARTIAL 解决未完成明细', + '部分拣货数量守恒并恢复最终可确认状态', + '报关与最终完成前保持 LOADING 且不提前写 COMPLETE_LOADING', + ]) + assert.match(loading, /action: 'SKIP', version: Number\(skipTaskItem\?\.version\),\s*reason: skipReason/) + assert.match(loading, /skipReason = `\$\{context\.marker\}-箱唛破损待复核`/) + assert.match(loading, /itemStatus === 'SKIPPED'[\s\S]*pickedPackageCount\) === 0/) + assert.match(loading, /action: 'PARTIAL', finalPickedPackageCount: 1/) + assert.match(loading, /plannedPackageCount\) === 2[\s\S]*pickedPackageCount\) === 1/) + assert.match(loading, /String\(unresolvedComplete\.body\?\.message \|\| ''\)\.includes\('尚未处理'\)/) + assert.match(loading, /taskItemsSignature\(afterUnresolvedCompleteTask\) === taskItemsSignature\(unresolvedTask\)/) + assert.match(loading, /operationsSignature\(operationsAfterUnresolvedComplete\) === operationsSignature\(operationsBeforeUnresolvedComplete\)/) + assert.doesNotMatch(loading, /addCoverageGap\([^)]*(SKIP|PARTIAL|未解决异常)/) +}) + test('phase3 loading fixture forces A0 through reversible public location status APIs', () => { const entry = source('../admin-test-env-acceptance.mjs') const measurement = source('./inbound-measurement.mjs') diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java index 7ede1b836..be951ca52 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java @@ -87,7 +87,8 @@ public class PlatformWarehouseResourceController { } @PostMapping("/pallets") - @Operation(summary = "Create a warehouse pallet", description = "Platform access required") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_CREATE + "')") + @Operation(summary = "Create a warehouse pallet", description = "Required authority: " + OverseasWarehousePermissions.PALLET_CREATE) public ApiResponse createPallet(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId, @Valid @RequestBody PalletRequests.SaveRequest request) { diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java index 6af72807f..65588f077 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java @@ -10,7 +10,6 @@ import java.util.Map; import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; class WarehouseResourcePermissionContractTest { private static final String PREFIX = "platform:overseas-warehouse:"; @@ -28,15 +27,9 @@ class WarehouseResourcePermissionContractTest { assertPermission(methods.get("deleteLocation"), PREFIX + "location:delete"); assertPermission(methods.get("pallets"), PREFIX + "pallet:list"); assertPermission(methods.get("pallet"), PREFIX + "pallet:list"); + assertPermission(methods.get("createPallet"), PREFIX + "pallet:create"); assertPermission(methods.get("updatePallet"), PREFIX + "pallet:edit"); assertPermission(methods.get("deletePallet"), PREFIX + "pallet:delete"); - - Method createPallet = Arrays.stream(PlatformWarehouseResourceController.class.getDeclaredMethods()) - .filter(method -> "createPallet".equals(method.getName())) - .findFirst() - .orElseThrow(); - assertNull(createPallet.getAnnotation(PreAuthorize.class)); - assertEquals("Platform access required", createPallet.getAnnotation(Operation.class).description()); } private static void assertPermission(Method method, String authority) { From cb78bc6b41ee375e90a0c14d74ebafc5df3615e9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 09:58:59 +0800 Subject: [PATCH 082/115] test: cover partial and concurrent pda completion --- ...e3-loading-dynamic-final-contract.test.mjs | 23 ++++++++ .../phase3-loading-finalize.mjs | 52 ++++++++++++++++--- .../admin-test-env-modules/phase3-loading.mjs | 9 ++++ .../phase3-producer-chain-contract.test.mjs | 4 ++ 4 files changed, 81 insertions(+), 7 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index 8e604f641..39cdbb7f6 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -142,3 +142,26 @@ test('HTTP finalizer preserves failure evidence and verifies the settled slice', assert.match(finalize, /traceId=\$\{completeResponse\.traceId \|\| 'missing'\}/) assert.match(finalize, /settlementMode = remainderSlice \? 'PARTIAL' : 'FULL'/) }) + +test('HTTP finalizer races distinct completion keys and persists exactly one terminal audit', () => { + ordered(finalize, [ + '读取结算前 PDA 操作历史基线', + 'const completeAttempts = [1, 2].map', + 'const concurrentCompleteResponses = await Promise.all', + '并发完成后回查 PDA 操作审计', + '两个不同幂等键并发 complete 仅一成功且只写一条完成审计', + '订舱与五类证据等保留门禁齐备后完成装柜', + 'LOADED 完成请求 Idempotency-Key 重放保持终态', + '入库单终态 LOADED、版本递增且审计历史存在', + ]) + assert.match(finalize, /pda-final-complete-concurrent-\$\{index\}/) + assert.match(finalize, /p3-complete-concurrent-key-\$\{index\}/) + assert.match(finalize, /successfulCompleteIndexes\.length === 1/) + assert.match(finalize, /conflictedCompleteIndexes\.length === 1/) + assert.match(finalize, /codeOf\(response\) === 'A0409'/) + assert.match(finalize, /Boolean\(conflictedCompleteResponse\?\.requestId\)/) + assert.match(finalize, /Boolean\(conflictedCompleteResponse\?\.traceId\)/) + assert.match(finalize, /operationsAfterConcurrent\.length === operationsBefore\.length \+ 1/) + assert.match(finalize, /concurrentCompleteOperations\.length === 1/) + assert.match(finalize, /concurrentCompleteOperations\[0\]\?\.operationId === completeBody\.operationId/) +}) diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index c77b313ae..05c4fbb18 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -129,13 +129,50 @@ export async function run(context) { packageCount: Number(item?.packageCount), picked: Number(item?.pickedPackageCount) })), }) const operationsBeforeSignature = JSON.stringify(operationsBefore || []) - const completeBody = { - expectedTaskVersion: Number(taskBefore.version), - operationId: `${context.marker}-pda-final-complete`.slice(0, 128), remark: context.marker, - } - const completeKey = `${context.marker}-p3-complete-after-downstream`.slice(0, 128) - const completeResponse = await request(context, 'POST', `${taskPath}/complete`, completeBody, - { 'Idempotency-Key': completeKey }) + const completeAttempts = [1, 2].map((index) => ({ + body: { + expectedTaskVersion: Number(taskBefore.version), + operationId: `${context.marker}-pda-final-complete-concurrent-${index}`.slice(0, 128), + remark: context.marker, + }, + key: `${context.marker}-p3-complete-concurrent-key-${index}`.slice(0, 128), + })) + const concurrentCompleteResponses = await Promise.all(completeAttempts.map((attempt) => + request(context, 'POST', `${taskPath}/complete`, attempt.body, + { 'Idempotency-Key': attempt.key }))) + const successfulCompleteIndexes = concurrentCompleteResponses.flatMap((response, index) => + response.http === 200 && codeOf(response) === '200' ? [index] : []) + const conflictedCompleteIndexes = concurrentCompleteResponses.flatMap((response, index) => + response.http === 200 && codeOf(response) === 'A0409' ? [index] : []) + const conflictedCompleteResponse = conflictedCompleteIndexes.length === 1 + ? concurrentCompleteResponses[conflictedCompleteIndexes[0]] : null + const winningCompleteIndex = successfulCompleteIndexes[0] ?? -1 + const completeResponse = winningCompleteIndex >= 0 + ? concurrentCompleteResponses[winningCompleteIndex] : concurrentCompleteResponses[0] + const completeBody = winningCompleteIndex >= 0 + ? completeAttempts[winningCompleteIndex].body : completeAttempts[0].body + const completeKey = winningCompleteIndex >= 0 + ? completeAttempts[winningCompleteIndex].key : completeAttempts[0].key + const operationsAfterConcurrentResponse = await context.request('GET', `${taskPath}/operations`) + const operationsAfterConcurrent = context.apiSuccess(MODULE, + '并发完成后回查 PDA 操作审计', operationsAfterConcurrentResponse, '', OPTIONS) + const baselineCompleteOperationIds = new Set((operationsBefore || []) + .filter((item) => item?.actionType === 'COMPLETE_LOADING').map((item) => String(item?.operationId || ''))) + const concurrentCompleteOperations = (operationsAfterConcurrent || []).filter((item) => + item?.actionType === 'COMPLETE_LOADING' + && completeAttempts.some((attempt) => attempt.body.operationId === item?.operationId) + && !baselineCompleteOperationIds.has(String(item?.operationId || ''))) + const concurrentCompleteVerified = successfulCompleteIndexes.length === 1 + && conflictedCompleteIndexes.length === 1 + && Boolean(conflictedCompleteResponse?.requestId) && Boolean(conflictedCompleteResponse?.traceId) + && Array.isArray(operationsAfterConcurrent) && Array.isArray(operationsBefore) + && operationsAfterConcurrent.length === operationsBefore.length + 1 + && concurrentCompleteOperations.length === 1 + && concurrentCompleteOperations[0]?.operationId === completeBody.operationId + && id(concurrentCompleteOperations[0]?.operatorId) && Boolean(concurrentCompleteOperations[0]?.createdAt) + context.assert(MODULE, '两个不同幂等键并发 complete 仅一成功且只写一条完成审计', + completeResponse, concurrentCompleteVerified, + `results=${concurrentCompleteResponses.map((response) => `${response.http}/${codeOf(response)}`).join(',')}, success=${successfulCompleteIndexes.length}, conflict=${conflictedCompleteIndexes.length}, completeAudits=${concurrentCompleteOperations.length}, winner=${completeBody.operationId}`, OPTIONS) if (completeResponse.http !== 200 || codeOf(completeResponse) !== '200') { context.addStep(MODULE, '结算递增 inboundVersion 后 final complete 必须使用最新版本', completeResponse, false, `expected=success after settlement refresh; HTTP=${completeResponse.http}, code=${codeOf(completeResponse)}, message=${completeResponse.body?.message || 'missing'}, taskId=${taskId}, inboundId=${inboundId}, taskVersion=${taskBefore?.version ?? 'missing'}, inboundVersion=${version}, requestId=${completeResponse.requestId || 'missing'}, traceId=${completeResponse.traceId || 'missing'}`, OPTIONS) @@ -166,6 +203,7 @@ export async function run(context) { `taskUnchanged=${taskAfterSignature === taskBeforeSignature}, planUnchanged=${planAfterSignature === planBeforeSignature}, inboundStatus=${inboundAfterFailure?.summary?.processStatus || 'missing'}, inboundVersion=${inboundAfterFailure?.summary?.version ?? 'missing'}, operationsUnchanged=${JSON.stringify(operationsAfterFailure || []) === operationsBeforeSignature}`, OPTIONS) return } + if (!concurrentCompleteVerified) return const completed = context.apiSuccess(MODULE, '订舱与五类证据等保留门禁齐备后完成装柜', completeResponse, '', OPTIONS) context.assert(MODULE, '统一硬门禁通过后 task 与 workflow 同事务完成且身份不漂移', completeResponse, completed?.taskStatus === 'COMPLETED' && completed?.workflowStatus === 'COMPLETED' diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 2fd8d05f9..19dc4aa1d 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1133,6 +1133,15 @@ export async function run(context) { && Number(partialTaskItem?.pickedPackageCount) === 1 && partialTask?.workflowStatus === 'AWAITING_REVIEW' && partialTask?.taskStatus === 'LOADING', `itemStatus=${partialTaskItem?.itemStatus || 'missing'}, planned=${partialTaskItem?.plannedPackageCount ?? 'missing'}, picked=${partialTaskItem?.pickedPackageCount ?? 'missing'}, workflow=${partialTask?.workflowStatus || 'missing'}`, OPTIONS) + const exceptionOverviewResponse = await context.request('GET', + `/api/platform/v1/containers/${encodeURIComponent(containerBookingId)}/overview`) + const exceptionOverview = context.apiSuccess(MODULE, + '回查柜次 PDA 异常原因与已解决进度', exceptionOverviewResponse, '', OPTIONS) + context.assert(MODULE, 'SKIP 异常原因持久化且 PARTIAL 后无未解决明细', exceptionOverviewResponse, + exceptionOverview?.pda?.exceptionReasons?.includes?.(skipReason) + && Number(exceptionOverview?.pda?.unresolvedItemCount) === 0 + && exceptionOverview?.pda?.workflowStatus === 'AWAITING_REVIEW', + `exceptionReasons=${JSON.stringify(exceptionOverview?.pda?.exceptionReasons || [])}, unresolved=${exceptionOverview?.pda?.unresolvedItemCount ?? 'missing'}, workflow=${exceptionOverview?.pda?.workflowStatus || 'missing'}`, OPTIONS) } if (dynamicCandidate?.inboundDetailId) { const planBeforeIncrementResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index d54cf72d4..6150e4891 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -471,6 +471,8 @@ test('phase3 loading resolves legal SKIP and PARTIAL exceptions before downstrea '未解决异常完成失败后 task/items/operation 全部零写入', '合法 PARTIAL 解决未完成明细', '部分拣货数量守恒并恢复最终可确认状态', + '回查柜次 PDA 异常原因与已解决进度', + 'SKIP 异常原因持久化且 PARTIAL 后无未解决明细', '报关与最终完成前保持 LOADING 且不提前写 COMPLETE_LOADING', ]) assert.match(loading, /action: 'SKIP', version: Number\(skipTaskItem\?\.version\),\s*reason: skipReason/) @@ -481,6 +483,8 @@ test('phase3 loading resolves legal SKIP and PARTIAL exceptions before downstrea assert.match(loading, /String\(unresolvedComplete\.body\?\.message \|\| ''\)\.includes\('尚未处理'\)/) assert.match(loading, /taskItemsSignature\(afterUnresolvedCompleteTask\) === taskItemsSignature\(unresolvedTask\)/) assert.match(loading, /operationsSignature\(operationsAfterUnresolvedComplete\) === operationsSignature\(operationsBeforeUnresolvedComplete\)/) + assert.match(loading, /exceptionOverview\?\.pda\?\.exceptionReasons\?\.includes\?\.\(skipReason\)/) + assert.match(loading, /Number\(exceptionOverview\?\.pda\?\.unresolvedItemCount\) === 0/) assert.doesNotMatch(loading, /addCoverageGap\([^)]*(SKIP|PARTIAL|未解决异常)/) }) From 52b9fc382ff111d1344f5703d4b87aaa2eca85b0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 10:31:04 +0800 Subject: [PATCH 083/115] feat: gate overseas receiving and harden phase4 workflows --- auth/AGENTS.md | 1 + .../controller/InternalAccountController.java | 16 ++ .../dto/OverseasSessionValidationDtos.java | 29 ++ .../OverseasSessionValidationService.java | 38 +++ .../auth/service/OverseasTokenService.java | 1 + ...ssionValidationControllerContractTest.java | 28 ++ .../auth/service/AuthServiceSmallJwtTest.java | 27 ++ .../OverseasSessionValidationServiceTest.java | 77 +++++ .../service/OverseasTokenServiceTest.java | 52 ++++ ...rstLegContainerShippingNodeRecordedV1.java | 8 +- ...egContainerShippingNodeRecordedV1Test.java | 6 + config/nacos/overseas/baiyue-common.yml | 4 + deploy/docker-compose.overseas.yml | 2 + deploy/scripts/deploy-built-service.sh | 7 + deploy/scripts/deploy-services.sh | 9 + deploy/sql/头程数据库统一增量-MySQL8.sql | 75 +++++ deploy/sql/海外区域统一增量-MySQL8.sql | 68 +++++ .../service/OutboundDispatchService.java | 5 + .../CarrierScheduleQueryRepository.java | 16 ++ .../CarrierScheduleQueryScheduler.java | 34 +-- .../service/CarrierScheduleQueryService.java | 49 +++- .../openapi/phase3-outbound-dispatch-v1.yaml | 3 +- .../dispatch/OutboundDispatchServiceTest.java | 19 ++ .../OutboundDispatchSqlContractTest.java | 1 + .../CarrierScheduleQuerySqlContractTest.java | 14 + .../CarrierScheduleNodeOutboxServiceTest.java | 6 +- .../CarrierScheduleQuerySchedulerTest.java | 39 +++ .../CarrierScheduleQueryServiceTest.java | 66 +++++ gateway-overseas/AGENTS.md | 1 + .../gateway/config/GatewaySecurityConfig.java | 4 +- .../PdaSessionValidationProperties.java | 25 ++ .../config/RequiredClaimsJwtValidator.java | 6 +- .../HttpPdaSessionValidationClient.java | 46 +++ .../filter/PdaSessionValidationClient.java | 10 + .../filter/PdaSessionValidationFilter.java | 92 ++++++ .../src/main/resources/application.yml | 4 + ...ssionValidationDeploymentContractTest.java | 43 +++ .../RequiredClaimsJwtValidatorTest.java | 15 +- .../PdaSessionValidationFilterTest.java | 119 ++++++++ .../main/resources/db/lastleg-permissions.sql | 75 +++++ ...erseasPhase4PermissionSqlContractTest.java | 37 +++ .../PlatformUserManagementServiceTest.java | 28 ++ .../controller/LastlegOrderController.java | 11 +- .../ReceivingContainerController.java | 2 +- .../gateway/HttpWarehouseResourceGateway.java | 28 ++ .../pda/gateway/WarehouseResourceGateway.java | 8 + .../service/ReceivingContainerService.java | 20 +- .../dto/LocalLooseboardRequests.java | 1 + .../dto/LocalLooseboardResponses.java | 2 +- .../entity/LocalLooseboardHistory.java | 1 + .../entity/LocalLooseboardOperation.java | 22 ++ .../LocalLooseboardOperationMapper.java | 15 + .../service/LocalLooseboardService.java | 31 ++- .../controller/PdaWarehouseController.java | 53 ++++ .../warehouse/mapper/PdaWarehouseMapper.java | 2 +- .../service/PdaWarehouseService.java | 101 +++++++ .../dto/OverseasProcurementRequests.java | 6 +- .../dto/OverseasProcurementResponses.java | 2 +- .../entity/OverseasProcurementOrder.java | 2 + .../OverseasProcurementStatusHistory.java | 1 + .../service/OverseasProcurementService.java | 27 +- .../service/LoadingCompletedConsumer.java | 5 + .../entity/ContainerShippingGate.java | 36 +++ .../mapper/ContainerShippingGateMapper.java | 29 ++ .../ContainerShippingNodeConsumer.java | 123 ++++++++ .../trucking/service/UsTruckOrderService.java | 82 +++++- .../src/main/resources/db/lastleg.sql | 61 +++- .../openapi/overseas-business-v1.yaml | 262 +++++++++++++++++- .../LastlegOrderControllerTest.java | 2 + .../pda/ReceivingContainerContractTest.java | 9 +- .../ReceivingContainerControllerTest.java | 6 +- ...ReceivingContainerServiceBehaviorTest.java | 26 ++ .../LocalLooseboardSchemaContractTest.java | 2 + .../service/LocalLooseboardServiceTest.java | 61 +++- .../warehouse/PdaWarehouseContractTest.java | 78 ++++++ .../service/PdaWarehouseServiceTest.java | 71 +++++ ...verseasProcurementOpenApiContractTest.java | 10 + ...OverseasProcurementSchemaContractTest.java | 3 + .../OverseasProcurementControllerTest.java | 4 +- .../OverseasProcurementServiceTest.java | 27 ++ .../SchemaChineseCommentContractTest.java | 13 +- .../service/LoadingCompletedConsumerTest.java | 5 +- .../ContainerShippingNodeConsumerTest.java | 118 ++++++++ .../UsTruckOrderSchemaContractTest.java | 6 +- .../UsTruckOrderControllerTest.java | 27 +- .../service/UsTruckOrderServiceTest.java | 234 +++++++++------- .../phase3-warehouse-rbac.mjs | 35 ++- ...canonical-warehouse-rbac-contract.test.mjs | 39 +++ scripts/db/incremental.sql | 9 + ...ternalWarehouseResourceLockController.java | 18 ++ .../dto/WarehouseLocationResolveResponse.java | 6 + .../service/WarehouseResourceService.java | 20 ++ .../service/WarehouseResourceServiceTest.java | 13 + 93 files changed, 2779 insertions(+), 201 deletions(-) create mode 100644 auth/src/main/java/com/baiyue/auth/dto/OverseasSessionValidationDtos.java create mode 100644 auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java create mode 100644 auth/src/test/java/com/baiyue/auth/controller/OverseasSessionValidationControllerContractTest.java create mode 100644 auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/config/PdaSessionValidationProperties.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationClient.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java create mode 100644 gateway-overseas/src/test/java/com/baiyue/gateway/config/PdaSessionValidationDeploymentContractTest.java create mode 100644 gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOperation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOperationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java create mode 100644 scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseLocationResolveResponse.java diff --git a/auth/AGENTS.md b/auth/AGENTS.md index 7e969404f..b36bad7b2 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -16,6 +16,7 @@ | 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 | | 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 | | 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只保留平台海外仓/海外业务/海外主数据及 `lastleg:*` 权限前缀;前端统一认证层负责按区域 API 自动选择 | +| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 | | Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` | | 注册协作 | `service/MerchantRegistrationService.java` | | IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` | diff --git a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java index ead027f87..643bb1607 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java @@ -3,12 +3,16 @@ package com.baiyue.auth.controller; import com.baiyue.auth.dto.AccountManagementDtos.*; import com.baiyue.auth.service.AccountManagementService; import com.baiyue.auth.service.AuthService; +import com.baiyue.auth.service.OverseasSessionValidationService; +import com.baiyue.auth.dto.OverseasSessionValidationDtos; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import jakarta.validation.Valid; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.*; @@ -18,11 +22,14 @@ import java.security.MessageDigest; /** 仅供可信内部服务调用的账号管理接口。 */ @RestController @RequestMapping("/internal/auth/accounts") +@Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and overseas PDA session APIs") public class InternalAccountController { @Resource private AccountManagementService service; @Resource private AuthService authService; + @Resource + private OverseasSessionValidationService overseasSessionValidationService; @Value("${baiyue.security.internal-service-token}") private String configuredToken; @@ -64,6 +71,15 @@ public class InternalAccountController { @Valid @RequestBody RevokeAccountSessionsRequest request) { verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null); } + @PostMapping("/overseas-session/validate") + @Operation(summary = "Validate verified overseas PDA claims / 校验已验签的海外PDA声明", + description = "Accepts only claims extracted after Gateway JWT verification; raw tokens and user credentials are forbidden.") + public ApiResponse validateOverseasSession( + @RequestHeader(value="X-Internal-Service-Token", required=false) String token, + @Valid @RequestBody OverseasSessionValidationDtos.Request request) { + verify(token); + return ApiResponse.success(overseasSessionValidationService.validate(request)); + } @PutMapping("/{id}/profile") public ApiResponse profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, @PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) { diff --git a/auth/src/main/java/com/baiyue/auth/dto/OverseasSessionValidationDtos.java b/auth/src/main/java/com/baiyue/auth/dto/OverseasSessionValidationDtos.java new file mode 100644 index 000000000..2ab9a5f3f --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/dto/OverseasSessionValidationDtos.java @@ -0,0 +1,29 @@ +package com.baiyue.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +/** 海外 PDA 已验签身份的实时会话校验契约;绝不接收 raw JWT 或用户凭据。 */ +public final class OverseasSessionValidationDtos { + private OverseasSessionValidationDtos() { } + + @Schema(name = "OverseasSessionValidationRequest", + description = "Claims extracted only after overseas Gateway verifies the JWT signature") + public record Request( + @NotNull Long userId, + @NotNull Integer tokenVersion, + Long overseasWarehouseId) { } + + @Schema(name = "OverseasSessionValidationResponse") + public record Response(boolean valid, FailureReason failureReason) { + public static Response accepted() { return new Response(true, null); } + public static Response rejected(FailureReason reason) { return new Response(false, reason); } + } + + public enum FailureReason { + ACCOUNT_INVALID, + TOKEN_VERSION_MISMATCH, + WAREHOUSE_UNBOUND, + WAREHOUSE_MISMATCH + } +} diff --git a/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java b/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java new file mode 100644 index 000000000..0f566a290 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java @@ -0,0 +1,38 @@ +package com.baiyue.auth.service; + +import com.baiyue.auth.client.AuthorizationLoader; +import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason; +import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request; +import com.baiyue.auth.dto.OverseasSessionValidationDtos.Response; +import com.baiyue.auth.entity.AuthAccount; +import com.baiyue.auth.repository.AuthAccountRepository; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import java.util.Objects; + +/** 实时核对海外 PDA token 的账号版本与当前 IAM 仓绑定。 */ +@Service +public class OverseasSessionValidationService { + @Resource private AuthAccountRepository accounts; + @Resource private AuthorizationLoader authorizationLoader; + + public Response validate(Request request) { + AuthAccount account = accounts.findById(request.userId()); + if (account == null || !"ACTIVE".equals(account.getAccountStatus())) { + return Response.rejected(FailureReason.ACCOUNT_INVALID); + } + int currentVersion = account.getTokenVersion() == null ? 0 : account.getTokenVersion(); + if (currentVersion != request.tokenVersion()) { + return Response.rejected(FailureReason.TOKEN_VERSION_MISMATCH); + } + Long currentWarehouseId = authorizationLoader.load(account).getOverseasWarehouseId(); + if (currentWarehouseId == null || request.overseasWarehouseId() == null) { + return Response.rejected(FailureReason.WAREHOUSE_UNBOUND); + } + if (!Objects.equals(currentWarehouseId, request.overseasWarehouseId())) { + return Response.rejected(FailureReason.WAREHOUSE_MISMATCH); + } + return Response.accepted(); + } +} diff --git a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java index 915604787..4d274f63e 100644 --- a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java +++ b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java @@ -60,6 +60,7 @@ public class OverseasTokenService { .claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType()) .claim("roles", authorization.getRoles()) .claim("authorities", overseasAuthorities(authorization.getPermissions())) + .claim("token_version", account.getTokenVersion() == null ? 0 : account.getTokenVersion()) .claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()); if (authorization.getOverseasWarehouseId() != null) { claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId()); diff --git a/auth/src/test/java/com/baiyue/auth/controller/OverseasSessionValidationControllerContractTest.java b/auth/src/test/java/com/baiyue/auth/controller/OverseasSessionValidationControllerContractTest.java new file mode 100644 index 000000000..7c698a724 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/controller/OverseasSessionValidationControllerContractTest.java @@ -0,0 +1,28 @@ +package com.baiyue.auth.controller; + +import com.baiyue.auth.dto.OverseasSessionValidationDtos; +import io.swagger.v3.oas.annotations.Operation; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.PostMapping; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class OverseasSessionValidationControllerContractTest { + @Test + void internalEndpointAcceptsOnlyVerifiedClaimValuesAndIsDocumented() { + var method = Arrays.stream(InternalAccountController.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals("validateOverseasSession")) + .findFirst().orElseThrow(); + + assertThat(method.getAnnotation(PostMapping.class).value()) + .containsExactly("/overseas-session/validate"); + assertThat(method.getAnnotation(Operation.class).description()) + .contains("Gateway JWT verification", "raw tokens and user credentials are forbidden"); + assertThat(OverseasSessionValidationDtos.Request.class.getRecordComponents()) + .extracting(java.lang.reflect.RecordComponent::getName) + .containsExactly("userId", "tokenVersion", "overseasWarehouseId") + .doesNotContain("token", "accessToken", "authorization", "password"); + } +} diff --git a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java index 562d09705..11db26bf7 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java @@ -23,7 +23,10 @@ import java.time.Duration; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -124,12 +127,36 @@ class AuthServiceSmallJwtTest { var response = service.refresh("refresh-old"); assertThat(response.accessToken()).isEqualTo("domestic-refreshed"); + assertThat(response.tokenType()).isEqualTo("Bearer"); + assertThat(response.expiresIn()).isEqualTo(1800); + assertThat(response.refreshToken()).isNotBlank().isNotEqualTo("refresh-old"); + assertThat(response.refreshExpiresIn()).isEqualTo(604800); assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed"); assertThat(response.overseasExpiresIn()).isEqualTo(900); + verify(tokenSessionStore).consume("refresh-old"); + verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L), + org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")), + org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")), + org.mockito.ArgumentMatchers.eq(response.refreshToken()), + org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), + org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); verify(authorizationLoader).load(account); verify(overseasTokenService).issue(account, snapshot); } + @Test + void consumedRefreshTokenCannotMintAnotherDomesticOrOverseasPair() { + when(tokenSessionStore.consume("refresh-consumed")).thenReturn(null); + + assertThatThrownBy(() -> service.refresh("refresh-consumed")) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class) + .hasMessageContaining("刷新令牌无效或已过期"); + + verify(repository, never()).findById(any()); + verify(jwtEncoder, never()).encode(any()); + verify(overseasTokenService, never()).issue(any(), any()); + } + @Test void impersonationTokenAlsoKeepsRolesAndPermissionsOutOfJwt() { AuthAccount account = new AuthAccount(); diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java new file mode 100644 index 000000000..b98b7b637 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java @@ -0,0 +1,77 @@ +package com.baiyue.auth.service; + +import com.baiyue.auth.client.AuthorizationLoader; +import com.baiyue.auth.client.AuthorizationSnapshot; +import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason; +import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request; +import com.baiyue.auth.entity.AuthAccount; +import com.baiyue.auth.repository.AuthAccountRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class OverseasSessionValidationServiceTest { + private final AuthAccountRepository accounts = mock(AuthAccountRepository.class); + private final AuthorizationLoader authorizationLoader = mock(AuthorizationLoader.class); + private final OverseasSessionValidationService service = new OverseasSessionValidationService(); + private AuthAccount account; + + @BeforeEach + void setUp() throws Exception { + inject("accounts", accounts); + inject("authorizationLoader", authorizationLoader); + account = new AuthAccount(); + account.setId(42L); + account.setAccountStatus("ACTIVE"); + account.setTokenVersion(8); + when(accounts.findById(42L)).thenReturn(account); + } + + @Test + void oldTokenIsRejectedAfterWarehouseChangeBumpsAccountVersion() { + assertThat(service.validate(new Request(42L, 7, 900L))) + .extracting("valid", "failureReason") + .containsExactly(false, FailureReason.TOKEN_VERSION_MISMATCH); + } + + @Test + void newlyIssuedTokenWithCurrentWarehousePasses() { + when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L)); + + assertThat(service.validate(new Request(42L, 8, 901L)).valid()).isTrue(); + } + + @Test + void missingOrChangedWarehouseBindingFailsClosed() { + when(authorizationLoader.load(account)).thenReturn(snapshot(8L, null)); + assertThat(service.validate(new Request(42L, 8, null)).failureReason()) + .isEqualTo(FailureReason.WAREHOUSE_UNBOUND); + + when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L)); + assertThat(service.validate(new Request(42L, 8, 900L)).failureReason()) + .isEqualTo(FailureReason.WAREHOUSE_MISMATCH); + } + + @Test + void disabledAccountFailsClosedBeforeIamLookup() { + account.setAccountStatus("DISABLED"); + assertThat(service.validate(new Request(42L, 8, 901L)).failureReason()) + .isEqualTo(FailureReason.ACCOUNT_INVALID); + } + + private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) { + return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA", + Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), version, warehouseId); + } + + private void inject(String fieldName, Object value) throws Exception { + var field = OverseasSessionValidationService.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(service, value); + } +} diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java index 2141df038..805788918 100644 --- a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java @@ -24,6 +24,7 @@ import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; @SpringJUnitConfig(classes = {OverseasTokenService.class, OverseasTokenServiceTest.Config.class}) @@ -71,10 +72,61 @@ class OverseasTokenServiceTest { .containsEntry("membership_id", 84L) .containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB") .containsEntry("permission_version", 7L) + .containsEntry("token_version", 0) .containsEntry("overseas_warehouse_id", 900L) .containsEntry("roles", Set.of("WAREHOUSE_PICKER")) .containsEntry("authorities", Set.of(authority, lastlegAuthority)) .doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion"); assertThat(response.expiresIn()).isEqualTo(900); } + + @Test + void refreshedAuthorizationSnapshotReplacesWarehouseBindingAndTokenIdentity() { + AuthAccount account = new AuthAccount(); + account.setId(43L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(0L); + account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM"); + account.setTokenVersion(8); + AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA", + Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 8L, 900L); + AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA", + Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 9L, 901L); + Jwt first = org.mockito.Mockito.mock(Jwt.class); + Jwt second = org.mockito.Mockito.mock(Jwt.class); + when(encoder.encode(any())).thenReturn(first, second); + when(first.getTokenValue()).thenReturn("overseas-old-binding"); + when(second.getTokenValue()).thenReturn("overseas-new-binding"); + + var oldToken = service.issue(account, oldBinding); + var newToken = service.issue(account, newBinding); + + ArgumentCaptor captor = ArgumentCaptor.forClass(JwtEncoderParameters.class); + verify(encoder, times(2)).encode(captor.capture()); + var oldClaims = captor.getAllValues().get(0).getClaims(); + var newClaims = captor.getAllValues().get(1).getClaims(); + assertThat(oldClaims.getId()).isNotEqualTo(newClaims.getId()); + assertThat(oldClaims.getClaims()).containsEntry("overseas_warehouse_id", 900L) + .containsEntry("permission_version", 8L); + assertThat(newClaims.getClaims()).containsEntry("overseas_warehouse_id", 901L) + .containsEntry("permission_version", 9L); + assertThat(newClaims.getClaims()).containsEntry("token_version", 8); + assertThat(oldToken.accessToken()).isNotEqualTo(newToken.accessToken()); + } + + @Test + void unboundWarehouseIsOmittedSoPdaIdentityMustFailClosed() { + AuthAccount account = new AuthAccount(); + account.setId(44L); account.setAccountStatus("ACTIVE"); + AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA", + Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 10L, null); + Jwt encoded = org.mockito.Mockito.mock(Jwt.class); + when(encoder.encode(any())).thenReturn(encoded); + when(encoded.getTokenValue()).thenReturn("overseas-unbound"); + + service.issue(account, unbound); + + ArgumentCaptor captor = ArgumentCaptor.forClass(JwtEncoderParameters.class); + verify(encoder).encode(captor.capture()); + assertThat(captor.getValue().getClaims().getClaims()) + .doesNotContainKey("overseas_warehouse_id"); + } } diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java index 9d1dbf684..a9a537c41 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java @@ -1,6 +1,7 @@ package com.baiyue.common.event; import java.time.Instant; +import java.util.Map; /** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */ public record FirstLegContainerShippingNodeRecordedV1( @@ -20,6 +21,11 @@ public record FirstLegContainerShippingNodeRecordedV1( String providerNodeCode, String sourceReference, String rawPayloadHash) { + private static final Map STANDARD_NODES = Map.of( + "DEPARTED_WAREHOUSE", 10, "AT_ORIGIN_PORT", 20, "LOADED_ON_VESSEL", 30, + "DEPARTED_ORIGIN_PORT", 40, "IN_TRANSIT", 50, "ARRIVED_DESTINATION_PORT", 60, + "DISCHARGED_FROM_VESSEL", 70, "PICKUP_READY", 80, "PICKED_UP", 90, + "EMPTY_RETURNED", 100); public FirstLegContainerShippingNodeRecordedV1 { if (queryId == null || queryId.isBlank() || customerId == null || customerId.isBlank() @@ -28,7 +34,7 @@ public record FirstLegContainerShippingNodeRecordedV1( || containerNo == null || containerNo.isBlank()) { throw new IllegalArgumentException("船节点交接业务标识不能为空"); } - if (standardNode == null || standardNode.isBlank() || nodeSequence < 10 + if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode)) || observedAt == null || actualAt == null) { throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实"); } diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java index 3f69c7f1f..faaa767cb 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java @@ -30,6 +30,12 @@ class FirstLegContainerShippingNodeRecordedV1Test { Instant.parse("2026-08-25T01:00:00Z"), null, null, null, "ZIM", "evt-1", "Carrier Release", "ZIM:evt-1", "a".repeat(64))) .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("实际时间"); + + assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1( + "1", "2", "3", "4", "ZCSU7881970", "ARRIVED", 60, + Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"), + null, null, "ZIM", "evt-2", "ARRIVED", "ZIM:evt-2", "b".repeat(64))) + .isInstanceOf(IllegalArgumentException.class); } private static FirstLegContainerShippingNodeRecordedV1 payload() { diff --git a/config/nacos/overseas/baiyue-common.yml b/config/nacos/overseas/baiyue-common.yml index f1ecfa04d..a9e0a75f3 100644 --- a/config/nacos/overseas/baiyue-common.yml +++ b/config/nacos/overseas/baiyue-common.yml @@ -8,6 +8,10 @@ baiyue: audience: ${JWT_AUDIENCE:lastleg-api} jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} internal-service-token: ${INTERNAL_SERVICE_TOKEN} + pda-session-validation: + auth-url: ${AUTH_SESSION_VALIDATION_URL} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s} permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} openapi.enabled: ${SWAGGER_ENABLED:false} audit.enabled: true diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index b79552050..29f78af91 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -45,6 +45,8 @@ services: SWAGGER_ENABLED: ${SWAGGER_ENABLED:-false} CORS_ALLOWED_ORIGINS: ${OVERSEAS_CORS_ALLOWED_ORIGINS:-} CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false} + AUTH_SESSION_VALIDATION_URL: ${AUTH_SESSION_VALIDATION_URL} + PDA_SESSION_VALIDATION_TIMEOUT: ${PDA_SESSION_VALIDATION_TIMEOUT:-2s} lastleg-service: <<: *overseas-common diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index 458834cd4..790431300 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -84,6 +84,13 @@ case "$SERVICE" in ;; esac +if [ "$SERVICE" = "gateway-overseas" ]; then + if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then + echo "部署 gateway-overseas 时,环境文件必须包含非空 AUTH_SESSION_VALIDATION_URL" >&2 + exit 2 + fi +fi + for file in "$ENV_FILE" "$COMPOSE_FILE"; do if [ ! -f "$file" ]; then echo "必需文件不存在: $file" >&2 diff --git a/deploy/scripts/deploy-services.sh b/deploy/scripts/deploy-services.sh index ea21fbb89..7c9983f82 100644 --- a/deploy/scripts/deploy-services.sh +++ b/deploy/scripts/deploy-services.sh @@ -80,6 +80,15 @@ if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then done fi +case " $SERVICES " in + *" gateway-overseas "*) + if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then + echo "部署 gateway-overseas 时,环境文件必须包含非空 AUTH_SESSION_VALIDATION_URL" >&2 + exit 2 + fi + ;; +esac + case " $SERVICES " in *" finance "*) for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 483eb69ce..9f35980e2 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16394,6 +16394,81 @@ DELIMITER ; CALL baiyue_iam.assert_overseas_pda_picker_permission(); DROP PROCEDURE baiyue_iam.assert_overseas_pda_picker_permission; +-- 四期仓库后台角色严格最小授权:list 同时覆盖列表与详情,只读角色不得获得任何写权限。 +DROP TEMPORARY TABLE IF EXISTS tmp_phase4_warehouse_role_permission; +CREATE TEMPORARY TABLE tmp_phase4_warehouse_role_permission ( + role_code VARCHAR(64) NOT NULL, + permission_code VARCHAR(160) NOT NULL, + PRIMARY KEY (role_code, permission_code) +); +INSERT INTO tmp_phase4_warehouse_role_permission (role_code, permission_code) VALUES + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:last-mile-order:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:inbound-order:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:relabel-order:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:inventory:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:warehouse:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:location:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:pallet:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-business:purchase:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-business:truck:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-business:looseboard:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:last-mile-order:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:inbound-order:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:relabel-order:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:inventory:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:warehouse:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:location:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:pallet:list'), + ('WAREHOUSE_TALLY','platform:overseas-business:purchase:list'), + ('WAREHOUSE_TALLY','platform:overseas-business:truck:list'), + ('WAREHOUSE_TALLY','platform:overseas-business:looseboard:list'); + +DELETE grant_row FROM baiyue_iam.iam_role_permission grant_row +JOIN baiyue_iam.iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id +JOIN baiyue_iam.iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id +LEFT JOIN tmp_phase4_warehouse_role_permission expected + ON expected.role_code=role.role_code AND expected.permission_code=permission.permission_code +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code IN ('WAREHOUSE_OPERATOR','WAREHOUSE_PICKER','WAREHOUSE_TALLY') + AND permission.permission_code LIKE 'platform:overseas-%' + AND expected.permission_code IS NULL; + +INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM tmp_phase4_warehouse_role_permission expected +JOIN baiyue_iam.iam_role role ON role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code=expected.role_code AND role.status='ACTIVE' AND role.deleted=0 +JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code=expected.permission_code + AND permission.status='ACTIVE' AND permission.deleted=0; + +DROP PROCEDURE IF EXISTS baiyue_iam.assert_phase4_warehouse_role_minimum; +DELIMITER $$ +CREATE PROCEDURE baiyue_iam.assert_phase4_warehouse_role_minimum() +BEGIN + IF (SELECT COUNT(*) FROM tmp_phase4_warehouse_role_permission) <> 20 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库角色最小权限矩阵数量异常'; + END IF; + IF EXISTS (SELECT 1 FROM tmp_phase4_warehouse_role_permission + WHERE permission_code REGEXP ':(create|edit|status|export|adjust|delete|review)$') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库只读角色错误获得写权限'; + END IF; + IF EXISTS (SELECT 1 FROM tmp_phase4_warehouse_role_permission expected + LEFT JOIN baiyue_iam.iam_role role ON role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code=expected.role_code AND role.status='ACTIVE' AND role.deleted=0 + LEFT JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code=expected.permission_code AND permission.status='ACTIVE' AND permission.deleted=0 + LEFT JOIN baiyue_iam.iam_role_permission grant_row ON grant_row.tenant_id=0 + AND grant_row.role_id=role.id AND grant_row.permission_id=permission.id + WHERE grant_row.role_id IS NULL) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库角色最小权限授权不完整'; + END IF; +END$$ +DELIMITER ; +CALL baiyue_iam.assert_phase4_warehouse_role_minimum(); +DROP PROCEDURE baiyue_iam.assert_phase4_warehouse_role_minimum; +DROP TEMPORARY TABLE tmp_phase4_warehouse_role_permission; + INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, role.id, permission.id, NOW(3) FROM baiyue_iam.iam_role role diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 819ed1c5e..4748e0dba 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -618,6 +618,74 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (customer_id,task_id,allocation_id,sku_code)')); PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +-- 海运节点达到可提柜后才允许海外 PDA 认领;装柜完成只建立等待门禁快照。 +CREATE TABLE IF NOT EXISTS ll_container_shipping_gate ( + container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户标识', + ocean_booking_id VARCHAR(128) NOT NULL COMMENT '来源海运订舱ID', + container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号', + status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE', + standard_node VARCHAR(64) NULL COMMENT '最新有效标准船节点', + node_sequence INT NOT NULL DEFAULT 0 COMMENT '最新有效节点单调序号', + observed_at DATETIME(3) NULL COMMENT '供应商节点观测时间', + actual_at DATETIME(3) NULL COMMENT '节点实际发生时间', + location_code VARCHAR(128) NULL COMMENT '节点地点编码', + location_name VARCHAR(255) NULL COMMENT '节点地点名称', + provider_code VARCHAR(64) NULL COMMENT '船节点供应商编码', + provider_event_id VARCHAR(128) NULL COMMENT '供应商事件ID', + provider_node_code VARCHAR(128) NULL COMMENT '供应商原始节点编码', + source_reference VARCHAR(255) NULL COMMENT '供应商来源引用', + raw_payload_hash VARCHAR(64) NULL COMMENT '供应商原始负载SHA-256', + aggregate_version BIGINT NOT NULL DEFAULT 0 COMMENT '来源查询聚合版本', + handover_version BIGINT NOT NULL DEFAULT 1 COMMENT '跨区交接版本', + last_event_id VARCHAR(128) NULL COMMENT '最近采纳的跨区事件ID', + payload_hash VARCHAR(71) 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 '更新时间', + PRIMARY KEY(container_booking_id), + KEY idx_ll_shipping_gate_candidate(customer_id,container_no,status), + KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence) +) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照'; + +INSERT IGNORE INTO ll_container_shipping_gate ( + container_booking_id,customer_id,ocean_booking_id,container_no,status, + node_sequence,aggregate_version,handover_version,created_at,updated_at +) +SELECT source_container_booking_id,customer_id,'',container_no,'AWAITING_PICKUP_READY', + 0,COALESCE(MAX(source_loading_plan_version),0),COALESCE(MAX(source_handover_version),1),NOW(3),NOW(3) +FROM ll_order +WHERE source_container_booking_id IS NOT NULL AND source_container_booking_id <> '' + AND container_no IS NOT NULL AND container_no <> '' +GROUP BY source_container_booking_id,customer_id,container_no; + +CREATE TABLE IF NOT EXISTS ll_pda_operation_history ( + id BIGINT NOT NULL COMMENT 'PDA作业历史ID', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + customer_id VARCHAR(128) NULL COMMENT '作业涉及的客户标识', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', + operation_type VARCHAR(64) NOT NULL COMMENT '作业类型', + business_key VARCHAR(255) NOT NULL COMMENT '作业业务键', + status VARCHAR(32) NOT NULL COMMENT '作业结果状态', + idempotency_key VARCHAR(255) NULL COMMENT '写操作幂等键', + request_hash VARCHAR(128) NULL COMMENT '写请求内容哈希', + summary VARCHAR(1000) NULL COMMENT '作业结果摘要', + occurred_at DATETIME(3) NOT NULL COMMENT '作业发生时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_pda_history_idempotency(operator_key,idempotency_key), + KEY idx_ll_pda_history_operator(overseas_warehouse_id,operator_key,occurred_at), + KEY idx_ll_pda_history_business(overseas_warehouse_id,operation_type,business_key) +) ENGINE=InnoDB COMMENT='海外PDA真实作业历史'; + +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_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; +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')); +PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt; + -- Warehouse Overseas 与 Lastleg 使用同一海外区域发布入口但保持独立数据库。 -- 公共托盘详情实体读取 fba_warehouse_code;旧库缺列时创建可成功但详情会 S0500。 USE baiyue_lastlag_warehouse; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java index 00267667e..fad3762c1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java @@ -106,6 +106,11 @@ public class OutboundDispatchService { throw conflict("当前出仓单不在等待装船状态"); } if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent(); + OutboundDispatchReadMapper.IdStatus ocean = repository.ocean(binding.containerBookingId(), binding.tenantId()); + if (carrierSchedules != null && ocean != null && carrierSchedules.hasOutboundDepartureFact( + binding.tenantId(), ocean.id(), binding.containerBookingId())) { + throw conflict("已产生实际DEPARTED_WAREHOUSE船节点,禁止撤销国内出仓"); + } if (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { throw concurrent(); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java index 6926f77cf..7df760374 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java @@ -70,6 +70,17 @@ public class CarrierScheduleQueryRepository { .orderByAsc(CarrierScheduleNodeFactEntity::getId)); } public boolean insertIfAbsent(CarrierScheduleNodeFactEntity value) { return nodes.insertIgnore(value) == 1; } + public CarrierScheduleNodeFactEntity node(long queryId, String eventKey) { + return nodes.selectOne(new LambdaQueryWrapper() + .eq(CarrierScheduleNodeFactEntity::getQueryId, queryId) + .eq(CarrierScheduleNodeFactEntity::getEventKey, eventKey)); + } + public boolean hasActualNode(long queryId, String standardNode) { + return nodes.selectCount(new LambdaQueryWrapper() + .eq(CarrierScheduleNodeFactEntity::getQueryId, queryId) + .eq(CarrierScheduleNodeFactEntity::getStandardNode, standardNode) + .isNotNull(CarrierScheduleNodeFactEntity::getActualAt)) > 0; + } public List exceptions(long id) { return exceptions.selectList(new LambdaQueryWrapper() .eq(CarrierScheduleExceptionFactEntity::getQueryId, id) @@ -79,6 +90,11 @@ public class CarrierScheduleQueryRepository { public boolean insertIfAbsent(CarrierScheduleExceptionFactEntity value) { return exceptions.insertIfAbsent(value) == 1; } + public CarrierScheduleExceptionFactEntity exception(long queryId, String eventKey) { + return exceptions.selectOne(new LambdaQueryWrapper() + .eq(CarrierScheduleExceptionFactEntity::getQueryId, queryId) + .eq(CarrierScheduleExceptionFactEntity::getEventKey, eventKey)); + } public boolean claimOperation(Long queryId, long operatorId, String operation, String key, String hash) { return operations.claim(IdWorker.getId(), queryId, operatorId, operation, key, hash) == 1; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java index 1a497bbc3..3f746a807 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java @@ -71,22 +71,24 @@ public class CarrierScheduleQueryScheduler { return; } var result = response.data(); - List observations = result.observations() == null - ? List.of() : result.observations(); - for (var observation : observations) { - service.applyProviderObservation(candidate.getId(), new ProviderObservationRequest( - result.providerCode(), observation.providerEventId(), observation.providerNodeCode(), - observation.standardNode(), observation.observedAt(), observation.estimatedAt(), - observation.actualAt(), observation.locationCode(), observation.locationName(), - observation.sourceReference(), observation.rawPayloadHash())); - } - List exceptions = result.exceptions() == null - ? List.of() : result.exceptions(); - for (var exception : exceptions) { - service.applyProviderException(candidate.getId(), new ProviderExceptionRequest( - result.providerCode(), exception.providerEventId(), exception.providerStatus(), - exception.exceptionCode(), exception.active(), exception.observedAt(), exception.locationCode(), - exception.locationName(), exception.sourceReference(), exception.rawPayloadHash())); + if ("SUCCESS".equals(result.outcome())) { + List observations = result.observations() == null + ? List.of() : result.observations(); + for (var observation : observations) { + service.applyProviderObservation(candidate.getId(), new ProviderObservationRequest( + result.providerCode(), observation.providerEventId(), observation.providerNodeCode(), + observation.standardNode(), observation.observedAt(), observation.estimatedAt(), + observation.actualAt(), observation.locationCode(), observation.locationName(), + observation.sourceReference(), observation.rawPayloadHash())); + } + List exceptions = result.exceptions() == null + ? List.of() : result.exceptions(); + for (var exception : exceptions) { + service.applyProviderException(candidate.getId(), new ProviderExceptionRequest( + result.providerCode(), exception.providerEventId(), exception.providerStatus(), + exception.exceptionCode(), exception.active(), exception.observedAt(), exception.locationCode(), + exception.locationName(), exception.sourceReference(), exception.rawPayloadHash())); + } } service.completeScheduledAttempt(candidate.getId(), result.outcome(), result.errorCode(), result.errorMessage()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java index a0c6230b6..4ab755ac0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java @@ -59,6 +59,7 @@ public class CarrierScheduleQueryService { @Resource private CarrierScheduleQueryRepository repository; @Resource private PortScheduleRepository portSchedules; @Resource private CarrierScheduleAdapterRegistry adapters; + @Resource private CarrierScheduleNodeOutboxService nodeOutbox; @Resource private ObjectMapper objectMapper; @Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}") private String scheduleZone = "Asia/Shanghai"; @@ -127,9 +128,10 @@ public class CarrierScheduleQueryService { if (concurrent != null) return concurrent; throw exception; } - addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM", + CarrierScheduleNodeFactEntity departed = addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM", CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM", sha256("OUTBOUND_CONFIRM|" + containerBookingId)); + if (departed != null) nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId)); addHistory(value.getId(), "AUTO_CREATE", null, 0, "国内确认出仓自动创建", null, value, operatorId, operatorName); return value; @@ -140,14 +142,17 @@ public class CarrierScheduleQueryService { CarrierScheduleQueryEntity value = requiredLocked(id); CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); CarrierScheduleQueryEntity before = copy(value); - if (!addNode(id, request.providerCode(), request.providerEventId(), request.providerNodeCode(), node, + CarrierScheduleNodeFactEntity fact = addNode(id, request.providerCode(), request.providerEventId(), + request.providerNodeCode(), node, request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(), - request.locationName(), request.sourceReference(), request.rawPayloadHash())) { + request.locationName(), request.sourceReference(), request.rawPayloadHash()); + if (fact == null) { return detail(value); } boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(), 0L, "carrier-scheduler"); CarrierScheduleQueryEntity after = advanced ? required(id) : value; + nodeOutbox.publish(after, fact, requireContainerNo(after.getContainerBookingId())); addHistory(id, "NODE_OBSERVED", before.getVersion(), after.getVersion(), request.standardNode(), before, after, 0L, "carrier-scheduler"); return detail(after); @@ -180,9 +185,11 @@ public class CarrierScheduleQueryService { CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); CarrierScheduleQueryEntity before = copy(value); String providerEventId = sha256("MANUAL|" + id + '|' + key); - if (!addNode(id, "MANUAL", providerEventId, node.name(), node, request.observedAt(), + CarrierScheduleNodeFactEntity fact = addNode(id, "MANUAL", providerEventId, node.name(), node, + request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(), - "MANUAL:" + operatorId, hash(operation, id, request))) { + "MANUAL:" + operatorId, hash(operation, id, request)); + if (fact == null) { return complete(id, operatorId, key, detail(value)); } boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(), @@ -191,6 +198,7 @@ public class CarrierScheduleQueryService { throw concurrent(); } CarrierScheduleQueryEntity after = required(id); + nodeOutbox.publish(after, fact, requireContainerNo(after.getContainerBookingId())); addHistory(id, operation, before.getVersion(), after.getVersion(), request.reason(), before, after, operatorId, operatorName); return complete(id, operatorId, key, detail(after)); @@ -221,6 +229,12 @@ public class CarrierScheduleQueryService { return false; } + public boolean hasOutboundDepartureFact(long customerId, long oceanBookingId, long containerBookingId) { + CarrierScheduleQueryEntity query = repository.findByBusinessScope(customerId, oceanBookingId, + containerBookingId); + return query != null && repository.hasActualNode(query.getId(), CarrierScheduleNode.DEPARTED_WAREHOUSE.name()); + } + @Transactional public boolean deferMissedNormalSlot(long id, LocalDateTime scanAt) { CarrierScheduleQueryEntity value = requiredLocked(id); @@ -426,7 +440,8 @@ public class CarrierScheduleQueryService { value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt()); } - private boolean addNode(long queryId, String providerCode, String providerEventId, String providerNodeCode, + private CarrierScheduleNodeFactEntity addNode(long queryId, String providerCode, String providerEventId, + String providerNodeCode, CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt, String locationCode, String locationName, String sourceReference, String rawPayloadHash) { @@ -439,7 +454,12 @@ public class CarrierScheduleQueryService { fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt); fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName)); fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash); - return repository.insertIfAbsent(fact); + if (repository.insertIfAbsent(fact)) return fact; + CarrierScheduleNodeFactEntity existing = repository.node(queryId, fact.getEventKey()); + if (existing != null && !Objects.equals(existing.getRawPayloadHash(), rawPayloadHash)) { + throw conflict("相同providerEventId对应不同rawPayloadHash,拒绝覆盖既有节点事实"); + } + return null; } private boolean addException(long queryId, String providerCode, String providerEventId, @@ -454,7 +474,20 @@ public class CarrierScheduleQueryService { fact.setExceptionCode(code.name()); fact.setActive(active); fact.setObservedAt(observedAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName)); fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash); - return repository.insertIfAbsent(fact); + if (repository.insertIfAbsent(fact)) return true; + CarrierScheduleExceptionFactEntity existing = repository.exception(queryId, fact.getEventKey()); + if (existing != null && !Objects.equals(existing.getRawPayloadHash(), rawPayloadHash)) { + throw conflict("相同providerEventId对应不同rawPayloadHash,拒绝覆盖既有异常事实"); + } + return false; + } + + private String requireContainerNo(long containerBookingId) { + ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId); + if (container == null || !StringUtils.hasText(container.getContainerNo())) { + throw conflict("跨区船节点交接要求柜次具备柜号"); + } + return container.getContainerNo().trim().toUpperCase(Locale.ROOT); } private static QueryView view(CarrierScheduleQueryEntity value, LocalDateTime now) { diff --git a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml index db431dac1..8553a2767 100644 --- a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml @@ -60,7 +60,8 @@ paths: '200': {description: Business envelope; gate or version failures use business error code} /api/platform/v1/first-leg/outbound-dispatches/reverse-dispatch: post: - summary: Reverse WAITING_FOR_LOADING to LOADED + summary: Reverse WAITING_FOR_LOADING only before maritime tracking starts + description: Once an actual DEPARTED_WAREHOUSE node exists, reversal is rejected with business code A0409 so READY_FOR_DISPATCH cannot coexist with maritime tracking. parameters: - {$ref: '#/components/parameters/IdempotencyKey'} requestBody: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java index 57841d96e..8c4bf583f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java @@ -102,6 +102,25 @@ class OutboundDispatchServiceTest { verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any()); } + @Test + void reverseIsRejectedAfterActualDepartedWarehouseFactExists() { + CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class); + OutboundDispatchService service = new OutboundDispatchService(repository, List.of(), carrierSchedules); + stubReadyChain(); + OutboundDispatchEntity state = new OutboundDispatchEntity(); + state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); state.setContainerBookingId(22L); + state.setStatus("WAITING_FOR_LOADING"); state.setVersion(1); + when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); + when(repository.claim(eq(9L), eq("REVERSE_DISPATCH"), eq("reverse-blocked"), anyString())).thenReturn(true); + when(carrierSchedules.hasOutboundDepartureFact(7L, 66L, 22L)).thenReturn(true); + + assertThatThrownBy(() -> service.reverse(9L, "admin", "reverse-blocked", + new ActionRequest(11L, 22L, 1, "误操作"))) + .isInstanceOf(BizException.class).hasMessageContaining("DEPARTED_WAREHOUSE"); + + verify(repository, never()).reverse(anyLong(), anyInt(), anyLong(), anyString(), any()); + } + @Test void selectorsThatMatchMultipleContainerBindingsRequireDisambiguation() { OutboundDispatchService service = new OutboundDispatchService(repository, List.of()); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java index 03a8422c9..e587f38f1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java @@ -46,6 +46,7 @@ class OutboundDispatchSqlContractTest { .contains("outbound-dispatches/check-gate:") .contains("outbound-dispatches/confirm-dispatch:") .contains("outbound-dispatches/reverse-dispatch:") + .contains("Once an actual DEPARTED_WAREHOUSE node exists") .contains("expectedVersion") .contains("Idempotency-Key"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java index b7e0d4d7e..817d421d8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java @@ -103,6 +103,20 @@ class CarrierScheduleQuerySqlContractTest { .doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'"); } + @Test + void unifiedOutboxSupportsVersionedCrossRegionCarrierNodeFacts() throws Exception { + String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + assertThat(unified) + .contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_key'") + .contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_version'") + .contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'aggregate_sequence'") + .contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'payload_hash'") + .contains("uk_inbound_outbox_event_key") + .contains("MODIFY COLUMN payload_hash VARCHAR(71)") + .contains("MODIFY COLUMN payload_json LONGTEXT NOT NULL"); + } + private static String moduleSql() throws Exception { return new ClassPathResource("db/phase3-carrier-schedule-query-schema.sql") .getContentAsString(StandardCharsets.UTF_8); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java index 27a76c2b4..ee314b6d1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java @@ -7,6 +7,7 @@ import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -21,7 +22,8 @@ import static org.mockito.Mockito.verify; class CarrierScheduleNodeOutboxServiceTest { private final InboundOutboxMapper mapper = mock(InboundOutboxMapper.class); - private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); private final CarrierScheduleNodeOutboxService service = new CarrierScheduleNodeOutboxService(); @BeforeEach @@ -56,7 +58,7 @@ class CarrierScheduleNodeOutboxServiceTest { void estimatedOnlyFactNeverEntersCrossRegionOutbox() { CarrierScheduleNodeFactEntity fact = fact(); fact.setActualAt(null); service.publish(query(), fact, "ZCSU7881970"); - verify(mapper, never()).insert(org.mockito.ArgumentMatchers.any()); + verify(mapper, never()).insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class)); } private static CarrierScheduleQueryEntity query() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java index 6c109ac37..8607f987d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java @@ -24,6 +24,7 @@ import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.never; class CarrierScheduleQuerySchedulerTest { @Test @@ -106,6 +107,44 @@ class CarrierScheduleQuerySchedulerTest { verify(service).completeScheduledAttempt(1L, "SUCCESS", null, null); } + @Test + void leaseLoserNeverCallsProviderOrWritesAttempt() { + CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class); + CarrierScheduleQueryService service = mock(CarrierScheduleQueryService.class); + CarrierScheduleIntegrationClient integration = mock(CarrierScheduleIntegrationClient.class); + CarrierScheduleQueryScheduler scheduler = new CarrierScheduleQueryScheduler(repository, service, integration); + CarrierScheduleQueryEntity task = task(1L); + when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(false); + + scheduler.execute(task, LocalDateTime.of(2026, 8, 25, 9, 0)); + + verify(integration, never()).query(any(), any()); + verify(service, never()).completeScheduledAttempt(anyLong(), anyString(), any(), any()); + } + + @Test + void retryableProviderResultCannotAdvanceEvenWhenItCarriesStrayFacts() { + CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class); + CarrierScheduleQueryService service = mock(CarrierScheduleQueryService.class); + CarrierScheduleIntegrationClient integration = mock(CarrierScheduleIntegrationClient.class); + CarrierScheduleQueryScheduler scheduler = new CarrierScheduleQueryScheduler(repository, service, integration); + CarrierScheduleQueryEntity task = task(1L); task.setCarrierCode("ZIM"); + task.setTrackingType("CONTAINER_NO"); task.setTrackingNo("ZCSU7881970"); + LocalDateTime now = LocalDateTime.of(2026, 8, 25, 9, 0); + var stray = new CarrierScheduleIntegrationClient.Observation("evt-stray", "AVAILABLE", "PICKUP_READY", + now, null, now, "USLAX", "Los Angeles", "ZIM:evt-stray", "a".repeat(64)); + when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(true); + when(integration.query(any(), any())).thenReturn(new CarrierScheduleIntegrationClient.Envelope<>("200", null, + new CarrierScheduleIntegrationClient.QueryResult("ZIM", "RETRYABLE_FAILURE", List.of(stray), List.of(), + "HTTP_503", "unavailable", now))); + + scheduler.execute(task, now); + + verify(service, never()).applyProviderObservation(anyLong(), any()); + verify(service, never()).applyProviderException(anyLong(), any()); + verify(service).completeScheduledAttempt(1L, "RETRYABLE_FAILURE", "HTTP_503", "unavailable"); + } + private static CarrierScheduleQueryEntity task(long id) { CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); value.setId(id); return value; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index a38e1799d..e88ed6f9c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -11,6 +11,7 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExc import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity; import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository; @@ -38,11 +39,13 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.times; class CarrierScheduleQueryServiceTest { private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 10, 12, 0); @Mock private CarrierScheduleQueryRepository repository; @Mock private PortScheduleRepository portSchedules; + @Mock private CarrierScheduleNodeOutboxService nodeOutbox; @InjectMocks private CarrierScheduleQueryService service; @BeforeEach @@ -56,6 +59,9 @@ class CarrierScheduleQueryServiceTest { when(repository.nodes(anyLong())).thenReturn(List.of()); when(repository.exceptions(anyLong())).thenReturn(List.of()); when(repository.histories(anyLong())).thenReturn(List.of()); + var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + container.setContainerNo("ZCSU7881970"); + when(portSchedules.containerBooking(20L)).thenReturn(container); } @Test @@ -165,6 +171,25 @@ class CarrierScheduleQueryServiceTest { assertThat(task.getNextAllowedAt()).isNull(); } + @Test + void lateActualFactAfterEmptyReturnIsAuditedWithoutRestartingTask() { + CarrierScheduleQueryEntity task = task(0, 4); + task.setStatus("STOPPED"); task.setLatestStandardNode("EMPTY_RETURNED"); + task.setLatestNodeObservedAt(NOW); task.setNextAllowedAt(null); task.setStopReason("已还柜"); + when(repository.lock(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true); + + service.applyProviderObservation(1L, new ProviderObservationRequest("ZIM", "late-discharge", + "DISCHARGED", "DISCHARGED_FROM_VESSEL", NOW.plusHours(1), null, NOW.minusDays(2), + "USLAX", "Los Angeles", "ZIM:late-discharge", "d".repeat(64))); + + assertThat(task.getStatus()).isEqualTo("STOPPED"); + assertThat(task.getLatestStandardNode()).isEqualTo("EMPTY_RETURNED"); + assertThat(task.getNextAllowedAt()).isNull(); + verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString()); + verify(nodeOutbox).publish(eq(task), any(CarrierScheduleNodeFactEntity.class), eq("ZCSU7881970")); + } + @Test void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() { CarrierScheduleQueryEntity task = task(0, 3); @@ -224,6 +249,47 @@ class CarrierScheduleQueryServiceTest { assertThat(task.getLatestStandardNode()).isNull(); } + @Test + void allFourHoldTypesAndReleaseFactsRemainSeparateFromBusinessProgress() { + CarrierScheduleQueryEntity task = task(0, 1); + task.setLatestStandardNode("IN_TRANSIT"); + when(repository.lock(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(CarrierScheduleExceptionFactEntity.class))).thenReturn(true); + + for (String code : List.of("CUSTOMS_HOLD", "TERMINAL_HOLD", "DOCUMENT_HOLD", "UNKNOWN_HOLD")) { + service.applyProviderException(1L, new ProviderExceptionRequest("TRACK_API", code + "-ON", + code, code, true, NOW, null, null, "provider:" + code, "a".repeat(64))); + service.applyProviderException(1L, new ProviderExceptionRequest("TRACK_API", code + "-OFF", + code + " RELEASED", code, false, NOW.plusMinutes(1), null, null, + "provider:" + code + ":released", "b".repeat(64))); + } + + verify(repository, times(8)).insertIfAbsent(any(CarrierScheduleExceptionFactEntity.class)); + verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString()); + assertThat(task.getLatestStandardNode()).isEqualTo("IN_TRANSIT"); + } + + @Test + void providerEventIdReplayRequiresTheSameRawPayloadHash() { + CarrierScheduleQueryEntity task = task(0, 1); + task.setLatestStandardNode("IN_TRANSIT"); + CarrierScheduleNodeFactEntity existing = new CarrierScheduleNodeFactEntity(); + existing.setEventKey("ZIM:evt-duplicate"); existing.setRawPayloadHash("a".repeat(64)); + when(repository.lock(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(false); + when(repository.node(1L, "ZIM:evt-duplicate")).thenReturn(existing); + ProviderObservationRequest replay = new ProviderObservationRequest("ZIM", "evt-duplicate", "GATE_IN", + "AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "a".repeat(64)); + + service.applyProviderObservation(1L, replay); + + verify(nodeOutbox, never()).publish(any(), any(), anyString()); + ProviderObservationRequest conflict = new ProviderObservationRequest("ZIM", "evt-duplicate", "GATE_IN", + "AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "b".repeat(64)); + assertThatThrownBy(() -> service.applyProviderObservation(1L, conflict)) + .isInstanceOf(BizException.class).hasMessageContaining("不同rawPayloadHash"); + } + @Test void everySchedulerCompletionWritesImmutableAttempt() { CarrierScheduleQueryEntity task = task(0, 0); diff --git a/gateway-overseas/AGENTS.md b/gateway-overseas/AGENTS.md index db1344368..72487d3aa 100644 --- a/gateway-overseas/AGENTS.md +++ b/gateway-overseas/AGENTS.md @@ -13,6 +13,7 @@ | 安全链配置 | `src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java` | | Audience 校验 | `config/AudienceJwtValidator.java` | | 必要 Claims 校验 | `config/RequiredClaimsJwtValidator.java` | +| PDA 换仓即时失效 | `filter/PdaSessionValidationFilter.java`、`HttpPdaSessionValidationClient.java`、`config/PdaSessionValidationProperties.java`;仅 `/api/pda/**` 本地验签后同步核对 Auth,任何异常 fail-closed | | 安全配置项 | `config/GatewaySecurityProperties.java` | | 可信身份头传递 | `filter/TrustedIdentityHeaderFilter.java` | | 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` | diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java index d82f31a65..f9191717c 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java @@ -24,7 +24,7 @@ import java.util.Map; * 海外网关响应式安全配置,使用国内认证中心公开的 JWKS 验证 JWT。 */ @Configuration -@EnableConfigurationProperties(GatewaySecurityProperties.class) +@EnableConfigurationProperties({GatewaySecurityProperties.class, PdaSessionValidationProperties.class}) public class GatewaySecurityConfig { @Bean SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http, @@ -63,7 +63,7 @@ public class GatewaySecurityConfig { return decoder; } - static Mono writeError(org.springframework.web.server.ServerWebExchange exchange, + public static Mono writeError(org.springframework.web.server.ServerWebExchange exchange, ObjectMapper mapper, HttpStatus status, String code, String message) { exchange.getResponse().setStatusCode(status); exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/PdaSessionValidationProperties.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/PdaSessionValidationProperties.java new file mode 100644 index 000000000..e2e40b47e --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/PdaSessionValidationProperties.java @@ -0,0 +1,25 @@ +package com.baiyue.gateway.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +/** 海外 PDA 实时会话校验配置;URL 与服务令牌只能由环境提供。 */ +@ConfigurationProperties(prefix = "baiyue.security.pda-session-validation") +public record PdaSessionValidationProperties(String authUrl, + String internalServiceToken, + Duration timeout) { + public PdaSessionValidationProperties { + if (authUrl == null || authUrl.isBlank()) { + throw new IllegalArgumentException("必须配置 AUTH_SESSION_VALIDATION_URL"); + } + if (internalServiceToken == null || internalServiceToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) { + throw new IllegalArgumentException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); + } + timeout = timeout == null ? Duration.ofSeconds(2) : timeout; + if (timeout.isZero() || timeout.isNegative()) { + throw new IllegalArgumentException("PDA_SESSION_VALIDATION_TIMEOUT 必须大于 0"); + } + authUrl = authUrl.replaceAll("/+$", ""); + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java index 4133a793a..35ff9d210 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java @@ -12,7 +12,7 @@ import java.util.Locale; /** Validates the identity and authorization claims required by overseas APIs. */ final class RequiredClaimsJwtValidator implements OAuth2TokenValidator { private static final List REQUIRED_CLAIMS = List.of( - "sub", "tenant_id", "app_code", "permission_version", "authorities"); + "sub", "tenant_id", "app_code", "token_version", "permission_version", "authorities"); @Override public OAuth2TokenValidatorResult validate(Jwt jwt) { @@ -26,6 +26,7 @@ final class RequiredClaimsJwtValidator implements OAuth2TokenValidator { Long subject = longValue(jwt.getSubject()); Long tenantId = longValue(jwt.getClaims().get("tenant_id")); Long permissionVersion = longValue(jwt.getClaims().get("permission_version")); + Long tokenVersion = longValue(jwt.getClaims().get("token_version")); String appCode = String.valueOf(jwt.getClaims().get("app_code")).toUpperCase(Locale.ROOT); if (subject == null || subject <= 0) { return failure("JWT sub 必须是正整数用户 ID"); @@ -36,6 +37,9 @@ final class RequiredClaimsJwtValidator implements OAuth2TokenValidator { if (permissionVersion == null || permissionVersion < 0) { return failure("JWT permission_version 必须是非负整数"); } + if (tokenVersion == null || tokenVersion < 0) { + return failure("JWT token_version 必须是非负整数"); + } if (!("PLATFORM".equals(appCode) && tenantId == 0L) && !("MERCHANT".equals(appCode) && tenantId > 0L)) { return failure("JWT app_code 与 tenant_id 访问域不一致"); diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java new file mode 100644 index 000000000..7f9bd9a97 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java @@ -0,0 +1,46 @@ +package com.baiyue.gateway.filter; + +import com.baiyue.gateway.config.PdaSessionValidationProperties; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +/** 调用 Auth 内部实时校验端点;任何传输或响应异常均交由 Gateway fail-closed。 */ +@Component +public class HttpPdaSessionValidationClient implements PdaSessionValidationClient { + private static final String PATH = "/internal/auth/accounts/overseas-session/validate"; + private final WebClient webClient; + private final PdaSessionValidationProperties properties; + + public HttpPdaSessionValidationClient(WebClient.Builder builder, PdaSessionValidationProperties properties) { + this.webClient = builder.build(); + this.properties = properties; + } + + @Override + public Mono validate(long userId, int tokenVersion, long overseasWarehouseId) { + return webClient.post() + .uri(properties.authUrl() + PATH) + .contentType(MediaType.APPLICATION_JSON) + .header("X-Internal-Service-Token", properties.internalServiceToken()) + .bodyValue(new Request(userId, tokenVersion, overseasWarehouseId)) + .retrieve() + .bodyToMono(JsonNode.class) + .timeout(properties.timeout()) + .flatMap(this::decode); + } + + private Mono decode(JsonNode envelope) { + JsonNode data = envelope == null ? null : envelope.path("data"); + if (data == null || data.isMissingNode() || data.isNull() || !data.has("valid")) { + return Mono.error(new IllegalStateException("Auth overseas session validation response is invalid")); + } + boolean valid = data.path("valid").asBoolean(false); + String reason = data.path("failureReason").isNull() ? null : data.path("failureReason").asText(null); + return Mono.just(new Result(valid, reason)); + } + + private record Request(long userId, int tokenVersion, long overseasWarehouseId) { } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationClient.java new file mode 100644 index 000000000..b40726567 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationClient.java @@ -0,0 +1,10 @@ +package com.baiyue.gateway.filter; + +import reactor.core.publisher.Mono; + +/** 只传递本地验签后提取的稳定 claims,不传递 raw JWT。 */ +public interface PdaSessionValidationClient { + Mono validate(long userId, int tokenVersion, long overseasWarehouseId); + + record Result(boolean valid, String failureReason) { } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java new file mode 100644 index 000000000..4e94ba57b --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java @@ -0,0 +1,92 @@ +package com.baiyue.gateway.filter; + +import com.baiyue.gateway.config.GatewaySecurityConfig; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** 仅对海外 PDA API 执行无缓存的账号版本与仓绑定实时校验。 */ +@Component +public class PdaSessionValidationFilter implements GlobalFilter, Ordered { + private static final String PDA_PREFIX = "/api/pda/"; + private final PdaSessionValidationClient client; + private final ObjectMapper objectMapper; + + public PdaSessionValidationFilter(PdaSessionValidationClient client, ObjectMapper objectMapper) { + this.client = client; + this.objectMapper = objectMapper; + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + if (!exchange.getRequest().getURI().getPath().startsWith(PDA_PREFIX)) { + return chain.filter(exchange); + } + return exchange.getPrincipal().cast(Authentication.class) + .filter(Authentication::isAuthenticated) + .ofType(JwtAuthenticationToken.class) + .switchIfEmpty(Mono.error(new InvalidPdaSession("A0401", "未登录或令牌已失效"))) + .flatMap(authentication -> validate(exchange, chain, authentication)) + .onErrorResume(InvalidPdaSession.class, failure -> write(exchange, failure.code, failure.getMessage())) + .onErrorResume(failure -> write(exchange, "A0401", "海外PDA会话校验不可用")); + } + + private Mono validate(ServerWebExchange exchange, GatewayFilterChain chain, + JwtAuthenticationToken authentication) { + Long userId = longValue(authentication.getToken().getSubject()); + Integer tokenVersion = intClaim(authentication, "token_version"); + Long warehouseId = longClaim(authentication, "overseas_warehouse_id"); + if (userId == null || tokenVersion == null) { + return Mono.error(new InvalidPdaSession("A0401", "海外PDA令牌版本声明缺失")); + } + if (warehouseId == null) { + return Mono.error(new InvalidPdaSession("A0403", "海外PDA账号未绑定仓库")); + } + return client.validate(userId, tokenVersion, warehouseId) + .switchIfEmpty(Mono.error(new InvalidPdaSession("A0401", "海外PDA会话校验不可用"))) + .flatMap(result -> { + if (result.valid()) return chain.filter(exchange); + if ("WAREHOUSE_UNBOUND".equals(result.failureReason()) + || "WAREHOUSE_MISMATCH".equals(result.failureReason())) { + return Mono.error(new InvalidPdaSession("A0403", "海外PDA仓库绑定已变化")); + } + return Mono.error(new InvalidPdaSession("A0401", "海外PDA令牌已失效")); + }); + } + + private Mono write(ServerWebExchange exchange, String code, String message) { + HttpStatus status = "A0403".equals(code) ? HttpStatus.FORBIDDEN : HttpStatus.UNAUTHORIZED; + return GatewaySecurityConfig.writeError(exchange, objectMapper, status, code, message); + } + + private static Integer intClaim(JwtAuthenticationToken authentication, String name) { + Object value = authentication.getToken().getClaims().get(name); + if (value instanceof Number number) return number.intValue(); + try { return value == null ? null : Integer.valueOf(value.toString()); } + catch (NumberFormatException ignored) { return null; } + } + + private static Long longClaim(JwtAuthenticationToken authentication, String name) { + return longValue(authentication.getToken().getClaims().get(name)); + } + + private static Long longValue(Object value) { + if (value instanceof Number number) return number.longValue(); + try { return value == null ? null : Long.valueOf(value.toString()); } + catch (NumberFormatException ignored) { return null; } + } + + @Override public int getOrder() { return -90; } + + private static final class InvalidPdaSession extends RuntimeException { + private final String code; + private InvalidPdaSession(String code, String message) { super(message); this.code = code; } + } +} diff --git a/gateway-overseas/src/main/resources/application.yml b/gateway-overseas/src/main/resources/application.yml index bbeb5714a..200280692 100644 --- a/gateway-overseas/src/main/resources/application.yml +++ b/gateway-overseas/src/main/resources/application.yml @@ -42,6 +42,10 @@ baiyue: issuer: ${JWT_ISSUER:https://auth.example.com} audience: ${JWT_AUDIENCE:lastleg-api} jwk-set-uri: ${JWT_JWK_SET_URI:https://auth.example.com/.well-known/jwks.json} + pda-session-validation: + auth-url: ${AUTH_SESSION_VALIDATION_URL} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s} permit-paths: - /actuator/health - /swagger-ui/** diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/PdaSessionValidationDeploymentContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/PdaSessionValidationDeploymentContractTest.java new file mode 100644 index 000000000..2b67d9ffd --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/PdaSessionValidationDeploymentContractTest.java @@ -0,0 +1,43 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class PdaSessionValidationDeploymentContractTest { + @Test + void nacosComposeAndJenkinsDeploymentRequireOnlyEnvironmentBackedAuthCoordinates() throws Exception { + String application = read("gateway-overseas/src/main/resources/application.yml"); + String nacos = read("config/nacos/overseas/baiyue-common.yml"); + String compose = read("deploy/docker-compose.overseas.yml"); + String deployServices = read("deploy/scripts/deploy-services.sh"); + String deployBuiltService = read("deploy/scripts/deploy-built-service.sh"); + String jenkins = read("Jenkinsfile"); + + for (String source : new String[]{application, nacos, compose}) { + assertThat(source).contains("AUTH_SESSION_VALIDATION_URL", "INTERNAL_SERVICE_TOKEN", + "PDA_SESSION_VALIDATION_TIMEOUT"); + } + assertThat(deployServices).contains("gateway-overseas", "AUTH_SESSION_VALIDATION_URL"); + assertThat(deployBuiltService).contains("gateway-overseas", "AUTH_SESSION_VALIDATION_URL"); + assertThat(jenkins).contains("'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS'", "'auth': 'SERVICE_AUTH'", + "'iam': 'SERVICE_IAM'"); + assertThat(application).doesNotContain("/internal/auth/accounts/overseas-session/validate"); + assertThat(read("gateway-overseas/src/main/resources/application-overseas.yml")) + .doesNotContain("/internal/auth/**", "/internal/auth/accounts/overseas-session/validate"); + assertThat(read("gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java")) + .contains("new Request(userId, tokenVersion, overseasWarehouseId)", + ".header(\"X-Internal-Service-Token\"") + .doesNotContain("Authorization", "accessToken", "rawToken", "getTokenValue"); + } + + private static String read(String relative) throws Exception { + Path path = Path.of(relative); + if (!Files.exists(path)) path = Path.of("..").resolve(relative); + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java index 8533dc7c7..fa9c07ea8 100644 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java @@ -21,11 +21,22 @@ class RequiredClaimsJwtValidatorTest { assertThat(validator.validate(jwt(false)).hasErrors()).isTrue(); } + @Test + void rejectsMissingAccountTokenVersion() { + Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001") + .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) + .claim("tenant_id", "0").claim("app_code", "PLATFORM") + .claim("permission_version", 3L).claim("authorities", List.of()) + .build(); + assertThat(validator.validate(value).hasErrors()).isTrue(); + } + @Test void rejectsMissingAuthorities() { Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001") .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) - .claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("permission_version", 3L) + .claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("token_version", 1) + .claim("permission_version", 3L) .build(); assertThat(validator.validate(value).hasErrors()).isTrue(); } @@ -54,6 +65,7 @@ class RequiredClaimsJwtValidatorTest { Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("not-a-user") .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) .claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("permission_version", -1L) + .claim("token_version", 1) .claim("authorities", List.of()).build(); assertThat(validator.validate(value).hasErrors()).isTrue(); } @@ -72,6 +84,7 @@ class RequiredClaimsJwtValidatorTest { .expiresAt(Instant.now().plusSeconds(300)) .claim("tenant_id", tenantId) .claim("app_code", appCode) + .claim("token_version", 1) .claim("authorities", authorities); if (includePermissionVersion) { builder.claim("permission_version", 3L); diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java new file mode 100644 index 000000000..ae9d3aa24 --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java @@ -0,0 +1,119 @@ +package com.baiyue.gateway.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class PdaSessionValidationFilterTest { + private final PdaSessionValidationClient client = mock(PdaSessionValidationClient.class); + private final PdaSessionValidationFilter filter = new PdaSessionValidationFilter(client, new ObjectMapper()); + + @Test + void oldTokenIsRejectedImmediatelyAfterWarehouseChange() { + when(client.validate(42L, 7, 900L)).thenReturn(Mono.just( + new PdaSessionValidationClient.Result(false, "TOKEN_VERSION_MISMATCH"))); + var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(7, 900L)); + + StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); + + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(body(exchange)).contains("A0401", "令牌已失效"); + } + + @Test + void newlyIssuedTokenWithCurrentWarehousePasses() { + when(client.validate(42L, 8, 901L)).thenReturn(Mono.just( + new PdaSessionValidationClient.Result(true, null))); + var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, 901L)); + AtomicBoolean routed = new AtomicBoolean(); + + StepVerifier.create(filter.filter(exchange, ignored -> { + routed.set(true); + return Mono.empty(); + })).verifyComplete(); + + assertThat(routed).isTrue(); + assertThat(exchange.getResponse().getStatusCode()).isNull(); + } + + @Test + void unboundWarehouseIsDeniedWithoutCallingAuth() { + var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, null)); + + StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); + + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(body(exchange)).contains("A0403", "未绑定仓库"); + verifyNoInteractions(client); + } + + @Test + void authUnavailableFailsClosed() { + when(client.validate(42L, 8, 901L)).thenReturn(Mono.error(new IllegalStateException("timeout"))); + var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, 901L)); + + StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); + + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(body(exchange)).contains("A0401", "校验不可用"); + } + + @Test + void ordinaryOverseasAdminAndClientPathsDoNotSynchronouslyValidate() { + for (String path : new String[]{"/api/platform/v1/last-leg/orders", "/api/merchant/v1/last-leg/orders"}) { + var exchange = exchange(path, claims(7, 900L)); + AtomicBoolean routed = new AtomicBoolean(); + StepVerifier.create(filter.filter(exchange, ignored -> { + routed.set(true); + return Mono.empty(); + })).verifyComplete(); + assertThat(routed).isTrue(); + } + verifyNoInteractions(client); + } + + private static ServerWebExchange exchange(String path, Map claims) { + Jwt.Builder jwt = Jwt.withTokenValue("already-verified-by-resource-server") + .header("alg", "RS256") + .subject("42") + .issuedAt(Instant.now().minusSeconds(5)) + .expiresAt(Instant.now().plusSeconds(300)); + claims.forEach(jwt::claim); + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.get(path).build()); + return exchange.mutate() + .principal(Mono.just(new JwtAuthenticationToken(jwt.build(), + List.of(new SimpleGrantedAuthority("ROLE_WAREHOUSE_PICKER"))))) + .build(); + } + + private static Map claims(int tokenVersion, Long warehouseId) { + var claims = new java.util.LinkedHashMap(); + claims.put("token_version", tokenVersion); + if (warehouseId != null) claims.put("overseas_warehouse_id", warehouseId); + return claims; + } + + private static String body(ServerWebExchange exchange) { + return ((org.springframework.mock.http.server.reactive.MockServerHttpResponse) exchange.getResponse()) + .getBodyAsString().block(); + } +} diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index cf6287b13..dc8e69d70 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -113,6 +113,81 @@ DELIMITER ; CALL assert_overseas_pda_picker_permission(); DROP PROCEDURE assert_overseas_pda_picker_permission; +-- 四期仓库后台角色严格最小授权:list 同时覆盖列表与详情,只读角色不得获得任何写权限。 +DROP TEMPORARY TABLE IF EXISTS tmp_phase4_warehouse_role_permission; +CREATE TEMPORARY TABLE tmp_phase4_warehouse_role_permission ( + role_code VARCHAR(64) NOT NULL, + permission_code VARCHAR(160) NOT NULL, + PRIMARY KEY (role_code, permission_code) +); +INSERT INTO tmp_phase4_warehouse_role_permission (role_code, permission_code) VALUES + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:last-mile-order:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:inbound-order:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:relabel-order:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:inventory:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:warehouse:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:location:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:pallet:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-business:purchase:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-business:truck:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-business:looseboard:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:last-mile-order:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:inbound-order:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:relabel-order:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:inventory:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:warehouse:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:location:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:pallet:list'), + ('WAREHOUSE_TALLY','platform:overseas-business:purchase:list'), + ('WAREHOUSE_TALLY','platform:overseas-business:truck:list'), + ('WAREHOUSE_TALLY','platform:overseas-business:looseboard:list'); + +DELETE grant_row FROM iam_role_permission grant_row +JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id +JOIN iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id +LEFT JOIN tmp_phase4_warehouse_role_permission expected + ON expected.role_code=role.role_code AND expected.permission_code=permission.permission_code +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code IN ('WAREHOUSE_OPERATOR','WAREHOUSE_PICKER','WAREHOUSE_TALLY') + AND permission.permission_code LIKE 'platform:overseas-%' + AND expected.permission_code IS NULL; + +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM tmp_phase4_warehouse_role_permission expected +JOIN iam_role role ON role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code=expected.role_code AND role.status='ACTIVE' AND role.deleted=0 +JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code=expected.permission_code + AND permission.status='ACTIVE' AND permission.deleted=0; + +DROP PROCEDURE IF EXISTS assert_phase4_warehouse_role_minimum; +DELIMITER $$ +CREATE PROCEDURE assert_phase4_warehouse_role_minimum() +BEGIN + IF (SELECT COUNT(*) FROM tmp_phase4_warehouse_role_permission) <> 20 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库角色最小权限矩阵数量异常'; + END IF; + IF EXISTS (SELECT 1 FROM tmp_phase4_warehouse_role_permission + WHERE permission_code REGEXP ':(create|edit|status|export|adjust|delete|review)$') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库只读角色错误获得写权限'; + END IF; + IF EXISTS (SELECT 1 FROM tmp_phase4_warehouse_role_permission expected + LEFT JOIN iam_role role ON role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code=expected.role_code AND role.status='ACTIVE' AND role.deleted=0 + LEFT JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code=expected.permission_code AND permission.status='ACTIVE' AND permission.deleted=0 + LEFT JOIN iam_role_permission grant_row ON grant_row.tenant_id=0 + AND grant_row.role_id=role.id AND grant_row.permission_id=permission.id + WHERE grant_row.role_id IS NULL) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库角色最小权限授权不完整'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase4_warehouse_role_minimum(); +DROP PROCEDURE assert_phase4_warehouse_role_minimum; +DROP TEMPORARY TABLE tmp_phase4_warehouse_role_permission; + INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, role.id, permission.id, NOW(3) FROM iam_role role diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index 776ec6854..2182c43f1 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -60,6 +60,35 @@ class OverseasPhase4PermissionSqlContractTest { assertCustomAdminGrant(sql); } + @Test + void warehouseRolesReceiveOnlyTheTenPhase4ListPermissionsAndPickerRemainsPdaOnly() throws Exception { + String moduleSql = new ClassPathResource("db/lastleg-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + Path path = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(path)) path = Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"); + String unifiedSql = Files.readString(path, StandardCharsets.UTF_8); + + for (String sql : List.of(moduleSql, unifiedSql)) { + String matrix = matrixBlock(sql); + assertThat(matrix).contains("WAREHOUSE_OPERATOR", "WAREHOUSE_TALLY"); + assertThat(codes(matrix)).containsExactlyInAnyOrder( + "platform:overseas-warehouse:last-mile-order:list", + "platform:overseas-warehouse:inbound-order:list", + "platform:overseas-warehouse:relabel-order:list", + "platform:overseas-warehouse:inventory:list", + "platform:overseas-warehouse:warehouse:list", + "platform:overseas-warehouse:location:list", + "platform:overseas-warehouse:pallet:list", + "platform:overseas-business:purchase:list", + "platform:overseas-business:truck:list", + "platform:overseas-business:looseboard:list"); + assertThat(matrix).doesNotContain("WAREHOUSE_PICKER"); + assertThat(matrix).doesNotContain(":create'", ":edit'", ":status'", ":export'", ":adjust'"); + assertThat(sql).contains("assert_phase4_warehouse_role_minimum", "<> 20", + "role.role_code IN ('WAREHOUSE_OPERATOR','WAREHOUSE_PICKER','WAREHOUSE_TALLY')"); + } + } + @Test void servicePermissionConstantsExactlyMatchTheIamCatalog() throws Exception { Set moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql") @@ -109,6 +138,14 @@ class OverseasPhase4PermissionSqlContractTest { assertThat(sql.substring(grantStart, grantEnd)).contains("lastleg:pda:operate"); } + private static String matrixBlock(String sql) { + int start = sql.indexOf("INSERT INTO tmp_phase4_warehouse_role_permission"); + int end = sql.indexOf("DELETE grant_row FROM", start); + assertThat(start).isGreaterThanOrEqualTo(0); + assertThat(end).isGreaterThan(start); + return sql.substring(start, end); + } + private static String repositoryFile(String relativePath) throws Exception { Path path = Path.of(relativePath); if (!Files.exists(path)) path = Path.of("..").resolve(relativePath); diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java index 8f45baf95..4dd7ddfb6 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -135,6 +135,34 @@ class PlatformUserManagementServiceTest { verify(auth).revokeSessions(eq("internal-token"), any()); } + @Test + void changingWarehouseBindingRevokesAllAuthSessionsSoAccountVersionIsBumped() { + platformRole.setRoleCode("WAREHOUSE_PICKER"); + membership.setOverseasWarehouseId(900L); + UpdateUserRequest changed = new UpdateUserRequest("张伟", "13800138000", null, + 0L, List.of(40001L), 901L); + + UserResponse response = service.update(30L, changed, Set.of("platform:user:grant-role")); + + assertEquals(901L, response.overseasWarehouseId()); + verify(auth).revokeSessions(eq("internal-token"), + eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(10L)))); + } + + @Test + void clearingWarehouseBindingAlsoRevokesAllAuthSessions() { + platformRole.setRoleCode("WAREHOUSE_OPERATOR"); + membership.setOverseasWarehouseId(900L); + UpdateUserRequest cleared = new UpdateUserRequest("张伟", "13800138000", null, + 0L, List.of(40001L), null); + + UserResponse response = service.update(30L, cleared, Set.of("platform:user:grant-role")); + + assertNull(response.overseasWarehouseId()); + verify(auth).revokeSessions(eq("internal-token"), + eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(10L)))); + } + @Test void currentPlatformOperatorCannotDisableSelf() { assertThrows(BizException.class, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index 9d8fc653d..28c46ba7a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -11,6 +11,8 @@ import com.baiyue.lastleg.security.InternalServiceTokenGuard; import com.baiyue.lastleg.security.OverseasPermissions; import com.baiyue.lastleg.service.LastlegOrderService; import com.baiyue.lastleg.service.LoadingCompletedConsumer; +import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer; +import com.baiyue.common.event.EventTypes; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -37,17 +39,20 @@ import java.nio.charset.StandardCharsets; @Tag(name = "Tail-leg orders") public class LastlegOrderController { @Resource private LoadingCompletedConsumer consumer; + @Resource private ContainerShippingNodeConsumer shippingNodeConsumer; @Resource private LastlegOrderService orderService; @Resource private InternalServiceTokenGuard tokenGuard; @PostMapping("/internal/lastleg/events") - @Operation(summary = "Receive a first-leg loading-completed event", - description = "V2 orders[].destinationType is FBA or OVERSEAS. FBA may use package counts; OVERSEAS requires one carton mark per loaded package. Legacy events are accepted only when the destination type can be inferred safely.") + @Operation(summary = "Receive a first-leg loading or actual shipping-node event", + description = "Accepts loading-completed V1/V2 and FirstLegContainerShippingNodeRecorded V1 through the internal token boundary; only monotonic actual PICKUP_READY-or-later facts open overseas PDA receiving.") public ApiResponse receive( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestBody EventEnvelope event) { tokenGuard.verify(token); - return ApiResponse.success(consumer.consume(event)); + int consumed = EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType()) + ? shippingNodeConsumer.consume(event) : consumer.consume(event); + return ApiResponse.success(consumed); } @GetMapping("/api/platform/v1/last-leg/orders") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java index fdb3e6d75..b87e1a101 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -29,7 +29,7 @@ import java.util.List; @RestController @RequestMapping("/api/pda/v1/last-leg/receiving-containers") @Tag(name = "Overseas PDA container receiving") -@PreAuthorize("hasAuthority('lastleg:pda:operate')") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')") public class ReceivingContainerController { public static final String OPERATE_PERMISSION = PdaWarehouseIdentity.OPERATE_PERMISSION; @Resource private ReceivingContainerService service; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java index 7aff90d5b..5bdfcc3f3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -114,6 +114,33 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { response.data().fbaWarehouseCode(), response.data().version()); } + @Override + public WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) { + WarehouseLocationResponse response = client.get() + .uri(uri -> uri.path("/internal/v1/warehouse-resource-locks/locations/resolve/{locationCode}") + .queryParam("warehouseId", warehouseId).build(locationCode)) + .header("X-Internal-Service-Token", internalToken).retrieve() + .body(WarehouseLocationResponse.class); + return location(response); + } + + @Override + public WarehouseLocationReference resolveLocation(long warehouseId, long locationId) { + WarehouseLocationResponse response = client.get() + .uri(uri -> uri.path("/internal/v1/warehouse-resource-locks/locations/{locationId}") + .queryParam("warehouseId", warehouseId).build(locationId)) + .header("X-Internal-Service-Token", internalToken).retrieve() + .body(WarehouseLocationResponse.class); + return location(response); + } + + private WarehouseLocationReference location(WarehouseLocationResponse response) { + if (response == null || response.data() == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse location was not found"); + } + return response.data(); + } + @Override public WarehouseReference requireActiveSharedWarehouse(long warehouseId) { WarehouseReferenceResponse response = client.get() @@ -147,4 +174,5 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { private record ReserveEnvelope(ReserveData data) {} private record ReserveData(long lockId) {} private record WarehouseReferenceResponse(String code, String message, WarehouseReference data) {} + private record WarehouseLocationResponse(String code, String message, WarehouseLocationReference data) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java index 1d6b66f96..138c9a784 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java @@ -8,6 +8,12 @@ public interface WarehouseResourceGateway { void scrap(String customerId, long resourceLockId); void scrapPallet(String customerId, long palletId); WarehousePalletReference resolvePallet(String palletCode); + default WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) { + throw new UnsupportedOperationException("Warehouse location resolution is not configured"); + } + default WarehouseLocationReference resolveLocation(long warehouseId, long locationId) { + throw new UnsupportedOperationException("Warehouse location resolution is not configured"); + } default WarehouseReference requireActiveSharedWarehouse(long warehouseId) { throw new UnsupportedOperationException("Shared warehouse resolution is not configured"); } @@ -21,4 +27,6 @@ public interface WarehouseResourceGateway { record WarehouseReference(long warehouseId, String warehouseCode, String warehouseName, String status) { } + record WarehouseLocationReference(long locationId, String locationCode, String locationType, + java.math.BigDecimal maxLoadKg, String status) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index f76eaf789..092e88c99 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -27,6 +27,7 @@ import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.MapperFeature; @@ -67,6 +68,7 @@ public class ReceivingContainerService { @Resource private ReceivingContainerSessionMapper sessions; @Resource private ReceivingContainerOperationMapper operations; @Resource private ReceivingSkuReceiptMapper skuReceipts; + @Resource private ContainerShippingGateMapper shippingGates; @Resource private WarehouseResourceGateway warehouse; @Resource private UnloadPhotoFileGateway unloadPhotos; @Resource private InboundPdaWorkflowService workflow; @@ -104,7 +106,8 @@ public class ReceivingContainerService { public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) { List containerPlans = requirePlans(containerNo); String customerId = requireSingleCustomer(containerPlans, containerNo); - if (!eligible(containerPlans, overseasWarehouseId)) throw notFound("Container is assigned to another overseas warehouse"); + if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw notFound("Container is assigned to another overseas warehouse"); + requireReceivingAvailable(customerId, containerNo); ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); requireSessionWarehouse(current, overseasWarehouseId); if (current != null && !containerNo.equals(current.getContainerNo())) { @@ -130,7 +133,8 @@ public class ReceivingContainerService { } List containerPlans = requirePlans(containerNo); String customerId = requireSingleCustomer(containerPlans, containerNo); - if (!eligible(containerPlans, overseasWarehouseId)) throw conflict("Container is assigned to another overseas warehouse"); + if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("Container is assigned to another overseas warehouse"); + requireReceivingAvailable(customerId, containerNo); String activeOperatorKey = activeOperatorKey(operatorKey); ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey); requireSessionWarehouse(active, overseasWarehouseId); @@ -641,6 +645,12 @@ public class ReceivingContainerService { } private boolean eligible(List containerPlans, long overseasWarehouseId) { + String customerId = requireSingleCustomer(containerPlans, containerPlans.get(0).getContainerNo()); + return warehouseEligible(containerPlans, overseasWarehouseId) + && shippingGates.receivingAvailable(customerId, containerPlans.get(0).getContainerNo()) == 1; + } + + private boolean warehouseEligible(List containerPlans, long overseasWarehouseId) { Set assigned = containerPlans.stream().map(InboundPlan::getOverseasWarehouseId) .filter(Objects::nonNull).collect(Collectors.toSet()); if (assigned.size() > 1 || (!assigned.isEmpty() && containerPlans.stream() @@ -650,6 +660,12 @@ public class ReceivingContainerService { return assigned.isEmpty() || assigned.contains(overseasWarehouseId); } + private void requireReceivingAvailable(String customerId, String containerNo) { + if (shippingGates.receivingAvailable(customerId, containerNo) != 1) { + throw conflict("Container has not reached PICKUP_READY and is not available for overseas receiving"); + } + } + private void claimReceivingWarehouse(String customerId, List containerPlans, WarehouseResourceGateway.WarehouseReference warehouse) { long overseasWarehouseId = warehouse.warehouseId(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java index 28d22b391..0b94032df 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java @@ -68,5 +68,6 @@ public final class LocalLooseboardRequests { public record ChangeStatus( @NotNull @Min(0) Long version, @NotBlank @Size(max = 32) String status, + @NotBlank @Size(max = 255) String idempotencyKey, @Size(max = 1000) String remark) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java index f4a53e06e..e8b930560 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java @@ -17,7 +17,7 @@ public final class LocalLooseboardResponses { public record History( String operationType, String fromStatus, String toStatus, String operatorId, - String changeDetail, String remark, LocalDateTime createdAt) { } + String idempotencyKey, String changeDetail, String remark, LocalDateTime createdAt) { } public record Detail( Summary summary, String originAddress, String destinationAddress, String remark, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardHistory.java index c816608cb..57dca5081 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardHistory.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardHistory.java @@ -17,6 +17,7 @@ public class LocalLooseboardHistory { private String fromStatus; private String toStatus; private String operatorId; + private String idempotencyKey; private String changeDetail; private String remark; private LocalDateTime createdAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOperation.java new file mode 100644 index 000000000..f016e7517 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOperation.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.looseboard.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_local_looseboard_operation") +public class LocalLooseboardOperation { + @TableId(type = IdType.INPUT) private Long id; + private Long orderId; + private String customerId; + private String operationType; + private String idempotencyKey; + private String requestHash; + private Long resultVersion; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOperationMapper.java new file mode 100644 index 000000000..061fbbd57 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOperationMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.looseboard.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface LocalLooseboardOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_local_looseboard_operation WHERE customer_id=#{customerId} AND order_id=#{orderId} AND idempotency_key=#{key} LIMIT 1") + LocalLooseboardOperation findKey(@Param("customerId") String customerId, + @Param("orderId") Long orderId, + @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java index 7775c196a..b50c883ba 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java @@ -12,8 +12,10 @@ import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests; import com.baiyue.lastleg.looseboard.dto.LocalLooseboardResponses; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation; import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper; import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper; +import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOperationMapper; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -51,6 +53,7 @@ public class LocalLooseboardService { @Resource private LocalLooseboardOrderMapper orders; @Resource private LocalLooseboardHistoryMapper histories; + @Resource private LocalLooseboardOperationMapper operations; @Resource private LastlegOrderMapper lastlegOrders; @Resource private LastlegOrderStatusHistoryMapper lastlegHistories; @Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator; @@ -88,7 +91,8 @@ public class LocalLooseboardService { if (concurrent != null) return replay(customerId, concurrent, requestHash); throw exception; } - history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId, null, request.remark(), now); + history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId, + key, null, request.remark(), now); lastlegHistory(customerId, lastlegOrder.getId(), null, lastlegOrder.getCurrentStatus(), "Local looseboard order submitted", now); return detail(customerId, order.getOrderNo()); @@ -139,7 +143,7 @@ public class LocalLooseboardService { touch(order, now); orders.updateById(order); history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, - changeDetail(before, after), null, now); + null, changeDetail(before, after), null, now); return response(order); } @@ -147,6 +151,15 @@ public class LocalLooseboardService { public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId, LocalLooseboardRequests.ChangeStatus request) { LocalLooseboardOrder order = lock(customerId, orderNo); + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + LocalLooseboardOperation existing = operations.findKey(customerId, order.getId(), key); + if (existing != null) { + if (!"STATUS".equals(existing.getOperationType()) || !requestHash.equals(existing.getRequestHash())) { + throw conflict("Idempotency key was reused with a different looseboard status payload"); + } + return response(order); + } if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); String next = norm(request.status()); if (!STATUSES.contains(next)) throw badRequest("Unsupported looseboard order status"); @@ -160,7 +173,13 @@ public class LocalLooseboardService { touch(order, now); orders.updateById(order); syncLastlegOrder(order, next, now); - history(order, "STATUS", from, next, operatorId, null, request.remark(), now); + LocalLooseboardOperation operation = new LocalLooseboardOperation(); + operation.setId(IdWorker.getId()); operation.setOrderId(order.getId()); + operation.setCustomerId(customerId); operation.setOperationType("STATUS"); + operation.setIdempotencyKey(key); operation.setRequestHash(requestHash); + operation.setResultVersion(order.getVersion()); operation.setCreatedBy(operatorId); + operation.setCreatedAt(now); operations.insert(operation); + history(order, "STATUS", from, next, operatorId, key, null, request.remark(), now); return response(order); } @@ -204,7 +223,8 @@ public class LocalLooseboardService { order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(), histories.listOrder(order.getCustomerId(), order.getId()).stream() .map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(), - value.getToStatus(), value.getOperatorId(), value.getChangeDetail(), value.getRemark(), + value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(), + value.getChangeDetail(), value.getRemark(), value.getCreatedAt())).toList()); } @@ -218,7 +238,7 @@ public class LocalLooseboardService { } private void history(LocalLooseboardOrder order, String type, String from, String to, String operator, - String changeDetail, String remark, LocalDateTime now) { + String idempotencyKey, String changeDetail, String remark, LocalDateTime now) { LocalLooseboardHistory history = new LocalLooseboardHistory(); history.setId(IdWorker.getId()); history.setOrderId(order.getId()); @@ -227,6 +247,7 @@ public class LocalLooseboardService { history.setFromStatus(from); history.setToStatus(to); history.setOperatorId(operator); + history.setIdempotencyKey(trim(idempotencyKey)); history.setChangeDetail(changeDetail); history.setRemark(trim(remark)); history.setCreatedAt(now); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java new file mode 100644 index 000000000..874bb0e17 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.pda.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseRequests; +import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseResponses; +import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/pda/v1/last-leg") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')") +public class PdaWarehouseController { + @Resource private PdaWarehouseService service; + + @PostMapping("/inventory/resolve") + public ApiResponse inventory(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PdaWarehouseRequests.Scan request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.inventory(identity.overseasWarehouseId(), request)); + } + + @PostMapping("/locations/resolve") + public ApiResponse location(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PdaWarehouseRequests.Scan request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.location(identity.overseasWarehouseId(), request)); + } + + @PostMapping("/pallets/resolve") + public ApiResponse pallet(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PdaWarehouseRequests.Scan request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.pallet(identity.overseasWarehouseId(), request)); + } + + @GetMapping("/operations") + public ApiResponse> operations( + @AuthenticationPrincipal AuthenticatedUser user, @Valid PdaWarehouseRequests.HistoryQuery query) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.history(identity.overseasWarehouseId(), identity.operatorKey(), query)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java index 0fab0336f..586d7dde6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java @@ -11,7 +11,7 @@ import java.util.List; @Mapper public interface PdaWarehouseMapper extends BaseMapper { - String INVENTORY_SELECT = "SELECT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," + + String INVENTORY_SELECT = "SELECT DISTINCT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," + "COALESCE(NULLIF(r.sku_code,''),b.sku_code) sku_code," + "COALESCE(NULLIF(r.product_name_zh,''),b.product_name_zh) product_name_zh," + "COALESCE(NULLIF(r.product_name_en,''),b.product_name_en) product_name_en,b.inbound_code," + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseService.java new file mode 100644 index 000000000..b8500a76e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseService.java @@ -0,0 +1,101 @@ +package com.baiyue.lastleg.pda.warehouse.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseRequests; +import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseResponses; +import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory; +import com.baiyue.lastleg.pda.warehouse.mapper.PdaWarehouseMapper; +import com.baiyue.lastleg.pda.warehouse.model.PdaInventoryProjection; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; + +@Service +public class PdaWarehouseService { + @Resource private PdaWarehouseMapper mapper; + @Resource private WarehouseResourceGateway warehouse; + + public PdaWarehouseResponses.InventoryScan inventory(long warehouseId, PdaWarehouseRequests.Scan request) { + String code = code(request.code()); + List items = items(mapper.scanInventory(String.valueOf(warehouseId), code)); + if (items.isEmpty()) throw notFound("Inventory code was not found in the bound warehouse"); + return new PdaWarehouseResponses.InventoryScan(code, items); + } + + public PdaWarehouseResponses.Location location(long warehouseId, PdaWarehouseRequests.Scan request) { + WarehouseResourceGateway.WarehouseLocationReference location = warehouse.resolveLocation(warehouseId, + code(request.code())); + List inventory = items(mapper.inventoryByLocation( + String.valueOf(warehouseId), location.locationId(), location.locationCode(), "__NO_CARTON__")); + long packages = inventory.stream().mapToLong(this::packages).sum(); + int positions = (int) inventory.stream().map(PdaWarehouseResponses.InventoryItem::positionId).distinct().count(); + return new PdaWarehouseResponses.Location(location.locationId(), location.locationCode(), + location.locationType(), location.maxLoadKg(), location.status(), packages, positions); + } + + public PdaWarehouseResponses.Pallet pallet(long warehouseId, PdaWarehouseRequests.Scan request) { + WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(code(request.code())); + if (pallet.warehouseId() != warehouseId) throw conflict("Pallet belongs to another overseas warehouse"); + WarehouseResourceGateway.WarehouseLocationReference location = warehouse.resolveLocation(warehouseId, + pallet.locationId()); + List inventory = items(mapper.inventoryByPallet( + String.valueOf(warehouseId), pallet.palletId(), "__NO_CARTON__")); + long packages = inventory.stream().mapToLong(this::packages).sum(); + int positions = (int) inventory.stream().map(PdaWarehouseResponses.InventoryItem::positionId).distinct().count(); + return new PdaWarehouseResponses.Pallet(pallet.palletId(), pallet.palletNo(), pallet.status(), pallet.version(), + location.locationId(), location.locationCode(), packages, positions, inventory); + } + + public PageResult history(long warehouseId, String operatorKey, + PdaWarehouseRequests.HistoryQuery query) { + long offset = (query.page() - 1) * query.size(); + List rows = mapper.history(warehouseId, operatorKey, + trim(query.operationType()), offset, query.size()); + long total = mapper.countHistory(warehouseId, operatorKey, trim(query.operationType())); + return new PageResult<>(rows, query.page(), query.size(), total, + total == 0 ? 0 : (total + query.size() - 1) / query.size()); + } + + public void record(long warehouseId, String customerId, String operatorKey, String operationType, + String businessKey, String status, String idempotencyKey, String requestHash, String summary) { + PdaOperationHistory history = new PdaOperationHistory(); + history.setId(IdWorker.getId()); history.setOverseasWarehouseId(warehouseId); + history.setCustomerId(trim(customerId)); history.setOperatorKey(operatorKey); + history.setOperationType(operationType); history.setBusinessKey(businessKey); history.setStatus(status); + history.setIdempotencyKey(trim(idempotencyKey)); history.setRequestHash(trim(requestHash)); + history.setSummary(trim(summary)); history.setOccurredAt(LocalDateTime.now()); history.setCreatedAt(history.getOccurredAt()); + mapper.insert(history); + } + + private List items(List values) { + LinkedHashMap result = new LinkedHashMap<>(); + for (PdaInventoryProjection value : values) { + String key = value.getPositionId() + ":" + text(value.getSkuCode()) + ":" + text(value.getCartonMark()); + result.putIfAbsent(key, new PdaWarehouseResponses.InventoryItem(value.getCustomerId(), value.getCustomerName(), + number(value.getBalanceId()), number(value.getPositionId()), value.getSkuCode(), + first(value.getProductNameZh(), value.getProductNameEn()), value.getInboundCode(), value.getCartonMark(), + number(value.getPalletId()), value.getPalletCode(), value.getLocationId(), value.getLocationCode(), + number(value.getAvailablePackages()), number(value.getHoldPackages()), number(value.getReceivingPackages()), + value.getStatus(), number(value.getVersion()))); + } + return List.copyOf(result.values()); + } + + private long packages(PdaWarehouseResponses.InventoryItem value) { + return value.availablePackages() + value.holdPackages() + value.receivingPackages(); + } + private static String code(String value) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Scan code is required"); return result; } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String text(String value) { return value == null ? "" : value; } + private static String first(String first, String second) { String value = trim(first); return value == null ? trim(second) : value; } + private static long number(Long value) { return value == null ? 0 : value; } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java index 133885b7d..d31cd0672 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java @@ -36,7 +36,11 @@ public final class OverseasProcurementRequests { String productName, String status, String purchaseWarehouseId, LocalDateTime createdFrom, LocalDateTime createdTo) {} - public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {} + public record Review( + @NotNull @Min(0) Long version, + @NotBlank String decision, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} public record Update( @NotNull @Min(0) Long version, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java index 2a2eff426..d0f94de6b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java @@ -22,7 +22,7 @@ public final class OverseasProcurementResponses { public record History( String operationType, String fromStatus, String toStatus, String operatorId, - String changeDetail, String remark, + String idempotencyKey, String changeDetail, String remark, LocalDateTime createdAt) {} public record Detail( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java index 7d5d34abc..0a0c52dd9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java @@ -42,6 +42,8 @@ public class OverseasProcurementOrder { private String createdBy; private String reviewedBy; private String reviewRemark; + private String reviewIdempotencyKey; + private String reviewRequestHash; private LocalDateTime reviewedAt; private Long version; private LocalDateTime createdAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementStatusHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementStatusHistory.java index 509b9f1bd..e7d3be60d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementStatusHistory.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementStatusHistory.java @@ -17,6 +17,7 @@ public class OverseasProcurementStatusHistory { private String fromStatus; private String toStatus; private String operatorId; + private String idempotencyKey; private String changeDetail; private String remark; private LocalDateTime createdAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java index 865caf78c..6a08ae5aa 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -89,7 +89,8 @@ public class OverseasProcurementService { if (concurrent != null) return replay(customerId, concurrent, requestHash); throw exception; } - history(order, "CREATE", null, PENDING_REVIEW, operatorId, null, request.remark(), now); + history(order, "CREATE", null, PENDING_REVIEW, operatorId, + key, null, request.remark(), now); return detail(customerId, order.getOrderNo()); } @@ -144,7 +145,8 @@ public class OverseasProcurementService { order.getCreatedBy(), order.getReviewedBy(), order.getReviewRemark(), order.getReviewedAt(), histories.listOrder(customerId, order.getId()).stream().map(value -> new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(), - value.getToStatus(), value.getOperatorId(), value.getChangeDetail(), value.getRemark(), + value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(), + value.getChangeDetail(), value.getRemark(), value.getCreatedAt())).toList()); } @@ -157,16 +159,27 @@ public class OverseasProcurementService { default -> throw badRequest("Review decision must be APPROVE or REJECT"); }; OverseasProcurementOrder order = lock(customerId, orderNo); - if (next.equals(order.getStatus())) return detail(customerId, orderNo); + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + if (key.equals(order.getReviewIdempotencyKey())) { + if (!requestHash.equals(order.getReviewRequestHash())) { + throw conflict("Review idempotency key was reused with a different payload"); + } + return detail(customerId, orderNo); + } + if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry"); if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Overseas procurement order was already reviewed"); LocalDateTime now = LocalDateTime.now(); order.setStatus(next); order.setReviewedBy(operatorId); order.setReviewRemark(trim(request.remark())); + order.setReviewIdempotencyKey(key); + order.setReviewRequestHash(requestHash); order.setReviewedAt(now); touch(order, now); orders.updateById(order); - history(order, "REVIEW", PENDING_REVIEW, next, operatorId, null, request.remark(), now); + history(order, "REVIEW", PENDING_REVIEW, next, operatorId, + key, null, request.remark(), now); return detail(customerId, orderNo); } @@ -197,7 +210,7 @@ public class OverseasProcurementService { touch(order, now); orders.updateById(order); history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, - changeDetail(before, after), null, now); + null, changeDetail(before, after), null, now); return detail(customerId, orderNo); } @@ -224,7 +237,8 @@ public class OverseasProcurementService { } private void history(OverseasProcurementOrder order, String operationType, String from, String to, - String operator, String changeDetail, String remark, LocalDateTime now) { + String operator, String idempotencyKey, String changeDetail, + String remark, LocalDateTime now) { OverseasProcurementStatusHistory history = new OverseasProcurementStatusHistory(); history.setId(IdWorker.getId()); history.setOrderId(order.getId()); @@ -233,6 +247,7 @@ public class OverseasProcurementService { history.setFromStatus(from); history.setToStatus(to); history.setOperatorId(operator); + history.setIdempotencyKey(trim(idempotencyKey)); history.setChangeDetail(changeDetail); history.setRemark(trim(remark)); history.setCreatedAt(now); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index bbb89c9ce..5ef81e214 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -11,6 +11,7 @@ import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.DeserializationFeature; import jakarta.annotation.Resource; @@ -41,6 +42,7 @@ public class LoadingCompletedConsumer { @Resource private LastlegOrderNoGenerator orderNoGenerator; @Resource private DropshipOrderService dropshipOrders; @Resource private InventoryCommandService inventory; + @Resource private ContainerShippingGateMapper shippingGates; @Transactional(isolation = Isolation.READ_COMMITTED) public int consume(EventEnvelope envelope) { @@ -71,6 +73,9 @@ public class LoadingCompletedConsumer { return countOrders(envelope,payload.containerNo()); } + shippingGates.initialize(payload.containerBookingId(), envelope.tenantId(), "", + payload.containerNo(), envelope.aggregateVersion(), envelope.handoverVersion()); + String orderNo = orderNoGenerator.forContainerNo(payload.containerNo()); JsonNode rawOrders = envelope.payload().path("orders"); for (int index = 0; index < payload.orders().size(); index++) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java new file mode 100644 index 000000000..26b248db4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.shipping.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_container_shipping_gate") +public class ContainerShippingGate { + @TableId(type = IdType.INPUT) + private String containerBookingId; + private String customerId; + private String oceanBookingId; + private String containerNo; + private String status; + private String standardNode; + private Integer nodeSequence; + private LocalDateTime observedAt; + private LocalDateTime actualAt; + private String locationCode; + private String locationName; + private String providerCode; + private String providerEventId; + private String providerNodeCode; + private String sourceReference; + private String rawPayloadHash; + private Long aggregateVersion; + private Long handoverVersion; + private String lastEventId; + private String payloadHash; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java new file mode 100644 index 000000000..c505dca92 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.shipping.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ContainerShippingGateMapper extends BaseMapper { + @Select("SELECT * FROM ll_container_shipping_gate WHERE container_booking_id=#{containerBookingId} FOR UPDATE") + ContainerShippingGate lock(@Param("containerBookingId") String containerBookingId); + + @Insert("INSERT IGNORE INTO ll_container_shipping_gate(container_booking_id,customer_id,ocean_booking_id," + + "container_no,status,node_sequence,aggregate_version,handover_version,created_at,updated_at) VALUES(" + + "#{containerBookingId},#{customerId},#{oceanBookingId},#{containerNo},'AWAITING_PICKUP_READY',0," + + "#{aggregateVersion},#{handoverVersion},NOW(3),NOW(3))") + int initialize(@Param("containerBookingId") String containerBookingId, @Param("customerId") String customerId, + @Param("oceanBookingId") String oceanBookingId, @Param("containerNo") String containerNo, + @Param("aggregateVersion") long aggregateVersion, @Param("handoverVersion") long handoverVersion); + + @Select("SELECT CASE WHEN COUNT(DISTINCT o.source_container_booking_id)=1 " + + "AND MIN(CASE WHEN g.status='RECEIVING_AVAILABLE' THEN 1 ELSE 0 END)=1 THEN 1 ELSE 0 END " + + "FROM ll_order o LEFT JOIN ll_container_shipping_gate g " + + "ON g.container_booking_id=o.source_container_booking_id AND g.customer_id=o.customer_id " + + "WHERE o.customer_id=#{customerId} AND o.container_no=#{containerNo}") + int receivingAvailable(@Param("customerId") String customerId, @Param("containerNo") String containerNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java new file mode 100644 index 000000000..938bc4404 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java @@ -0,0 +1,123 @@ +package com.baiyue.lastleg.shipping.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.FirstLegContainerShippingNodeRecordedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.lastleg.entity.LastlegEventInbox; +import com.baiyue.lastleg.mapper.LastlegEventInboxMapper; +import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +@Service +public class ContainerShippingNodeConsumer { + public static final int PICKUP_READY_SEQUENCE = 80; + @Resource private LastlegEventInboxMapper inboxes; + @Resource private ContainerShippingGateMapper gates; + @Resource private ObjectMapper objectMapper; + + @Transactional(isolation = Isolation.READ_COMMITTED) + public int consume(EventEnvelope envelope) { + validate(envelope); + FirstLegContainerShippingNodeRecordedV1 payload = payload(envelope); + validateEnvelopePayload(envelope, payload); + LastlegEventInbox existing = inboxes.selectById(envelope.eventId()); + if (existing != null) { + if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { + throw new IllegalArgumentException("船节点事件摘要冲突"); + } + return 1; + } + LastlegEventInbox inbox = inbox(envelope); + try { inboxes.insert(inbox); } + catch (DuplicateKeyException raced) { + LastlegEventInbox concurrent = inboxes.selectById(envelope.eventId()); + if (concurrent == null || !concurrent.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { + throw new IllegalArgumentException("船节点事件摘要冲突", raced); + } + return 1; + } + + gates.initialize(payload.containerBookingId(), payload.customerId(), payload.oceanBookingId(), + payload.containerNo(), 0, 1); + ContainerShippingGate gate = gates.lock(payload.containerBookingId()); + if (gate == null || !payload.customerId().equals(gate.getCustomerId()) + || !payload.containerNo().equals(gate.getContainerNo())) { + throw new IllegalArgumentException("船节点柜号快照与装柜交接不一致"); + } + apply(gate, envelope, payload); + gates.updateById(gate); + inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxes.updateById(inbox); + return 1; + } + + private void apply(ContainerShippingGate gate, EventEnvelope envelope, + FirstLegContainerShippingNodeRecordedV1 payload) { + long currentVersion = gate.getAggregateVersion() == null ? 0 : gate.getAggregateVersion(); + int currentSequence = gate.getNodeSequence() == null ? 0 : gate.getNodeSequence(); + LocalDateTime actualAt = LocalDateTime.ofInstant(payload.actualAt(), ZoneOffset.UTC); + boolean newerVersion = envelope.aggregateVersion() >= currentVersion; + boolean newerNode = payload.nodeSequence() > currentSequence; + boolean laterSameNode = payload.nodeSequence() == currentSequence + && (gate.getActualAt() == null || actualAt.isAfter(gate.getActualAt())); + boolean accepted = newerVersion && (newerNode || laterSameNode); + if (accepted) { + gate.setOceanBookingId(payload.oceanBookingId()); + gate.setStandardNode(payload.standardNode()); gate.setNodeSequence(payload.nodeSequence()); + gate.setObservedAt(LocalDateTime.ofInstant(payload.observedAt(), ZoneOffset.UTC)); gate.setActualAt(actualAt); + gate.setLocationCode(payload.locationCode()); gate.setLocationName(payload.locationName()); + gate.setProviderCode(payload.providerCode()); gate.setProviderEventId(payload.providerEventId()); + gate.setProviderNodeCode(payload.providerNodeCode()); gate.setSourceReference(payload.sourceReference()); + gate.setRawPayloadHash(payload.rawPayloadHash()); gate.setLastEventId(envelope.eventId()); + gate.setPayloadHash(envelope.payloadHash()); + } + if (accepted && payload.nodeSequence() >= PICKUP_READY_SEQUENCE) { + gate.setStatus("RECEIVING_AVAILABLE"); + } + gate.setAggregateVersion(Math.max(currentVersion, envelope.aggregateVersion())); + gate.setHandoverVersion(Math.max(gate.getHandoverVersion() == null ? 0 : gate.getHandoverVersion(), + envelope.handoverVersion())); + gate.setUpdatedAt(LocalDateTime.now()); + } + + private void validate(EventEnvelope envelope) { + envelope.requireValid(); + if (!EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(envelope.eventType()) + || envelope.eventVersion() != 1 || envelope.sourceRegion() != RegionCode.CN + || envelope.targetRegion() != RegionCode.OVERSEAS + || !"CONTAINER_BOOKING".equals(envelope.aggregateType()) + || envelope.handoverVersion() == null + || envelope.handoverVersion() != envelope.aggregateVersion() + 1) { + throw new IllegalArgumentException("船节点交接信封无效"); + } + } + + private void validateEnvelopePayload(EventEnvelope envelope, FirstLegContainerShippingNodeRecordedV1 payload) { + if (!envelope.tenantId().equals(payload.customerId()) + || !envelope.aggregateId().equals(payload.containerBookingId())) { + throw new IllegalArgumentException("船节点交接客户或聚合标识不一致"); + } + } + + private FirstLegContainerShippingNodeRecordedV1 payload(EventEnvelope envelope) { + try { return objectMapper.treeToValue(envelope.payload(), FirstLegContainerShippingNodeRecordedV1.class); } + catch (Exception exception) { throw new IllegalArgumentException("船节点交接负载无效", exception); } + } + + private LastlegEventInbox inbox(EventEnvelope envelope) { + LastlegEventInbox value = new LastlegEventInbox(); value.setEventId(envelope.eventId()); + value.setCustomerId(envelope.tenantId()); value.setEventType(envelope.eventType()); + value.setEventVersion(envelope.eventVersion()); value.setPayloadHash(envelope.payloadHash()); + value.setPayloadJson(envelope.payload().toString()); value.setStatus("RECEIVED"); + value.setReceivedAt(LocalDateTime.now()); return value; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java index 7f336fd7f..fe586c4da 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java @@ -289,8 +289,7 @@ public class UsTruckOrderService { pallet.setUpdatedAt(now); pallets.updateById(pallet); } - int allocated = (int) rows.stream().filter(row -> "ALLOCATED".equals(row.getStatus())).count() - + selected.size(); + int allocated = (int) rows.stream().filter(row -> "ALLOCATED".equals(row.getStatus())).count(); int accepted = value(order.getReceivedPalletCount()); if (accepted < 1 || allocated > accepted) throw conflict("Allocated pallet count exceeds accepted pallets"); String from = order.getStatus(); @@ -330,6 +329,69 @@ public class UsTruckOrderService { return command(customerId, orderNo, operatorId, "COMPLETE", IN_TRANSIT, COMPLETED, request); } + private UsTruckOrderResponses.Detail command(String customerId, String orderNo, String operatorId, + String operationType, String expectedStatus, String nextStatus, + UsTruckOrderRequests.Command request) { + UsTruckOrder order = lock(customerId, orderNo); + UsTruckOrderResponses.Detail replay = replayOperation(order, operationType, + request.idempotencyKey(), request); + if (replay != null) return replay; + requireVersion(order, request.version()); + if (!expectedStatus.equals(order.getStatus())) { + throw conflict("Truck order must be " + expectedStatus + " before " + operationType); + } + if (ALLOCATED.equals(expectedStatus) + && value(order.getAllocatedPalletCount()) != value(order.getReceivedPalletCount())) { + throw conflict("All accepted pallets must be allocated before dispatch preparation"); + } + return transition(order, operatorId, operationType, request.idempotencyKey(), request, + expectedStatus, nextStatus, request.remark()); + } + + private UsTruckOrderResponses.Detail transition(UsTruckOrder order, String operatorId, + String operationType, String idempotencyKey, Object request, + String from, String to, String remark) { + LocalDateTime now = LocalDateTime.now(); + order.setStatus(to); + touch(order); + orders.updateById(order); + recordOperation(order, operationType, idempotencyKey, request, operatorId, now); + history(order, operationType, from, to, operatorId, idempotencyKey, remark, now); + return detail(order.getCustomerId(), order.getOrderNo()); + } + + private UsTruckOrderResponses.Detail replayOperation(UsTruckOrder order, String operationType, + String idempotencyKey, Object request) { + String key = idempotencyKey.trim(); + UsTruckOrderOperation existing = operations.findKey(order.getCustomerId(), order.getId(), key); + if (existing == null) return null; + if (!operationType.equals(existing.getOperationType()) || !hash(request).equals(existing.getRequestHash())) { + throw conflict("Idempotency key was reused with a different truck operation payload"); + } + return detail(order.getCustomerId(), order.getOrderNo()); + } + + private void recordOperation(UsTruckOrder order, String operationType, String idempotencyKey, + Object request, String operatorId, LocalDateTime now) { + UsTruckOrderOperation operation = new UsTruckOrderOperation(); + operation.setId(IdWorker.getId()); + operation.setOrderId(order.getId()); + operation.setCustomerId(order.getCustomerId()); + operation.setOperationType(operationType); + operation.setIdempotencyKey(idempotencyKey.trim()); + operation.setRequestHash(hash(request)); + operation.setResultVersion(order.getVersion()); + operation.setCreatedBy(operatorId); + operation.setCreatedAt(now); + operations.insert(operation); + } + + private void requireVersion(UsTruckOrder order, Long requestedVersion) { + if (!requestedVersion.equals(value(order.getVersion()))) { + throw conflict("Order was changed; refresh and retry"); + } + } + private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = orders.lockNo(customerId, orderNo); if (o == null) throw notFound(); return o; } private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); } private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List hs) { @@ -338,15 +400,23 @@ public class UsTruckOrderService { .toList(); return new UsTruckOrderResponses.Detail(summary(o), o.getOriginAddress(), o.getDestinationAddress(), o.getRemark(), o.getCreatedBy(), palletDetails, - hs.stream().map(h -> new UsTruckOrderResponses.History(h.getFromStatus(), h.getToStatus(), - h.getOperatorId(), h.getRemark(), h.getCreatedAt())).toList()); + hs.stream().map(h -> new UsTruckOrderResponses.History(h.getOperationType(), + h.getFromStatus(), h.getToStatus(), h.getOperatorId(), h.getIdempotencyKey(), + h.getRemark(), h.getCreatedAt())).toList()); } private UsTruckOrderResponses.Pallet pallet(UsTruckOrderPallet p) { return new UsTruckOrderResponses.Pallet(p.getId(), p.getPalletNo(), value(p.getSequenceNo()), p.getSource(), p.getStatus(), p.getWarehousePalletId(), p.getPackageCount(), p.getReceivedAt()); } - private UsTruckOrderResponses.Summary summary(UsTruckOrder o) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getTransportType(), o.getOrigin(), o.getDestination(), o.getContainerType(), value(o.getPalletCount()), value(o.getPackageCount()), value(o.getReceivedPalletCount()), value(o.getAllocatedPalletCount()), value(o.getShippedPalletCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getContactName(), o.getContactInfo(), o.getRequestedDate(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt()); } - private void history(UsTruckOrder o, String from, String to, String operator, String remark, LocalDateTime at) { UsTruckOrderStatusHistory h = new UsTruckOrderStatusHistory(); h.setId(IdWorker.getId()); h.setOrderId(o.getId()); h.setCustomerId(o.getCustomerId()); h.setFromStatus(from); h.setToStatus(to); h.setOperatorId(operator); h.setRemark(trim(remark)); h.setCreatedAt(at); histories.insert(h); } + private UsTruckOrderResponses.Summary summary(UsTruckOrder o) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getTransportType(), o.getOrigin(), o.getDestination(), o.getContainerType(), value(o.getPalletCount()), value(o.getPackageCount()), value(o.getReceivedPalletCount()), value(o.getAllocatedPalletCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getContactName(), o.getContactInfo(), o.getRequestedDate(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt()); } + private void history(UsTruckOrder o, String operationType, String from, String to, String operator, + String idempotencyKey, String remark, LocalDateTime at) { + UsTruckOrderStatusHistory h = new UsTruckOrderStatusHistory(); + h.setId(IdWorker.getId()); h.setOrderId(o.getId()); h.setCustomerId(o.getCustomerId()); + h.setOperationType(operationType); h.setFromStatus(from); h.setToStatus(to); + h.setOperatorId(operator); h.setIdempotencyKey(trim(idempotencyKey)); h.setRemark(trim(remark)); + h.setCreatedAt(at); histories.insert(h); + } private void createPlannedPallets(UsTruckOrder order) { int existing = pallets.listOrder(order.getCustomerId(), order.getId()).size(); LocalDateTime now = LocalDateTime.now(); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 26b5d70ba..201ed9d60 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -18,6 +18,23 @@ CREATE TABLE IF NOT EXISTS ll_loading_event_inbox ( UNIQUE KEY uk_ll_loading_inbox_tenant_key (customer_id,event_type,event_id) ) ENGINE=InnoDB COMMENT='头程装柜完成事件接收幂等箱'; +CREATE TABLE IF NOT EXISTS ll_container_shipping_gate ( + container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户标识', + ocean_booking_id VARCHAR(128) NOT NULL COMMENT '来源海运订舱ID', container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号', + status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE', + standard_node VARCHAR(64) NULL COMMENT '最新有效标准船节点', node_sequence INT NOT NULL DEFAULT 0 COMMENT '最新有效节点单调序号', + observed_at DATETIME(3) NULL COMMENT '供应商节点观测时间', actual_at DATETIME(3) NULL COMMENT '节点实际发生时间', + location_code VARCHAR(128) NULL COMMENT '节点地点编码', location_name VARCHAR(255) NULL COMMENT '节点地点名称', + provider_code VARCHAR(64) NULL COMMENT '船节点供应商编码', provider_event_id VARCHAR(128) NULL COMMENT '供应商事件ID', + provider_node_code VARCHAR(128) NULL COMMENT '供应商原始节点编码', source_reference VARCHAR(255) NULL COMMENT '供应商来源引用', + raw_payload_hash VARCHAR(64) NULL COMMENT '供应商原始负载SHA-256', aggregate_version BIGINT NOT NULL DEFAULT 0 COMMENT '来源查询聚合版本', + handover_version BIGINT NOT NULL DEFAULT 1 COMMENT '跨区交接版本', last_event_id VARCHAR(128) NULL COMMENT '最近采纳的跨区事件ID', + payload_hash VARCHAR(71) 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 '更新时间', + PRIMARY KEY(container_booking_id), KEY idx_ll_shipping_gate_candidate(customer_id,container_no,status), + KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence) +) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照'; + CREATE TABLE IF NOT EXISTS ll_order ( id BIGINT NOT NULL COMMENT '雪花 ID', order_no VARCHAR(32) NOT NULL COMMENT '尾程单号', @@ -715,6 +732,18 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_operation ( UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at) ) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录'; +CREATE TABLE IF NOT EXISTS ll_pda_operation_history ( + id BIGINT NOT NULL COMMENT 'PDA作业历史ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + customer_id VARCHAR(128) NULL COMMENT '作业涉及的客户标识', operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', + operation_type VARCHAR(64) NOT NULL COMMENT '作业类型', business_key VARCHAR(255) NOT NULL COMMENT '作业业务键', + status VARCHAR(32) NOT NULL COMMENT '作业结果状态', idempotency_key VARCHAR(255) NULL COMMENT '写操作幂等键', + request_hash VARCHAR(128) NULL COMMENT '写请求内容哈希', summary VARCHAR(1000) NULL COMMENT '作业结果摘要', + occurred_at DATETIME(3) NOT NULL COMMENT '作业发生时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_history_idempotency(operator_key,idempotency_key), + KEY idx_ll_pda_history_operator(overseas_warehouse_id,operator_key,occurred_at), + KEY idx_ll_pda_history_business(overseas_warehouse_id,operation_type,business_key) +) ENGINE=InnoDB COMMENT='海外PDA真实作业历史'; + CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', @@ -1253,6 +1282,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_application ( status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注', + review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键', review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希', reviewed_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 '更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_cargo_discard_no(customer_id,discard_no), @@ -1303,6 +1333,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注', + review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键', review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希', reviewed_at DATETIME(3) NULL COMMENT '审核时间', version BIGINT NOT NULL DEFAULT 0 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 '更新时间', @@ -1316,7 +1347,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history ( id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、REVIEW或UPDATE', from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', - change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '备注', + idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_overseas_procurement_history(customer_id,order_id,created_at) ) ENGINE=InnoDB COMMENT='海外采购操作流水'; @@ -1335,7 +1366,6 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order ( package_count INT NULL COMMENT '可选箱数,零或空值均有效', received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数', allocated_pallet_count INT NOT NULL DEFAULT 0 COMMENT '已分配到合车行程的托盘数', - shipped_pallet_count INT NOT NULL DEFAULT 0 COMMENT '已出库托盘数', total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', budget_amount DECIMAL(18,2) NULL COMMENT '预算金额', @@ -1345,8 +1375,6 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order ( requested_date DATE NOT NULL COMMENT '要求服务日期', status VARCHAR(32) NOT NULL COMMENT '美国卡车订单状态', remark VARCHAR(1000) NULL COMMENT '备注', - receipt_idempotency_key VARCHAR(255) NULL COMMENT '实物收货幂等键', - receipt_request_hash CHAR(64) NULL COMMENT '实物收货请求哈希', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID', @@ -1356,7 +1384,6 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order ( PRIMARY KEY(id), UNIQUE KEY uk_ll_us_truck_order_no(customer_id,order_no), UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key), - UNIQUE KEY uk_ll_us_truck_receipt_key(customer_id,receipt_idempotency_key), KEY idx_ll_us_truck_order_list(customer_id,status,created_at), KEY idx_ll_us_truck_order_date(customer_id,requested_date,created_at) ) ENGINE=InnoDB COMMENT='美国卡车运输订单'; @@ -1380,13 +1407,25 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet ( KEY idx_ll_us_truck_order_pallet_list(customer_id,order_id,status) ) ENGINE=InnoDB COMMENT='美国卡车订单托盘实收记录'; +CREATE TABLE IF NOT EXISTS ll_us_truck_order_operation ( + id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '美国卡车订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(64) NOT NULL COMMENT '具名操作类型', + idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作请求内容哈希', + result_version BIGINT NOT NULL COMMENT '操作完成后的订单版本', created_by VARCHAR(128) NULL COMMENT '操作人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_us_truck_operation_key(customer_id,order_id,idempotency_key), + KEY idx_ll_us_truck_operation_order(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='美国卡车订单幂等操作账'; + CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history ( id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + operation_type VARCHAR(64) NOT NULL COMMENT '具名操作类型', from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), @@ -1421,7 +1460,17 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_history ( id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、UPDATE或STATUS', from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', - change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '操作备注', + idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '操作备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_local_looseboard_history(customer_id,order_id,created_at) ) ENGINE=InnoDB COMMENT='本土散板操作流水'; + +CREATE TABLE IF NOT EXISTS ll_local_looseboard_operation ( + id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型', + idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作请求内容哈希', + result_version BIGINT NOT NULL COMMENT '操作完成后的订单版本', created_by VARCHAR(128) NULL COMMENT '操作人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_local_looseboard_operation_key(customer_id,order_id,idempotency_key), + KEY idx_ll_local_looseboard_operation_order(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='本土散板订单幂等操作账'; diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index fe5c2ee78..1c5d4c33f 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -6,7 +6,7 @@ paths: /api/pda/v1/last-leg/receiving-containers/candidates: get: summary: 查询当前客户待签收柜号 - description: 权限 lastleg:pda:operate;仅 PLATFORM WAREHOUSE_PICKER 可用,操作账号从 Token 获取,客户由所选柜业务事实唯一解析,不接收 customerId。 + description: 权限 lastleg:pda:operate;仅 PLATFORM WAREHOUSE_PICKER 可用;仅返回已单调消费 PICKUP_READY(80) 或更高实际船节点、状态为 RECEIVING_AVAILABLE 的柜,不接收 customerId。 security: [{ bearerAuth: [] }] responses: { '200': { description: 柜号候选及箱数进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCandidateListApiResponse' } } } }, '403': { description: 缺少 PDA 操作权限 } } /api/pda/v1/last-leg/receiving-containers/{containerNo}: @@ -19,7 +19,7 @@ paths: /api/pda/v1/last-leg/receiving-containers/{containerNo}/prepare: post: summary: 选择当前待签收柜号 - description: 权限 lastleg:pda:operate;建立 WAIT_SIGN 会话并以平台操作账号、柜号全局双唯一键防止切柜或多人同时处理。 + description: 权限 lastleg:pda:operate;仅 RECEIVING_AVAILABLE 柜可建立 WAIT_SIGN 会话,并以平台操作账号、柜号全局双唯一键防止切柜或多人同时处理。 security: [{ bearerAuth: [] }] parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } } @@ -86,6 +86,37 @@ paths: security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } } responses: { '200': { description: COMPLETED 柜号会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 数量未核平或状态冲突 } } + /api/pda/v1/last-leg/inventory/resolve: + post: + summary: PDA按SKU、箱唛或入仓码扫码查询库存 + description: 仅 WAREHOUSE_PICKER + lastleg:pda:operate;海外实际仓只取Token overseasWarehouseId,不接收客户或仓库参数。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PdaWarehouseScan' } } } } + responses: { '200': { description: 同仓跨客户库存与物理仓位事实, content: { application/json: { schema: { $ref: '#/components/schemas/PdaInventoryScanApiResponse' } } } }, '404': { description: A0404:当前绑定仓无匹配库存 } } + /api/pda/v1/last-leg/locations/resolve: + post: + summary: PDA查询库位容量、状态与库存占用 + description: 库位从Warehouse Overseas公共资源解析,并强制匹配Token绑定仓。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PdaWarehouseScan' } } } } + responses: { '200': { description: 库位物理事实及Lastleg库存占用汇总, content: { application/json: { schema: { $ref: '#/components/schemas/PdaLocationApiResponse' } } } }, '404': { description: A0404:库位不存在或跨仓 } } + /api/pda/v1/last-leg/pallets/resolve: + post: + summary: PDA查询托盘、库位与绑定库存 + description: 托盘从Warehouse Overseas公共资源解析;跨Token绑定仓返回A0409。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PdaWarehouseScan' } } } } + responses: { '200': { description: 托盘物理事实及Lastleg绑定库存, content: { application/json: { schema: { $ref: '#/components/schemas/PdaPalletApiResponse' } } } }, '409': { description: A0409:托盘属于其他仓 } } + /api/pda/v1/last-leg/operations: + get: + summary: 查询当前PDA账号真实作业历史 + description: 返回Token绑定仓内当前操作账号的柜号作业及盘点、移库、托盘、换标历史;不含出库或派送。 + security: [{ bearerAuth: [] }] + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } + - { name: operationType, in: query, schema: { type: string } } + responses: { '200': { description: 真实作业历史分页, content: { application/json: { schema: { $ref: '#/components/schemas/PdaOperationPageApiResponse' } } } } } /api/platform/v1/last-leg/orders: get: summary: 分页查询尾程订单 @@ -269,14 +300,14 @@ paths: /api/platform/v1/last-leg/overseas-procurements/{orderNo}/review: post: summary: 审核海外采购订单 - description: 权限 platform:overseas-business:purchase:review。 + description: 权限 platform:overseas-business:purchase:review;同幂等键同请求重放成功,异请求、旧 version 或终态反向审核返回 A0409。 security: [{ bearerAuth: [] }] parameters: - { name: orderNo, in: path, required: true, schema: { type: string } } - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } requestBody: required: true - content: { application/json: { schema: { type: object, required: [decision], properties: { decision: { type: string, enum: [APPROVE, REJECT] }, remark: { type: string, nullable: true, maxLength: 1000 } } } } } + content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementReview' } } } responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } } /api/merchant/v1/last-leg/overseas-procurements: post: @@ -325,14 +356,24 @@ paths: responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } } /api/platform/v1/last-leg/us-truck-orders/{orderNo}/status: patch: - summary: 推进本土卡车订单状态 - description: 权限 platform:overseas-business:truck:status;普通编辑接口不能修改状态。 + summary: 取消未发车的本土卡车订单 + description: 权限 platform:overseas-business:truck:status;该通用入口只接受 CANCELLED,正向状态必须使用具名命令,不能绕过实收、超托与分配事实。 security: [{ bearerAuth: [] }] parameters: - { name: orderNo, in: path, required: true, schema: { type: string } } - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } - requestBody: { required: true, content: { application/json: { schema: { type: object, required: [version, status], properties: { version: { type: integer, format: int64, minimum: 0 }, status: { $ref: '#/components/schemas/TruckStatus' }, remark: { type: string, nullable: true, maxLength: 1000 } } } } } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCancel' } } } } responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 }, '409': { description: 非法状态迁移 } } + /api/platform/v1/last-leg/us-truck-orders/{orderNo}/review: + parameters: &truckOrderTarget + - { name: orderNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + post: + summary: 审核本土卡车订单 + description: 权限 platform:overseas-business:truck:status;PENDING_REVIEW 审核通过进入 WAITING_INBOUND 并生成预期托盘;拒绝进入 REJECTED。幂等键异请求、旧 version 或非法跳转返回 A0409。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckReview' } } } } + responses: { '200': { description: 卡车订单详情 }, '409': { description: 版本、状态或幂等冲突 } } /api/platform/v1/last-leg/us-truck-orders/{orderNo}/receive-pallets: post: summary: 记录本土卡车订单实收托盘 @@ -341,7 +382,56 @@ paths: parameters: - { name: orderNo, in: path, required: true, schema: { type: string } } - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckReceivePallets' } } } } responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } } + /api/platform/v1/last-leg/us-truck-orders/{orderNo}/review-pallet-overage: + parameters: *truckOrderTarget + post: + summary: 审核实收超出申报数的托盘 + description: 权限 platform:overseas-business:truck:status;APPROVE 将超收托盘纳入本单并进入 WAITING_CONSOLIDATION;REJECT 标记超收托盘拒收并按原申报数继续。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckReviewPalletOverage' } } } } + responses: { '200': { description: 卡车订单详情 }, '409': { description: 版本、状态或幂等冲突 } } + /api/platform/v1/last-leg/us-truck-orders/{orderNo}/allocate-pallets: + parameters: *truckOrderTarget + post: + summary: 分配已实收托盘到本土卡车行程 + description: 权限 platform:overseas-business:truck:status;仅接收本单 RECEIVED 托盘 ID;部分分配进入 PARTIALLY_ALLOCATED,全部分配进入 ALLOCATED。不创建海外仓出库单或扣减库存。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckAllocatePallets' } } } } + responses: { '200': { description: 卡车订单详情 }, '409': { description: 托盘归属、状态、版本或幂等冲突 } } + /api/platform/v1/last-leg/us-truck-orders/{orderNo}/prepare-dispatch: + parameters: *truckOrderTarget + post: + summary: 全部托盘分配后准备派车 + description: 权限 platform:overseas-business:truck:status;旧 version、幂等键异请求或非 ALLOCATED 状态返回 A0409。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCommand' } } } } + responses: { '200': { description: 状态 PENDING_DISPATCH } } + /api/platform/v1/last-leg/us-truck-orders/{orderNo}/dispatch: + parameters: *truckOrderTarget + post: + summary: 确认本土卡车已派车 + description: 权限 platform:overseas-business:truck:status;本土卡车单自身履约,不创建海外仓排车单、出库单或派送单。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCommand' } } } } + responses: { '200': { description: 状态 DISPATCHED } } + /api/platform/v1/last-leg/us-truck-orders/{orderNo}/start-transit: + parameters: *truckOrderTarget + post: + summary: 确认本土卡车开始运输 + description: 权限 platform:overseas-business:truck:status;仅 DISPATCHED 可进入 IN_TRANSIT。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCommand' } } } } + responses: { '200': { description: 状态 IN_TRANSIT } } + /api/platform/v1/last-leg/us-truck-orders/{orderNo}/complete: + parameters: *truckOrderTarget + post: + summary: 完成本土卡车订单 + description: 权限 platform:overseas-business:truck:status;仅 IN_TRANSIT 可进入不可逆 COMPLETED。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCommand' } } } } + responses: { '200': { description: 状态 COMPLETED } } /api/platform/v1/last-leg/local-looseboard-orders: get: summary: 分页查询本土散板订单 @@ -373,12 +463,12 @@ paths: /api/platform/v1/last-leg/local-looseboard-orders/{orderNo}/status: patch: summary: 推进或取消本土散板订单 - description: 权限 platform:overseas-business:looseboard:status;只允许 PENDING_ACCEPTANCE→DISPATCHED→IN_TRANSIT→COMPLETED,未完成节点按规则取消。 + description: 权限 platform:overseas-business:looseboard:status;只允许 PENDING_ACCEPTANCE→DISPATCHED→IN_TRANSIT→COMPLETED,另建订单可进入 CANCELLED;幂等键异请求、旧 version、非法跳转或终态反向返回 A0409。 security: [{ bearerAuth: [] }] parameters: - { name: orderNo, in: path, required: true, schema: { type: string } } - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } - requestBody: { required: true, content: { application/json: { schema: { type: object, required: [version, status], properties: { version: { type: integer, format: int64, minimum: 0 }, status: { $ref: '#/components/schemas/LooseboardStatus' }, remark: { type: string, nullable: true, maxLength: 1000 } } } } } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardStatusChange' } } } } responses: { '200': { description: 统一 ApiResponse 包装的散板订单详情 }, '409': { description: 非法状态迁移 } } /api/platform/v1/last-leg/relabel-applications: get: @@ -423,6 +513,101 @@ paths: responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } } components: schemas: + PdaWarehouseScan: + type: object + required: [code] + properties: { code: { type: string, minLength: 1, maxLength: 128 } } + PdaInventoryItem: + type: object + required: [customerId, balanceId, positionId, inboundCode, palletId, palletCode, availablePackages, holdPackages, receivingPackages, status, version] + properties: + customerId: { type: string } + customerName: { type: string, nullable: true } + balanceId: { type: integer, format: int64 } + positionId: { type: integer, format: int64 } + skuCode: { type: string, nullable: true } + productName: { type: string, nullable: true } + inboundCode: { type: string } + cartonMark: { type: string, nullable: true } + palletId: { type: integer, format: int64 } + palletCode: { type: string } + locationId: { type: integer, format: int64, nullable: true } + locationCode: { type: string, nullable: true } + availablePackages: { type: integer, format: int64, minimum: 0 } + holdPackages: { type: integer, format: int64, minimum: 0 } + receivingPackages: { type: integer, format: int64, minimum: 0 } + status: { type: string } + version: { type: integer, format: int64, minimum: 0 } + PdaInventoryScan: + type: object + required: [queryCode, items] + properties: + queryCode: { type: string } + items: { type: array, items: { $ref: '#/components/schemas/PdaInventoryItem' } } + PdaLocation: + type: object + required: [locationId, locationCode, locationType, status, occupiedPackages, inventoryPositionCount] + properties: + locationId: { type: integer, format: int64 } + locationCode: { type: string } + locationType: { type: string } + maxLoadKg: { type: number, nullable: true } + status: { type: string } + occupiedPackages: { type: integer, format: int64, minimum: 0 } + inventoryPositionCount: { type: integer, minimum: 0 } + PdaPallet: + type: object + required: [palletId, palletCode, status, version, locationId, locationCode, occupiedPackages, inventoryPositionCount, inventory] + properties: + palletId: { type: integer, format: int64 } + palletCode: { type: string } + status: { type: string } + version: { type: integer, minimum: 0 } + locationId: { type: integer, format: int64 } + locationCode: { type: string } + occupiedPackages: { type: integer, format: int64, minimum: 0 } + inventoryPositionCount: { type: integer, minimum: 0 } + inventory: { type: array, items: { $ref: '#/components/schemas/PdaInventoryItem' } } + PdaOperation: + type: object + required: [operationId, operationType, businessKey, status, occurredAt] + properties: + operationId: { type: integer, format: int64 } + operationType: { type: string } + businessKey: { type: string } + status: { type: string } + summary: { type: string, nullable: true } + customerId: { type: string, nullable: true } + occurredAt: { type: string, format: date-time } + PdaInventoryScanApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + properties: { data: { $ref: '#/components/schemas/PdaInventoryScan' } } + PdaLocationApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + properties: { data: { $ref: '#/components/schemas/PdaLocation' } } + PdaPalletApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + properties: { data: { $ref: '#/components/schemas/PdaPallet' } } + PdaOperationPageApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + properties: + data: + type: object + required: [records, page, size, total, pages] + properties: + records: { type: array, items: { $ref: '#/components/schemas/PdaOperation' } } + page: { type: integer } + size: { type: integer } + total: { type: integer, format: int64 } + pages: { type: integer, format: int64 } ReceivingAction: type: object required: [idempotencyKey] @@ -765,6 +950,14 @@ components: status: { $ref: '#/components/schemas/RelabelStatus' } remark: { type: string, nullable: true, maxLength: 1000 } ProcurementStatus: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED] } + ProcurementReview: + type: object + required: [version, decision, idempotencyKey] + properties: + version: { type: integer, format: int64, minimum: 0 } + decision: { type: string, enum: [APPROVE, REJECT] } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, nullable: true, maxLength: 1000 } ProcurementCreate: type: object required: [purchaseCategory, productName, purchaseQuantity, contactName, contactPhone, idempotencyKey] @@ -812,6 +1005,49 @@ components: code: { type: string, enum: ['200'] } data: { type: array, items: { type: object, properties: { warehouseId: { type: string }, warehouseCode: { type: string }, warehouseName: { type: string }, countryCode: { type: string } } } } TruckStatus: { type: string, enum: [PENDING_REVIEW, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED] } + TruckReview: + type: object + required: [version, decision, idempotencyKey] + properties: + version: { type: integer, format: int64, minimum: 0 } + decision: { type: string, enum: [APPROVE, REJECT] } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, nullable: true, maxLength: 1000 } + TruckCancel: + type: object + required: [version, status, idempotencyKey] + properties: + version: { type: integer, format: int64, minimum: 0 } + status: { type: string, enum: [CANCELLED] } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, nullable: true, maxLength: 1000 } + TruckReceivePallets: + type: object + required: [version, actualPalletCount, idempotencyKey] + properties: + version: { type: integer, format: int64, minimum: 0 } + actualPalletCount: { type: integer, minimum: 1 } + packageCount: { type: integer, nullable: true, minimum: 0 } + palletPackageCounts: { type: array, maxItems: 500, items: { type: integer, minimum: 0 } } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, nullable: true, maxLength: 1000 } + TruckReviewPalletOverage: + allOf: [{ $ref: '#/components/schemas/TruckReview' }] + TruckAllocatePallets: + type: object + required: [version, palletIds, idempotencyKey] + properties: + version: { type: integer, format: int64, minimum: 0 } + palletIds: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: integer, format: int64, minimum: 1 } } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, nullable: true, maxLength: 1000 } + TruckCommand: + type: object + required: [version, idempotencyKey] + properties: + version: { type: integer, format: int64, minimum: 0 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, nullable: true, maxLength: 1000 } TruckCreate: type: object required: [origin, destination, palletCount, totalWeightKg, totalVolumeCbm, contactName, contactInfo, requestedDate, idempotencyKey] @@ -855,6 +1091,14 @@ components: requestedDate: { type: string, format: date } remark: { type: string, nullable: true, maxLength: 1000 } LooseboardStatus: { type: string, enum: [PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] } + LooseboardStatusChange: + type: object + required: [version, status, idempotencyKey] + properties: + version: { type: integer, format: int64, minimum: 0 } + status: { $ref: '#/components/schemas/LooseboardStatus' } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, nullable: true, maxLength: 1000 } LooseboardCreate: type: object required: [transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java index e2975697a..aeee3ebe5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java @@ -8,6 +8,7 @@ import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.security.InternalServiceTokenGuard; import com.baiyue.lastleg.service.LastlegOrderService; import com.baiyue.lastleg.service.LoadingCompletedConsumer; +import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.server.ResponseStatusException; @@ -52,6 +53,7 @@ class LastlegOrderControllerTest { private LastlegOrderController controller() { LastlegOrderController controller = new LastlegOrderController(); ReflectionTestUtils.setField(controller, "consumer", mock(LoadingCompletedConsumer.class)); + ReflectionTestUtils.setField(controller, "shippingNodeConsumer", mock(ContainerShippingNodeConsumer.class)); ReflectionTestUtils.setField(controller, "orderService", service); ReflectionTestUtils.setField(controller, "tokenGuard", mock(InternalServiceTokenGuard.class)); return controller; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java index d66ef69ae..d3d4447dc 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -51,7 +51,8 @@ class ReceivingContainerContractTest { void everyBrowserReachableReceivingMethodUsesOnlyPdaOperatePermission() { PreAuthorize permission = ReceivingContainerController.class.getAnnotation(PreAuthorize.class); assertTrue(permission != null); - assertTrue("hasAuthority('lastleg:pda:operate')".equals(permission.value())); + assertTrue("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')" + .equals(permission.value())); } @Test @@ -131,9 +132,9 @@ class ReceivingContainerContractTest { String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:")); List successResponses = receivingPaths.lines().map(String::trim) .filter(line -> line.startsWith("responses: { '200':")).toList(); - assertEquals(12, successResponses.size()); - assertTrue(successResponses.stream().allMatch(line -> - line.contains("content: { application/json: { schema: { $ref: '#/components/schemas/Receiving"))); + assertEquals(16, successResponses.size()); + assertEquals(12, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count()); + assertEquals(4, successResponses.stream().filter(line -> line.contains("/schemas/Pda")).count()); assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜")); assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用")); assertFalse(receivingPaths.contains("客户与操作账号均从 PDA Token 获取")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java index a35d8ca26..3ff993075 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.pda.controller; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses; import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -57,13 +58,14 @@ class ReceivingContainerControllerTest { @Test void pickerAuthorityWithoutWarehouseBindingIsForbidden() { AuthenticatedUser unbound = new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA", - Set.of(ReceivingContainerController.OPERATE_PERMISSION)); + Set.of(PdaWarehouseIdentity.PICKER_ROLE, ReceivingContainerController.OPERATE_PERMISSION)); assertThatThrownBy(() -> controller.candidates(unbound)).isInstanceOf(ResponseStatusException.class) .hasMessageContaining("403"); } private static AuthenticatedUser picker() { return new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA", - Set.of(ReceivingContainerController.OPERATE_PERMISSION), false, null, null, false, 900L); + Set.of(PdaWarehouseIdentity.PICKER_ROLE, ReceivingContainerController.OPERATE_PERMISSION), + false, null, null, false, 900L); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 61b54e06d..fcd4ef4a4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -23,6 +23,7 @@ import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -57,6 +58,7 @@ class ReceivingContainerServiceBehaviorTest { @Mock ReceivingContainerSessionMapper sessions; @Mock ReceivingContainerOperationMapper operations; @Mock ReceivingSkuReceiptMapper skuReceipts; + @Mock ContainerShippingGateMapper shippingGates; @Mock WarehouseResourceGateway warehouse; @Mock UnloadPhotoFileGateway unloadPhotos; @Mock InboundPdaWorkflowService workflow; @@ -75,11 +77,25 @@ class ReceivingContainerServiceBehaviorTest { ReflectionTestUtils.setField(service, "sessions", sessions); ReflectionTestUtils.setField(service, "operations", operations); ReflectionTestUtils.setField(service, "skuReceipts", skuReceipts); + ReflectionTestUtils.setField(service, "shippingGates", shippingGates); ReflectionTestUtils.setField(service, "warehouse", warehouse); ReflectionTestUtils.setField(service, "unloadPhotos", unloadPhotos); ReflectionTestUtils.setField(service, "workflow", workflow); ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules()); service.initializeObjectMapper(); + org.mockito.Mockito.lenient().when(shippingGates.receivingAvailable(any(), any())).thenReturn(1); + } + + @Test + void containerIsHiddenAndPrepareRejectedBeforePickupReady() { + when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A"))); + when(shippingGates.receivingAvailable("100", "CONT-A")).thenReturn(0); + + assertThat(service.candidates("7", 100L)).isEmpty(); + assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", + new ReceivingContainerRequests.Action("not-ready"))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + verifyNoInteractions(warehouse); } @Test @@ -315,6 +331,16 @@ class ReceivingContainerServiceBehaviorTest { verifyNoInteractions(unloadPhotos); } + @Test + void signRejectsMissingPhotosBeforeFileValidation() { + when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); + ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(), "missing-photos"); + + assertThatThrownBy(() -> service.sign("7", "receiver", request)) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + verifyNoInteractions(unloadPhotos); + } + @Test void signRejectsDuplicateFileIdsBeforeFileValidation() { when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java index d0b305fce..b67cd7f22 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java @@ -15,6 +15,8 @@ class LocalLooseboardSchemaContractTest { } assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_local_looseboard_order")); assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_local_looseboard_history")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_local_looseboard_operation")); + assertTrue(sql.contains("uk_ll_local_looseboard_operation_key(customer_id,order_id,idempotency_key)")); assertTrue(sql.contains("operation_type VARCHAR(32) NOT NULL")); assertTrue(sql.contains("uk_ll_local_looseboard_no(customer_id,order_no)")); assertTrue(sql.contains("lastleg_order_id BIGINT NULL")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java index c3ab0e0b5..330c6b2fd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java @@ -3,8 +3,10 @@ package com.baiyue.lastleg.looseboard.service; import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation; import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper; import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper; +import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOperationMapper; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; @@ -22,6 +24,8 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -29,27 +33,38 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class LocalLooseboardServiceTest { @Mock LocalLooseboardOrderMapper orders; @Mock LocalLooseboardHistoryMapper histories; + @Mock LocalLooseboardOperationMapper operations; @Mock LastlegOrderMapper lastlegOrders; @Mock LastlegOrderStatusHistoryMapper lastlegHistories; @Mock LastlegOrderNoGenerator lastlegOrderNoGenerator; private LocalLooseboardService service; + private final Map operationRows = new HashMap<>(); @BeforeEach void setUp() { service = new LocalLooseboardService(); ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "histories", histories); + ReflectionTestUtils.setField(service, "operations", operations); ReflectionTestUtils.setField(service, "lastlegOrders", lastlegOrders); ReflectionTestUtils.setField(service, "lastlegHistories", lastlegHistories); ReflectionTestUtils.setField(service, "lastlegOrderNoGenerator", lastlegOrderNoGenerator); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); org.mockito.Mockito.lenient().when(lastlegOrderNoGenerator.next()).thenReturn("LMO-LOOSE-1"); + org.mockito.Mockito.lenient().when(operations.findKey(anyString(), any(), anyString())) + .thenAnswer(call -> operationRows.get(call.getArgument(2))); + org.mockito.Mockito.lenient().when(operations.insert(any(LocalLooseboardOperation.class))).thenAnswer(call -> { + LocalLooseboardOperation value = call.getArgument(0); + operationRows.put(value.getIdempotencyKey(), value); + return 1; + }); } @Test @@ -103,12 +118,54 @@ class LocalLooseboardServiceTest { when(histories.listOrder("100", 1L)).thenReturn(List.of()); var detail = service.changeStatus("100", "LB1", "7", - new LocalLooseboardRequests.ChangeStatus(0L, "DISPATCHED", "accepted")); + new LocalLooseboardRequests.ChangeStatus(0L, "DISPATCHED", "status-1", "accepted")); assertEquals(LocalLooseboardService.DISPATCHED, detail.summary().status()); assertEquals(1L, detail.summary().version()); verify(histories).insert(org.mockito.ArgumentMatchers.argThat(value -> - "STATUS".equals(value.getOperationType()) && "DISPATCHED".equals(value.getToStatus()))); + "STATUS".equals(value.getOperationType()) && "DISPATCHED".equals(value.getToStatus()) + && "7".equals(value.getOperatorId()) && "status-1".equals(value.getIdempotencyKey()))); + } + + @Test + void fullStatusLifecycleIsIdempotentAndCompletedCannotReverse() { + LocalLooseboardOrder order = order(LocalLooseboardService.PENDING_ACCEPTANCE, 0L); + when(orders.lockNo("100", "LB1")).thenReturn(order); + when(histories.listOrder("100", 1L)).thenReturn(List.of()); + var dispatch = new LocalLooseboardRequests.ChangeStatus(0L, "DISPATCHED", "dispatch-1", "dispatch"); + assertEquals(LocalLooseboardService.DISPATCHED, + service.changeStatus("100", "LB1", "operator-7", dispatch).summary().status()); + assertEquals(1L, service.changeStatus("100", "LB1", "operator-7", dispatch).summary().version()); + assertThrows(ResponseStatusException.class, () -> service.changeStatus("100", "LB1", "operator-7", + new LocalLooseboardRequests.ChangeStatus(1L, "IN_TRANSIT", "dispatch-1", "different"))); + assertThrows(ResponseStatusException.class, () -> service.changeStatus("100", "LB1", "operator-7", + new LocalLooseboardRequests.ChangeStatus(0L, "IN_TRANSIT", "stale-1", "stale"))); + assertEquals(LocalLooseboardService.IN_TRANSIT, service.changeStatus("100", "LB1", "operator-8", + new LocalLooseboardRequests.ChangeStatus(1L, "IN_TRANSIT", "transit-1", "transit")) + .summary().status()); + assertEquals(LocalLooseboardService.COMPLETED, service.changeStatus("100", "LB1", "operator-9", + new LocalLooseboardRequests.ChangeStatus(2L, "COMPLETED", "complete-1", "complete")) + .summary().status()); + assertThrows(ResponseStatusException.class, () -> service.changeStatus("100", "LB1", "operator-9", + new LocalLooseboardRequests.ChangeStatus(3L, "IN_TRANSIT", "reverse-1", "reverse"))); + verify(histories, times(3)).insert(org.mockito.ArgumentMatchers.argThat(value -> + "STATUS".equals(value.getOperationType()) && value.getOperatorId() != null + && value.getIdempotencyKey() != null)); + } + + @Test + void separateDispatchedFixtureCanCancelButCannotProgressAfterCancellation() { + LocalLooseboardOrder order = order(LocalLooseboardService.PENDING_ACCEPTANCE, 0L); + when(orders.lockNo("100", "LB1")).thenReturn(order); + when(histories.listOrder("100", 1L)).thenReturn(List.of()); + assertEquals(LocalLooseboardService.DISPATCHED, service.changeStatus("100", "LB1", "operator-7", + new LocalLooseboardRequests.ChangeStatus(0L, "DISPATCHED", "cancel-fixture-dispatch", "dispatch")) + .summary().status()); + assertEquals(LocalLooseboardService.CANCELLED, service.changeStatus("100", "LB1", "operator-7", + new LocalLooseboardRequests.ChangeStatus(1L, "CANCELLED", "cancel-1", "cancel")) + .summary().status()); + assertThrows(ResponseStatusException.class, () -> service.changeStatus("100", "LB1", "operator-7", + new LocalLooseboardRequests.ChangeStatus(2L, "IN_TRANSIT", "after-cancel", "invalid"))); } private LocalLooseboardRequests.Create create(String key) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java new file mode 100644 index 000000000..dfb7bdde8 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java @@ -0,0 +1,78 @@ +package com.baiyue.lastleg.pda.warehouse; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.pda.warehouse.controller.PdaWarehouseController; +import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseRequests; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.server.ResponseStatusException; + +import java.lang.reflect.RecordComponent; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PdaWarehouseContractTest { + @Test + void requestsNeverAcceptServerOwnedIdentityOrResourceIds() { + Set forbidden = Set.of("customerId", "warehouseId", "planNo", "allocationId", "operatorId"); + for (Class type : PdaWarehouseRequests.class.getDeclaredClasses()) { + if (!type.isRecord()) continue; + Set fields = Arrays.stream(type.getRecordComponents()).map(RecordComponent::getName) + .collect(Collectors.toSet()); + assertThat(fields).doesNotContainAnyElementsOf(forbidden); + } + } + + @Test + void controllerRequiresPickerRoleAndPdaAuthority() { + PreAuthorize permission = PdaWarehouseController.class.getAnnotation(PreAuthorize.class); + assertThat(permission.value()).isEqualTo( + "hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')"); + } + + @Test + void identityRejectsUnboundOrNonPickerPlatformAccounts() { + AuthenticatedUser unbound = user(Set.of(PdaWarehouseIdentity.PICKER_ROLE, + PdaWarehouseIdentity.OPERATE_PERMISSION), null); + assertThatThrownBy(() -> PdaWarehouseIdentity.require(unbound)) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("403"); + AuthenticatedUser admin = user(Set.of(PdaWarehouseIdentity.OPERATE_PERMISSION), 900L); + assertThatThrownBy(() -> PdaWarehouseIdentity.require(admin)) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("403"); + assertThat(PdaWarehouseIdentity.require(user(Set.of(PdaWarehouseIdentity.PICKER_ROLE, + PdaWarehouseIdentity.OPERATE_PERMISSION), 900L)).overseasWarehouseId()).isEqualTo(900L); + } + + @Test + void baselineContainsShippingGateAndRealOperationHistory() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql")).toLowerCase(); + assertThat(sql).contains("create table if not exists ll_container_shipping_gate") + .contains("awaiting_pickup_ready") + .contains("receiving_available") + .contains("create table if not exists ll_pda_operation_history") + .contains("unique key uk_ll_pda_history_idempotency(operator_key,idempotency_key)"); + } + + @Test + void openApiAndGatewayPublishOnlyStablePdaPaths() throws Exception { + String openapi = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml")); + for (String path : Set.of("/api/pda/v1/last-leg/inventory/resolve:", + "/api/pda/v1/last-leg/locations/resolve:", "/api/pda/v1/last-leg/pallets/resolve:", + "/api/pda/v1/last-leg/operations:")) assertThat(openapi).contains(path); + assertThat(openapi).doesNotContain("/api/pda/v1/last-leg/inbound-plans/{planNo}"); + String gateway = Files.readString(Path.of("../gateway-overseas/src/main/resources/application-overseas.yml")); + assertThat(gateway).contains("/api/pda/v1/last-leg/**").contains("lb://lastleg-service"); + } + + private static AuthenticatedUser user(Set authorities, Long warehouseId) { + return new AuthenticatedUser(42L, 0L, 0L, 8L, "picker", "PLATFORM", "PDA", + authorities, false, null, null, false, warehouseId); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseServiceTest.java new file mode 100644 index 000000000..af623cbef --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseServiceTest.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.pda.warehouse.service; + +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseRequests; +import com.baiyue.lastleg.pda.warehouse.mapper.PdaWarehouseMapper; +import com.baiyue.lastleg.pda.warehouse.model.PdaInventoryProjection; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PdaWarehouseServiceTest { + @Mock PdaWarehouseMapper mapper; + @Mock WarehouseResourceGateway warehouse; + private PdaWarehouseService service; + + @BeforeEach + void setUp() { + service = new PdaWarehouseService(); + ReflectionTestUtils.setField(service, "mapper", mapper); + ReflectionTestUtils.setField(service, "warehouse", warehouse); + } + + @Test + void inventoryScanIsBoundToTokenWarehouse() { + when(mapper.scanInventory("900", "SKU-1")).thenReturn(List.of(inventory())); + var result = service.inventory(900L, new PdaWarehouseRequests.Scan("SKU-1")); + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).customerId()).isEqualTo("100"); + } + + @Test + void palletFromAnotherWarehouseIsRejected() { + when(warehouse.resolvePallet("PAL-1")).thenReturn(new WarehouseResourceGateway.WarehousePalletReference( + 1L, "PAL-1", 901L, 2L, "OCCUPIED", null, 1)); + assertThatThrownBy(() -> service.pallet(900L, new PdaWarehouseRequests.Scan("PAL-1"))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + } + + @Test + void locationCombinesPhysicalCapacityAndInventoryOccupation() { + when(warehouse.resolveLocation(900L, "A-01")).thenReturn( + new WarehouseResourceGateway.WarehouseLocationReference(2L, "A-01", "STORAGE", + new BigDecimal("1000"), "OCCUPIED")); + when(mapper.inventoryByLocation("900", 2L, "A-01", "__NO_CARTON__")) + .thenReturn(List.of(inventory())); + var result = service.location(900L, new PdaWarehouseRequests.Scan("A-01")); + assertThat(result.occupiedPackages()).isEqualTo(5); + assertThat(result.inventoryPositionCount()).isEqualTo(1); + } + + private static PdaInventoryProjection inventory() { + PdaInventoryProjection value = new PdaInventoryProjection(); value.setCustomerId("100"); + value.setBalanceId(10L); value.setPositionId(11L); value.setSkuCode("SKU-1"); + value.setInboundCode("IN-1"); value.setPalletId(12L); value.setPalletCode("PAL-1"); + value.setLocationId(2L); value.setLocationCode("A-01"); value.setAvailablePackages(5L); + value.setHoldPackages(0L); value.setReceivingPackages(0L); value.setVersion(1L); value.setStatus("OCCUPIED"); + return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java index dcd83c472..a56f94cc7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java @@ -31,6 +31,16 @@ class OverseasProcurementOpenApiContractTest { } assertTrue(yaml.contains("platform:overseas-business:truck:create")); assertTrue(yaml.contains("platform:overseas-business:truck:edit")); + assertTrue(yaml.contains("/{orderNo}/review:")); + assertTrue(yaml.contains("/{orderNo}/receive-pallets:")); + assertTrue(yaml.contains("/{orderNo}/review-pallet-overage:")); + assertTrue(yaml.contains("/{orderNo}/allocate-pallets:")); + assertTrue(yaml.contains("/{orderNo}/prepare-dispatch:")); + assertTrue(yaml.contains("/{orderNo}/dispatch:")); + assertTrue(yaml.contains("/{orderNo}/start-transit:")); + assertTrue(yaml.contains("/{orderNo}/complete:")); + assertTrue(yaml.contains("enum: [PENDING_REVIEW, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED]")); + assertTrue(yaml.contains("required: [version, status, idempotencyKey]")); assertTrue(yaml.contains("/api/platform/v1/last-leg/local-looseboard-orders:")); assertTrue(yaml.contains("platform:overseas-business:looseboard:list")); assertTrue(yaml.contains("platform:overseas-business:looseboard:create")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java index 8560aa6b6..a90369d8f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java @@ -16,6 +16,9 @@ class OverseasProcurementSchemaContractTest { assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order")); assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history")); assertTrue(sql.contains("operation_type VARCHAR(32) NOT NULL")); + assertTrue(sql.contains("review_idempotency_key VARCHAR(255) NULL")); + assertTrue(sql.contains("review_request_hash CHAR(64) NULL")); + assertTrue(sql.contains("idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键'")); assertTrue(sql.contains("change_detail JSON NULL")); org.junit.jupiter.api.Assertions.assertFalse(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound")); org.junit.jupiter.api.Assertions.assertFalse(sql.contains("outbound_quantity BIGINT")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java index c06909add..ef5d0138b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java @@ -40,7 +40,7 @@ class OverseasProcurementControllerTest { @Test void reviewRequiresExplicitPermission() { - var request = new OverseasProcurementRequests.Review("APPROVE", null); + var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "review-1", null); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> platform.review(user(0L, "PLATFORM", Set.of()), "100", "HC1", request)); assertEquals(403, error.getStatusCode().value()); @@ -56,7 +56,7 @@ class OverseasProcurementControllerTest { @Test void platformReviewUsesExplicitTargetTenant() { - var request = new OverseasProcurementRequests.Review("APPROVE", "ok"); + var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "review-1", "ok"); platform.review(user(0L, "PLATFORM", Set.of(OverseasProcurementService.REVIEW_PERMISSION)), "100", "HC1", request); verify(service).review("100", "HC1", "7", request); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java index 9e41c94e8..a73d2f04a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java @@ -145,6 +145,33 @@ class OverseasProcurementServiceTest { assertEquals("Acme", values.get(0).customerName()); } + @Test + void approveReviewIsIdempotentAuditedAndTerminalCannotReverse() { + OverseasProcurementOrder order = order(OverseasProcurementService.PENDING_REVIEW, 0, 0); + when(orders.lockNo("100", "HC1")).thenReturn(order); + when(orders.findNo("100", "HC1")).thenReturn(order); + when(histories.listOrder("100", 1L)).thenReturn(List.of()); + var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "approve-1", "approved"); + + var approved = service.review("100", "HC1", "operator-7", request); + assertEquals(OverseasProcurementService.APPROVED, approved.summary().status()); + assertEquals(1L, approved.summary().version()); + assertEquals(1L, service.review("100", "HC1", "operator-7", request).summary().version()); + verify(histories).insert(org.mockito.ArgumentMatchers + .argThat(value -> + "REVIEW".equals(value.getOperationType()) && "operator-7".equals(value.getOperatorId()) + && "approve-1".equals(value.getIdempotencyKey()) + && OverseasProcurementService.APPROVED.equals(value.getToStatus()))); + + assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7", + new OverseasProcurementRequests.Review(1L, "REJECT", "approve-1", "different payload"))); + assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7", + new OverseasProcurementRequests.Review(0L, "REJECT", "stale-review", "old version"))); + assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7", + new OverseasProcurementRequests.Review(1L, "REJECT", "reject-after-approved", "reverse"))); + assertEquals(OverseasProcurementService.APPROVED, order.getStatus()); + } + private OverseasProcurementOrder order(String status, long inbound, long outbound) { OverseasProcurementOrder order = new OverseasProcurementOrder(); order.setId(1L); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java index 551219f83..abdaccab3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java @@ -7,6 +7,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -15,6 +16,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class SchemaChineseCommentContractTest { + private static final Set MODULE_BASELINE_ONLY_TABLES = Set.of( + "ll_us_truck_order_operation", "ll_local_looseboard_operation"); + private static final Pattern CHINESE = Pattern.compile("[\\u4e00-\\u9fff]"); private static final Pattern TABLE = Pattern.compile( "(?is)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+(ll_[a-z0-9_]+)" @@ -45,14 +49,16 @@ class SchemaChineseCommentContractTest { List columns = List.of( "allocation_idempotency_key", "allocation_request_hash", "outbound_idempotency_key", "outbound_request_hash", "outbound_at", "pallet_count", "received_pallet_count", - "allocated_pallet_count", "shipped_pallet_count", "receipt_idempotency_key", - "receipt_request_hash" + "allocated_pallet_count" ); for (String column : columns) { assertColumnHasChineseComment(baseline, column, "基线 SQL"); assertColumnHasChineseComment(incremental, column, "增量 SQL"); assertIncrementalAddColumnHasChineseComment(incremental, column); } + for (String column : List.of("review_idempotency_key", "review_request_hash", "result_version")) { + assertColumnHasChineseComment(baseline, column, "基线 SQL"); + } } private static List assertAllChineseComments(String sql, String source, int expectedCount) { @@ -90,6 +96,9 @@ class SchemaChineseCommentContractTest { assertTrue(marker >= 0, "增量 SQL 缺少 025 中文备注迁移"); String migration = incremental.substring(marker); for (TableDefinition table : tables) { + if (MODULE_BASELINE_ONLY_TABLES.contains(table.name())) { + continue; + } boolean createdInLegacy = hasCommentedCreate(migration, table.name()); boolean createdInUnified = hasCommentedCreate(overseasUnified, table.name()); assertTrue(createdInLegacy || createdInUnified diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 2646b549d..c54bb904c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -2,9 +2,10 @@ package com.baiyue.lastleg.service; import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) class LoadingCompletedConsumerTest { - @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; @Mock InventoryCommandService inventory; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; - @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(inboundPlans.convert(anyString(),anyLong())).thenReturn(new InboundPlanResponse(1L,"LIP-1","100",1L,"MSCU1","PENDING_ASSIGNMENT",1,1,BigDecimal.ONE,List.of(),List.of(),List.of())); } + @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; + @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(inboundPlans.convert(anyString(),anyLong())).thenReturn(new InboundPlanResponse(1L,"LIP-1","100",1L,"MSCU1","PENDING_ASSIGNMENT",1,1,BigDecimal.ONE,List.of(),List.of(),List.of())); } @Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),any(InventoryCommandRequests.InTransit.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } + @Test void loadingCreatesAwaitingPickupReadyGate(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU1",2L,1L); } @Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } @Test void inboundSnapshotUsesCustomerIdAndHasNoPhantomTenantColumn() throws Exception { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java new file mode 100644 index 000000000..5e1b9c95d --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java @@ -0,0 +1,118 @@ +package com.baiyue.lastleg.shipping.service; + +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.FirstLegContainerShippingNodeRecordedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.lastleg.entity.LastlegEventInbox; +import com.baiyue.lastleg.mapper.LastlegEventInboxMapper; +import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ContainerShippingNodeConsumerTest { + @Mock LastlegEventInboxMapper inboxes; + @Mock ContainerShippingGateMapper gates; + private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); + private ContainerShippingNodeConsumer consumer; + + @BeforeEach + void setUp() { + consumer = new ContainerShippingNodeConsumer(); + ReflectionTestUtils.setField(consumer, "inboxes", inboxes); + ReflectionTestUtils.setField(consumer, "gates", gates); + ReflectionTestUtils.setField(consumer, "objectMapper", json); + } + + @Test + void pickupReadyMonotonicallyOpensReceivingGate() { + ContainerShippingGate gate = gate("AWAITING_PICKUP_READY", 70, 4, Instant.parse("2026-08-20T10:00:00Z")); + when(gates.lock("CB-1")).thenReturn(gate); + EventEnvelope event = event("123e4567-e89b-12d3-a456-426614174101", "PICKUP_READY", 80, 5, + Instant.parse("2026-08-21T10:00:00Z")); + + assertThat(consumer.consume(event)).isEqualTo(1); + + assertThat(gate.getStatus()).isEqualTo("RECEIVING_AVAILABLE"); + assertThat(gate.getNodeSequence()).isEqualTo(80); + assertThat(gate.getAggregateVersion()).isEqualTo(5); + verify(gates).updateById(gate); + } + + @Test + void lowerNodeAndOlderAggregateAreAuditedWithoutRegression() { + ContainerShippingGate gate = gate("RECEIVING_AVAILABLE", 90, 8, Instant.parse("2026-08-22T10:00:00Z")); + when(gates.lock("CB-1")).thenReturn(gate); + + consumer.consume(event("123e4567-e89b-12d3-a456-426614174102", "PICKUP_READY", 80, 7, + Instant.parse("2026-08-23T10:00:00Z"))); + + assertThat(gate.getStatus()).isEqualTo("RECEIVING_AVAILABLE"); + assertThat(gate.getNodeSequence()).isEqualTo(90); + assertThat(gate.getAggregateVersion()).isEqualTo(8); + } + + @Test + void sameNodeOnlyAcceptsLaterActualTimeAndEmptyReturnedNeverClosesGate() { + ContainerShippingGate gate = gate("RECEIVING_AVAILABLE", 100, 9, Instant.parse("2026-08-22T10:00:00Z")); + when(gates.lock("CB-1")).thenReturn(gate); + consumer.consume(event("123e4567-e89b-12d3-a456-426614174103", "EMPTY_RETURNED", 100, 10, + Instant.parse("2026-08-22T12:00:00Z"))); + + assertThat(gate.getStatus()).isEqualTo("RECEIVING_AVAILABLE"); + assertThat(gate.getActualAt()).isEqualTo(java.time.LocalDateTime.parse("2026-08-22T12:00:00")); + } + + @Test + void eventIdIsIdempotentByHashAndRejectsDifferentHash() { + EventEnvelope event = event("123e4567-e89b-12d3-a456-426614174104", "PICKUP_READY", 80, 5, + Instant.parse("2026-08-21T10:00:00Z")); + LastlegEventInbox existing = new LastlegEventInbox(); existing.setPayloadHash(event.payloadHash()); + when(inboxes.selectById(event.eventId())).thenReturn(existing); + assertThat(consumer.consume(event)).isEqualTo(1); + verify(gates, never()).updateById(any(ContainerShippingGate.class)); + + existing.setPayloadHash("sha256:" + "0".repeat(64)); + assertThatThrownBy(() -> consumer.consume(event)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("摘要冲突"); + } + + private ContainerShippingGate gate(String status, int sequence, long version, Instant actualAt) { + ContainerShippingGate gate = new ContainerShippingGate(); gate.setContainerBookingId("CB-1"); + gate.setCustomerId("100"); gate.setOceanBookingId("OB-1"); gate.setContainerNo("CONT-1"); + gate.setStatus(status); gate.setNodeSequence(sequence); gate.setAggregateVersion(version); + gate.setHandoverVersion(version + 1); gate.setActualAt(java.time.LocalDateTime.ofInstant(actualAt, + java.time.ZoneOffset.UTC)); return gate; + } + + private EventEnvelope event(String eventId, String node, int sequence, long version, Instant actualAt) { + FirstLegContainerShippingNodeRecordedV1 payload = new FirstLegContainerShippingNodeRecordedV1( + "Q-1", "100", "OB-1", "CB-1", "CONT-1", node, sequence, actualAt, + actualAt, "LAX", "Los Angeles", "CARRIER", "PE-1", node, "REF-1", "a".repeat(64)); + JsonNode payloadNode = json.valueToTree(payload); + String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode)); + return new EventEnvelope(eventId, EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1, + actualAt, RegionCode.CN, RegionCode.OVERSEAS, "100", "CONTAINER_BOOKING", "CB-1", + version, version + 1, "123e4567-e89b-12d3-a456-426614174199", "shipping:CB-1", null, + payloadNode, hash); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java index 724c2010e..8dd0a31d7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java @@ -18,7 +18,11 @@ class UsTruckOrderSchemaContractTest { assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet")); assertTrue(sql.contains("pallet_count INT NOT NULL DEFAULT 1")); assertTrue(sql.contains("received_pallet_count INT NOT NULL DEFAULT 0")); - assertTrue(sql.contains("receipt_idempotency_key VARCHAR(255) NULL")); + assertTrue(sql.contains("allocated_pallet_count INT NOT NULL DEFAULT 0")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_operation")); + assertTrue(sql.contains("UNIQUE KEY uk_ll_us_truck_operation_key(customer_id,order_id,idempotency_key)")); + assertTrue(sql.contains("operation_type VARCHAR(64) NOT NULL")); + assertTrue(sql.contains("idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键'")); assertTrue(sql.contains("UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key)")); assertTrue(sql.contains("KEY idx_ll_us_truck_history(customer_id,order_id,created_at)")); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java index fdcb0caf1..316954093 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java @@ -37,7 +37,7 @@ class UsTruckOrderControllerTest { @Test void platformStatusChangeRequiresManagePermission() { - var request = new UsTruckOrderRequests.ChangeStatus(0L, "DISPATCHED", null); + var request = new UsTruckOrderRequests.ChangeStatus(0L, "CANCELLED", "cancel-1", null); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> platform.changeStatus(user(0L, "PLATFORM", Set.of()), "100", "TK1", request)); assertEquals(403, error.getStatusCode().value()); @@ -45,11 +45,34 @@ class UsTruckOrderControllerTest { @Test void platformStatusChangeUsesExplicitTargetTenant() { - var request = new UsTruckOrderRequests.ChangeStatus(0L, "DISPATCHED", "truck assigned"); + var request = new UsTruckOrderRequests.ChangeStatus(0L, "CANCELLED", "cancel-1", "cancelled"); platform.changeStatus(user(0L, "PLATFORM", Set.of(UsTruckOrderService.MANAGE_PERMISSION)), "100", "TK1", request); verify(service).changeStatus("100", "TK1", "7", request); } + @Test + void platformLifecycleCommandsUseExplicitTargetAndStatusPermission() { + var user = user(0L, "PLATFORM", Set.of(UsTruckOrderService.MANAGE_PERMISSION)); + var review = new UsTruckOrderRequests.Review(0L, "APPROVE", "review-1", null); + var overage = new UsTruckOrderRequests.ReviewPalletOverage(2L, "APPROVE", "overage-1", null); + var allocation = new UsTruckOrderRequests.AllocatePallets(3L, java.util.List.of(1L), "allocate-1", null); + var command = new UsTruckOrderRequests.Command(4L, "command-1", null); + platform.review(user, "100", "TK1", review); + platform.reviewPalletOverage(user, "100", "TK1", overage); + platform.allocatePallets(user, "100", "TK1", allocation); + platform.prepareDispatch(user, "100", "TK1", command); + platform.dispatch(user, "100", "TK1", command); + platform.startTransit(user, "100", "TK1", command); + platform.complete(user, "100", "TK1", command); + verify(service).review("100", "TK1", "7", review); + verify(service).reviewPalletOverage("100", "TK1", "7", overage); + verify(service).allocatePallets("100", "TK1", "7", allocation); + verify(service).prepareDispatch("100", "TK1", "7", command); + verify(service).dispatch("100", "TK1", "7", command); + verify(service).startTransit("100", "TK1", "7", command); + verify(service).complete("100", "TK1", "7", command); + } + @Test void platformCreateUsesExplicitCustomerAndCreatePermission() { var request = new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles", null, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java index de1cf17df..a64b113c6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java @@ -2,9 +2,11 @@ package com.baiyue.lastleg.trucking.service; import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests; import com.baiyue.lastleg.trucking.entity.UsTruckOrder; +import com.baiyue.lastleg.trucking.entity.UsTruckOrderOperation; import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet; import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory; import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper; +import com.baiyue.lastleg.trucking.mapper.UsTruckOrderOperationMapper; import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper; import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper; import com.fasterxml.jackson.databind.ObjectMapper; @@ -16,18 +18,18 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.server.ResponseStatusException; -import java.math.BigDecimal; -import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; -import java.util.concurrent.atomic.AtomicReference; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -35,7 +37,12 @@ class UsTruckOrderServiceTest { @Mock UsTruckOrderMapper orders; @Mock UsTruckOrderPalletMapper pallets; @Mock UsTruckOrderStatusHistoryMapper histories; + @Mock UsTruckOrderOperationMapper operations; + private final List palletRows = new ArrayList<>(); + private final List historyRows = new ArrayList<>(); + private final Map operationRows = new HashMap<>(); private UsTruckOrderService service; + private UsTruckOrder order; @BeforeEach void setUp() { @@ -43,113 +50,128 @@ class UsTruckOrderServiceTest { ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "pallets", pallets); ReflectionTestUtils.setField(service, "histories", histories); + ReflectionTestUtils.setField(service, "operations", operations); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); - org.mockito.Mockito.lenient().when(pallets.listOrder(anyString(), any())).thenReturn(List.of()); - } - - @Test - void customerOrderStartsPendingConfirmationAndUsesUsdByDefault() { - AtomicReference saved = new AtomicReference<>(); - when(orders.insert(any(UsTruckOrder.class))).thenAnswer(call -> { saved.set(call.getArgument(0)); return 1; }); - when(orders.findNo(anyString(), anyString())).thenAnswer(call -> saved.get()); - when(histories.listOrder(anyString(), any())).thenReturn(List.of()); - - var detail = service.create("100", "7", create("key-1")); - - assertEquals(UsTruckOrderService.PENDING_CONFIRMATION, detail.summary().status()); - assertEquals("USD", detail.summary().currency()); - assertEquals("100", detail.summary().customerId()); - } - - @Test - void statusChangePersistsHistoryAndIncrementsVersion() { - UsTruckOrder order = order(UsTruckOrderService.PENDING_CONFIRMATION, 0L); - when(orders.lockNo("100", "TK1")).thenReturn(order); - when(orders.findNo("100", "TK1")).thenReturn(order); - when(histories.listOrder("100", 1L)).thenReturn(List.of()); - - var result = service.changeStatus("100", "TK1", "7", - new UsTruckOrderRequests.ChangeStatus(0L, "WAITING_INBOUND", "accepted")); - - assertEquals(UsTruckOrderService.WAITING_INBOUND, result.summary().status()); - assertEquals(1, result.summary().version()); - verify(histories).insert(any(UsTruckOrderStatusHistory.class)); - } - - @Test - void staleStatusChangeIsRejected() { - when(orders.lockNo("100", "TK1")).thenReturn(order(UsTruckOrderService.DISPATCHED, 2L)); - ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> - service.changeStatus("100", "TK1", "7", new UsTruckOrderRequests.ChangeStatus(1L, "IN_TRANSIT", null))); - assertEquals(409, error.getStatusCode().value()); - } - - @Test - void staleOrdinaryUpdateIsRejectedAsConflict() { - when(orders.lockNo("100", "TK1")).thenReturn(order(UsTruckOrderService.PENDING_REVIEW, 2L)); - UsTruckOrderRequests.Update request = new UsTruckOrderRequests.Update(1L, - null, null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null); - - ResponseStatusException error = assertThrows(ResponseStatusException.class, - () -> service.update("100", "TK1", "7", request)); - - assertEquals(409, error.getStatusCode().value()); - } - - @Test - void detailIncludesBoundPallets() { - UsTruckOrder order = order(UsTruckOrderService.WAITING_INBOUND, 1L); - UsTruckOrderPallet pallet = new UsTruckOrderPallet(); - pallet.setId(10L); pallet.setPalletNo("PLT-001"); pallet.setSequenceNo(1); - pallet.setSource("SYSTEM"); pallet.setStatus("OCCUPIED"); - pallet.setWarehousePalletId(20L); pallet.setPackageCount(12); - when(orders.findNo("100", "TK1")).thenReturn(order); - when(histories.listOrder("100", 1L)).thenReturn(List.of()); - when(pallets.listOrder("100", 1L)).thenReturn(List.of(pallet)); - - var detail = service.detail("100", "TK1"); - - assertEquals(1, detail.pallets().size()); - assertEquals("PLT-001", detail.pallets().get(0).palletNo()); - assertEquals(12, detail.pallets().get(0).packageCount()); - } - - @Test - void receivingMorePalletsCreatesOverageAndKeepsDeclaredCount() { - UsTruckOrder order = order(UsTruckOrderService.WAITING_INBOUND, 0L); - order.setPalletCount(1); - when(orders.lockNo("100", "TK1")).thenReturn(order); - when(orders.findNo("100", "TK1")).thenReturn(order); - when(histories.listOrder("100", 1L)).thenReturn(List.of()); - List savedPallets = new ArrayList<>(); - when(pallets.listOrder("100", 1L)).thenAnswer(call -> List.copyOf(savedPallets)); - when(pallets.insert(any(UsTruckOrderPallet.class))).thenAnswer(call -> { - savedPallets.add(call.getArgument(0)); - return 1; + order = order(UsTruckOrderService.PENDING_REVIEW, 0L, 2); + when(orders.lockNo("100", "TK1")).thenAnswer(call -> order); + when(orders.findNo("100", "TK1")).thenAnswer(call -> order); + when(pallets.listOrder("100", 1L)).thenAnswer(call -> palletRows); + when(histories.listOrder("100", 1L)).thenAnswer(call -> historyRows); + when(histories.insert(any(UsTruckOrderStatusHistory.class))).thenAnswer(call -> { + historyRows.add(call.getArgument(0)); return 1; }); - - var result = service.receivePallets("100", "TK1", "7", - new UsTruckOrderRequests.ReceivePallets(0L, 3, 0, "receive-1", "actual three pallets")); - - assertEquals(1, result.summary().palletCount()); - assertEquals(3, result.summary().receivedPalletCount()); - assertEquals(UsTruckOrderService.PALLET_OVERAGE_REVIEW, result.summary().status()); - verify(pallets, org.mockito.Mockito.times(3)).insert(any(UsTruckOrderPallet.class)); + when(pallets.insert(any(UsTruckOrderPallet.class))).thenAnswer(call -> { + UsTruckOrderPallet row = call.getArgument(0); palletRows.add(row); return 1; + }); + when(operations.findKey(anyString(), anyLong(), anyString())) + .thenAnswer(call -> operationRows.get(call.getArgument(2))); + when(operations.insert(any(UsTruckOrderOperation.class))).thenAnswer(call -> { + UsTruckOrderOperation row = call.getArgument(0); operationRows.put(row.getIdempotencyKey(), row); return 1; + }); + UsTruckOrderStatusHistory created = new UsTruckOrderStatusHistory(); + created.setOperationType("CREATE"); created.setOperatorId("creator"); created.setIdempotencyKey("create-1"); + created.setToStatus(UsTruckOrderService.PENDING_REVIEW); historyRows.add(created); } - private UsTruckOrderRequests.Create create(String key) { - return new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles Port", "Pier 1", "Ontario, CA", - "Warehouse A", "40HQ", 100, new BigDecimal("12600"), new BigDecimal("55.2"), - new BigDecimal("3800"), null, "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null, key); + @Test + void completeLifecycleUsesFactsIdempotencyOperatorsAndCannotReverseTerminal() { + var review = new UsTruckOrderRequests.Review(0L, "APPROVE", "review-1", "approve"); + var reviewed = service.review("100", "TK1", "operator-7", review); + assertEquals(UsTruckOrderService.WAITING_INBOUND, reviewed.summary().status()); + assertEquals(2, palletRows.size()); + assertEquals(1L, reviewed.summary().version()); + assertEquals(1L, service.review("100", "TK1", "operator-7", review).summary().version()); + assertThrows(ResponseStatusException.class, () -> service.review("100", "TK1", "operator-7", + new UsTruckOrderRequests.Review(1L, "REJECT", "review-1", "different"))); + + var receive = new UsTruckOrderRequests.ReceivePallets(1L, 3, 6, List.of(2, 2, 2), + "receive-1", "receive overage"); + var received = service.receivePallets("100", "TK1", "receiver-8", receive); + assertEquals(UsTruckOrderService.PALLET_OVERAGE_REVIEW, received.summary().status()); + assertEquals(3, received.summary().receivedPalletCount()); + assertEquals(3, palletRows.size()); + assertEquals(2L, service.receivePallets("100", "TK1", "receiver-8", receive).summary().version()); + + var overage = new UsTruckOrderRequests.ReviewPalletOverage(2L, "APPROVE", + "overage-1", "approve overage"); + var overageReviewed = service.reviewPalletOverage("100", "TK1", "reviewer-9", overage); + assertEquals(UsTruckOrderService.WAITING_CONSOLIDATION, overageReviewed.summary().status()); + assertEquals(3, overageReviewed.summary().palletCount()); + assertTrue(palletRows.stream().allMatch(row -> "RECEIVED".equals(row.getStatus()))); + + Long first = palletRows.get(0).getId(); + List remaining = palletRows.subList(1, 3).stream().map(UsTruckOrderPallet::getId).toList(); + var partial = service.allocatePallets("100", "TK1", "allocator-10", + new UsTruckOrderRequests.AllocatePallets(3L, List.of(first), "allocate-1", "partial")); + assertEquals(UsTruckOrderService.PARTIALLY_ALLOCATED, partial.summary().status()); + assertEquals(1, partial.summary().allocatedPalletCount()); + var allocated = service.allocatePallets("100", "TK1", "allocator-10", + new UsTruckOrderRequests.AllocatePallets(4L, remaining, "allocate-2", "all")); + assertEquals(UsTruckOrderService.ALLOCATED, allocated.summary().status()); + assertEquals(3, allocated.summary().allocatedPalletCount()); + + assertEquals(UsTruckOrderService.PENDING_DISPATCH, service.prepareDispatch("100", "TK1", "dispatcher-11", + command(5L, "prepare-1")).summary().status()); + assertEquals(UsTruckOrderService.DISPATCHED, service.dispatch("100", "TK1", "dispatcher-11", + command(6L, "dispatch-1")).summary().status()); + assertEquals(UsTruckOrderService.IN_TRANSIT, service.startTransit("100", "TK1", "driver-12", + command(7L, "transit-1")).summary().status()); + var completed = service.complete("100", "TK1", "driver-12", command(8L, "complete-1")); + assertEquals(UsTruckOrderService.COMPLETED, completed.summary().status()); + assertEquals(9L, completed.summary().version()); + assertEquals(9L, service.complete("100", "TK1", "driver-12", command(8L, "complete-1")) + .summary().version()); + + assertThrows(ResponseStatusException.class, () -> service.changeStatus("100", "TK1", "operator-7", + new UsTruckOrderRequests.ChangeStatus(9L, "CANCELLED", "cancel-terminal", "forbidden"))); + assertThrows(ResponseStatusException.class, () -> service.startTransit("100", "TK1", "driver-12", + command(8L, "reverse-state"))); + assertThrows(ResponseStatusException.class, () -> service.dispatch("100", "TK1", "dispatcher-11", + command(1L, "stale-version"))); + + assertEquals(List.of("CREATE", "REVIEW", "RECEIVE_PALLETS", "REVIEW_PALLET_OVERAGE", + "ALLOCATE_PALLETS", "ALLOCATE_PALLETS", "PREPARE_DISPATCH", "DISPATCH", + "START_TRANSIT", "COMPLETE"), + historyRows.stream().map(UsTruckOrderStatusHistory::getOperationType).toList()); + assertTrue(historyRows.stream().skip(1).allMatch(row -> row.getOperatorId() != null + && row.getIdempotencyKey() != null)); } - private UsTruckOrder order(String status, long version) { - UsTruckOrder order = new UsTruckOrder(); order.setId(1L); order.setCustomerId("100"); order.setOrderNo("TK1"); - order.setTransportType("PORT_PICKUP"); order.setOrigin("Los Angeles"); order.setDestination("Ontario"); - order.setContainerType("40HQ"); order.setPalletCount(1); order.setPackageCount(0); - order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setShippedPalletCount(0); - order.setStatus(status); order.setVersion(version); - order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(order.getCreatedAt()); return order; + @Test + void partialReceiptUsesReceivingThenExactReceiptMovesToConsolidation() { + service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review( + 0L, "APPROVE", "review-partial", null)); + var partial = service.receivePallets("100", "TK1", "receiver", + new UsTruckOrderRequests.ReceivePallets(1L, 1, 2, List.of(2), "receive-partial", null)); + assertEquals(UsTruckOrderService.RECEIVING, partial.summary().status()); + var exact = service.receivePallets("100", "TK1", "receiver", + new UsTruckOrderRequests.ReceivePallets(2L, 2, 4, List.of(2, 2), "receive-exact", null)); + assertEquals(UsTruckOrderService.WAITING_CONSOLIDATION, exact.summary().status()); + } + + @Test + void rejectedOverageExcludesExtraPalletFromAcceptedCount() { + service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review( + 0L, "APPROVE", "review-reject-overage", null)); + service.receivePallets("100", "TK1", "receiver", new UsTruckOrderRequests.ReceivePallets( + 1L, 3, 6, List.of(2, 2, 2), "receive-reject-overage", null)); + var reviewed = service.reviewPalletOverage("100", "TK1", "reviewer", + new UsTruckOrderRequests.ReviewPalletOverage(2L, "REJECT", "reject-overage", null)); + assertEquals(2, reviewed.summary().receivedPalletCount()); + assertEquals("OVERAGE_REJECTED", palletRows.get(2).getStatus()); + } + + private UsTruckOrderRequests.Command command(long version, String key) { + return new UsTruckOrderRequests.Command(version, key, key); + } + + private UsTruckOrder order(String status, long version, int pallets) { + UsTruckOrder value = new UsTruckOrder(); + value.setId(1L); value.setCustomerId("100"); value.setOrderNo("TK1"); + value.setTransportType("PORT_PICKUP"); value.setOrigin("Los Angeles"); value.setDestination("Ontario"); + value.setPalletCount(pallets); value.setPackageCount(0); value.setReceivedPalletCount(0); + value.setAllocatedPalletCount(0); value.setStatus(status); value.setVersion(version); + value.setCreatedAt(LocalDateTime.now()); value.setUpdatedAt(value.getCreatedAt()); + return value; } } diff --git a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs index fce80fc51..92896b776 100644 --- a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs +++ b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs @@ -1,5 +1,18 @@ const MODULE = 'phase3-warehouse-rbac' const OPTIONS = { scenario: 'warehouse-role-boundary', deploymentBlocker: true } +const PHASE4_READ_CODES = [ + 'platform:overseas-warehouse:last-mile-order:list', + 'platform:overseas-warehouse:inbound-order:list', + 'platform:overseas-warehouse:relabel-order:list', + 'platform:overseas-warehouse:inventory:list', + 'platform:overseas-warehouse:warehouse:list', + 'platform:overseas-warehouse:location:list', + 'platform:overseas-warehouse:pallet:list', + 'platform:overseas-business:purchase:list', + 'platform:overseas-business:truck:list', + 'platform:overseas-business:looseboard:list', +] +const PHASE4_WRITE_SUFFIXES = [':create', ':edit', ':status', ':export', ':adjust', ':delete', ':review'] function id(value) { return value == null ? '' : String(value) } @@ -41,14 +54,17 @@ export async function run(context) { `permissions=${[...pickerCodes].sort().join(',')}`, OPTIONS) const operatorCodes = roleCodes('WAREHOUSE_OPERATOR') - context.assert(MODULE, '操作员仅具备入仓查看和后台证据维护权限', rolesResponse, + context.assert(MODULE, '操作员具备国内入仓权限与四期十项只读权限', rolesResponse, operatorCodes.has('platform:firstleg:inbound:loading:view') && operatorCodes.has('platform:firstleg:inbound:loading:edit') - && operatorCodes.size === 5 + && operatorCodes.size === 15 + && PHASE4_READ_CODES.every((code) => operatorCodes.has(code)) && !operatorCodes.has('platform:firstleg:inbound:loading:complete') && !operatorCodes.has('platform:firstleg:pda:assign') && !operatorCodes.has('platform:firstleg:outbound-dispatch:confirm') - && !operatorCodes.has('platform:firstleg:outbound-dispatch:reverse'), + && !operatorCodes.has('platform:firstleg:outbound-dispatch:reverse') + && [...operatorCodes].filter((code) => code.startsWith('platform:overseas-')) + .every((code) => !PHASE4_WRITE_SUFFIXES.some((suffix) => code.endsWith(suffix))), `permissionCount=${operatorCodes.size}`, OPTIONS) const tallyCodes = roleCodes('WAREHOUSE_TALLY') @@ -56,10 +72,21 @@ export async function run(context) { tallyCodes.has('platform:firstleg:inbound:loading:view') && tallyCodes.has('platform:firstleg:inbound:loading:edit') && tallyCodes.has('platform:firstleg:pda:review') + && tallyCodes.size === 22 + && PHASE4_READ_CODES.every((code) => tallyCodes.has(code)) && !tallyCodes.has('platform:firstleg:inbound:loading:complete') - && !tallyCodes.has('platform:firstleg:outbound-dispatch:confirm'), + && !tallyCodes.has('platform:firstleg:outbound-dispatch:confirm') + && [...tallyCodes].filter((code) => code.startsWith('platform:overseas-')) + .every((code) => !PHASE4_WRITE_SUFFIXES.some((suffix) => code.endsWith(suffix))), `permissionCount=${tallyCodes.size}`, OPTIONS) + context.assert(MODULE, '四期list权限同时用于列表与详情且不隐含写权限', rolesResponse, + PHASE4_READ_CODES.every((code) => code.endsWith(':list')) + && [...operatorCodes, ...tallyCodes] + .filter((code) => code.startsWith('platform:overseas-')) + .every((code) => PHASE4_READ_CODES.includes(code)), + `phase4ReadCodes=${PHASE4_READ_CODES.length}`, OPTIONS) + context.assert(MODULE, '仓库系统角色不包含任何客户侧权限', rolesResponse, [...operatorCodes, ...pickerCodes, ...tallyCodes] .every((code) => code.startsWith('platform:') || code === 'lastleg:pda:operate'), diff --git a/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs b/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs new file mode 100644 index 000000000..c1fdaa0fe --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs @@ -0,0 +1,39 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +const acceptance = readFileSync(new URL('./phase3-warehouse-rbac.mjs', import.meta.url), 'utf8') +const iamSql = readFileSync(new URL('../../iam/src/main/resources/db/lastleg-permissions.sql', import.meta.url), 'utf8') + +const listCodes = [ + 'platform:overseas-warehouse:last-mile-order:list', + 'platform:overseas-warehouse:inbound-order:list', + 'platform:overseas-warehouse:relabel-order:list', + 'platform:overseas-warehouse:inventory:list', + 'platform:overseas-warehouse:warehouse:list', + 'platform:overseas-warehouse:location:list', + 'platform:overseas-warehouse:pallet:list', + 'platform:overseas-business:purchase:list', + 'platform:overseas-business:truck:list', + 'platform:overseas-business:looseboard:list', +] + +test('warehouse operator and tally use the exact phase-four list/detail permission set', () => { + for (const code of listCodes) { + assert.match(acceptance, new RegExp(code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) + assert.match(iamSql, new RegExp(`'WAREHOUSE_OPERATOR','${code}'`)) + assert.match(iamSql, new RegExp(`'WAREHOUSE_TALLY','${code}'`)) + } + assert.match(acceptance, /operatorCodes\.size === 15/) + assert.match(acceptance, /tallyCodes\.size === 22/) + assert.match(acceptance, /PHASE4_WRITE_SUFFIXES/) + assert.match(iamSql, /assert_phase4_warehouse_role_minimum/) + assert.match(iamSql, /permission_code REGEXP ':\(create\|edit\|status\|export\|adjust\|delete\|review\)\$'/) +}) + +test('picker stays isolated to the dedicated overseas PDA authority', () => { + assert.match(acceptance, /pickerCodes\.size === 3/) + assert.match(acceptance, /pickerCodes\.has\('lastleg:pda:operate'\)/) + assert.match(iamSql, /role\.role_code='WAREHOUSE_PICKER'/) + assert.match(iamSql, /permission\.permission_code='lastleg:pda:operate'/) +}) diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 3a9bf24a9..24a9d2c14 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -3765,3 +3765,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN change_detail JSON NULL COMMENT ''字段变更前后值JSON''', 'ALTER TABLE ll_overseas_procurement_status_history ADD COLUMN change_detail JSON NULL COMMENT ''字段变更前后值JSON'' AFTER operator_id')); PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; +-- 四期货物废弃审核幂等快照;存量表按字段存在性幂等补齐。 +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_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; +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')); +PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt; diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java index 13bac4df9..94b636b8e 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java @@ -5,6 +5,7 @@ import com.baiyue.warehouse.dto.WarehouseResourceLockRequests; import com.baiyue.warehouse.dto.WarehouseResourceLockResponse; import com.baiyue.warehouse.dto.WarehousePalletScrapResponse; import com.baiyue.warehouse.dto.SharedWarehouseReference; +import com.baiyue.warehouse.dto.WarehouseLocationResolveResponse; import com.baiyue.warehouse.entity.WarehouseLocationEntity; import com.baiyue.warehouse.security.InternalServiceTokenGuard; import com.baiyue.warehouse.service.WarehouseResourceLockService; @@ -17,6 +18,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -79,4 +81,20 @@ public class InternalWarehouseResourceLockController { tokenGuard.verify(token); return ApiResponse.success(warehouses.activeSharedReference(warehouseId)); } + + @GetMapping("/locations/resolve/{locationCode}") + public ApiResponse resolveLocation( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestParam long warehouseId, @PathVariable String locationCode) { + tokenGuard.verify(token); + return ApiResponse.success(resourceService.resolvePublicLocation(warehouseId, locationCode)); + } + + @GetMapping("/locations/{locationId}") + public ApiResponse location( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestParam long warehouseId, @PathVariable long locationId) { + tokenGuard.verify(token); + return ApiResponse.success(resourceService.resolvePublicLocation(warehouseId, locationId)); + } } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseLocationResolveResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseLocationResolveResponse.java new file mode 100644 index 000000000..571c15925 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseLocationResolveResponse.java @@ -0,0 +1,6 @@ +package com.baiyue.warehouse.dto; + +import java.math.BigDecimal; + +public record WarehouseLocationResolveResponse(long locationId, String locationCode, String locationType, + BigDecimal maxLoadKg, String status) { } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java index ec8fef579..796d9890e 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.LocationRequests; import com.baiyue.warehouse.dto.PalletRequests; import com.baiyue.warehouse.dto.WarehousePalletResolveResponse; +import com.baiyue.warehouse.dto.WarehouseLocationResolveResponse; import com.baiyue.warehouse.entity.WarehouseLocationEntity; import com.baiyue.warehouse.entity.WarehousePalletEntity; import com.baiyue.warehouse.entity.WarehouseEntity; @@ -191,6 +192,25 @@ public class WarehouseResourceService { pallet.getVersion() == null ? 0 : pallet.getVersion()); } + public WarehouseLocationResolveResponse resolvePublicLocation(long warehouseId, String locationCode) { + if (!StringUtils.hasText(locationCode)) throw new BizException(ResultCode.BAD_REQUEST, "库位码不能为空"); + WarehouseLocationEntity location = repository.findLocationByCode(PUBLIC_RESOURCE_SCOPE, warehouseId, + locationCode.trim()); + if (location == null) throw new BizException(ResultCode.NOT_FOUND, "库位码不存在或不属于当前仓库"); + return locationReference(location); + } + + public WarehouseLocationResolveResponse resolvePublicLocation(long warehouseId, long locationId) { + WarehouseLocationEntity location = repository.findLocation(PUBLIC_RESOURCE_SCOPE, warehouseId, locationId); + if (location == null) throw new BizException(ResultCode.NOT_FOUND, "库位不存在或不属于当前仓库"); + return locationReference(location); + } + + private static WarehouseLocationResolveResponse locationReference(WarehouseLocationEntity location) { + return new WarehouseLocationResolveResponse(location.getId(), location.getLocationCode(), + location.getLocationType(), location.getMaxLoadKg(), location.getStatus()); + } + public PageResult pallets(long customerId, long warehouseId, PalletRequests.QueryRequest query) { requireWarehouse(customerId, warehouseId); diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java index b3e47eb2b..5907572f9 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java @@ -50,6 +50,19 @@ class WarehouseResourceServiceTest { .isEqualTo(IdType.ASSIGN_ID); } + @Test + void resolvesOnlyPublicLocationInsideBoundWarehouse() { + WarehouseLocationEntity location = new WarehouseLocationEntity(); location.setId(30001L); + location.setWarehouseId(WAREHOUSE); location.setCustomerId(0L); location.setLocationCode("A-01"); + location.setLocationType("STORAGE"); location.setMaxLoadKg(new BigDecimal("1000")); location.setStatus("IDLE"); + when(repository.findLocationByCode(0L, WAREHOUSE, "A-01")).thenReturn(location); + + var result = service.resolvePublicLocation(WAREHOUSE, " A-01 "); + + assertThat(result.locationId()).isEqualTo(30001L); + assertThat(result.locationCode()).isEqualTo("A-01"); + } + @Test void createsLocationWithSnowflakeAssignedByMapper() { when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); From a6be840588ee9a0fd062dec4a7e9b170c55cbf30 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 10:39:43 +0800 Subject: [PATCH 084/115] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 1 + .../phase4-canonical-warehouse-rbac-contract.test.mjs | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 9f35980e2..271ad12d9 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16395,6 +16395,7 @@ CALL baiyue_iam.assert_overseas_pda_picker_permission(); DROP PROCEDURE baiyue_iam.assert_overseas_pda_picker_permission; -- 四期仓库后台角色严格最小授权:list 同时覆盖列表与详情,只读角色不得获得任何写权限。 +USE baiyue_iam; DROP TEMPORARY TABLE IF EXISTS tmp_phase4_warehouse_role_permission; CREATE TEMPORARY TABLE tmp_phase4_warehouse_role_permission ( role_code VARCHAR(64) NOT NULL, diff --git a/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs b/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs index c1fdaa0fe..d36246ae5 100644 --- a/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs @@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs' const acceptance = readFileSync(new URL('./phase3-warehouse-rbac.mjs', import.meta.url), 'utf8') const iamSql = readFileSync(new URL('../../iam/src/main/resources/db/lastleg-permissions.sql', import.meta.url), 'utf8') +const unifiedSql = readFileSync(new URL('../../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url), 'utf8') const listCodes = [ 'platform:overseas-warehouse:last-mile-order:list', @@ -37,3 +38,11 @@ test('picker stays isolated to the dedicated overseas PDA authority', () => { assert.match(iamSql, /role\.role_code='WAREHOUSE_PICKER'/) assert.match(iamSql, /permission\.permission_code='lastleg:pda:operate'/) }) + +test('unified migration creates the phase-four role matrix in the IAM schema used by its assertion routine', () => { + assert.match( + unifiedSql, + /USE baiyue_iam;\s*DROP TEMPORARY TABLE IF EXISTS tmp_phase4_warehouse_role_permission;\s*CREATE TEMPORARY TABLE tmp_phase4_warehouse_role_permission/, + ) + assert.match(unifiedSql, /CREATE PROCEDURE baiyue_iam\.assert_phase4_warehouse_role_minimum\(\)/) +}) From 2a16c91c89928bdf7782b7801fa309a383f2dee3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 10:48:10 +0800 Subject: [PATCH 085/115] test: sequence pickup readiness before overseas receiving --- scripts/admin-test-env-acceptance.mjs | 7 +- .../phase4-carrier-nodes-contract.test.mjs | 14 +++- .../phase4-carrier-nodes.mjs | 66 ++++++++++++++++++- .../phase4-carrier-pickup-ready.mjs | 5 ++ 4 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase4-carrier-pickup-ready.mjs diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 51ba55c58..0c3a005ff 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import process from 'node:process' -const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-pda', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] +const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] const MODULE_DEPENDENCIES = new Map([ ['orders', ['merchant-qualification-fixture']], @@ -29,12 +29,13 @@ const MODULE_DEPENDENCIES = new Map([ ['phase3-carrier-schedule-queries', ['phase3-outbound-dispatch']], ['phase3-order-splitting', ['phase3-loading-finalize']], ['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings']], - ['phase4-overseas-pda', ['phase3-loading-finalize']], + ['phase4-carrier-pickup-ready', ['phase3-carrier-schedule-queries']], + ['phase4-overseas-pda', ['phase4-carrier-pickup-ready']], ['phase4-overseas-inbound-operations', ['phase4-overseas-pda']], ['phase4-overseas-client-services', ['phase4-overseas-pda']], ['phase4-overseas-dispatch', ['phase4-overseas-pda']], ['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda']], - ['phase4-carrier-nodes', ['phase3-carrier-schedule-queries']], + ['phase4-carrier-nodes', ['phase4-overseas-pda']], ]) function stableTopologicalOrder(modules) { diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index 2b88fe588..4a26efff9 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -31,13 +31,25 @@ test('focused carrier node execution injects the complete outbound-confirm produ 'phase3-container-loading', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-loading-finalize', 'phase3-customs', 'ocean-bookings', 'phase3-port-schedules', 'truck-appointments', 'service-center-flow', 'phase3-outbound-dispatch', - 'phase3-carrier-schedule-queries', 'phase4-carrier-nodes'] + 'phase3-carrier-schedule-queries', 'phase4-carrier-pickup-ready', + 'phase4-overseas-pda', 'phase4-carrier-nodes'] for (const module of required) assert.ok(modules.includes(module), module) for (let index = 1; index < required.length; index += 1) { assert.ok(modules.indexOf(required[index - 1]) < modules.indexOf(required[index]), required[index]) } }) +test('real lifecycle opens the shipping gate before PDA and records pickup and empty return afterwards', () => { + const modules = normalizeModuleOrder(['phase4-overseas-pda', 'phase4-carrier-nodes']) + const pickup = modules.indexOf('phase4-carrier-pickup-ready') + const pda = modules.indexOf('phase4-overseas-pda') + const completion = modules.indexOf('phase4-carrier-nodes') + assert.ok(pickup >= 0 && pickup < pda) + assert.ok(pda < completion) + assert.match(source, /phase4CarrierPickupReady/) + assert.match(source, /STANDARD_NODES\.slice\(STANDARD_NODES\.indexOf\('PICKED_UP'\)\)/) +}) + test('canonical states, ten ordered nodes, exception codes and complete payload are frozen', () => { assert.deepEqual(QUERY_STATUSES, ['READY', 'MANUAL_REQUIRED', 'STOPPED']) assert.deepEqual(STANDARD_NODES, [ diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index bd0bea3a9..903cb0da3 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -100,7 +100,7 @@ function blocker(context, name, evidence, response = null) { context.addStep(MODULE, name, response, false, evidence, OPTIONS) } -export async function run(context) { +export async function runUntilPickupReady(context) { const carriersResponse = await context.request('GET', `${BASE}/carriers`) const carriers = context.apiSuccess(MODULE, '查询船司只读辅助信息', carriersResponse, '', OPTIONS) context.assert(MODULE, '船司辅助信息结构稳定', carriersResponse, @@ -188,7 +188,8 @@ export async function run(context) { const frozenLatestActualMs = frozenLatestActual == null ? Number.NaN : Date.parse(`${frozenLatestActual}Z`) const baseActual = (Number.isNaN(frozenLatestActualMs) ? Date.now() : frozenLatestActualMs) + 60 * 60_000 - for (let index = 0; index < STANDARD_NODES.length; index += 1) { + const pickupReadyIndex = STANDARD_NODES.indexOf('PICKUP_READY') + for (let index = 0; index <= pickupReadyIndex; index += 1) { const standardNode = STANDARD_NODES[index] const actualAt = localIso(new Date(baseActual + index * 60_000)) const body = manualNodePayload({ expectedVersion: version, standardNode, @@ -214,6 +215,67 @@ export async function run(context) { detail = updated } + const pickupTask = detail.query.query + const actualNodes = new Set(detail.nodes.filter((item) => item.actualAt != null).map((item) => item.standardNode)) + const expectedPickupNodes = STANDARD_NODES.slice(0, pickupReadyIndex + 1) + context.assert(MODULE, 'PDA开始前已覆盖至PICKUP_READY且任务尚未停止', detailResponse, + expectedPickupNodes.every((node) => actualNodes.has(node)) && pickupTask.status !== 'STOPPED' + && pickupTask.latestActualNode === 'PICKUP_READY', + `actualNodes=${[...actualNodes].join(',')}, status=${pickupTask.status}`, OPTIONS) + context.createdResources.phase4CarrierPickupReady = { + marker: context.marker, queryId, version: Number(pickupTask.version), + latestActualAt: pickupTask.latestActualAt, ready: true, + } +} + +export async function run(context) { + let pickup = context.createdResources?.phase4CarrierPickupReady + if (pickup?.marker !== context.marker || pickup?.ready !== true) { + await runUntilPickupReady(context) + pickup = context.createdResources?.phase4CarrierPickupReady + } + if (!pickup?.queryId) return + + const queryId = id(pickup.queryId) + const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`) + let detail = context.apiSuccess(MODULE, 'PDA收货完成后恢复本轮船节点任务', detailResponse, + `queryId=${queryId}`, OPTIONS) + assertDetail(context, detailResponse, detail, '恢复任务详情DTO完整') + const pickupTask = detail?.query?.query + if (pickupTask?.latestActualNode !== 'PICKUP_READY' || pickupTask?.status === 'STOPPED') { + blocker(context, 'PDA收货后船节点未停留在PICKUP_READY,禁止伪造后续节点', + `latest=${pickupTask?.latestActualNode || 'missing'}, status=${pickupTask?.status || 'missing'}`, detailResponse) + return + } + + let version = Number(pickupTask.version) + const frozenLatestActualMs = Date.parse(`${pickupTask.latestActualAt}Z`) + const baseActual = (Number.isNaN(frozenLatestActualMs) ? Date.now() : frozenLatestActualMs) + 60 * 60_000 + const remainingNodes = STANDARD_NODES.slice(STANDARD_NODES.indexOf('PICKED_UP')) + for (let index = 0; index < remainingNodes.length; index += 1) { + const standardNode = remainingNodes[index] + const actualAt = localIso(new Date(baseActual + index * 60_000)) + const body = manualNodePayload({ expectedVersion: version, standardNode, + observedAt: localIso(new Date()), actualAt, marker: context.marker, + index: STANDARD_NODES.indexOf(standardNode) + 1 }) + const key = `${context.marker}-carrier-actual-${String(STANDARD_NODES.indexOf(standardNode) + 1).padStart(2, '0')}`.slice(0, 128) + const response = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, body, + context.token, { 'Idempotency-Key': key }) + const updated = context.apiSuccess(MODULE, `PDA完成后补录实际节点${standardNode}`, response, + `queryId=${queryId}`, OPTIONS) + assertDetail(context, response, updated, `实际节点${standardNode}响应DTO完整`) + const updatedTask = updated?.query?.query + const isEmptyReturned = standardNode === 'EMPTY_RETURNED' + context.assert(MODULE, `${standardNode}实际事实单调推进且停止语义正确`, response, + updatedTask?.latestActualNode === standardNode && updatedTask?.latestActualAt === actualAt + && (isEmptyReturned + ? updatedTask.status === 'STOPPED' && updatedTask.stopReason === '已还柜' && updatedTask.stoppedAt != null + : updatedTask.status !== 'STOPPED'), + `status=${updatedTask?.status || 'missing'}, latest=${updatedTask?.latestActualNode || 'none'}`, OPTIONS) + version = Number(updatedTask.version) + detail = updated + } + const finalTask = detail.query.query const actualNodes = new Set(detail.nodes.filter((item) => item.actualAt != null).map((item) => item.standardNode)) context.assert(MODULE, '最终详情覆盖十标准实际节点且只有EMPTY_RETURNED触发停止', detailResponse, diff --git a/scripts/admin-test-env-modules/phase4-carrier-pickup-ready.mjs b/scripts/admin-test-env-modules/phase4-carrier-pickup-ready.mjs new file mode 100644 index 000000000..1201dc813 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-carrier-pickup-ready.mjs @@ -0,0 +1,5 @@ +import { runUntilPickupReady } from './phase4-carrier-nodes.mjs' + +export async function run(context) { + await runUntilPickupReady(context) +} From 45a3b9adfdf3f2adc5197879440d8c4d3ba35042 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 10:50:58 +0800 Subject: [PATCH 086/115] test: verify pda warehouse queries after receiving --- .../phase4-overseas-pda-contract.test.mjs | 5 ++ .../phase4-overseas-pda.mjs | 46 ++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 87c2addb0..6b0e61040 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -24,6 +24,9 @@ test('PDA module covers the container-number receiving contract and excludes out for (const token of ['/candidates', '/prepare', '/current/unload-photos/files', '/current/sign', '/current/skus/resolve', '/current/cartons/resolve', '/current/pallets/resolve', '/current/receipts/bulk', '/current/receipts/carton', '/current/complete']) assert.ok(source.includes(token), token) + for (const token of ['/inventory/resolve', '/locations/resolve', '/pallets/resolve', '/operations?page=1&size=50']) { + assert.ok(source.includes(token), token) + } for (const role of ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN']) assert.ok(source.includes(role), role) for (const forbidden of ['/outbound', '/dispatch', '/delivery', '/internal/']) assert.equal(source.includes(forbidden), false, forbidden) assert.match(source, /同键同载荷重放/) @@ -50,6 +53,8 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /last-leg\/orders\?page=1&size=200&containerNo=/) assert.match(source, /phase4OverseasPda\.orderId = id\(order\.id\)/) assert.match(source, /phase4OverseasPda\.customerId = id\(order\.customerId\)/) + assert.match(source, /现场库存查询只返回Token绑定仓且包含库存位置/) + assert.match(source, /作业历史包含本轮柜号收货动作且分页稳定/) }) test('missing WAREHOUSE_PICKER overseas authority is a deployment blocker', async () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 9a57ec520..b9edf6c8f 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -117,8 +117,9 @@ async function createPallet(context, inbound, index) { const warehouseId = id(inbound?.overseasWarehouseId) if (!longId(warehouseId)) return null const suffix = markerSuffix(context) + const locationCode = `PDA-${suffix}-${index + 1}`.slice(0, 64) const location = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { - zoneCode: `PDA-${index + 1}`, locationCode: `PDA-${suffix}-${index + 1}`.slice(0, 64), + zoneCode: `PDA-${index + 1}`, locationCode, locationType: 'STORAGE', maxLoadKg: 5000, status: 'IDLE', remark: `${context.marker}-receiving`, }) context.addStep(MODULE, `创建当前柜实收库位${index + 1}`, location, success(location), trace(location), OPTIONS) @@ -133,7 +134,44 @@ async function createPallet(context, inbound, index) { }) context.addStep(MODULE, `创建当前柜实收托盘${index + 1}`, pallet, success(pallet), trace(pallet), OPTIONS) const palletId = id(pallet?.body?.data) - return longId(palletId) ? { warehouseId, locationId, palletId, palletCode, retained: true } : null + return longId(palletId) ? { warehouseId, locationId, locationCode, palletId, palletCode, retained: true } : null +} + +async function verifyWarehouseQueries(context, token, inboundCode, pallet) { + const inventory = await pda(context, token, 'POST', '完成入库后按入仓码现场查询库存', + '/api/pda/v1/last-leg/inventory/resolve', { code: inboundCode }) + context.assert(MODULE, '现场库存查询只返回Token绑定仓且包含库存位置', inventory.response, + success(inventory.response) && inventory.data?.queryCode === inboundCode + && Array.isArray(inventory.data?.items) && inventory.data.items.length > 0 + && inventory.data.items.every((item) => id(item?.palletId) === pallet.palletId + && id(item?.locationId) === pallet.locationId && Number(item?.availablePackages) > 0), + `items=${inventory.data?.items?.length ?? 'missing'}; ${trace(inventory.response)}`, OPTIONS) + + const location = await pda(context, token, 'POST', '完成入库后扫码查询库位', + '/api/pda/v1/last-leg/locations/resolve', { code: pallet.locationCode }) + context.assert(MODULE, '库位查询返回当前仓真实占用汇总', location.response, + success(location.response) && id(location.data?.locationId) === pallet.locationId + && location.data?.locationCode === pallet.locationCode + && Number(location.data?.occupiedPackages) > 0 && Number(location.data?.inventoryPositionCount) > 0, + `locationId=${id(location.data?.locationId) || 'missing'}; ${trace(location.response)}`, OPTIONS) + + const resolvedPallet = await pda(context, token, 'POST', '完成入库后扫码查询托盘内容', + '/api/pda/v1/last-leg/pallets/resolve', { code: pallet.palletCode }) + context.assert(MODULE, '托盘查询返回当前位置与库存明细', resolvedPallet.response, + success(resolvedPallet.response) && id(resolvedPallet.data?.palletId) === pallet.palletId + && id(resolvedPallet.data?.locationId) === pallet.locationId + && Array.isArray(resolvedPallet.data?.inventory) && resolvedPallet.data.inventory.length > 0 + && Number(resolvedPallet.data?.occupiedPackages) > 0, + `palletId=${id(resolvedPallet.data?.palletId) || 'missing'}; ${trace(resolvedPallet.response)}`, OPTIONS) + + const history = await pda(context, token, 'GET', '查询当前仓管PDA作业历史', + '/api/pda/v1/last-leg/operations?page=1&size=50') + const historyRows = records(history.data) + context.assert(MODULE, '作业历史包含本轮柜号收货动作且分页稳定', history.response, + success(history.response) && historyRows.length > 0 + && historyRows.some((item) => String(item?.operationType || '').startsWith('RECEIVING_')) + && Number(history.data?.page) === 1 && Number(history.data?.size) === 50, + `records=${historyRows.length}; ${trace(history.response)}`, OPTIONS) } async function uploadPhotos(context, token) { @@ -395,6 +433,10 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') { await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId) + const firstInbound = current.data.inbounds.find((item) => pallets.has(item.inboundCode)) + if (firstInbound) { + await verifyWarehouseQueries(context, token, firstInbound.inboundCode, pallets.get(firstInbound.inboundCode)) + } } } From 042d0b738a91390f35a2619f9f445e81aad62ed8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 11:20:36 +0800 Subject: [PATCH 087/115] feat: complete overseas pda warehouse operations --- deploy/sql/海外区域统一增量-MySQL8.sql | 152 ++++++ .../service/InternalBusinessFileService.java | 3 +- .../InternalBusinessFileServiceTest.java | 16 + lastleg-service/AGENTS.md | 1 + .../PdaPalletOperationController.java | 58 ++ .../dto/PalletOperationRequests.java | 42 ++ .../dto/PalletOperationResponses.java | 23 + .../palletops/entity/PdaPalletOperation.java | 25 + .../gateway/HttpPalletPhysicalGateway.java | 75 +++ .../gateway/PalletPhysicalGateway.java | 16 + .../mapper/PdaPalletOperationMapper.java | 45 ++ .../palletops/model/PalletInventoryRow.java | 27 + .../service/PdaPalletOperationService.java | 506 ++++++++++++++++++ .../PdaInventoryMoveController.java | 30 ++ .../pda/move/dto/PdaInventoryMoveRequest.java | 13 + .../move/dto/PdaInventoryMoveResponse.java | 26 + .../pda/move/entity/PdaInventoryMove.java | 35 ++ .../move/mapper/PdaInventoryMoveMapper.java | 25 + .../move/service/PdaInventoryMoveService.java | 192 +++++++ .../controller/PdaStocktakeController.java | 61 +++ .../stocktake/dto/PdaStocktakeRequests.java | 28 + .../stocktake/dto/PdaStocktakeResponses.java | 20 + .../stocktake/entity/PdaStocktakeAction.java | 24 + .../stocktake/entity/PdaStocktakeItem.java | 35 ++ .../stocktake/entity/PdaStocktakeTask.java | 32 ++ .../mapper/PdaStocktakeActionMapper.java | 15 + .../mapper/PdaStocktakeInventoryMapper.java | 20 + .../mapper/PdaStocktakeItemMapper.java | 66 +++ .../mapper/PdaStocktakeTaskMapper.java | 51 ++ .../stocktake/model/StocktakeCandidate.java | 19 + .../service/PdaStocktakeService.java | 323 +++++++++++ .../pda/PdaRelabelMapperConfiguration.java | 9 + .../pda/controller/PdaRelabelController.java | 127 +++++ .../relabel/pda/dto/PdaRelabelRequests.java | 45 ++ .../relabel/pda/dto/PdaRelabelResponses.java | 37 ++ .../relabel/pda/entity/PdaRelabelHistory.java | 24 + .../pda/entity/PdaRelabelOperation.java | 22 + .../relabel/pda/entity/PdaRelabelTask.java | 37 ++ .../pda/entity/PdaRelabelUnitFact.java | 26 + .../gateway/HttpRelabelPhotoFileGateway.java | 109 ++++ .../pda/gateway/RelabelPhotoFileGateway.java | 10 + .../pda/mapper/PdaRelabelHistoryMapper.java | 9 + .../pda/mapper/PdaRelabelOperationMapper.java | 13 + .../pda/mapper/PdaRelabelTaskMapper.java | 30 ++ .../pda/mapper/PdaRelabelUnitFactMapper.java | 13 + .../pda/service/PdaRelabelService.java | 480 +++++++++++++++++ .../src/main/resources/db/lastleg.sql | 150 ++++++ .../openapi/overseas-pda-operations-v1.yaml | 256 +++++++++ .../HttpWarehouseResourceGatewayTest.java | 1 + ...PalletOperationControllerContractTest.java | 27 + .../PdaPalletOperationServiceTest.java | 124 +++++ .../PdaInventoryOperationContractTest.java | 41 ++ .../pda/PdaOperationsOpenApiContractTest.java | 83 +++ .../service/PdaInventoryMoveServiceTest.java | 141 +++++ .../service/PdaStocktakeServiceTest.java | 146 +++++ .../controller/PdaRelabelContractTest.java | 86 +++ .../pda/service/PdaRelabelServiceTest.java | 245 +++++++++ scripts/db/incremental.sql | 35 ++ ...InternalPdaPalletTransitionController.java | 39 ++ .../InternalWarehousePalletController.java | 1 + .../dto/PalletTransitionRequests.java | 20 + .../dto/PalletTransitionResponse.java | 16 + .../WarehousePalletOperationEntity.java | 29 + .../mapper/WarehousePalletMapper.java | 3 +- .../WarehousePalletTransitionMapper.java | 32 ++ .../WarehousePalletTransitionService.java | 106 ++++ .../src/main/resources/db/warehouse.sql | 14 + .../SchemaChineseCommentContractTest.java | 2 +- .../WarehousePalletTransitionServiceTest.java | 91 ++++ 69 files changed, 4679 insertions(+), 4 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/entity/PdaPalletOperation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/model/PalletInventoryRow.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveRequest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveResponse.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/entity/PdaInventoryMove.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/mapper/PdaInventoryMoveMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeAction.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeItem.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeTask.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeActionMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeInventoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeTaskMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/model/StocktakeCandidate.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/PdaRelabelMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelHistory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelOperation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelTask.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelUnitFact.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/RelabelPhotoFileGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelHistoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelOperationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelUnitFactMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java create mode 100644 lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionRequests.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionResponse.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 4748e0dba..a64450395 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -677,6 +677,125 @@ CREATE TABLE IF NOT EXISTS ll_pda_operation_history ( KEY idx_ll_pda_history_business(overseas_warehouse_id,operation_type,business_key) ) ENGINE=InnoDB COMMENT='海外PDA真实作业历史'; +CREATE TABLE IF NOT EXISTS ll_pda_pallet_operation ( + id BIGINT NOT NULL COMMENT 'PDA托盘作业ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '设备幂等键', + operation_type VARCHAR(64) NOT NULL COMMENT '托盘作业类型', request_hash VARCHAR(128) NOT NULL COMMENT '请求内容摘要', + status VARCHAR(32) NOT NULL COMMENT '处理状态:PROCESSING、COMPLETED、FAILED_COMPENSATED、COMPENSATION_REQUIRED', + result_json LONGTEXT NULL COMMENT '成功响应快照', remote_transitions_json LONGTEXT NULL COMMENT '实物托盘状态变更回执', + last_error VARCHAR(2000) 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 '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_pallet_idempotency(operator_key,idempotency_key), + KEY idx_ll_pda_pallet_warehouse(overseas_warehouse_id,status,updated_at) +) ENGINE=InnoDB COMMENT='海外PDA托盘作业幂等、补偿与结果记录'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_task ( + id BIGINT NOT NULL COMMENT '盘点任务ID', task_no VARCHAR(64) NOT NULL COMMENT '盘点任务号', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', scope_type VARCHAR(16) NOT NULL COMMENT '盘点范围类型LOCATION或PALLET', + scope_resource_id BIGINT NOT NULL COMMENT '盘点库位或托盘资源ID', scope_code VARCHAR(128) NOT NULL COMMENT '盘点库位码或托盘码快照', + status VARCHAR(32) NOT NULL COMMENT '盘点任务状态', position_count INT NOT NULL COMMENT '库存位置总数', + counted_count INT NOT NULL DEFAULT 0 COMMENT '已盘库存位置数', difference_count INT NOT NULL DEFAULT 0 COMMENT '存在差异的库存位置数', + created_by VARCHAR(128) NOT NULL COMMENT '创建操作账号标识', submitted_by VARCHAR(128) NULL COMMENT '提交操作账号标识', + submit_reason VARCHAR(500) NULL COMMENT '盘点差异提交原因', idempotency_key VARCHAR(128) NOT NULL COMMENT '创建幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '创建请求哈希', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + submitted_at DATETIME(3) NULL COMMENT '提交完成时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_no(task_no), + UNIQUE KEY uk_ll_pda_stocktake_create(overseas_warehouse_id,created_by,idempotency_key), + KEY idx_ll_pda_stocktake_list(overseas_warehouse_id,status,created_at) +) ENGINE=InnoDB COMMENT='海外PDA库存盘点任务'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_item ( + id BIGINT NOT NULL COMMENT '盘点明细ID', task_id BIGINT NOT NULL COMMENT '盘点任务ID', customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户标识', + balance_id BIGINT NOT NULL COMMENT '库存余额ID', position_id BIGINT NOT NULL COMMENT '库存位置ID', sku_code VARCHAR(128) NULL COMMENT 'SKU快照', + product_name VARCHAR(255) NULL COMMENT '品名快照', inbound_code VARCHAR(128) NULL COMMENT '入仓码快照', pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', + pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', location_id BIGINT NOT NULL COMMENT '库位ID快照', location_code VARCHAR(128) NOT NULL COMMENT '库位码快照', + book_packages BIGINT NOT NULL COMMENT '任务创建时账面总箱数', actual_packages BIGINT NULL COMMENT '现场实盘总箱数', + difference_packages BIGINT NULL COMMENT '实盘与账面箱数差异', status VARCHAR(32) NOT NULL COMMENT '盘点明细状态', + position_version BIGINT NOT NULL COMMENT '任务创建时库存位置版本', counted_by VARCHAR(128) NULL COMMENT '盘点操作账号标识', + version BIGINT NOT NULL DEFAULT 0 COMMENT '明细乐观锁版本', counted_at DATETIME(3) NULL COMMENT '盘点时间', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_item(task_id,position_id), KEY idx_ll_pda_stocktake_item_task(task_id,status,id) +) ENGINE=InnoDB COMMENT='海外PDA库存盘点位置明细'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_action ( + id BIGINT NOT NULL COMMENT '盘点动作ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '动作幂等键', + action_type VARCHAR(32) NOT NULL COMMENT '盘点动作类型', task_id BIGINT NULL COMMENT '盘点任务ID', request_hash VARCHAR(128) NOT NULL COMMENT '动作请求哈希', + status VARCHAR(32) NOT NULL COMMENT '动作处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_pda_stocktake_action(overseas_warehouse_id,operator_key,idempotency_key), KEY idx_ll_pda_stocktake_action_task(task_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA盘点动作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_pda_inventory_move ( + id BIGINT NOT NULL COMMENT '移库操作ID', move_no VARCHAR(64) NOT NULL COMMENT '移库操作单号', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', pallet_id BIGINT NOT NULL COMMENT '整托移库的实物托盘ID', + pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', source_location_id BIGINT NOT NULL COMMENT '来源库位ID', + source_location_code VARCHAR(128) NOT NULL COMMENT '来源库位码快照', target_location_id BIGINT NOT NULL COMMENT '目标库位ID', + target_location_code VARCHAR(128) NOT NULL COMMENT '目标库位码快照', customer_count INT NOT NULL COMMENT '托盘涉及客户数', + position_count INT NOT NULL COMMENT '迁移库存位置数', package_count BIGINT NOT NULL COMMENT '迁移总箱数', + pallet_version_before INT NOT NULL COMMENT '迁移前托盘版本', pallet_version_after INT NULL COMMENT '迁移后托盘版本', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', reason VARCHAR(500) NOT NULL COMMENT '移库原因', + idempotency_key VARCHAR(128) NOT NULL COMMENT '移库幂等键', request_hash VARCHAR(128) NOT NULL COMMENT '移库请求哈希', + status VARCHAR(32) NOT NULL COMMENT '移库处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', completed_at DATETIME(3) NULL COMMENT '完成时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_pda_inventory_move_no(move_no), + UNIQUE KEY uk_ll_pda_inventory_move_key(overseas_warehouse_id,operator_key,idempotency_key), + KEY idx_ll_pda_inventory_move_pallet(overseas_warehouse_id,pallet_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA同仓整托移库记录'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_task ( + id BIGINT NOT NULL COMMENT '换标PDA任务ID', application_id BIGINT NOT NULL COMMENT '换标申请单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', relabel_no VARCHAR(64) NOT NULL COMMENT '换标申请单号快照', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'PDA令牌冻结的海外实际作业仓ID', + status VARCHAR(32) NOT NULL COMMENT '任务状态:PROCESSING、WAIT_REVIEW或COMPLETED', + operator_key VARCHAR(128) NOT NULL COMMENT '认领操作账号标识', operator_name VARCHAR(255) NOT NULL COMMENT '认领操作员名称快照', + reviewer_key VARCHAR(128) NULL COMMENT '复核账号标识', reviewer_name VARCHAR(255) NULL COMMENT '复核员名称快照', + expected_packages INT NOT NULL COMMENT '申请单通知的应换标箱数', relabelled_packages INT NOT NULL DEFAULT 0 COMMENT '已正常换标箱数', + skipped_packages INT NOT NULL DEFAULT 0 COMMENT '已说明原因跳过箱数', exception_packages INT NOT NULL DEFAULT 0 COMMENT '已登记异常箱数', + completion_photo_file_ids JSON NULL COMMENT '换标完成现场照片文件ID列表', completion_remark VARCHAR(1000) NULL COMMENT '完成提交备注', + review_decision VARCHAR(16) NULL COMMENT '最近复核结论:APPROVE或REWORK', review_remark VARCHAR(1000) NULL COMMENT '最近复核备注', + claimed_at DATETIME(3) NOT NULL COMMENT '任务认领时间', submitted_at DATETIME(3) NULL COMMENT '提交复核时间', + reviewed_at DATETIME(3) NULL COMMENT '最近复核时间', version INT NOT NULL DEFAULT 0 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_ll_relabel_pda_task_application (customer_id,application_id), + UNIQUE KEY uk_ll_relabel_pda_task_no (relabel_no), KEY idx_ll_relabel_pda_task_warehouse (overseas_warehouse_id,status,updated_at), + KEY idx_ll_relabel_pda_task_operator (operator_key,status,updated_at) +) ENGINE=InnoDB COMMENT='海外PDA换标现场作业任务'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_unit_fact ( + id BIGINT NOT NULL COMMENT '逐件换标事实ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + application_id BIGINT NOT NULL COMMENT '换标申请单ID', item_id BIGINT NOT NULL COMMENT '换标申请明细ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', old_label VARCHAR(255) NULL COMMENT '扫描的实物旧标签或箱标', + new_label VARCHAR(255) NULL COMMENT '扫描的换标后新标签', result VARCHAR(32) NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION', + exception_code VARCHAR(32) NULL COMMENT '异常编码', reason VARCHAR(1000) NULL COMMENT '跳过或异常原因', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识', operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照', + occurred_at DATETIME(3) NOT NULL COMMENT '逐件事实发生时间', PRIMARY KEY (id), + UNIQUE KEY uk_ll_relabel_pda_old_label (old_label), KEY idx_ll_relabel_pda_unit_task (customer_id,task_id,occurred_at,id), + KEY idx_ll_relabel_pda_unit_item (customer_id,application_id,item_id) +) ENGINE=InnoDB COMMENT='海外PDA逐件换标扫码与异常事实'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_operation ( + id BIGINT NOT NULL COMMENT '换标PDA幂等操作ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', operator_key VARCHAR(128) NOT NULL COMMENT '请求操作账号标识', + action VARCHAR(32) NOT NULL COMMENT '幂等操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '设备生成的幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', result_json LONGTEXT NOT NULL COMMENT '成功响应快照JSON', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + UNIQUE KEY uk_ll_relabel_pda_operation_key (operator_key,idempotency_key), + KEY idx_ll_relabel_pda_operation_task (customer_id,task_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA换标写操作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_history ( + id BIGINT NOT NULL COMMENT '换标PDA任务历史ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + application_id BIGINT NOT NULL COMMENT '换标申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + action VARCHAR(32) NOT NULL COMMENT '现场动作类型', from_status VARCHAR(32) NULL COMMENT '动作前任务状态', + to_status VARCHAR(32) NOT NULL COMMENT '动作后任务状态', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识', + operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照', detail_json LONGTEXT NULL COMMENT '动作详情JSON', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + KEY idx_ll_relabel_pda_history_task (customer_id,task_id,created_at,id), + KEY idx_ll_relabel_pda_history_application (customer_id,application_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA换标任务不可变操作历史'; + 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_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER request_hash')); @@ -689,6 +808,20 @@ PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt -- Warehouse Overseas 与 Lastleg 使用同一海外区域发布入口但保持独立数据库。 -- 公共托盘详情实体读取 fba_warehouse_code;旧库缺列时创建可成功但详情会 S0500。 USE baiyue_lastlag_warehouse; +CREATE TABLE IF NOT EXISTS wh_pallet_operation ( + id BIGINT NOT NULL COMMENT '托盘状态操作ID', operation_key VARCHAR(128) NOT NULL COMMENT '跨服务幂等操作键', + request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', + pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', + before_status VARCHAR(20) NOT NULL COMMENT '变更前托盘状态', after_status VARCHAR(20) NOT NULL COMMENT '变更后托盘状态', + before_location_id BIGINT NOT NULL COMMENT '变更前库位ID', after_location_id BIGINT NOT NULL COMMENT '变更后库位ID', + before_version INT NOT NULL COMMENT '变更前托盘版本', after_version INT NOT NULL COMMENT '变更后托盘版本', + operation_status VARCHAR(32) NOT NULL COMMENT '操作状态:APPLIED已应用、COMPENSATED已补偿', + reason VARCHAR(500) 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 '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_operation_key (operation_key), + KEY idx_wh_pallet_operation_pallet (warehouse_id,pallet_id,created_at) +) ENGINE=InnoDB COMMENT='实物托盘状态幂等变更与补偿记录'; + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='fba_warehouse_code'), 'SELECT 1', @@ -713,4 +846,23 @@ CALL assert_phase4_warehouse_pallet_ready(); DROP PROCEDURE assert_phase4_warehouse_pallet_ready; USE baiyue_lastleg; +DROP PROCEDURE IF EXISTS assert_phase4_pda_operations_ready; +DELIMITER $$ +CREATE PROCEDURE assert_phase4_pda_operations_ready() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_lastleg' AND table_name IN ( + 'll_pda_pallet_operation','ll_pda_stocktake_task','ll_pda_stocktake_item', + 'll_pda_stocktake_action','ll_pda_inventory_move','ll_relabel_pda_task', + 'll_relabel_pda_unit_fact','ll_relabel_pda_operation','ll_relabel_pda_history')) <> 9 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA盘点、移库、托盘或换标表未完整部署'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_lastlag_warehouse' AND table_name='wh_pallet_operation') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA实物托盘操作表未部署'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase4_pda_operations_ready(); +DROP PROCEDURE assert_phase4_pda_operations_ready; SELECT 'PASS' AS migration_status, '海外仓报价、月度汇率、车辆、海外业务、入仓库存与海外仓托盘结构已就绪' AS message; diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java index d320f4aaa..86268a298 100644 --- a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -23,7 +23,8 @@ public class InternalBusinessFileService { "LOADING_CUSTOMS_DOCUMENT", "CONTAINER_DELIVERY_ORDER", "CONTAINER_PICKUP_ORDER", - "OVERSEAS_UNLOAD_PHOTO"); + "OVERSEAS_UNLOAD_PHOTO", + "OVERSEAS_RELABEL_PHOTO"); @Resource private FileObjectMapper files; @Resource private BusinessFileStorage storage; diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index 7b8c7bcd6..f0cc94a06 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -80,6 +80,22 @@ class InternalBusinessFileServiceTest { verifyNoInteractions(files, storage); } + @Test void overseasRelabelPhotoPersistsExactTaskOwnership() { + MockMultipartFile file = new MockMultipartFile("file", "relabel.jpg", "image/jpeg", "photo".getBytes()); + when(storage.storeLoading(100L, 81L, file)).thenReturn(new BusinessFileStorage.Stored( + "business-files/100/81/relabel.jpg", "image/jpeg", file.getSize(), "d".repeat(64), "relabel.jpg")); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { + ((FileObjectEntity) invocation.getArgument(0)).setId(91L); return 1; + }); + + var result = service.upload(100L, "OVERSEAS_RELABEL_PHOTO", "81", 7L, file); + + assertThat(result).extracting(InternalBusinessFileService.Metadata::tenantId, + InternalBusinessFileService.Metadata::businessType, + InternalBusinessFileService.Metadata::businessId) + .containsExactly(100L, "OVERSEAS_RELABEL_PHOTO", "81"); + } + @Test void unsupportedBusinessTypeFailsClosed() { assertThatThrownBy(() -> service.upload(10001L, "ARBITRARY", "41", 7L, new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes()))) diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index d2853f74d..7adcf9f70 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -26,6 +26,7 @@ | PDA 卸柜照片文件上传与归属校验 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO` | | 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | | 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | +| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,不含出库和派送 | | 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,范围开关与禁用写操作见“四期开发范围路由” | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` | | 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java new file mode 100644 index 000000000..def4fce41 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.palletops.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.palletops.dto.PalletOperationRequests; +import com.baiyue.lastleg.palletops.dto.PalletOperationResponses; +import com.baiyue.lastleg.palletops.service.PdaPalletOperationService; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/pda/v1/last-leg/pallet-operations") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')") +public class PdaPalletOperationController { + @Resource private PdaPalletOperationService service; + + @PostMapping("/pallets/resolve") + public ApiResponse resolve( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.Scan request) { + return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request)); + } + + @PostMapping("/assemble") + public ApiResponse assemble( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.Assemble request) { + return ApiResponse.success(service.assemble(PdaWarehouseIdentity.require(user), request)); + } + + @PostMapping("/disassemble") + public ApiResponse disassemble( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.Disassemble request) { + return ApiResponse.success(service.disassemble(PdaWarehouseIdentity.require(user), request)); + } + + @PostMapping("/swap") + public ApiResponse swap( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.Swap request) { + return ApiResponse.success(service.swap(PdaWarehouseIdentity.require(user), request)); + } + + @PostMapping("/unbind") + public ApiResponse unbind( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.Unbind request) { + return ApiResponse.success(service.unbind(PdaWarehouseIdentity.require(user), request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java new file mode 100644 index 000000000..1b804c6ca --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.palletops.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public final class PalletOperationRequests { + private PalletOperationRequests() { } + + public record Scan(@NotBlank @Size(max = 128) String code) { } + public record PalletExpectation(@NotBlank @Size(max = 128) String palletCode, + @PositiveOrZero int expectedVersion) { } + public record MoveLine(@Positive long positionId, @Positive long packages, + @PositiveOrZero long expectedPositionVersion) { } + public record TargetMove(@Valid PalletExpectation target, + @NotEmpty @Size(max = 200) List<@Valid MoveLine> lines) { } + + public record Assemble(@NotBlank @Size(max = 128) String idempotencyKey, + @Valid PalletExpectation target, + @NotEmpty @Size(max = 20) List<@Valid PalletExpectation> sources, + @NotEmpty @Size(max = 200) List<@Valid MoveLine> lines, + @Size(max = 500) String reason) { } + + public record Disassemble(@NotBlank @Size(max = 128) String idempotencyKey, + @Valid PalletExpectation source, + @NotEmpty @Size(max = 20) List<@Valid TargetMove> targets, + @Size(max = 500) String reason) { } + + public record Swap(@NotBlank @Size(max = 128) String idempotencyKey, + @Valid PalletExpectation source, + @Valid PalletExpectation target, + @Size(max = 500) String reason) { } + + public record Unbind(@NotBlank @Size(max = 128) String idempotencyKey, + @Valid PalletExpectation source, + @Size(max = 500) String reason) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java new file mode 100644 index 000000000..c0cd61ce7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.palletops.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class PalletOperationResponses { + private PalletOperationResponses() { } + + public record InventoryLine(String customerId, String customerName, long balanceId, long positionId, + String inboundCode, String skuCode, String productName, + long availablePackages, long holdPackages, long allocatedPackages, + long pickedPackages, long receivingPackages, long positionVersion) { } + public record PalletContent(long palletId, String palletCode, String status, int palletVersion, + long locationId, String locationCode, String destinationWarehouseCode, + long totalPackages, List inventory) { } + public record Operation(long operationId, String operationType, String idempotencyKey, + String status, boolean replayed, LocalDateTime completedAt, + List pallets) { + public Operation asReplay() { + return new Operation(operationId, operationType, idempotencyKey, status, true, completedAt, pallets); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/entity/PdaPalletOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/entity/PdaPalletOperation.java new file mode 100644 index 000000000..7d4e1a9be --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/entity/PdaPalletOperation.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.palletops.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_pda_pallet_operation") +public class PdaPalletOperation { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private Long overseasWarehouseId; + private String operatorKey; + private String idempotencyKey; + private String operationType; + private String requestHash; + private String status; + private String resultJson; + private String remoteTransitionsJson; + private String lastError; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java new file mode 100644 index 000000000..c93045fd4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java @@ -0,0 +1,75 @@ +package com.baiyue.lastleg.palletops.gateway; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +public class HttpPalletPhysicalGateway implements PalletPhysicalGateway { + @Resource private RestClient.Builder restClientBuilder; + @Value("${baiyue.warehouse.base-url:http://127.0.0.1:9305}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + private RestClient client; + + @PostConstruct void initialize() { client = restClientBuilder.baseUrl(baseUrl).build(); } + + @Override + public PalletReference resolve(String palletCode) { + ResolveEnvelope response = client.get().uri("/internal/v1/warehouse-pallets/resolve/{code}", palletCode) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalArgumentException("Warehouse rejected pallet scan: HTTP " + result.getStatusCode()); + }).body(ResolveEnvelope.class); + if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned no pallet"); + return response.data(); + } + + @Override + public LocationReference resolveLocation(long warehouseId, long locationId) { + LocationEnvelope response = client.get().uri(uri -> uri + .path("/internal/v1/warehouse-resource-locks/locations/{locationId}") + .queryParam("warehouseId", warehouseId).build(locationId)) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalArgumentException("Warehouse rejected location lookup: HTTP " + result.getStatusCode()); + }).body(LocationEnvelope.class); + if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned no location"); + return response.data(); + } + + @Override + public Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId, + String operationKey, String reason) { + Map body = new LinkedHashMap<>(); + body.put("warehouseId", warehouseId); body.put("expectedVersion", pallet.version()); + body.put("targetStatus", targetStatus); body.put("targetLocationId", targetLocationId); + body.put("operationKey", operationKey); if (reason != null) body.put("reason", reason); + TransitionEnvelope response = client.post().uri("/internal/v1/pda-pallet-transitions/{id}", pallet.palletId()) + .header("X-Internal-Service-Token", internalToken).body(body).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalStateException("Warehouse rejected pallet transition: HTTP " + result.getStatusCode()); + }).body(TransitionEnvelope.class); + if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned no transition"); + return response.data(); + } + + @Override + public void compensate(long warehouseId, Transition transition) { + client.post().uri("/internal/v1/pda-pallet-transitions/{id}/{key}/compensate", + transition.palletId(), transition.operationKey()) + .header("X-Internal-Service-Token", internalToken).body(Map.of("warehouseId", warehouseId)) + .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalStateException("Warehouse rejected pallet compensation: HTTP " + result.getStatusCode()); + }).toBodilessEntity(); + } + + private record ResolveEnvelope(PalletReference data) { } + private record LocationEnvelope(LocationReference data) { } + private record TransitionEnvelope(Transition data) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java new file mode 100644 index 000000000..f00726b97 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.palletops.gateway; + +public interface PalletPhysicalGateway { + PalletReference resolve(String palletCode); + LocationReference resolveLocation(long warehouseId, long locationId); + Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId, + String operationKey, String reason); + void compensate(long warehouseId, Transition transition); + + record PalletReference(long palletId, String palletCode, long warehouseId, long locationId, + String status, String fbaWarehouseCode, int version) { } + record LocationReference(long locationId, String locationCode, String locationType, String status) { } + record Transition(String operationKey, long palletId, String palletCode, long warehouseId, + String beforeStatus, String afterStatus, long beforeLocationId, long afterLocationId, + int beforeVersion, int afterVersion, String operationStatus, boolean replayed) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java new file mode 100644 index 000000000..91be448d6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.palletops.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.palletops.entity.PdaPalletOperation; +import com.baiyue.lastleg.palletops.model.PalletInventoryRow; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface PdaPalletOperationMapper extends BaseMapper { + String CONTENT_SELECT = "SELECT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," + + "b.inbound_code,b.sku_code,b.product_name_zh,b.product_name_en,b.destination_warehouse_code," + + "p.pallet_id,p.pallet_no,p.batch_no,p.warehouse_location_id,p.warehouse_location_code," + + "p.available_packages,p.hold_packages,p.allocated_packages,p.picked_packages,p.receiving_packages,p.version " + + "FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=p.customer_id "; + + @Select("SELECT * FROM ll_pda_pallet_operation WHERE operator_key=#{operatorKey} " + + "AND idempotency_key=#{idempotencyKey} LIMIT 1") + PdaPalletOperation findOperation(@Param("operatorKey") String operatorKey, + @Param("idempotencyKey") String idempotencyKey); + + @Select(CONTENT_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " + + "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + + "ORDER BY b.customer_id,b.inbound_code,p.id") + List content(@Param("warehouseId") String warehouseId, + @Param("palletId") long palletId); + + @Select(CONTENT_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.id=#{positionId} " + + "LIMIT 1 FOR UPDATE") + PalletInventoryRow lockPosition(@Param("warehouseId") String warehouseId, + @Param("positionId") long positionId); + + @Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId} " + + "AND pallet_id=#{palletId} AND batch_no=#{batchNo} LIMIT 1 FOR UPDATE") + InventoryPosition lockTarget(@Param("customerId") String customerId, @Param("balanceId") long balanceId, + @Param("palletId") long palletId, @Param("batchNo") String batchNo); + + @Select("SELECT COALESCE(SUM(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages),0) " + + "FROM ll_inventory_position WHERE pallet_id=#{palletId} AND overseas_warehouse_id=#{warehouseId}") + long palletPackages(@Param("warehouseId") String warehouseId, @Param("palletId") long palletId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/model/PalletInventoryRow.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/model/PalletInventoryRow.java new file mode 100644 index 000000000..98aa0084c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/model/PalletInventoryRow.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.palletops.model; + +import lombok.Data; + +@Data +public class PalletInventoryRow { + private String customerId; + private String customerName; + private Long balanceId; + private Long positionId; + private String inboundCode; + private String skuCode; + private String productNameZh; + private String productNameEn; + private String destinationWarehouseCode; + private Long palletId; + private String palletNo; + private String batchNo; + private Long warehouseLocationId; + private String warehouseLocationCode; + private Long availablePackages; + private Long holdPackages; + private Long allocatedPackages; + private Long pickedPackages; + private Long receivingPackages; + private Long version; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java new file mode 100644 index 000000000..3bc77d10c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java @@ -0,0 +1,506 @@ +package com.baiyue.lastleg.palletops.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.palletops.dto.PalletOperationRequests; +import com.baiyue.lastleg.palletops.dto.PalletOperationResponses; +import com.baiyue.lastleg.palletops.entity.PdaPalletOperation; +import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; +import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper; +import com.baiyue.lastleg.palletops.model.PalletInventoryRow; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +@Service +public class PdaPalletOperationService { + @Resource private PdaPalletOperationMapper mapper; + @Resource private InventoryPositionMapper positions; + @Resource private InventoryBalanceMapper balances; + @Resource private InventoryLedgerMapper ledgers; + @Resource private PalletPhysicalGateway physical; + @Resource private PdaWarehouseService history; + @Resource private ObjectMapper objectMapper; + private TransactionTemplate transaction; + private TransactionTemplate newTransaction; + + @Resource + void configureTransactions(PlatformTransactionManager manager) { + transaction = new TransactionTemplate(manager); + DefaultTransactionDefinition definition = new DefaultTransactionDefinition(); + definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + newTransaction = new TransactionTemplate(manager, definition); + } + + public PalletOperationResponses.PalletContent resolve(PdaWarehouseIdentity identity, + PalletOperationRequests.Scan request) { + PalletPhysicalGateway.PalletReference pallet = requirePallet(identity.overseasWarehouseId(), request.code()); + return content(identity.overseasWarehouseId(), pallet); + } + + public PalletOperationResponses.Operation assemble(PdaWarehouseIdentity identity, + PalletOperationRequests.Assemble request) { + Map pallets = resolvePallets(identity.overseasWarehouseId(), + request.target(), request.sources()); + PalletPhysicalGateway.PalletReference target = pallets.get(code(request.target().palletCode())); + Map sources = new LinkedHashMap<>(); + request.sources().forEach(value -> sources.put(code(value.palletCode()), pallets.get(code(value.palletCode())))); + List moves = plannedLines(identity.overseasWarehouseId(), request.lines(), sources, + target, null); + requireCommonDestination(identity.overseasWarehouseId(), moves, List.of(target)); + return execute(identity, "PALLET_ASSEMBLE", request.idempotencyKey(), request, request.reason(), + plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false)); + } + + public PalletOperationResponses.Operation disassemble(PdaWarehouseIdentity identity, + PalletOperationRequests.Disassemble request) { + LinkedHashMap expectations = new LinkedHashMap<>(); + expectations.put(code(request.source().palletCode()), request.source()); + request.targets().forEach(value -> expectations.put(code(value.target().palletCode()), value.target())); + Map pallets = resolvePallets(identity.overseasWarehouseId(), + expectations.values()); + PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode())); + List moves = new ArrayList<>(); + for (PalletOperationRequests.TargetMove targetMove : request.targets()) { + PalletPhysicalGateway.PalletReference target = pallets.get(code(targetMove.target().palletCode())); + moves.addAll(plannedLines(identity.overseasWarehouseId(), targetMove.lines(), Map.of( + code(request.source().palletCode()), source), target, source)); + } + requireUniqueLines(moves); + List targets = request.targets().stream() + .map(value -> pallets.get(code(value.target().palletCode()))).toList(); + requireCommonDestination(identity.overseasWarehouseId(), moves, targets); + return execute(identity, "PALLET_DISASSEMBLE", request.idempotencyKey(), request, request.reason(), + plan(identity.overseasWarehouseId(), pallets, moves, + targets.stream().map(PalletPhysicalGateway.PalletReference::palletId).collect(java.util.stream.Collectors.toSet()), + false)); + } + + public PalletOperationResponses.Operation swap(PdaWarehouseIdentity identity, + PalletOperationRequests.Swap request) { + Map pallets = resolvePallets(identity.overseasWarehouseId(), + request.source(), List.of(request.target())); + PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode())); + PalletPhysicalGateway.PalletReference target = pallets.get(code(request.target().palletCode())); + if (!mapper.content(String.valueOf(identity.overseasWarehouseId()), target.palletId()).isEmpty()) { + throw conflict("换托目标托盘必须为空"); + } + List rows = nonEmptyContent(identity.overseasWarehouseId(), source.palletId()); + List moves = rows.stream().map(row -> { + requireMovable(row, value(row.getAvailablePackages())); + return new PlannedMove(row.getPositionId(), value(row.getAvailablePackages()), value(row.getVersion()), + source.palletId(), source.palletCode(), target.palletId(), target.palletCode(), target.locationId(), + locationCode(identity.overseasWarehouseId(), target.locationId())); + }).toList(); + return execute(identity, "PALLET_SWAP", request.idempotencyKey(), request, request.reason(), + plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false)); + } + + public PalletOperationResponses.Operation unbind(PdaWarehouseIdentity identity, + PalletOperationRequests.Unbind request) { + Map pallets = resolvePallets(identity.overseasWarehouseId(), + List.of(request.source())); + PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode())); + List rows = nonEmptyContent(identity.overseasWarehouseId(), source.palletId()); + List moves = rows.stream().map(row -> { + requireMovable(row, value(row.getAvailablePackages())); + return new PlannedMove(row.getPositionId(), value(row.getAvailablePackages()), value(row.getVersion()), + source.palletId(), source.palletCode(), 0L, "UNBOUND", source.locationId(), + locationCode(identity.overseasWarehouseId(), source.locationId())); + }).toList(); + return execute(identity, "PALLET_UNBIND", request.idempotencyKey(), request, request.reason(), + plan(identity.overseasWarehouseId(), pallets, moves, Set.of(), true)); + } + + private PalletOperationResponses.Operation execute(PdaWarehouseIdentity identity, String type, String key, + Object request, String reason, Plan plan) { + String normalizedKey = required(key, "幂等键不能为空"); + String requestHash = hash(request); + PdaPalletOperation operation = begin(identity, type, normalizedKey, requestHash); + if ("COMPLETED".equals(operation.getStatus())) return read(operation).asReplay(); + + List transitions = new ArrayList<>(); + try { + for (PalletChange change : plan.changes()) { + transitions.add(physical.apply(identity.overseasWarehouseId(), change.pallet(), change.targetStatus(), + change.targetLocationId(), transitionKey(identity.operatorKey(), normalizedKey, + change.pallet().palletId()), reason)); + } + saveTransitions(operation.getId(), transitions); + PalletOperationResponses.Operation result = transaction.execute(status -> + mutate(identity, operation, plan, normalizedKey, requestHash, reason)); + if (result == null) throw new IllegalStateException("托盘作业事务没有返回结果"); + return result; + } catch (RuntimeException failure) { + boolean compensated = compensate(identity.overseasWarehouseId(), transitions); + fail(operation.getId(), failure, compensated); + throw failure; + } + } + + private PalletOperationResponses.Operation mutate(PdaWarehouseIdentity identity, PdaPalletOperation operation, + Plan plan, String key, String requestHash, String reason) { + Set seen = new HashSet<>(); + LinkedHashSet customers = new LinkedHashSet<>(); + for (PlannedMove move : plan.moves()) { + if (!seen.add(move.positionId())) throw conflict("同一库存行不能在一次托盘作业中重复移动"); + PalletInventoryRow locked = mapper.lockPosition(String.valueOf(identity.overseasWarehouseId()), + move.positionId()); + if (locked == null || !Objects.equals(locked.getPalletId(), move.sourcePalletId())) { + throw conflict("库存所在托盘已变化,请重新扫码"); + } + if (value(locked.getVersion()) != move.expectedVersion()) throw conflict("库存版本已变化,请重新扫码"); + requireMovable(locked, move.packages()); + customers.add(locked.getCustomerId()); + moveInventory(identity, operation, locked, move, key, reason); + } + for (PalletChange change : plan.changes()) { + long actual = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), change.pallet().palletId()); + boolean shouldBeEmpty = "IDLE".equals(change.targetStatus()); + if (shouldBeEmpty != (actual == 0)) { + throw conflict("库存与实物托盘状态不一致,作业已回滚"); + } + } + + List contents = plan.pallets().values().stream() + .sorted(Comparator.comparingLong(PalletPhysicalGateway.PalletReference::palletId)) + .map(pallet -> content(identity.overseasWarehouseId(), physical.resolve(pallet.palletCode()))) + .toList(); + PalletOperationResponses.Operation result = new PalletOperationResponses.Operation(operation.getId(), + operation.getOperationType(), key, "COMPLETED", false, LocalDateTime.now(), contents); + operation.setStatus("COMPLETED"); operation.setResultJson(write(result)); operation.setLastError(null); + operation.setUpdatedAt(result.completedAt()); mapper.updateById(operation); + history.record(identity.overseasWarehouseId(), customers.size() == 1 ? customers.iterator().next() : null, + identity.operatorKey(), operation.getOperationType(), businessKey(plan), "COMPLETED", key, requestHash, + "托盘作业完成,移动库存行 " + plan.moves().size() + " 条"); + return result; + } + + private void moveInventory(PdaWarehouseIdentity identity, PdaPalletOperation operation, + PalletInventoryRow sourceRow, PlannedMove move, String key, String reason) { + InventoryPosition source = positions.selectById(sourceRow.getPositionId()); + if (source == null) throw conflict("库存行不存在"); + long sourceBefore = value(source.getAvailablePackages()); + source.setAvailablePackages(sourceBefore - move.packages()); + source.setStatus(positionPackages(source) == 0 ? "EMPTY" : "OCCUPIED"); + source.setVersion(value(source.getVersion()) + 1); source.setUpdatedAt(LocalDateTime.now()); + positions.updateById(source); + + InventoryPosition target = mapper.lockTarget(source.getCustomerId(), source.getBalanceId(), + move.targetPalletId(), source.getBatchNo()); + if (target == null) { + target = new InventoryPosition(); target.setId(IdWorker.getId()); + target.setCustomerId(source.getCustomerId()); target.setBalanceId(source.getBalanceId()); + target.setOverseasWarehouseId(source.getOverseasWarehouseId()); + target.setDestinationWarehouseCode(source.getDestinationWarehouseCode()); + target.setPalletId(move.targetPalletId()); target.setPalletNo(move.targetPalletCode()); + target.setBatchNo(source.getBatchNo()); target.setWarehouseLocationId(move.targetLocationId()); + target.setWarehouseLocationCode(move.targetLocationCode()); + target.setAvailablePackages(0L); target.setHoldPackages(0L); target.setAllocatedPackages(0L); + target.setPickedPackages(0L); target.setReceivingPackages(0L); target.setStatus("OCCUPIED"); + target.setVersion(0L); target.setCreatedAt(LocalDateTime.now()); target.setUpdatedAt(target.getCreatedAt()); + try { positions.insert(target); } + catch (DuplicateKeyException duplicate) { + target = mapper.lockTarget(source.getCustomerId(), source.getBalanceId(), move.targetPalletId(), source.getBatchNo()); + if (target == null) throw duplicate; + } + } + long targetBefore = value(target.getAvailablePackages()); + target.setAvailablePackages(targetBefore + move.packages()); target.setStatus("OCCUPIED"); + target.setWarehouseLocationId(move.targetLocationId()); + target.setWarehouseLocationCode(move.targetLocationCode()); + target.setVersion(value(target.getVersion()) + 1); target.setUpdatedAt(LocalDateTime.now()); + positions.updateById(target); + + InventoryBalance balance = balances.selectById(source.getBalanceId()); + if (balance == null) throw new IllegalStateException("库存余额不存在"); + ledger(identity, operation, balance, source, key + ":" + source.getId() + ":OUT", + sourceBefore, -move.packages(), sourceBefore - move.packages(), move.sourcePalletCode(), move.targetPalletCode(), reason); + ledger(identity, operation, balance, target, key + ":" + source.getId() + ":IN", + targetBefore, move.packages(), targetBefore + move.packages(), move.sourcePalletCode(), move.targetPalletCode(), reason); + } + + private void ledger(PdaWarehouseIdentity identity, PdaPalletOperation operation, InventoryBalance balance, + InventoryPosition position, String idempotencyKey, long before, long delta, long after, + String sourcePallet, String targetPallet, String reason) { + InventoryLedger ledger = new InventoryLedger(); ledger.setId(IdWorker.getId()); + ledger.setCustomerId(balance.getCustomerId()); ledger.setBalanceId(balance.getId()); + ledger.setPositionId(position.getId()); ledger.setOverseasWarehouseId(balance.getOverseasWarehouseId()); + ledger.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); + ledger.setInboundOrderId(balance.getInboundOrderId()); ledger.setPalletId(position.getPalletId()); + ledger.setPalletNo(position.getPalletNo()); ledger.setBatchNo(position.getBatchNo()); + ledger.setSkuCode(balance.getSkuCode()); ledger.setWarehouseLocationCode(position.getWarehouseLocationCode()); + ledger.setBusinessType(operation.getOperationType()); ledger.setBusinessOrderId(operation.getId()); + ledger.setBusinessOrderNo(String.valueOf(operation.getId())); ledger.setInboundCode(balance.getInboundCode()); + ledger.setInboundOrderNo(balance.getInboundOrderNo()); ledger.setIdempotencyKey(idempotencyKey); + ledger.setStatusFrom("PALLET:" + sourcePallet); ledger.setStatusTo("PALLET:" + targetPallet); + ledger.setBeforePackages(before); ledger.setDeltaPackages(delta); ledger.setAfterPackages(after); + ledger.setOperatorId(identity.operatorKey()); ledger.setRemark(trim(reason)); + ledger.setOccurredAt(LocalDateTime.now()); ledger.setCreatedAt(ledger.getOccurredAt()); ledgers.insert(ledger); + } + + private Plan plan(long warehouseId, Map pallets, + List moves, Set targets, boolean unbind) { + Map movedBySource = new HashMap<>(); + moves.forEach(move -> movedBySource.merge(move.sourcePalletId(), move.packages(), Long::sum)); + List changes = new ArrayList<>(); + for (PalletPhysicalGateway.PalletReference pallet : pallets.values()) { + long total = mapper.palletPackages(String.valueOf(warehouseId), pallet.palletId()); + long moved = movedBySource.getOrDefault(pallet.palletId(), 0L); + String targetStatus = targets.contains(pallet.palletId()) ? "OCCUPIED" + : moved > 0 && moved == total ? "IDLE" : pallet.status(); + if ("SCRAPPED".equals(targetStatus)) throw conflict("已报废托盘不能执行作业"); + changes.add(new PalletChange(pallet, targetStatus, pallet.locationId())); + } + if (unbind && changes.stream().noneMatch(change -> "IDLE".equals(change.targetStatus()))) { + throw conflict("解绑必须清空来源托盘"); + } + changes.sort(Comparator.comparingLong(change -> change.pallet().palletId())); + return new Plan(pallets, moves, changes); + } + + private List plannedLines(long warehouseId, List lines, + Map sources, + PalletPhysicalGateway.PalletReference target, + PalletPhysicalGateway.PalletReference requiredSource) { + Map available = new HashMap<>(); + for (PalletPhysicalGateway.PalletReference source : sources.values()) { + for (PalletInventoryRow row : mapper.content(String.valueOf(warehouseId), source.palletId())) { + available.put(row.getPositionId(), row); + } + } + List result = new ArrayList<>(); + for (PalletOperationRequests.MoveLine line : lines) { + PalletInventoryRow row = available.get(line.positionId()); + if (row == null || requiredSource != null && !Objects.equals(row.getPalletId(), requiredSource.palletId())) { + throw conflict("库存行不属于已扫描的来源托盘"); + } + if (value(row.getVersion()) != line.expectedPositionVersion()) throw conflict("库存版本已变化,请重新扫码"); + requireMovable(row, line.packages()); + result.add(new PlannedMove(row.getPositionId(), line.packages(), line.expectedPositionVersion(), + row.getPalletId(), row.getPalletNo(), target.palletId(), target.palletCode(), target.locationId(), + locationCode(warehouseId, target.locationId()))); + } + requireUniqueLines(result); + return result; + } + + private void requireCommonDestination(long warehouseId, List moves, + List targets) { + LinkedHashSet destinations = new LinkedHashSet<>(); + for (PlannedMove move : moves) { + PalletInventoryRow row = mapper.content(String.valueOf(warehouseId), move.sourcePalletId()).stream() + .filter(value -> Objects.equals(value.getPositionId(), move.positionId())).findFirst() + .orElseThrow(() -> conflict("库存行不存在")); + destinations.add(text(row.getDestinationWarehouseCode())); + } + for (PalletPhysicalGateway.PalletReference target : targets) { + mapper.content(String.valueOf(warehouseId), target.palletId()).stream() + .map(PalletInventoryRow::getDestinationWarehouseCode).map(PdaPalletOperationService::text) + .forEach(destinations::add); + } + if (destinations.size() > 1) throw conflict("不同目的仓货物不能组到同一托盘"); + } + + private Map resolvePallets(long warehouseId, + PalletOperationRequests.PalletExpectation first, List rest) { + List values = new ArrayList<>(); values.add(first); values.addAll(rest); + return resolvePallets(warehouseId, values); + } + private Map resolvePallets(long warehouseId, + java.util.Collection values) { + LinkedHashMap result = new LinkedHashMap<>(); + for (PalletOperationRequests.PalletExpectation expected : values) { + String code = code(expected.palletCode()); + if (result.containsKey(code)) throw badRequest("一次作业不能重复提交同一托盘"); + PalletPhysicalGateway.PalletReference pallet = requirePallet(warehouseId, code); + if (pallet.version() != expected.expectedVersion()) throw conflict("托盘版本已变化,请重新扫码"); + result.put(code, pallet); + } + return result; + } + + private PalletPhysicalGateway.PalletReference requirePallet(long warehouseId, String code) { + PalletPhysicalGateway.PalletReference pallet = physical.resolve(code(code)); + if (pallet.warehouseId() != warehouseId) throw conflict("托盘不属于 Token 冻结的海外仓"); + if ("SCRAPPED".equals(pallet.status())) throw conflict("已报废托盘不能执行作业"); + return pallet; + } + + private List nonEmptyContent(long warehouseId, long palletId) { + List values = mapper.content(String.valueOf(warehouseId), palletId); + if (values.isEmpty()) throw conflict("来源托盘没有可操作库存"); + return values; + } + + private PalletOperationResponses.PalletContent content(long warehouseId, + PalletPhysicalGateway.PalletReference pallet) { + List rows = mapper.content(String.valueOf(warehouseId), pallet.palletId()); + LinkedHashSet destinations = new LinkedHashSet<>(); + List inventory = rows.stream().map(row -> { + destinations.add(text(row.getDestinationWarehouseCode())); + return new PalletOperationResponses.InventoryLine(row.getCustomerId(), row.getCustomerName(), + value(row.getBalanceId()), value(row.getPositionId()), row.getInboundCode(), row.getSkuCode(), + first(row.getProductNameZh(), row.getProductNameEn()), value(row.getAvailablePackages()), + value(row.getHoldPackages()), value(row.getAllocatedPackages()), value(row.getPickedPackages()), + value(row.getReceivingPackages()), value(row.getVersion())); + }).toList(); + long total = rows.stream().mapToLong(PdaPalletOperationService::rowPackages).sum(); + String locationCode = locationCode(warehouseId, pallet.locationId()); + return new PalletOperationResponses.PalletContent(pallet.palletId(), pallet.palletCode(), pallet.status(), + pallet.version(), pallet.locationId(), locationCode, destinations.size() == 1 ? destinations.iterator().next() : null, + total, inventory); + } + + private PdaPalletOperation begin(PdaWarehouseIdentity identity, String type, String key, String requestHash) { + PdaPalletOperation existing = mapper.findOperation(identity.operatorKey(), key); + if (existing != null) return validateExisting(existing, type, requestHash); + try { + return newTransaction.execute(status -> { + PdaPalletOperation operation = new PdaPalletOperation(); operation.setId(IdWorker.getId()); + operation.setOverseasWarehouseId(identity.overseasWarehouseId()); + operation.setOperatorKey(identity.operatorKey()); operation.setIdempotencyKey(key); + operation.setOperationType(type); operation.setRequestHash(requestHash); operation.setStatus("PROCESSING"); + operation.setCreatedAt(LocalDateTime.now()); operation.setUpdatedAt(operation.getCreatedAt()); + mapper.insert(operation); return operation; + }); + } catch (DuplicateKeyException duplicate) { + PdaPalletOperation winner = mapper.findOperation(identity.operatorKey(), key); + if (winner != null) return validateExisting(winner, type, requestHash); + throw duplicate; + } + } + + private PdaPalletOperation validateExisting(PdaPalletOperation value, String type, String requestHash) { + if (!type.equals(value.getOperationType()) || !requestHash.equals(value.getRequestHash())) { + throw conflict("幂等键已被不同托盘作业使用"); + } + if (!"COMPLETED".equals(value.getStatus())) { + throw conflict("同一托盘作业正在处理或需要人工恢复"); + } + return value; + } + + private void saveTransitions(long operationId, List transitions) { + newTransaction.executeWithoutResult(status -> { + PdaPalletOperation operation = mapper.selectById(operationId); + operation.setRemoteTransitionsJson(write(transitions)); operation.setUpdatedAt(LocalDateTime.now()); + mapper.updateById(operation); + }); + } + private void fail(long operationId, RuntimeException failure, boolean compensated) { + newTransaction.executeWithoutResult(status -> { + PdaPalletOperation operation = mapper.selectById(operationId); + if (operation == null || "COMPLETED".equals(operation.getStatus())) return; + operation.setStatus(compensated ? "FAILED_COMPENSATED" : "COMPENSATION_REQUIRED"); + operation.setLastError(limit(failure.getMessage(), 2000)); operation.setUpdatedAt(LocalDateTime.now()); + mapper.updateById(operation); + }); + } + private boolean compensate(long warehouseId, List transitions) { + boolean success = true; + for (int i = transitions.size() - 1; i >= 0; i--) { + try { physical.compensate(warehouseId, transitions.get(i)); } + catch (RuntimeException ignored) { success = false; } + } + return success; + } + + private PalletOperationResponses.Operation read(PdaPalletOperation operation) { + try { return objectMapper.readValue(operation.getResultJson(), PalletOperationResponses.Operation.class); } + catch (JsonProcessingException exception) { throw new IllegalStateException("无法读取托盘作业幂等结果", exception); } + } + private String write(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { throw new IllegalStateException("无法序列化托盘作业", exception); } + } + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(write(value).getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } + } + private static String transitionKey(String operator, String key, long palletId) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest((operator + "|" + key + "|" + palletId).getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } + } + private String locationCode(long warehouseId, long locationId) { + PalletPhysicalGateway.LocationReference location = physical.resolveLocation(warehouseId, locationId); + if (location == null || location.locationId() != locationId || "INACTIVE".equals(location.status())) { + throw conflict("托盘库位不存在或已停用"); + } + return location.locationCode(); + } + private static void requireMovable(PalletInventoryRow row, long packages) { + if (packages <= 0 || value(row.getAvailablePackages()) < packages) throw conflict("可用库存箱数不足"); + if (value(row.getHoldPackages()) > 0 || value(row.getAllocatedPackages()) > 0 + || value(row.getPickedPackages()) > 0 || value(row.getReceivingPackages()) > 0) { + throw conflict("冻结、已分配、已拣或收货中的库存不能执行托盘作业"); + } + } + private static void requireUniqueLines(List moves) { + Set ids = new HashSet<>(); + if (moves.stream().anyMatch(move -> !ids.add(move.positionId()))) throw badRequest("库存行不能重复提交"); + } + private static long rowPackages(PalletInventoryRow row) { + return value(row.getAvailablePackages()) + value(row.getHoldPackages()) + value(row.getAllocatedPackages()) + + value(row.getPickedPackages()) + value(row.getReceivingPackages()); + } + private static long positionPackages(InventoryPosition value) { + return value(value.getAvailablePackages()) + value(value.getHoldPackages()) + value(value.getAllocatedPackages()) + + value(value.getPickedPackages()) + value(value.getReceivingPackages()); + } + private static String businessKey(Plan plan) { + return plan.pallets().values().stream().map(PalletPhysicalGateway.PalletReference::palletCode) + .sorted().collect(java.util.stream.Collectors.joining("->")); + } + private static String required(String value, String message) { String result = trim(value); if (result == null) throw badRequest(message); return result; } + private static String code(String value) { return required(value, "托盘码不能为空").toUpperCase(); } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String text(String value) { return value == null ? "" : value.trim(); } + private static String first(String first, String second) { String value = trim(first); return value == null ? trim(second) : value; } + private static String limit(String value, int size) { if (value == null) return null; return value.length() <= size ? value : value.substring(0, size); } + private static long value(Long value) { return value == null ? 0L : value; } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + + private record PlannedMove(long positionId, long packages, long expectedVersion, + long sourcePalletId, String sourcePalletCode, + long targetPalletId, String targetPalletCode, long targetLocationId, + String targetLocationCode) { } + private record PalletChange(PalletPhysicalGateway.PalletReference pallet, String targetStatus, + long targetLocationId) { } + private record Plan(Map pallets, + List moves, List changes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java new file mode 100644 index 000000000..6bfdf88b7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.pda.move.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest; +import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveResponse; +import com.baiyue.lastleg.pda.move.service.PdaInventoryMoveService; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/pda/v1/last-leg/inventory-moves") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')") +public class PdaInventoryMoveController { + @Resource private PdaInventoryMoveService service; + + @PostMapping + public ApiResponse move(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PdaInventoryMoveRequest request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.move(identity.overseasWarehouseId(), identity.operatorKey(), request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveRequest.java new file mode 100644 index 000000000..0e79b961d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveRequest.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.pda.move.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +public record PdaInventoryMoveRequest( + @NotBlank @Size(max = 128) String sourcePalletCode, + @NotBlank @Size(max = 128) String targetLocationCode, + @PositiveOrZero int expectedPalletVersion, + @NotBlank @Size(max = 128) String idempotencyKey, + @NotBlank @Size(max = 500) String reason +) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveResponse.java new file mode 100644 index 000000000..40f55affc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveResponse.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.pda.move.dto; + +import java.time.LocalDateTime; + +public record PdaInventoryMoveResponse( + String moveNo, + long palletId, + String palletCode, + long sourceLocationId, + String sourceLocationCode, + long targetLocationId, + String targetLocationCode, + int customerCount, + int positionCount, + long packageCount, + int palletVersion, + String status, + LocalDateTime completedAt, + boolean replayed +) { + public PdaInventoryMoveResponse asReplay() { + return replayed ? this : new PdaInventoryMoveResponse(moveNo, palletId, palletCode, sourceLocationId, + sourceLocationCode, targetLocationId, targetLocationCode, customerCount, positionCount, + packageCount, palletVersion, status, completedAt, true); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/entity/PdaInventoryMove.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/entity/PdaInventoryMove.java new file mode 100644 index 000000000..c57580eaa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/entity/PdaInventoryMove.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.pda.move.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_pda_inventory_move") +public class PdaInventoryMove { + @TableId(type = IdType.INPUT) private Long id; + private String moveNo; + private Long overseasWarehouseId; + private Long palletId; + private String palletCode; + private Long sourceLocationId; + private String sourceLocationCode; + private Long targetLocationId; + private String targetLocationCode; + private Integer customerCount; + private Integer positionCount; + private Long packageCount; + private Integer palletVersionBefore; + private Integer palletVersionAfter; + private String operatorKey; + private String reason; + private String idempotencyKey; + private String requestHash; + private String status; + private String resultJson; + private LocalDateTime createdAt; + private LocalDateTime completedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/mapper/PdaInventoryMoveMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/mapper/PdaInventoryMoveMapper.java new file mode 100644 index 000000000..4347aa101 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/mapper/PdaInventoryMoveMapper.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.pda.move.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.pda.move.entity.PdaInventoryMove; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface PdaInventoryMoveMapper extends BaseMapper { + @Select("SELECT * FROM ll_pda_inventory_move WHERE overseas_warehouse_id=#{warehouseId} " + + "AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1") + PdaInventoryMove find(@Param("warehouseId") long warehouseId, + @Param("operatorKey") String operatorKey, @Param("key") String key); + + @Select("SELECT * FROM ll_inventory_position WHERE overseas_warehouse_id=#{warehouseId} " + + "AND pallet_id=#{palletId} AND " + + "(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages)>0 " + + "ORDER BY customer_id,id FOR UPDATE") + List lockPositions(@Param("warehouseId") String warehouseId, + @Param("palletId") long palletId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java new file mode 100644 index 000000000..03be9225d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java @@ -0,0 +1,192 @@ +package com.baiyue.lastleg.pda.move.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; +import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest; +import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveResponse; +import com.baiyue.lastleg.pda.move.entity.PdaInventoryMove; +import com.baiyue.lastleg.pda.move.mapper.PdaInventoryMoveMapper; +import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; + +@Service +public class PdaInventoryMoveService { + @Resource private PdaInventoryMoveMapper moves; + @Resource private InventoryPositionMapper positions; + @Resource private InventoryBalanceMapper balances; + @Resource private InventoryLedgerMapper ledgers; + @Resource private WarehouseResourceGateway warehouse; + @Resource private PalletPhysicalGateway physical; + @Resource private PdaWarehouseService history; + @Resource private ObjectMapper objectMapper; + + @Transactional + public PdaInventoryMoveResponse move(long warehouseId, String operatorKey, PdaInventoryMoveRequest request) { + String key = required(request.idempotencyKey(), "Idempotency key is required"); + String requestHash = hash(request); + PdaInventoryMove existing = moves.find(warehouseId, operatorKey, key); + if (existing != null) return replay(existing, requestHash); + + var pallet = warehouse.resolvePallet(required(request.sourcePalletCode(), "Source pallet code is required")); + if (pallet.warehouseId() != warehouseId) throw conflict("Source pallet belongs to another overseas warehouse"); + var target = warehouse.resolveLocation(warehouseId, + required(request.targetLocationCode(), "Target location code is required")); + if (!Set.of("ACTIVE", "IDLE", "OCCUPIED").contains(target.status())) { + throw conflict("Target location is not available"); + } + boolean recoveringRemoteSuccess = pallet.locationId() == target.locationId() + && pallet.version() == request.expectedPalletVersion() + 1; + if (!recoveringRemoteSuccess && pallet.version() != request.expectedPalletVersion()) { + throw conflict("Source pallet version changed; rescan it"); + } + + List inventory = moves.lockPositions(String.valueOf(warehouseId), pallet.palletId()); + if (inventory.isEmpty()) throw conflict("Source pallet has no movable inventory"); + Set inventoryLocations = new HashSet<>(); + inventory.forEach(position -> inventoryLocations.add(position.getWarehouseLocationId())); + if (inventoryLocations.contains(null) || inventoryLocations.size() != 1) { + throw conflict("Inventory and warehouse pallet locations are inconsistent; reconcile before moving"); + } + long sourceLocationId = inventoryLocations.iterator().next(); + if (!recoveringRemoteSuccess && sourceLocationId != pallet.locationId()) { + throw conflict("Inventory and warehouse pallet locations are inconsistent; reconcile before moving"); + } + if (sourceLocationId == target.locationId()) throw badRequest("Source and target locations must differ"); + var source = warehouse.resolveLocation(warehouseId, sourceLocationId); + long packages = inventory.stream().mapToLong(PdaInventoryMoveService::physical).sum(); + Set customers = new HashSet<>(); + inventory.forEach(position -> customers.add(position.getCustomerId())); + + long moveId = IdWorker.getId(); + PdaInventoryMove operation = new PdaInventoryMove(); + operation.setId(moveId); operation.setMoveNo("MV-" + Long.toUnsignedString(moveId)); + operation.setOverseasWarehouseId(warehouseId); operation.setPalletId(pallet.palletId()); + operation.setPalletCode(pallet.palletNo()); operation.setSourceLocationId(source.locationId()); + operation.setSourceLocationCode(source.locationCode()); operation.setTargetLocationId(target.locationId()); + operation.setTargetLocationCode(target.locationCode()); operation.setCustomerCount(customers.size()); + operation.setPositionCount(inventory.size()); operation.setPackageCount(packages); + operation.setPalletVersionBefore(pallet.version()); operation.setOperatorKey(operatorKey); + operation.setReason(request.reason().trim()); operation.setIdempotencyKey(key); operation.setRequestHash(requestHash); + operation.setStatus("PROCESSING"); operation.setCreatedAt(LocalDateTime.now()); + try { moves.insert(operation); } + catch (DuplicateKeyException duplicate) { + PdaInventoryMove winner = moves.find(warehouseId, operatorKey, key); + if (winner == null) throw duplicate; + return replay(winner, requestHash); + } + + String transitionKey = transitionKey(operatorKey, key, pallet.palletId()); + var transitionSource = new PalletPhysicalGateway.PalletReference(pallet.palletId(), pallet.palletNo(), + warehouseId, source.locationId(), "OCCUPIED", pallet.fbaWarehouseCode(), request.expectedPalletVersion()); + var relocated = physical.apply(warehouseId, transitionSource, "OCCUPIED", target.locationId(), + transitionKey, request.reason()); + if (relocated.afterLocationId() != target.locationId() + || !"APPLIED".equals(relocated.operationStatus())) { + throw new IllegalStateException("Warehouse relocation returned another target location"); + } + LocalDateTime completedAt = LocalDateTime.now(); + for (InventoryPosition position : inventory) { + InventoryBalance balance = balances.selectById(position.getBalanceId()); + if (balance == null || !position.getCustomerId().equals(balance.getCustomerId()) + || !String.valueOf(warehouseId).equals(balance.getOverseasWarehouseId())) { + throw new IllegalStateException("Inventory balance scope is inconsistent during relocation"); + } + position.setWarehouseLocationId(target.locationId()); position.setWarehouseLocationCode(target.locationCode()); + position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(completedAt); + positions.updateById(position); + ledger(operation, position, balance, operatorKey, key, request.reason(), source.locationCode(), + target.locationCode(), completedAt); + } + operation.setPalletVersionAfter(relocated.afterVersion()); operation.setStatus("COMPLETED"); + operation.setCompletedAt(completedAt); + PdaInventoryMoveResponse response = response(operation, false); + operation.setResultJson(write(response)); moves.updateById(operation); + history.record(warehouseId, null, operatorKey, "INVENTORY_MOVE", operation.getMoveNo(), "COMPLETED", + "INVENTORY_MOVE:" + key, requestHash, + pallet.palletNo() + ":" + source.locationCode() + " → " + target.locationCode() + + "," + packages + " 箱"); + return response; + } + + private PdaInventoryMoveResponse replay(PdaInventoryMove operation, String requestHash) { + if (!requestHash.equals(operation.getRequestHash())) { + throw conflict("Idempotency key was reused with a different inventory move"); + } + if (!"COMPLETED".equals(operation.getStatus()) || operation.getResultJson() == null) { + throw conflict("Inventory move is still processing"); + } + try { return objectMapper.readValue(operation.getResultJson(), PdaInventoryMoveResponse.class).asReplay(); } + catch (JsonProcessingException error) { throw new IllegalStateException("Cannot read inventory move replay", error); } + } + + private PdaInventoryMoveResponse response(PdaInventoryMove value, boolean replayed) { + return new PdaInventoryMoveResponse(value.getMoveNo(), value.getPalletId(), value.getPalletCode(), + value.getSourceLocationId(), value.getSourceLocationCode(), value.getTargetLocationId(), + value.getTargetLocationCode(), value.getCustomerCount(), value.getPositionCount(), + value.getPackageCount(), value.getPalletVersionAfter(), value.getStatus(), value.getCompletedAt(), replayed); + } + + private void ledger(PdaInventoryMove operation, InventoryPosition position, InventoryBalance balance, + String operator, String key, String reason, String source, String target, + LocalDateTime now) { + InventoryLedger row = new InventoryLedger(); row.setId(IdWorker.getId()); row.setCustomerId(position.getCustomerId()); + row.setBalanceId(balance.getId()); row.setPositionId(position.getId()); + row.setOverseasWarehouseId(String.valueOf(operation.getOverseasWarehouseId())); + row.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); row.setInboundOrderId(balance.getInboundOrderId()); + row.setPalletId(position.getPalletId()); row.setPalletNo(position.getPalletNo()); row.setBatchNo(position.getBatchNo()); + row.setSkuCode(balance.getSkuCode()); row.setWarehouseLocationCode(target); row.setBusinessType("INTERNAL_MOVE"); + row.setBusinessOrderId(operation.getId()); row.setBusinessOrderNo(operation.getMoveNo()); + row.setInboundCode(balance.getInboundCode()); row.setInboundOrderNo(balance.getInboundOrderNo()); + row.setIdempotencyKey("MOVE:" + operation.getId() + ":" + position.getId()); + row.setStatusFrom(source); row.setStatusTo(target); long packages = physical(position); + row.setBeforePackages(packages); row.setDeltaPackages(0L); row.setAfterPackages(packages); + row.setOperatorId(operator); row.setRemark(reason.trim()); row.setOccurredAt(now); row.setCreatedAt(now); + ledgers.insert(row); + } + + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(write(value).getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); } + } + private static String transitionKey(String operator, String key, long palletId) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(("INVENTORY_MOVE|" + operator + "|" + key + "|" + palletId) + .getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); } + } + private String write(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException error) { throw new IllegalStateException("Cannot serialize inventory move", error); } + } + private static long physical(InventoryPosition value) { return value(value.getAvailablePackages()) + value(value.getHoldPackages()) + + value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); } + private static long value(Long value) { return value == null ? 0 : value; } + private static String required(String value, String message) { if (value == null || value.isBlank()) throw badRequest(message); return value.trim(); } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java new file mode 100644 index 000000000..fb103115a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java @@ -0,0 +1,61 @@ +package com.baiyue.lastleg.pda.stocktake.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses; +import com.baiyue.lastleg.pda.stocktake.service.PdaStocktakeService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/pda/v1/last-leg/stocktakes") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')") +public class PdaStocktakeController { + @Resource private PdaStocktakeService service; + + @GetMapping + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, @Valid PdaStocktakeRequests.TaskQuery query) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.page(identity.overseasWarehouseId(), query)); + } + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PdaStocktakeRequests.Create request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.create(identity.overseasWarehouseId(), identity.operatorKey(), request)); + } + + @GetMapping("/{taskNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String taskNo) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.detail(identity.overseasWarehouseId(), taskNo)); + } + + @PostMapping("/{taskNo}/counts") + public ApiResponse count(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String taskNo, @Valid @RequestBody PdaStocktakeRequests.Count request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.count(identity.overseasWarehouseId(), identity.operatorKey(), taskNo, request)); + } + + @PostMapping("/{taskNo}/submit") + public ApiResponse submit(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String taskNo, @Valid @RequestBody PdaStocktakeRequests.Submit request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.submit(identity.overseasWarehouseId(), identity.operatorKey(), taskNo, request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeRequests.java new file mode 100644 index 000000000..bfba023cd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeRequests.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.pda.stocktake.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +public final class PdaStocktakeRequests { + private PdaStocktakeRequests() { } + + public record TaskQuery(@Min(1) long page, @Min(1) @Max(100) long size, String status) { + public TaskQuery { if (page == 0) page = 1; if (size == 0) size = 20; } + } + + public record Create(@NotBlank @Size(max = 16) String scopeType, + @NotBlank @Size(max = 128) String scopeCode, + @NotBlank @Size(max = 128) String idempotencyKey) { } + + public record Count(@NotNull Long positionId, @PositiveOrZero long actualPackages, + @PositiveOrZero long expectedVersion, + @NotBlank @Size(max = 128) String idempotencyKey) { } + + public record Submit(@PositiveOrZero long expectedVersion, + @NotBlank @Size(max = 500) String reason, + @NotBlank @Size(max = 128) String idempotencyKey) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeResponses.java new file mode 100644 index 000000000..ce72041aa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeResponses.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.pda.stocktake.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class PdaStocktakeResponses { + private PdaStocktakeResponses() { } + + public record Summary(String taskNo, String scopeType, String scopeCode, String status, + int positionCount, int countedCount, int differenceCount, long version, + LocalDateTime createdAt, LocalDateTime submittedAt) { } + + public record Item(long itemId, String customerId, long balanceId, long positionId, + String skuCode, String productName, String inboundCode, + long palletId, String palletCode, long locationId, String locationCode, + long bookPackages, Long actualPackages, Long differencePackages, + String status, long positionVersion, long version) { } + + public record Detail(Summary task, List items) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeAction.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeAction.java new file mode 100644 index 000000000..a0246bf33 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeAction.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.pda.stocktake.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_pda_stocktake_action") +public class PdaStocktakeAction { + @TableId(type = IdType.INPUT) private Long id; + private Long overseasWarehouseId; + private String operatorKey; + private String idempotencyKey; + private String actionType; + private Long taskId; + private String requestHash; + private String status; + private String resultJson; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeItem.java new file mode 100644 index 000000000..0473f5343 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeItem.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.pda.stocktake.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_pda_stocktake_item") +public class PdaStocktakeItem { + @TableId(type = IdType.INPUT) private Long id; + private Long taskId; + private String customerId; + private Long balanceId; + private Long positionId; + private String skuCode; + private String productName; + private String inboundCode; + private Long palletId; + private String palletCode; + private Long locationId; + private String locationCode; + private Long bookPackages; + private Long actualPackages; + private Long differencePackages; + private String status; + private Long positionVersion; + private String countedBy; + private Long version; + private LocalDateTime countedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeTask.java new file mode 100644 index 000000000..733b8ac43 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeTask.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.pda.stocktake.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_pda_stocktake_task") +public class PdaStocktakeTask { + @TableId(type = IdType.INPUT) private Long id; + private String taskNo; + private Long overseasWarehouseId; + private String scopeType; + private Long scopeResourceId; + private String scopeCode; + private String status; + private Integer positionCount; + private Integer countedCount; + private Integer differenceCount; + private String createdBy; + private String submittedBy; + private String submitReason; + private String idempotencyKey; + private String requestHash; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private LocalDateTime submittedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeActionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeActionMapper.java new file mode 100644 index 000000000..3e42d42d6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeActionMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.pda.stocktake.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeAction; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface PdaStocktakeActionMapper extends BaseMapper { + @Select("SELECT * FROM ll_pda_stocktake_action WHERE overseas_warehouse_id=#{warehouseId} " + + "AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1") + PdaStocktakeAction find(@Param("warehouseId") long warehouseId, + @Param("operatorKey") String operatorKey, @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeInventoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeInventoryMapper.java new file mode 100644 index 000000000..e2ab71e35 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeInventoryMapper.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.pda.stocktake.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface PdaStocktakeInventoryMapper { + @Select("SELECT * FROM ll_inventory_position WHERE id=#{positionId} AND customer_id=#{customerId} " + + "AND overseas_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE") + InventoryPosition lockPosition(@Param("positionId") long positionId, @Param("customerId") String customerId, + @Param("warehouseId") String warehouseId); + + @Select("SELECT * FROM ll_inventory_balance WHERE id=#{balanceId} AND customer_id=#{customerId} " + + "AND overseas_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE") + InventoryBalance lockBalance(@Param("balanceId") long balanceId, @Param("customerId") String customerId, + @Param("warehouseId") String warehouseId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java new file mode 100644 index 000000000..e74e61729 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java @@ -0,0 +1,66 @@ +package com.baiyue.lastleg.pda.stocktake.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem; +import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +@Mapper +public interface PdaStocktakeItemMapper extends BaseMapper { + String CANDIDATE = "SELECT p.customer_id,b.id balance_id,p.id position_id,b.sku_code," + + "COALESCE(NULLIF(b.product_name_zh,''),b.product_name_en) product_name,b.inbound_code," + + "p.pallet_id,p.pallet_no pallet_code,p.warehouse_location_id location_id," + + "p.warehouse_location_code location_code," + + "(p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages) book_packages," + + "p.version position_version FROM ll_inventory_position p JOIN ll_inventory_balance b " + + "ON b.id=p.balance_id AND b.customer_id=p.customer_id WHERE p.overseas_warehouse_id=#{warehouseId} "; + + @Select(CANDIDATE + "AND p.pallet_id=#{palletId} AND " + + "(p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + + "ORDER BY p.customer_id,p.id") + List candidatesByPallet(@Param("warehouseId") String warehouseId, + @Param("palletId") long palletId); + + @Select(CANDIDATE + "AND (p.warehouse_location_id=#{locationId} OR p.warehouse_location_code=#{locationCode}) " + + "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + + "ORDER BY p.customer_id,p.pallet_no,p.id") + List candidatesByLocation(@Param("warehouseId") String warehouseId, + @Param("locationId") long locationId, + @Param("locationCode") String locationCode); + + @Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} ORDER BY location_code,pallet_code,position_id") + List list(@Param("taskId") long taskId); + + @Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} ORDER BY id FOR UPDATE") + List lockAll(@Param("taskId") long taskId); + + @Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} AND position_id=#{positionId} LIMIT 1 FOR UPDATE") + PdaStocktakeItem lockItem(@Param("taskId") long taskId, @Param("positionId") long positionId); + + @Update("UPDATE ll_pda_stocktake_item SET actual_packages=#{actualPackages}," + + "difference_packages=#{differencePackages},status=#{status},counted_by=#{operatorKey}," + + "counted_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1 " + + "WHERE id=#{itemId} AND version=#{expectedVersion} AND status IN ('PENDING','COUNTED')") + int countItem(@Param("itemId") long itemId, @Param("expectedVersion") long expectedVersion, + @Param("actualPackages") long actualPackages, @Param("differencePackages") long differencePackages, + @Param("status") String status, @Param("operatorKey") String operatorKey); + + @Select("SELECT COUNT(*) FROM ll_pda_stocktake_item WHERE task_id=#{taskId} AND actual_packages IS NOT NULL") + int counted(@Param("taskId") long taskId); + + @Select("SELECT COUNT(*) FROM ll_pda_stocktake_item WHERE task_id=#{taskId} " + + "AND actual_packages IS NOT NULL AND difference_packages<>0") + int differences(@Param("taskId") long taskId); + + @Select("SELECT id item_id,customer_id,balance_id,position_id,sku_code,product_name,inbound_code," + + "pallet_id,pallet_code,location_id,location_code,book_packages,actual_packages,difference_packages," + + "status,position_version,version FROM ll_pda_stocktake_item WHERE task_id=#{taskId} " + + "ORDER BY location_code,pallet_code,position_id") + List views(@Param("taskId") long taskId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeTaskMapper.java new file mode 100644 index 000000000..77ffae857 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeTaskMapper.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.pda.stocktake.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +@Mapper +public interface PdaStocktakeTaskMapper extends BaseMapper { + @Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " + + "AND created_by=#{operatorKey} AND idempotency_key=#{key} LIMIT 1") + PdaStocktakeTask findCreate(@Param("warehouseId") long warehouseId, + @Param("operatorKey") String operatorKey, @Param("key") String key); + + @Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " + + "AND task_no=#{taskNo} LIMIT 1") + PdaStocktakeTask find(@Param("warehouseId") long warehouseId, @Param("taskNo") String taskNo); + + @Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " + + "AND task_no=#{taskNo} LIMIT 1 FOR UPDATE") + PdaStocktakeTask lock(@Param("warehouseId") long warehouseId, @Param("taskNo") String taskNo); + + @Select("") + List page(@Param("warehouseId") long warehouseId, + @Param("status") String status, + @Param("offset") long offset, @Param("limit") long limit); + + @Select("") + long count(@Param("warehouseId") long warehouseId, @Param("status") String status); + + @Update("UPDATE ll_pda_stocktake_task SET counted_count=#{countedCount},difference_count=#{differenceCount}," + + "version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{taskId} AND version=#{expectedVersion} " + + "AND status='IN_PROGRESS'") + int refreshCounts(@Param("taskId") long taskId, @Param("expectedVersion") long expectedVersion, + @Param("countedCount") int countedCount, @Param("differenceCount") int differenceCount); + + @Update("UPDATE ll_pda_stocktake_task SET status='SUBMITTED',submitted_by=#{operatorKey}," + + "submit_reason=#{reason},submitted_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3)," + + "version=version+1 WHERE id=#{taskId} AND version=#{expectedVersion} AND status='IN_PROGRESS'") + int submit(@Param("taskId") long taskId, @Param("expectedVersion") long expectedVersion, + @Param("operatorKey") String operatorKey, @Param("reason") String reason); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/model/StocktakeCandidate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/model/StocktakeCandidate.java new file mode 100644 index 000000000..41443f089 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/model/StocktakeCandidate.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.pda.stocktake.model; + +import lombok.Data; + +@Data +public class StocktakeCandidate { + private String customerId; + private Long balanceId; + private Long positionId; + private String skuCode; + private String productName; + private String inboundCode; + private Long palletId; + private String palletCode; + private Long locationId; + private String locationCode; + private Long bookPackages; + private Long positionVersion; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java new file mode 100644 index 000000000..101e03b74 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java @@ -0,0 +1,323 @@ +package com.baiyue.lastleg.pda.stocktake.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeAction; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeActionMapper; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeInventoryMapper; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeItemMapper; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeTaskMapper; +import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate; +import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; + +@Service +public class PdaStocktakeService { + @Resource private PdaStocktakeTaskMapper tasks; + @Resource private PdaStocktakeItemMapper items; + @Resource private PdaStocktakeActionMapper actions; + @Resource private PdaStocktakeInventoryMapper inventory; + @Resource private InventoryPositionMapper positions; + @Resource private InventoryBalanceMapper balances; + @Resource private InventoryLedgerMapper ledgers; + @Resource private WarehouseResourceGateway warehouse; + @Resource private PdaWarehouseService history; + @Resource private ObjectMapper objectMapper; + + public PageResult page(long warehouseId, PdaStocktakeRequests.TaskQuery query) { + String status = status(query.status()); + long offset = (query.page() - 1) * query.size(); + List rows = tasks.page(warehouseId, status, offset, query.size()); + long total = tasks.count(warehouseId, status); + return new PageResult<>(rows, query.page(), query.size(), total, + total == 0 ? 0 : (total + query.size() - 1) / query.size()); + } + + public PdaStocktakeResponses.Detail detail(long warehouseId, String taskNo) { + PdaStocktakeTask task = requireTask(warehouseId, taskNo, false); + return detail(task); + } + + @Transactional + public PdaStocktakeResponses.Detail create(long warehouseId, String operatorKey, + PdaStocktakeRequests.Create request) { + String key = required(request.idempotencyKey(), "Idempotency key is required"); + String requestHash = hash(request); + PdaStocktakeTask replay = tasks.findCreate(warehouseId, operatorKey, key); + if (replay != null) { + if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with another stocktake"); + return detail(replay); + } + + String scopeType = required(request.scopeType(), "Scope type is required").toUpperCase(); + String scopeCode = required(request.scopeCode(), "Scope code is required"); + long resourceId; + List candidates; + if ("PALLET".equals(scopeType)) { + var pallet = warehouse.resolvePallet(scopeCode); + if (pallet.warehouseId() != warehouseId) throw conflict("Pallet belongs to another overseas warehouse"); + resourceId = pallet.palletId(); + scopeCode = pallet.palletNo(); + candidates = items.candidatesByPallet(String.valueOf(warehouseId), resourceId); + } else if ("LOCATION".equals(scopeType)) { + var location = warehouse.resolveLocation(warehouseId, scopeCode); + if (!List.of("ACTIVE", "IDLE", "OCCUPIED").contains(location.status())) { + throw conflict("Stocktake location is not active"); + } + resourceId = location.locationId(); + scopeCode = location.locationCode(); + candidates = items.candidatesByLocation(String.valueOf(warehouseId), resourceId, scopeCode); + } else { + throw badRequest("scopeType must be LOCATION or PALLET"); + } + if (candidates.isEmpty()) throw conflict("No inventory is available in the scanned scope"); + + long taskId = IdWorker.getId(); + LocalDateTime now = LocalDateTime.now(); + PdaStocktakeTask task = new PdaStocktakeTask(); + task.setId(taskId); task.setTaskNo("CC-" + Long.toUnsignedString(taskId)); + task.setOverseasWarehouseId(warehouseId); task.setScopeType(scopeType); task.setScopeResourceId(resourceId); + task.setScopeCode(scopeCode); task.setStatus("IN_PROGRESS"); task.setPositionCount(candidates.size()); + task.setCountedCount(0); task.setDifferenceCount(0); task.setCreatedBy(operatorKey); + task.setIdempotencyKey(key); task.setRequestHash(requestHash); task.setVersion(0L); + task.setCreatedAt(now); task.setUpdatedAt(now); + try { + tasks.insert(task); + } catch (DuplicateKeyException duplicate) { + PdaStocktakeTask winner = tasks.findCreate(warehouseId, operatorKey, key); + if (winner == null || !requestHash.equals(winner.getRequestHash())) throw duplicate; + return detail(winner); + } + for (StocktakeCandidate candidate : candidates) { + PdaStocktakeItem item = new PdaStocktakeItem(); + item.setId(IdWorker.getId()); item.setTaskId(taskId); item.setCustomerId(candidate.getCustomerId()); + item.setBalanceId(candidate.getBalanceId()); item.setPositionId(candidate.getPositionId()); + item.setSkuCode(candidate.getSkuCode()); item.setProductName(candidate.getProductName()); + item.setInboundCode(candidate.getInboundCode()); item.setPalletId(candidate.getPalletId()); + item.setPalletCode(candidate.getPalletCode()); item.setLocationId(candidate.getLocationId()); + item.setLocationCode(candidate.getLocationCode()); item.setBookPackages(value(candidate.getBookPackages())); + item.setStatus("PENDING"); item.setPositionVersion(value(candidate.getPositionVersion())); + item.setVersion(0L); item.setCreatedAt(now); item.setUpdatedAt(now); items.insert(item); + } + history.record(warehouseId, null, operatorKey, "STOCKTAKE_CREATE", task.getTaskNo(), "IN_PROGRESS", + "STOCKTAKE_CREATE:" + key, requestHash, + scopeType + " " + scopeCode + ",共 " + candidates.size() + " 个库存位置"); + return detail(task); + } + + @Transactional + public PdaStocktakeResponses.Item count(long warehouseId, String operatorKey, String taskNo, + PdaStocktakeRequests.Count request) { + String key = required(request.idempotencyKey(), "Idempotency key is required"); + String requestHash = hash(request); + PdaStocktakeAction action = beginAction(warehouseId, operatorKey, key, "COUNT", requestHash, taskNo); + if ("COMPLETED".equals(action.getStatus())) return read(action, PdaStocktakeResponses.Item.class); + + PdaStocktakeTask task = requireTask(warehouseId, taskNo, true); + if (!"IN_PROGRESS".equals(task.getStatus())) throw conflict("Submitted stocktake cannot be changed"); + PdaStocktakeItem item = items.lockItem(task.getId(), request.positionId()); + if (item == null) throw notFound("Stocktake position was not found in this task"); + long difference = request.actualPackages() - value(item.getBookPackages()); + if (items.countItem(item.getId(), request.expectedVersion(), request.actualPackages(), difference, + difference == 0 ? "MATCHED" : "DIFFERENCE", operatorKey) != 1) { + throw conflict("Stocktake item version changed; refresh and retry"); + } + int counted = items.counted(task.getId()); + int differences = items.differences(task.getId()); + if (tasks.refreshCounts(task.getId(), value(task.getVersion()), counted, differences) != 1) { + throw conflict("Stocktake task version changed; refresh and retry"); + } + PdaStocktakeResponses.Item response = items.views(task.getId()).stream() + .filter(row -> row.positionId() == request.positionId()).findFirst().orElseThrow(); + complete(action, response); + return response; + } + + @Transactional + public PdaStocktakeResponses.Detail submit(long warehouseId, String operatorKey, String taskNo, + PdaStocktakeRequests.Submit request) { + String key = required(request.idempotencyKey(), "Idempotency key is required"); + String requestHash = hash(request); + PdaStocktakeAction action = beginAction(warehouseId, operatorKey, key, "SUBMIT", requestHash, taskNo); + if ("COMPLETED".equals(action.getStatus())) return read(action, PdaStocktakeResponses.Detail.class); + + PdaStocktakeTask task = requireTask(warehouseId, taskNo, true); + if (!"IN_PROGRESS".equals(task.getStatus())) throw conflict("Stocktake was already submitted"); + if (value(task.getVersion()) != request.expectedVersion()) throw conflict("Stocktake task version changed; refresh and retry"); + List taskItems = items.lockAll(task.getId()); + if (taskItems.isEmpty() || taskItems.stream().anyMatch(item -> item.getActualPackages() == null)) { + throw conflict("Every stocktake position must be counted before submission"); + } + + Map changedBalances = new HashMap<>(); + LocalDateTime now = LocalDateTime.now(); + for (PdaStocktakeItem item : taskItems) { + InventoryPosition position = inventory.lockPosition(item.getPositionId(), item.getCustomerId(), + String.valueOf(warehouseId)); + if (position == null || value(position.getVersion()) != value(item.getPositionVersion())) { + throw conflict("Inventory changed after counting; recreate the stocktake task"); + } + long before = physical(position); + if (before != value(item.getBookPackages())) { + throw conflict("Inventory package total changed after counting; recreate the stocktake task"); + } + long fixed = value(position.getHoldPackages()) + value(position.getAllocatedPackages()) + + value(position.getPickedPackages()) + value(position.getReceivingPackages()); + if (value(item.getActualPackages()) < fixed) { + throw conflict("Actual packages cannot be lower than reserved or processing packages"); + } + long newAvailable = value(item.getActualPackages()) - fixed; + long delta = newAvailable - value(position.getAvailablePackages()); + InventoryBalance balance = changedBalances.get(item.getBalanceId()); + if (balance == null) { + balance = inventory.lockBalance(item.getBalanceId(), item.getCustomerId(), String.valueOf(warehouseId)); + if (balance == null) throw new IllegalStateException("Stocktake inventory balance is missing"); + changedBalances.put(item.getBalanceId(), balance); + } + long balanceAfter = value(balance.getAvailablePackages()) + delta; + if (balanceAfter < 0) throw conflict("Stocktake adjustment would make inventory negative"); + + position.setAvailablePackages(newAvailable); position.setStatus(physicalAfter(position, newAvailable) == 0 ? "EMPTY" : "OCCUPIED"); + position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(now); positions.updateById(position); + balance.setAvailablePackages(balanceAfter); balance.setVersion(value(balance.getVersion()) + 1); balance.setUpdatedAt(now); + + ledger(task, item, position, balance, operatorKey, key, request.reason(), before, delta, + value(item.getActualPackages()), now); + item.setStatus(delta == 0 ? "MATCHED" : "ADJUSTED"); item.setUpdatedAt(now); + item.setVersion(value(item.getVersion()) + 1); items.updateById(item); + } + changedBalances.values().forEach(balances::updateById); + if (tasks.submit(task.getId(), request.expectedVersion(), operatorKey, request.reason()) != 1) { + throw conflict("Stocktake task version changed; refresh and retry"); + } + history.record(warehouseId, null, operatorKey, "STOCKTAKE_SUBMIT", taskNo, "COMPLETED", + "STOCKTAKE_SUBMIT:" + key, requestHash, + "盘点提交,差异位置 " + task.getDifferenceCount() + " 个;原因:" + request.reason().trim()); + PdaStocktakeResponses.Detail response = detail(requireTask(warehouseId, taskNo, false)); + complete(action, response); + return response; + } + + private PdaStocktakeResponses.Detail detail(PdaStocktakeTask task) { + return new PdaStocktakeResponses.Detail(summary(task), items.views(task.getId())); + } + + private PdaStocktakeResponses.Summary summary(PdaStocktakeTask task) { + return new PdaStocktakeResponses.Summary(task.getTaskNo(), task.getScopeType(), task.getScopeCode(), + task.getStatus(), intValue(task.getPositionCount()), intValue(task.getCountedCount()), + intValue(task.getDifferenceCount()), value(task.getVersion()), task.getCreatedAt(), task.getSubmittedAt()); + } + + private PdaStocktakeTask requireTask(long warehouseId, String taskNo, boolean lock) { + String normalized = required(taskNo, "Stocktake task number is required"); + PdaStocktakeTask task = lock ? tasks.lock(warehouseId, normalized) : tasks.find(warehouseId, normalized); + if (task == null) throw notFound("Stocktake task was not found in the bound warehouse"); + return task; + } + + private PdaStocktakeAction beginAction(long warehouseId, String operatorKey, String key, String type, + String requestHash, String taskNo) { + PdaStocktakeAction existing = actions.find(warehouseId, operatorKey, key); + if (existing != null) return replay(existing, type, requestHash); + PdaStocktakeAction action = new PdaStocktakeAction(); + action.setId(IdWorker.getId()); action.setOverseasWarehouseId(warehouseId); action.setOperatorKey(operatorKey); + action.setIdempotencyKey(key); action.setActionType(type); action.setRequestHash(requestHash); + PdaStocktakeTask task = tasks.find(warehouseId, taskNo); action.setTaskId(task == null ? null : task.getId()); + action.setStatus("PROCESSING"); action.setCreatedAt(LocalDateTime.now()); action.setUpdatedAt(action.getCreatedAt()); + try { actions.insert(action); return action; } + catch (DuplicateKeyException duplicate) { + PdaStocktakeAction winner = actions.find(warehouseId, operatorKey, key); + if (winner == null) throw duplicate; + return replay(winner, type, requestHash); + } + } + + private PdaStocktakeAction replay(PdaStocktakeAction action, String type, String requestHash) { + if (!type.equals(action.getActionType()) || !requestHash.equals(action.getRequestHash())) { + throw conflict("Idempotency key was reused with a different stocktake action"); + } + if (!"COMPLETED".equals(action.getStatus())) throw conflict("Stocktake action is still processing"); + return action; + } + + private void complete(PdaStocktakeAction action, Object response) { + action.setStatus("COMPLETED"); action.setResultJson(write(response)); action.setUpdatedAt(LocalDateTime.now()); + actions.updateById(action); + } + + private T read(PdaStocktakeAction action, Class type) { + try { return objectMapper.readValue(action.getResultJson(), type); } + catch (JsonProcessingException error) { throw new IllegalStateException("Cannot read stocktake replay", error); } + } + + private void ledger(PdaStocktakeTask task, PdaStocktakeItem item, InventoryPosition position, + InventoryBalance balance, String operator, String key, String reason, + long before, long delta, long after, LocalDateTime now) { + InventoryLedger row = new InventoryLedger(); + row.setId(IdWorker.getId()); row.setCustomerId(item.getCustomerId()); row.setBalanceId(balance.getId()); + row.setPositionId(position.getId()); row.setOverseasWarehouseId(String.valueOf(task.getOverseasWarehouseId())); + row.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); row.setInboundOrderId(balance.getInboundOrderId()); + row.setPalletId(position.getPalletId()); row.setPalletNo(position.getPalletNo()); row.setBatchNo(position.getBatchNo()); + row.setSkuCode(item.getSkuCode()); row.setWarehouseLocationCode(position.getWarehouseLocationCode()); + row.setBusinessType("STOCKTAKE_ADJUST"); row.setBusinessOrderId(task.getId()); row.setBusinessOrderNo(task.getTaskNo()); + row.setInboundCode(balance.getInboundCode()); row.setInboundOrderNo(balance.getInboundOrderNo()); + row.setIdempotencyKey("STOCKTAKE:" + task.getId() + ":" + item.getId()); + row.setStatusFrom("PHYSICAL"); row.setStatusTo("PHYSICAL"); row.setBeforePackages(before); + row.setDeltaPackages(delta); row.setAfterPackages(after); row.setOperatorId(operator); row.setRemark(reason.trim()); + row.setOccurredAt(now); row.setCreatedAt(now); ledgers.insert(row); + } + + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(write(value).getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); } + } + private String write(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException error) { throw new IllegalStateException("Cannot serialize stocktake operation", error); } + } + private static String status(String value) { + String normalized = trim(value); if (normalized == null) return null; + normalized = normalized.toUpperCase(); + if (!List.of("IN_PROGRESS", "SUBMITTED").contains(normalized)) throw badRequest("Unsupported stocktake status"); + return normalized; + } + private static long physical(InventoryPosition value) { return value(value.getAvailablePackages()) + value(value.getHoldPackages()) + + value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); } + private static long physicalAfter(InventoryPosition value, long available) { return available + value(value.getHoldPackages()) + + value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); } + private static long value(Long value) { return value == null ? 0 : value; } + private static int intValue(Integer value) { return value == null ? 0 : value; } + private static String required(String value, String message) { String normalized = trim(value); if (normalized == null) throw badRequest(message); return normalized; } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/PdaRelabelMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/PdaRelabelMapperConfiguration.java new file mode 100644 index 000000000..893536c8e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/PdaRelabelMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.relabel.pda; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.relabel.pda.mapper") +public class PdaRelabelMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java new file mode 100644 index 000000000..6d89ac391 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java @@ -0,0 +1,127 @@ +package com.baiyue.lastleg.relabel.pda.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests; +import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses; +import com.baiyue.lastleg.relabel.pda.service.PdaRelabelService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@RestController +@RequestMapping("/api/pda/v1/last-leg/relabel-tasks") +@PreAuthorize("hasAuthority('lastleg:pda:operate')") +public class PdaRelabelController { + public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; + private static final String PICKER_ROLE = "ROLE_WAREHOUSE_PICKER"; + + @Resource private PdaRelabelService service; + + @GetMapping("/candidates") + public ApiResponse> candidates( + @AuthenticationPrincipal AuthenticatedUser user) { + Identity identity = identity(user); + return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId())); + } + + @GetMapping("/{relabelNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo) { + Identity identity = identity(user); + return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), relabelNo)); + } + + @PostMapping("/{relabelNo}/claim") + public ApiResponse claim(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Claim request) { + Identity identity = identity(user); + return ApiResponse.success(service.claim(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + @PostMapping("/{relabelNo}/units") + public ApiResponse scan(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Scan request) { + Identity identity = identity(user); + return ApiResponse.success(service.scan(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + @PostMapping("/{relabelNo}/units/skip") + public ApiResponse skip(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Skip request) { + Identity identity = identity(user); + return ApiResponse.success(service.skip(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + @PostMapping("/{relabelNo}/exceptions") + public ApiResponse exception(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.ExceptionFact request) { + Identity identity = identity(user); + return ApiResponse.success(service.exception(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + @PostMapping(value = "/{relabelNo}/photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @RequestPart("file") MultipartFile file) { + Identity identity = identity(user); + return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), identity.overseasWarehouseId(), + identity.userId(), relabelNo, file)); + } + + @PostMapping("/{relabelNo}/complete") + public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Complete request) { + Identity identity = identity(user); + return ApiResponse.success(service.complete(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + @PostMapping("/{relabelNo}/review") + public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Review request) { + Identity identity = identity(user); + return ApiResponse.success(service.review(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + static Identity identity(AuthenticatedUser user) { + if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0L + || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, + "Authenticated platform warehouse PDA identity is required"); + } + if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION) + || !user.permissions().contains(PICKER_ROLE)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + "WAREHOUSE_PICKER and " + OPERATE_PERMISSION + " are required"); + } + if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + "PDA operator must be bound to one overseas warehouse"); + } + String operator = String.valueOf(user.userId()); + return new Identity(user.userId(), operator, + user.username() == null || user.username().isBlank() ? operator : user.username(), + user.overseasWarehouseId()); + } + + record Identity(long userId, String operatorKey, String operatorName, long overseasWarehouseId) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java new file mode 100644 index 000000000..c363ac059 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.relabel.pda.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public final class PdaRelabelRequests { + private PdaRelabelRequests() { } + + public record Claim(@NotBlank @Size(max = 255) String idempotencyKey) { } + + public record Scan(@NotNull @Positive Long itemId, + @NotBlank @Size(max = 255) String oldLabel, + @NotBlank @Size(max = 255) String newLabel, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey) { } + + public record Skip(@NotNull @Positive Long itemId, + @NotBlank @Size(max = 255) String oldLabel, + @NotBlank @Size(max = 1000) String reason, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey) { } + + public record ExceptionFact(@NotNull @Positive Long itemId, + @Size(max = 255) String oldLabel, + @NotBlank @Size(max = 32) String exceptionCode, + @NotBlank @Size(max = 1000) String reason, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey) { } + + public record Complete(@NotEmpty @Size(max = 20) List<@Positive Long> photoFileIds, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) { } + + public record Review(@NotBlank @Size(max = 16) String decision, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java new file mode 100644 index 000000000..72724a325 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.relabel.pda.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class PdaRelabelResponses { + private PdaRelabelResponses() { } + + public record Candidate(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline, + String applicationStatus, String taskStatus, int itemCount, + int expectedPackages, int relabelledPackages, int skippedPackages, + int exceptionPackages, int version, boolean claimedByCurrentOperator) { } + + public record Detail(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline, + String contactName, String contactPhone, String applicationStatus, + String taskStatus, int expectedPackages, int relabelledPackages, + int skippedPackages, int exceptionPackages, int version, + String operatorName, String reviewerName, List completionPhotoFileIds, + String completionRemark, String reviewDecision, String reviewRemark, + LocalDateTime claimedAt, LocalDateTime submittedAt, LocalDateTime reviewedAt, + List items, List unitFacts, List history) { } + + public record Item(Long itemId, String skuCode, String productNameZh, String shipmentNo, + String trackingCode, String newSkuCode, String labelRequirement, + String relabelFileId, String relabelFileName, String relabelFileUrl, + int expectedPackages, int relabelledPackages, int skippedPackages, + int exceptionPackages) { } + + public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel, + String result, String exceptionCode, String reason, + String operatorName, LocalDateTime occurredAt) { } + + public record History(String action, String fromStatus, String toStatus, + String operatorName, String detail, LocalDateTime createdAt) { } + + public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelHistory.java new file mode 100644 index 000000000..674a07be8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelHistory.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.relabel.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_relabel_pda_history") +public class PdaRelabelHistory { + @TableId(type = IdType.INPUT) private Long id; + private Long taskId; + private Long applicationId; + private String customerId; + private String action; + private String fromStatus; + private String toStatus; + private String operatorKey; + private String operatorName; + private String detailJson; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelOperation.java new file mode 100644 index 000000000..991bbea9d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelOperation.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.relabel.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_relabel_pda_operation") +public class PdaRelabelOperation { + @TableId(type = IdType.INPUT) private Long id; + private Long taskId; + private String customerId; + private String operatorKey; + private String action; + private String idempotencyKey; + private String requestHash; + private String resultJson; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelTask.java new file mode 100644 index 000000000..f2e07ea86 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelTask.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.relabel.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_relabel_pda_task") +public class PdaRelabelTask { + @TableId(type = IdType.INPUT) private Long id; + private Long applicationId; + private String customerId; + private String relabelNo; + private Long overseasWarehouseId; + private String status; + private String operatorKey; + private String operatorName; + private String reviewerKey; + private String reviewerName; + private Integer expectedPackages; + private Integer relabelledPackages; + private Integer skippedPackages; + private Integer exceptionPackages; + private String completionPhotoFileIds; + private String completionRemark; + private String reviewDecision; + private String reviewRemark; + private LocalDateTime claimedAt; + private LocalDateTime submittedAt; + private LocalDateTime reviewedAt; + private Integer version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelUnitFact.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelUnitFact.java new file mode 100644 index 000000000..a6ece68da --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelUnitFact.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.relabel.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_relabel_pda_unit_fact") +public class PdaRelabelUnitFact { + @TableId(type = IdType.INPUT) private Long id; + private Long taskId; + private Long applicationId; + private Long itemId; + private String customerId; + private String oldLabel; + private String newLabel; + private String result; + private String exceptionCode; + private String reason; + private String operatorKey; + private String operatorName; + private LocalDateTime occurredAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java new file mode 100644 index 000000000..12cf36e23 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java @@ -0,0 +1,109 @@ +package com.baiyue.lastleg.relabel.pda.gateway; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; + +@Component +public class HttpRelabelPhotoFileGateway implements RelabelPhotoFileGateway { + private static final String BUSINESS_TYPE = "OVERSEAS_RELABEL_PHOTO"; + @Resource private RestClient.Builder restClientBuilder; + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + private RestClient client; + + @PostConstruct + void initializeClient() { client = restClientBuilder.baseUrl(baseUrl).build(); } + + @Override + public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) { + long tenantId = tenantId(customerId); + MultipartBodyBuilder body = new MultipartBodyBuilder(); + try { + body.part("file", new ByteArrayResource(file.getBytes()) { + @Override public String getFilename() { return file.getOriginalFilename(); } + }).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM + : MediaType.parseMediaType(file.getContentType())); + } catch (IOException exception) { + throw new IllegalArgumentException("Cannot read relabel photo", exception); + } + MetadataEnvelope response = client.post() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", + tenantId, BUSINESS_TYPE, taskId) + .header("X-Internal-Service-Token", internalToken) + .header("X-Operator-Id", String.valueOf(operatorId)) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "Relabel photo upload failed"); + }).body(MetadataEnvelope.class); + requireSuccess(response, "Relabel photo upload failed"); + Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "File service returned no relabel photo metadata"); + } + return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), + data.sizeBytes() == null ? 0L : data.sizeBytes()); + } + + @Override + public void validate(String customerId, long taskId, long fileId) { + long tenantId = tenantId(customerId); + MetadataEnvelope response = client.get() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}", + tenantId, BUSINESS_TYPE, taskId, fileId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "Relabel photo does not exist or its ownership is invalid"); + }).body(MetadataEnvelope.class); + requireSuccess(response, "Relabel photo does not exist or its ownership is invalid"); + Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null || data.fileId() != fileId + || data.tenantId() == null || data.tenantId() != tenantId + || !BUSINESS_TYPE.equals(data.businessType()) + || !String.valueOf(taskId).equals(data.businessId())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Relabel photo metadata does not match the PDA task"); + } + } + + private static long tenantId(String customerId) { + try { return Long.parseLong(customerId); } + catch (NumberFormatException exception) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Customer ID must be numeric", exception); + } + } + + private static void requireSuccess(MetadataEnvelope response, String fallback) { + if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback); + if ("200".equals(response.code())) return; + HttpStatus status = switch (String.valueOf(response.code())) { + case "A0400", "A0422" -> HttpStatus.BAD_REQUEST; + case "A0403" -> HttpStatus.FORBIDDEN; + case "A0404" -> HttpStatus.NOT_FOUND; + default -> HttpStatus.SERVICE_UNAVAILABLE; + }; + throw new ResponseStatusException(status, + response.message() == null || response.message().isBlank() ? fallback : response.message()); + } + + private record MetadataEnvelope(String code, String message, Metadata data) { } + private record Metadata(Long fileId, Long tenantId, String businessType, String businessId, + String fileName, String contentType, Long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/RelabelPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/RelabelPhotoFileGateway.java new file mode 100644 index 000000000..c309f5dd8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/RelabelPhotoFileGateway.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.relabel.pda.gateway; + +import org.springframework.web.multipart.MultipartFile; + +public interface RelabelPhotoFileGateway { + UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file); + void validate(String customerId, long taskId, long fileId); + + record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelHistoryMapper.java new file mode 100644 index 000000000..ccd126e40 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelHistoryMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.relabel.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PdaRelabelHistoryMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelOperationMapper.java new file mode 100644 index 000000000..8e96b2050 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelOperationMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.relabel.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelOperation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface PdaRelabelOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_relabel_pda_operation WHERE operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1") + PdaRelabelOperation find(@Param("operatorKey") String operatorKey, @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java new file mode 100644 index 000000000..ee6b56521 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.relabel.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface PdaRelabelTaskMapper extends BaseMapper { + @Select("SELECT * FROM ll_relabel_pda_task WHERE customer_id=#{customerId} AND application_id=#{applicationId} LIMIT 1") + PdaRelabelTask find(@Param("customerId") String customerId, @Param("applicationId") Long applicationId); + + @Select("SELECT * FROM ll_relabel_pda_task WHERE customer_id=#{customerId} AND application_id=#{applicationId} LIMIT 1 FOR UPDATE") + PdaRelabelTask lock(@Param("customerId") String customerId, @Param("applicationId") Long applicationId); + + @Update(""" + UPDATE ll_relabel_pda_task + SET status=#{task.status}, operator_key=#{task.operatorKey}, operator_name=#{task.operatorName}, + reviewer_key=#{task.reviewerKey}, reviewer_name=#{task.reviewerName}, + relabelled_packages=#{task.relabelledPackages}, skipped_packages=#{task.skippedPackages}, + exception_packages=#{task.exceptionPackages}, completion_photo_file_ids=#{task.completionPhotoFileIds}, + completion_remark=#{task.completionRemark}, review_decision=#{task.reviewDecision}, + review_remark=#{task.reviewRemark}, submitted_at=#{task.submittedAt}, reviewed_at=#{task.reviewedAt}, + version=version+1, updated_at=#{task.updatedAt} + WHERE id=#{task.id} AND customer_id=#{task.customerId} AND version=#{expectedVersion} + """) + int updateWithVersion(@Param("task") PdaRelabelTask task, @Param("expectedVersion") Integer expectedVersion); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelUnitFactMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelUnitFactMapper.java new file mode 100644 index 000000000..af9100c89 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelUnitFactMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.relabel.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface PdaRelabelUnitFactMapper extends BaseMapper { + @Select("SELECT * FROM ll_relabel_pda_unit_fact WHERE old_label=#{oldLabel} LIMIT 1") + PdaRelabelUnitFact findOldLabel(@Param("oldLabel") String oldLabel); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java new file mode 100644 index 000000000..a9fc30a0b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java @@ -0,0 +1,480 @@ +package com.baiyue.lastleg.relabel.pda.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests; +import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelOperation; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact; +import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelOperationMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +@Service +public class PdaRelabelService { + public static final String PROCESSING = "PROCESSING"; + public static final String WAIT_REVIEW = "WAIT_REVIEW"; + public static final String COMPLETED = "COMPLETED"; + private static final long MAX_PHOTO_SIZE = 10L * 1024 * 1024; + private static final Set EXCEPTION_CODES = Set.of( + "DAMAGED", "MISSING_LABEL", "LABEL_MISMATCH", "UNREADABLE", "OTHER"); + + @Resource private RelabelApplicationMapper applications; + @Resource private RelabelItemMapper items; + @Resource private PdaRelabelTaskMapper tasks; + @Resource private PdaRelabelUnitFactMapper unitFacts; + @Resource private PdaRelabelOperationMapper operations; + @Resource private PdaRelabelHistoryMapper histories; + @Resource private RelabelApplicationService applicationService; + @Resource private RelabelPhotoFileGateway photoFiles; + @Resource private ObjectMapper sourceObjectMapper; + private ObjectMapper objectMapper; + + @PostConstruct + void initializeObjectMapper() { + objectMapper = sourceObjectMapper.copy().findAndRegisterModules() + .configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true) + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + } + + @Transactional(readOnly = true) + public List candidates(String operatorKey, long overseasWarehouseId) { + return applications.selectList(new LambdaQueryWrapper() + .eq(RelabelApplication::getOverseasWarehouseId, String.valueOf(overseasWarehouseId)) + .in(RelabelApplication::getStatus, RelabelApplicationService.PENDING_PROCESSING, PROCESSING) + .orderByAsc(RelabelApplication::getLabelDeadline) + .orderByAsc(RelabelApplication::getCreatedAt)) + .stream().map(application -> candidate(application, + tasks.find(application.getCustomerId(), application.getId()), operatorKey)).toList(); + } + + @Transactional(readOnly = true) + public PdaRelabelResponses.Detail detail(String operatorKey, long overseasWarehouseId, String relabelNo) { + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, false); + return detail(application, tasks.find(application.getCustomerId(), application.getId())); + } + + @Transactional + public PdaRelabelResponses.Detail claim(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.Claim request) { + ClaimCommand command = new ClaimCommand(relabelNo, request.idempotencyKey()); + PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo, + "CLAIM", request.idempotencyKey(), command); + if (replay != null) return replay; + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true); + if (!Set.of(RelabelApplicationService.PENDING_PROCESSING, PROCESSING).contains(application.getStatus())) { + throw conflict("Relabel application is not executable"); + } + PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId()); + if (task != null) { + if (!operatorKey.equals(task.getOperatorKey()) || !PROCESSING.equals(task.getStatus())) { + throw conflict("Relabel task is already claimed or awaiting review"); + } + } else { + LocalDateTime now = LocalDateTime.now(); + task = new PdaRelabelTask(); + task.setId(IdWorker.getId()); + task.setApplicationId(application.getId()); task.setCustomerId(application.getCustomerId()); + task.setRelabelNo(application.getRelabelNo()); task.setOverseasWarehouseId(overseasWarehouseId); + task.setStatus(PROCESSING); task.setOperatorKey(operatorKey); task.setOperatorName(operatorName); + task.setExpectedPackages(expectedPackages(application.getId())); + task.setRelabelledPackages(0); task.setSkippedPackages(0); task.setExceptionPackages(0); + task.setClaimedAt(now); task.setVersion(0); task.setCreatedAt(now); task.setUpdatedAt(now); + try { tasks.insert(task); } + catch (DuplicateKeyException duplicate) { throw conflict("Relabel task was claimed concurrently"); } + if (RelabelApplicationService.PENDING_PROCESSING.equals(application.getStatus())) { + applicationService.changeStatus(application.getCustomerId(), application.getRelabelNo(), operatorKey, + new RelabelRequests.ChangeStatus(PROCESSING, "PDA relabel task claimed")); + application.setStatus(PROCESSING); + } + insertHistory(task, "CLAIM", null, PROCESSING, operatorKey, operatorName, + Map.of("relabelNo", relabelNo)); + } + PdaRelabelResponses.Detail result = detail(application, task); + saveOperation(task, operatorKey, "CLAIM", request.idempotencyKey(), command, result); + return result; + } + + @Transactional + public PdaRelabelResponses.Detail scan(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.Scan request) { + return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "RELABEL", request.idempotencyKey(), + request, request.itemId(), request.oldLabel(), request.newLabel(), "RELABELLED", null, null, + request.expectedVersion(), item -> { + if (!item.getNewSkuCode().equalsIgnoreCase(request.newLabel().trim())) { + throw conflict("Scanned new label does not match the approved new SKU"); + } + if (request.oldLabel().trim().equalsIgnoreCase(request.newLabel().trim())) { + throw conflict("Old and new labels must be different"); + } + }); + } + + @Transactional + public PdaRelabelResponses.Detail skip(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.Skip request) { + return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "SKIP", request.idempotencyKey(), + request, request.itemId(), request.oldLabel(), null, "SKIPPED", null, request.reason(), + request.expectedVersion(), ignored -> { }); + } + + @Transactional + public PdaRelabelResponses.Detail exception(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.ExceptionFact request) { + String code = request.exceptionCode().trim().toUpperCase(Locale.ROOT); + if (!EXCEPTION_CODES.contains(code)) throw badRequest("Unsupported relabel exception code"); + return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "EXCEPTION", + request.idempotencyKey(), request, request.itemId(), trim(request.oldLabel()), null, + "EXCEPTION", code, request.reason(), request.expectedVersion(), ignored -> { }); + } + + public PdaRelabelResponses.Photo uploadPhoto(String operatorKey, long overseasWarehouseId, long operatorId, + String relabelNo, MultipartFile file) { + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, false); + PdaRelabelTask task = requireOwnedTask(application, operatorKey, false); + if (!PROCESSING.equals(task.getStatus())) throw conflict("Relabel photos can only be uploaded while processing"); + if (file == null || file.isEmpty()) throw badRequest("Relabel photo is required"); + if (file.getSize() > MAX_PHOTO_SIZE) throw badRequest("Relabel photo exceeds 10 MB"); + if (file.getContentType() == null || !file.getContentType().toLowerCase(Locale.ROOT).startsWith("image/")) { + throw badRequest("Relabel photo must be an image"); + } + RelabelPhotoFileGateway.UploadedFile uploaded = photoFiles.upload(application.getCustomerId(), + task.getId(), operatorId, file); + return new PdaRelabelResponses.Photo(uploaded.fileId(), uploaded.fileName(), + uploaded.contentType(), uploaded.sizeBytes()); + } + + @Transactional + public PdaRelabelResponses.Detail complete(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.Complete request) { + CompleteCommand command = new CompleteCommand(relabelNo, request); + PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo, + "COMPLETE", request.idempotencyKey(), command); + if (replay != null) return replay; + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true); + PdaRelabelTask task = requireOwnedTask(application, operatorKey, true); + requireVersion(task, request.expectedVersion()); + List detailItems = itemList(application); + List facts = factList(task); + for (RelabelItem item : detailItems) { + long actual = facts.stream().filter(fact -> item.getId().equals(fact.getItemId())).count(); + if (actual != value(item.getNotifiedPackageCount())) { + throw conflict("Every relabel item must have exactly one fact per notified package"); + } + } + List photoIds = request.photoFileIds().stream().distinct().toList(); + if (photoIds.size() != request.photoFileIds().size()) throw conflict("Relabel photo IDs must be unique"); + photoIds.forEach(fileId -> photoFiles.validate(application.getCustomerId(), task.getId(), fileId)); + String before = task.getStatus(); + task.setStatus(WAIT_REVIEW); task.setCompletionPhotoFileIds(write(photoIds)); + task.setCompletionRemark(trim(request.remark())); task.setSubmittedAt(LocalDateTime.now()); + updateTask(task, request.expectedVersion()); + insertHistory(task, "COMPLETE", before, WAIT_REVIEW, operatorKey, operatorName, + Map.of("photoFileIds", photoIds, "remark", Objects.toString(trim(request.remark()), ""))); + application.setStatus(PROCESSING); + PdaRelabelResponses.Detail result = detail(application, task); + saveOperation(task, operatorKey, "COMPLETE", request.idempotencyKey(), command, result); + return result; + } + + @Transactional + public PdaRelabelResponses.Detail review(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.Review request) { + ReviewCommand command = new ReviewCommand(relabelNo, request); + PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo, + "REVIEW", request.idempotencyKey(), command); + if (replay != null) return replay; + String decision = request.decision().trim().toUpperCase(Locale.ROOT); + if (!Set.of("APPROVE", "REWORK").contains(decision)) throw badRequest("Review decision must be APPROVE or REWORK"); + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true); + if (!PROCESSING.equals(application.getStatus())) { + throw conflict("Relabel application is no longer executable"); + } + PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId()); + if (task == null) throw notFound("PDA relabel task was not found"); + if (!Objects.equals(task.getOverseasWarehouseId(), overseasWarehouseId)) { + throw notFound("PDA relabel task was not found in the bound overseas warehouse"); + } + if (!WAIT_REVIEW.equals(task.getStatus())) throw conflict("Relabel task is not awaiting review"); + requireVersion(task, request.expectedVersion()); + String before = task.getStatus(); + String after = "APPROVE".equals(decision) ? COMPLETED : PROCESSING; + task.setStatus(after); task.setReviewerKey(operatorKey); task.setReviewerName(operatorName); + task.setReviewDecision(decision); task.setReviewRemark(trim(request.remark())); + task.setReviewedAt(LocalDateTime.now()); + updateTask(task, request.expectedVersion()); + if ("APPROVE".equals(decision)) { + applicationService.changeStatus(application.getCustomerId(), relabelNo, operatorKey, + new RelabelRequests.ChangeStatus(COMPLETED, request.remark())); + application.setStatus(COMPLETED); + } + insertHistory(task, "REVIEW_" + decision, before, after, operatorKey, operatorName, + Map.of("decision", decision, "remark", Objects.toString(trim(request.remark()), ""))); + PdaRelabelResponses.Detail result = detail(application, task); + saveOperation(task, operatorKey, "REVIEW", request.idempotencyKey(), command, result); + return result; + } + + private PdaRelabelResponses.Detail unitAction(String operatorKey, String operatorName, + long overseasWarehouseId, String relabelNo, String action, String idempotencyKey, Object command, + Long itemId, String oldLabel, String newLabel, String resultCode, String exceptionCode, String reason, + Integer expectedVersion, Consumer validation) { + UnitCommand replayCommand = new UnitCommand(relabelNo, action, command); + PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo, + action, idempotencyKey, replayCommand); + if (replay != null) return replay; + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true); + PdaRelabelTask task = requireOwnedTask(application, operatorKey, true); + requireVersion(task, expectedVersion); + RelabelItem item = requireItem(application, itemId); + validation.accept(item); + List existingFacts = factList(task); + long itemFacts = existingFacts.stream().filter(fact -> itemId.equals(fact.getItemId())).count(); + if (itemFacts >= value(item.getNotifiedPackageCount())) { + throw conflict("Relabel item already has all required package facts"); + } + String normalizedOldLabel = trim(oldLabel); + if (normalizedOldLabel != null && unitFacts.findOldLabel(normalizedOldLabel) != null) { + throw conflict("Old label was already processed by another relabel unit"); + } + PdaRelabelUnitFact fact = new PdaRelabelUnitFact(); + fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId()); + fact.setItemId(itemId); fact.setCustomerId(application.getCustomerId()); + fact.setOldLabel(normalizedOldLabel); fact.setNewLabel(trim(newLabel)); fact.setResult(resultCode); + fact.setExceptionCode(exceptionCode); fact.setReason(trim(reason)); + fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now()); + try { unitFacts.insert(fact); } + catch (DuplicateKeyException duplicate) { throw conflict("Old label was processed concurrently"); } + if ("RELABELLED".equals(resultCode)) task.setRelabelledPackages(value(task.getRelabelledPackages()) + 1); + if ("SKIPPED".equals(resultCode)) task.setSkippedPackages(value(task.getSkippedPackages()) + 1); + if ("EXCEPTION".equals(resultCode)) task.setExceptionPackages(value(task.getExceptionPackages()) + 1); + updateTask(task, expectedVersion); + insertHistory(task, action, PROCESSING, PROCESSING, operatorKey, operatorName, + Map.of("factId", fact.getId(), "itemId", itemId, "result", resultCode)); + PdaRelabelResponses.Detail response = detail(application, task); + saveOperation(task, operatorKey, action, idempotencyKey, replayCommand, response); + return response; + } + + private RelabelApplication requireApplication(String relabelNo, long overseasWarehouseId, boolean lock) { + RelabelApplication application = lock ? applications.lockAnyByRelabelNo(relabelNo) + : applications.findAnyByRelabelNo(relabelNo); + if (application == null) throw notFound("Relabel application was not found"); + long applicationWarehouse; + try { applicationWarehouse = Long.parseLong(application.getOverseasWarehouseId()); } + catch (Exception invalid) { throw conflict("Relabel application has no valid overseas warehouse"); } + if (applicationWarehouse != overseasWarehouseId) { + throw notFound("Relabel application was not found in the bound overseas warehouse"); + } + return application; + } + + private PdaRelabelTask requireOwnedTask(RelabelApplication application, String operatorKey, boolean lock) { + if (!PROCESSING.equals(application.getStatus())) { + throw conflict("Relabel application is no longer executable"); + } + PdaRelabelTask task = lock ? tasks.lock(application.getCustomerId(), application.getId()) + : tasks.find(application.getCustomerId(), application.getId()); + if (task == null) throw conflict("Claim the relabel task before operating"); + if (!operatorKey.equals(task.getOperatorKey())) throw conflict("Relabel task is claimed by another operator"); + if (!Objects.equals(task.getOverseasWarehouseId(), Long.parseLong(application.getOverseasWarehouseId()))) { + throw conflict("Relabel task warehouse does not match the approved application"); + } + if (!PROCESSING.equals(task.getStatus())) throw conflict("Relabel task is not in processing status"); + return task; + } + + private RelabelItem requireItem(RelabelApplication application, Long itemId) { + RelabelItem item = items.selectOne(new LambdaQueryWrapper() + .eq(RelabelItem::getId, itemId).eq(RelabelItem::getApplicationId, application.getId()) + .eq(RelabelItem::getCustomerId, application.getCustomerId())); + if (item == null) throw notFound("Relabel item does not belong to the current application"); + return item; + } + + private void requireVersion(PdaRelabelTask task, Integer expectedVersion) { + if (!Objects.equals(task.getVersion(), expectedVersion)) { + throw conflict("Relabel task version changed; reload before retrying"); + } + } + + private void updateTask(PdaRelabelTask task, Integer expectedVersion) { + task.setUpdatedAt(LocalDateTime.now()); + if (tasks.updateWithVersion(task, expectedVersion) != 1) { + throw conflict("Relabel task version changed concurrently"); + } + task.setVersion(expectedVersion + 1); + } + + private int expectedPackages(Long applicationId) { + return itemList(applicationId).stream().mapToInt(item -> value(item.getNotifiedPackageCount())).sum(); + } + + private List itemList(RelabelApplication application) { return itemList(application.getId()); } + private List itemList(Long applicationId) { + return items.selectList(new LambdaQueryWrapper() + .eq(RelabelItem::getApplicationId, applicationId).orderByAsc(RelabelItem::getId)); + } + + private List factList(PdaRelabelTask task) { + return unitFacts.selectList(new LambdaQueryWrapper() + .eq(PdaRelabelUnitFact::getCustomerId, task.getCustomerId()) + .eq(PdaRelabelUnitFact::getTaskId, task.getId()) + .orderByAsc(PdaRelabelUnitFact::getOccurredAt).orderByAsc(PdaRelabelUnitFact::getId)); + } + + private PdaRelabelResponses.Candidate candidate(RelabelApplication application, PdaRelabelTask task, + String operatorKey) { + return new PdaRelabelResponses.Candidate(application.getRelabelNo(), application.getCustomerOrderNo(), + application.getLabelDeadline(), application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(), + value(application.getItemCount()), task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()), + task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()), + task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()), + task != null && operatorKey.equals(task.getOperatorKey())); + } + + private PdaRelabelResponses.Detail detail(RelabelApplication application, PdaRelabelTask task) { + List detailItems = itemList(application); + List facts = task == null ? List.of() : factList(task); + Map> factsByItem = facts.stream() + .collect(Collectors.groupingBy(PdaRelabelUnitFact::getItemId, LinkedHashMap::new, Collectors.toList())); + List itemResponses = detailItems.stream().map(item -> { + List itemFacts = factsByItem.getOrDefault(item.getId(), List.of()); + return new PdaRelabelResponses.Item(item.getId(), item.getSkuCode(), item.getProductNameZh(), + item.getShipmentNo(), item.getTrackingCode(), item.getNewSkuCode(), item.getLabelRequirement(), + item.getRelabelFileId(), item.getRelabelFileName(), item.getRelabelFileUrl(), + value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"), + count(itemFacts, "EXCEPTION")); + }).toList(); + List factResponses = facts.stream().map(fact -> + new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(), + fact.getResult(), fact.getExceptionCode(), fact.getReason(), fact.getOperatorName(), fact.getOccurredAt())).toList(); + List historyResponses = task == null ? List.of() : histories.selectList( + new LambdaQueryWrapper().eq(PdaRelabelHistory::getCustomerId, application.getCustomerId()) + .eq(PdaRelabelHistory::getTaskId, task.getId()) + .orderByAsc(PdaRelabelHistory::getCreatedAt).orderByAsc(PdaRelabelHistory::getId)).stream() + .map(history -> new PdaRelabelResponses.History(history.getAction(), history.getFromStatus(), + history.getToStatus(), history.getOperatorName(), history.getDetailJson(), history.getCreatedAt())).toList(); + return new PdaRelabelResponses.Detail(application.getRelabelNo(), application.getCustomerOrderNo(), + application.getLabelDeadline(), application.getContactName(), application.getContactPhone(), + application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(), + task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()), + task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()), + task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()), + task == null ? null : task.getOperatorName(), task == null ? null : task.getReviewerName(), + task == null ? List.of() : readIds(task.getCompletionPhotoFileIds()), + task == null ? null : task.getCompletionRemark(), task == null ? null : task.getReviewDecision(), + task == null ? null : task.getReviewRemark(), task == null ? null : task.getClaimedAt(), + task == null ? null : task.getSubmittedAt(), task == null ? null : task.getReviewedAt(), + itemResponses, factResponses, historyResponses); + } + + private void insertHistory(PdaRelabelTask task, String action, String from, String to, + String operatorKey, String operatorName, Object detail) { + PdaRelabelHistory history = new PdaRelabelHistory(); + history.setId(IdWorker.getId()); history.setTaskId(task.getId()); + history.setApplicationId(task.getApplicationId()); history.setCustomerId(task.getCustomerId()); + history.setAction(action); history.setFromStatus(from); history.setToStatus(to); + history.setOperatorKey(operatorKey); history.setOperatorName(operatorName); + history.setDetailJson(write(detail)); history.setCreatedAt(LocalDateTime.now()); + histories.insert(history); + } + + private PdaRelabelResponses.Detail replay(String operatorKey, long overseasWarehouseId, String relabelNo, + String action, String key, Object request) { + PdaRelabelOperation operation = operations.find(operatorKey, idempotencyKey(key)); + if (operation == null) return null; + PdaRelabelTask task = tasks.selectById(operation.getTaskId()); + if (task == null || !Objects.equals(task.getOverseasWarehouseId(), overseasWarehouseId) + || !Objects.equals(task.getRelabelNo(), relabelNo) + || !Objects.equals(task.getCustomerId(), operation.getCustomerId())) { + throw notFound("PDA relabel operation was not found in the bound overseas warehouse"); + } + if (!action.equals(operation.getAction()) || !hash(request).equals(operation.getRequestHash())) { + throw conflict("Idempotency key was already used with a different relabel request"); + } + try { return objectMapper.readValue(operation.getResultJson(), PdaRelabelResponses.Detail.class); } + catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read PDA relabel replay", exception); } + } + + private void saveOperation(PdaRelabelTask task, String operatorKey, String action, String key, + Object request, PdaRelabelResponses.Detail result) { + PdaRelabelOperation operation = new PdaRelabelOperation(); + operation.setId(IdWorker.getId()); operation.setTaskId(task.getId()); operation.setCustomerId(task.getCustomerId()); + operation.setOperatorKey(operatorKey); operation.setAction(action); operation.setIdempotencyKey(idempotencyKey(key)); + operation.setRequestHash(hash(request)); operation.setResultJson(write(result)); operation.setCreatedAt(LocalDateTime.now()); + try { operations.insert(operation); } + catch (DuplicateKeyException duplicate) { throw conflict("Relabel operation was submitted concurrently"); } + } + + private String hash(Object value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(write(value).getBytes(StandardCharsets.UTF_8))); + } catch (Exception exception) { throw new IllegalStateException(exception); } + } + + private String write(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { throw new IllegalStateException(exception); } + } + + private List readIds(String value) { + if (!StringUtils.hasText(value)) return List.of(); + try { + return objectMapper.readValue(value, objectMapper.getTypeFactory().constructCollectionType(List.class, Long.class)); + } catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read relabel photo IDs", exception); } + } + + private static int count(List facts, String result) { + return (int) facts.stream().filter(fact -> result.equals(fact.getResult())).count(); + } + private static int value(Integer value) { return value == null ? 0 : value; } + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static String idempotencyKey(String value) { return value == null ? null : value.trim(); } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } + + private record ClaimCommand(String relabelNo, String idempotencyKey) { } + private record UnitCommand(String relabelNo, String action, Object request) { } + private record CompleteCommand(String relabelNo, PdaRelabelRequests.Complete request) { } + private record ReviewCommand(String relabelNo, PdaRelabelRequests.Review request) { } +} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 201ed9d60..893cea457 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -744,6 +744,72 @@ CREATE TABLE IF NOT EXISTS ll_pda_operation_history ( KEY idx_ll_pda_history_business(overseas_warehouse_id,operation_type,business_key) ) ENGINE=InnoDB COMMENT='海外PDA真实作业历史'; +CREATE TABLE IF NOT EXISTS ll_pda_pallet_operation ( + id BIGINT NOT NULL COMMENT 'PDA托盘作业ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '设备幂等键', + operation_type VARCHAR(64) NOT NULL COMMENT '托盘作业类型', request_hash VARCHAR(128) NOT NULL COMMENT '请求内容摘要', + status VARCHAR(32) NOT NULL COMMENT '处理状态:PROCESSING、COMPLETED、FAILED_COMPENSATED、COMPENSATION_REQUIRED', + result_json LONGTEXT NULL COMMENT '成功响应快照', remote_transitions_json LONGTEXT NULL COMMENT '实物托盘状态变更回执', + last_error VARCHAR(2000) 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 '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_pallet_idempotency(operator_key,idempotency_key), + KEY idx_ll_pda_pallet_warehouse(overseas_warehouse_id,status,updated_at) +) ENGINE=InnoDB COMMENT='海外PDA托盘作业幂等、补偿与结果记录'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_task ( + id BIGINT NOT NULL COMMENT '盘点任务ID', task_no VARCHAR(64) NOT NULL COMMENT '盘点任务号', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', scope_type VARCHAR(16) NOT NULL COMMENT '盘点范围类型LOCATION或PALLET', + scope_resource_id BIGINT NOT NULL COMMENT '盘点库位或托盘资源ID', scope_code VARCHAR(128) NOT NULL COMMENT '盘点库位码或托盘码快照', + status VARCHAR(32) NOT NULL COMMENT '盘点任务状态', position_count INT NOT NULL COMMENT '库存位置总数', + counted_count INT NOT NULL DEFAULT 0 COMMENT '已盘库存位置数', difference_count INT NOT NULL DEFAULT 0 COMMENT '存在差异的库存位置数', + created_by VARCHAR(128) NOT NULL COMMENT '创建操作账号标识', submitted_by VARCHAR(128) NULL COMMENT '提交操作账号标识', + submit_reason VARCHAR(500) NULL COMMENT '盘点差异提交原因', idempotency_key VARCHAR(128) NOT NULL COMMENT '创建幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '创建请求哈希', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + submitted_at DATETIME(3) NULL COMMENT '提交完成时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_no(task_no), + UNIQUE KEY uk_ll_pda_stocktake_create(overseas_warehouse_id,created_by,idempotency_key), + KEY idx_ll_pda_stocktake_list(overseas_warehouse_id,status,created_at) +) ENGINE=InnoDB COMMENT='海外PDA库存盘点任务'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_item ( + id BIGINT NOT NULL COMMENT '盘点明细ID', task_id BIGINT NOT NULL COMMENT '盘点任务ID', customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户标识', + balance_id BIGINT NOT NULL COMMENT '库存余额ID', position_id BIGINT NOT NULL COMMENT '库存位置ID', sku_code VARCHAR(128) NULL COMMENT 'SKU快照', + product_name VARCHAR(255) NULL COMMENT '品名快照', inbound_code VARCHAR(128) NULL COMMENT '入仓码快照', pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', + pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', location_id BIGINT NOT NULL COMMENT '库位ID快照', location_code VARCHAR(128) NOT NULL COMMENT '库位码快照', + book_packages BIGINT NOT NULL COMMENT '任务创建时账面总箱数', actual_packages BIGINT NULL COMMENT '现场实盘总箱数', + difference_packages BIGINT NULL COMMENT '实盘与账面箱数差异', status VARCHAR(32) NOT NULL COMMENT '盘点明细状态', + position_version BIGINT NOT NULL COMMENT '任务创建时库存位置版本', counted_by VARCHAR(128) NULL COMMENT '盘点操作账号标识', + version BIGINT NOT NULL DEFAULT 0 COMMENT '明细乐观锁版本', counted_at DATETIME(3) NULL COMMENT '盘点时间', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_item(task_id,position_id), KEY idx_ll_pda_stocktake_item_task(task_id,status,id) +) ENGINE=InnoDB COMMENT='海外PDA库存盘点位置明细'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_action ( + id BIGINT NOT NULL COMMENT '盘点动作ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '动作幂等键', + action_type VARCHAR(32) NOT NULL COMMENT '盘点动作类型', task_id BIGINT NULL COMMENT '盘点任务ID', request_hash VARCHAR(128) NOT NULL COMMENT '动作请求哈希', + status VARCHAR(32) NOT NULL COMMENT '动作处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_pda_stocktake_action(overseas_warehouse_id,operator_key,idempotency_key), KEY idx_ll_pda_stocktake_action_task(task_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA盘点动作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_pda_inventory_move ( + id BIGINT NOT NULL COMMENT '移库操作ID', move_no VARCHAR(64) NOT NULL COMMENT '移库操作单号', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', pallet_id BIGINT NOT NULL COMMENT '整托移库的实物托盘ID', + pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', source_location_id BIGINT NOT NULL COMMENT '来源库位ID', + source_location_code VARCHAR(128) NOT NULL COMMENT '来源库位码快照', target_location_id BIGINT NOT NULL COMMENT '目标库位ID', + target_location_code VARCHAR(128) NOT NULL COMMENT '目标库位码快照', customer_count INT NOT NULL COMMENT '托盘涉及客户数', + position_count INT NOT NULL COMMENT '迁移库存位置数', package_count BIGINT NOT NULL COMMENT '迁移总箱数', + pallet_version_before INT NOT NULL COMMENT '迁移前托盘版本', pallet_version_after INT NULL COMMENT '迁移后托盘版本', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', reason VARCHAR(500) NOT NULL COMMENT '移库原因', + idempotency_key VARCHAR(128) NOT NULL COMMENT '移库幂等键', request_hash VARCHAR(128) NOT NULL COMMENT '移库请求哈希', + status VARCHAR(32) NOT NULL COMMENT '移库处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', completed_at DATETIME(3) NULL COMMENT '完成时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_pda_inventory_move_no(move_no), + UNIQUE KEY uk_ll_pda_inventory_move_key(overseas_warehouse_id,operator_key,idempotency_key), + KEY idx_ll_pda_inventory_move_pallet(overseas_warehouse_id,pallet_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA同仓整托移库记录'; + CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', @@ -1236,6 +1302,90 @@ CREATE TABLE IF NOT EXISTS ll_relabel_status_history ( KEY idx_ll_relabel_status_history_application (customer_id,application_id,created_at) ) ENGINE=InnoDB COMMENT='换标状态变更历史'; +CREATE TABLE IF NOT EXISTS ll_relabel_pda_task ( + id BIGINT NOT NULL COMMENT '换标PDA任务ID', + application_id BIGINT NOT NULL COMMENT '换标申请单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + relabel_no VARCHAR(64) NOT NULL COMMENT '换标申请单号快照', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'PDA令牌冻结的海外实际作业仓ID', + status VARCHAR(32) NOT NULL COMMENT '任务状态:PROCESSING、WAIT_REVIEW或COMPLETED', + operator_key VARCHAR(128) NOT NULL COMMENT '认领操作账号标识', + operator_name VARCHAR(255) NOT NULL COMMENT '认领操作员名称快照', + reviewer_key VARCHAR(128) NULL COMMENT '复核账号标识', + reviewer_name VARCHAR(255) NULL COMMENT '复核员名称快照', + expected_packages INT NOT NULL COMMENT '申请单通知的应换标箱数', + relabelled_packages INT NOT NULL DEFAULT 0 COMMENT '已正常换标箱数', + skipped_packages INT NOT NULL DEFAULT 0 COMMENT '已说明原因跳过箱数', + exception_packages INT NOT NULL DEFAULT 0 COMMENT '已登记异常箱数', + completion_photo_file_ids JSON NULL COMMENT '换标完成现场照片文件ID列表', + completion_remark VARCHAR(1000) NULL COMMENT '完成提交备注', + review_decision VARCHAR(16) NULL COMMENT '最近复核结论:APPROVE或REWORK', + review_remark VARCHAR(1000) NULL COMMENT '最近复核备注', + claimed_at DATETIME(3) NOT NULL COMMENT '任务认领时间', + submitted_at DATETIME(3) NULL COMMENT '提交复核时间', + reviewed_at DATETIME(3) NULL COMMENT '最近复核时间', + version INT NOT NULL DEFAULT 0 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_ll_relabel_pda_task_application (customer_id,application_id), + UNIQUE KEY uk_ll_relabel_pda_task_no (relabel_no), + KEY idx_ll_relabel_pda_task_warehouse (overseas_warehouse_id,status,updated_at), + KEY idx_ll_relabel_pda_task_operator (operator_key,status,updated_at) +) ENGINE=InnoDB COMMENT='海外PDA换标现场作业任务'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_unit_fact ( + id BIGINT NOT NULL COMMENT '逐件换标事实ID', + task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + application_id BIGINT NOT NULL COMMENT '换标申请单ID', + item_id BIGINT NOT NULL COMMENT '换标申请明细ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + old_label VARCHAR(255) NULL COMMENT '扫描的实物旧标签或箱标', + new_label VARCHAR(255) NULL COMMENT '扫描的换标后新标签', + result VARCHAR(32) NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION', + exception_code VARCHAR(32) NULL COMMENT '异常编码', + reason VARCHAR(1000) NULL COMMENT '跳过或异常原因', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识', + operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照', + occurred_at DATETIME(3) NOT NULL COMMENT '逐件事实发生时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_relabel_pda_old_label (old_label), + KEY idx_ll_relabel_pda_unit_task (customer_id,task_id,occurred_at,id), + KEY idx_ll_relabel_pda_unit_item (customer_id,application_id,item_id) +) ENGINE=InnoDB COMMENT='海外PDA逐件换标扫码与异常事实'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_operation ( + id BIGINT NOT NULL COMMENT '换标PDA幂等操作ID', + task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + operator_key VARCHAR(128) NOT NULL COMMENT '请求操作账号标识', + action VARCHAR(32) NOT NULL COMMENT '幂等操作动作', + idempotency_key VARCHAR(255) NOT NULL COMMENT '设备生成的幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + result_json LONGTEXT NOT NULL COMMENT '成功响应快照JSON', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_relabel_pda_operation_key (operator_key,idempotency_key), + KEY idx_ll_relabel_pda_operation_task (customer_id,task_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA换标写操作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_history ( + id BIGINT NOT NULL COMMENT '换标PDA任务历史ID', + task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + application_id BIGINT NOT NULL COMMENT '换标申请单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + action VARCHAR(32) NOT NULL COMMENT '现场动作类型', + from_status VARCHAR(32) NULL COMMENT '动作前任务状态', + to_status VARCHAR(32) NOT NULL COMMENT '动作后任务状态', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识', + operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照', + detail_json LONGTEXT NULL COMMENT '动作详情JSON', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + KEY idx_ll_relabel_pda_history_task (customer_id,task_id,created_at,id), + KEY idx_ll_relabel_pda_history_application (customer_id,application_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA换标任务不可变操作历史'; + CREATE TABLE IF NOT EXISTS ll_dropship_order_profile ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml new file mode 100644 index 000000000..e455f6f48 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -0,0 +1,256 @@ +openapi: 3.0.3 +info: + title: Lastleg Overseas PDA Warehouse Operations API + version: 1.0.0 + description: 海外仓PDA盘点、同仓整托移库、托盘作业与换标现场作业;全部范围从PDA Token解析。 +security: + - bearerAuth: [] +paths: + /api/pda/v1/last-leg/stocktakes: + get: + summary: 查询Token绑定仓盘点任务 + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } } + - { name: status, in: query, schema: { type: string, enum: [IN_PROGRESS, SUBMITTED] } } + responses: { '200': { description: 盘点任务分页 } } + post: + summary: 按库位或托盘创建盘点任务 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeCreate' } } } } + responses: { '200': { description: 创建后的完整盘点任务 } } + /api/pda/v1/last-leg/stocktakes/{taskNo}: + get: + summary: 查询盘点任务详情 + parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 盘点任务及冻结库存位置 } } + /api/pda/v1/last-leg/stocktakes/{taskNo}/counts: + post: + summary: 提交一个库存位置的实盘箱数 + parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeCount' } } } } + responses: { '200': { description: 已计数明细 }, '409': { description: 版本或幂等冲突 } } + /api/pda/v1/last-leg/stocktakes/{taskNo}/submit: + post: + summary: 提交盘点并按差异调整库存 + parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeSubmit' } } } } + responses: { '200': { description: 已提交盘点任务 }, '409': { description: 未全盘、库存版本变化或负库存风险 } } + /api/pda/v1/last-leg/inventory-moves: + post: + summary: 同仓整托移库 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryMove' } } } } + responses: { '200': { description: 移库结果 }, '409': { description: 跨仓、资源或库存版本冲突 } } + /api/pda/v1/last-leg/pallet-operations/pallets/resolve: + post: + summary: 扫码查询托盘及库存内容 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ScanCode' } } } } + responses: { '200': { description: Token绑定仓托盘内容 } } + /api/pda/v1/last-leg/pallet-operations/assemble: + post: + summary: 将多个来源托盘的指定库存行组到目标托盘 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PalletAssemble' } } } } + responses: { '200': { description: 组托及实物托盘状态结果 }, '409': { description: 目的仓、库存或资源版本冲突 } } + /api/pda/v1/last-leg/pallet-operations/disassemble: + post: + summary: 将来源托盘库存拆到一个或多个目标托盘 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PalletDisassemble' } } } } + responses: { '200': { description: 拆托结果 }, '409': { description: 库存或资源版本冲突 } } + /api/pda/v1/last-leg/pallet-operations/swap: + post: + summary: 将整托库存更换到空目标托盘 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PalletSwap' } } } } + responses: { '200': { description: 换托结果 }, '409': { description: 目标托盘非空或版本冲突 } } + /api/pda/v1/last-leg/pallet-operations/unbind: + post: + summary: 解除整托库存与实物托盘绑定 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PalletUnbind' } } } } + responses: { '200': { description: 解绑结果 }, '409': { description: 库存或资源版本冲突 } } + /api/pda/v1/last-leg/relabel-tasks/candidates: + get: + summary: 查询Token绑定仓可执行换标任务 + responses: { '200': { description: 待认领、处理中或待复核换标任务 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}: + get: + summary: 查询换标现场任务详情 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 申请明细、逐件事实、照片与历史 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/claim: + post: + summary: 认领换标任务并推进申请至处理中 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/IdempotentAction' } } } } + responses: { '200': { description: 已认领任务 }, '409': { description: 仓库、状态或幂等冲突 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units: + post: + summary: 登记一件正常换标事实 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelUnit' } } } } + responses: { '200': { description: 更新后的任务 }, '409': { description: 旧标重复、新标不符或版本冲突 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units/skip: + post: + summary: 登记一件有原因的跳过事实 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelSkip' } } } } + responses: { '200': { description: 更新后的任务 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/exceptions: + post: + summary: 登记一件换标异常事实 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelException' } } } } + responses: { '200': { description: 更新后的任务 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/photos/files: + post: + summary: 上传换标完成现场照片 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } } + responses: { '200': { description: OVERSEAS_RELABEL_PHOTO文件元数据 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete: + post: + summary: 提交现场任务等待复核,不提前完成申请 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelComplete' } } } } + responses: { '200': { description: WAIT_REVIEW任务 }, '409': { description: 箱数未核平、照片或版本冲突 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review: + post: + summary: 现场复核;APPROVE才完成既有换标申请,REWORK退回处理 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelReview' } } } } + responses: { '200': { description: COMPLETED或PROCESSING任务 }, '409': { description: 状态或版本冲突 } } +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } + schemas: + ScanCode: + type: object + required: [code] + properties: { code: { type: string, minLength: 1, maxLength: 128 } } + IdempotentAction: + type: object + required: [idempotencyKey] + properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } } + StocktakeCreate: + type: object + required: [scopeType, scopeCode, idempotencyKey] + properties: + scopeType: { type: string, enum: [LOCATION, PALLET] } + scopeCode: { type: string, maxLength: 128 } + idempotencyKey: { type: string, maxLength: 128 } + StocktakeCount: + type: object + required: [positionId, actualPackages, expectedVersion, idempotencyKey] + properties: + positionId: { type: string, pattern: '^[0-9]+$' } + actualPackages: { type: integer, format: int64, minimum: 0 } + expectedVersion: { type: integer, format: int64, minimum: 0 } + idempotencyKey: { type: string, maxLength: 128 } + StocktakeSubmit: + type: object + required: [expectedVersion, reason, idempotencyKey] + properties: + expectedVersion: { type: integer, format: int64, minimum: 0 } + reason: { type: string, minLength: 1, maxLength: 500 } + idempotencyKey: { type: string, maxLength: 128 } + InventoryMove: + type: object + required: [sourcePalletCode, targetLocationCode, expectedPalletVersion, idempotencyKey, reason] + properties: + sourcePalletCode: { type: string, maxLength: 128 } + targetLocationCode: { type: string, maxLength: 128 } + expectedPalletVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 128 } + reason: { type: string, minLength: 1, maxLength: 500 } + PalletExpectation: + type: object + required: [palletCode, expectedVersion] + properties: + palletCode: { type: string, maxLength: 128 } + expectedVersion: { type: integer, minimum: 0 } + PalletMoveLine: + type: object + required: [positionId, packages, expectedPositionVersion] + properties: + positionId: { type: string, pattern: '^[0-9]+$' } + packages: { type: integer, format: int64, minimum: 1 } + expectedPositionVersion: { type: integer, format: int64, minimum: 0 } + PalletAssemble: + type: object + required: [idempotencyKey, target, sources, lines] + properties: + idempotencyKey: { type: string, maxLength: 128 } + target: { $ref: '#/components/schemas/PalletExpectation' } + sources: { type: array, minItems: 1, maxItems: 20, items: { $ref: '#/components/schemas/PalletExpectation' } } + lines: { type: array, minItems: 1, maxItems: 200, items: { $ref: '#/components/schemas/PalletMoveLine' } } + reason: { type: string, maxLength: 500 } + PalletTargetMove: + type: object + required: [target, lines] + properties: + target: { $ref: '#/components/schemas/PalletExpectation' } + lines: { type: array, minItems: 1, maxItems: 200, items: { $ref: '#/components/schemas/PalletMoveLine' } } + PalletDisassemble: + type: object + required: [idempotencyKey, source, targets] + properties: + idempotencyKey: { type: string, maxLength: 128 } + source: { $ref: '#/components/schemas/PalletExpectation' } + targets: { type: array, minItems: 1, maxItems: 20, items: { $ref: '#/components/schemas/PalletTargetMove' } } + reason: { type: string, maxLength: 500 } + PalletSwap: + type: object + required: [idempotencyKey, source, target] + properties: + idempotencyKey: { type: string, maxLength: 128 } + source: { $ref: '#/components/schemas/PalletExpectation' } + target: { $ref: '#/components/schemas/PalletExpectation' } + reason: { type: string, maxLength: 500 } + PalletUnbind: + type: object + required: [idempotencyKey, source] + properties: + idempotencyKey: { type: string, maxLength: 128 } + source: { $ref: '#/components/schemas/PalletExpectation' } + reason: { type: string, maxLength: 500 } + RelabelUnit: + type: object + required: [itemId, oldLabel, newLabel, expectedVersion, idempotencyKey] + properties: + itemId: { type: string, pattern: '^[0-9]+$' } + oldLabel: { type: string, maxLength: 255 } + newLabel: { type: string, maxLength: 255 } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + RelabelSkip: + type: object + required: [itemId, oldLabel, reason, expectedVersion, idempotencyKey] + properties: + itemId: { type: string, pattern: '^[0-9]+$' } + oldLabel: { type: string, maxLength: 255 } + reason: { type: string, maxLength: 1000 } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + RelabelException: + type: object + required: [itemId, exceptionCode, reason, expectedVersion, idempotencyKey] + properties: + itemId: { type: string, pattern: '^[0-9]+$' } + oldLabel: { type: string, maxLength: 255, nullable: true } + exceptionCode: { type: string, enum: [DAMAGED, MISSING_LABEL, LABEL_MISMATCH, UNREADABLE, OTHER] } + reason: { type: string, maxLength: 1000 } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + RelabelComplete: + type: object + required: [photoFileIds, expectedVersion, idempotencyKey] + properties: + photoFileIds: { type: array, minItems: 1, maxItems: 20, items: { type: string, pattern: '^[0-9]+$' } } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + remark: { type: string, maxLength: 1000, nullable: true } + RelabelReview: + type: object + required: [decision, expectedVersion, idempotencyKey] + properties: + decision: { type: string, enum: [APPROVE, REWORK] } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + remark: { type: string, maxLength: 1000, nullable: true } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java index 188ff9c41..a4005d745 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java @@ -58,4 +58,5 @@ class HttpWarehouseResourceGatewayTest { assertThat(result.palletId()).isEqualTo(2092040891261427713L); server.verify(); } + } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java new file mode 100644 index 000000000..1f728cc1d --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.palletops.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +class PdaPalletOperationControllerContractTest { + @Test void exposesOnlyDedicatedPdaPalletEndpointsWithCanonicalPickerAuthority() { + RequestMapping mapping = PdaPalletOperationController.class.getAnnotation(RequestMapping.class); + assertArrayEquals(new String[]{"/api/pda/v1/last-leg/pallet-operations"}, mapping.value()); + PreAuthorize authorize = PdaPalletOperationController.class.getAnnotation(PreAuthorize.class); + assertEquals("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')", authorize.value()); + Set paths = Arrays.stream(PdaPalletOperationController.class.getDeclaredMethods()) + .map(Method::getDeclaredAnnotations).flatMap(Arrays::stream) + .filter(PostMapping.class::isInstance).map(PostMapping.class::cast) + .flatMap(annotation -> Arrays.stream(annotation.value())).collect(Collectors.toSet()); + assertEquals(Set.of("/pallets/resolve", "/assemble", "/disassemble", "/swap", "/unbind"), paths); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java new file mode 100644 index 000000000..59301bc76 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java @@ -0,0 +1,124 @@ +package com.baiyue.lastleg.palletops.service; + +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.palletops.dto.PalletOperationRequests; +import com.baiyue.lastleg.palletops.entity.PdaPalletOperation; +import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; +import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper; +import com.baiyue.lastleg.palletops.model.PalletInventoryRow; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.AbstractPlatformTransactionManager; +import org.springframework.transaction.support.DefaultTransactionStatus; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PdaPalletOperationServiceTest { + @Mock private PdaPalletOperationMapper mapper; + @Mock private InventoryPositionMapper positions; + @Mock private InventoryBalanceMapper balances; + @Mock private InventoryLedgerMapper ledgers; + @Mock private PalletPhysicalGateway physical; + @Mock private PdaWarehouseService history; + private PdaPalletOperationService service; + + @BeforeEach void setUp() { + service = new PdaPalletOperationService(); + ReflectionTestUtils.setField(service, "mapper", mapper); + ReflectionTestUtils.setField(service, "positions", positions); + ReflectionTestUtils.setField(service, "balances", balances); + ReflectionTestUtils.setField(service, "ledgers", ledgers); + ReflectionTestUtils.setField(service, "physical", physical); + ReflectionTestUtils.setField(service, "history", history); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); + service.configureTransactions(new NoOpTransactionManager()); + } + + @Test void resolveRejectsPalletOutsideTokenFrozenWarehouse() { + when(physical.resolve("P-1")).thenReturn(pallet(10L, "P-1", 99L, 1)); + assertThrows(ResponseStatusException.class, () -> service.resolve(identity(), + new PalletOperationRequests.Scan("P-1"))); + verifyNoInteractions(mapper); + } + + @Test void localConcurrencyFailureCompensatesEveryAppliedPhysicalTransitionInReverseSafeForm() { + var source = pallet(10L, "SOURCE", 88L, 1); + var target = pallet(20L, "TARGET", 88L, 2); + when(physical.resolve("SOURCE")).thenReturn(source); + when(physical.resolve("TARGET")).thenReturn(target); + when(physical.resolveLocation(88L, target.locationId())).thenReturn( + new PalletPhysicalGateway.LocationReference(target.locationId(), "LOC-" + target.locationId(), + "STORAGE", "ACTIVE")); + PalletInventoryRow planned = row(100L, 10L, 1L, 5L); + PalletInventoryRow stale = row(100L, 10L, 2L, 5L); + when(mapper.content("88", 10L)).thenReturn(List.of(planned)); + when(mapper.content("88", 20L)).thenReturn(List.of()); + when(mapper.palletPackages("88", 10L)).thenReturn(5L); + when(mapper.palletPackages("88", 20L)).thenReturn(0L); + when(mapper.lockPosition("88", 100L)).thenReturn(stale); + + AtomicReference operation = new AtomicReference<>(); + doAnswer(invocation -> { operation.set(invocation.getArgument(0)); return 1; }) + .when(mapper).insert(any(PdaPalletOperation.class)); + when(mapper.selectById(anyLong())).thenAnswer(invocation -> operation.get()); + when(physical.apply(eq(88L), any(), anyString(), anyLong(), anyString(), any())) + .thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2))); + + var request = new PalletOperationRequests.Assemble("idem-1", + new PalletOperationRequests.PalletExpectation("TARGET", 2), + List.of(new PalletOperationRequests.PalletExpectation("SOURCE", 1)), + List.of(new PalletOperationRequests.MoveLine(100L, 5L, 1L)), "assemble"); + + assertThrows(ResponseStatusException.class, () -> service.assemble(identity(), request)); + + verify(physical, times(2)).compensate(eq(88L), any(PalletPhysicalGateway.Transition.class)); + assertEquals("FAILED_COMPENSATED", operation.get().getStatus()); + verify(positions, never()).updateById(any(com.baiyue.lastleg.inventory.entity.InventoryPosition.class)); + verifyNoInteractions(ledgers, history); + } + + private static PdaWarehouseIdentity identity() { return new PdaWarehouseIdentity("7", "picker", 88L); } + private static PalletPhysicalGateway.PalletReference pallet(long id, String code, long warehouse, int version) { + return new PalletPhysicalGateway.PalletReference(id, code, warehouse, 1000L + id, + id == 20L ? "IDLE" : "OCCUPIED", null, version); + } + private static PalletPhysicalGateway.Transition transition(PalletPhysicalGateway.PalletReference pallet, + String targetStatus) { + return new PalletPhysicalGateway.Transition("op-" + pallet.palletId(), pallet.palletId(), pallet.palletCode(), + pallet.warehouseId(), pallet.status(), targetStatus, pallet.locationId(), pallet.locationId(), + pallet.version(), pallet.version() + 1, "APPLIED", false); + } + private static PalletInventoryRow row(long positionId, long palletId, long version, long packages) { + PalletInventoryRow value = new PalletInventoryRow(); value.setCustomerId("100"); value.setCustomerName("C"); + value.setBalanceId(200L); value.setPositionId(positionId); value.setInboundCode("IN-1"); + value.setSkuCode("SKU-1"); value.setDestinationWarehouseCode("ONT8"); value.setPalletId(palletId); + value.setPalletNo(palletId == 10L ? "SOURCE" : "TARGET"); value.setBatchNo("DEFAULT"); + value.setWarehouseLocationId(1010L); value.setWarehouseLocationCode("L-1"); + value.setAvailablePackages(packages); value.setHoldPackages(0L); value.setAllocatedPackages(0L); + value.setPickedPackages(0L); value.setReceivingPackages(0L); value.setVersion(version); return value; + } + + private static final class NoOpTransactionManager extends AbstractPlatformTransactionManager { + @Override protected Object doGetTransaction() { return new Object(); } + @Override protected void doBegin(Object transaction, TransactionDefinition definition) { } + @Override protected void doCommit(DefaultTransactionStatus status) { } + @Override protected void doRollback(DefaultTransactionStatus status) { } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java new file mode 100644 index 000000000..eaef73a37 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java @@ -0,0 +1,41 @@ +package com.baiyue.lastleg.pda; + +import com.baiyue.lastleg.pda.move.controller.PdaInventoryMoveController; +import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest; +import com.baiyue.lastleg.pda.stocktake.controller.PdaStocktakeController; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.Set; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class PdaInventoryOperationContractTest { + @Test + void controllersUseOnlyCanonicalPdaIdentityAndRoutes() { + assertThat(PdaStocktakeController.class.getAnnotation(RequestMapping.class).value()) + .containsExactly("/api/pda/v1/last-leg/stocktakes"); + assertThat(PdaInventoryMoveController.class.getAnnotation(RequestMapping.class).value()) + .containsExactly("/api/pda/v1/last-leg/inventory-moves"); + assertThat(PdaStocktakeController.class.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')"); + assertThat(PdaInventoryMoveController.class.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')"); + } + + @Test + void deviceRequestsCannotSupplyIdentityOrWarehouseScope() { + Set forbidden = Set.of("customerId", "warehouseId", "overseasWarehouseId", "operator", "operatorId"); + for (Class type : List.of(PdaStocktakeRequests.Create.class, PdaStocktakeRequests.Count.class, + PdaStocktakeRequests.Submit.class, PdaInventoryMoveRequest.class)) { + Set fields = Stream.of(type.getRecordComponents()).map(component -> component.getName()) + .collect(Collectors.toSet()); + assertThat(fields).doesNotContainAnyElementsOf(forbidden); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java new file mode 100644 index 000000000..c1a5118a4 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java @@ -0,0 +1,83 @@ +package com.baiyue.lastleg.pda; + +import com.baiyue.lastleg.palletops.dto.PalletOperationRequests; +import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests; +import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.lang.reflect.RecordComponent; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PdaOperationsOpenApiContractTest { + private static final Set SERVER_OWNED = Set.of( + "customerId", "warehouseId", "overseasWarehouseId", "operator", "operatorKey", "operatorName"); + + @Test + void pdaOperationRequestsNeverAcceptServerOwnedScope() { + List> containers = List.of(PdaStocktakeRequests.class, PalletOperationRequests.class, + PdaRelabelRequests.class); + for (Class container : containers) { + for (Class type : container.getDeclaredClasses()) { + if (type.isRecord()) assertNoServerOwnedFields(type); + } + } + assertNoServerOwnedFields(PdaInventoryMoveRequest.class); + } + + @Test + void versionedOpenApiPublishesEveryPdaWarehouseOperation() throws IOException { + String yaml = resource("openapi/overseas-pda-operations-v1.yaml"); + List paths = List.of( + "/api/pda/v1/last-leg/stocktakes:", + "/api/pda/v1/last-leg/stocktakes/{taskNo}:", + "/api/pda/v1/last-leg/stocktakes/{taskNo}/counts:", + "/api/pda/v1/last-leg/stocktakes/{taskNo}/submit:", + "/api/pda/v1/last-leg/inventory-moves:", + "/api/pda/v1/last-leg/pallet-operations/pallets/resolve:", + "/api/pda/v1/last-leg/pallet-operations/assemble:", + "/api/pda/v1/last-leg/pallet-operations/disassemble:", + "/api/pda/v1/last-leg/pallet-operations/swap:", + "/api/pda/v1/last-leg/pallet-operations/unbind:", + "/api/pda/v1/last-leg/relabel-tasks/candidates:", + "/api/pda/v1/last-leg/relabel-tasks/{relabelNo}:", + "/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/claim:", + "/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units:", + "/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units/skip:", + "/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/exceptions:", + "/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/photos/files:", + "/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete:", + "/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review:"); + for (String path : paths) assertTrue(yaml.contains(path), path); + assertEquals(19, paths.size()); + assertTrue(yaml.contains("enum: [LOCATION, PALLET]")); + assertTrue(yaml.contains("enum: [APPROVE, REWORK]")); + assertTrue(yaml.contains("enum: [DAMAGED, MISSING_LABEL, LABEL_MISMATCH, UNREADABLE, OTHER]")); + for (String field : SERVER_OWNED) assertFalse(yaml.contains(field + ":"), field); + for (String excluded : List.of("/outbound", "/dispatch", "/delivery")) { + assertFalse(yaml.contains(excluded), excluded); + } + } + + private static void assertNoServerOwnedFields(Class type) { + Set fields = Arrays.stream(type.getRecordComponents()) + .map(RecordComponent::getName).collect(Collectors.toSet()); + assertTrue(java.util.Collections.disjoint(fields, SERVER_OWNED), type.getSimpleName()); + } + + private String resource(String name) throws IOException { + try (var input = getClass().getClassLoader().getResourceAsStream(name)) { + assertTrue(input != null, name); + return new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveServiceTest.java new file mode 100644 index 000000000..cdb3714ae --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveServiceTest.java @@ -0,0 +1,141 @@ +package com.baiyue.lastleg.pda.move.service; + +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; +import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest; +import com.baiyue.lastleg.pda.move.entity.PdaInventoryMove; +import com.baiyue.lastleg.pda.move.mapper.PdaInventoryMoveMapper; +import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PdaInventoryMoveServiceTest { + @Mock PdaInventoryMoveMapper moves; + @Mock InventoryPositionMapper positions; + @Mock InventoryBalanceMapper balances; + @Mock InventoryLedgerMapper ledgers; + @Mock WarehouseResourceGateway warehouse; + @Mock PalletPhysicalGateway physical; + @Mock PdaWarehouseService history; + private PdaInventoryMoveService service; + + @BeforeEach + void setUp() { + service = new PdaInventoryMoveService(); ReflectionTestUtils.setField(service, "moves", moves); + ReflectionTestUtils.setField(service, "positions", positions); ReflectionTestUtils.setField(service, "balances", balances); + ReflectionTestUtils.setField(service, "ledgers", ledgers); ReflectionTestUtils.setField(service, "warehouse", warehouse); + ReflectionTestUtils.setField(service, "physical", physical); + ReflectionTestUtils.setField(service, "history", history); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); + } + + @Test + void atomicallyRelocatesPhysicalPalletAndEveryWarehouseScopedPosition() { + when(warehouse.resolvePallet("PAL-1")).thenReturn(pallet(900L, 20L, 2)); + when(warehouse.resolveLocation(900L, 20L)).thenReturn(location(20L, "A-01")); + when(warehouse.resolveLocation(900L, "B-01")).thenReturn(location(21L, "B-01")); + InventoryPosition position = new InventoryPosition(); position.setId(11L); position.setCustomerId("100"); + position.setBalanceId(10L); position.setOverseasWarehouseId("900"); position.setPalletId(30L); + position.setPalletNo("PAL-1"); position.setWarehouseLocationId(20L); position.setWarehouseLocationCode("A-01"); + position.setAvailablePackages(5L); position.setHoldPackages(1L); position.setAllocatedPackages(0L); + position.setPickedPackages(0L); position.setReceivingPackages(0L); position.setVersion(4L); + when(moves.lockPositions("900", 30L)).thenReturn(List.of(position)); + InventoryBalance balance = new InventoryBalance(); balance.setId(10L); balance.setCustomerId("100"); + balance.setOverseasWarehouseId("900"); balance.setInboundOrderId(50L); balance.setInboundCode("IN-1"); + when(balances.selectById(10L)).thenReturn(balance); + when(physical.apply(eq(900L), any(PalletPhysicalGateway.PalletReference.class), eq("OCCUPIED"), + eq(21L), anyString(), eq("库位优化"))).thenReturn(transition(false)); + + var response = service.move(900L, "operator-1", + new PdaInventoryMoveRequest("PAL-1", "B-01", 2, "move-1", "库位优化")); + + verify(physical).apply(eq(900L), argThat(value -> value.palletId() == 30L + && value.locationId() == 20L && value.version() == 2), eq("OCCUPIED"), + eq(21L), anyString(), eq("库位优化")); + ArgumentCaptor updated = ArgumentCaptor.forClass(InventoryPosition.class); + verify(positions).updateById(updated.capture()); + assertThat(updated.getValue().getWarehouseLocationId()).isEqualTo(21L); + assertThat(updated.getValue().getWarehouseLocationCode()).isEqualTo("B-01"); + assertThat(response.packageCount()).isEqualTo(6L); + assertThat(response.palletVersion()).isEqualTo(3); + assertThat(response.status()).isEqualTo("COMPLETED"); + verify(ledgers).insert(any(InventoryLedger.class)); verify(moves).updateById(any(PdaInventoryMove.class)); + } + + @Test + void rejectsCrossWarehousePalletWithoutMovingResources() { + when(warehouse.resolvePallet("PAL-1")).thenReturn(pallet(901L, 20L, 2)); + assertThatThrownBy(() -> service.move(900L, "operator-1", + new PdaInventoryMoveRequest("PAL-1", "B-01", 2, "move-1", "库位优化"))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + verify(physical, never()).apply(anyLong(), any(), anyString(), anyLong(), anyString(), any()); + } + + @Test + void resumesWhenWarehouseCommittedButLastlegTransactionRolledBack() { + when(warehouse.resolvePallet("PAL-1")).thenReturn(pallet(900L, 21L, 3)); + when(warehouse.resolveLocation(900L, "B-01")).thenReturn(location(21L, "B-01")); + when(warehouse.resolveLocation(900L, 20L)).thenReturn(location(20L, "A-01")); + InventoryPosition position = new InventoryPosition(); position.setId(11L); position.setCustomerId("100"); + position.setBalanceId(10L); position.setOverseasWarehouseId("900"); position.setPalletId(30L); + position.setPalletNo("PAL-1"); position.setWarehouseLocationId(20L); position.setWarehouseLocationCode("A-01"); + position.setAvailablePackages(5L); position.setHoldPackages(0L); position.setAllocatedPackages(0L); + position.setPickedPackages(0L); position.setReceivingPackages(0L); position.setVersion(4L); + when(moves.lockPositions("900", 30L)).thenReturn(List.of(position)); + InventoryBalance balance = new InventoryBalance(); balance.setId(10L); balance.setCustomerId("100"); + balance.setOverseasWarehouseId("900"); balance.setInboundOrderId(50L); balance.setInboundCode("IN-1"); + when(balances.selectById(10L)).thenReturn(balance); + when(physical.apply(eq(900L), any(PalletPhysicalGateway.PalletReference.class), eq("OCCUPIED"), + eq(21L), anyString(), eq("恢复移库"))).thenReturn(transition(true)); + + var response = service.move(900L, "operator-1", + new PdaInventoryMoveRequest("PAL-1", "B-01", 2, "move-retry", "恢复移库")); + + verify(physical).apply(eq(900L), argThat(value -> value.locationId() == 20L && value.version() == 2), + eq("OCCUPIED"), eq(21L), anyString(), eq("恢复移库")); + assertThat(response.sourceLocationCode()).isEqualTo("A-01"); + assertThat(response.targetLocationCode()).isEqualTo("B-01"); + verify(positions).updateById(any(InventoryPosition.class)); + } + + private static WarehouseResourceGateway.WarehousePalletReference pallet(long warehouseId, long locationId, int version) { + return new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-1", warehouseId, locationId, + "OCCUPIED", null, version); + } + private static WarehouseResourceGateway.WarehouseLocationReference location(long id, String code) { + return new WarehouseResourceGateway.WarehouseLocationReference(id, code, "STORAGE", BigDecimal.TEN, "OCCUPIED"); + } + private static PalletPhysicalGateway.Transition transition(boolean replayed) { + return new PalletPhysicalGateway.Transition("move-transition", 30L, "PAL-1", 900L, + "OCCUPIED", "OCCUPIED", 20L, 21L, 2, 3, "APPLIED", replayed); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeServiceTest.java new file mode 100644 index 000000000..255e8180c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeServiceTest.java @@ -0,0 +1,146 @@ +package com.baiyue.lastleg.pda.stocktake.service; + +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeActionMapper; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeInventoryMapper; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeItemMapper; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeTaskMapper; +import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate; +import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PdaStocktakeServiceTest { + @Mock PdaStocktakeTaskMapper tasks; + @Mock PdaStocktakeItemMapper items; + @Mock PdaStocktakeActionMapper actions; + @Mock PdaStocktakeInventoryMapper inventory; + @Mock InventoryPositionMapper positions; + @Mock InventoryBalanceMapper balances; + @Mock InventoryLedgerMapper ledgers; + @Mock WarehouseResourceGateway warehouse; + @Mock PdaWarehouseService history; + private PdaStocktakeService service; + + @BeforeEach + void setUp() { + service = new PdaStocktakeService(); + ReflectionTestUtils.setField(service, "tasks", tasks); ReflectionTestUtils.setField(service, "items", items); + ReflectionTestUtils.setField(service, "actions", actions); ReflectionTestUtils.setField(service, "inventory", inventory); + ReflectionTestUtils.setField(service, "positions", positions); ReflectionTestUtils.setField(service, "balances", balances); + ReflectionTestUtils.setField(service, "ledgers", ledgers); ReflectionTestUtils.setField(service, "warehouse", warehouse); + ReflectionTestUtils.setField(service, "history", history); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); + } + + @Test + void createsWarehouseBoundLocationTaskAndFreezesEveryPosition() { + when(warehouse.resolveLocation(900L, "A-01")).thenReturn(new WarehouseResourceGateway.WarehouseLocationReference( + 20L, "A-01", "STORAGE", BigDecimal.TEN, "OCCUPIED")); + StocktakeCandidate candidate = new StocktakeCandidate(); candidate.setCustomerId("100"); + candidate.setBalanceId(10L); candidate.setPositionId(11L); candidate.setSkuCode("SKU-1"); + candidate.setPalletId(30L); candidate.setPalletCode("PAL-1"); candidate.setLocationId(20L); + candidate.setLocationCode("A-01"); candidate.setBookPackages(8L); candidate.setPositionVersion(3L); + when(items.candidatesByLocation("900", 20L, "A-01")).thenReturn(List.of(candidate)); + when(items.views(anyLong())).thenReturn(List.of()); + + var response = service.create(900L, "operator-1", new PdaStocktakeRequests.Create( + "location", "A-01", "create-1")); + + ArgumentCaptor task = ArgumentCaptor.forClass(PdaStocktakeTask.class); + ArgumentCaptor item = ArgumentCaptor.forClass(PdaStocktakeItem.class); + verify(tasks).insert(task.capture()); verify(items).insert(item.capture()); + assertThat(task.getValue().getOverseasWarehouseId()).isEqualTo(900L); + assertThat(task.getValue().getScopeType()).isEqualTo("LOCATION"); + assertThat(task.getValue().getCreatedBy()).isEqualTo("operator-1"); + assertThat(item.getValue().getCustomerId()).isEqualTo("100"); + assertThat(item.getValue().getBookPackages()).isEqualTo(8L); + assertThat(item.getValue().getPositionVersion()).isEqualTo(3L); + assertThat(response.task().status()).isEqualTo("IN_PROGRESS"); + } + + @Test + void rejectsPalletFromAnotherWarehouseBeforeReadingInventory() { + when(warehouse.resolvePallet("PAL-1")).thenReturn(new WarehouseResourceGateway.WarehousePalletReference( + 30L, "PAL-1", 901L, 20L, "OCCUPIED", null, 2)); + + assertThatThrownBy(() -> service.create(900L, "operator-1", + new PdaStocktakeRequests.Create("PALLET", "PAL-1", "create-1"))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + verify(items, never()).candidatesByPallet(anyString(), anyLong()); + } + + @Test + void submitsCountedDifferenceAndWritesBalancedAuditLedger() { + PdaStocktakeTask task = new PdaStocktakeTask(); task.setId(40L); task.setTaskNo("CC-40"); + task.setOverseasWarehouseId(900L); task.setScopeType("PALLET"); task.setScopeCode("PAL-1"); + task.setStatus("IN_PROGRESS"); task.setPositionCount(1); task.setCountedCount(1); task.setDifferenceCount(1); + task.setVersion(2L); + PdaStocktakeTask submitted = new PdaStocktakeTask(); submitted.setId(40L); submitted.setTaskNo("CC-40"); + submitted.setOverseasWarehouseId(900L); submitted.setScopeType("PALLET"); submitted.setScopeCode("PAL-1"); + submitted.setStatus("SUBMITTED"); submitted.setPositionCount(1); submitted.setCountedCount(1); + submitted.setDifferenceCount(1); submitted.setVersion(3L); + when(tasks.find(900L, "CC-40")).thenReturn(task, submitted); + when(tasks.lock(900L, "CC-40")).thenReturn(task); when(tasks.submit(40L, 2L, "operator-1", "复盘确认")).thenReturn(1); + + PdaStocktakeItem item = new PdaStocktakeItem(); item.setId(41L); item.setTaskId(40L); item.setCustomerId("100"); + item.setBalanceId(10L); item.setPositionId(11L); item.setSkuCode("SKU-1"); item.setBookPackages(5L); + item.setActualPackages(7L); item.setDifferencePackages(2L); item.setPositionVersion(3L); item.setVersion(1L); + when(items.lockAll(40L)).thenReturn(List.of(item)); when(items.views(40L)).thenReturn(List.of()); + + InventoryPosition position = new InventoryPosition(); position.setId(11L); position.setCustomerId("100"); + position.setBalanceId(10L); position.setOverseasWarehouseId("900"); position.setPalletId(30L); + position.setPalletNo("PAL-1"); position.setAvailablePackages(5L); position.setHoldPackages(0L); + position.setAllocatedPackages(0L); position.setPickedPackages(0L); position.setReceivingPackages(0L); + position.setVersion(3L); + InventoryBalance balance = new InventoryBalance(); balance.setId(10L); balance.setCustomerId("100"); + balance.setOverseasWarehouseId("900"); balance.setAvailablePackages(5L); balance.setVersion(8L); + when(inventory.lockPosition(11L, "100", "900")).thenReturn(position); + when(inventory.lockBalance(10L, "100", "900")).thenReturn(balance); + + var response = service.submit(900L, "operator-1", "CC-40", + new PdaStocktakeRequests.Submit(2L, "复盘确认", "submit-1")); + + ArgumentCaptor changedPosition = ArgumentCaptor.forClass(InventoryPosition.class); + ArgumentCaptor changedBalance = ArgumentCaptor.forClass(InventoryBalance.class); + ArgumentCaptor ledger = ArgumentCaptor.forClass(InventoryLedger.class); + verify(positions).updateById(changedPosition.capture()); verify(balances).updateById(changedBalance.capture()); + verify(ledgers).insert(ledger.capture()); + assertThat(changedPosition.getValue().getAvailablePackages()).isEqualTo(7L); + assertThat(changedBalance.getValue().getAvailablePackages()).isEqualTo(7L); + assertThat(ledger.getValue().getBeforePackages()).isEqualTo(5L); + assertThat(ledger.getValue().getDeltaPackages()).isEqualTo(2L); + assertThat(ledger.getValue().getAfterPackages()).isEqualTo(7L); + assertThat(ledger.getValue().getBusinessType()).isEqualTo("STOCKTAKE_ADJUST"); + assertThat(response.task().status()).isEqualTo("SUBMITTED"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelContractTest.java new file mode 100644 index 000000000..188b70adf --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelContractTest.java @@ -0,0 +1,86 @@ +package com.baiyue.lastleg.relabel.pda.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.transaction.annotation.Transactional; + +import java.lang.reflect.RecordComponent; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PdaRelabelContractTest { + @Test + void controllerUsesStablePdaPathAndExactPickerAuthority() { + assertEquals("/api/pda/v1/last-leg/relabel-tasks", + PdaRelabelController.class.getAnnotation(RequestMapping.class).value()[0]); + assertEquals("hasAuthority('lastleg:pda:operate')", + PdaRelabelController.class.getAnnotation(PreAuthorize.class).value()); + } + + @Test + void deviceRequestsNeverAcceptServerOwnedScopeOrOperatorFields() { + Set forbidden = Set.of("customerId", "warehouseId", "overseasWarehouseId", + "operatorId", "operatorName", "tenantId"); + for (Class type : PdaRelabelRequests.class.getDeclaredClasses()) { + Set fields = Arrays.stream(type.getRecordComponents()).map(RecordComponent::getName) + .collect(Collectors.toSet()); + assertTrue(fields.stream().noneMatch(forbidden::contains), type.getSimpleName() + " exposes server scope"); + } + } + + @Test + void identityRequiresPlatformPickerPermissionAndFrozenWarehouse() { + AuthenticatedUser valid = user(Set.of("lastleg:pda:operate", "ROLE_WAREHOUSE_PICKER"), 900L); + assertEquals(900L, PdaRelabelController.identity(valid).overseasWarehouseId()); + assertThrows(ResponseStatusException.class, + () -> PdaRelabelController.identity(user(Set.of("lastleg:pda:operate"), 900L))); + assertThrows(ResponseStatusException.class, + () -> PdaRelabelController.identity(user(Set.of("lastleg:pda:operate", "ROLE_WAREHOUSE_PICKER"), null))); + } + + @Test + void baselineDefinesPdaRelabelFactsWithoutOutboundFields() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8); + for (String table : Set.of("ll_relabel_pda_task", "ll_relabel_pda_unit_fact", + "ll_relabel_pda_operation", "ll_relabel_pda_history")) { + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS " + table)); + } + assertTrue(sql.contains("uk_ll_relabel_pda_operation_key (operator_key,idempotency_key)")); + assertTrue(sql.contains("uk_ll_relabel_pda_old_label (old_label)")); + int start = sql.indexOf("CREATE TABLE IF NOT EXISTS ll_relabel_pda_task"); + int end = sql.indexOf("CREATE TABLE IF NOT EXISTS ll_dropship", start); + String block = sql.substring(start, end); + assertFalse(block.contains("dispatch_no")); + assertFalse(block.contains("delivery_no")); + assertFalse(block.contains("outbound_order_no")); + } + + @Test + void everyStateMutationIsTransactional() throws Exception { + Class service = Class.forName("com.baiyue.lastleg.relabel.pda.service.PdaRelabelService"); + for (String method : Set.of("claim", "scan", "skip", "exception", "complete", "review")) { + assertTrue(Arrays.stream(service.getDeclaredMethods()) + .filter(candidate -> method.equals(candidate.getName())) + .anyMatch(candidate -> candidate.isAnnotationPresent(Transactional.class)), + method + " must be transactional"); + } + } + + private static AuthenticatedUser user(Set permissions, Long warehouseId) { + return new AuthenticatedUser(7L, 0L, 0L, 70L, "picker", "PLATFORM", "PDA", + permissions, false, null, null, false, warehouseId); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java new file mode 100644 index 000000000..8ee0d9732 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java @@ -0,0 +1,245 @@ +package com.baiyue.lastleg.relabel.pda.service; + +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelOperation; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact; +import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelOperationMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PdaRelabelServiceTest { + @Mock RelabelApplicationMapper applications; + @Mock RelabelItemMapper items; + @Mock PdaRelabelTaskMapper tasks; + @Mock PdaRelabelUnitFactMapper unitFacts; + @Mock PdaRelabelOperationMapper operations; + @Mock PdaRelabelHistoryMapper histories; + @Mock RelabelApplicationService applicationService; + @Mock RelabelPhotoFileGateway photoFiles; + PdaRelabelService service; + RelabelApplication application; + RelabelItem item; + List persistedFacts; + + @BeforeEach + void setUp() { + service = new PdaRelabelService(); + ReflectionTestUtils.setField(service, "applications", applications); + ReflectionTestUtils.setField(service, "items", items); + ReflectionTestUtils.setField(service, "tasks", tasks); + ReflectionTestUtils.setField(service, "unitFacts", unitFacts); + ReflectionTestUtils.setField(service, "operations", operations); + ReflectionTestUtils.setField(service, "histories", histories); + ReflectionTestUtils.setField(service, "applicationService", applicationService); + ReflectionTestUtils.setField(service, "photoFiles", photoFiles); + ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper()); + service.initializeObjectMapper(); + application = application("PENDING_PROCESSING"); + item = item(); + persistedFacts = new ArrayList<>(); + lenient().when(operations.find(anyString(), anyString())).thenReturn(null); + lenient().when(items.selectList(any())).thenReturn(List.of(item)); + lenient().when(histories.selectList(any())).thenReturn(List.of()); + lenient().when(unitFacts.selectList(any())).thenAnswer(call -> List.copyOf(persistedFacts)); + lenient().when(unitFacts.insert(any(PdaRelabelUnitFact.class))).thenAnswer(call -> { persistedFacts.add(call.getArgument(0)); return 1; }); + lenient().when(tasks.updateWithVersion(any(), anyInt())).thenReturn(1); + } + + @Test + void claimUsesFrozenWarehouseAndAdvancesExistingApplicationStateMachine() { + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(null); + ArgumentCaptor task = ArgumentCaptor.forClass(PdaRelabelTask.class); + + var result = service.claim("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.Claim("claim-1")); + + verify(tasks).insert(task.capture()); + assertEquals(900L, task.getValue().getOverseasWarehouseId()); + assertEquals("100", task.getValue().getCustomerId()); + assertEquals(PdaRelabelService.PROCESSING, result.taskStatus()); + verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("7"), any(RelabelRequests.ChangeStatus.class)); + } + + @Test + void crossWarehouseClaimIsHiddenAndCannotCreateTask() { + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.claim("7", "picker", 901L, "FBR-1", new PdaRelabelRequests.Claim("claim-1"))); + assertEquals(404, error.getStatusCode().value()); + verify(tasks, never()).insert(any(PdaRelabelTask.class)); + } + + @Test + void unitScanLocksItemToApplicationValidatesNewSkuAndUpdatesVersion() { + application.setStatus("PROCESSING"); + PdaRelabelTask task = task("PROCESSING", 0); + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(task); + when(items.selectOne(any())).thenReturn(item); + when(unitFacts.findOldLabel("BOX-1")).thenReturn(null); + ArgumentCaptor fact = ArgumentCaptor.forClass(PdaRelabelUnitFact.class); + + var result = service.scan("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.Scan(10L, "BOX-1", "SKU-NEW", 0, "scan-1")); + + verify(unitFacts).insert(fact.capture()); + assertEquals("RELABELLED", fact.getValue().getResult()); + assertEquals("BOX-1", fact.getValue().getOldLabel()); + assertEquals(1, result.relabelledPackages()); + assertEquals(1, result.version()); + } + + @Test + void duplicateOldLabelAndStaleVersionFailBeforeWritingAnotherFact() { + application.setStatus("PROCESSING"); + PdaRelabelTask task = task("PROCESSING", 1); + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(task); + + assertThrows(ResponseStatusException.class, () -> service.skip("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.Skip(10L, "BOX-2", "damaged", 0, "skip-stale"))); + verify(unitFacts, never()).insert(any(PdaRelabelUnitFact.class)); + } + + @Test + void oldLabelAlreadyAttributedToAnotherTaskIsRejected() { + application.setStatus("PROCESSING"); + PdaRelabelTask task = task("PROCESSING", 0); + PdaRelabelUnitFact otherTaskFact = fact("RELABELLED"); + otherTaskFact.setTaskId(99L); + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(task); + when(items.selectOne(any())).thenReturn(item); + when(unitFacts.findOldLabel("BOX-1")).thenReturn(otherTaskFact); + + assertThrows(ResponseStatusException.class, () -> service.scan("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.Scan(10L, "BOX-1", "SKU-NEW", 0, "scan-cross-task"))); + verify(unitFacts, never()).insert(any(PdaRelabelUnitFact.class)); + } + + @Test + void completeRequiresEveryUnitAndMovesOnlyPdaTaskToWaitReview() { + application.setStatus("PROCESSING"); + PdaRelabelTask task = task("PROCESSING", 1); + task.setRelabelledPackages(1); + persistedFacts.add(fact("RELABELLED")); + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(task); + + var result = service.complete("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.Complete(List.of(501L), 1, "complete-1", "done")); + + verify(photoFiles).validate("100", 20L, 501L); + assertEquals(PdaRelabelService.WAIT_REVIEW, result.taskStatus()); + assertEquals("PROCESSING", result.applicationStatus()); + verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any()); + } + + @Test + void approvalUsesExistingApplicationCompletionAndReworkDoesNot() { + application.setStatus("PROCESSING"); + PdaRelabelTask approved = task("WAIT_REVIEW", 2); + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(approved); + + var result = service.review("8", "reviewer", 900L, "FBR-1", + new PdaRelabelRequests.Review("APPROVE", 2, "review-1", "ok")); + + assertEquals("COMPLETED", result.taskStatus()); + assertEquals("COMPLETED", result.applicationStatus()); + verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("8"), any(RelabelRequests.ChangeStatus.class)); + } + + @Test + void reviewCanReturnTaskForReworkWithoutCompletingApplication() { + application.setStatus("PROCESSING"); + PdaRelabelTask task = task("WAIT_REVIEW", 2); + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(task); + + var result = service.review("8", "reviewer", 900L, "FBR-1", + new PdaRelabelRequests.Review("REWORK", 2, "review-rework", "wrong label")); + + assertEquals("PROCESSING", result.taskStatus()); + assertEquals("PROCESSING", result.applicationStatus()); + verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any()); + } + + @Test + void completionRejectsMissingPackageFactsBeforePhotoValidation() { + application.setStatus("PROCESSING"); + PdaRelabelTask task = task("PROCESSING", 0); + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(task); + + assertThrows(ResponseStatusException.class, () -> service.complete("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.Complete(List.of(501L), 0, "complete-1", null))); + verify(photoFiles, never()).validate(anyString(), anyLong(), anyLong()); + } + + private RelabelApplication application(String status) { + RelabelApplication value = new RelabelApplication(); + value.setId(1L); value.setCustomerId("100"); value.setRelabelNo("FBR-1"); + value.setCustomerOrderNo("ORD-1"); value.setOverseasWarehouseId("900"); + value.setStatus(status); value.setItemCount(1); value.setNotifiedPackageCount(1); + value.setLabelDeadline(LocalDateTime.now().plusDays(1)); + return value; + } + + private RelabelItem item() { + RelabelItem value = new RelabelItem(); value.setId(10L); value.setApplicationId(1L); value.setCustomerId("100"); + value.setSkuCode("SKU-OLD"); value.setNewSkuCode("SKU-NEW"); value.setProductNameZh("product"); + value.setShipmentNo("SHIP-1"); value.setNotifiedPackageCount(1); return value; + } + + private PdaRelabelTask task(String status, int version) { + PdaRelabelTask value = new PdaRelabelTask(); value.setId(20L); value.setApplicationId(1L); value.setCustomerId("100"); + value.setRelabelNo("FBR-1"); value.setOverseasWarehouseId(900L); value.setStatus(status); + value.setOperatorKey("7"); value.setOperatorName("picker"); value.setExpectedPackages(1); + value.setRelabelledPackages(0); value.setSkippedPackages(0); value.setExceptionPackages(0); + value.setClaimedAt(LocalDateTime.now()); value.setVersion(version); return value; + } + + private PdaRelabelUnitFact fact(String result) { + PdaRelabelUnitFact value = new PdaRelabelUnitFact(); value.setId(30L); value.setTaskId(20L); + value.setApplicationId(1L); value.setItemId(10L); value.setCustomerId("100"); + value.setOldLabel("BOX-1"); value.setNewLabel("SKU-NEW"); value.setResult(result); + value.setOperatorName("picker"); value.setOccurredAt(LocalDateTime.now()); return value; + } +} diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 24a9d2c14..81332bff9 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -3614,6 +3614,41 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_business_binding' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND COALESCE(table_comment,'') <> '实物托盘状态幂等变更与补偿记录'), 'ALTER TABLE wh_pallet_operation COMMENT = ''实物托盘状态幂等变更与补偿记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='id' AND COALESCE(column_comment,'') <> '托盘状态操作ID'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN id BIGINT NOT NULL COMMENT ''托盘状态操作ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='operation_key' AND COALESCE(column_comment,'') <> '跨服务幂等操作键'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN operation_key VARCHAR(128) NOT NULL COMMENT ''跨服务幂等操作键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '状态变更请求摘要'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN request_hash VARCHAR(128) NOT NULL COMMENT ''状态变更请求摘要''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '实际海外仓ID'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN warehouse_id BIGINT NOT NULL COMMENT ''实际海外仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='pallet_id' AND COALESCE(column_comment,'') <> '实物托盘ID'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN pallet_id BIGINT NOT NULL COMMENT ''实物托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='pallet_code' AND COALESCE(column_comment,'') <> '实物托盘码快照'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT ''实物托盘码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='before_status' AND COALESCE(column_comment,'') <> '变更前托盘状态'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN before_status VARCHAR(20) NOT NULL COMMENT ''变更前托盘状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='after_status' AND COALESCE(column_comment,'') <> '变更后托盘状态'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN after_status VARCHAR(20) NOT NULL COMMENT ''变更后托盘状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='before_location_id' AND COALESCE(column_comment,'') <> '变更前库位ID'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN before_location_id BIGINT NOT NULL COMMENT ''变更前库位ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='after_location_id' AND COALESCE(column_comment,'') <> '变更后库位ID'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN after_location_id BIGINT NOT NULL COMMENT ''变更后库位ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='before_version' AND COALESCE(column_comment,'') <> '变更前托盘版本'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN before_version INT NOT NULL COMMENT ''变更前托盘版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='after_version' AND COALESCE(column_comment,'') <> '变更后托盘版本'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN after_version INT NOT NULL COMMENT ''变更后托盘版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='operation_status' AND COALESCE(column_comment,'') <> '操作状态:APPLIED已应用、COMPENSATED已补偿'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN operation_status VARCHAR(32) NOT NULL COMMENT ''操作状态:APPLIED已应用、COMPENSATED已补偿''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='reason' AND COALESCE(column_comment,'') <> '操作原因'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN reason VARCHAR(500) NULL COMMENT ''操作原因''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; -- ============================================================================ -- 2026-08-19 / 026 -- Separate tail-leg main workflow status from child-document status diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java new file mode 100644 index 000000000..37ffad011 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java @@ -0,0 +1,39 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.warehouse.dto.PalletTransitionRequests; +import com.baiyue.warehouse.dto.PalletTransitionResponse; +import com.baiyue.warehouse.security.InternalServiceTokenGuard; +import com.baiyue.warehouse.service.WarehousePalletTransitionService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/internal/v1/pda-pallet-transitions") +public class InternalPdaPalletTransitionController { + @Resource private InternalServiceTokenGuard tokenGuard; + @Resource private WarehousePalletTransitionService service; + + @PostMapping("/{palletId}") + public ApiResponse apply( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long palletId, @Valid @RequestBody PalletTransitionRequests.Apply request) { + tokenGuard.verify(token); + return ApiResponse.success(service.apply(palletId, request)); + } + + @PostMapping("/{palletId}/{operationKey}/compensate") + public ApiResponse compensate( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long palletId, @PathVariable String operationKey, + @Valid @RequestBody PalletTransitionRequests.Compensate request) { + tokenGuard.verify(token); + return ApiResponse.success(service.compensate(palletId, operationKey, request)); + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java index a5e66727a..1d651fc2d 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java @@ -31,4 +31,5 @@ public class InternalWarehousePalletController { tokenGuard.verify(token); return ApiResponse.success(service.resolvePublicPallet(palletCode)); } + } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionRequests.java new file mode 100644 index 000000000..9d8bdc683 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionRequests.java @@ -0,0 +1,20 @@ +package com.baiyue.warehouse.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +public final class PalletTransitionRequests { + private PalletTransitionRequests() { } + + public record Apply(@Positive long warehouseId, + @PositiveOrZero int expectedVersion, + @NotBlank @Size(max = 20) String targetStatus, + @NotNull @Positive Long targetLocationId, + @NotBlank @Size(max = 128) String operationKey, + @Size(max = 500) String reason) { } + + public record Compensate(@Positive long warehouseId) { } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionResponse.java new file mode 100644 index 000000000..c3e159661 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionResponse.java @@ -0,0 +1,16 @@ +package com.baiyue.warehouse.dto; + +public record PalletTransitionResponse( + String operationKey, + long palletId, + String palletCode, + long warehouseId, + String beforeStatus, + String afterStatus, + long beforeLocationId, + long afterLocationId, + int beforeVersion, + int afterVersion, + String operationStatus, + boolean replayed +) { } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java new file mode 100644 index 000000000..f9bfbc36e --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java @@ -0,0 +1,29 @@ +package com.baiyue.warehouse.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("wh_pallet_operation") +public class WarehousePalletOperationEntity { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String operationKey; + private String requestHash; + private Long warehouseId; + private Long palletId; + private String palletCode; + private String beforeStatus; + private String afterStatus; + private Long beforeLocationId; + private Long afterLocationId; + private Integer beforeVersion; + private Integer afterVersion; + private String operationStatus; + private String reason; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java index 5e2f5ca0d..fb225f1f0 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java @@ -3,5 +3,4 @@ package com.baiyue.warehouse.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.warehouse.entity.WarehousePalletEntity; -public interface WarehousePalletMapper extends BaseMapper { -} +public interface WarehousePalletMapper extends BaseMapper { } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java new file mode 100644 index 000000000..751a848c0 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java @@ -0,0 +1,32 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehousePalletOperationEntity; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +public interface WarehousePalletTransitionMapper extends BaseMapper { + @Select("SELECT * FROM wh_pallet_operation WHERE operation_key=#{operationKey} LIMIT 1") + WarehousePalletOperationEntity findOperation(@Param("operationKey") String operationKey); + + @Select("SELECT * FROM wh_pallet_operation WHERE operation_key=#{operationKey} LIMIT 1 FOR UPDATE") + WarehousePalletOperationEntity lockOperation(@Param("operationKey") String operationKey); + + @Select("SELECT * FROM wh_pallet WHERE id=#{palletId} AND customer_id=0 " + + "AND warehouse_id=#{warehouseId} AND deleted=0 LIMIT 1 FOR UPDATE") + WarehousePalletEntity lockPublicPallet(@Param("warehouseId") long warehouseId, + @Param("palletId") long palletId); + + @Select("SELECT COUNT(*) FROM wh_location WHERE id=#{locationId} AND customer_id=0 " + + "AND warehouse_id=#{warehouseId} AND status<>'INACTIVE' AND deleted=0") + long countActiveLocation(@Param("warehouseId") long warehouseId, @Param("locationId") long locationId); + + @Update("UPDATE wh_pallet SET status=#{targetStatus},location_id=#{targetLocationId},version=version+1," + + "updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{palletId} AND customer_id=0 " + + "AND warehouse_id=#{warehouseId} AND version=#{expectedVersion} AND status<>'SCRAPPED' AND deleted=0") + int transition(@Param("warehouseId") long warehouseId, @Param("palletId") long palletId, + @Param("targetStatus") String targetStatus, @Param("targetLocationId") long targetLocationId, + @Param("expectedVersion") int expectedVersion); +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java new file mode 100644 index 000000000..bf2782ec6 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java @@ -0,0 +1,106 @@ +package com.baiyue.warehouse.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.PalletTransitionRequests; +import com.baiyue.warehouse.dto.PalletTransitionResponse; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehousePalletOperationEntity; +import com.baiyue.warehouse.mapper.WarehousePalletTransitionMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.Set; + +@Service +public class WarehousePalletTransitionService { + private static final Set MUTABLE_STATUSES = Set.of("IDLE", "OCCUPIED"); + @Resource private WarehousePalletTransitionMapper mapper; + + @Transactional + public PalletTransitionResponse apply(long palletId, PalletTransitionRequests.Apply request) { + String key = request.operationKey().trim(); + String status = request.targetStatus().trim().toUpperCase(); + if (!MUTABLE_STATUSES.contains(status)) { + throw new BizException(ResultCode.BAD_REQUEST, "托盘作业状态只允许 IDLE 或 OCCUPIED"); + } + String requestHash = hash(request.warehouseId() + "|" + palletId + "|" + request.expectedVersion() + + "|" + status + "|" + request.targetLocationId()); + WarehousePalletOperationEntity existing = mapper.findOperation(key); + if (existing != null) return replay(existing, requestHash); + + WarehousePalletEntity pallet = mapper.lockPublicPallet(request.warehouseId(), palletId); + if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "当前冻结仓内托盘不存在"); + int version = pallet.getVersion() == null ? 0 : pallet.getVersion(); + if (version != request.expectedVersion()) { + throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请重新扫码"); + } + if (mapper.countActiveLocation(request.warehouseId(), request.targetLocationId()) != 1) { + throw new BizException(ResultCode.CONFLICT, "目标库位无效或已停用"); + } + if (pallet.getLocationId() == null) { + throw new BizException(ResultCode.CONFLICT, "托盘没有有效库位,不能执行库内作业"); + } + if (mapper.transition(request.warehouseId(), palletId, status, request.targetLocationId(), version) != 1) { + throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请重新扫码"); + } + WarehousePalletOperationEntity operation = new WarehousePalletOperationEntity(); + operation.setId(IdWorker.getId()); operation.setOperationKey(key); operation.setRequestHash(requestHash); + operation.setWarehouseId(request.warehouseId()); operation.setPalletId(palletId); + operation.setPalletCode(pallet.getPalletCode()); operation.setBeforeStatus(pallet.getStatus()); + operation.setAfterStatus(status); operation.setBeforeLocationId(pallet.getLocationId()); + operation.setAfterLocationId(request.targetLocationId()); operation.setBeforeVersion(version); + operation.setAfterVersion(version + 1); operation.setOperationStatus("APPLIED"); + operation.setReason(trim(request.reason())); operation.setCreatedAt(LocalDateTime.now()); + operation.setUpdatedAt(operation.getCreatedAt()); mapper.insert(operation); + return response(operation, false); + } + + @Transactional + public PalletTransitionResponse compensate(long palletId, String operationKey, + PalletTransitionRequests.Compensate request) { + WarehousePalletOperationEntity operation = mapper.lockOperation(operationKey); + if (operation == null || !Long.valueOf(palletId).equals(operation.getPalletId()) + || !Long.valueOf(request.warehouseId()).equals(operation.getWarehouseId())) { + throw new BizException(ResultCode.NOT_FOUND, "托盘状态变更记录不存在"); + } + if ("COMPENSATED".equals(operation.getOperationStatus())) return response(operation, true); + if (!"APPLIED".equals(operation.getOperationStatus())) { + throw new BizException(ResultCode.CONFLICT, "托盘状态变更不可补偿"); + } + if (mapper.transition(request.warehouseId(), palletId, operation.getBeforeStatus(), + operation.getBeforeLocationId(), operation.getAfterVersion()) != 1) { + throw new BizException(ResultCode.CONFLICT, "托盘已被后续作业修改,自动补偿失败"); + } + operation.setOperationStatus("COMPENSATED"); operation.setUpdatedAt(LocalDateTime.now()); + mapper.updateById(operation); + return response(operation, false); + } + + private PalletTransitionResponse replay(WarehousePalletOperationEntity operation, String hash) { + if (!hash.equals(operation.getRequestHash())) { + throw new BizException(ResultCode.CONFLICT, "操作幂等键已被不同托盘请求使用"); + } + return response(operation, true); + } + private static PalletTransitionResponse response(WarehousePalletOperationEntity value, boolean replayed) { + return new PalletTransitionResponse(value.getOperationKey(), value.getPalletId(), value.getPalletCode(), + value.getWarehouseId(), value.getBeforeStatus(), value.getAfterStatus(), value.getBeforeLocationId(), + value.getAfterLocationId(), value.getBeforeVersion(), value.getAfterVersion(), + value.getOperationStatus(), replayed); + } + private static String hash(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } + } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } +} diff --git a/warehouse-overseas-service/src/main/resources/db/warehouse.sql b/warehouse-overseas-service/src/main/resources/db/warehouse.sql index 9eb4d8dcd..7665093d0 100644 --- a/warehouse-overseas-service/src/main/resources/db/warehouse.sql +++ b/warehouse-overseas-service/src/main/resources/db/warehouse.sql @@ -178,6 +178,20 @@ CREATE TABLE IF NOT EXISTS wh_pallet_business_binding ( UNIQUE KEY uk_wh_pallet_binding_pallet (pallet_id) ) ENGINE=InnoDB COMMENT='托盘业务绑定关系'; +CREATE TABLE IF NOT EXISTS wh_pallet_operation ( + id BIGINT NOT NULL COMMENT '托盘状态操作ID', operation_key VARCHAR(128) NOT NULL COMMENT '跨服务幂等操作键', + request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', + pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', + before_status VARCHAR(20) NOT NULL COMMENT '变更前托盘状态', after_status VARCHAR(20) NOT NULL COMMENT '变更后托盘状态', + before_location_id BIGINT NOT NULL COMMENT '变更前库位ID', after_location_id BIGINT NOT NULL COMMENT '变更后库位ID', + before_version INT NOT NULL COMMENT '变更前托盘版本', after_version INT NOT NULL COMMENT '变更后托盘版本', + operation_status VARCHAR(32) NOT NULL COMMENT '操作状态:APPLIED已应用、COMPENSATED已补偿', + reason VARCHAR(500) 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 '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_operation_key (operation_key), + KEY idx_wh_pallet_operation_pallet (warehouse_id,pallet_id,created_at) +) ENGINE=InnoDB COMMENT='实物托盘状态幂等变更与补偿记录'; + -- Every warehouse has one non-occupiable virtual location used for inventory history. INSERT INTO wh_location (id, customer_id, warehouse_id, zone_code, location_code, location_type, max_load_kg, status, remark) diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java index f59b0735b..678cdbb21 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java @@ -29,7 +29,7 @@ class SchemaChineseCommentContractTest { Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8); List tables = parseAndAssert(baseline, "仓储基线 SQL"); - assertEquals(7, tables.size(), "仓储表数量变化时必须同步表索引和备注迁移"); + assertEquals(8, tables.size(), "仓储表数量变化时必须同步表索引和备注迁移"); parseAndAssert(incremental, "统一增量 SQL"); int marker = incremental.indexOf("2026-08-18 / 025 Chinese table and column comments"); diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java new file mode 100644 index 000000000..5f998e23c --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java @@ -0,0 +1,91 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.PalletTransitionRequests; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehousePalletOperationEntity; +import com.baiyue.warehouse.mapper.WarehousePalletTransitionMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class WarehousePalletTransitionServiceTest { + @Mock private WarehousePalletTransitionMapper mapper; + private WarehousePalletTransitionService service; + + @BeforeEach void setUp() { + service = new WarehousePalletTransitionService(); + ReflectionTestUtils.setField(service, "mapper", mapper); + } + + @Test void appliesVersionedPublicPalletTransitionAndPersistsCompensationReceipt() { + WarehousePalletEntity pallet = pallet("P-1", "IDLE", 4); + when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet); + when(mapper.countActiveLocation(88L, 20L)).thenReturn(1L); + when(mapper.transition(88L, 10L, "OCCUPIED", 20L, 4)).thenReturn(1); + + var response = service.apply(10L, new PalletTransitionRequests.Apply( + 88L, 4, "OCCUPIED", 20L, "operation-1", "assemble")); + + assertEquals("APPLIED", response.operationStatus()); + assertEquals(5, response.afterVersion()); + ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletOperationEntity.class); + verify(mapper).insert(captor.capture()); + assertEquals("IDLE", captor.getValue().getBeforeStatus()); + assertEquals("OCCUPIED", captor.getValue().getAfterStatus()); + assertEquals(4, captor.getValue().getBeforeVersion()); + } + + @Test void exactReplayDoesNotMutatePhysicalPalletAgain() { + WarehousePalletOperationEntity existing = operation(); + when(mapper.findOperation("operation-1")).thenReturn(existing); + + var response = service.apply(10L, new PalletTransitionRequests.Apply( + 88L, 4, "OCCUPIED", 20L, "operation-1", "any changed reason")); + + assertTrue(response.replayed()); + verify(mapper, never()).transition(anyLong(), anyLong(), anyString(), anyLong(), anyInt()); + } + + @Test void compensationRequiresExactAppliedVersionAndRestoresBeforeState() { + WarehousePalletOperationEntity existing = operation(); + when(mapper.lockOperation("operation-1")).thenReturn(existing); + when(mapper.transition(88L, 10L, "IDLE", 11L, 5)).thenReturn(1); + + var response = service.compensate(10L, "operation-1", new PalletTransitionRequests.Compensate(88L)); + + assertEquals("COMPENSATED", response.operationStatus()); + verify(mapper).updateById(existing); + } + + @Test void staleVersionIsRejectedWithoutPhysicalMutation() { + when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet("P-1", "IDLE", 5)); + assertThrows(BizException.class, () -> service.apply(10L, new PalletTransitionRequests.Apply( + 88L, 4, "OCCUPIED", 20L, "operation-1", null))); + verify(mapper, never()).transition(anyLong(), anyLong(), anyString(), anyLong(), anyInt()); + } + + private static WarehousePalletEntity pallet(String code, String status, int version) { + WarehousePalletEntity value = new WarehousePalletEntity(); value.setId(10L); value.setCustomerId(0L); + value.setWarehouseId(88L); value.setLocationId(11L); value.setPalletCode(code); + value.setStatus(status); value.setVersion(version); return value; + } + private static WarehousePalletOperationEntity operation() { + WarehousePalletOperationEntity value = new WarehousePalletOperationEntity(); value.setId(1L); + value.setOperationKey("operation-1"); + value.setRequestHash("f17b4386e6fdecbaab1d066ae4fefa47dba0687ff27361b8f92ef097d4e8072e"); + value.setWarehouseId(88L); value.setPalletId(10L); value.setPalletCode("P-1"); + value.setBeforeStatus("IDLE"); value.setAfterStatus("OCCUPIED"); value.setBeforeLocationId(11L); + value.setAfterLocationId(20L); value.setBeforeVersion(4); value.setAfterVersion(5); + value.setOperationStatus("APPLIED"); return value; + } +} From 68bdc3e6bbeea430456e03f70c70e2756cbf0cca Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 11:25:13 +0800 Subject: [PATCH 088/115] test: cover overseas pda warehouse operation lifecycles --- scripts/admin-test-env-acceptance.mjs | 23 +- scripts/admin-test-env-modules/README.md | 6 +- ...-overseas-pda-operations-contract.test.mjs | 88 ++++ .../phase4-overseas-pda-operations.mjs | 442 ++++++++++++++++++ .../phase4-overseas-pda.mjs | 44 +- ...verseas-acceptance-entry-contract.test.mjs | 4 +- 6 files changed, 588 insertions(+), 19 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 0c3a005ff..ea82e9925 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import process from 'node:process' -const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] +const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] const MODULE_DEPENDENCIES = new Map([ ['orders', ['merchant-qualification-fixture']], @@ -31,11 +31,12 @@ const MODULE_DEPENDENCIES = new Map([ ['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings']], ['phase4-carrier-pickup-ready', ['phase3-carrier-schedule-queries']], ['phase4-overseas-pda', ['phase4-carrier-pickup-ready']], - ['phase4-overseas-inbound-operations', ['phase4-overseas-pda']], - ['phase4-overseas-client-services', ['phase4-overseas-pda']], - ['phase4-overseas-dispatch', ['phase4-overseas-pda']], - ['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda']], - ['phase4-carrier-nodes', ['phase4-overseas-pda']], + ['phase4-overseas-pda-operations', ['phase4-overseas-pda']], + ['phase4-overseas-inbound-operations', ['phase4-overseas-pda-operations']], + ['phase4-overseas-client-services', ['phase4-overseas-pda-operations']], + ['phase4-overseas-dispatch', ['phase4-overseas-pda-operations']], + ['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda-operations']], + ['phase4-carrier-nodes', ['phase4-overseas-pda-operations']], ]) function stableTopologicalOrder(modules) { @@ -119,10 +120,14 @@ export function normalizeModuleOrder(modules) { } for (const consumer of ['phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch']) { - if (normalized.includes(consumer) && !normalized.includes('phase4-overseas-pda')) { - normalized.splice(normalized.indexOf(consumer), 0, 'phase4-overseas-pda') + if (normalized.includes(consumer) && !normalized.includes('phase4-overseas-pda-operations')) { + normalized.splice(normalized.indexOf(consumer), 0, 'phase4-overseas-pda-operations') } } + if (normalized.includes('phase4-overseas-pda') + && !normalized.includes('phase4-overseas-pda-operations')) { + normalized.splice(normalized.indexOf('phase4-overseas-pda') + 1, 0, 'phase4-overseas-pda-operations') + } if (normalized.includes('phase4-overseas-pda')) { const prerequisites = ['merchant-qualification-fixture', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-loading-finalize'] @@ -133,7 +138,7 @@ export function normalizeModuleOrder(modules) { if (normalized.includes('phase4-cross-region-events')) { const prerequisites = ['system', 'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate', - 'phase3-loading-finalize', 'phase3-customs', 'phase4-overseas-pda'] + 'phase3-loading-finalize', 'phase3-customs', 'phase4-overseas-pda-operations'] const targetIndex = normalized.indexOf('phase4-cross-region-events') const missing = prerequisites.filter((name) => !normalized.includes(name)) if (missing.length > 0) normalized.splice(targetIndex, 0, ...missing) diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 80a224f18..30398eadd 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -55,7 +55,7 @@ export async function run(context) { | 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` | | Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` | | 四期海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` | -| 四期海外 PDA 柜号收货 HTTP 生命周期 | `phase4-overseas-pda.mjs`(客户海外 Token 按柜号执行 candidates/detail/prepare、三角色照片签收、FBA SKU 箱数或 OVERSEAS 逐箱实收、完成与幂等/冲突边界;真实柜、箱唛或托盘前置缺失时 fail-closed) | `phase4-overseas-pda-contract.test.mjs` | +| 四期海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行 P2 盘点、P3 整托移库、P4 托盘拆装换绑、P5 换标复核;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` | | 三四期跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` | | 四期海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` | | 四期海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` | @@ -64,10 +64,10 @@ export async function run(context) { 表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。 -四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;独立 PDA 模块使用本轮客户账号的海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID,也不调用出库、派送或内部接口。收货占用的托盘作为库存事实保留,无法自动取得真实柜、箱唛或托盘时必须形成 coverage gap 和部署阻断,不能按空场景通过;船节点禁止手工创建任务。 +四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 WAREHOUSE_PICKER 海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、整托移库、托盘操作和换标;新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。 ```powershell -node scripts/admin-test-env-acceptance.mjs --gateway --overseas-gateway --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes +node scripts/admin-test-env-acceptance.mjs --gateway --overseas-gateway --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-overseas-pda-operations,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes ``` 只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.mjs` 不代表总入口已注册。新增或改名 HTTP 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。 diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs new file mode 100644 index 000000000..844fecbb7 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' +import { run } from './phase4-overseas-pda-operations.mjs' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda-operations.mjs'), 'utf8') +const receivingSource = fs.readFileSync(path.join(here, 'phase4-overseas-pda.mjs'), 'utf8') +const entrySource = fs.readFileSync(path.join(here, '..', 'admin-test-env-acceptance.mjs'), 'utf8') + +test('PDA operations run immediately after receiving and all later consumers depend on cleanup completion', () => { + const modules = normalizeModuleOrder(['phase4-overseas-client-services']) + const receiving = modules.indexOf('phase4-overseas-pda') + const operations = modules.indexOf('phase4-overseas-pda-operations') + const consumer = modules.indexOf('phase4-overseas-client-services') + assert.ok(receiving >= 0 && operations === receiving + 1 && consumer > operations) + assert.match(entrySource, /'phase4-overseas-pda-operations', \['phase4-overseas-pda'\]/) + for (const module of ['phase4-overseas-inbound-operations', 'phase4-overseas-client-services', + 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-carrier-nodes']) { + assert.match(entrySource, new RegExp(`'${module}', \\[[^\\]]*'phase4-overseas-pda-operations'`)) + } +}) + +test('receiving hands off only transient credentials and completed warehouse facts', () => { + assert.match(receivingSource, /context\.context\.phase4OverseasPdaOperations = \{/) + assert.match(receivingSource, /completedFixtures: \[\]/) + assert.match(receivingSource, /if \(!handoffReady\) \{[\s\S]*await cleanup\(\)/) + assert.doesNotMatch(receivingSource, /createdResources\.phase4OverseasPdaOperations/) + assert.doesNotMatch(receivingSource, /primary: primary \? \{[^}]*password/) + assert.doesNotMatch(entrySource.match(/const reportSurface = JSON\.stringify\(\{([\s\S]*?)\n \}\)/)?.[1] || '', + /context/) +}) + +test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries without outbound domains', () => { + for (const endpoint of [ + '/api/pda/v1/last-leg/stocktakes', + '/api/pda/v1/last-leg/inventory-moves', + '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', + '/swap', '/disassemble', '/assemble', '/unbind', + '/api/pda/v1/last-leg/relabel-tasks', + '/candidates', '/claim', '/units', '/skip', '/exceptions', '/photos/files', '/complete', '/review', + ]) assert.ok(source.includes(endpoint), endpoint) + for (const marker of ['按原数计数', '同键重放', '同键异载荷A0409', '旧版本A0409', + 'SUBMITTED', '移回原库位', 'WAIT_REVIEW', 'REWORK', 'APPROVE', 'COMPLETED']) { + assert.ok(source.includes(marker), marker) + } + for (const forbidden of ['/outbound', '/returns/outbound', '/dispatch', '/delivery', '/internal/']) { + assert.equal(source.includes(forbidden), false, forbidden) + } + assert.doesNotMatch(source, /fetch\(|https?:\/\//) + assert.match(source, /requestId=/) + assert.match(source, /traceId=/) +}) + +test('fixture failure still disables both picker accounts and removes all transient tokens', async () => { + const cleanups = [] + const gaps = [] + const steps = [] + const handoff = { + marker: 'PDAOPS-CONTRACT', + primary: { membershipId: '1', username: 'primary', token: 'primary-secret-token' }, + secondary: { membershipId: '2', username: 'secondary', token: 'secondary-secret-token' }, + completedFixtures: [], + async cleanup() { cleanups.push('secondary', 'primary') }, + } + const context = { + marker: 'PDAOPS-CONTRACT', context: { phase4OverseasPdaOperations: handoff }, + createdResources: {}, + addCoverageGap(...args) { gaps.push(args) }, + assert(module, name, response, passed, evidence, options) { + steps.push({ module, name, passed, evidence, options }) + }, + } + + await run(context) + + assert.deepEqual(cleanups, ['secondary', 'primary']) + assert.equal(handoff.primary.token, undefined) + assert.equal(handoff.secondary.token, undefined) + assert.equal(context.context.phase4OverseasPdaOperations, undefined) + assert.ok(gaps.some((gap) => gap[2] === 'P2-P5本轮PDA收货交接')) + assert.ok(steps.some((step) => !step.passed && step.options?.deploymentBlocker)) + assert.equal(JSON.stringify(context).includes('secret-token'), false) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs new file mode 100644 index 000000000..28696796e --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs @@ -0,0 +1,442 @@ +const MODULE = 'phase4-overseas-pda-operations' +const SCENARIO = 'phase4-overseas-pda-p2-p5' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')) + +function id(value) { return value == null ? '' : String(value) } +function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' } +function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' } +function conflict(response) { + return (response?.http === 409 || (response?.http === 200 + && ['409', 'A0409'].includes(String(response?.body?.code)))) + && nonBlank(response?.requestId) && nonBlank(response?.traceId) +} +function trace(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +function records(value) { return Array.isArray(value?.records) ? value.records : Array.isArray(value) ? value : [] } +function key(context, action) { + return `${context.marker}-PDAOPS-${action}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) +} +function query(path, values) { + const params = new URLSearchParams() + for (const [name, value] of Object.entries(values || {})) { + if (value !== undefined && value !== null && String(value) !== '') params.set(name, String(value)) + } + return params.size ? `${path}?${params}` : path +} +function operationPallet(result, palletCode) { + return (result?.pallets || []).find((item) => item?.palletCode === palletCode) +} + +function blocker(context, name, reason, response = null) { + context.addCoverageGap(MODULE, SCENARIO, name, reason, 'fixture-unavailable', { deploymentBlocker: true }) + context.assert(MODULE, `${name}覆盖前置`, response, false, `${reason}; ${trace(response)}`, OPTIONS) +} + +async function request(context, token, method, name, path, body = undefined) { + const response = await context.requestOverseas(method, path, body, {}, token) + context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS) + return { response, data: success(response) ? response.body?.data : null } +} + +async function expectConflict(context, token, method, name, path, body) { + const response = await context.requestOverseas(method, path, body, {}, token) + context.addStep(MODULE, name, response, conflict(response), trace(response), OPTIONS) + return response +} + +async function resolvePallet(context, token, palletCode, name = '扫码解析托盘') { + return request(context, token, 'POST', name, + '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode }) +} + +async function createLocation(context, warehouseId, suffix) { + const locationCode = `PDAOPS-${context.marker}-${suffix}`.replace(/[^A-Za-z0-9_-]/g, '').slice(-64) + const response = await context.requestOverseas('POST', + `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { + zoneCode: 'PDAOPS', locationCode, locationType: 'STORAGE', maxLoadKg: 5000, + status: 'IDLE', remark: `${context.marker}-pda-operations-${suffix}`, + }) + const locationId = id(response?.body?.data) + context.addStep(MODULE, `创建${suffix}目标库位`, response, + success(response) && /^\d+$/.test(locationId) && locationId !== '0', + `locationId=${locationId || 'missing'}, locationCode=${locationCode}; ${trace(response)}`, OPTIONS) + return success(response) ? { locationId, locationCode } : null +} + +async function createEmptyPallet(context, token, fixture, suffix, destinationWarehouseCode) { + const palletCode = `PDAOPS-${context.marker}-${suffix}`.replace(/[^A-Za-z0-9_-]/g, '').slice(-120) + const response = await context.requestOverseas('POST', + `/api/platform/v1/warehouse-admin/${encodeURIComponent(fixture.overseasWarehouseId)}/pallets`, { + palletCode, palletType: '木托盘', capacityCbm: 10, unitPrice: 0, status: 'IDLE', + locationId: fixture.location.locationId, + fbaWarehouseCode: destinationWarehouseCode || null, + remark: `${context.marker}-pda-operations-${suffix}`, + }) + context.addStep(MODULE, `创建${suffix}辅助空托盘`, response, success(response), trace(response), OPTIONS) + if (!success(response)) return null + return (await resolvePallet(context, token, palletCode, `解析${suffix}辅助空托盘`)).data +} + +async function verifyStocktake(context, token, fixture) { + const path = '/api/pda/v1/last-leg/stocktakes' + const body = { scopeType: 'PALLET', scopeCode: fixture.pallet.palletCode, + idempotencyKey: key(context, 'STOCKTAKE-CREATE') } + const created = await request(context, token, 'POST', 'P2按PALLET创建盘点任务', path, body) + const taskNo = id(created.data?.task?.taskNo) + if (!taskNo || !Array.isArray(created.data?.items) || created.data.items.length === 0) { + blocker(context, 'P2真实库存盘点', '本轮完成托盘没有可盘点库存位置', created.response) + return false + } + const replay = await request(context, token, 'POST', 'P2创建盘点同键重放', path, body) + context.assert(MODULE, 'P2创建重放复用同一任务', replay.response, + id(replay.data?.task?.taskNo) === taskNo, trace(replay.response), OPTIONS) + await expectConflict(context, token, 'POST', 'P2创建盘点同键异载荷A0409', path, + { ...body, scopeCode: `${body.scopeCode}-DIFFERENT` }) + + for (const [index, item] of created.data.items.entries()) { + const countPath = `${path}/${encodeURIComponent(taskNo)}/counts` + const countBody = { positionId: id(item.positionId), actualPackages: Number(item.bookPackages), + expectedVersion: Number(item.version), idempotencyKey: key(context, `STOCKTAKE-COUNT-${index}`) } + const counted = await request(context, token, 'POST', `P2库存项${index + 1}按原数计数`, countPath, countBody) + context.assert(MODULE, `P2库存项${index + 1}零差异`, counted.response, + Number(counted.data?.actualPackages) === Number(item.bookPackages) + && Number(counted.data?.differencePackages) === 0, + `positionId=${id(item.positionId)}, book=${item.bookPackages}; ${trace(counted.response)}`, OPTIONS) + const countReplay = await request(context, token, 'POST', `P2库存项${index + 1}计数同键重放`, countPath, countBody) + context.assert(MODULE, `P2库存项${index + 1}重放结果稳定`, countReplay.response, + id(countReplay.data?.positionId) === id(item.positionId) + && Number(countReplay.data?.actualPackages) === Number(item.bookPackages), trace(countReplay.response), OPTIONS) + await expectConflict(context, token, 'POST', `P2库存项${index + 1}计数同键异载荷A0409`, countPath, + { ...countBody, actualPackages: Number(item.bookPackages) + 1 }) + await expectConflict(context, token, 'POST', `P2库存项${index + 1}旧版本A0409`, countPath, + { ...countBody, idempotencyKey: key(context, `STOCKTAKE-COUNT-STALE-${index}`) }) + } + + const detail = await request(context, token, 'GET', 'P2回查全部盘点项已计数', + `${path}/${encodeURIComponent(taskNo)}`) + const taskVersion = Number(detail.data?.task?.version) + context.assert(MODULE, 'P2盘点任务计数守恒', detail.response, + detail.data?.task?.status === 'IN_PROGRESS' + && Number(detail.data?.task?.countedCount) === detail.data?.items?.length + && detail.data.items.every((item) => Number(item.actualPackages) === Number(item.bookPackages)), + `counted=${detail.data?.task?.countedCount ?? 'missing'}, items=${detail.data?.items?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS) + await expectConflict(context, token, 'POST', 'P2提交盘点旧任务版本A0409', + `${path}/${encodeURIComponent(taskNo)}/submit`, { + expectedVersion: Math.max(0, taskVersion - 1), reason: `${context.marker}-stale`, + idempotencyKey: key(context, 'STOCKTAKE-SUBMIT-STALE'), + }) + const submitBody = { expectedVersion: taskVersion, reason: `${context.marker}-matched`, + idempotencyKey: key(context, 'STOCKTAKE-SUBMIT') } + const submitted = await request(context, token, 'POST', 'P2提交盘点到SUBMITTED', + `${path}/${encodeURIComponent(taskNo)}/submit`, submitBody) + context.assert(MODULE, 'P2盘点提交状态正确且无差异', submitted.response, + submitted.data?.task?.status === 'SUBMITTED' && Number(submitted.data?.task?.differenceCount) === 0, + trace(submitted.response), OPTIONS) + const submitReplay = await request(context, token, 'POST', 'P2提交盘点同键重放', + `${path}/${encodeURIComponent(taskNo)}/submit`, submitBody) + context.assert(MODULE, 'P2提交重放仍为SUBMITTED', submitReplay.response, + submitReplay.data?.task?.status === 'SUBMITTED', trace(submitReplay.response), OPTIONS) + const page = await request(context, token, 'GET', 'P2分页查询已提交盘点', + `${path}?page=1&size=20&status=SUBMITTED`) + context.assert(MODULE, 'P2列表和详情均包含本轮任务', page.response, + records(page.data).some((item) => id(item?.taskNo) === taskNo), + `taskNo=${taskNo}, records=${records(page.data).length}; ${trace(page.response)}`, OPTIONS) + return success(submitted.response) +} + +async function verifyInventoryMove(context, token, fixture) { + const target = await createLocation(context, fixture.overseasWarehouseId, 'MOVE') + if (!target) return false + let pallet = (await resolvePallet(context, token, fixture.pallet.palletCode, 'P3移库前解析整托')).data + if (!pallet || !nonBlank(pallet.locationCode)) { + blocker(context, 'P3整托移库', '本轮完成托盘无法解析当前位置') + return false + } + const originalLocationCode = pallet.locationCode + const path = '/api/pda/v1/last-leg/inventory-moves' + const body = { sourcePalletCode: pallet.palletCode, targetLocationCode: target.locationCode, + expectedPalletVersion: Number(pallet.palletVersion), idempotencyKey: key(context, 'MOVE-FORWARD'), + reason: `${context.marker}-move-forward` } + const moved = await request(context, token, 'POST', 'P3整托移到目标库位', path, body) + context.assert(MODULE, 'P3整托移库数量和目标位置正确', moved.response, + moved.data?.status === 'COMPLETED' && moved.data?.targetLocationCode === target.locationCode + && Number(moved.data?.packageCount) > 0, trace(moved.response), OPTIONS) + const replay = await request(context, token, 'POST', 'P3整托移库同键重放', path, body) + context.assert(MODULE, 'P3移库重放标记且结果稳定', replay.response, + replay.data?.replayed === true && replay.data?.moveNo === moved.data?.moveNo, trace(replay.response), OPTIONS) + await expectConflict(context, token, 'POST', 'P3移库同键异载荷A0409', path, + { ...body, reason: `${body.reason}-DIFFERENT` }) + await expectConflict(context, token, 'POST', 'P3移库旧托盘版本A0409', path, { + ...body, targetLocationCode: originalLocationCode, idempotencyKey: key(context, 'MOVE-STALE'), + }) + pallet = (await resolvePallet(context, token, pallet.palletCode, 'P3回查托盘已位于目标库位')).data + context.assert(MODULE, 'P3托盘与库存位置同步到目标库位', moved.response, + pallet?.locationCode === target.locationCode && Number(pallet?.totalPackages) > 0, + `location=${pallet?.locationCode || 'missing'}; ${trace(moved.response)}`, OPTIONS) + const restoreBody = { sourcePalletCode: pallet.palletCode, targetLocationCode: originalLocationCode, + expectedPalletVersion: Number(pallet.palletVersion), idempotencyKey: key(context, 'MOVE-RESTORE'), + reason: `${context.marker}-move-restore` } + const restored = await request(context, token, 'POST', 'P3整托移回原库位供后续验证', path, restoreBody) + return success(restored.response) && restored.data?.targetLocationCode === originalLocationCode +} + +async function replayOperation(context, token, name, path, body, expectedType) { + const first = await request(context, token, 'POST', name, path, body) + const replay = await request(context, token, 'POST', `${name}同键重放`, path, body) + context.assert(MODULE, `${name}重放结果稳定`, replay.response, + replay.data?.replayed === true && replay.data?.operationId === first.data?.operationId + && replay.data?.operationType === expectedType, + trace(replay.response), OPTIONS) + return first +} + +async function verifyPalletOperations(context, token, fixture) { + const base = '/api/pda/v1/last-leg/pallet-operations' + let main = (await resolvePallet(context, token, fixture.pallet.palletCode, 'P4 resolve主托盘')).data + if (!main || !Array.isArray(main.inventory) || main.inventory.length === 0) { + blocker(context, 'P4托盘操作', '主托盘没有可移动库存行') + return false + } + if (main.inventory.reduce((total, line) => total + Number(line.availablePackages || 0), 0) <= 1) { + blocker(context, 'P4解绑后主托盘可继续验证', + '主托盘只有一箱可用库存,解绑会清空唯一业务托盘;必须由本轮生产矩阵提供至少两箱') + return false + } + const swapTarget = await createEmptyPallet(context, token, fixture, 'SWAP', main.destinationWarehouseCode) + const splitTarget = await createEmptyPallet(context, token, fixture, 'SPLIT', main.destinationWarehouseCode) + const unbindTarget = await createEmptyPallet(context, token, fixture, 'UNBIND', main.destinationWarehouseCode) + if (!swapTarget || !splitTarget || !unbindTarget) { + blocker(context, 'P4辅助实物托盘', '无法创建换托、拆托和解绑所需辅助托盘') + return false + } + + const swapBody = { idempotencyKey: key(context, 'PALLET-SWAP'), + source: { palletCode: main.palletCode, expectedVersion: Number(main.palletVersion) }, + target: { palletCode: swapTarget.palletCode, expectedVersion: Number(swapTarget.palletVersion) }, + reason: `${context.marker}-swap` } + const swapped = await replayOperation(context, token, 'P4整托换托', `${base}/swap`, swapBody, 'PALLET_SWAP') + await expectConflict(context, token, 'POST', 'P4换托同键异载荷A0409', `${base}/swap`, + { ...swapBody, reason: `${swapBody.reason}-DIFFERENT` }) + let occupiedSwap = operationPallet(swapped.data, swapTarget.palletCode) + || (await resolvePallet(context, token, swapTarget.palletCode, 'P4换托后解析目标托盘')).data + let emptyMain = operationPallet(swapped.data, main.palletCode) + || (await resolvePallet(context, token, main.palletCode, 'P4换托后解析原托盘')).data + await expectConflict(context, token, 'POST', 'P4换托旧托盘版本A0409', `${base}/swap`, { + ...swapBody, idempotencyKey: key(context, 'PALLET-SWAP-STALE'), + source: { ...swapBody.source }, target: { ...swapBody.target }, + }) + const swapBackBody = { idempotencyKey: key(context, 'PALLET-SWAP-BACK'), + source: { palletCode: occupiedSwap.palletCode, expectedVersion: Number(occupiedSwap.palletVersion) }, + target: { palletCode: emptyMain.palletCode, expectedVersion: Number(emptyMain.palletVersion) }, + reason: `${context.marker}-swap-back` } + await replayOperation(context, token, 'P4换托恢复主托盘', `${base}/swap`, swapBackBody, 'PALLET_SWAP') + + main = (await resolvePallet(context, token, main.palletCode, 'P4拆托前刷新主托盘')).data + const sourceLine = main.inventory.find((line) => Number(line.availablePackages) > 1) + || main.inventory.find((line) => Number(line.availablePackages) > 0) + const onePackage = 1 + const disassembleBody = { idempotencyKey: key(context, 'PALLET-DISASSEMBLE'), + source: { palletCode: main.palletCode, expectedVersion: Number(main.palletVersion) }, + targets: [{ target: { palletCode: splitTarget.palletCode, expectedVersion: Number(splitTarget.palletVersion) }, + lines: [{ positionId: id(sourceLine.positionId), packages: onePackage, + expectedPositionVersion: Number(sourceLine.positionVersion) }] }], + reason: `${context.marker}-disassemble` } + const disassembled = await replayOperation(context, token, 'P4拆托一箱到辅助托盘', + `${base}/disassemble`, disassembleBody, 'PALLET_DISASSEMBLE') + const splitOccupied = operationPallet(disassembled.data, splitTarget.palletCode) + || (await resolvePallet(context, token, splitTarget.palletCode, 'P4拆托后解析辅助托盘')).data + const mainAfterSplit = operationPallet(disassembled.data, main.palletCode) + || (await resolvePallet(context, token, main.palletCode, 'P4拆托后解析主托盘')).data + const splitLine = splitOccupied.inventory.find((line) => Number(line.availablePackages) > 0) + const assembleBody = { idempotencyKey: key(context, 'PALLET-ASSEMBLE'), + target: { palletCode: mainAfterSplit.palletCode, expectedVersion: Number(mainAfterSplit.palletVersion) }, + sources: [{ palletCode: splitOccupied.palletCode, expectedVersion: Number(splitOccupied.palletVersion) }], + lines: [{ positionId: id(splitLine.positionId), packages: Number(splitLine.availablePackages), + expectedPositionVersion: Number(splitLine.positionVersion) }], + reason: `${context.marker}-assemble` } + await replayOperation(context, token, 'P4合托恢复主托盘', `${base}/assemble`, assembleBody, 'PALLET_ASSEMBLE') + + main = (await resolvePallet(context, token, main.palletCode, 'P4解绑前刷新主托盘')).data + const unbindSource = main.inventory.find((line) => Number(line.availablePackages) > 1) + || main.inventory.find((line) => Number(line.availablePackages) > 0) + const unbindPreparedBody = { idempotencyKey: key(context, 'PALLET-UNBIND-PREPARE'), + source: { palletCode: main.palletCode, expectedVersion: Number(main.palletVersion) }, + targets: [{ target: { palletCode: unbindTarget.palletCode, expectedVersion: Number(unbindTarget.palletVersion) }, + lines: [{ positionId: id(unbindSource.positionId), packages: 1, + expectedPositionVersion: Number(unbindSource.positionVersion) }] }], + reason: `${context.marker}-unbind-prepare` } + const prepared = await replayOperation(context, token, 'P4拆出一箱作为解绑样本', + `${base}/disassemble`, unbindPreparedBody, 'PALLET_DISASSEMBLE') + const boundAuxiliary = operationPallet(prepared.data, unbindTarget.palletCode) + || (await resolvePallet(context, token, unbindTarget.palletCode, 'P4解析解绑样本托盘')).data + const unbindBody = { idempotencyKey: key(context, 'PALLET-UNBIND'), + source: { palletCode: boundAuxiliary.palletCode, expectedVersion: Number(boundAuxiliary.palletVersion) }, + reason: `${context.marker}-unbind` } + const unbound = await replayOperation(context, token, 'P4解绑辅助托盘库存', + `${base}/unbind`, unbindBody, 'PALLET_UNBIND') + context.assert(MODULE, 'P4主托盘保留可继续验证状态且辅助托盘解绑完成', unbound.response, + unbound.data?.status === 'COMPLETED' + && operationPallet(unbound.data, boundAuxiliary.palletCode)?.status === 'IDLE', + trace(unbound.response), OPTIONS) + return success(unbound.response) +} + +async function verifyRelabel(context, token, fixture) { + const customerId = id(fixture.order?.order?.customerId || context.createdResources?.phase4OverseasPda?.customerId) + const customerOrderNo = fixture.order?.order?.orderNo || fixture.order?.order?.sourceOrderNo + || `PDA-${context.marker}` + const inventory = fixture.inventory.find((item) => nonBlank(item?.skuCode)) + if (!customerId || !inventory?.skuCode || !fixture.overseasWarehouseId) { + blocker(context, 'P5本轮库存换标申请', '缺少本轮完成库存的客户、SKU或海外仓事实') + return false + } + const relabelBase = '/api/platform/v1/last-leg/relabel-applications' + const pdaBase = '/api/pda/v1/last-leg/relabel-tasks' + const newSku = `${inventory.skuCode}-NEW-${String(context.marker).slice(-8)}`.slice(0, 128) + const createBody = { + customerOrderNo: String(customerOrderNo).slice(0, 128), overseasWarehouseId: id(fixture.overseasWarehouseId), + labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19), + contactName: 'PDA换标验收', contactPhone: '13800000006', remark: context.marker, + idempotencyKey: key(context, 'RELABEL-CREATE'), + items: [{ skuCode: inventory.skuCode, productNameZh: inventory.productName || '本轮库存商品', + totalPackageCount: 3, notifiedPackageCount: 3, shipmentNo: `SHIP-${context.marker}`.slice(0, 128), + newSkuCode: newSku, labelRequirement: context.marker }], + } + const createdResponse = await context.requestOverseas('POST', + query(relabelBase, { customerId }), createBody) + const created = success(createdResponse) ? createdResponse.body?.data : null + const relabelNo = id(created?.application?.relabelNo) + context.addStep(MODULE, 'P5基于本轮完成库存创建真实换标申请', createdResponse, + success(createdResponse) && nonBlank(relabelNo) + && created?.application?.status === 'PENDING_PROCESSING', + `relabelNo=${relabelNo || 'missing'}, sku=${inventory.skuCode}; ${trace(createdResponse)}`, OPTIONS) + if (!relabelNo) { + blocker(context, 'P5真实换标申请', '平台正式换标创建接口未返回申请单号', createdResponse) + return false + } + const candidates = await request(context, token, 'GET', 'P5读取当前仓换标候选', `${pdaBase}/candidates`) + context.assert(MODULE, 'P5候选包含本轮真实申请', candidates.response, + Array.isArray(candidates.data) && candidates.data.some((item) => id(item?.relabelNo) === relabelNo), + `relabelNo=${relabelNo}; ${trace(candidates.response)}`, OPTIONS) + const available = await request(context, token, 'GET', 'P5读取换标任务详情', + `${pdaBase}/${encodeURIComponent(relabelNo)}`) + const claimBody = { idempotencyKey: key(context, 'RELABEL-CLAIM') } + const claimed = await request(context, token, 'POST', 'P5认领换标任务', + `${pdaBase}/${encodeURIComponent(relabelNo)}/claim`, claimBody) + const claimReplay = await request(context, token, 'POST', 'P5认领换标同键重放', + `${pdaBase}/${encodeURIComponent(relabelNo)}/claim`, claimBody) + context.assert(MODULE, 'P5认领重放保持PROCESSING', claimReplay.response, + claimReplay.data?.taskStatus === 'PROCESSING' && claimed.data?.taskStatus === 'PROCESSING', + trace(claimReplay.response), OPTIONS) + let detail = claimed.data || available.data + const item = detail?.items?.[0] + if (!id(item?.itemId)) { + blocker(context, 'P5换标明细', '认领后的换标任务没有真实明细', claimed.response) + return false + } + const unitPath = `${pdaBase}/${encodeURIComponent(relabelNo)}/units` + const scanBody = { itemId: id(item.itemId), oldLabel: `${context.marker}-OLD-1`, newLabel: newSku, + expectedVersion: Number(detail.version), idempotencyKey: key(context, 'RELABEL-NORMAL') } + const scanned = await request(context, token, 'POST', 'P5正常换标一箱', unitPath, scanBody) + const scanReplay = await request(context, token, 'POST', 'P5正常换标同键重放', unitPath, scanBody) + context.assert(MODULE, 'P5正常换标重放不重复计数', scanReplay.response, + Number(scanReplay.data?.relabelledPackages) === 1, trace(scanReplay.response), OPTIONS) + await expectConflict(context, token, 'POST', 'P5正常换标同键异载荷A0409', unitPath, + { ...scanBody, oldLabel: `${context.marker}-OLD-DIFFERENT` }) + await expectConflict(context, token, 'POST', 'P5正常换标旧版本A0409', unitPath, + { ...scanBody, oldLabel: `${context.marker}-OLD-STALE`, idempotencyKey: key(context, 'RELABEL-STALE') }) + detail = scanned.data + const skipBody = { itemId: id(item.itemId), oldLabel: `${context.marker}-OLD-2`, + reason: `${context.marker}-skip`, expectedVersion: Number(detail.version), + idempotencyKey: key(context, 'RELABEL-SKIP') } + const skipped = await request(context, token, 'POST', 'P5跳过换标一箱', `${unitPath}/skip`, skipBody) + const skipReplay = await request(context, token, 'POST', 'P5跳过换标同键重放', `${unitPath}/skip`, skipBody) + context.assert(MODULE, 'P5跳过换标重放不重复计数', skipReplay.response, + Number(skipReplay.data?.skippedPackages) === 1, trace(skipReplay.response), OPTIONS) + detail = skipped.data + const exceptionBody = { itemId: id(item.itemId), oldLabel: `${context.marker}-OLD-3`, + exceptionCode: 'DAMAGED', reason: `${context.marker}-damaged`, expectedVersion: Number(detail.version), + idempotencyKey: key(context, 'RELABEL-EXCEPTION') } + const exception = await request(context, token, 'POST', 'P5登记换标异常一箱', + `${pdaBase}/${encodeURIComponent(relabelNo)}/exceptions`, exceptionBody) + const exceptionReplay = await request(context, token, 'POST', 'P5换标异常同键重放', + `${pdaBase}/${encodeURIComponent(relabelNo)}/exceptions`, exceptionBody) + context.assert(MODULE, 'P5换标异常重放不重复计数', exceptionReplay.response, + Number(exceptionReplay.data?.exceptionPackages) === 1, trace(exceptionReplay.response), OPTIONS) + detail = exception.data + + const photo = await context.requestOverseasMultipart('POST', + `${pdaBase}/${encodeURIComponent(relabelNo)}/photos/files`, { + name: `${context.marker}-relabel.png`, type: 'image/png', content: PNG, + }, token) + const photoId = id(photo?.body?.data?.fileId) + context.addStep(MODULE, 'P5上传换标完成照片', photo, + success(photo) && /^\d+$/.test(photoId) && photoId !== '0', + `fileId=${photoId || 'missing'}; ${trace(photo)}`, OPTIONS) + if (!photoId) return false + const completePath = `${pdaBase}/${encodeURIComponent(relabelNo)}/complete` + const completeBody = { photoFileIds: [photoId], expectedVersion: Number(detail.version), + idempotencyKey: key(context, 'RELABEL-COMPLETE-1'), remark: `${context.marker}-complete-1` } + const completed = await request(context, token, 'POST', 'P5首次完成换标进入WAIT_REVIEW', completePath, completeBody) + const completeReplay = await request(context, token, 'POST', 'P5首次完成同键重放', completePath, completeBody) + context.assert(MODULE, 'P5首次完成重放保持WAIT_REVIEW', completeReplay.response, + completeReplay.data?.taskStatus === 'WAIT_REVIEW', trace(completeReplay.response), OPTIONS) + const reviewPath = `${pdaBase}/${encodeURIComponent(relabelNo)}/review` + const reworkBody = { decision: 'REWORK', expectedVersion: Number(completed.data?.version), + idempotencyKey: key(context, 'RELABEL-REWORK'), remark: `${context.marker}-rework` } + const reworked = await request(context, token, 'POST', 'P5复核退回REWORK', reviewPath, reworkBody) + const reworkReplay = await request(context, token, 'POST', 'P5 REWORK同键重放', reviewPath, reworkBody) + context.assert(MODULE, 'P5 REWORK重放保持PROCESSING', reworkReplay.response, + reworkReplay.data?.taskStatus === 'PROCESSING', trace(reworkReplay.response), OPTIONS) + const secondCompleteBody = { photoFileIds: [photoId], expectedVersion: Number(reworked.data?.version), + idempotencyKey: key(context, 'RELABEL-COMPLETE-2'), remark: `${context.marker}-complete-2` } + const secondCompleted = await request(context, token, 'POST', 'P5返工后再次完成WAIT_REVIEW', + completePath, secondCompleteBody) + const secondReplay = await request(context, token, 'POST', 'P5再次完成同键重放', completePath, secondCompleteBody) + context.assert(MODULE, 'P5再次完成重放保持WAIT_REVIEW', secondReplay.response, + secondReplay.data?.taskStatus === 'WAIT_REVIEW', trace(secondReplay.response), OPTIONS) + const approveBody = { decision: 'APPROVE', expectedVersion: Number(secondCompleted.data?.version), + idempotencyKey: key(context, 'RELABEL-APPROVE'), remark: `${context.marker}-approved` } + const approved = await request(context, token, 'POST', 'P5复核批准换标完成', reviewPath, approveBody) + const approveReplay = await request(context, token, 'POST', 'P5批准同键重放', reviewPath, approveBody) + context.assert(MODULE, 'P5最终任务和申请均COMPLETED', approveReplay.response, + approved.data?.taskStatus === 'COMPLETED' && approved.data?.applicationStatus === 'COMPLETED' + && approveReplay.data?.taskStatus === 'COMPLETED', trace(approveReplay.response), OPTIONS) + return success(approved.response) +} + +export async function run(context) { + const handoff = context.context?.phase4OverseasPdaOperations + const token = handoff?.primary?.token + try { + if (handoff?.marker !== context.marker || !nonBlank(token) + || !Array.isArray(handoff.completedFixtures) || handoff.completedFixtures.length === 0) { + blocker(context, 'P2-P5本轮PDA收货交接', + 'phase4-overseas-pda未交接本轮primary picker会话与已完成库存事实') + return + } + const fixture = handoff.completedFixtures.find((item) => Array.isArray(item?.inventory) + && item.inventory.some((line) => Number(line?.availablePackages) > 1)) + || handoff.completedFixtures.find((item) => Array.isArray(item?.inventory) + && item.inventory.some((line) => Number(line?.availablePackages) > 0)) + if (!fixture) { + blocker(context, 'P2-P5可操作库存', '本轮已完成柜没有可移动的真实库存位置') + return + } + fixture.overseasWarehouseId = id(handoff.overseasWarehouseId) + await verifyStocktake(context, token, fixture) + await verifyInventoryMove(context, token, fixture) + await verifyPalletOperations(context, token, fixture) + await verifyRelabel(context, token, fixture) + } finally { + try { + if (typeof handoff?.cleanup === 'function') await handoff.cleanup() + } finally { + if (handoff?.primary) delete handoff.primary.token + if (handoff?.secondary) delete handoff.secondary.token + delete context.context.phase4OverseasPdaOperations + } + } +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index b9edf6c8f..e7e7040c1 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -50,7 +50,15 @@ async function captureCompletedOrder(context, containerNo, expectedCustomerId) { if (mapped) { context.createdResources.phase4OverseasPda.orderId = id(order.id) context.createdResources.phase4OverseasPda.customerId = id(order.customerId) + const detailPath = `/api/platform/v1/last-leg/orders/${encodeURIComponent(id(order.id))}?customerId=${encodeURIComponent(id(order.customerId))}` + const detail = await context.requestOverseas('GET', detailPath) + context.assert(MODULE, '完成柜回查尾程订单货物明细供后续PDA作业', detail, + success(detail) && id(detail.body?.data?.order?.id) === id(order.id) + && Array.isArray(detail.body?.data?.cargo) && detail.body.data.cargo.length > 0, + `orderId=${id(order.id)}, cargo=${detail.body?.data?.cargo?.length ?? 'missing'}; ${trace(detail)}`, OPTIONS) + return success(detail) ? detail.body?.data : null } + return null } async function loginMerchantForNegativeProbe(context) { @@ -172,6 +180,9 @@ async function verifyWarehouseQueries(context, token, inboundCode, pallet) { && historyRows.some((item) => String(item?.operationType || '').startsWith('RECEIVING_')) && Number(history.data?.page) === 1 && Number(history.data?.size) === 50, `records=${historyRows.length}; ${trace(history.response)}`, OPTIONS) + return success(inventory.response) && success(location.response) && success(resolvedPallet.response) + ? { inboundCode, pallet: resolvedPallet.data, location: location.data, inventory: inventory.data?.items || [] } + : null } async function uploadPhotos(context, token) { @@ -432,10 +443,15 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS) context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') { - await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId) - const firstInbound = current.data.inbounds.find((item) => pallets.has(item.inboundCode)) - if (firstInbound) { - await verifyWarehouseQueries(context, token, firstInbound.inboundCode, pallets.get(firstInbound.inboundCode)) + const order = await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId) + for (const inbound of current.data.inbounds.filter((item) => pallets.has(item.inboundCode))) { + const warehouseFacts = await verifyWarehouseQueries(context, token, inbound.inboundCode, + pallets.get(inbound.inboundCode)) + if (warehouseFacts && context.context?.phase4OverseasPdaOperations) { + context.context.phase4OverseasPdaOperations.completedFixtures.push({ + containerNo, order, ...warehouseFacts, + }) + } } } } @@ -462,6 +478,19 @@ export async function run(context) { for (const picker of [primary, secondary]) if (picker?.membershipId) { context.createdResources.phase4OverseasPda.pickerMembershipIds.push(picker.membershipId) } + let handoffReady = false + const cleanup = async () => { + await disablePicker(context, secondary, 2) + await disablePicker(context, primary, 1) + } + context.context.phase4OverseasPdaOperations = { + marker: context.marker, + primary: primary ? { membershipId: primary.membershipId, username: primary.username, token: primary.token } : null, + secondary: secondary ? { membershipId: secondary.membershipId, username: secondary.username, token: secondary.token } : null, + overseasWarehouseId: sharedWarehouse.id, + completedFixtures: [], + cleanup, + } try { if (!primary?.token || !secondary?.token) { blockerGap(context, '海外PDA双操作员账号', '未能创建并登录两个WAREHOUSE_PICKER平台账号,无法验证全局一柜锁') @@ -486,10 +515,13 @@ export async function run(context) { context.createdResources.phase4OverseasPda.completedContainers.push(producer?.containerNo || null) } } + handoffReady = context.context.phase4OverseasPdaOperations.completedFixtures.length > 0 } finally { delete context.pdaContainerSuffix - await disablePicker(context, secondary, 2) - await disablePicker(context, primary, 1) + if (!handoffReady) { + try { await cleanup() } + finally { delete context.context.phase4OverseasPdaOperations } + } } } diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index 0a1cf2a60..05baae375 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -10,6 +10,7 @@ test('phase-four HTTP acceptance modules are registered in the Admin acceptance for (const module of [ 'phase4-overseas-warehouse', 'phase4-overseas-pda', + 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', @@ -27,7 +28,7 @@ test('overseas inbound commands, client services and dispatch follow completed P 'phase4-overseas-dispatch']) { const modules = parseArguments(['--modules', consumer]).modules for (const prerequisite of ['merchant-qualification-fixture', 'phase3-loading', - 'phase3-loading-finalize', 'phase4-overseas-pda']) { + 'phase3-loading-finalize', 'phase4-overseas-pda', 'phase4-overseas-pda-operations']) { assert.ok(modules.indexOf(prerequisite) >= 0 && modules.indexOf(prerequisite) < modules.indexOf(consumer)) } @@ -82,4 +83,5 @@ test('PDA entry expands the real phase-three producer chain and builds an FBA/OV assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS'\)/) assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA'\)/) assert.match(source, /readyForPhase4Pda === true/) + assert.equal(modules.indexOf('phase4-overseas-pda-operations'), modules.indexOf('phase4-overseas-pda') + 1) }) From 6ba4537d3db1493122cac631b7cb6c96df9889d2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 11:39:12 +0800 Subject: [PATCH 089/115] fix(deploy): bind development ports to loopback --- deploy/docker-compose.overseas.yml | 8 +++---- deploy/docker-compose.yml | 22 +++++++++---------- deploy/scripts/deploy-built-service.sh | 12 ++++++++++ deploy/scripts/deploy-development-service.sh | 2 ++ ...est-environment-pipeline-contract.test.mjs | 10 +++++++++ 5 files changed, 39 insertions(+), 15 deletions(-) diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index 29f78af91..5eb84191d 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -37,7 +37,7 @@ services: <<: *overseas-common image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas - ports: ["${GATEWAY_OVERSEAS_PORT:-9300}:9300"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_OVERSEAS_PORT:-9300}:9300"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway-overseas:/app/logs"] environment: <<: *overseas-environment @@ -52,7 +52,7 @@ services: <<: *overseas-common image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-lastleg - ports: ["${LASTLEG_PORT:-9303}:9303"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PORT:-9303}:9303"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/lastleg-service:/app/logs"] environment: <<: *overseas-environment @@ -70,7 +70,7 @@ services: <<: *overseas-common image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas - ports: ["${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse-overseas-service:/app/logs"] environment: <<: *overseas-environment @@ -83,7 +83,7 @@ services: <<: *overseas-common image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas - ports: ["${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service-overseas:/app/logs"] environment: <<: *overseas-environment diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index e917070cf..4f6700005 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -39,7 +39,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-gateway - ports: ["${GATEWAY_PORT:-9000}:9000"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_PORT:-9000}:9000"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway:/app/logs"] environment: <<: *common-environment @@ -52,7 +52,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-auth - ports: ["${AUTH_PORT:-9100}:9100"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${AUTH_PORT:-9100}:9100"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/auth:/app/logs"] environment: <<: *common-environment @@ -71,7 +71,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-iam - ports: ["${IAM_PORT:-9201}:9201"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${IAM_PORT:-9201}:9201"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/iam:/app/logs"] environment: <<: *common-environment @@ -84,7 +84,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-business - ports: ["${FIRSTLEG_PORT:-9202}:9202"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FIRSTLEG_PORT:-9202}:9202"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/firstleg:/app/logs"] environment: <<: *common-environment @@ -110,7 +110,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-finance - ports: ["${FINANCE_PORT:-9203}:9203"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FINANCE_PORT:-9203}:9203"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/finance:/app/logs"] environment: <<: *common-environment @@ -123,7 +123,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-order - ports: ["${ORDER_PORT:-9204}:9204"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${ORDER_PORT:-9204}:9204"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/order:/app/logs"] environment: <<: *common-environment @@ -136,7 +136,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-warehouse - ports: ["${WAREHOUSE_PORT:-9205}:9205"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_PORT:-9205}:9205"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse:/app/logs"] environment: <<: *common-environment @@ -149,7 +149,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/file:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-file - ports: ["${FILE_PORT:-9206}:9206"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FILE_PORT:-9206}:9206"] volumes: - "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files" - "${LOG_ROOT:-/data/logs/baiyue-firstleg}/file:/app/logs" @@ -173,7 +173,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-print - ports: ["${PRINT_PORT:-9207}:9207"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${PRINT_PORT:-9207}:9207"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/print:/app/logs"] environment: <<: *common-environment @@ -184,7 +184,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-integration - ports: ["${INTEGRATION_PORT:-9210}:9210"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${INTEGRATION_PORT:-9210}:9210"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/integration-service:/app/logs"] environment: <<: *common-environment @@ -234,7 +234,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge - ports: ["${EVENT_BRIDGE_PORT:-9312}:9312"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_PORT:-9312}:9312"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service:/app/logs"] environment: <<: *common-environment diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index 790431300..860474778 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -36,6 +36,13 @@ case "$DEPLOY_ENV" in ;; esac +if [ "$DEPLOY_ENV" = "development" ]; then + PUBLISHED_BIND_ADDRESS=127.0.0.1 +else + PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}" +fi +export PUBLISHED_BIND_ADDRESS + case "$SERVICE" in *[!A-Za-z0-9_-]*|'') echo "非法服务名: $SERVICE" >&2 @@ -390,6 +397,11 @@ cd "$remote_dir" export DEPLOY_ENV="$deploy_env" export IMAGE_REPOSITORY="$image_repository" export IMAGE_TAG="$image_tag" +if [ "$deploy_env" = "development" ]; then + export PUBLISHED_BIND_ADDRESS=127.0.0.1 +else + export PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}" +fi compose() { docker compose \ diff --git a/deploy/scripts/deploy-development-service.sh b/deploy/scripts/deploy-development-service.sh index b1034e2b9..23343f7a4 100644 --- a/deploy/scripts/deploy-development-service.sh +++ b/deploy/scripts/deploy-development-service.sh @@ -64,6 +64,7 @@ if [ "${DEVELOPMENT_DEPLOY_LOCAL:-false}" = "true" ]; then cd "$REMOTE_DIR" export DEPLOY_ENV=development export IMAGE_REPOSITORY IMAGE_TAG + export PUBLISHED_BIND_ADDRESS=127.0.0.1 docker compose \ --project-name "$PROJECT_NAME" \ @@ -143,6 +144,7 @@ cd "$remote_dir" export DEPLOY_ENV=development export IMAGE_REPOSITORY="$image_repository" export IMAGE_TAG="$image_tag" +export PUBLISHED_BIND_ADDRESS=127.0.0.1 docker compose \ --project-name "$project_name" \ diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index cb7a9566c..4a69ce5fe 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -12,6 +12,10 @@ const ciMavenSettings = (await readFile(new URL('../deploy/maven/settings-ci.xml .replaceAll('\r\n', '\n') const serviceDockerfile = (await readFile(new URL('../deploy/docker/Dockerfile', import.meta.url), 'utf8')) .replaceAll('\r\n', '\n') +const serviceCompose = (await readFile(new URL('../deploy/docker-compose.yml', import.meta.url), 'utf8')) + .replaceAll('\r\n', '\n') +const overseasCompose = (await readFile(new URL('../deploy/docker-compose.overseas.yml', import.meta.url), 'utf8')) + .replaceAll('\r\n', '\n') test('service pipeline keeps boolean operators on parseable Groovy continuation lines', () => { assert.doesNotMatch(pipeline, /^\s*(?:&&|\|\|)/m) @@ -77,6 +81,12 @@ test('development and test deployment locks are acquired before target executors assert.ok(lockIndex < nodeIndex, 'deployment lock must be acquired before target node allocation') }) +test('development publishes service ports on loopback without changing test defaults', () => { + assert.match(serviceCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_PORT:-9000\}:9000/) + assert.match(overseasCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_OVERSEAS_PORT:-9300\}:9300/) + assert.match(directDeployScript, /if \[ "\$DEPLOY_ENV" = "development" \]; then[\s\S]*?PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) +}) + test('parallel image publications isolate registry credentials per Jenkins job', () => { const imageStage = pipeline.slice( pipeline.indexOf("stage('构建并发布单服务镜像')"), From d066381ae2220bd14e6698b6394da965850c4857 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 11:41:25 +0800 Subject: [PATCH 090/115] fix: expose failed deployment container logs --- deploy/scripts/deploy-built-service.sh | 14 ++++++++++++++ scripts/deploy-built-service-rollback.test.mjs | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index 860474778..f47f8f2c9 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -273,6 +273,13 @@ deploy_local() { fi echo "新镜像健康检查失败: $IMAGE" >&2 + failed_container_id="$(local_container_id || true)" + if [ -n "$failed_container_id" ]; then + echo "新镜像容器最近日志(最多 200 行):" >&2 + docker logs --tail 200 "$failed_container_id" 2>&1 || true + else + echo "新镜像容器不存在,无法读取启动日志" >&2 + fi expected_prefix="$IMAGE_REPOSITORY/$SERVICE:" case "$previous_image" in "$expected_prefix"*) @@ -476,6 +483,13 @@ if [ "$new_started" = "true" ] && wait_for_health; then fi echo "新镜像健康检查失败: $new_image" >&2 +failed_container_id="$(remote_container_id || true)" +if [ -n "$failed_container_id" ]; then + echo "新镜像容器最近日志(最多 200 行):" >&2 + docker logs --tail 200 "$failed_container_id" 2>&1 || true +else + echo "新镜像容器不存在,无法读取启动日志" >&2 +fi expected_prefix="$image_repository/$service:" case "$previous_image" in "$expected_prefix"*) previous_tag="${previous_image#"$expected_prefix"}" ;; diff --git a/scripts/deploy-built-service-rollback.test.mjs b/scripts/deploy-built-service-rollback.test.mjs index e8103388e..84906b7b8 100644 --- a/scripts/deploy-built-service-rollback.test.mjs +++ b/scripts/deploy-built-service-rollback.test.mjs @@ -54,6 +54,9 @@ case "$command" in *) exit 2 ;; esac ;; + logs) + echo 'mock startup failure' + ;; *) echo "unexpected docker command: $command $*" >&2 exit 2 @@ -129,6 +132,9 @@ test('unhealthy deployment restores the previous healthy image and still fails t assert.equal(result.status, 1, output) assert.match(output, /新镜像健康检查失败: registry\.local\/group\/firstleg\/audit-service:newbad/) + assert.match(output, /新镜像容器最近日志(最多 200 行):/) + assert.match(output, /mock startup failure/) + assert.match(result.dockerLog, /logs --tail 200 mock-container/) assert.match(output, /开始自动回滚到: registry\.local\/group\/firstleg\/audit-service:oldgood/) assert.match(output, /自动回滚成功: registry\.local\/group\/firstleg\/audit-service:oldgood/) assert.match(result.dockerLog, /pull registry\.local\/group\/firstleg\/audit-service:oldgood/) From d64706e97bcfd573358e6753d038d93c8c3fe28b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 11:44:38 +0800 Subject: [PATCH 091/115] fix: keep development gateways reachable --- deploy/scripts/deploy-built-service.sh | 10 ++++++++-- scripts/test-environment-pipeline-contract.test.mjs | 7 +++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index f47f8f2c9..dd0aef38d 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -37,7 +37,10 @@ case "$DEPLOY_ENV" in esac if [ "$DEPLOY_ENV" = "development" ]; then - PUBLISHED_BIND_ADDRESS=127.0.0.1 + case "$SERVICE" in + gateway|gateway-overseas) PUBLISHED_BIND_ADDRESS=0.0.0.0 ;; + *) PUBLISHED_BIND_ADDRESS=127.0.0.1 ;; + esac else PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}" fi @@ -405,7 +408,10 @@ export DEPLOY_ENV="$deploy_env" export IMAGE_REPOSITORY="$image_repository" export IMAGE_TAG="$image_tag" if [ "$deploy_env" = "development" ]; then - export PUBLISHED_BIND_ADDRESS=127.0.0.1 + case "$service" in + gateway|gateway-overseas) export PUBLISHED_BIND_ADDRESS=0.0.0.0 ;; + *) export PUBLISHED_BIND_ADDRESS=127.0.0.1 ;; + esac else export PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}" fi diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index 4a69ce5fe..cb9d380ce 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -81,10 +81,13 @@ test('development and test deployment locks are acquired before target executors assert.ok(lockIndex < nodeIndex, 'deployment lock must be acquired before target node allocation') }) -test('development publishes service ports on loopback without changing test defaults', () => { +test('development exposes gateways for remote acceptance and keeps internal services on loopback', () => { assert.match(serviceCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_PORT:-9000\}:9000/) assert.match(overseasCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_OVERSEAS_PORT:-9300\}:9300/) - assert.match(directDeployScript, /if \[ "\$DEPLOY_ENV" = "development" \]; then[\s\S]*?PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) + assert.match(directDeployScript, /gateway\|gateway-overseas\) PUBLISHED_BIND_ADDRESS=0\.0\.0\.0/) + assert.match(directDeployScript, /\*\) PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) + assert.match(directDeployScript, /gateway\|gateway-overseas\) export PUBLISHED_BIND_ADDRESS=0\.0\.0\.0/) + assert.match(directDeployScript, /\*\) export PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) }) test('parallel image publications isolate registry credentials per Jenkins job', () => { From 4fbf8bfb53c675177b693320f6dca1667d337e1b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 11:52:25 +0800 Subject: [PATCH 092/115] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitlab-ci.yml | 15 +- AGENTS.md | 5 +- README.md | 14 +- common-event-contract/AGENTS.md | 3 +- docs/同步调用依赖与失败传播治理基线.md | 105 +++++++ docs/头程项目目录结构与服务说明.md | 17 +- firstleg/AGENTS.md | 1 + ...rstleg-internal-modularization-baseline.md | 189 ++++++++++++ .../scripts/analyze-firstleg-dependencies.mjs | 284 ++++++++++++++++++ .../analyze-firstleg-dependencies.test.mjs | 39 +++ .../firstleg-slice-dependencies.json | 48 +++ lastleg-service/AGENTS.md | 1 + pom.xml | 23 ++ scripts/agents-index-contract.ps1 | 39 ++- scripts/java-topology-contract.test.mjs | 146 +++++++++ .../synchronous-dependency-contract.test.mjs | 188 ++++++++++++ 16 files changed, 1101 insertions(+), 16 deletions(-) create mode 100644 docs/同步调用依赖与失败传播治理基线.md create mode 100644 firstleg/docs/firstleg-internal-modularization-baseline.md create mode 100644 firstleg/scripts/analyze-firstleg-dependencies.mjs create mode 100644 firstleg/scripts/analyze-firstleg-dependencies.test.mjs create mode 100644 firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json create mode 100644 scripts/java-topology-contract.test.mjs create mode 100644 scripts/synchronous-dependency-contract.test.mjs diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4dd6604f7..4c67d8bc7 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -13,6 +13,13 @@ cache: paths: - .m2/repository/ +Java拓扑合同: + stage: verify + image: node:22-alpine + cache: [] + script: + - node --test scripts/java-topology-contract.test.mjs + 校验与测试: stage: verify script: @@ -25,10 +32,13 @@ cache: - "**/target/surefire-reports/TEST-*.xml" paths: - "**/target/surefire-reports/" + - "**/target/site/jacoco/" + - "**/target/jacoco.exec" 打包服务: stage: package needs: + - Java拓扑合同 - 校验与测试 script: - mvn $MAVEN_CLI_OPTS -DskipTests package @@ -36,14 +46,17 @@ cache: expire_in: 7 days paths: - gateway/target/*.jar + - gateway-overseas/target/*.jar - auth/target/*.jar - iam/target/*.jar - order/target/*.jar - firstleg/target/*.jar + - finance/target/*.jar - warehouse/target/*.jar + - warehouse-overseas-service/target/*.jar + - lastleg-service/target/*.jar - file/target/*.jar - print/target/*.jar - audit-service/target/*.jar - integration-service/target/*.jar - event-bridge-service/target/*.jar - diff --git a/AGENTS.md b/AGENTS.md index 2d5d600f2..2b612f218 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,10 +37,11 @@ | 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | | 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | | 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | -| 海外入仓分页、FBA 预约、PDA 卸柜照片 | `lastleg-service/` | `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`、`inboundplan/pda/` | +| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | | 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | | 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | -| 海外仓 PDA 到仓、卸柜、实收、上架、合托 | `lastleg-service/`、`warehouse-overseas-service/` | 业务流程 `lastleg-service/inboundplan/pda/`;库位、托盘和资源锁归 `warehouse-overseas-service/`;设备端 `../apps/pda-overseas-uniapp/`,公网入口 `gateway-overseas/` | +| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理库位/托盘调用 `warehouse-overseas-service/`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 | +| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 | | 海外仓库、海外库位、海外托盘、资源锁 | `warehouse-overseas-service/` | `warehouse-overseas-service/AGENTS.md` | | PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` | diff --git a/README.md b/README.md index 884c9de44..d8202d879 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 柏悦头程平台 -本仓库承载国内头程业务,负责统一登录、客户与企业成员、角色、菜单、按钮权限、订单、国内仓储、头程履约以及国内公共能力。尾程业务不进入本仓库,通过版本化跨区域事件与尾程仓库协作。 +本仓库承载国内头程与海外仓后端,包含统一认证、国内订单与履约、国内外仓储资源、海外尾程与入仓业务,以及跨区域事件桥和公共能力。国内、海外仍保持独立数据库、Nacos 命名空间与部署边界,通过版本化跨区域事件协作。 > 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。 @@ -12,20 +12,24 @@ - `firstleg`:头程揽收、入仓、装载、发运和交接。 - `warehouse`:国内仓储能力。 - `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。 -- `event-bridge-service`:跨区域事件接收幂等和投递记录基础骨架;真实跨区发送、重试调度和死信补投仍需完成。 +- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`:海外公网入口、海外尾程与入仓业务、海外仓储资源。 +- `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。 - `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。 -- `platform-bom`:发布 `com.baiyue.shared:baiyue-platform-bom:1.0.0-SNAPSHOT`,尾程仓只通过固定版本引用。 +- `platform-bom`:统一管理仓库内公共依赖版本。 ## 本地构建 ```powershell mvn -DskipTests validate +node --test scripts/java-topology-contract.test.mjs mvn test ``` +GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。 + 2026-07-30 一期前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。 -本地一期完整联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。 +国内本地联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。海外服务通过独立 Compose 与 Nacos 配置启动,不与国内服务共享数据库或配置命名空间。 国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。 @@ -41,4 +45,4 @@ CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com ## 仓库边界 -仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。本仓不得引入尾程源码,不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。 +仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。国内与海外服务不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。 diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index c35c4997c..bf9e87321 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -17,9 +17,10 @@ | 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` | | 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 | | 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` | +| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 | | 契约说明 | `package-info.java` | -| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java` | +| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` | | 真实生产/消费验收 | `../firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`、`../lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java` | ## 修改与验证 diff --git a/docs/同步调用依赖与失败传播治理基线.md b/docs/同步调用依赖与失败传播治理基线.md new file mode 100644 index 000000000..5755b0d21 --- /dev/null +++ b/docs/同步调用依赖与失败传播治理基线.md @@ -0,0 +1,105 @@ +# 同步调用依赖与失败传播治理基线 + +## 目的与口径 + +本基线覆盖后端服务之间的 OpenFeign、`RestClient`、`WebClient` 与 JDK `HttpClient` 调用。它用于识别同步依赖方向、在线请求深度、后台中继、超时、重试、熔断、幂等、错误映射和失败传播,不改变 Finance 或四期业务状态机。 + +“存在双向服务边”不等于“一次在线请求会递归调用”。每条边必须继续区分: + +- `REQUEST`:用户或设备请求线程内,失败直接影响当前业务结果; +- `BACKGROUND`:Outbox、Inbox、定时器或导出 Worker 中的同步传输,业务事务已与传输解耦; +- `INFRASTRUCTURE`:审计、网关会话校验等基础设施调用; +- `EXTERNAL`:Integration 对第三方供应商的调用。 + +离线清单合同为 `scripts/synchronous-dependency-contract.test.mjs`。新增客户端、目标服务、原始 HTTP 实现、强连通分量或超时配置时,必须先更新本基线并完成调用方与被调用方评审,不能仅修改测试期望以放行。 + +## 当前服务边 + +### OpenFeign + +| 调用方 | 被调用方 | 客户端数 | 主要上下文 | +|---|---|---:|---| +| Auth | IAM | 2 | 登录授权、注册,`REQUEST` | +| IAM | Auth | 1 | 账号开通/管理,`REQUEST` | +| IAM | Firstleg | 1 | 客户尺寸规则,`REQUEST` | +| IAM | Warehouse | 1 | 海外仓储费协作,`REQUEST` | +| IAM | Event Bridge | 1 | 安全事件中继,`BACKGROUND` | +| Order | Firstleg | 3 | 计价/统一订单查询为 `REQUEST`,入库协作为 Outbox `BACKGROUND` | +| Order | IAM / File / Warehouse | 各 1 | 资格、图片、仓库守卫,`REQUEST` | +| Firstleg | Order | 2 | 货物查询与分拆回写,`REQUEST` | +| Firstleg | IAM / Finance / Integration / Event Bridge / Print | 各 1 | IAM、Integration、Print 为 `REQUEST`;Finance 与 Event Bridge 为 `BACKGROUND` 中继 | +| Firstleg | File | 7 | 测量、装柜、凭证、报关、拖车等,`REQUEST` | +| Firstleg | Warehouse | 4 | 主数据、供应商、库位/托盘,`REQUEST` | +| Finance | Firstleg | 2 | 费用来源/成本快照,事件消费与平台操作混合 | +| Finance | File / IAM / Warehouse | 各 1 | 凭证、客户、供应商校验,`REQUEST` | +| File | IAM | 2 | 资质登记为 `REQUEST`;导出 Provider 为 `BACKGROUND` | +| File | Order / Firstleg / Finance / Warehouse | 各 1 | 导出 Provider,`BACKGROUND` | +| File | Integration | 1 | OCR,`BACKGROUND` 或上传处理线程 | +| Lastleg | Event Bridge | 1 | 跨区事件 Outbox 中继,`BACKGROUND` | +| Lastleg | 国内 Warehouse | 1 | 合作仓报价快照,`REQUEST` | + +合计 44 个 Feign 客户端。公共默认连接/读取超时为 3 秒/5 秒;File 的五个导出 Provider 客户端读取超时单独为 15 秒。 + +### 原始 HTTP + +| 调用方 | 被调用方 | 实现 | 上下文与当前策略 | +|---|---|---|---| +| Gateway Overseas | Auth | `HttpPdaSessionValidationClient` | `INFRASTRUCTURE`;总超时 2 秒,异常由网关失败关闭 | +| Lastleg | Warehouse Overseas | 两个 `RestClient` Gateway | `REQUEST`;当前未看到客户端级连接/读取超时,是高优先级缺口 | +| Lastleg | File | 两个照片 `RestClient` Gateway | `REQUEST`;校验响应 code 且失败关闭,但当前未看到客户端级连接/读取超时 | +| Event Bridge | Firstleg / Lastleg | 两个 JDK `HttpClient` | `BACKGROUND`;连接 3 秒、请求 10 秒,持久化租约和退避由 Event Bridge 专项治理 | +| 启用 Common Audit 的服务 | Audit | `HttpAuditSink` | `INFRASTRUCTURE`;当前捕获异常后仅告警,可能让调用方误以为审计已成功,是高优先级失败传播缺口 | +| Integration | 外部 OCR/船司 | 六个 JDK `HttpClient` | `EXTERNAL`;OCR 默认 10/60 秒,船司默认 10/30 秒,业务重试必须服从供应商和调度契约 | + +## 依赖环基线 + +按服务边计算,当前唯一非平凡强连通分量包含 8 个服务: + +`Auth ↔ IAM ↔ Order ↔ Firstleg ↔ Finance ↔ File ↔ Event Bridge ↔ Lastleg` + +其中优先关注的直接双向边为: + +1. `Auth ↔ IAM`:账号写模型与授权读模型相互同步依赖; +2. `Order ↔ Firstleg`:订单交易与履约同时存在查询、命令和 Outbox 投递; +3. `IAM ↔ Firstleg`:客户规则与客户快照双向查询; +4. `Firstleg ↔ Finance`:费用 Outbox 投递后 Finance 同步回读不可变快照; +5. `Firstleg ↔ File`:业务文件请求边与 File 导出 Worker 回调边叠加; +6. `File ↔ Finance`:凭证请求边与导出 Worker 回调边叠加。 + +强连通分量说明静态图无法给出有限的“最大服务跳数”。在线深度治理必须以入口用例为单位:默认不新增超过 2 个下游服务跳数的在线链;后台 Outbox/导出链单独计算,不得计入在线调用预算,也不得在下游处理期间同步回调原始调用方形成嵌套闭环。 + +## 退出顺序 + +1. **冻结与分层。** 先用清单合同阻止未评审新边;为每个客户端补齐 `REQUEST/BACKGROUND/INFRASTRUCTURE/EXTERNAL`、读写语义、幂等键和失败映射。这个阶段不改变业务状态。 +2. **统一失败传播与时间预算。** 为 Feign 和原始 HTTP 明确连接/读取/总超时;默认禁用自动重试,只有具备幂等键且由业务明确拥有退避状态的后台操作可以重试;禁止空数据、默认成功或仅日志告警替代远端失败。 +3. **先退出伪在线环。** File 导出 Provider、Firstleg/Lastleg Event Bridge、Firstleg→Finance 费用投递继续作为后台链路,但必须从在线深度图剥离,并禁止 Worker 消费时无界同步回调。 +4. **退出 `Order ↔ Firstleg` 在线双向命令。** 保留一个方向的权威查询,将反向状态推进改为带版本、幂等和审计的 Outbox/Inbox;不复制 Order 交易状态或 Firstleg 履约状态。 +5. **退出 `Auth ↔ IAM` 在线双向写。** Auth 保持账号与 Token 唯一所有者,IAM 保持客户/RBAC 所有者;注册和账号开通使用可恢复编排,登录授权快照失败关闭,不引入第二套账号事实。 +6. **收敛 `IAM ↔ Firstleg`。** 客户维度规则和客户快照确定单一事实方向;允许短期只读查询,禁止两个服务互相修正对方数据。 +7. **在财务总控确认后退出 `Firstleg ↔ Finance` 回调。** 优先让版本化事件携带 Finance 校验所需的不可变快照或使用单向快照读取,保持 Finance 状态机和 Firstleg 费用事实边界不变。 +8. **收敛 File 相关环。** File 只拥有文件对象与导出任务;业务服务拥有导出数据和业务归属。凭证/照片校验失败关闭,导出 Worker 使用受限批次、超时和显式失败状态。 + +## 错误、重试与熔断基线 + +- 远端 HTTP 非 2xx、响应体 `code != "200"`、空响应和 DTO 不完整是四类不同失败,均不得变成成功或空列表。 +- 在线查询/命令默认不自动重试。若启用重试,必须同时证明方法幂等、请求具有稳定幂等键、最大尝试次数和总时间预算可控。 +- Outbox/Inbox 重试由持久化状态拥有,Feign/JDK 客户端不得再叠加隐式重试造成次数乘法。 +- 熔断打开时返回统一“依赖不可用”错误并保留 request/trace 关联信息,不配置业务成功型 fallback。 +- 404 只有在契约明确“资源不存在”时可映射为领域未找到;连接失败、超时、5xx、空响应必须映射为依赖不可用。 +- Common Audit 当前“只记录告警后返回”列为已知例外,不能据此推广静默降级;其可靠性与业务阻断口径需和审计/总控共同冻结。 + +## 直接受影响验证 + +| 变更范围 | 最低验证 | +|---|---| +| 图、客户端或超时配置 | `node --test scripts/synchronous-dependency-contract.test.mjs` | +| 单个 Feign/HTTP 签名或错误映射 | `mvn -pl <调用方>,<被调用方> -am test-compile`,并运行双方直接相关测试 | +| Auth/IAM | `mvn -pl auth,iam -am test` | +| Order/Firstleg | `mvn -pl order,firstleg -am test` | +| IAM/Firstleg | `mvn -pl iam,firstleg -am test` | +| Firstleg/Finance | `mvn -pl firstleg,finance -am test`;业务口径先由财务总控确认 | +| Firstleg/File 或 File/Finance | `mvn -pl firstleg,file,finance -am test`,按实际消费者缩小模块集 | +| Lastleg 原始 HTTP | `mvn -pl lastleg-service,warehouse-overseas-service,file -am test`;不改变四期状态机 | +| 全部治理阶段收口 | `mvn -DskipTests validate` 后执行 `mvn test` | + +以上均为本地离线验证;未经明确授权不启动 Java/Nacos/数据库,不执行 SQL、部署或测试环境 HTTP 验收。 diff --git a/docs/头程项目目录结构与服务说明.md b/docs/头程项目目录结构与服务说明.md index 9c15e66f1..0fad3c9d3 100644 --- a/docs/头程项目目录结构与服务说明.md +++ b/docs/头程项目目录结构与服务说明.md @@ -10,7 +10,7 @@ ## 1. 项目定位 -`baiyue-firstleg` 是国内头程业务后端仓库,采用 Maven 多模块单仓结构。仓库内包含 7 个公共模块和 11 个可独立启动、构建、制作镜像的微服务。 +`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 22 个 Maven 模块,其中 7 个是公共/依赖模块,15 个是可独立启动、构建、制作镜像的微服务。 当前技术基线: @@ -24,7 +24,7 @@ - SpringDoc OpenAPI / Swagger - GitLab、Jenkins、Docker Compose -本仓库只保存头程代码。尾程业务位于独立 Git 仓库,不允许把尾程源码、数据库或配置重新放回本仓库。 +国内与海外服务共享源码仓库和公共制品,但保持独立数据库、Nacos 命名空间与部署边界;跨区域业务协作使用版本化 Outbox/Inbox 事件,不共享业务数据源。 ## 2. 根目录结构 @@ -39,11 +39,15 @@ baiyue-firstleg/ ├─ common-event-contract/ # 国内外跨区域事件统一契约 │ ├─ gateway/ # 统一 API 网关 +├─ gateway-overseas/ # 海外区域 API 网关 ├─ auth/ # 统一登录与账号凭据 ├─ iam/ # 租户、成员、角色、权限、企业审核 ├─ order/ # 头程订单中心 ├─ firstleg/ # 履约任务、二期入库、测量与计费 ├─ warehouse/ # 仓库主数据、托盘与库位资源 +├─ finance/ # 财务账期、账单、收付款与核销 +├─ lastleg-service/ # 海外尾程、入仓、库存与本土业务 +├─ warehouse-overseas-service/ # 海外仓库、库位、托盘与资源锁 ├─ file/ # 资质、导出与测量图片文件中心 ├─ print/ # 打印中心 ├─ audit-service/ # 审计日志中心 @@ -170,7 +174,7 @@ config/nacos/ └─ cn/ # 国内服务器配置模板 ``` -每套目录包含公共配置 `baiyue-common.yml` 和 11 个服务配置。配置模板可以提交,真实密码和第三方密钥不能提交。 +`local/`、`cn/`、`overseas/` 按部署区域保存公共配置和该区域需要的服务配置。配置模板可以提交,真实密码和第三方密钥不能提交。 ### `deploy/` @@ -179,7 +183,8 @@ deploy/ ├─ docker/Dockerfile # 通用 Java 17 镜像 ├─ env/.env.test.example # 环境变量示例 ├─ scripts/deploy-services.sh # Jenkins 增量部署脚本 -├─ docker-compose.yml # 11 个业务服务编排 +├─ docker-compose.yml # 国内区域服务编排 +├─ docker-compose.overseas.yml # 海外区域服务编排 ├─ docker-compose.rocketmq.yml # 公共导出 RocketMQ 基础设施 ├─ sql/ # 测试/存量环境统一迁移入口与说明 └─ service-map.yml # 模块、JAR、端口映射 @@ -276,6 +281,6 @@ HTTP 401 / `A0401`,不得再按 HTTP 200 + 空权限处理;只有使用新 - 平台端和商户端权限分别管理,接口与按钮权限都必须由后端校验。 - Controller 负责协议转换,Service 负责业务编排,Repository/Mapper 负责持久化。 - 服务之间只传 DTO 或事件契约,不能依赖其他服务的 Entity、Mapper。 -- 公共模块变化会影响多个服务,Jenkins 会自动重建全部 11 个服务。 +- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 15 个服务执行受影响构建。 - 单一 Git 仓库不等于单一部署单元;每个服务仍生成独立 JAR、镜像和容器。 -- 头程与尾程保持两个 Git 仓库、两套数据库和两套运行环境。 +- 国内头程与海外仓共享 Git 仓库,但保持两套数据库、Nacos 命名空间和部署环境。 diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 72b9200eb..a9afeb4a0 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -32,6 +32,7 @@ | 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 三期海运订舱与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | | 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` | +| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | 拖车与出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java` | | 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` | diff --git a/firstleg/docs/firstleg-internal-modularization-baseline.md b/firstleg/docs/firstleg-internal-modularization-baseline.md new file mode 100644 index 000000000..3a37feacd --- /dev/null +++ b/firstleg/docs/firstleg-internal-modularization-baseline.md @@ -0,0 +1,189 @@ +# Firstleg 内部模块化基线与首切片方案 + +## 1. 范围与生成方式 + +本基线只分析 `firstleg` 同一部署单元,不拆微服务,不改变业务流程、数据库结构、OpenAPI 或跨服务契约。分析输入为: + +- `src/main/java`:429 个 Java 文件;948 条可解析的模块内类依赖;66 个物理包。 +- `src/test/java`:169 个 Java 文件。与主源码合并分析时共 598 个 Java 文件、1,635 条模块内类依赖、69 个物理包。 + +可重复命令: + +```powershell +node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set main --format markdown +node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set all --format json +node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set main --format dot +``` + +分析器解析显式内部 `import`、静态 `import` 及同包类型引用,输出物理包图、逻辑切片图、SCC、跨切片类依赖、Entity/DTO 泄漏和 Mapper 泄漏。Spring 字符串扫描路径、反射、MyBatis SQL 中的表引用不属于 Java 类型图,因此在本报告单独标注。 + +## 2. 当前 SCC + +### 2.1 主源码物理包 SCC + +当前只有一个多节点物理包 SCC: + +```text +com.baiyue.firstleg.mapper + <-> com.baiyue.firstleg.repository +``` + +直接证据是全局 Repository 大量依赖全局 Mapper,同时 `InboundAllocationQueryMapper` 反向引用 `repository.PriceBookQueryRows`。这说明全局数据访问层尚不能按传统横向层直接单向化。 + +合并测试源码后,测试对生产类型的依赖把 SCC 扩大为下列十包;这不是生产运行时环,但表示测试也直接绑定了横向实现包: + +```text +controller -> loading.repository -> loading.service -> mapper +-> oceanbooking.controller -> oceanbooking.repository -> oceanbooking.service +-> pricing -> repository -> service -> controller +``` + +### 2.2 逻辑切片 SCC + +逻辑切片图存在一个八节点 SCC: + +```text +booking-customs-trucking +finance-integration +inbound +measurement-pricing +outbound +shared-technical +shipping-nodes +split-loading +``` + +`customer-statistics` 不在 SCC 内。当前 36 条切片间边均冻结在 `src/test/resources/architecture/firstleg-slice-dependencies.json`;合同允许删除现有边,不允许增加新边。 + +主要双向依赖包括: + +| 双向关系 | 直接证据 | +|---|---| +| 入库 ↔ 测量计价 | 入库编排直接使用测量/费用 Entity、Mapper;计价与客户费用查询直接使用入库单、状态和 Outbox | +| 入库 ↔ 分柜装柜 | 统一入库读模型读取装柜/PDA Mapper 与 Entity;装柜结算和事件发布读取入库状态、托盘与 Outbox | +| 分柜装柜 ↔ 出仓 | 货柜聚合读出仓历史;海外卸柜回写反向更新货柜/装柜事实 | +| 订舱报关拖车 ↔ 出仓 | 出仓门禁聚合报关/船期贡献者;报关和船期贡献者反向依赖出仓 DTO | +| 订舱报关拖车 ↔ 分柜装柜 | 订舱货柜分配使用装柜实体;报关直接读取装柜计划与明细 Mapper | +| 订舱报关拖车 ↔ 测量计价 | 价格簿校验影响订舱;供应商/渠道校验又由全局计价服务消费 | + +## 3. 八个业务切片现状映射 + +类数量和跨切片依赖数量以主源码图为准;“出/入”均为类级直接依赖条数。 + +| 业务切片 | 当前物理位置与识别范围 | 类数 | 出/入 | 主要边界问题 | +|---|---|---:|---:|---| +| 入库 | 全局 `controller/service/repository/mapper/entity/dto` 中 `Inbound*`、`Fulfillment*`、`Assignment*`、`UnifiedInbound*` | 67 | 26/60 | 被计价和装柜大量反向读取;`InboundWorkflowRepository` 直接持有测量/费用 Mapper | +| 测量计价 | 全局层中的 `Measurement*`、`Mojia*`、`Pricing*`、`PriceBook*`、`Fba*`、`Dimension*`、`FeeItem*`、`InboundFee*`,以及 `pricing/` | 113 | 72/25 | 最大切片;对入库有 35 条直接依赖,其中 20 条为跨切片数据模型泄漏、6 条为 Mapper 泄漏 | +| 分柜装柜 | `split/`、`loading/` 加全局 `Loading*`、`SmartLoading*`、货柜与 PDA 类 | 82 | 50/32 | 同时依赖入库、订舱、出仓和共享层;全局 Repository 直接读取 `loading/split` Mapper/Entity | +| 订舱报关拖车 | `oceanbooking/`、`customs/`,全局 `TruckAppointment*`、`WarehouseSupplier*`、出仓门禁贡献者 | 68 | 25/13 | `CustomsRepository` 直接读取入库与装柜 Mapper;订舱 Repository 直接读取全局货柜 Entity | +| 出仓 | `outbound/dispatch/` 与全局 `OutboundDispatch*`、运输历史、海外卸柜回写 | 17 | 8/10 | 与装柜、订舱门禁双向;海外回写直接使用全局货柜 Mapper/Entity | +| 船节点 | `portschedule/` 中 `CarrierScheduleQuery/Node/Adapter/Attempt/Exception*` | 22 | 8/1 | 查询服务直接读取订舱、船期、货柜 Entity;节点 Outbox 直接使用入库 Outbox Mapper/Entity | +| 客户统计 | `customerstatistics/` | 4 | 1/0 | Java 依赖最低;唯一跨切片依赖为 Controller 调用 `FirstlegScopeGuard`。SQL 只读订舱、货柜和装柜表 | +| 财务集成 | 全局 `InternalFinance*`、`FinanceFeeSource*`、`FinanceCostSnapshot*`、付款凭证/快照类 | 20 | 17/6 | 直接读取入库费、柜次费、装柜及共享 Outbox 的 Entity/Mapper;继续受财务总控冻结的业务口径约束 | + +共享技术层当前有 36 类,包含应用启动/配置、Scope Guard、单据号、部分跨服务 Client 及无法安全归属的全局模型。跨服务适配器按 `client` 包独立标识,不能因逻辑切片归属而被迁入应用核心。 + +## 4. 泄漏与逆向依赖基线 + +主源码共有 88 条跨切片 Entity/DTO 泄漏、31 条跨切片 Mapper 泄漏。高频项如下: + +| 来源 → 目标 | Entity/DTO | Mapper | 代表性证据 | +|---|---:|---:|---| +| 测量计价 → 入库 | 20 | 6 | `FbaPricingRepository -> InboundOutboxEntity/Mapper`;客户费用查询直接使用 `InboundOrderDetail` | +| 分柜装柜 → 入库 | 7 | 6 | `LoadingDeliveryService -> InboundStatusHistoryEntity/Mapper`;装柜 Outbox 复用入库 Outbox | +| 入库 → 测量计价 | 4 | 4 | `InboundWorkflowRepository -> InboundFee/Measurement/Review Mapper` | +| 入库 → 分柜装柜 | 4 | 3 | `UnifiedInboundReadRepository` 直接读取装柜证据、PDA 任务、预装计划 Mapper/Entity | +| 财务集成 → 入库 | 4 | 2 | 财务来源 Outbox 直接复用 `InboundOutboxEntity/Mapper` | +| 订舱报关拖车 → 分柜装柜 | 3 | 3 | `CustomsRepository -> LoadingPlan/LoadingPlanItem Mapper` | +| 分柜装柜 → 订舱报关拖车 | 3 | 0 | `ContainerService -> OceanBookingContainer*Entity` | +| 船节点 → 入库 | 1 | 1 | `CarrierScheduleNodeOutboxService -> InboundOutboxEntity/Mapper` | + +逆向依赖的判定不是简单的 `controller -> service -> repository` 层次关系,而是“业务核心直接依赖另一切片的持久化实现或 Entity”。首轮治理应优先用查询/命令 Port 替代这些直接引用,不共享领域 Entity。 + +## 5. 首切片选择:客户统计 + +选择 `customer-statistics`,不是因为预设,而是因为以下实际证据: + +1. 仅 4 个生产类,已集中在单一顶层包;没有业务切片对其产生 Java 类型入向依赖。 +2. 唯一跨切片 Java 依赖为 Web Controller 对共享 `FirstlegScopeGuard` 的调用,不涉及 Entity 或 Mapper 泄漏。 +3. 不属于当前八节点 SCC,也没有跨服务 Feign/HTTP Client。 +4. 现有调用面稳定:一个平台 GET 接口、一个 Admin API 消费者、两个后端测试。 +5. 它仍包含一个明确的内部逆向依赖:应用 Service 直接依赖 MyBatis Mapper,适合用最小 Out Port 证明目标依赖方向。 + +限制:`CustomerTransitStatisticsMapper` 的 SQL 只读 `fl_ocean_booking`、`fl_container_booking`、`fl_loading_plan`、`fl_loading_plan_item`。首切片只隔离 Java 依赖,不宣称拥有这些表,也不改变 SQL、索引或统计口径。 + +## 6. 首切片目标目录与允许依赖 + +```text +customerstatistics/ + adapter/in/web/ + PlatformCustomerTransitStatisticsController.java + application/ + CustomerTransitStatisticsQueryService.java + model/CustomerTransitStatisticsModels.java + port/out/CustomerTransitStatisticsReader.java + adapter/out/persistence/ + CustomerTransitStatisticsMapper.java +``` + +允许方向: + +```text +adapter.in.web -> application +adapter.out.persistence -> application.port.out +application -> application.model + common-core +``` + +额外允许项仅限 Web 适配器使用 `common-security`、Spring Web 和现有 `FirstlegScopeGuard`。禁止 `application` 依赖 MyBatis、Spring MVC、全局 `entity/mapper/repository/service` 或其他业务切片。 + +## 7. 精确迁移清单、调用方与测试 + +首切片实施时只迁移/新增以下文件: + +| 当前文件 | 目标/动作 | +|---|---| +| `customerstatistics/controller/PlatformCustomerTransitStatisticsController.java` | 移到 `adapter/in/web/`,URI、权限和响应保持不变 | +| `customerstatistics/dto/CustomerTransitStatisticsDtos.java` | 移到 `application/model/`,保持 JSON 字段与数值语义不变 | +| `customerstatistics/service/CustomerTransitStatisticsService.java` | 移到 `application/` 并改名为 Query Service,只依赖 Out Port | +| `customerstatistics/mapper/CustomerTransitStatisticsMapper.java` | 移到 `adapter/out/persistence/`,实现/扩展只读 Out Port;SQL 原样保留 | +| 新增 `customerstatistics/application/port/out/CustomerTransitStatisticsReader.java` | 定义 `summarize(customerId)` 与只读投影,不暴露 MyBatis 类型 | +| `src/test/java/.../customerstatistics/CustomerTransitStatisticsServiceTest.java` | Mock Out Port,不再 Mock Mapper | +| `src/test/java/.../customerstatistics/CustomerTransitStatisticsContractTest.java` | 更新包引用,继续锁定 URI、权限、SQL、OpenAPI 与索引语义 | +| `BaiyueFirstlegApplication.java`、`BaiyueFirstlegApplicationContractTest.java` | 只有 Mapper 扫描路径因移动确需调整时修改;不改变其他扫描包 | + +外部调用方只读核对,不在首切片修改: + +- `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` +- `apps/admin-web/src/modules/container-loading/api/platformContainerLoading.ts` +- `apps/admin-web/src/modules/container-loading/api/platformContainerLoading.test.ts` + +## 8. 回滚边界 + +- 不修改表、SQL 内容、接口路径、权限码、响应字段、统计定义、Nacos、部署或跨服务契约。 +- 回滚只涉及上述客户统计 Java 包移动、Out Port 和对应测试;可整体恢复到现有四类结构。 +- 若 Mapper 继承 Out Port 导致 MyBatis 代理行为或扫描合同失败,停止实施并恢复包内变更,不以增加全局扫描、共享 POM 依赖或兼容 Bean 绕过。 +- 首切片完成前不开始第二切片。 + +## 9. 当前只读合同与后续协作 + +本轮已增加模块内、无第三方依赖的只读合同: + +- `scripts/analyze-firstleg-dependencies.mjs`:生成 JSON/Markdown/DOT 和 Tarjan SCC。 +- `src/test/resources/architecture/firstleg-slice-dependencies.json`:冻结当前切片边;历史边可减少,不可增加。 +- `scripts/analyze-firstleg-dependencies.test.mjs`:验证基线无新增边,并锁定客户统计 4 类、唯一出向边、零入向边和不进入 SCC。 + +与其他架构专项的依赖: + +- 专项一(拓扑与质量门禁):决定是否引入 ArchUnit、如何把本合同接入 Maven/CI、以及共享 POM 和全局质量门禁。当前未修改 POM;建议由专项一把本脚本作为 Firstleg 局部门禁接入,再以 ArchUnit 替换/增强源码级解析。 +- 专项二(同步依赖与失败传播):在为其他切片定义应用 Port 前提供 Feign/HTTP 调用图和失败语义。客户统计当前没有跨服务 Client,因此首切片无需等待专项二;后续入库、财务集成、订舱和装柜切片必须协调。 + +## 10. 最小验证命令 + +```powershell +node --test firstleg/scripts/analyze-firstleg-dependencies.test.mjs +node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set main --format markdown +mvn -pl firstleg -am -Dtest=CustomerTransitStatisticsServiceTest,CustomerTransitStatisticsContractTest -Dsurefire.failIfNoSpecifiedTests=false test +``` + +当前只新增只读脚本、合同和报告,没有 Java 签名变化;前两条命令是本轮直接门禁。第三条用于首切片实际迁移后的 Maven 行为验证。 + diff --git a/firstleg/scripts/analyze-firstleg-dependencies.mjs b/firstleg/scripts/analyze-firstleg-dependencies.mjs new file mode 100644 index 000000000..e2de4acf8 --- /dev/null +++ b/firstleg/scripts/analyze-firstleg-dependencies.mjs @@ -0,0 +1,284 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const moduleRoot = path.resolve(import.meta.dirname, '..'); +const sourceRoots = { + main: path.join(moduleRoot, 'src', 'main', 'java'), + test: path.join(moduleRoot, 'src', 'test', 'java'), +}; +const internalPrefix = 'com.baiyue.firstleg.'; + +const sliceRules = [ + ['customer-statistics', /(?:^|\.)(?:customerstatistics)(?:\.|$)/], + ['finance-integration', /(?:InternalFinance|FinanceFeeSource|FinanceCostSnapshot|FeePaymentVoucher|FeePaymentSnapshot|ContainerFeeSourceSnapshot)/], + ['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException)/], + ['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/], + ['outbound', /(?:^|\.)outbound(?:\.|$)|(?:OutboundDispatch|ContainerTransport|OverseasContainerEvent)/], + ['split-loading', /(?:^|\.)(?:split|loading)(?:\.|$)|(?:Loading|SmartLoading|Container(?:Booking|Overview|Service|Dtos)|PdaPick)/], + ['measurement-pricing', /(?:^|\.)pricing(?:\.|$)|(?:Measurement|MeasureReview|Mojia|Pricing|PriceBook|PriceChannel|PriceOrigin|PriceWarehouse|Fba|Dimension|FeeItem|MerchantInboundBilling|InboundFee)/], + ['inbound', /(?:Inbound|Fulfillment|Assignment|UnifiedInbound|CartonLabel)/], +]; + +function parseArgs(argv) { + const options = { sourceSet: 'main', format: 'json', output: null, check: null }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--source-set') options.sourceSet = argv[++index]; + else if (argument === '--format') options.format = argv[++index]; + else if (argument === '--output') options.output = argv[++index]; + else if (argument === '--check') options.check = argv[++index]; + else if (argument === '--help') { + process.stdout.write('Usage: node scripts/analyze-firstleg-dependencies.mjs [--source-set main|test|all] [--format json|markdown|dot] [--output FILE] [--check CONTRACT.json]\n'); + process.exit(0); + } else throw new Error(`Unknown argument: ${argument}`); + } + if (!['main', 'test', 'all'].includes(options.sourceSet)) throw new Error(`Invalid source set: ${options.sourceSet}`); + if (!['json', 'markdown', 'dot'].includes(options.format)) throw new Error(`Invalid format: ${options.format}`); + return options; +} + +function walkJavaFiles(root) { + if (!fs.existsSync(root)) return []; + const result = []; + const pending = [root]; + while (pending.length > 0) { + const current = pending.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const absolute = path.join(current, entry.name); + if (entry.isDirectory()) pending.push(absolute); + else if (entry.isFile() && entry.name.endsWith('.java')) result.push(absolute); + } + } + return result.sort(); +} + +function stripCommentsAndStrings(source) { + return source + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/\/\/[^\r\n]*/g, ' ') + .replace(/"(?:\\.|[^"\\])*"/g, '""') + .replace(/'(?:\\.|[^'\\])*'/g, "''"); +} + +function classifySlice(fqcn) { + for (const [slice, expression] of sliceRules) { + if (expression.test(fqcn)) return slice; + } + return 'shared-technical'; +} + +function classifyKind(fqcn) { + const packageName = fqcn.slice(0, fqcn.lastIndexOf('.')); + if (packageName.includes('.client')) return 'cross-service-adapter'; + if (/\.(controller|repository|mapper|config|security)(?:\.|$)/.test(packageName)) return 'technical-adapter'; + if (/\.(entity|dto)(?:\.|$)/.test(packageName)) return 'data-model'; + return 'business-application'; +} + +function parseSource(file, sourceSet) { + const raw = fs.readFileSync(file, 'utf8'); + const source = stripCommentsAndStrings(raw); + const packageName = source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1]; + const typeName = path.basename(file, '.java'); + if (!packageName) throw new Error(`Missing package declaration: ${file}`); + const fqcn = `${packageName}.${typeName}`; + const imports = [...source.matchAll(/\bimport\s+(?:static\s+)?([\w.]+)(?:\.\*)?\s*;/g)].map(match => match[1]); + return { + file: path.relative(moduleRoot, file).replaceAll(path.sep, '/'), + sourceSet, + packageName, + typeName, + fqcn, + imports, + source, + slice: classifySlice(fqcn), + kind: classifyKind(fqcn), + }; +} + +function collectSources(sourceSet) { + const selected = sourceSet === 'all' ? ['main', 'test'] : [sourceSet]; + return selected.flatMap(set => walkJavaFiles(sourceRoots[set]).map(file => parseSource(file, set))); +} + +function resolveDependencies(sources) { + const knownTypes = new Map(sources.map(source => [source.fqcn, source])); + const typesByPackage = new Map(); + for (const source of sources) { + if (!typesByPackage.has(source.packageName)) typesByPackage.set(source.packageName, []); + typesByPackage.get(source.packageName).push(source); + } + const dependencies = []; + const seen = new Set(); + for (const source of sources) { + const candidates = new Set(); + for (const imported of source.imports) { + if (knownTypes.has(imported)) candidates.add(imported); + else if (imported.startsWith(internalPrefix)) { + const owner = [...knownTypes.keys()].find(type => imported.startsWith(`${type}.`)); + if (owner) candidates.add(owner); + } + } + for (const samePackageType of typesByPackage.get(source.packageName) ?? []) { + if (samePackageType.fqcn !== source.fqcn && new RegExp(`\\b${samePackageType.typeName}\\b`).test(source.source)) { + candidates.add(samePackageType.fqcn); + } + } + for (const targetFqcn of candidates) { + if (targetFqcn === source.fqcn) continue; + const key = `${source.fqcn}->${targetFqcn}`; + if (seen.has(key)) continue; + seen.add(key); + dependencies.push({ source: source.fqcn, target: targetFqcn }); + } + } + return dependencies.sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`)); +} + +function aggregateEdges(dependencies, nodeOf) { + const counts = new Map(); + for (const dependency of dependencies) { + const source = nodeOf(dependency.source); + const target = nodeOf(dependency.target); + if (source === target) continue; + const key = `${source}\0${target}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return [...counts].map(([key, count]) => { + const [source, target] = key.split('\0'); + return { source, target, count }; + }).sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`)); +} + +function stronglyConnectedComponents(nodes, edges) { + const adjacency = new Map(nodes.map(node => [node, []])); + for (const edge of edges) adjacency.get(edge.source)?.push(edge.target); + const indexByNode = new Map(); + const lowLink = new Map(); + const stack = []; + const onStack = new Set(); + const components = []; + let nextIndex = 0; + function visit(node) { + indexByNode.set(node, nextIndex); + lowLink.set(node, nextIndex); + nextIndex += 1; + stack.push(node); + onStack.add(node); + for (const target of adjacency.get(node) ?? []) { + if (!indexByNode.has(target)) { + visit(target); + lowLink.set(node, Math.min(lowLink.get(node), lowLink.get(target))); + } else if (onStack.has(target)) { + lowLink.set(node, Math.min(lowLink.get(node), indexByNode.get(target))); + } + } + if (lowLink.get(node) === indexByNode.get(node)) { + const component = []; + let current; + do { + current = stack.pop(); + onStack.delete(current); + component.push(current); + } while (current !== node); + components.push(component.sort()); + } + } + for (const node of [...nodes].sort()) if (!indexByNode.has(node)) visit(node); + return components.filter(component => component.length > 1).sort((a, b) => b.length - a.length || a[0].localeCompare(b[0])); +} + +function buildReport(sourceSet) { + const sources = collectSources(sourceSet); + const sourceByFqcn = new Map(sources.map(source => [source.fqcn, source])); + const dependencies = resolveDependencies(sources); + const packages = [...new Set(sources.map(source => source.packageName))].sort(); + const slices = [...new Set(sources.map(source => source.slice))].sort(); + const packageEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).packageName); + const sliceEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).slice); + const packageSccs = stronglyConnectedComponents(packages, packageEdges); + const sliceSccs = stronglyConnectedComponents(slices, sliceEdges); + const directCrossSliceDependencies = dependencies.filter(edge => sourceByFqcn.get(edge.source).slice !== sourceByFqcn.get(edge.target).slice); + const modelLeaks = directCrossSliceDependencies.filter(edge => ['data-model'].includes(sourceByFqcn.get(edge.target).kind)); + const mapperLeaks = directCrossSliceDependencies.filter(edge => /\.mapper(?:\.|$)/.test(sourceByFqcn.get(edge.target).packageName)); + return { + schemaVersion: 1, + generatedFrom: sourceSet, + moduleRoot: 'firstleg', + summary: { + javaFiles: sources.length, + internalClassDependencies: dependencies.length, + packages: packages.length, + slices: slices.length, + crossSliceClassDependencies: directCrossSliceDependencies.length, + crossSliceModelLeaks: modelLeaks.length, + crossSliceMapperLeaks: mapperLeaks.length, + }, + sliceDefinitions: Object.fromEntries(sliceRules.map(([name, expression]) => [name, expression.source]).concat([['shared-technical', 'fallback']])), + classes: sources.map(({ source, imports, ...entry }) => entry), + packageEdges, + sliceEdges, + packageSccs, + sliceSccs, + directCrossSliceDependencies, + modelLeaks, + mapperLeaks, + }; +} + +function renderMarkdown(report) { + const lines = [ + '# Firstleg internal dependency report', '', + `- Source set: ${report.generatedFrom}`, + `- Java files: ${report.summary.javaFiles}`, + `- Internal class dependencies: ${report.summary.internalClassDependencies}`, + `- Package SCCs (>1 node): ${report.packageSccs.length}`, + `- Slice SCCs (>1 node): ${report.sliceSccs.length}`, + '', '## Slice edges', '', '| From | To | Class edges |', '|---|---|---:|', + ...report.sliceEdges.map(edge => `| ${edge.source} | ${edge.target} | ${edge.count} |`), + '', '## Slice SCCs', '', + ...(report.sliceSccs.length ? report.sliceSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']), + '', '## Package SCCs', '', + ...(report.packageSccs.length ? report.packageSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']), + '', '## Cross-slice model and mapper leaks', '', + ...[...report.modelLeaks, ...report.mapperLeaks] + .filter((edge, index, entries) => entries.findIndex(candidate => candidate.source === edge.source && candidate.target === edge.target) === index) + .map(edge => `- \`${edge.source}\` -> \`${edge.target}\``), + '', + ]; + return lines.join('\n'); +} + +function renderDot(report) { + const lines = ['digraph firstleg_slices {', ' rankdir=LR;']; + for (const edge of report.sliceEdges) lines.push(` "${edge.source}" -> "${edge.target}" [label="${edge.count}"];`); + lines.push('}', ''); + return lines.join('\n'); +} + +function checkContract(report, contractPath) { + const absolute = path.resolve(process.cwd(), contractPath); + const contract = JSON.parse(fs.readFileSync(absolute, 'utf8')); + const allowed = new Set(contract.allowedSliceEdges ?? []); + const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`); + const additions = actual.filter(edge => !allowed.has(edge)); + if (additions.length > 0) { + process.stderr.write(`New slice dependencies are not allowed by ${contractPath}:\n${additions.map(edge => `- ${edge}`).join('\n')}\n`); + process.exitCode = 1; + } +} + +const options = parseArgs(process.argv.slice(2)); +const report = buildReport(options.sourceSet); +if (options.check) checkContract(report, options.check); +const rendered = options.format === 'json' + ? `${JSON.stringify(report, null, 2)}\n` + : options.format === 'markdown' + ? renderMarkdown(report) + : renderDot(report); +if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8'); +else process.stdout.write(rendered); + diff --git a/firstleg/scripts/analyze-firstleg-dependencies.test.mjs b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs new file mode 100644 index 000000000..9cf71321f --- /dev/null +++ b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import test from 'node:test'; + +const moduleRoot = path.resolve(import.meta.dirname, '..'); +const analyzer = path.join(moduleRoot, 'scripts', 'analyze-firstleg-dependencies.mjs'); +const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'firstleg-slice-dependencies.json'); + +function analyze() { + return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'json'], { + cwd: moduleRoot, + encoding: 'utf8', + })); +} + +test('current source does not add slice dependencies beyond the baseline', () => { + execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'dot', '--check', contract], { + cwd: moduleRoot, + encoding: 'utf8', + }); +}); + +test('customer statistics remains an independently packaged low-coupling slice', () => { + const report = analyze(); + const classes = report.classes.filter(entry => entry.slice === 'customer-statistics'); + assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [ + 'com.baiyue.firstleg.customerstatistics.controller.PlatformCustomerTransitStatisticsController', + 'com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos', + 'com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper', + 'com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService', + ]); + const outgoing = report.sliceEdges.filter(edge => edge.source === 'customer-statistics'); + const incoming = report.sliceEdges.filter(edge => edge.target === 'customer-statistics'); + assert.deepEqual(outgoing, [{ source: 'customer-statistics', target: 'shared-technical', count: 1 }]); + assert.deepEqual(incoming, []); + assert.equal(report.sliceSccs.some(component => component.includes('customer-statistics')), false); +}); + diff --git a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json new file mode 100644 index 000000000..4607d9891 --- /dev/null +++ b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 1, + "description": "Development-stage Firstleg slice dependency baseline. Existing edges may be removed; new edges require architecture review.", + "allowedSliceEdges": [ + "booking-customs-trucking->inbound", + "booking-customs-trucking->measurement-pricing", + "booking-customs-trucking->outbound", + "booking-customs-trucking->shared-technical", + "booking-customs-trucking->split-loading", + "customer-statistics->shared-technical", + "finance-integration->inbound", + "finance-integration->measurement-pricing", + "finance-integration->shared-technical", + "finance-integration->split-loading", + "inbound->measurement-pricing", + "inbound->outbound", + "inbound->shared-technical", + "inbound->split-loading", + "measurement-pricing->booking-customs-trucking", + "measurement-pricing->finance-integration", + "measurement-pricing->inbound", + "measurement-pricing->shared-technical", + "outbound->booking-customs-trucking", + "outbound->shared-technical", + "outbound->shipping-nodes", + "outbound->split-loading", + "shared-technical->booking-customs-trucking", + "shared-technical->finance-integration", + "shared-technical->inbound", + "shared-technical->measurement-pricing", + "shared-technical->split-loading", + "shipping-nodes->booking-customs-trucking", + "shipping-nodes->inbound", + "shipping-nodes->shared-technical", + "shipping-nodes->split-loading", + "split-loading->booking-customs-trucking", + "split-loading->inbound", + "split-loading->measurement-pricing", + "split-loading->outbound", + "split-loading->shared-technical" + ], + "sliceSpecificRules": { + "customer-statistics": { + "allowedOutgoing": ["shared-technical"], + "allowedIncoming": [] + } + } +} diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 7adcf9f70..f70c92a95 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -27,6 +27,7 @@ | 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | | 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | | 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,不含出库和派送 | +| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 | | 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,范围开关与禁用写操作见“四期开发范围路由” | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` | | 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` | diff --git a/pom.xml b/pom.xml index 35b79ff33..cbb4a2780 100644 --- a/pom.xml +++ b/pom.xml @@ -65,6 +65,7 @@ 2.5.0 3.13.0 3.4.1 + 0.8.12 2.3.3 @@ -137,6 +138,28 @@ [3.8.8,) + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + + 准备覆盖率采集 + prepare-agent + + + 生成覆盖率报告 + verify + report + + + HTML + XML + + + + + diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 4052af722..ad8e06621 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -115,6 +115,19 @@ foreach ($pdaApplication in $pdaApplications) { } } +# 海外 PDA 当前只开放真实拆柜收货;其余后端虽已存在,前端接入前必须保持禁用。 +$overseasWorkbenchPath = Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/src/data/overseas-workbench.js' +$overseasWorkbench = Read-RequiredText $overseasWorkbenchPath +Assert-Matches $overseasWorkbench "(?s)name:\s*'拆柜收货'.{0,300}?available:\s*true" 'Overseas PDA 拆柜收货开关' +foreach ($disabledOperation in @( + '库存盘点', '商品移库', '托盘管理', + '换标收货', '换标执行', '换标复核', + '库存查询', '库位查询', '托盘查询' +)) { + $disabledPattern = "(?s)name:\s*'$([regex]::Escape($disabledOperation))'.{0,300}?available:\s*false" + Assert-Matches $overseasWorkbench $disabledPattern "Overseas PDA 禁用入口 $disabledOperation" +} + # 文档声明为正式开放的 Client 海外仓页面必须仍是 active 路由。 $clientOverseasRoutesPath = Join-Path $workspaceRoot 'apps/client-web/src/modules/overseas-warehouse/routes.ts' $clientOverseasRoutes = Read-RequiredText $clientOverseasRoutesPath @@ -134,6 +147,7 @@ $overseasGatewayConfig = Read-RequiredText $overseasGatewayConfigPath foreach ($gatewayPath in @( '/api/merchant/v1/last-leg/**', '/api/platform/v1/last-leg/**', + '/api/pda/v1/last-leg/**', '/api/merchant/v1/warehouses/**', '/api/platform/v1/warehouse-admin/**' )) { @@ -174,6 +188,9 @@ $criticalPaths = @( 'apps/pda-overseas-uniapp/src/pages/operation/operation.vue', 'apps/pda-overseas-uniapp/src/config/runtime.js', 'apps/pda-overseas-uniapp/src/api/auth.js', + 'apps/pda-overseas-uniapp/src/api/inbound.js', + 'apps/pda-overseas-uniapp/src/api/receiving-adapter.js', + 'apps/pda-overseas-uniapp/src/api/overseas.js', 'apps/pda-overseas-uniapp/src/utils/scan-broadcast.js', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidator.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java', @@ -187,6 +204,22 @@ $criticalPaths = @( 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java', 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java', + 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java', + 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java', + 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseService.java', + 'baiyue-firstleg/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/controller/MerchantLastlegOrderController.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderType.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java', @@ -197,6 +230,10 @@ $criticalPaths = @( 'baiyue-firstleg/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java', 'baiyue-firstleg/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java', 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java', + 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java', 'baiyue-firstleg/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegInboundOrderMapper.java', @@ -256,5 +293,5 @@ if ($failures.Count -gt 0) { exit 1 } -Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、Gateway 覆盖、PDA 注册页面、Maven 模块、关键入口和排除目录。" -ForegroundColor Green +Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、Gateway 覆盖、PDA 注册页面与能力开关、Maven 模块、关键入口和排除目录。" -ForegroundColor Green exit 0 diff --git a/scripts/java-topology-contract.test.mjs b/scripts/java-topology-contract.test.mjs new file mode 100644 index 000000000..180022fb0 --- /dev/null +++ b/scripts/java-topology-contract.test.mjs @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict' +import { access, readFile } from 'node:fs/promises' +import test from 'node:test' + +const read = async (path) => (await readFile(new URL(`../${path}`, import.meta.url), 'utf8')) + .replaceAll('\r\n', '\n') + +const sorted = (values) => [...values].sort() + +const parseRootModules = (xml) => { + const body = xml.match(/([\s\S]*?)<\/modules>/)?.[1] ?? '' + return [...body.matchAll(/([^<]+)<\/module>/g)].map((match) => match[1].trim()) +} + +const parsePom = (xml) => { + const project = xml.replace(/[\s\S]*?<\/parent>/, '') + return { + artifactId: project.match(/([^<]+)<\/artifactId>/)?.[1]?.trim(), + springBoot: /spring-boot-maven-plugin<\/artifactId>/.test(project) + } +} + +const parseServiceMap = (yaml) => { + const services = new Map() + let current + for (const line of yaml.split('\n')) { + const service = line.match(/^ ([a-z][a-z0-9-]+):$/) + if (service) { + current = {} + services.set(service[1], current) + continue + } + const property = line.match(/^ ([a-zA-Z]+): (.+)$/) + if (!current || !property) continue + const [, key, raw] = property + current[key] = key === 'port' + ? Number(raw) + : key === 'regions' + ? raw.slice(1, -1).split(',').map((value) => value.trim()) + : raw + } + return services +} + +const parseGroovyList = (source, variable) => { + const body = source.match(new RegExp(`def ${variable} = \\[([\\s\\S]*?)\\n\\]`))?.[1] ?? '' + return [...body.matchAll(/'([^']+)'/g)].map((match) => match[1]) +} + +const parseGroovyMap = (source, variable) => { + const body = source.match(new RegExp(`def ${variable} = \\[([\\s\\S]*?)\\n\\]`))?.[1] ?? '' + return new Map([...body.matchAll(/'([^']+)':\s*'([^']+)'/g)] + .map((match) => [match[1], match[2]])) +} + +const parseCompose = (yaml) => { + const body = yaml.match(/^services:\n([\s\S]*?)(?=^networks:)/m)?.[1] ?? '' + const matches = [...body.matchAll(/^ ([a-z][a-z0-9-]+):\s*$/gm)] + return new Map(matches.map((match, index) => { + const block = body.slice(match.index, matches[index + 1]?.index ?? body.length) + return [match[1], Number(block.match(/^ SERVER_PORT: (\d+)$/m)?.[1])] + })) +} + +test('root POM freezes 22 modules and service-map freezes 15 executable services', async () => { + const modules = parseRootModules(await read('pom.xml')) + const services = parseServiceMap(await read('deploy/service-map.yml')) + + assert.equal(modules.length, 22) + assert.equal(new Set(modules).size, 22) + assert.equal(services.size, 15) + + const bootModules = [] + for (const module of modules) { + const pom = parsePom(await read(`${module}/pom.xml`)) + if (pom.springBoot) bootModules.push(module) + if (!services.has(module)) continue + const service = services.get(module) + assert.equal(service.artifactId, pom.artifactId, `${module} artifactId drifted`) + assert.equal(service.jar, `${module}/target/${pom.artifactId}-1.0.0-SNAPSHOT.jar`) + } + assert.deepEqual(sorted(bootModules), sorted(services.keys())) + assert.equal(new Set([...services.values()].map(({ port }) => port)).size, services.size) +}) + +test('Jenkins and GitLab package exactly the service-map modules', async () => { + const services = parseServiceMap(await read('deploy/service-map.yml')) + const expected = sorted(services.keys()) + const coordinator = await read('Jenkinsfile') + const servicePipeline = await read('Jenkinsfile.service') + const serviceJars = parseGroovyMap(servicePipeline, 'serviceJars') + const gitlab = await read('.gitlab-ci.yml') + const gitlabArtifacts = [...gitlab.matchAll(/^ - ([a-z][a-z0-9-]+)\/target\/\*\.jar$/gm)] + .map((match) => match[1]) + + assert.deepEqual(sorted(parseGroovyList(coordinator, 'serviceModules')), expected) + assert.deepEqual(sorted(parseGroovyList(servicePipeline, 'serviceModules')), expected) + assert.deepEqual(sorted(serviceJars.keys()), expected) + assert.deepEqual(sorted(gitlabArtifacts), expected) + for (const [module, service] of services) assert.equal(serviceJars.get(module), service.jar) +}) + +test('GitLab runs the topology contract in a dedicated Node job', async () => { + const gitlab = await read('.gitlab-ci.yml') + const topologyJob = gitlab.match(/^Java拓扑合同:\n([\s\S]*?)(?=^[^\s#].*:\n)/m)?.[1] ?? '' + const packageJob = gitlab.match(/^打包服务:\n([\s\S]*)/m)?.[1] ?? '' + + assert.match(topologyJob, /^ stage: verify$/m) + assert.match(topologyJob, /^ image: node:[^\s]+$/m) + assert.match(topologyJob, /^ - node --test scripts\/java-topology-contract\.test\.mjs$/m) + assert.match(packageJob, /^ - Java拓扑合同$/m) +}) + +test('Jenkins cannot invoke the Node contract without declaring a Node runtime', async () => { + for (const path of ['Jenkinsfile', 'Jenkinsfile.service']) { + const pipeline = await read(path) + if (!pipeline.includes('java-topology-contract.test.mjs')) continue + assert.match( + pipeline, + /(?:nodejs\s*\(|NodeJSInstallation|docker(?:\.image\([^)]*node:|\s+run[^\n]*\bnode:))/, + `${path} must declare how Node is provided before invoking the topology contract` + ) + } +}) + +test('regional Compose and Nacos manifests cover the service-map topology', async () => { + const services = parseServiceMap(await read('deploy/service-map.yml')) + const composeByRegion = new Map([ + ['CN', parseCompose(await read('deploy/docker-compose.yml'))], + ['OVERSEAS', parseCompose(await read('deploy/docker-compose.overseas.yml'))] + ]) + + for (const [module, service] of services) { + const regions = service.regions ?? ['CN'] + for (const [region, compose] of composeByRegion) { + assert.equal(compose.has(module), regions.includes(region), `${module} Compose region drifted for ${region}`) + if (!regions.includes(region)) continue + assert.equal(compose.get(module), service.port, `${module} internal port drifted for ${region}`) + await access(new URL(`../config/nacos/${region.toLowerCase()}/${service.artifactId}.yml`, import.meta.url)) + } + } + assert.deepEqual( + sorted(new Set([...composeByRegion.values()].flatMap((compose) => [...compose.keys()]))), + sorted(services.keys()) + ) +}) diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs new file mode 100644 index 000000000..60c2af78b --- /dev/null +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -0,0 +1,188 @@ +import assert from 'node:assert/strict' +import { readdir, readFile } from 'node:fs/promises' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + +const serviceModules = [ + 'auth', 'iam', 'order', 'firstleg', 'finance', 'file', 'warehouse', + 'integration-service', 'print', 'event-bridge-service', 'lastleg-service', + 'warehouse-overseas-service' +] + +const canonicalSource = new Map(serviceModules.map(name => [name, `${name}${name.endsWith('-service') ? '' : '-service'}`])) + +const expectedFeignEdges = new Map(Object.entries({ + 'auth-service->iam-service': 2, + 'file-service->finance-service': 1, + 'file-service->firstleg-service': 1, + 'file-service->iam-service': 2, + 'file-service->integration-service': 1, + 'file-service->order-service': 1, + 'file-service->warehouse-service': 1, + 'finance-service->file-service': 1, + 'finance-service->firstleg-service': 2, + 'finance-service->iam-service': 1, + 'finance-service->warehouse-service': 1, + 'firstleg-service->event-bridge-service': 1, + 'firstleg-service->file-service': 7, + 'firstleg-service->finance-service': 1, + 'firstleg-service->iam-service': 1, + 'firstleg-service->integration-service': 1, + 'firstleg-service->order-service': 2, + 'firstleg-service->print-service': 1, + 'firstleg-service->warehouse-service': 4, + 'iam-service->auth-service': 1, + 'iam-service->event-bridge-service': 1, + 'iam-service->firstleg-service': 1, + 'iam-service->warehouse-service': 1, + 'lastleg-service->event-bridge-service': 1, + 'lastleg-service->warehouse-service': 1, + 'order-service->file-service': 1, + 'order-service->firstleg-service': 3, + 'order-service->iam-service': 1, + 'order-service->warehouse-service': 1 +})) + +const expectedRawHttpImplementations = [ + 'common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java', + 'event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java', + 'event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java', + 'gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', + 'lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', + 'lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java', + 'lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java', + 'lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java', + 'integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java', + 'integration-service/src/main/java/com/baiyue/integration/provider/HedeTrackingClient.java', + 'integration-service/src/main/java/com/baiyue/integration/provider/MatsonTrackingClient.java', + 'integration-service/src/main/java/com/baiyue/integration/provider/SmlTrackingClient.java', + 'integration-service/src/main/java/com/baiyue/integration/provider/ZimTrackingClient.java', + 'integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java' +] + +const reviewedRawInternalEdges = [ + 'event-bridge-service->firstleg-service', + 'event-bridge-service->lastleg-service', + 'gateway-overseas->auth-service', + 'lastleg-service->file-service', + 'lastleg-service->warehouse-overseas-service' +] + +const rawHttpPattern = /\b(?:RestClient|WebClient|HttpClient)\b/ + +async function javaFiles(directory) { + const result = [] + async function visit(current) { + for (const entry of await readdir(current, { withFileTypes: true })) { + const target = path.join(current, entry.name) + if (entry.isDirectory()) await visit(target) + else if (entry.isFile() && entry.name.endsWith('.java')) result.push(target) + } + } + await visit(directory) + return result +} + +async function discoverFeignEdges() { + const edges = new Map() + for (const module of serviceModules) { + const sourceRoot = path.join(root, module, 'src', 'main', 'java') + for (const file of await javaFiles(sourceRoot)) { + const source = await readFile(file, 'utf8') + if (!source.includes('@FeignClient')) continue + const annotation = source.match(/@FeignClient\(([\s\S]*?)\)\s*(?:public\s+)?interface/) + const target = annotation?.[1].match(/name\s*=\s*"([^"]+)"/)?.[1] + assert.ok(target, `cannot resolve Feign target from ${path.relative(root, file)}`) + const edge = `${canonicalSource.get(module)}->${target}` + edges.set(edge, (edges.get(edge) ?? 0) + 1) + } + } + return edges +} + +function sortedEntries(map) { + return [...map.entries()].sort(([left], [right]) => left.localeCompare(right)) +} + +function stronglyConnectedComponents(edgeNames) { + const adjacency = new Map() + for (const edge of edgeNames) { + const [from, to] = edge.split('->') + if (!adjacency.has(from)) adjacency.set(from, new Set()) + if (!adjacency.has(to)) adjacency.set(to, new Set()) + adjacency.get(from).add(to) + } + let index = 0 + const indexes = new Map() + const lowLinks = new Map() + const stack = [] + const onStack = new Set() + const components = [] + + function connect(node) { + indexes.set(node, index) + lowLinks.set(node, index++) + stack.push(node) + onStack.add(node) + for (const target of adjacency.get(node)) { + if (!indexes.has(target)) { + connect(target) + lowLinks.set(node, Math.min(lowLinks.get(node), lowLinks.get(target))) + } else if (onStack.has(target)) { + lowLinks.set(node, Math.min(lowLinks.get(node), indexes.get(target))) + } + } + if (lowLinks.get(node) !== indexes.get(node)) return + const component = [] + let current + do { + current = stack.pop() + onStack.delete(current) + component.push(current) + } while (current !== node) + components.push(component.sort()) + } + + for (const node of adjacency.keys()) if (!indexes.has(node)) connect(node) + return components.filter(component => component.length > 1) + .sort((left, right) => left.join().localeCompare(right.join())) +} + +test('all Feign clients remain covered by the reviewed synchronous edge baseline', async () => { + assert.deepEqual(sortedEntries(await discoverFeignEdges()), sortedEntries(expectedFeignEdges)) +}) + +test('raw HTTP transports cannot be added without an explicit graph review', async () => { + const discovered = [] + for (const module of ['common-audit', 'event-bridge-service', 'gateway-overseas', 'lastleg-service', 'integration-service']) { + for (const file of await javaFiles(path.join(root, module, 'src', 'main', 'java'))) { + const source = await readFile(file, 'utf8') + if (!rawHttpPattern.test(source)) continue + if (!/(?:private|final)\s+(?:RestClient|WebClient|HttpClient)\b|HttpClient\s+client\s*=/.test(source)) continue + discovered.push(path.relative(root, file).replaceAll('\\', '/')) + } + } + assert.deepEqual(discovered.sort(), [...expectedRawHttpImplementations].sort()) +}) + +test('the current synchronous service graph keeps its reviewed strongly connected component visible', async () => { + const feignEdges = (await discoverFeignEdges()).keys() + const components = stronglyConnectedComponents([...feignEdges, ...reviewedRawInternalEdges]) + assert.deepEqual(components, [[ + 'auth-service', 'event-bridge-service', 'file-service', 'finance-service', + 'firstleg-service', 'iam-service', 'lastleg-service', 'order-service' + ]]) +}) + +test('shared Feign timeout defaults and long-running export overrides do not drift silently', async () => { + for (const profile of ['local', 'cn', 'overseas']) { + const source = await readFile(path.join(root, 'config', 'nacos', profile, 'baiyue-common.yml'), 'utf8') + assert.match(source, /connect-timeout:\s*3000/) + assert.match(source, /read-timeout:\s*5000/) + } + const fileConfig = await readFile(path.join(root, 'file', 'src', 'main', 'resources', 'application.yml'), 'utf8') + assert.equal((fileConfig.match(/read-timeout:\s*15000/g) ?? []).length, 5) +}) From 322de03312738ff7b8ace82505575a5c8a34f473 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 11:53:59 +0800 Subject: [PATCH 093/115] fix(ci): serialize deployments per service --- Jenkinsfile.service | 59 ++++++++++--------- ...est-environment-pipeline-contract.test.mjs | 4 ++ 2 files changed, 36 insertions(+), 27 deletions(-) diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 72b8e5f43..bd81515d2 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -194,38 +194,43 @@ pipeline { steps { script { String deploySlotLabel = "firstleg-${params.ENVIRONMENT}-deploy-slot" + String serviceDeployResource = "firstleg-${params.ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy" String credentialId = "firstleg-${params.ENVIRONMENT}-env" String targetLabel = params.ENVIRONMENT == 'development' ? 'java-dev' : 'java-test' - lock(label: deploySlotLabel, quantity: 1, inversePrecedence: true) { - node(targetLabel) { - deleteDir() - try { - unstash 'deployment-files' - echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.ENVIRONMENT}" - List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' && - (env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' || - ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION))) - ? ['CN', 'OVERSEAS'] - : [params.EVENT_BRIDGE_REGION] - withCredentials([ - file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'), - usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, - usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD') - ]) { - bridgeRegions.each { bridgeRegion -> - if (env.RESOLVED_SERVICE == 'event-bridge-service') { - echo "部署事件桥实例: ${bridgeRegion}" - } - withEnv([ - 'TARGET_IS_LOCAL=true', - "EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}" - ]) { - sh "sh deploy/scripts/deploy-built-service.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" + // 不同服务可以并行,但同一环境的同一服务只能有一个任务更新容器。 + // resource 未预配置时 Lockable Resources 会创建仅在构建期间存在的临时资源。 + lock(resource: serviceDeployResource, inversePrecedence: true) { + lock(label: deploySlotLabel, quantity: 1, inversePrecedence: true) { + node(targetLabel) { + deleteDir() + try { + unstash 'deployment-files' + echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.ENVIRONMENT}" + List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' && + (env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' || + ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION))) + ? ['CN', 'OVERSEAS'] + : [params.EVENT_BRIDGE_REGION] + withCredentials([ + file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'), + usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, + usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD') + ]) { + bridgeRegions.each { bridgeRegion -> + if (env.RESOLVED_SERVICE == 'event-bridge-service') { + echo "部署事件桥实例: ${bridgeRegion}" + } + withEnv([ + 'TARGET_IS_LOCAL=true', + "EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}" + ]) { + sh "sh deploy/scripts/deploy-built-service.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" + } } } + } finally { + cleanWs(deleteDirs: true, notFailBuild: true) } - } finally { - cleanWs(deleteDirs: true, notFailBuild: true) } } } diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index cb9d380ce..22eaf133c 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -71,13 +71,17 @@ test('development and test deployment locks are acquired before target executors pipeline.indexOf("stage('部署开发或测试环境')"), pipeline.indexOf("stage('部署单个服务')") ) + const serviceLockIndex = directDeployStage.indexOf('lock(resource: serviceDeployResource') const lockIndex = directDeployStage.indexOf('lock(label: deploySlotLabel') const nodeIndex = directDeployStage.indexOf('node(targetLabel)') assert.match(directDeployStage, /agent none/) assert.match(directDeployStage, /String targetLabel = params\.ENVIRONMENT == 'development' \? 'java-dev' : 'java-test'/) + assert.match(directDeployStage, /String serviceDeployResource = "firstleg-\$\{params\.ENVIRONMENT\}-\$\{env\.RESOLVED_SERVICE\}-deploy"/) + assert.ok(serviceLockIndex >= 0, 'per-service deployment lock must exist') assert.ok(lockIndex >= 0, 'deployment slot lock must exist') assert.ok(nodeIndex >= 0, 'target node allocation must exist') + assert.ok(serviceLockIndex < lockIndex, 'per-service lock must be acquired before the shared deployment slot') assert.ok(lockIndex < nodeIndex, 'deployment lock must be acquired before target node allocation') }) From d9c8cc478e2a1bc6f0356f88700dac405af17cd7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 11:54:19 +0800 Subject: [PATCH 094/115] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../baiyue/firstleg/BaiyueFirstlegApplication.java | 2 +- ...latformCustomerTransitStatisticsController.java | 6 +++--- .../CustomerTransitStatisticsMapper.java | 14 +++++--------- .../CustomerTransitStatisticsService.java | 10 +++++----- .../model}/CustomerTransitStatisticsDtos.java | 2 +- .../port/out/CustomerTransitStatisticsReader.java | 11 +++++++++++ .../BaiyueFirstlegApplicationContractTest.java | 3 ++- .../CustomerTransitStatisticsContractTest.java | 6 +++--- .../CustomerTransitStatisticsServiceTest.java | 10 +++++----- 9 files changed, 36 insertions(+), 28 deletions(-) rename firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/{controller => adapter/in/web}/PlatformCustomerTransitStatisticsController.java (83%) rename firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/{mapper => adapter/out/persistence}/CustomerTransitStatisticsMapper.java (85%) rename firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/{service => application}/CustomerTransitStatisticsService.java (75%) rename firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/{dto => application/model}/CustomerTransitStatisticsDtos.java (84%) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java index b9ed1c6ee..8c071e0be 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java @@ -18,7 +18,7 @@ import org.springframework.scheduling.annotation.EnableScheduling; "com.baiyue.firstleg.outbound.dispatch.mapper", "com.baiyue.firstleg.portschedule.mapper", "com.baiyue.firstleg.customs.mapper", - "com.baiyue.firstleg.customerstatistics.mapper" + "com.baiyue.firstleg.customerstatistics.adapter.out.persistence" }) @EnableFeignClients(basePackages = "com.baiyue.firstleg") @EnableScheduling diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/controller/PlatformCustomerTransitStatisticsController.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java similarity index 83% rename from firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/controller/PlatformCustomerTransitStatisticsController.java rename to firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java index ca99b7399..ae441abed 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/controller/PlatformCustomerTransitStatisticsController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java @@ -1,9 +1,9 @@ -package com.baiyue.firstleg.customerstatistics.controller; +package com.baiyue.firstleg.customerstatistics.adapter.in.web; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos.TransitSummary; -import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService; +import com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService; +import com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos.TransitSummary; import com.baiyue.firstleg.service.FirstlegScopeGuard; import jakarta.annotation.Resource; import jakarta.validation.constraints.Positive; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/out/persistence/CustomerTransitStatisticsMapper.java similarity index 85% rename from firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java rename to firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/out/persistence/CustomerTransitStatisticsMapper.java index 0c208dc1e..93f276654 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/out/persistence/CustomerTransitStatisticsMapper.java @@ -1,14 +1,13 @@ -package com.baiyue.firstleg.customerstatistics.mapper; +package com.baiyue.firstleg.customerstatistics.adapter.out.persistence; +import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; -import java.math.BigDecimal; -import java.time.LocalDateTime; - @Mapper -public interface CustomerTransitStatisticsMapper { +public interface CustomerTransitStatisticsMapper extends CustomerTransitStatisticsReader { + @Override @Select(""" SELECT #{customerId} AS customerId, COUNT(*) AS containerCount, @@ -49,8 +48,5 @@ public interface CustomerTransitStatisticsMapper { WHERE x.customer_id = #{customerId} ) scoped """) - SummaryRow summarize(@Param("customerId") long customerId); - - record SummaryRow(Long customerId, Long containerCount, BigDecimal totalTeu, - Long unknownContainerCount, LocalDateTime asOf) {} + SummaryProjection summarize(@Param("customerId") long customerId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/service/CustomerTransitStatisticsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/CustomerTransitStatisticsService.java similarity index 75% rename from firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/service/CustomerTransitStatisticsService.java rename to firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/CustomerTransitStatisticsService.java index 485a9572b..71477ce5b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/service/CustomerTransitStatisticsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/CustomerTransitStatisticsService.java @@ -1,9 +1,9 @@ -package com.baiyue.firstleg.customerstatistics.service; +package com.baiyue.firstleg.customerstatistics.application; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos.TransitSummary; -import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper; +import com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos.TransitSummary; +import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; @@ -12,11 +12,11 @@ import java.math.BigDecimal; @Service public class CustomerTransitStatisticsService { private static final String DEFINITION = "actual_sailing_at IS NOT NULL 且 actual_warehouse_arrival_at IS NULL;排除取消/失败订舱;客户+containerBookingId去重;拼柜按active LoadingPlanItem.accountId归属,整柜按OceanBooking.customerId归属。TEU:20*=1,40*=2,45*=2.25,未知箱型不计totalTeu。"; - @Resource private CustomerTransitStatisticsMapper mapper; + @Resource private CustomerTransitStatisticsReader reader; public TransitSummary summarize(long customerId) { if (customerId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "customerId必须为正数"); - var row = mapper.summarize(customerId); + var row = reader.summarize(customerId); return new TransitSummary(customerId, value(row == null ? null : row.containerCount()), row == null || row.totalTeu() == null ? BigDecimal.ZERO : row.totalTeu(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/dto/CustomerTransitStatisticsDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/model/CustomerTransitStatisticsDtos.java similarity index 84% rename from firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/dto/CustomerTransitStatisticsDtos.java rename to firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/model/CustomerTransitStatisticsDtos.java index e123871da..7afb319b1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/dto/CustomerTransitStatisticsDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/model/CustomerTransitStatisticsDtos.java @@ -1,4 +1,4 @@ -package com.baiyue.firstleg.customerstatistics.dto; +package com.baiyue.firstleg.customerstatistics.application.model; import java.math.BigDecimal; import java.time.LocalDateTime; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java new file mode 100644 index 000000000..58ece2751 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java @@ -0,0 +1,11 @@ +package com.baiyue.firstleg.customerstatistics.application.port.out; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public interface CustomerTransitStatisticsReader { + SummaryProjection summarize(long customerId); + + record SummaryProjection(Long customerId, Long containerCount, BigDecimal totalTeu, + Long unknownContainerCount, LocalDateTime asOf) {} +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java index 30474a894..2b9409161 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java @@ -20,7 +20,8 @@ class BaiyueFirstlegApplicationContractTest { .contains("com.baiyue.firstleg.outbound.dispatch.mapper") .contains("com.baiyue.firstleg.portschedule.mapper") .contains("com.baiyue.firstleg.customs.mapper") - .contains("com.baiyue.firstleg.customerstatistics.mapper"); + .contains("com.baiyue.firstleg.customerstatistics.adapter.out.persistence") + .doesNotContain("com.baiyue.firstleg.customerstatistics.mapper"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java index 33463262f..33793badc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java @@ -1,8 +1,8 @@ package com.baiyue.firstleg.customerstatistics; -import com.baiyue.firstleg.customerstatistics.controller.PlatformCustomerTransitStatisticsController; -import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper; -import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService; +import com.baiyue.firstleg.customerstatistics.adapter.in.web.PlatformCustomerTransitStatisticsController; +import com.baiyue.firstleg.customerstatistics.adapter.out.persistence.CustomerTransitStatisticsMapper; +import com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsServiceTest.java index e7028ebb9..04bf83ec8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsServiceTest.java @@ -1,8 +1,8 @@ package com.baiyue.firstleg.customerstatistics; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper; -import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService; +import com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService; +import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -19,12 +19,12 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class CustomerTransitStatisticsServiceTest { - @Mock CustomerTransitStatisticsMapper mapper; + @Mock CustomerTransitStatisticsReader reader; @InjectMocks CustomerTransitStatisticsService service; @Test void preservesDecimalTeuAndUnknownCount() { var asOf = LocalDateTime.of(2026, 8, 13, 12, 0); - when(mapper.summarize(88L)).thenReturn(new CustomerTransitStatisticsMapper.SummaryRow( + when(reader.summarize(88L)).thenReturn(new CustomerTransitStatisticsReader.SummaryProjection( 88L, 4L, new BigDecimal("6.25"), 1L, asOf)); var result = service.summarize(88L); @@ -35,7 +35,7 @@ class CustomerTransitStatisticsServiceTest { assertThat(result.unknownContainerCount()).isEqualTo(1); assertThat(result.asOf()).isEqualTo(asOf); assertThat(result.definition()).contains("20*=1", "40*=2", "45*=2.25", "未知箱型"); - verify(mapper).summarize(88L); + verify(reader).summarize(88L); } @Test void rejectsInvalidCustomerBeforeQuery() { From 24df851acfec2bdf509fdf7c780ba2e703b1ec45 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 12:18:19 +0800 Subject: [PATCH 095/115] fix(lastleg): register shipping mapper on startup --- deploy/scripts/deploy-built-service.sh | 18 ++++++++++++- .../shipping/ShippingMapperConfiguration.java | 10 ++++++++ .../ShippingMapperConfigurationTest.java | 15 +++++++++++ .../deploy-built-service-rollback.test.mjs | 25 +++++++++++++++++-- 4 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/shipping/ShippingMapperConfiguration.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/shipping/ShippingMapperConfigurationTest.java diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index dd0aef38d..f20ca9be2 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -222,11 +222,27 @@ local_health_state() { docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}no-health:{{.State.Status}}{{end}}' "$container_id" 2>/dev/null || echo missing } +local_runtime_state() { + container_id="$(local_container_id)" + if [ -z "$container_id" ]; then + echo missing + return + fi + docker inspect --format '{{.State.Status}}' "$container_id" 2>/dev/null || echo missing +} + wait_for_local_health() { elapsed=0 while [ "$elapsed" -lt "$HEALTH_TIMEOUT_SECONDS" ]; do + runtime_state="$(local_runtime_state)" health_state="$(local_health_state)" - echo "容器健康状态: $health_state(已等待 ${elapsed}s)" + echo "容器运行状态: $runtime_state,健康状态: $health_state(已等待 ${elapsed}s)" + case "$runtime_state" in + restarting|exited|dead) + echo "容器未保持运行,立即停止等待: $runtime_state" >&2 + return 1 + ;; + esac case "$health_state" in healthy) return 0 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/ShippingMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/ShippingMapperConfiguration.java new file mode 100644 index 000000000..509032dff --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/ShippingMapperConfiguration.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.shipping; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +/** Registers the shipping gate persistence mapper used during event and receiving workflows. */ +@Configuration +@MapperScan(basePackages = "com.baiyue.lastleg.shipping.mapper") +public class ShippingMapperConfiguration { +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/ShippingMapperConfigurationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/ShippingMapperConfigurationTest.java new file mode 100644 index 000000000..34545e9f9 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/ShippingMapperConfigurationTest.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.shipping; + +import org.junit.jupiter.api.Test; +import org.mybatis.spring.annotation.MapperScan; + +import static org.assertj.core.api.Assertions.assertThat; + +class ShippingMapperConfigurationTest { + @Test + void scansShippingMappers() { + MapperScan mapperScan = ShippingMapperConfiguration.class.getAnnotation(MapperScan.class); + + assertThat(mapperScan.basePackages()).containsExactly("com.baiyue.lastleg.shipping.mapper"); + } +} diff --git a/scripts/deploy-built-service-rollback.test.mjs b/scripts/deploy-built-service-rollback.test.mjs index 84906b7b8..e794a23d3 100644 --- a/scripts/deploy-built-service-rollback.test.mjs +++ b/scripts/deploy-built-service-rollback.test.mjs @@ -41,8 +41,12 @@ case "$command" in printf '%s\\n' "$IMAGE_REPOSITORY/$service:$IMAGE_TAG" > "$MOCK_CURRENT_IMAGE" if [ "$IMAGE_TAG" = "\${MOCK_FAIL_TAG:-never}" ]; then printf '%s\\n' unhealthy > "$MOCK_HEALTH_STATE" + elif [ "$IMAGE_TAG" = "\${MOCK_RESTART_TAG:-never}" ]; then + printf '%s\\n' starting > "$MOCK_HEALTH_STATE" + printf '%s\\n' restarting > "$MOCK_RUNTIME_STATE" else printf '%s\\n' healthy > "$MOCK_HEALTH_STATE" + printf '%s\\n' running > "$MOCK_RUNTIME_STATE" fi ;; esac @@ -51,6 +55,7 @@ case "$command" in case "$2" in *Config.Image*) cat "$MOCK_CURRENT_IMAGE" ;; *State.Health*) cat "$MOCK_HEALTH_STATE" ;; + *State.Status*) cat "$MOCK_RUNTIME_STATE" ;; *) exit 2 ;; esac ;; @@ -64,7 +69,7 @@ case "$command" in esac ` -async function runDeployment({ imageTag, failingTag, failingStartTag }) { +async function runDeployment({ imageTag, failingTag, failingStartTag, restartingTag }) { const temporaryDirectory = await mkdtemp(join(tmpdir(), 'firstleg-rollback-')) const mockBin = join(temporaryDirectory, 'bin') const dockerPath = join(mockBin, 'docker') @@ -72,6 +77,7 @@ async function runDeployment({ imageTag, failingTag, failingStartTag }) { const logFile = join(temporaryDirectory, 'docker.log') const currentImageFile = join(temporaryDirectory, 'current-image') const healthStateFile = join(temporaryDirectory, 'health-state') + const runtimeStateFile = join(temporaryDirectory, 'runtime-state') await mkdir(mockBin) await writeFile(dockerPath, mockDocker, 'utf8') @@ -80,6 +86,7 @@ async function runDeployment({ imageTag, failingTag, failingStartTag }) { await writeFile(logFile, '', 'utf8') await writeFile(currentImageFile, 'registry.local/group/firstleg/audit-service:oldgood\n', 'utf8') await writeFile(healthStateFile, 'healthy\n', 'utf8') + await writeFile(runtimeStateFile, 'running\n', 'utf8') const result = spawnSync(shell, [ deployScript.replaceAll('\\', '/'), @@ -103,7 +110,9 @@ async function runDeployment({ imageTag, failingTag, failingStartTag }) { MOCK_CURRENT_IMAGE: currentImageFile, MOCK_HEALTH_STATE: healthStateFile, MOCK_FAIL_TAG: failingTag ?? 'never', - MOCK_FAIL_UP_TAG: failingStartTag ?? 'never' + MOCK_FAIL_UP_TAG: failingStartTag ?? 'never', + MOCK_RESTART_TAG: restartingTag ?? 'never', + MOCK_RUNTIME_STATE: runtimeStateFile } }) @@ -152,3 +161,15 @@ test('failed compose start also restores the previous healthy image', async (con assert.match(output, /自动回滚成功: registry\.local\/group\/firstleg\/audit-service:oldgood/) assert.equal(result.currentImage, 'registry.local/group/firstleg/audit-service:oldgood') }) + +test('restart loops fail fast and restore the previous image', async (context) => { + if (!existsSync(shell)) context.skip(`shell unavailable: ${shell}`) + + const result = await runDeployment({ imageTag: 'restarting', restartingTag: 'restarting' }) + const output = `${result.stdout}\n${result.stderr}` + + assert.equal(result.status, 1, output) + assert.match(output, /容器未保持运行,立即停止等待: restarting/) + assert.match(output, /自动回滚成功: registry\.local\/group\/firstleg\/audit-service:oldgood/) + assert.equal(result.currentImage, 'registry.local/group/firstleg/audit-service:oldgood') +}) From 0e0b6f8264013c88b855a9cc6a8fbb1fbaabd926 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 12:22:14 +0800 Subject: [PATCH 096/115] test: add architecture and OpenAPI validation tooling --- .gitignore | 1 + ...rstleg-internal-modularization-baseline.md | 25 ++- firstleg/pom.xml | 1 + .../scripts/analyze-firstleg-dependencies.mjs | 45 ++++- .../analyze-firstleg-dependencies.test.mjs | 23 ++- .../CustomerStatisticsArchitectureTest.java | 80 ++++++++ .../firstleg-slice-dependencies.json | 16 ++ pom.xml | 6 + scripts/java-topology-contract.test.mjs | 18 +- scripts/openapi-validation/package-lock.json | 180 ++++++++++++++++++ scripts/openapi-validation/package.json | 16 ++ .../openapi-validation/validate-openapi.mjs | 83 ++++++++ .../validate-openapi.test.mjs | 67 +++++++ 13 files changed, 544 insertions(+), 17 deletions(-) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerStatisticsArchitectureTest.java create mode 100644 scripts/openapi-validation/package-lock.json create mode 100644 scripts/openapi-validation/package.json create mode 100644 scripts/openapi-validation/validate-openapi.mjs create mode 100644 scripts/openapi-validation/validate-openapi.test.mjs diff --git a/.gitignore b/.gitignore index 533ecf24c..dc4b3792c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ target/ +node_modules/ .docker-context/ *.log .idea/ diff --git a/firstleg/docs/firstleg-internal-modularization-baseline.md b/firstleg/docs/firstleg-internal-modularization-baseline.md index 3a37feacd..7dbecb0d3 100644 --- a/firstleg/docs/firstleg-internal-modularization-baseline.md +++ b/firstleg/docs/firstleg-internal-modularization-baseline.md @@ -4,8 +4,9 @@ 本基线只分析 `firstleg` 同一部署单元,不拆微服务,不改变业务流程、数据库结构、OpenAPI 或跨服务契约。分析输入为: -- `src/main/java`:429 个 Java 文件;948 条可解析的模块内类依赖;66 个物理包。 -- `src/test/java`:169 个 Java 文件。与主源码合并分析时共 598 个 Java 文件、1,635 条模块内类依赖、69 个物理包。 +- 首切片实施前 `src/main/java`:429 个 Java 文件;948 条可解析的模块内类依赖;66 个物理包。 +- 首切片实施前 `src/test/java`:169 个 Java 文件。与主源码合并分析时共 598 个 Java 文件、1,635 条模块内类依赖、69 个物理包。 +- 首切片实施后 `src/main/java`:430 个 Java 文件;949 条可解析的模块内类依赖;67 个物理包。增加项仅为只读 Out Port。 可重复命令: @@ -78,7 +79,7 @@ split-loading | 订舱报关拖车 | `oceanbooking/`、`customs/`,全局 `TruckAppointment*`、`WarehouseSupplier*`、出仓门禁贡献者 | 68 | 25/13 | `CustomsRepository` 直接读取入库与装柜 Mapper;订舱 Repository 直接读取全局货柜 Entity | | 出仓 | `outbound/dispatch/` 与全局 `OutboundDispatch*`、运输历史、海外卸柜回写 | 17 | 8/10 | 与装柜、订舱门禁双向;海外回写直接使用全局货柜 Mapper/Entity | | 船节点 | `portschedule/` 中 `CarrierScheduleQuery/Node/Adapter/Attempt/Exception*` | 22 | 8/1 | 查询服务直接读取订舱、船期、货柜 Entity;节点 Outbox 直接使用入库 Outbox Mapper/Entity | -| 客户统计 | `customerstatistics/` | 4 | 1/0 | Java 依赖最低;唯一跨切片依赖为 Controller 调用 `FirstlegScopeGuard`。SQL 只读订舱、货柜和装柜表 | +| 客户统计 | `customerstatistics/adapter`、`customerstatistics/application` | 5 | 1/0 | 已完成首切片;唯一跨切片依赖为 Web Adapter 调用 `FirstlegScopeGuard`。SQL 只读订舱、货柜和装柜表 | | 财务集成 | 全局 `InternalFinance*`、`FinanceFeeSource*`、`FinanceCostSnapshot*`、付款凭证/快照类 | 20 | 17/6 | 直接读取入库费、柜次费、装柜及共享 Outbox 的 Entity/Mapper;继续受财务总控冻结的业务口径约束 | 共享技术层当前有 36 类,包含应用启动/配置、Scope Guard、单据号、部分跨服务 Client 及无法安全归属的全局模型。跨服务适配器按 `client` 包独立标识,不能因逻辑切片归属而被迁入应用核心。 @@ -104,7 +105,7 @@ split-loading 选择 `customer-statistics`,不是因为预设,而是因为以下实际证据: -1. 仅 4 个生产类,已集中在单一顶层包;没有业务切片对其产生 Java 类型入向依赖。 +1. 实施前仅 4 个生产类且已集中在单一顶层包;实施后为原 4 类加 1 个只读 Out Port,没有业务切片对其产生 Java 类型入向依赖。 2. 唯一跨切片 Java 依赖为 Web Controller 对共享 `FirstlegScopeGuard` 的调用,不涉及 Entity 或 Mapper 泄漏。 3. 不属于当前八节点 SCC,也没有跨服务 Feign/HTTP Client。 4. 现有调用面稳定:一个平台 GET 接口、一个 Admin API 消费者、两个后端测试。 @@ -138,7 +139,7 @@ application -> application.model + common-core ## 7. 精确迁移清单、调用方与测试 -首切片实施时只迁移/新增以下文件: +首切片已按以下清单迁移/新增: | 当前文件 | 目标/动作 | |---|---| @@ -170,7 +171,7 @@ application -> application.model + common-core - `scripts/analyze-firstleg-dependencies.mjs`:生成 JSON/Markdown/DOT 和 Tarjan SCC。 - `src/test/resources/architecture/firstleg-slice-dependencies.json`:冻结当前切片边;历史边可减少,不可增加。 -- `scripts/analyze-firstleg-dependencies.test.mjs`:验证基线无新增边,并锁定客户统计 4 类、唯一出向边、零入向边和不进入 SCC。 +- `scripts/analyze-firstleg-dependencies.test.mjs`:验证基线无新增边,并锁定客户统计目标 5 类/目录、唯一出向边、零入向边、不进入 SCC 和 application 依赖禁令。 与其他架构专项的依赖: @@ -182,8 +183,18 @@ application -> application.model + common-core ```powershell node --test firstleg/scripts/analyze-firstleg-dependencies.test.mjs node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set main --format markdown +mvn -pl firstleg -am test-compile mvn -pl firstleg -am -Dtest=CustomerTransitStatisticsServiceTest,CustomerTransitStatisticsContractTest -Dsurefire.failIfNoSpecifiedTests=false test ``` -当前只新增只读脚本、合同和报告,没有 Java 签名变化;前两条命令是本轮直接门禁。第三条用于首切片实际迁移后的 Maven 行为验证。 +首切片已经发生 Java 包与依赖签名变化,三条命令均为直接门禁;另需执行 `mvn -pl firstleg -am test-compile` 覆盖全部测试源码调用点。 +## 11. 首切片实施结果 + +- 四个旧包类已删除,没有转发兼容层;目标五层目录已由 Node 合同锁定。 +- `CustomerTransitStatisticsReader.SummaryProjection` 只包含 `customerId`、`containerCount`、`totalTeu`、`unknownContainerCount`、`asOf`,不暴露 MyBatis、Entity 或其他切片类型。 +- 依赖方向由 `application Service -> MyBatis Mapper` 改为 `application Service -> application Port <- persistence Mapper`。 +- 主源码由 429 类/948 条内部依赖/66 包变为 430 类/949 条内部依赖/67 包;36 条切片间边、88 条模型泄漏、31 条 Mapper 泄漏及全局 `mapper <-> repository` SCC 均未增加。客户统计仍只有 `adapter.in.web -> FirstlegScopeGuard` 一条跨切片边,且没有入向边。 +- 根 Mapper 扫描只把旧精确包替换为新精确包,没有扩大扫描范围或增加兼容 Bean。 +- 验证结果:Node 合同 3/3;`test-compile` 成功;客户统计加根扫描定向测试 7/7;`mvn -pl firstleg -am test` 共 854 个测试,0 失败、0 错误、3 跳过,构建成功。 +- 首次并发执行 `test-compile` 时,其他专项同时写入共享 `target/classes` 导致若干无关 class 文件瞬时缺失;源符号核对正常,独立重跑 `test-compile` 与完整测试均成功。 diff --git a/firstleg/pom.xml b/firstleg/pom.xml index 02098cbe8..1a35216ef 100644 --- a/firstleg/pom.xml +++ b/firstleg/pom.xml @@ -23,6 +23,7 @@ org.springframework.bootspring-boot-starter-actuator com.mysqlmysql-connector-jruntime org.springframework.bootspring-boot-starter-testtest + com.tngtech.archunitarchunittest org.projectlomboklomboktrue org.springframework.bootspring-boot-maven-plugin diff --git a/firstleg/scripts/analyze-firstleg-dependencies.mjs b/firstleg/scripts/analyze-firstleg-dependencies.mjs index e2de4acf8..5b252d7a8 100644 --- a/firstleg/scripts/analyze-firstleg-dependencies.mjs +++ b/firstleg/scripts/analyze-firstleg-dependencies.mjs @@ -73,7 +73,7 @@ function classifySlice(fqcn) { function classifyKind(fqcn) { const packageName = fqcn.slice(0, fqcn.lastIndexOf('.')); if (packageName.includes('.client')) return 'cross-service-adapter'; - if (/\.(controller|repository|mapper|config|security)(?:\.|$)/.test(packageName)) return 'technical-adapter'; + if (/\.(adapter|controller|repository|mapper|config|security)(?:\.|$)/.test(packageName)) return 'technical-adapter'; if (/\.(entity|dto)(?:\.|$)/.test(packageName)) return 'data-model'; return 'business-application'; } @@ -218,7 +218,8 @@ function buildReport(sourceSet) { crossSliceMapperLeaks: mapperLeaks.length, }, sliceDefinitions: Object.fromEntries(sliceRules.map(([name, expression]) => [name, expression.source]).concat([['shared-technical', 'fallback']])), - classes: sources.map(({ source, imports, ...entry }) => entry), + classes: sources.map(({ source, ...entry }) => entry), + classDependencies: dependencies, packageEdges, sliceEdges, packageSccs, @@ -265,8 +266,43 @@ function checkContract(report, contractPath) { const allowed = new Set(contract.allowedSliceEdges ?? []); const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`); const additions = actual.filter(edge => !allowed.has(edge)); - if (additions.length > 0) { - process.stderr.write(`New slice dependencies are not allowed by ${contractPath}:\n${additions.map(edge => `- ${edge}`).join('\n')}\n`); + const violations = additions.map(edge => `new slice dependency: ${edge}`); + const classesByName = new Map(report.classes.map(entry => [entry.fqcn, entry])); + for (const [fqcn, expectedFile] of Object.entries(contract.requiredClassLocations ?? {})) { + const actualClass = classesByName.get(fqcn); + if (!actualClass) violations.push(`required class missing: ${fqcn}`); + else if (actualClass.file !== expectedFile) violations.push(`class location changed: ${fqcn} (${actualClass.file})`); + } + const applicationRule = contract.applicationDependencyRule; + if (applicationRule) { + const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(applicationRule.packagePrefix)); + for (const entry of applicationClasses) { + for (const imported of entry.imports) { + if ((applicationRule.forbiddenImportPrefixes ?? []).some(prefix => imported.startsWith(prefix))) { + violations.push(`forbidden application import: ${entry.fqcn} -> ${imported}`); + } + } + } + for (const edge of report.classDependencies) { + if (edge.source.startsWith(applicationRule.packagePrefix) + && edge.target.startsWith(internalPrefix) + && !edge.target.startsWith(applicationRule.allowedInternalPrefix)) { + violations.push(`forbidden application dependency: ${edge.source} -> ${edge.target}`); + } + } + } + for (const [slice, rule] of Object.entries(contract.sliceSpecificRules ?? {})) { + const outgoing = report.sliceEdges.filter(edge => edge.source === slice).map(edge => edge.target).sort(); + const incoming = report.sliceEdges.filter(edge => edge.target === slice).map(edge => edge.source).sort(); + if (JSON.stringify(outgoing) !== JSON.stringify([...(rule.allowedOutgoing ?? [])].sort())) { + violations.push(`${slice} outgoing dependencies changed: ${outgoing.join(', ')}`); + } + if (JSON.stringify(incoming) !== JSON.stringify([...(rule.allowedIncoming ?? [])].sort())) { + violations.push(`${slice} incoming dependencies changed: ${incoming.join(', ')}`); + } + } + if (violations.length > 0) { + process.stderr.write(`Firstleg dependency contract violations in ${contractPath}:\n${violations.map(item => `- ${item}`).join('\n')}\n`); process.exitCode = 1; } } @@ -281,4 +317,3 @@ const rendered = options.format === 'json' : renderDot(report); if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8'); else process.stdout.write(rendered); - diff --git a/firstleg/scripts/analyze-firstleg-dependencies.test.mjs b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs index 9cf71321f..9bcb61d64 100644 --- a/firstleg/scripts/analyze-firstleg-dependencies.test.mjs +++ b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs @@ -25,10 +25,11 @@ test('customer statistics remains an independently packaged low-coupling slice', const report = analyze(); const classes = report.classes.filter(entry => entry.slice === 'customer-statistics'); assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [ - 'com.baiyue.firstleg.customerstatistics.controller.PlatformCustomerTransitStatisticsController', - 'com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos', - 'com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper', - 'com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService', + 'com.baiyue.firstleg.customerstatistics.adapter.in.web.PlatformCustomerTransitStatisticsController', + 'com.baiyue.firstleg.customerstatistics.adapter.out.persistence.CustomerTransitStatisticsMapper', + 'com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService', + 'com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos', + 'com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader', ]); const outgoing = report.sliceEdges.filter(edge => edge.source === 'customer-statistics'); const incoming = report.sliceEdges.filter(edge => edge.target === 'customer-statistics'); @@ -37,3 +38,17 @@ test('customer statistics remains an independently packaged low-coupling slice', assert.equal(report.sliceSccs.some(component => component.includes('customer-statistics')), false); }); +test('customer statistics application layer only depends inward', () => { + const report = analyze(); + const prefix = 'com.baiyue.firstleg.customerstatistics.application'; + const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(prefix)); + assert.equal(applicationClasses.length, 3); + for (const entry of applicationClasses) { + assert.equal(entry.imports.some(imported => imported.startsWith('org.springframework.web')), false); + assert.equal(entry.imports.some(imported => imported.startsWith('org.apache.ibatis')), false); + assert.equal(entry.imports.some(imported => imported.startsWith('com.baomidou.mybatisplus')), false); + } + const outwardDependencies = report.classDependencies.filter(edge => edge.source.startsWith(prefix) + && edge.target.startsWith('com.baiyue.firstleg.') && !edge.target.startsWith(prefix)); + assert.deepEqual(outwardDependencies, []); +}); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerStatisticsArchitectureTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerStatisticsArchitectureTest.java new file mode 100644 index 000000000..252da501c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerStatisticsArchitectureTest.java @@ -0,0 +1,80 @@ +package com.baiyue.firstleg.customerstatistics; + +import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +class CustomerStatisticsArchitectureTest { + private static final String FIRSTLEG = "com.baiyue.firstleg.."; + private static final String CUSTOMER_STATISTICS = "com.baiyue.firstleg.customerstatistics.."; + private static final String APPLICATION = "com.baiyue.firstleg.customerstatistics.application.."; + private static final String OUTPUT_PORT = "com.baiyue.firstleg.customerstatistics.application.port.out.."; + private static final String PERSISTENCE = "com.baiyue.firstleg.customerstatistics.adapter.out.persistence.."; + private static final String WEB = "com.baiyue.firstleg.customerstatistics.adapter.in.web.."; + + private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS) + .importPackages("com.baiyue.firstleg"); + + @Test + void applicationModelAndPortsStayFreeOfWebAndPersistenceFrameworks() { + noClasses().that().resideInAPackage(APPLICATION) + .should().dependOnClassesThat().resideInAnyPackage( + "org.springframework.web..", + "org.apache.ibatis..", + "com.baomidou.mybatisplus..") + .check(PRODUCTION_CLASSES); + } + + @Test + void applicationOnlyDependsOnItsOwnSliceWithinFirstleg() { + classes().that().resideInAPackage(APPLICATION) + .should().onlyDependOnClassesThat( + resideOutsideOfPackage(FIRSTLEG).or(resideInAPackage(APPLICATION))) + .check(PRODUCTION_CLASSES); + } + + @Test + void persistenceAdapterCollaboratesThroughTheApplicationOutputPort() { + classes().that().resideInAPackage(PERSISTENCE) + .should().onlyDependOnClassesThat( + resideOutsideOfPackage(CUSTOMER_STATISTICS).or(resideInAPackage(OUTPUT_PORT))) + .andShould().beAssignableTo(CustomerTransitStatisticsReader.class) + .check(PRODUCTION_CLASSES); + } + + @Test + void webAdapterOnlyUsesApplicationAndScopeGuardWithinFirstleg() { + DescribedPredicate scopeGuard = new DescribedPredicate<>("FirstlegScopeGuard") { + @Override + public boolean test(JavaClass javaClass) { + return javaClass.getName().equals(FirstlegScopeGuard.class.getName()); + } + }; + classes().that().resideInAPackage(WEB) + .should().onlyDependOnClassesThat( + resideOutsideOfPackage(FIRSTLEG) + .or(resideInAPackage(APPLICATION)) + .or(resideInAPackage(WEB)) + .or(scopeGuard)) + .check(PRODUCTION_CLASSES); + } + + @Test + void otherFirstlegProductionPackagesDoNotDependOnCustomerStatistics() { + noClasses().that().resideOutsideOfPackage(CUSTOMER_STATISTICS) + .should().dependOnClassesThat().resideInAPackage(CUSTOMER_STATISTICS) + .check(PRODUCTION_CLASSES); + } +} diff --git a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json index 4607d9891..308cda8bc 100644 --- a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json +++ b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json @@ -39,6 +39,22 @@ "split-loading->outbound", "split-loading->shared-technical" ], + "requiredClassLocations": { + "com.baiyue.firstleg.customerstatistics.adapter.in.web.PlatformCustomerTransitStatisticsController": "src/main/java/com/baiyue/firstleg/customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java", + "com.baiyue.firstleg.customerstatistics.adapter.out.persistence.CustomerTransitStatisticsMapper": "src/main/java/com/baiyue/firstleg/customerstatistics/adapter/out/persistence/CustomerTransitStatisticsMapper.java", + "com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService": "src/main/java/com/baiyue/firstleg/customerstatistics/application/CustomerTransitStatisticsService.java", + "com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos": "src/main/java/com/baiyue/firstleg/customerstatistics/application/model/CustomerTransitStatisticsDtos.java", + "com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader": "src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java" + }, + "applicationDependencyRule": { + "packagePrefix": "com.baiyue.firstleg.customerstatistics.application", + "allowedInternalPrefix": "com.baiyue.firstleg.customerstatistics.application", + "forbiddenImportPrefixes": [ + "org.springframework.web", + "org.apache.ibatis", + "com.baomidou.mybatisplus" + ] + }, "sliceSpecificRules": { "customer-statistics": { "allowedOutgoing": ["shared-technical"], diff --git a/pom.xml b/pom.xml index cbb4a2780..0e706d52c 100644 --- a/pom.xml +++ b/pom.xml @@ -66,6 +66,7 @@ 3.13.0 3.4.1 0.8.12 + 1.5.0 2.3.3 @@ -115,6 +116,11 @@ springdoc-openapi-starter-webflux-ui ${springdoc.version} + + com.tngtech.archunit + archunit + ${archunit.version} + diff --git a/scripts/java-topology-contract.test.mjs b/scripts/java-topology-contract.test.mjs index 180022fb0..736e5e8af 100644 --- a/scripts/java-topology-contract.test.mjs +++ b/scripts/java-topology-contract.test.mjs @@ -117,12 +117,28 @@ test('Jenkins cannot invoke the Node contract without declaring a Node runtime', if (!pipeline.includes('java-topology-contract.test.mjs')) continue assert.match( pipeline, - /(?:nodejs\s*\(|NodeJSInstallation|docker(?:\.image\([^)]*node:|\s+run[^\n]*\bnode:))/, + /(?:nodejs\s*\(|NodeJSInstallation|docker(?:\.image\([^)]*node:|\s+run[^\n]*\bnode:))/, `${path} must declare how Node is provided before invoking the topology contract` ) } }) +test('Maven verify publishes JaCoCo reports without enforcing a threshold', async () => { + const pom = await read('pom.xml') + const gitlab = await read('.gitlab-ci.yml') + const jacoco = pom.match(/\s*org\.jacoco<\/groupId>[\s\S]*?<\/plugin>/)?.[0] ?? '' + + assert.match(jacoco, /jacoco-maven-plugin<\/artifactId>/) + assert.match(jacoco, /prepare-agent<\/goal>/) + assert.match(jacoco, /verify<\/phase>/) + assert.match(jacoco, /report<\/goal>/) + assert.match(jacoco, /HTML<\/format>/) + assert.match(jacoco, /XML<\/format>/) + assert.doesNotMatch(jacoco, /check<\/goal>|/) + assert.match(gitlab, /^ - "\*\*\/target\/site\/jacoco\/"$/m) + assert.match(gitlab, /^ - "\*\*\/target\/jacoco\.exec"$/m) +}) + test('regional Compose and Nacos manifests cover the service-map topology', async () => { const services = parseServiceMap(await read('deploy/service-map.yml')) const composeByRegion = new Map([ diff --git a/scripts/openapi-validation/package-lock.json b/scripts/openapi-validation/package-lock.json new file mode 100644 index 000000000..a1127021a --- /dev/null +++ b/scripts/openapi-validation/package-lock.json @@ -0,0 +1,180 @@ +{ + "name": "baiyue-openapi-validation", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "baiyue-openapi-validation", + "version": "1.0.0", + "dependencies": { + "@apidevtools/swagger-parser": "12.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz", + "integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz", + "integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "14.0.1", + "@apidevtools/openapi-schemas": "^2.1.0", + "@apidevtools/swagger-methods": "^3.0.2", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "call-me-maybe": "^1.0.2" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + } + } +} diff --git a/scripts/openapi-validation/package.json b/scripts/openapi-validation/package.json new file mode 100644 index 000000000..b270dd57e --- /dev/null +++ b/scripts/openapi-validation/package.json @@ -0,0 +1,16 @@ +{ + "name": "baiyue-openapi-validation", + "version": "1.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "test": "node --test validate-openapi.test.mjs", + "validate": "node validate-openapi.mjs" + }, + "dependencies": { + "@apidevtools/swagger-parser": "12.1.0" + } +} diff --git a/scripts/openapi-validation/validate-openapi.mjs b/scripts/openapi-validation/validate-openapi.mjs new file mode 100644 index 000000000..16d47d6ed --- /dev/null +++ b/scripts/openapi-validation/validate-openapi.mjs @@ -0,0 +1,83 @@ +import SwaggerParser from '@apidevtools/swagger-parser' +import { readdir, readFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const OPENAPI_DIRECTORY = ['src', 'main', 'resources', 'openapi'].join('/') +const OPENAPI_EXTENSIONS = new Set(['.json', '.yaml', '.yml']) +const EXCLUDED_DIRECTORIES = new Set([ + '.git', '.logs', 'dist', 'node_modules', 'output', 'outputs', 'reports', 'target' +]) + +export const repositoryRoot = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) + +export async function discoverOpenApiFiles(root) { + const files = [] + async function walk(directory) { + const entries = await readdir(directory, { withFileTypes: true }) + for (const entry of entries) { + if (entry.isDirectory() && EXCLUDED_DIRECTORIES.has(entry.name)) continue + const absolute = path.join(directory, entry.name) + if (entry.isDirectory()) { + await walk(absolute) + continue + } + const relative = path.relative(root, absolute).replaceAll(path.sep, '/') + if (relative.includes(`/${OPENAPI_DIRECTORY}/`) + && OPENAPI_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(relative) + } + } + await walk(root) + return files.sort() +} + +export function parseServiceModules(serviceMap) { + return [...serviceMap.matchAll(/^ ([a-z][a-z0-9-]+):$/gm)].map((match) => match[1]).sort() +} + +export function summarizeCoverage(files, serviceModules) { + const coveredServices = [...new Set(files.map((file) => file.split('/')[0]))].sort() + const codeFirstServices = serviceModules.filter((service) => !coveredServices.includes(service)) + return { coveredServices, codeFirstServices } +} + +export async function validateRepositoryOpenApi(root = repositoryRoot) { + const files = await discoverOpenApiFiles(root) + if (files.length === 0) throw new Error('No static OpenAPI documents were discovered') + const serviceMap = await readFile(path.join(root, 'deploy', 'service-map.yml'), 'utf8') + const serviceModules = parseServiceModules(serviceMap.replaceAll('\r\n', '\n')) + const coverage = summarizeCoverage(files, serviceModules) + + const failures = [] + for (const relative of files) { + try { + await SwaggerParser.validate(path.join(root, relative)) + } catch (error) { + failures.push(`${relative}: ${error instanceof Error ? error.message : String(error)}`) + } + } + if (failures.length > 0) { + throw new Error( + `Discovered ${files.length} documents across ${coverage.coveredServices.length}/${serviceModules.length} services; ` + + `code-first gaps: ${coverage.codeFirstServices.join(', ')}.\n` + + `OpenAPI validation failed for ${failures.length}/${files.length} documents:\n` + + failures.map((failure) => `- ${failure}`).join('\n') + ) + } + + return { files, serviceModules, ...coverage } +} + +async function main() { + const result = await validateRepositoryOpenApi() + process.stdout.write(`OpenAPI validation passed: ${result.files.length} documents across ${result.coveredServices.length}/${result.serviceModules.length} services.\n`) + process.stdout.write(`Static OpenAPI services: ${result.coveredServices.join(', ')}\n`) + process.stdout.write(`Code-first services without static documents: ${result.codeFirstServices.join(', ')}\n`) +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/scripts/openapi-validation/validate-openapi.test.mjs b/scripts/openapi-validation/validate-openapi.test.mjs new file mode 100644 index 000000000..4c2eaeb24 --- /dev/null +++ b/scripts/openapi-validation/validate-openapi.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import { + discoverOpenApiFiles, + parseServiceModules, + summarizeCoverage, + validateRepositoryOpenApi +} from './validate-openapi.mjs' + +test('discovers new static OpenAPI documents without a handwritten file list', async (t) => { + const root = await mkdtemp(path.join(tmpdir(), 'baiyue-openapi-discovery-')) + t.after(() => rm(root, { recursive: true, force: true })) + const openapi = path.join(root, 'new-service', 'src', 'main', 'resources', 'openapi') + const ignored = path.join(root, 'new-service', 'target', 'src', 'main', 'resources', 'openapi') + await mkdir(openapi, { recursive: true }) + await mkdir(ignored, { recursive: true }) + await writeFile(path.join(openapi, 'new-api.yaml'), 'openapi: 3.0.3\n', 'utf8') + await writeFile(path.join(openapi, 'new-api.json'), '{"openapi":"3.0.3"}\n', 'utf8') + await writeFile(path.join(openapi, 'notes.md'), 'not an OpenAPI document\n', 'utf8') + await writeFile(path.join(ignored, 'ignored.yaml'), 'openapi: 3.0.3\n', 'utf8') + + assert.deepEqual(await discoverOpenApiFiles(root), [ + 'new-service/src/main/resources/openapi/new-api.json', + 'new-service/src/main/resources/openapi/new-api.yaml' + ]) +}) + +test('derives static and code-first service coverage from service-map', () => { + const serviceModules = parseServiceModules('services:\n alpha:\n port: 1\n beta:\n port: 2\n') + assert.deepEqual(serviceModules, ['alpha', 'beta']) + assert.deepEqual(summarizeCoverage([ + 'alpha/src/main/resources/openapi/alpha.yaml' + ], serviceModules), { + coveredServices: ['alpha'], + codeFirstServices: ['beta'] + }) +}) + +test('rejects an unresolved OpenAPI reference', async (t) => { + const root = await mkdtemp(path.join(tmpdir(), 'baiyue-openapi-ref-')) + t.after(() => rm(root, { recursive: true, force: true })) + const openapi = path.join(root, 'alpha', 'src', 'main', 'resources', 'openapi') + await mkdir(openapi, { recursive: true }) + await mkdir(path.join(root, 'deploy'), { recursive: true }) + await writeFile(path.join(root, 'deploy', 'service-map.yml'), 'services:\n alpha:\n port: 1\n', 'utf8') + await writeFile(path.join(openapi, 'broken.yaml'), `openapi: 3.0.3 +info: + title: Broken API + version: 1.0.0 +paths: + /broken: + get: + responses: + '200': + description: broken + content: + application/json: + schema: + $ref: '#/components/schemas/Missing' +`, 'utf8') + + await assert.rejects(validateRepositoryOpenApi(root), /Missing \$ref pointer/) +}) From 4b5e5a1e4e706891ca9987876f3b69a0966a635a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 12:27:47 +0800 Subject: [PATCH 097/115] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitlab-ci.yml | 10 ++++ .../openapi/carrier-schedule-query-v1.yaml | 2 +- .../resources/openapi/fba-pricing-v1.yaml | 48 +++++++++++++++-- .../main/resources/openapi/firstleg-v1.yaml | 1 - .../openapi/phase3-loading-delivery-v1.yaml | 2 +- .../openapi/phase3-pda-convergence-v1.yaml | 2 +- .../openapi/truck-appointments-v1.yaml | 16 +++--- .../controller/FbaPricingContractTest.java | 51 +++++++++++++++++++ iam/src/main/resources/openapi/iam-v1.yaml | 2 +- .../resources/openapi/notification-v1.yaml | 28 ++++++---- .../NotificationOpenApiContractTest.java | 27 ++++++++++ .../src/main/resources/openapi/order-v1.yaml | 11 ++-- scripts/java-topology-contract.test.mjs | 16 ++++++ 13 files changed, 183 insertions(+), 33 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4c67d8bc7..f05dade2c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -20,6 +20,15 @@ Java拓扑合同: script: - node --test scripts/java-topology-contract.test.mjs +OpenAPI有效性: + stage: verify + image: node:22-alpine + cache: [] + script: + - npm ci --ignore-scripts --prefix scripts/openapi-validation + - npm test --prefix scripts/openapi-validation + - npm run validate --prefix scripts/openapi-validation + 校验与测试: stage: verify script: @@ -39,6 +48,7 @@ Java拓扑合同: stage: package needs: - Java拓扑合同 + - OpenAPI有效性 - 校验与测试 script: - mvn $MAVEN_CLI_OPTS -DskipTests package diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index 5cf380ab9..4f6dd8f73 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -9,7 +9,7 @@ paths: summary: List official carrier sources and compliant access modes x-permission: platform:firstleg:port-schedule-query:list responses: - '200': {description: MATSON, SML, ZIM, COSCO, EMC and HEDE descriptors} + '200': {description: 'MATSON, SML, ZIM, COSCO, EMC and HEDE descriptors'} /api/platform/v1/carrier-schedule-queries: get: summary: Query carrier schedule tasks diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index c7a74e31f..aa91a88be 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -18,7 +18,7 @@ paths: description: 成功或业务失败 content: application/json: - schema: {$ref: '#/components/schemas/CustomerRuleVersionSummary'} + schema: {$ref: '#/components/schemas/CustomerRuleVersionListApiResponse'} '403': {description: 无权限} /api/platform/v1/fba-pricing/customer-rules/versions: post: @@ -231,6 +231,44 @@ components: VersionId: {name: versionId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} schemas: + CustomerRuleVersionListApiResponse: + type: object + required: [code, message, data, timestamp] + properties: + code: {type: string, example: '200'} + message: {type: string} + data: + type: array + items: {$ref: '#/components/schemas/CustomerRuleVersionResource'} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time} + CustomerRuleVersionResource: + type: object + properties: + id: {type: string, pattern: '^[1-9][0-9]*$'} + tenantId: {type: string, pattern: '^[1-9][0-9]*$'} + ruleCode: {type: string} + ruleVersion: {type: integer} + customerId: {type: string, pattern: '^[1-9][0-9]*$'} + customerName: {type: string} + businessType: {type: string} + chargeMode: {type: string} + taxMode: {type: string} + conversionFactor: {type: number} + roundingScale: {type: integer} + roundingMode: {type: string} + currency: {type: string} + effectiveFrom: {type: string, format: date-time} + effectiveTo: {type: string, format: date-time, nullable: true} + status: {type: string} + remark: {type: string, nullable: true} + createdBy: {type: string, nullable: true} + createdAt: {type: string, format: date-time, nullable: true} + updatedBy: {type: string, nullable: true} + updatedAt: {type: string, format: date-time, nullable: true} + deleted: {type: boolean, nullable: true} + version: {type: integer, nullable: true} FeeSnapshotApiResponse: type: object required: [code, message, data] @@ -415,7 +453,7 @@ components: required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason] properties: expectedFeeVersion: {type: integer, minimum: 1} - chargeableQuantity: {type: number, exclusiveMinimum: 0, description: 计费量,单位沿用当前费用快照的 chargeUnit} + chargeableQuantity: {type: number, minimum: 0, exclusiveMinimum: true, description: 计费量,单位沿用当前费用快照的 chargeUnit} unitPrice: {type: number, minimum: 0, description: 人工确认单价} additionalAmount: {type: number, minimum: 0, multipleOf: 0.01} reason: {type: string, minLength: 1, maxLength: 500} @@ -432,7 +470,7 @@ components: businessType: {type: string, maxLength: 32} chargeMode: {type: string, enum: [KG, CBM]} taxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]} - conversionFactor: {type: number, exclusiveMinimum: 0, example: 167} + conversionFactor: {type: number, minimum: 0, exclusiveMinimum: true, example: 167} roundingScale: {type: integer, minimum: 0, maximum: 4} roundingMode: {type: string, enum: [UP, DOWN, CEILING, FLOOR, HALF_UP, HALF_DOWN, HALF_EVEN]} currency: {type: string, pattern: '^[A-Z]{3}$'} @@ -454,7 +492,7 @@ components: destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} destinationName: {type: string, maxLength: 128, nullable: true} chargeUnit: {type: string, enum: [KG, CBM]} - unitPrice: {type: number, exclusiveMinimum: 0} + unitPrice: {type: number, minimum: 0, exclusiveMinimum: true} currency: {type: string, pattern: '^[A-Z]{3}$'} additionalFee: {type: number, minimum: 0} effectiveFrom: {type: string, format: date-time} @@ -578,7 +616,7 @@ components: pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'} chargeUnit: {type: string, enum: [KG, CBM]} weightFromKg: {type: number, minimum: 0, nullable: true} - unitPrice: {type: number, exclusiveMinimum: 0} + unitPrice: {type: number, minimum: 0, exclusiveMinimum: true} currency: {type: string, pattern: '^[A-Z]{3}$'} sourceSheetName: {type: string, maxLength: 128} sourceRowNumber: {type: integer, minimum: 1} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index f38dfe72d..652cd8003 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -1112,7 +1112,6 @@ components: notificationStatus: {type: string, nullable: true} version: {type: integer, nullable: true} expectedArrivalAt: {type: string, format: date-time, nullable: true} - requestedPickupAt: {type: string, format: date-time, nullable: true, description: Customer-requested pickup appointment from fl_inbound_pickup_snapshot.pickup_at} customerId: {type: integer, format: int64, nullable: true} customerName: {type: string, nullable: true} companyId: {type: integer, format: int64, nullable: true} diff --git a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml index 60e832d45..3b70a9548 100644 --- a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml @@ -34,7 +34,7 @@ paths: content: application/json: {schema: {$ref: '#/components/schemas/UpsertEvidenceRequest'}} responses: - '200': {description: Business envelope; ownership, type and version failures use business error codes} + '200': {description: 'Business envelope; ownership, type and version failures use business error codes'} /api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/content/pda: get: summary: Download one loading evidence photo for the active PDA worker diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index eef1c0492..165d36799 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -93,7 +93,7 @@ paths: required: true content: {application/json: {schema: {$ref: '#/components/schemas/BatchRequest'}}} responses: - '200': {description: Completed task, identical replay result, or A0409 when any snapshot changed} + '200': {description: 'Completed task, identical replay result, or A0409 when any snapshot changed'} /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/review: post: summary: 人工确认装柜完成的兼容入口 diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index 4a953b89d..0d2355f8f 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -8,7 +8,7 @@ paths: get: summary: Query truck appointments parameters: - - {name: customerId, in: query, description: Filter by customer, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: customerId, in: query, description: 'Filter by customer', schema: {type: string, pattern: '^[1-9][0-9]*$'}} responses: '200': description: Paged truck appointments with stable booking/container snapshots and attachment count @@ -29,13 +29,13 @@ paths: application/json: schema: {$ref: '#/components/schemas/SaveRequest'} responses: - '200': {description: Created appointment, content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}} + '200': {description: 'Created appointment', content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}} /api/platform/v1/truck-appointments/{id}: parameters: [{$ref: '#/components/parameters/AppointmentId'}] get: summary: Query a truck appointment responses: - '200': {description: Appointment detail, content: {application/json: {schema: {$ref: '#/components/schemas/DetailStableReferences'}}}} + '200': {description: 'Appointment detail', content: {application/json: {schema: {$ref: '#/components/schemas/DetailStableReferences'}}}} put: summary: Update an editable truck appointment with optimistic locking requestBody: @@ -84,20 +84,20 @@ paths: get: summary: Query immutable appointment status history responses: - '200': {description: History entries including action, from/to status, operator, reason and time} + '200': {description: 'History entries including action, from/to status, operator, reason and time'} /api/platform/v1/truck-appointments/{id}/attachments: parameters: [{$ref: '#/components/parameters/AppointmentId'}] get: summary: Query attachment references owned by the appointment description: 'Requires platform:firstleg:truck-appointment:list.' responses: - '200': {description: Attachment references, content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/Attachment'}}}}} + '200': {description: 'Attachment references', content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/Attachment'}}}}} post: summary: Reference a file already owned by the appointment customer and business scope description: 'Requires platform:firstleg:truck-appointment:edit. The file must belong to customerId + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId.' requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/AttachmentRequest'}}}} responses: - '200': {description: Created attachment reference, content: {application/json: {schema: {$ref: '#/components/schemas/Attachment'}}}} + '200': {description: 'Created attachment reference', content: {application/json: {schema: {$ref: '#/components/schemas/Attachment'}}}} /api/platform/v1/truck-appointments/{id}/attachments/upload: parameters: [{$ref: '#/components/parameters/AppointmentId'}] post: @@ -181,8 +181,8 @@ components: required: [customerId, carrierId, plannedLoadingAt, expectedVersion] properties: outboundOrderId: {type: integer, format: int64, minimum: 1, nullable: true} - oceanBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional booking association; no RELEASED-state prerequisite. When supplied, customer and warehouse must match.} - containerBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional ACTIVE container association. When supplied, customer and warehouse must match.} + oceanBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: 'Optional booking association; no RELEASED-state prerequisite. When supplied, customer and warehouse must match.'} + containerBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: 'Optional ACTIVE container association. When supplied, customer and warehouse must match.'} loadingPlanId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional loading-plan association. It may be supplied only with containerBookingId and must be that container's current plan.} warehouseId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional warehouse scope. Linked booking/container warehouse facts must agree; when omitted the linked fact is frozen if available.} customerId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE IAM customer used for isolation and attachment ownership.} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java index 956e14f4a..a3362b6cc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java @@ -4,9 +4,12 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import org.springframework.web.bind.annotation.RequestParam; +import org.yaml.snakeyaml.Yaml; import java.nio.charset.StandardCharsets; import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -82,9 +85,57 @@ class FbaPricingContractTest { .doesNotContain("format: int64"); } + @Test + @DisplayName("客户规则列表响应锁定数组包络和实体核心字段") + void freezesCustomerRuleVersionListResponse() throws Exception { + String source = new ClassPathResource("openapi/fba-pricing-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + Map api = map(new Yaml().load(source)); + Map paths = map(api.get("paths")); + Map get = map(map(paths.get( + "/api/platform/v1/fba-pricing/customer-rules")).get("get")); + Map success = map(map(get.get("responses")).get("200")); + Map responseSchema = map(map(map(success.get("content")) + .get("application/json")).get("schema")); + assertThat(responseSchema).containsEntry( + "$ref", "#/components/schemas/CustomerRuleVersionListApiResponse"); + + Map schemas = map(map(api.get("components")).get("schemas")); + Map envelope = map(schemas.get("CustomerRuleVersionListApiResponse")); + assertThat(list(envelope.get("required"))).containsExactly("code", "message", "data", "timestamp"); + Map envelopeProperties = map(envelope.get("properties")); + assertThat(envelopeProperties).containsOnlyKeys( + "code", "message", "data", "requestId", "traceId", "timestamp"); + Map data = map(envelopeProperties.get("data")); + assertThat(data).containsEntry("type", "array"); + assertThat(map(data.get("items"))).containsEntry( + "$ref", "#/components/schemas/CustomerRuleVersionResource"); + + Map resource = map(schemas.get("CustomerRuleVersionResource")); + assertThat(map(resource.get("properties"))).containsOnlyKeys( + "id", "tenantId", "ruleCode", "ruleVersion", "customerId", "customerName", + "businessType", "chargeMode", "taxMode", "conversionFactor", "roundingScale", + "roundingMode", "currency", "effectiveFrom", "effectiveTo", "status", "remark", + "createdBy", "createdAt", "updatedBy", "updatedAt", "deleted", "version"); + for (String longField : List.of("id", "tenantId", "customerId", "createdBy", "updatedBy")) { + assertThat(map(map(resource.get("properties")).get(longField))).containsEntry("type", "string"); + } + assertThat(source).doesNotContain("CustomerRuleVersionSummary"); + } + private static Method method(String name) { return java.util.Arrays.stream(PlatformFbaMasterDataController.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(name)) .findFirst().orElseThrow(); } + + @SuppressWarnings("unchecked") + private static Map map(Object value) { + return (Map) value; + } + + @SuppressWarnings("unchecked") + private static List list(Object value) { + return (List) value; + } } diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index fe88bdc06..4fa22a7e6 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -886,7 +886,7 @@ components: TenantCreationSuccess: {description: Enterprise tenant and one-time owner credentials returned / 返回企业客户及一次性管理员凭据, content: {application/json: {schema: {$ref: '#/components/schemas/TenantCreationApiResponse'}}}} BadRequest: {description: Invalid request / 请求参数错误, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} Unauthorized: {description: Authentication required / 需要登录, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} - Forbidden: {description: Permission, customer scope or platform technical scope rejected / 权限、客户范围或平台技术作用域不匹配, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} + Forbidden: {description: 'Permission, customer scope or platform technical scope rejected / 权限、客户范围或平台技术作用域不匹配', content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} NotFound: {description: Resource not found / 资源不存在, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} Conflict: {description: Resource conflict / 资源冲突, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} ServiceUnavailable: {description: Authentication service unavailable / 认证服务不可用, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} diff --git a/iam/src/main/resources/openapi/notification-v1.yaml b/iam/src/main/resources/openapi/notification-v1.yaml index 99f5f992f..e184472d0 100644 --- a/iam/src/main/resources/openapi/notification-v1.yaml +++ b/iam/src/main/resources/openapi/notification-v1.yaml @@ -60,7 +60,7 @@ paths: - {$ref: '#/components/parameters/Size'} - {name: templateCode, in: query, schema: {type: string, maxLength: 64}} - {name: category, in: query, schema: {type: string, maxLength: 64}} - - {name: title, in: query, description: Fuzzy match template name or title template, schema: {type: string, maxLength: 128}} + - {name: title, in: query, description: 'Fuzzy match template name or title template', schema: {type: string, maxLength: 128}} - {name: status, in: query, schema: {type: string, enum: [DRAFT, ACTIVE, DISABLED]}} responses: {'200': {$ref: '#/components/responses/TemplatePage'}} post: @@ -92,7 +92,7 @@ paths: - {$ref: '#/components/parameters/Size'} - {name: taskNo, in: query, schema: {type: string, maxLength: 64}} - {name: category, in: query, schema: {type: string, maxLength: 64}} - - {name: title, in: query, description: Fuzzy match rendered title, schema: {type: string, maxLength: 128}} + - {name: title, in: query, description: 'Fuzzy match rendered title', schema: {type: string, maxLength: 128}} - {name: status, in: query, schema: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}} responses: {'200': {$ref: '#/components/responses/TaskPage'}} /platform/v1/notification-tasks/{id}: @@ -145,17 +145,27 @@ components: tenantId: {type: integer, format: int64, minimum: 0} templateCode: {type: string, maxLength: 64} recipientType: {type: string, enum: [USER, MEMBERSHIP, TENANT_ROLE, TENANT_ALL, PLATFORM_ROLE]} - recipientIds: {type: array, maxItems: 1000, items: {type: integer, format: int64}, description: Required for USER or MEMBERSHIP; forbidden for role/all recipient types.} - roleCode: {type: string, maxLength: 64, description: Required for TENANT_ROLE or PLATFORM_ROLE; forbidden otherwise.} + recipientIds: {type: array, minItems: 1, maxItems: 1000, items: {type: integer, format: int64}, description: Required for USER or MEMBERSHIP; forbidden for role/all recipient types.} + roleCode: {type: string, minLength: 1, maxLength: 64, description: Required for TENANT_ROLE or PLATFORM_ROLE; forbidden otherwise.} variables: {type: object, maxProperties: 32, additionalProperties: {type: string, maxLength: 2000}} scheduledAt: {type: string, format: date-time, nullable: true} idempotencyKey: {type: string, minLength: 8, maxLength: 128} sourceEventId: {type: string, maxLength: 128, nullable: true} - allOf: - - if: {properties: {recipientType: {enum: [USER, MEMBERSHIP]}}} - then: {required: [recipientIds]} - - if: {properties: {recipientType: {enum: [TENANT_ROLE, PLATFORM_ROLE]}}} - then: {required: [roleCode]} + oneOf: + - required: [recipientIds] + properties: + recipientType: {type: string, enum: [USER, MEMBERSHIP]} + not: {required: [roleCode]} + - required: [roleCode] + properties: + recipientType: {type: string, enum: [TENANT_ROLE, PLATFORM_ROLE]} + not: {required: [recipientIds]} + - properties: + recipientType: {type: string, enum: [TENANT_ALL]} + not: + anyOf: + - required: [recipientIds] + - required: [roleCode] CreateTemplateRequest: type: object required: [templateCode, templateName, category, titleTemplate, bodyTemplate, variableWhitelist] diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java index af6a9a172..d514112cd 100644 --- a/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java @@ -69,6 +69,33 @@ class NotificationOpenApiContractTest { assertThat(map(map(taskDetail.get("properties")).get("attemptCount"))) .containsEntry("minimum", 0).containsEntry("maximum", 3); assertThat(document).contains("scheduler delivery starts after the retry delay"); + + Map createNotification = map(schemas.get("CreateNotificationRequest")); + Map selectorProperties = map(createNotification.get("properties")); + assertThat(map(selectorProperties.get("recipientIds"))).containsEntry("minItems", 1); + assertThat(map(selectorProperties.get("roleCode"))).containsEntry("minLength", 1); + List selectorBranches = list(createNotification.get("oneOf")); + assertThat(selectorBranches).hasSize(3); + + Map directRecipients = map(selectorBranches.get(0)); + assertThat(list(directRecipients.get("required"))).containsExactly("recipientIds"); + assertThat(list(map(directRecipients.get("not")).get("required"))).containsExactly("roleCode"); + assertThat(list(map(map(directRecipients.get("properties")).get("recipientType")).get("enum"))) + .containsExactly("USER", "MEMBERSHIP"); + + Map roleRecipients = map(selectorBranches.get(1)); + assertThat(list(roleRecipients.get("required"))).containsExactly("roleCode"); + assertThat(list(map(roleRecipients.get("not")).get("required"))).containsExactly("recipientIds"); + assertThat(list(map(map(roleRecipients.get("properties")).get("recipientType")).get("enum"))) + .containsExactly("TENANT_ROLE", "PLATFORM_ROLE"); + + Map allRecipients = map(selectorBranches.get(2)); + assertThat(list(map(map(allRecipients.get("properties")).get("recipientType")).get("enum"))) + .containsExactly("TENANT_ALL"); + assertThat(list(map(allRecipients.get("not")).get("anyOf"))) + .extracting(value -> list(map(value).get("required"))) + .containsExactly(List.of("recipientIds"), List.of("roleCode")); + assertThat(document).doesNotContain("if:").doesNotContain("then:"); } private void assertRequestBody(Map paths, String path, String method) { diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index b015fdb8b..114b1e06e 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -379,6 +379,11 @@ paths: '403': {description: 需要 merchant:order:view 权限} components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT schemas: TenantOrderStatisticsResponse: type: object @@ -391,12 +396,6 @@ components: nextMonthStart: {type: string, format: date-time} zoneId: {type: string, example: Asia/Shanghai} asOf: {type: string, format: date-time} - securitySchemes: - bearerAuth: - type: http - scheme: bearer - bearerFormat: JWT - schemas: UnifiedMainStatus: type: string enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED] diff --git a/scripts/java-topology-contract.test.mjs b/scripts/java-topology-contract.test.mjs index 736e5e8af..371e7053f 100644 --- a/scripts/java-topology-contract.test.mjs +++ b/scripts/java-topology-contract.test.mjs @@ -111,6 +111,22 @@ test('GitLab runs the topology contract in a dedicated Node job', async () => { assert.match(packageJob, /^ - Java拓扑合同$/m) }) +test('GitLab validates every static OpenAPI document before packaging', async () => { + const gitlab = await read('.gitlab-ci.yml') + const openapiJob = gitlab.match(/^OpenAPI有效性:\n([\s\S]*?)(?=^[^\s#].*:\n)/m)?.[1] ?? '' + const packageJob = gitlab.match(/^打包服务:\n([\s\S]*)/m)?.[1] ?? '' + + assert.match(openapiJob, /^ stage: verify$/m) + assert.match(openapiJob, /^ image: node:[^\s]+$/m) + assert.match(openapiJob, /^ - npm ci --ignore-scripts --prefix scripts\/openapi-validation$/m) + assert.match(openapiJob, /^ - npm test --prefix scripts\/openapi-validation$/m) + assert.match(openapiJob, /^ - npm run validate --prefix scripts\/openapi-validation$/m) + assert.doesNotMatch(openapiJob, /\bnpx\b|@latest/) + assert.match(packageJob, /^ - Java拓扑合同$/m) + assert.match(packageJob, /^ - OpenAPI有效性$/m) + assert.match(packageJob, /^ - 校验与测试$/m) +}) + test('Jenkins cannot invoke the Node contract without declaring a Node runtime', async () => { for (const path of ['Jenkinsfile', 'Jenkinsfile.service']) { const pipeline = await read(path) From 12dad6673df6dec110ef778c900fca16af2d38f4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 12:29:47 +0800 Subject: [PATCH 098/115] fix(lastleg): register PDA operation mappers --- .../pda/PdaOperationsMapperConfiguration.java | 15 ++++++++++++++ .../PdaOperationsMapperConfigurationTest.java | 20 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/PdaOperationsMapperConfiguration.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsMapperConfigurationTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/PdaOperationsMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/PdaOperationsMapperConfiguration.java new file mode 100644 index 000000000..56392c5f7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/PdaOperationsMapperConfiguration.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.pda; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +/** Registers mapper packages used by the overseas PDA warehouse operation workflows. */ +@Configuration +@MapperScan(basePackages = { + "com.baiyue.lastleg.palletops.mapper", + "com.baiyue.lastleg.pda.move.mapper", + "com.baiyue.lastleg.pda.stocktake.mapper", + "com.baiyue.lastleg.pda.warehouse.mapper" +}) +public class PdaOperationsMapperConfiguration { +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsMapperConfigurationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsMapperConfigurationTest.java new file mode 100644 index 000000000..91c76f380 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsMapperConfigurationTest.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.pda; + +import org.junit.jupiter.api.Test; +import org.mybatis.spring.annotation.MapperScan; + +import static org.assertj.core.api.Assertions.assertThat; + +class PdaOperationsMapperConfigurationTest { + @Test + void scansEveryPdaWarehouseOperationMapperPackage() { + MapperScan mapperScan = PdaOperationsMapperConfiguration.class.getAnnotation(MapperScan.class); + + assertThat(mapperScan.basePackages()).containsExactlyInAnyOrder( + "com.baiyue.lastleg.palletops.mapper", + "com.baiyue.lastleg.pda.move.mapper", + "com.baiyue.lastleg.pda.stocktake.mapper", + "com.baiyue.lastleg.pda.warehouse.mapper" + ); + } +} From 1382902f86e74ba5fd75d9217549737a6168bb7a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 12:30:07 +0800 Subject: [PATCH 099/115] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common-web/AGENTS.md | 2 + .../baiyue/common/web/CorrelationContext.java | 11 + .../common/web/CorrelationIdFilter.java | 53 +++- .../com/baiyue/common/web/TraceParent.java | 84 +++++ .../web/CommonWebAutoConfigurationTest.java | 4 + .../common/web/CorrelationIdFilterTest.java | 74 ++++- .../baiyue/common/web/TraceParentTest.java | 46 +++ config/nacos/overseas/lastleg-service.yml | 14 + .../scripts/analyze-lastleg-dependencies.mjs | 295 ++++++++++++++++++ .../config/LastlegRestClientFactory.java | 21 ++ .../config/LastlegRestClientProperties.java | 54 ++++ .../gateway/HttpUnloadPhotoFileGateway.java | 9 +- .../gateway/HttpWarehouseResourceGateway.java | 7 +- .../gateway/HttpPalletPhysicalGateway.java | 9 +- .../gateway/HttpRelabelPhotoFileGateway.java | 9 +- .../src/main/resources/application.yml | 14 + 16 files changed, 687 insertions(+), 19 deletions(-) create mode 100644 common-web/src/main/java/com/baiyue/common/web/TraceParent.java create mode 100644 common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java create mode 100644 lastleg-service/scripts/analyze-lastleg-dependencies.mjs create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index 23b2f7310..19fdeacb0 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -11,6 +11,7 @@ |---|---| | 链路上下文 | `src/main/java/com/baiyue/common/web/CorrelationContext.java` | | 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` | +| W3C Trace Context | `src/main/java/com/baiyue/common/web/TraceParent.java`;严格接收 version 00,响应 `traceparent`、`X-Trace-Id` 与 MDC `traceId` 使用同一 trace ID | | 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 | | 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 | | 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` | @@ -23,5 +24,6 @@ - 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503、Audit 202 与 Event Bridge 内部鉴权仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。 - 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。 - 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。 +- 入站 Trace 优先级固定为:合法单值 `traceparent` > 完全缺失 `traceparent` 时的 32 位非零十六进制 `X-Trace-Id` > 新 trace。只要 `traceparent` 已出现但重复、超长、格式非法或含全零 ID,就必须生成新 trace,不得回退信任 `X-Trace-Id`;请求 ID 独立按 `X-Request-Id` 校验或生成。 - 从父目录运行 `mvn -pl common-web -am test`。 - 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。 diff --git a/common-web/src/main/java/com/baiyue/common/web/CorrelationContext.java b/common-web/src/main/java/com/baiyue/common/web/CorrelationContext.java index 976ab0b30..a38dfaf80 100644 --- a/common-web/src/main/java/com/baiyue/common/web/CorrelationContext.java +++ b/common-web/src/main/java/com/baiyue/common/web/CorrelationContext.java @@ -9,8 +9,11 @@ import org.slf4j.MDC; public final class CorrelationContext { public static final String REQUEST_ID = "requestId"; public static final String TRACE_ID = "traceId"; + public static final String SPAN_ID = "spanId"; + public static final String TRACE_PARENT = "traceparent"; public static final String REQUEST_ID_HEADER = "X-Request-Id"; public static final String TRACE_ID_HEADER = "X-Trace-Id"; + public static final String TRACE_PARENT_HEADER = TraceParent.HEADER; private CorrelationContext() {} @@ -22,6 +25,14 @@ public final class CorrelationContext { return value(request, TRACE_ID); } + public static String spanId(HttpServletRequest request) { + return value(request, SPAN_ID); + } + + public static String traceParent(HttpServletRequest request) { + return value(request, TRACE_PARENT); + } + private static String value(HttpServletRequest request, String key) { Object attribute = request.getAttribute(key); return attribute == null ? MDC.get(key) : attribute.toString(); diff --git a/common-web/src/main/java/com/baiyue/common/web/CorrelationIdFilter.java b/common-web/src/main/java/com/baiyue/common/web/CorrelationIdFilter.java index b08f7c832..f9e39379d 100644 --- a/common-web/src/main/java/com/baiyue/common/web/CorrelationIdFilter.java +++ b/common-web/src/main/java/com/baiyue/common/web/CorrelationIdFilter.java @@ -11,6 +11,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.Enumeration; import java.util.UUID; import java.util.regex.Pattern; @@ -25,24 +26,60 @@ public class CorrelationIdFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String requestId = acceptedOrGenerated(request.getHeader(CorrelationContext.REQUEST_ID_HEADER), false); - String traceId = acceptedOrGenerated(request.getHeader(CorrelationContext.TRACE_ID_HEADER), true); + String requestId = requestId(request); + TraceParent traceParent = traceParent(request); + String traceId = traceParent.traceId(); request.setAttribute(CorrelationContext.REQUEST_ID, requestId); request.setAttribute(CorrelationContext.TRACE_ID, traceId); + request.setAttribute(CorrelationContext.SPAN_ID, traceParent.parentId()); + request.setAttribute(CorrelationContext.TRACE_PARENT, traceParent.value()); response.setHeader(CorrelationContext.REQUEST_ID_HEADER, requestId); response.setHeader(CorrelationContext.TRACE_ID_HEADER, traceId); + response.setHeader(CorrelationContext.TRACE_PARENT_HEADER, traceParent.value()); try (MDC.MDCCloseable ignoredRequest = MDC.putCloseable(CorrelationContext.REQUEST_ID, requestId); - MDC.MDCCloseable ignoredTrace = MDC.putCloseable(CorrelationContext.TRACE_ID, traceId)) { + MDC.MDCCloseable ignoredTrace = MDC.putCloseable(CorrelationContext.TRACE_ID, traceId); + MDC.MDCCloseable ignoredSpan = MDC.putCloseable(CorrelationContext.SPAN_ID, traceParent.parentId())) { filterChain.doFilter(request, response); } } - private String acceptedOrGenerated(String candidate, boolean compact) { - if (candidate != null && SAFE_ID.matcher(candidate).matches()) { - return candidate; + private String requestId(HttpServletRequest request) { + HeaderValue header = singleHeader(request, CorrelationContext.REQUEST_ID_HEADER, 128); + if (header.valid() && SAFE_ID.matcher(header.value()).matches()) { + return header.value(); + } + return UUID.randomUUID().toString(); + } + + private TraceParent traceParent(HttpServletRequest request) { + HeaderValue standard = singleHeader(request, CorrelationContext.TRACE_PARENT_HEADER, + TraceParent.HEADER_LENGTH); + if (standard.present()) { + if (standard.valid()) { + return TraceParent.parse(standard.value()).map(TraceParent::childOf) + .orElseGet(() -> TraceParent.newRoot(null)); + } + return TraceParent.newRoot(null); + } + + HeaderValue legacy = singleHeader(request, CorrelationContext.TRACE_ID_HEADER, 32); + return TraceParent.newRoot(legacy.valid() ? legacy.value() : null); + } + + private HeaderValue singleHeader(HttpServletRequest request, String name, int maxLength) { + Enumeration values = request.getHeaders(name); + if (values == null || !values.hasMoreElements()) { + return HeaderValue.missing(); + } + String value = values.nextElement(); + boolean duplicate = values.hasMoreElements(); + return new HeaderValue(true, !duplicate && value != null && value.length() <= maxLength, value); + } + + private record HeaderValue(boolean present, boolean valid, String value) { + private static HeaderValue missing() { + return new HeaderValue(false, false, null); } - String id = UUID.randomUUID().toString(); - return compact ? id.replace("-", "") : id; } } diff --git a/common-web/src/main/java/com/baiyue/common/web/TraceParent.java b/common-web/src/main/java/com/baiyue/common/web/TraceParent.java new file mode 100644 index 000000000..2fbd3a1ab --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/TraceParent.java @@ -0,0 +1,84 @@ +package com.baiyue.common.web; + +import java.security.SecureRandom; +import java.util.HexFormat; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Strict W3C {@code traceparent} version 00 value. + * + *

This type deliberately accepts only the currently frozen version-00 shape. Unsupported, + * ambiguous or malformed input must start a new trace instead of being partially trusted.

+ */ +public record TraceParent(String traceId, String parentId, String traceFlags) { + public static final String HEADER = "traceparent"; + public static final int HEADER_LENGTH = 55; + + private static final Pattern VERSION_00 = Pattern.compile( + "00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})"); + private static final Pattern LEGACY_TRACE_ID = Pattern.compile("[0-9A-Fa-f]{32}"); + private static final String ZERO_TRACE_ID = "0".repeat(32); + private static final String ZERO_PARENT_ID = "0".repeat(16); + private static final SecureRandom RANDOM = new SecureRandom(); + private static final HexFormat HEX = HexFormat.of(); + + public TraceParent { + if (!validTraceId(traceId) || !validParentId(parentId) + || traceFlags == null || !traceFlags.matches("[0-9a-f]{2}")) { + throw new IllegalArgumentException("Invalid W3C traceparent components"); + } + } + + /** Parses one exact, lowercase W3C version-00 header value. */ + public static Optional parse(String value) { + if (value == null || value.length() != HEADER_LENGTH) { + return Optional.empty(); + } + Matcher matcher = VERSION_00.matcher(value); + if (!matcher.matches() || !validTraceId(matcher.group(1)) || !validParentId(matcher.group(2))) { + return Optional.empty(); + } + return Optional.of(new TraceParent(matcher.group(1), matcher.group(2), matcher.group(3))); + } + + /** Creates the local inbound context for a valid remote parent. */ + public static TraceParent childOf(TraceParent remoteParent) { + return new TraceParent(remoteParent.traceId(), randomNonZero(8), remoteParent.traceFlags()); + } + + /** Creates a new root context, optionally reusing a valid legacy trace ID. */ + public static TraceParent newRoot(String legacyTraceId) { + String traceId = validLegacyTraceId(legacyTraceId) + ? legacyTraceId.toLowerCase(java.util.Locale.ROOT) : randomNonZero(16); + return new TraceParent(traceId, randomNonZero(8), "00"); + } + + public static boolean validLegacyTraceId(String value) { + return value != null && LEGACY_TRACE_ID.matcher(value).matches() + && !ZERO_TRACE_ID.equalsIgnoreCase(value); + } + + public String value() { + return "00-" + traceId + '-' + parentId + '-' + traceFlags; + } + + private static boolean validTraceId(String value) { + return value != null && value.matches("[0-9a-f]{32}") && !ZERO_TRACE_ID.equals(value); + } + + private static boolean validParentId(String value) { + return value != null && value.matches("[0-9a-f]{16}") && !ZERO_PARENT_ID.equals(value); + } + + private static String randomNonZero(int byteCount) { + byte[] bytes = new byte[byteCount]; + String value; + do { + RANDOM.nextBytes(bytes); + value = HEX.formatHex(bytes); + } while (value.chars().allMatch(character -> character == '0')); + return value; + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/CommonWebAutoConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/CommonWebAutoConfigurationTest.java index bab2952df..08739af98 100644 --- a/common-web/src/test/java/com/baiyue/common/web/CommonWebAutoConfigurationTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/CommonWebAutoConfigurationTest.java @@ -1,6 +1,8 @@ package com.baiyue.common.web; import org.junit.jupiter.api.Test; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -16,6 +18,8 @@ class CommonWebAutoConfigurationTest { contextRunner.run(context -> { assertThat(context).hasSingleBean(GlobalExceptionHandler.class); assertThat(context).hasSingleBean(CorrelationIdFilter.class); + assertThat(CorrelationIdFilter.class.getAnnotation(Order.class).value()) + .isEqualTo(Ordered.HIGHEST_PRECEDENCE); }); } diff --git a/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java b/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java index 08bb2e583..c73051fbe 100644 --- a/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java @@ -6,6 +6,8 @@ import org.springframework.mock.web.MockFilterChain; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import java.io.IOException; + import static org.assertj.core.api.Assertions.assertThat; @DisplayName("请求链路标识过滤功能") @@ -13,27 +15,85 @@ class CorrelationIdFilterTest { private final CorrelationIdFilter filter = new CorrelationIdFilter(); @Test - void preservesSafeIncomingIds() throws Exception { + void validTraceParentWinsAndMapsToLegacyTraceIdAndMdc() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest(); request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "req-123"); - request.addHeader(CorrelationContext.TRACE_ID_HEADER, "trace-123"); + request.addHeader(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + request.addHeader(CorrelationContext.TRACE_PARENT_HEADER, + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"); MockHttpServletResponse response = new MockHttpServletResponse(); - filter.doFilter(request, response, new MockFilterChain()); + CapturingFilterChain chain = new CapturingFilterChain(); + filter.doFilter(request, response, chain); assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER)).isEqualTo("req-123"); - assertThat(request.getAttribute(CorrelationContext.TRACE_ID)).isEqualTo("trace-123"); + assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER)) + .isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(response.getHeader(CorrelationContext.TRACE_PARENT_HEADER)) + .matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); + assertThat(chain.traceId).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(chain.spanId).matches("[0-9a-f]{16}").isNotEqualTo("0000000000000000"); } @Test - void replacesUnsafeIncomingIds() throws Exception { + void fallsBackToValidLegacyTraceIdOnlyWhenTraceParentIsMissing() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest(); - request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "invalid value\n"); + request.addHeader(CorrelationContext.TRACE_ID_HEADER, "ABCDEF0123456789ABCDEF0123456789"); MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(request, response, new MockFilterChain()); + + assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER)) + .isEqualTo("abcdef0123456789abcdef0123456789"); + assertThat(response.getHeader(CorrelationContext.TRACE_PARENT_HEADER)) + .matches("00-abcdef0123456789abcdef0123456789-[0-9a-f]{16}-00"); + } + + @Test + void invalidTraceParentStartsNewTraceWithoutTrustingLegacyTraceId() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(CorrelationContext.TRACE_PARENT_HEADER, + "00-00000000000000000000000000000000-00f067aa0ba902b7-01"); + request.addHeader(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, new MockFilterChain()); + + assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER)) + .matches("[0-9a-f]{32}") + .isNotEqualTo("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .isNotEqualTo("00000000000000000000000000000000"); + } + + @Test + void rejectsDuplicateAndOversizedHeaders() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "a".repeat(129)); + request.addHeader(CorrelationContext.TRACE_PARENT_HEADER, + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"); + request.addHeader(CorrelationContext.TRACE_PARENT_HEADER, + "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, new MockFilterChain()); + assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER)) .matches("[0-9a-f-]{36}"); assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER)) - .matches("[0-9a-f]{32}"); + .matches("[0-9a-f]{32}") + .isNotEqualTo("4bf92f3577b34da6a3ce929d0e0e4736") + .isNotEqualTo("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + } + + private static final class CapturingFilterChain extends MockFilterChain { + private String traceId; + private String spanId; + + @Override + public void doFilter(jakarta.servlet.ServletRequest request, + jakarta.servlet.ServletResponse response) throws IOException { + traceId = org.slf4j.MDC.get(CorrelationContext.TRACE_ID); + spanId = org.slf4j.MDC.get(CorrelationContext.SPAN_ID); + } } } diff --git a/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java b/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java new file mode 100644 index 000000000..c97edfa34 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java @@ -0,0 +1,46 @@ +package com.baiyue.common.web; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TraceParentTest { + + @Test + void parsesCanonicalVersion00AndCreatesLocalChild() { + TraceParent remote = TraceParent.parse( + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01").orElseThrow(); + + TraceParent local = TraceParent.childOf(remote); + + assertThat(local.traceId()).isEqualTo(remote.traceId()); + assertThat(local.parentId()).matches("[0-9a-f]{16}") + .isNotEqualTo(remote.parentId()) + .isNotEqualTo("0000000000000000"); + assertThat(local.traceFlags()).isEqualTo("01"); + assertThat(local.value()).hasSize(TraceParent.HEADER_LENGTH); + } + + @Test + void rejectsNonCanonicalUnsupportedAndZeroIdentifiers() { + assertThat(TraceParent.parse( + "00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01")).isEmpty(); + assertThat(TraceParent.parse( + "01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")).isEmpty(); + assertThat(TraceParent.parse( + "00-00000000000000000000000000000000-00f067aa0ba902b7-01")).isEmpty(); + assertThat(TraceParent.parse( + "00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01")).isEmpty(); + assertThat(TraceParent.parse("x".repeat(10_000))).isEmpty(); + } + + @Test + void createsCanonicalNonZeroRootAndNormalizesLegacyHex() { + TraceParent root = TraceParent.newRoot("ABCDEF0123456789ABCDEF0123456789"); + + assertThat(root.traceId()).isEqualTo("abcdef0123456789abcdef0123456789"); + assertThat(root.parentId()).matches("[0-9a-f]{16}").isNotEqualTo("0000000000000000"); + assertThat(root.value()).matches( + "00-abcdef0123456789abcdef0123456789-[0-9a-f]{16}-00"); + } +} diff --git a/config/nacos/overseas/lastleg-service.yml b/config/nacos/overseas/lastleg-service.yml index e6241c4b6..c4d919be3 100644 --- a/config/nacos/overseas/lastleg-service.yml +++ b/config/nacos/overseas/lastleg-service.yml @@ -10,6 +10,20 @@ lastleg: smart-dispatch-api: enabled: true baiyue: + http: + clients: + unload-photo-file: + connect-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_READ_TIMEOUT:30s} + relabel-photo-file: + connect-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_READ_TIMEOUT:30s} + warehouse-resource: + connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s} + pallet-physical: + connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s} clients: domestic-warehouse: url: ${DOMESTIC_WAREHOUSE_URL} diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs new file mode 100644 index 000000000..378e380e8 --- /dev/null +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs @@ -0,0 +1,295 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const moduleRoot = path.resolve(import.meta.dirname, '..'); +const sourceRoots = { + main: path.join(moduleRoot, 'src', 'main', 'java'), + test: path.join(moduleRoot, 'src', 'test', 'java'), +}; +const internalPrefix = 'com.baiyue.lastleg.'; + +const sliceRules = [ + ['inbound-pda', /(?:^|\.)(?:inboundplan|palletops|pda)(?:\.|$)/], + ['inventory', /(?:^|\.)inventory(?:\.|$)/], + ['dispatch-delivery', /(?:^|\.)(?:dispatch|delivery|outbound)(?:\.|$)/], + ['relabel-transfer-discard', /(?:^|\.)(?:relabel|transfer|discard)(?:\.|$)/], + ['dropship', /(?:^|\.)dropship(?:\.|$)/], + ['procurement-trucking-looseboard', /(?:^|\.)(?:procurement|trucking|looseboard)(?:\.|$)/], + ['overseas-master-data', /(?:^|\.)masterdata(?:\.|$)/], + ['tailleg-handoff', /(?:^|\.)(?:controller|dto|entity|mapper|model|repository|service|shipping)(?:\.|$)/], +]; + +function parseArgs(argv) { + const options = { sourceSet: 'main', format: 'json', output: null, check: null }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--source-set') options.sourceSet = argv[++index]; + else if (argument === '--format') options.format = argv[++index]; + else if (argument === '--output') options.output = argv[++index]; + else if (argument === '--check') options.check = argv[++index]; + else if (argument === '--help') { + process.stdout.write('Usage: node scripts/analyze-lastleg-dependencies.mjs [--source-set main|test|all] [--format json|markdown|dot] [--output FILE] [--check CONTRACT.json]\n'); + process.exit(0); + } else throw new Error(`Unknown argument: ${argument}`); + } + if (!['main', 'test', 'all'].includes(options.sourceSet)) throw new Error(`Invalid source set: ${options.sourceSet}`); + if (!['json', 'markdown', 'dot'].includes(options.format)) throw new Error(`Invalid format: ${options.format}`); + return options; +} + +function walkJavaFiles(root) { + if (!fs.existsSync(root)) return []; + const result = []; + const pending = [root]; + while (pending.length > 0) { + const current = pending.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const absolute = path.join(current, entry.name); + if (entry.isDirectory()) pending.push(absolute); + else if (entry.isFile() && entry.name.endsWith('.java')) result.push(absolute); + } + } + return result.sort(); +} + +function stripCommentsAndStrings(source) { + return source + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/\/\/[^\r\n]*/g, ' ') + .replace(/"(?:\\.|[^"\\])*"/g, '""') + .replace(/'(?:\\.|[^'\\])*'/g, "''"); +} + +function classifySlice(fqcn) { + for (const [slice, expression] of sliceRules) { + if (expression.test(fqcn)) return slice; + } + return 'shared-technical'; +} + +function classifyKind(fqcn) { + const packageName = fqcn.slice(0, fqcn.lastIndexOf('.')); + if (/(?:^|\.)(?:client|gateway)(?:\.|$)/.test(packageName)) return 'cross-service-adapter'; + if (/(?:^|\.)(?:adapter|controller|repository|mapper|config|security)(?:\.|$)/.test(packageName)) return 'technical-adapter'; + if (/(?:^|\.)(?:entity|dto|model)(?:\.|$)/.test(packageName)) return 'data-model'; + return 'business-application'; +} + +function parseSource(file, sourceSet) { + const raw = fs.readFileSync(file, 'utf8'); + const source = stripCommentsAndStrings(raw); + const packageName = source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1]; + const typeName = path.basename(file, '.java'); + if (!packageName) throw new Error(`Missing package declaration: ${file}`); + const fqcn = `${packageName}.${typeName}`; + const imports = [...source.matchAll(/\bimport\s+(?:static\s+)?([\w.]+)(?:\.\*)?\s*;/g)].map(match => match[1]); + return { + file: path.relative(moduleRoot, file).replaceAll(path.sep, '/'), + sourceSet, + packageName, + typeName, + fqcn, + imports, + source, + slice: classifySlice(fqcn), + kind: classifyKind(fqcn), + }; +} + +function collectSources(sourceSet) { + const selected = sourceSet === 'all' ? ['main', 'test'] : [sourceSet]; + return selected.flatMap(set => walkJavaFiles(sourceRoots[set]).map(file => parseSource(file, set))); +} + +function resolveDependencies(sources) { + const knownTypes = new Map(sources.map(source => [source.fqcn, source])); + const typesByPackage = new Map(); + for (const source of sources) { + if (!typesByPackage.has(source.packageName)) typesByPackage.set(source.packageName, []); + typesByPackage.get(source.packageName).push(source); + } + const dependencies = []; + const seen = new Set(); + for (const source of sources) { + const candidates = new Set(); + for (const imported of source.imports) { + if (knownTypes.has(imported)) candidates.add(imported); + else if (imported.startsWith(internalPrefix)) { + const owner = [...knownTypes.keys()].find(type => imported.startsWith(`${type}.`)); + if (owner) candidates.add(owner); + } + } + for (const samePackageType of typesByPackage.get(source.packageName) ?? []) { + if (samePackageType.fqcn !== source.fqcn && new RegExp(`\\b${samePackageType.typeName}\\b`).test(source.source)) { + candidates.add(samePackageType.fqcn); + } + } + for (const targetFqcn of candidates) { + if (targetFqcn === source.fqcn) continue; + const key = `${source.fqcn}->${targetFqcn}`; + if (seen.has(key)) continue; + seen.add(key); + dependencies.push({ source: source.fqcn, target: targetFqcn }); + } + } + return dependencies.sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`)); +} + +function aggregateEdges(dependencies, nodeOf) { + const counts = new Map(); + for (const dependency of dependencies) { + const source = nodeOf(dependency.source); + const target = nodeOf(dependency.target); + if (source === target) continue; + const key = `${source}\0${target}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return [...counts].map(([key, count]) => { + const [source, target] = key.split('\0'); + return { source, target, count }; + }).sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`)); +} + +function stronglyConnectedComponents(nodes, edges) { + const adjacency = new Map(nodes.map(node => [node, []])); + for (const edge of edges) adjacency.get(edge.source)?.push(edge.target); + const indexByNode = new Map(); + const lowLink = new Map(); + const stack = []; + const onStack = new Set(); + const components = []; + let nextIndex = 0; + function visit(node) { + indexByNode.set(node, nextIndex); + lowLink.set(node, nextIndex); + nextIndex += 1; + stack.push(node); + onStack.add(node); + for (const target of adjacency.get(node) ?? []) { + if (!indexByNode.has(target)) { + visit(target); + lowLink.set(node, Math.min(lowLink.get(node), lowLink.get(target))); + } else if (onStack.has(target)) { + lowLink.set(node, Math.min(lowLink.get(node), indexByNode.get(target))); + } + } + if (lowLink.get(node) === indexByNode.get(node)) { + const component = []; + let current; + do { + current = stack.pop(); + onStack.delete(current); + component.push(current); + } while (current !== node); + components.push(component.sort()); + } + } + for (const node of [...nodes].sort()) if (!indexByNode.has(node)) visit(node); + return components.filter(component => component.length > 1).sort((a, b) => b.length - a.length || a[0].localeCompare(b[0])); +} + +function buildReport(sourceSet) { + const sources = collectSources(sourceSet); + const sourceByFqcn = new Map(sources.map(source => [source.fqcn, source])); + const dependencies = resolveDependencies(sources); + const packages = [...new Set(sources.map(source => source.packageName))].sort(); + const slices = [...new Set(sources.map(source => source.slice))].sort(); + const packageEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).packageName); + const sliceEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).slice); + const directCrossSliceDependencies = dependencies.filter(edge => sourceByFqcn.get(edge.source).slice !== sourceByFqcn.get(edge.target).slice); + const modelLeaks = directCrossSliceDependencies.filter(edge => sourceByFqcn.get(edge.target).kind === 'data-model'); + const mapperLeaks = directCrossSliceDependencies.filter(edge => /\.mapper(?:\.|$)/.test(sourceByFqcn.get(edge.target).packageName)); + return { + schemaVersion: 1, + generatedFrom: sourceSet, + moduleRoot: 'lastleg-service', + summary: { + javaFiles: sources.length, + internalClassDependencies: dependencies.length, + packages: packages.length, + slices: slices.length, + crossSliceClassDependencies: directCrossSliceDependencies.length, + crossSliceModelLeaks: modelLeaks.length, + crossSliceMapperLeaks: mapperLeaks.length, + }, + sliceDefinitions: Object.fromEntries(sliceRules.map(([name, expression]) => [name, expression.source]).concat([['shared-technical', 'fallback']])), + classes: sources.map(({ source, ...entry }) => entry), + classDependencies: dependencies, + packageEdges, + sliceEdges, + packageSccs: stronglyConnectedComponents(packages, packageEdges), + sliceSccs: stronglyConnectedComponents(slices, sliceEdges), + directCrossSliceDependencies, + modelLeaks, + mapperLeaks, + }; +} + +function renderMarkdown(report) { + const leaks = [...report.modelLeaks, ...report.mapperLeaks] + .filter((edge, index, entries) => entries.findIndex(candidate => candidate.source === edge.source && candidate.target === edge.target) === index); + return [ + '# Lastleg internal dependency report', '', + `- Source set: ${report.generatedFrom}`, + `- Java files: ${report.summary.javaFiles}`, + `- Internal class dependencies: ${report.summary.internalClassDependencies}`, + `- Packages: ${report.summary.packages}`, + `- Cross-slice class dependencies: ${report.summary.crossSliceClassDependencies}`, + `- Cross-slice model leaks: ${report.summary.crossSliceModelLeaks}`, + `- Cross-slice mapper leaks: ${report.summary.crossSliceMapperLeaks}`, + `- Package SCCs (>1 node): ${report.packageSccs.length}`, + `- Slice SCCs (>1 node): ${report.sliceSccs.length}`, + '', '## Slice edges', '', '| From | To | Class edges |', '|---|---|---:|', + ...report.sliceEdges.map(edge => `| ${edge.source} | ${edge.target} | ${edge.count} |`), + '', '## Slice SCCs', '', + ...(report.sliceSccs.length ? report.sliceSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']), + '', '## Package SCCs', '', + ...(report.packageSccs.length ? report.packageSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']), + '', '## Cross-slice model and mapper leaks', '', + ...(leaks.length ? leaks.map(edge => `- \`${edge.source}\` -> \`${edge.target}\``) : ['- None']), + '', + ].join('\n'); +} + +function renderDot(report) { + const lines = ['digraph lastleg_slices {', ' rankdir=LR;']; + for (const edge of report.sliceEdges) lines.push(` "${edge.source}" -> "${edge.target}" [label="${edge.count}"];`); + lines.push('}', ''); + return lines.join('\n'); +} + +function checkContract(report, contractPath) { + const absolute = path.resolve(process.cwd(), contractPath); + const contract = JSON.parse(fs.readFileSync(absolute, 'utf8')); + const allowed = new Set(contract.allowedSliceEdges ?? []); + const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`); + const violations = actual.filter(edge => !allowed.has(edge)).map(edge => `new slice dependency: ${edge}`); + for (const [slice, rule] of Object.entries(contract.sliceSpecificRules ?? {})) { + const outgoing = report.sliceEdges.filter(edge => edge.source === slice).map(edge => edge.target).sort(); + const incoming = report.sliceEdges.filter(edge => edge.target === slice).map(edge => edge.source).sort(); + if (JSON.stringify(outgoing) !== JSON.stringify([...(rule.allowedOutgoing ?? [])].sort())) { + violations.push(`${slice} outgoing dependencies changed: ${outgoing.join(', ')}`); + } + if (JSON.stringify(incoming) !== JSON.stringify([...(rule.allowedIncoming ?? [])].sort())) { + violations.push(`${slice} incoming dependencies changed: ${incoming.join(', ')}`); + } + } + if (violations.length > 0) { + process.stderr.write(`Lastleg dependency contract violations in ${contractPath}:\n${violations.map(item => `- ${item}`).join('\n')}\n`); + process.exitCode = 1; + } +} + +const options = parseArgs(process.argv.slice(2)); +const report = buildReport(options.sourceSet); +if (options.check) checkContract(report, options.check); +const rendered = options.format === 'json' + ? `${JSON.stringify(report, null, 2)}\n` + : options.format === 'markdown' + ? renderMarkdown(report) + : renderDot(report); +if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8'); +else process.stdout.write(rendered); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java new file mode 100644 index 000000000..27a8b5cf0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.config; + +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.net.http.HttpClient; + +@Component +public class LastlegRestClientFactory { + public RestClient build(RestClient.Builder builder, String baseUrl, + LastlegRestClientProperties.ClientTimeouts timeouts) { + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(timeouts.getConnectTimeout()) + .build(); + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); + requestFactory.setReadTimeout(timeouts.getReadTimeout()); + return builder.requestFactory(requestFactory).baseUrl(baseUrl).build(); + } +} + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java new file mode 100644 index 000000000..be6fd6900 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +@ConfigurationProperties(prefix = "baiyue.http.clients") +public class LastlegRestClientProperties { + private ClientTimeouts unloadPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); + private ClientTimeouts relabelPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); + private ClientTimeouts warehouseResource = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5)); + private ClientTimeouts palletPhysical = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5)); + + public ClientTimeouts getUnloadPhotoFile() { return unloadPhotoFile; } + public void setUnloadPhotoFile(ClientTimeouts value) { unloadPhotoFile = required(value, "unload-photo-file"); } + public ClientTimeouts getRelabelPhotoFile() { return relabelPhotoFile; } + public void setRelabelPhotoFile(ClientTimeouts value) { relabelPhotoFile = required(value, "relabel-photo-file"); } + public ClientTimeouts getWarehouseResource() { return warehouseResource; } + public void setWarehouseResource(ClientTimeouts value) { warehouseResource = required(value, "warehouse-resource"); } + public ClientTimeouts getPalletPhysical() { return palletPhysical; } + public void setPalletPhysical(ClientTimeouts value) { palletPhysical = required(value, "pallet-physical"); } + + private static ClientTimeouts required(ClientTimeouts value, String client) { + if (value == null) throw new IllegalArgumentException(client + " HTTP timeouts are required"); + return value; + } + + public static class ClientTimeouts { + private Duration connectTimeout; + private Duration readTimeout; + + public ClientTimeouts() { } + + public ClientTimeouts(Duration connectTimeout, Duration readTimeout) { + setConnectTimeout(connectTimeout); + setReadTimeout(readTimeout); + } + + public Duration getConnectTimeout() { return connectTimeout; } + public void setConnectTimeout(Duration value) { connectTimeout = positive(value, "connect-timeout"); } + public Duration getReadTimeout() { return readTimeout; } + public void setReadTimeout(Duration value) { readTimeout = positive(value, "read-timeout"); } + + private static Duration positive(Duration value, String name) { + if (value == null || value.isZero() || value.isNegative()) { + throw new IllegalArgumentException(name + " must be positive"); + } + return value; + } + } +} + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java index 3195adf30..6a8b0c243 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java @@ -1,5 +1,7 @@ package com.baiyue.lastleg.inboundplan.pda.gateway; +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; @@ -19,12 +21,17 @@ import java.io.IOException; public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway { private static final String BUSINESS_TYPE = "OVERSEAS_UNLOAD_PHOTO"; @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; @Value("${baiyue.security.internal-service-token:}") private String internalToken; private RestClient client; @PostConstruct - void initializeClient() { client = restClientBuilder.baseUrl(baseUrl).build(); } + void initializeClient() { + client = restClientFactory.build(restClientBuilder, baseUrl, + restClientProperties.getUnloadPhotoFile()); + } @Override public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java index 5bdfcc3f3..ea792ea46 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -1,5 +1,7 @@ package com.baiyue.lastleg.inboundplan.pda.gateway; +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; import com.fasterxml.jackson.annotation.JsonAlias; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatusCode; @@ -15,6 +17,8 @@ import java.util.Map; @Component public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; @Value("${baiyue.warehouse.base-url:http://127.0.0.1:9305}") private String baseUrl; @@ -26,7 +30,8 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { @PostConstruct void initializeClient() { - this.client = restClientBuilder.baseUrl(baseUrl).build(); + this.client = restClientFactory.build(restClientBuilder, baseUrl, + restClientProperties.getWarehouseResource()); } @Override diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java index c93045fd4..4b4154ddf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java @@ -1,5 +1,7 @@ package com.baiyue.lastleg.palletops.gateway; +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; @@ -13,11 +15,16 @@ import java.util.Map; @Component public class HttpPalletPhysicalGateway implements PalletPhysicalGateway { @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; @Value("${baiyue.warehouse.base-url:http://127.0.0.1:9305}") private String baseUrl; @Value("${baiyue.security.internal-service-token:}") private String internalToken; private RestClient client; - @PostConstruct void initialize() { client = restClientBuilder.baseUrl(baseUrl).build(); } + @PostConstruct void initialize() { + client = restClientFactory.build(restClientBuilder, baseUrl, + restClientProperties.getPalletPhysical()); + } @Override public PalletReference resolve(String palletCode) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java index 12cf36e23..d693674a3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java @@ -1,5 +1,7 @@ package com.baiyue.lastleg.relabel.pda.gateway; +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; @@ -19,12 +21,17 @@ import java.io.IOException; public class HttpRelabelPhotoFileGateway implements RelabelPhotoFileGateway { private static final String BUSINESS_TYPE = "OVERSEAS_RELABEL_PHOTO"; @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; @Value("${baiyue.security.internal-service-token:}") private String internalToken; private RestClient client; @PostConstruct - void initializeClient() { client = restClientBuilder.baseUrl(baseUrl).build(); } + void initializeClient() { + client = restClientFactory.build(restClientBuilder, baseUrl, + restClientProperties.getRelabelPhotoFile()); + } @Override public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) { diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index 1aa3d7f34..3699c27ba 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -41,6 +41,20 @@ lastleg: enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true} baiyue: + http: + clients: + unload-photo-file: + connect-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_READ_TIMEOUT:30s} + relabel-photo-file: + connect-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_READ_TIMEOUT:30s} + warehouse-resource: + connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s} + pallet-physical: + connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s} clients: domestic-warehouse: url: ${DOMESTIC_WAREHOUSE_URL:} From 4cca194eee6c8a1b42b7d4ea8748c3aaea7072fa Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 12:30:08 +0800 Subject: [PATCH 100/115] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lastleg-service/scripts/analyze-lastleg-dependencies.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs index 378e380e8..03146b819 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs @@ -12,10 +12,10 @@ const sourceRoots = { const internalPrefix = 'com.baiyue.lastleg.'; const sliceRules = [ - ['inbound-pda', /(?:^|\.)(?:inboundplan|palletops|pda)(?:\.|$)/], - ['inventory', /(?:^|\.)inventory(?:\.|$)/], ['dispatch-delivery', /(?:^|\.)(?:dispatch|delivery|outbound)(?:\.|$)/], ['relabel-transfer-discard', /(?:^|\.)(?:relabel|transfer|discard)(?:\.|$)/], + ['inbound-pda', /(?:^|\.)(?:inboundplan|palletops|pda)(?:\.|$)/], + ['inventory', /(?:^|\.)inventory(?:\.|$)/], ['dropship', /(?:^|\.)dropship(?:\.|$)/], ['procurement-trucking-looseboard', /(?:^|\.)(?:procurement|trucking|looseboard)(?:\.|$)/], ['overseas-master-data', /(?:^|\.)masterdata(?:\.|$)/], From d686b05966b1df7b9a4579b8942718e4a1fd58ab Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 13:02:26 +0800 Subject: [PATCH 101/115] fix(firstleg): expose event bridge relay failures --- .../service/LoadingCompletedOutboxRelay.java | 34 +++++++++++++-- firstleg/src/main/resources/application.yml | 2 +- ...entBridgeClientDeploymentContractTest.java | 32 +++++++++++++++ .../LoadingCompletedOutboxRelayTest.java | 41 ++++++++++++++++++- 4 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/client/EventBridgeClientDeploymentContractTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java index c635fbe76..f01597951 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java @@ -1,5 +1,6 @@ package com.baiyue.firstleg.service; +import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventEnvelopeValidator; import com.baiyue.firstleg.client.EventBridgeClient; @@ -38,8 +39,7 @@ public class LoadingCompletedOutboxRelay { EventEnvelope event=objectMapper.readValue(row.getPayloadJson(),EventEnvelope.class); EventEnvelopeValidator.requireValid(event); var response=client.receive(internalToken,event); - if(response==null || !response.successful() || response.data()==null - || !event.eventId().equals(response.data().eventId())) throw new IllegalStateException("事件桥未确认头程跨区事件"); + requireAcknowledgement(event, response); repository.published(row.getId(),workerId,LocalDateTime.now()); } catch(Exception exception) { String error=safeError(exception); @@ -50,11 +50,37 @@ public class LoadingCompletedOutboxRelay { } } + private void requireAcknowledgement(EventEnvelope event, + ApiResponse response) { + if(response==null) throw new IllegalStateException("事件桥未返回响应"); + if(!response.successful()) { + throw new IllegalStateException("事件桥拒绝头程跨区事件: code=" + response.code() + + ", message=" + response.message() + ", requestId=" + response.requestId() + + ", traceId=" + response.traceId()); + } + if(response.data()==null || !event.eventId().equals(response.data().eventId())) { + throw new IllegalStateException("事件桥 ACK 不匹配: expectedEventId=" + event.eventId() + + ", actualEventId=" + (response.data()==null?null:response.data().eventId())); + } + } + private String safeError(Exception exception) { - String value=exception.getMessage(); - if(value==null || value.isBlank()) value=exception.getClass().getSimpleName(); + Throwable diagnostic=diagnosticCause(exception); + String message=diagnostic.getMessage(); + String value=diagnostic.getClass().getSimpleName() + + ((message==null || message.isBlank())?"":": " + message); if(internalToken!=null && !internalToken.isBlank()) value=value.replace(internalToken,"***"); value=value.replaceAll("[\\r\\n\\t]+"," "); return value.length()>500?value.substring(0,500):value; } + + private Throwable diagnosticCause(Throwable failure) { + Throwable current=failure; + while(current.getCause()!=null && current.getCause()!=current + && ("NoFallbackAvailableException".equals(current.getClass().getSimpleName()) + || "No fallback available.".equals(current.getMessage()))) { + current=current.getCause(); + } + return current; + } } diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml index ed6994938..1a5342f3a 100644 --- a/firstleg/src/main/resources/application.yml +++ b/firstleg/src/main/resources/application.yml @@ -44,7 +44,7 @@ baiyue: jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} lastleg: - event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL:http://127.0.0.1:9312} + event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL:} clients: order: url: ${ORDER_SERVICE_URL:} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/client/EventBridgeClientDeploymentContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/client/EventBridgeClientDeploymentContractTest.java new file mode 100644 index 000000000..b22423e5f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/client/EventBridgeClientDeploymentContractTest.java @@ -0,0 +1,32 @@ +package com.baiyue.firstleg.client; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EventBridgeClientDeploymentContractTest { + private static final Path REPOSITORY_ROOT = Path.of("..").toAbsolutePath().normalize(); + + @Test + void firstlegBridgeTargetComesFromEnvironmentWithoutLoopbackFallback() throws Exception { + String application = Files.readString(REPOSITORY_ROOT.resolve("firstleg/src/main/resources/application.yml")); + String nacos = Files.readString(REPOSITORY_ROOT.resolve("config/nacos/cn/firstleg-service.yml")); + + assertTrue(application.contains("event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL:}")); + assertFalse(application.contains("LASTLEG_EVENT_BRIDGE_URL:http://127.0.0.1")); + assertTrue(nacos.contains("event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL}")); + } + + @Test + void cnComposeForwardsTargetAndPublishesStableNetworkAlias() throws Exception { + String compose = Files.readString(REPOSITORY_ROOT.resolve("deploy/docker-compose.yml")); + + assertTrue(compose.contains("LASTLEG_EVENT_BRIDGE_URL: ${LASTLEG_EVENT_BRIDGE_URL}")); + assertTrue(compose.contains("SERVER_PORT: 9312")); + assertTrue(compose.contains("aliases: [event-bridge-cn]")); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java index abd8ea172..3970763f6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java @@ -1,6 +1,7 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; @@ -18,6 +19,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; import java.time.Instant; import java.time.LocalDateTime; @@ -73,7 +75,44 @@ class LoadingCompletedOutboxRelayTest { relay.relay(); - verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq("failed *** remote")); + verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), + eq("IllegalStateException: failed *** remote")); + } + + @Test + void persistsRootCauseWhenCircuitBreakerWrapsBridgeFailure() throws Exception { + EventEnvelope event = event(); + InboundOutboxEntity row = row(event); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenThrow( + new NoFallbackAvailableException("No fallback available.", + new IllegalStateException("Connection refused: event-bridge-cn:9312"))); + + relay.relay(); + + verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), + eq("IllegalStateException: Connection refused: event-bridge-cn:9312")); + verify(repository, never()).published(anyLong(), anyString(), any()); + } + + @Test + void persistsDownstreamCodeAndRequestEvidenceWhenBridgeRejectsEvent() throws Exception { + EventEnvelope event = event(); + InboundOutboxEntity row = row(event); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.failure( + ResultCode.SERVICE_UNAVAILABLE, "bridge inbox unavailable", "request-123", "trace-456")); + + relay.relay(); + + verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq( + "IllegalStateException: 事件桥拒绝头程跨区事件: code=S0503, message=bridge inbox unavailable, " + + "requestId=request-123, traceId=trace-456")); + verify(repository, never()).published(anyLong(), anyString(), any()); } private InboundOutboxEntity row(EventEnvelope event) throws Exception { From 995c1833f8ba46fea9dc01a25a9e1c2c01eaa310 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 13:06:09 +0800 Subject: [PATCH 102/115] test: align phase3 and overseas business acceptance --- scripts/admin-test-env-acceptance.mjs | 4 +- .../inbound-measurement.mjs | 87 +++++++++++++++++-- .../phase3-http-fail-closed-contract.test.mjs | 19 ++-- .../phase3-outbound-dispatch.mjs | 60 +++++-------- .../phase3-producer-chain-contract.test.mjs | 7 ++ .../phase3-waiting-for-loading.mjs | 14 +-- ...phase4-overseas-business-contract.test.mjs | 87 ++++++++++++++++--- .../phase4-overseas-business.mjs | 75 +++++++++++++--- 8 files changed, 269 insertions(+), 84 deletions(-) diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index ea82e9925..91fa0303d 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -903,7 +903,8 @@ export class AcceptanceRun { const fixtureMarker = `${originalMarker}-LOAD${suffix}` const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement'] const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound', - 'phase3LoadingRequireSharedOverflow', 'phase3LoadingIncrementalPackageCount'] + 'phase3LoadingRequireSharedOverflow', 'phase3LoadingIncrementalPackageCount', + 'phase3LoadingIncrementalDetailCount'] const createdSnapshot = new Map(createdKeys.map((key) => [key, { present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key], }])) @@ -947,6 +948,7 @@ export class AcceptanceRun { this.marker = incrementalMarker this.context.phase3LoadingRequireSharedOverflow = false this.context.phase3LoadingIncrementalPackageCount = 2 + this.context.phase3LoadingIncrementalDetailCount = 4 await this.executeModule('orders-phase3-loading-incremental-fixture', () => orders.run(this)) const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url)) await this.executeModule('inbound-measurement-phase3-loading-incremental-fixture', () => measurement.run(this)) diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index e3707c25e..6c2f7e14d 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -339,9 +339,9 @@ export async function run(context) { && closeTo(rescan?.lengthCm, replacement.length), `sameItem=${asId(rescan?.itemId) === asId(firstScan.itemId)}, confirmedPackageCount=${rescan?.confirmedPackageCount ?? 'missing'}`, OPTIONS) - const measuredResponse = await context.request('GET', measurementPath) - const measured = context.apiSuccess(MODULE, '回查覆盖后的尺寸与体积结果', measuredResponse, '', OPTIONS) - const measuredItem = measured?.items?.find((item) => asId(item.id) === asId(firstScan.itemId)) + let measuredResponse = await context.request('GET', measurementPath) + let measured = context.apiSuccess(MODULE, '回查覆盖后的尺寸与体积结果', measuredResponse, '', OPTIONS) + let measuredItem = measured?.items?.find((item) => asId(item.id) === asId(firstScan.itemId)) // The verified customer rule maps this scan to EXTRA_LARGE 60x50x50cm and adds 2cm per edge. const weightedOuter = { length: 64, width: 54, height: 54 } const unitVolume = halfUp( @@ -362,6 +362,63 @@ export async function run(context) { && closeTo(measured.actualVolumeCbm, totalVolume) && closeTo(measured.volumeWeightKg, volumeWeight), `weightedOuter=${weightedOuter.length}x${weightedOuter.width}x${weightedOuter.height}, unitVolume=${unitVolume}, totalVolume=${totalVolume}, volumeWeight=${volumeWeight}, girth=${girth}`, OPTIONS) + const incrementalDetailCount = Number(context.context?.phase3LoadingIncrementalDetailCount || 1) + if (incrementalDetailCount > 1) { + const packagesPerDetail = Number(context.context?.phase3LoadingIncrementalPackageCount || 1) + const expectedPackages = incrementalDetailCount * packagesPerDetail + context.assert(MODULE, '动态增量多明细箱数前置与冻结总数一致', measuredResponse, + expectedPackages === originalPackageCount, + `detailCount=${incrementalDetailCount}, packagesPerDetail=${packagesPerDetail}, frozen=${originalPackageCount}`, OPTIONS) + if (expectedPackages === originalPackageCount) { + const resetFirstCountResponse = await context.request('PUT', + `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/items/${firstScan.itemId}/confirmed-package-count?${query(tenantId)}`, + { measurementVersion: Number(measured?.version), confirmedPackageCount: packagesPerDetail }) + const resetFirstCount = context.apiSuccess(MODULE, + '增量 fixture 将首条货物确认箱数恢复为自身两箱', resetFirstCountResponse, + `itemId=${asId(firstScan.itemId)}, confirmedPackageCount=${packagesPerDetail}`, OPTIONS) + context.assert(MODULE, '增量首条货物箱数和版本更新正确', resetFirstCountResponse, + Number(resetFirstCount?.confirmedPackageCount) === packagesPerDetail + && Number(resetFirstCount?.measurementVersion) === Number(measured?.version) + 1, + `version=${resetFirstCount?.measurementVersion ?? 'missing'}`, OPTIONS) + + for (let detailIndex = 1; detailIndex < incrementalDetailCount; detailIndex += 1) { + const cartonIndex = detailIndex * packagesPerDetail + 1 + const detailBoxCode = `${inboundCode}#${originalPackageCount}-${cartonIndex}` + const scanResponse = await multipartRequest(context, scanPath, { + boxCode: detailBoxCode, ...replacement, + scannedShipmentId: `FBA-${context.marker.slice(-12)}-${String(detailIndex + 2).padStart(2, '0')}`, + machineNo: `MJ-ADMIN-E2E-INC-${detailIndex + 1}`, + measuredAt: new Date().toISOString().slice(0, 19), + }) + const scanned = context.apiSuccess(MODULE, + `增量 fixture 扫描第${detailIndex + 1}条独立货物箱码`, scanResponse, + `boxCode=${detailBoxCode}`, OPTIONS) + context.assert(MODULE, `增量第${detailIndex + 1}条扫描解析独立货物`, scanResponse, + scanned?.created === true && asId(scanned?.itemId) + && asId(scanned?.itemId) !== asId(firstScan.itemId), + `itemId=${asId(scanned?.itemId) || 'missing'}, inboundDetailId=${asId(scanned?.inboundDetailId) || 'pending-query'}`, OPTIONS) + if (!scanned?.itemId) continue + const countDetailResponse = await context.request('PUT', + `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/items/${scanned.itemId}/confirmed-package-count?${query(tenantId)}`, + { measurementVersion: Number(scanned.measurementVersion), confirmedPackageCount: packagesPerDetail }) + const countDetail = context.apiSuccess(MODULE, + `增量第${detailIndex + 1}条货物确认自身箱数`, countDetailResponse, + `confirmedPackageCount=${packagesPerDetail}`, OPTIONS) + context.assert(MODULE, `增量第${detailIndex + 1}条货物箱数更新正确`, countDetailResponse, + Number(countDetail?.confirmedPackageCount) === packagesPerDetail, + `measurementVersion=${countDetail?.measurementVersion ?? 'missing'}`, OPTIONS) + } + measuredResponse = await context.request('GET', measurementPath) + measured = context.apiSuccess(MODULE, '回查增量 fixture 四条测量明细', measuredResponse, '', OPTIONS) + measuredItem = measured?.items?.find((item) => asId(item.id) === asId(firstScan.itemId)) + const detailIds = new Set((measured?.items || []).map((item) => asId(item?.inboundDetailId)).filter(Boolean)) + context.assert(MODULE, '增量 fixture 形成四条真实独立货物测量明细', measuredResponse, + detailIds.size >= incrementalDetailCount + && measured.items.filter((item) => detailIds.has(asId(item?.inboundDetailId))).length >= incrementalDetailCount, + `measurementItems=${measured?.items?.length ?? 'missing'}, distinctInboundDetails=${detailIds.size}`, OPTIONS) + } + } + let inboundDetailId = measuredItem?.inboundDetailId if (!inboundDetailId) { const orderId = asId(frozenSummary?.orderId || context.createdResources?.orders?.orderId) @@ -387,10 +444,18 @@ export async function run(context) { return } - const draftPayload = { - measurementId: measured.measurementId, version: Number(measured.version), - actualGoodsName: `墨家测量验收-${context.marker.slice(-12)}`, - items: [{ + const draftItems = incrementalDetailCount > 1 + ? (measured?.items || []).filter((item) => asId(item?.inboundDetailId) && asId(item?.intelligentImageFileId)) + .slice(0, incrementalDetailCount).map((item) => ({ + inboundDetailId: item.inboundDetailId, itemNo: item.itemNo, + boxSpecification: item.boxSpecification, packageCount: Number(item.confirmedPackageCount), + lengthCm: Number(item.lengthCm), widthCm: Number(item.widthCm), heightCm: Number(item.heightCm), + intelligentImageFileId: item.intelligentImageFileId, + overLimitWarning: item.overLimitWarning ?? null, + outerLengthCm: item.outerLengthCm ?? null, outerWidthCm: item.outerWidthCm ?? null, + outerHeightCm: item.outerHeightCm ?? null, remark: context.marker, + })) + : [{ inboundDetailId, itemNo: measuredItem.itemNo, boxSpecification: measuredItem.boxSpecification, packageCount: originalPackageCount, lengthCm: replacement.length, widthCm: replacement.width, heightCm: replacement.height, @@ -398,12 +463,16 @@ export async function run(context) { overLimitWarning: measuredItem.overLimitWarning ?? null, outerLengthCm: measuredItem.outerLengthCm ?? null, outerWidthCm: measuredItem.outerWidthCm ?? null, outerHeightCm: measuredItem.outerHeightCm ?? null, remark: context.marker, - }], + }] + const draftPayload = { + measurementId: measured.measurementId, version: Number(measured.version), + actualGoodsName: `墨家测量验收-${context.marker.slice(-12)}`, + items: draftItems, } const draftResponse = await context.request('PUT', `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/draft?${query(tenantId)}`, draftPayload) const draft = context.apiSuccess(MODULE, '保存同一测量批次草稿', draftResponse, - `measurementId=${asId(measured.measurementId)}, items=1`, OPTIONS) + `measurementId=${asId(measured.measurementId)}, items=${draftItems.length}`, OPTIONS) context.assert(MODULE, '草稿沿用批次并递增版本', draftResponse, asId(draft?.measurementId) === asId(measured.measurementId) && Number(draft?.version) === Number(measured.version) + 1 && draft?.status === 'DRAFT', diff --git a/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs index fd73c84af..36331259e 100644 --- a/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs @@ -37,17 +37,26 @@ test('phase3 inventory mutation and downstream gates cannot pass as coverage gap assert.doesNotMatch(aggregate, /报关与船期历史并入统一时间线|当前服务端聚合未公开/) }) -test('outbound lifecycle checks replay, hash conflict, stale version and final reread', () => { +test('outbound lifecycle checks confirm replay, immutable departure reverse boundary and final reread', () => { const source = read('phase3-outbound-dispatch.mjs') for (const step of [ 'confirm-dispatch 幂等重放', '相同 Idempotency-Key 不同请求拒绝', - 'reverse-dispatch 幂等重放', - 'reverse-dispatch 同键异参返回 A0409', - '撤销后旧版本再次确认返回 A0409', + '实际DEPARTED_WAREHOUSE节点产生后撤销精确返回A0409', + '禁止撤销边界重复请求仍精确返回A0409', '负向与幂等验证后回查出仓聚合', - '出仓聚合保持 READY_FOR_DISPATCH、LOADED 和确认撤销历史', + '出仓聚合保持 WAITING_FOR_LOADING、LOADED 且无成功撤销历史', ]) assert.match(source, new RegExp(step)) assert.match(source, /if \(!confirmed\?\.aggregate\) return/) assert.match(source, /hashConflict\.http === 200 && codeOf\(hashConflict\) === 'A0409'/) + const confirm = source.indexOf('确认国内出仓进入等待装船') + const reverse = source.indexOf('实际DEPARTED_WAREHOUSE节点产生后撤销精确返回A0409') + const finalRead = source.indexOf('负向与幂等验证后回查出仓聚合') + assert.ok(confirm >= 0 && reverse > confirm && finalRead > reverse) + assert.match(source, /confirmedStatus: confirmed\.aggregate\.dispatchStatus/) + assert.doesNotMatch(source, /outbound-confirm-final|finalConfirmed|reversedVersion/) + const waiting = read('phase3-waiting-for-loading.mjs') + assert.match(waiting, /confirmations\.length === 1 && reversed\.length === 0/) + assert.match(waiting, /实际离仓节点冻结后保持一次确认且零次成功撤销/) + assert.match(waiting, /detail\?\.aggregate\?\.dispatchStatus === 'WAITING_FOR_LOADING'/) }) diff --git a/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs b/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs index 94611f91e..a489ba65c 100644 --- a/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs @@ -127,52 +127,40 @@ export async function run(context) { const reverseResponse = await request(context, 'POST', `${BASE}/reverse-dispatch`, { inboundId, containerBookingId, expectedVersion: Number(confirmed.aggregate.version), remark: context.marker, }, { 'Idempotency-Key': reverseKey }) - const reversed = context.apiSuccess(MODULE, '撤销国内出仓恢复装柜完成', reverseResponse, '', OPTIONS) - context.assert(MODULE, 'reverse-dispatch 恢复 READY_FOR_DISPATCH 与 LOADED', reverseResponse, - reversed?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH' && reversed?.aggregate?.inboundStatus === 'LOADED' - && reversed?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'REVERSE_DISPATCH'), - `dispatch=${reversed?.aggregate?.dispatchStatus || 'missing'}, inbound=${reversed?.aggregate?.inboundStatus || 'missing'}`, OPTIONS) + context.addStep(MODULE, '实际DEPARTED_WAREHOUSE节点产生后撤销精确返回A0409', reverseResponse, + reverseResponse.http === 200 && codeOf(reverseResponse) === 'A0409' + && String(reverseResponse.body?.message || '').includes('DEPARTED_WAREHOUSE') + && String(reverseResponse.body?.message || '').includes('禁止撤销'), + `HTTP=${reverseResponse.http}, code=${codeOf(reverseResponse)}, message=${reverseResponse.body?.message || 'missing'}`, OPTIONS) const reverseReplayResponse = await request(context, 'POST', `${BASE}/reverse-dispatch`, { inboundId, containerBookingId, expectedVersion: Number(confirmed.aggregate.version), remark: context.marker, }, { 'Idempotency-Key': reverseKey }) - const reverseReplay = context.apiSuccess(MODULE, 'reverse-dispatch 幂等重放', reverseReplayResponse, '', OPTIONS) - context.assert(MODULE, '撤销重放保持 READY_FOR_DISPATCH 与同一版本', reverseReplayResponse, - reverseReplay?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH' - && Number(reverseReplay?.aggregate?.version) === Number(reversed?.aggregate?.version), - `dispatch=${reverseReplay?.aggregate?.dispatchStatus || 'missing'}, version=${reverseReplay?.aggregate?.version ?? 'missing'}`, OPTIONS) - - const reverseHashConflict = await request(context, 'POST', `${BASE}/reverse-dispatch`, { - inboundId, containerBookingId, expectedVersion: Number(confirmed.aggregate.version), remark: 'forged', - }, { 'Idempotency-Key': reverseKey }) - context.addStep(MODULE, 'reverse-dispatch 同键异参返回 A0409', reverseHashConflict, - reverseHashConflict.http === 200 && codeOf(reverseHashConflict) === 'A0409', - `HTTP=${reverseHashConflict.http}, code=${codeOf(reverseHashConflict)}`, OPTIONS) - - const staleConfirmResponse = await request(context, 'POST', `${BASE}/confirm-dispatch`, actionBody, - { 'Idempotency-Key': `${context.marker}-outbound-confirm-stale`.slice(0, 128) }) - context.addStep(MODULE, '撤销后旧版本再次确认返回 A0409', staleConfirmResponse, - staleConfirmResponse.http === 200 && codeOf(staleConfirmResponse) === 'A0409', - `HTTP=${staleConfirmResponse.http}, code=${codeOf(staleConfirmResponse)}`, OPTIONS) + context.addStep(MODULE, '禁止撤销边界重复请求仍精确返回A0409', reverseReplayResponse, + reverseReplayResponse.http === 200 && codeOf(reverseReplayResponse) === 'A0409' + && String(reverseReplayResponse.body?.message || '').includes('DEPARTED_WAREHOUSE'), + `HTTP=${reverseReplayResponse.http}, code=${codeOf(reverseReplayResponse)}`, OPTIONS) const finalDetailResponse = await request(context, 'GET', `${BASE}/detail?${selector}`) const finalDetail = context.apiSuccess(MODULE, '负向与幂等验证后回查出仓聚合', finalDetailResponse, '', OPTIONS) - context.assert(MODULE, '出仓聚合保持 READY_FOR_DISPATCH、LOADED 和确认撤销历史', finalDetailResponse, - finalDetail?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH' + context.assert(MODULE, '出仓聚合保持 WAITING_FOR_LOADING、LOADED 且无成功撤销历史', finalDetailResponse, + finalDetail?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING' && finalDetail?.aggregate?.inboundStatus === 'LOADED' - && Number(finalDetail?.aggregate?.version) === Number(reversed?.aggregate?.version) - && finalDetail?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'CONFIRM_DISPATCH') - && finalDetail?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'REVERSE_DISPATCH'), + && Number(finalDetail?.aggregate?.version) === Number(confirmed?.aggregate?.version) + && finalDetail?.timeline?.filter?.((item) => item.module === 'OUTBOUND_DISPATCH' + && item.action === 'CONFIRM_DISPATCH').length === 1 + && !finalDetail?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' + && item.action === 'REVERSE_DISPATCH'), `dispatch=${finalDetail?.aggregate?.dispatchStatus || 'missing'}, inbound=${finalDetail?.aggregate?.inboundStatus || 'missing'}, version=${finalDetail?.aggregate?.version ?? 'missing'}`, OPTIONS) - if (reversed?.aggregate) { + if (confirmed?.aggregate) { context.createdResources.phase3OutboundDispatch = { marker: context.marker, inboundId, containerBookingId, - orderId: id(reversed.aggregate.orderId), tenantId: id(reversed.aggregate.tenantId), - confirmedStatus: confirmed?.aggregate?.dispatchStatus || null, - confirmedInboundStatus: confirmed?.aggregate?.inboundStatus || null, - confirmedVersion: Number(confirmed?.aggregate?.version), - finalStatus: reversed.aggregate.dispatchStatus, - finalInboundStatus: reversed.aggregate.inboundStatus, - finalVersion: Number(reversed.aggregate.version), + orderId: id(confirmed.aggregate.orderId), tenantId: id(confirmed.aggregate.tenantId), + confirmedStatus: confirmed.aggregate.dispatchStatus, + confirmedInboundStatus: confirmed.aggregate.inboundStatus, + confirmedVersion: Number(confirmed.aggregate.version), + finalStatus: confirmed.aggregate.dispatchStatus, + finalInboundStatus: confirmed.aggregate.inboundStatus, + finalVersion: Number(confirmed.aggregate.version), } } } diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 6150e4891..8a5da135f 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -460,6 +460,8 @@ test('phase3 loading proves stale inventory zero-write and defers final completi test('phase3 loading resolves legal SKIP and PARTIAL exceptions before downstream completion', () => { const loading = source('./phase3-loading.mjs') + const entry = source('../admin-test-env-acceptance.mjs') + const measurement = source('./inbound-measurement.mjs') assertOrder(loading, [ '本轮增量 fixture 提供独立 SKIP 与 PARTIAL 合法货物明细', '加入本轮待跳拣异常明细', @@ -486,6 +488,11 @@ test('phase3 loading resolves legal SKIP and PARTIAL exceptions before downstrea assert.match(loading, /exceptionOverview\?\.pda\?\.exceptionReasons\?\.includes\?\.\(skipReason\)/) assert.match(loading, /Number\(exceptionOverview\?\.pda\?\.unresolvedItemCount\) === 0/) assert.doesNotMatch(loading, /addCoverageGap\([^)]*(SKIP|PARTIAL|未解决异常)/) + assert.match(entry, /this\.context\.phase3LoadingIncrementalDetailCount = 4/) + assert.match(measurement, /增量 fixture 形成四条真实独立货物测量明细/) + assert.match(measurement, /detailBoxCode = `\$\{inboundCode\}#\$\{originalPackageCount\}-\$\{cartonIndex\}`/) + assert.match(measurement, /items: draftItems/) + assert.match(measurement, /expectedPackages === originalPackageCount/) }) test('phase3 loading fixture forces A0 through reversible public location status APIs', () => { diff --git a/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs b/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs index 21789ecc5..6729d98f0 100644 --- a/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs @@ -17,17 +17,17 @@ export async function run(context) { const detail = context.apiSuccess(MODULE, '回查 WAITING_FOR_LOADING 状态历史', response, selector, OPTIONS) const history = Array.isArray(detail?.timeline) ? detail.timeline.filter((item) => item.module === 'OUTBOUND_DISPATCH') : [] - const confirmed = history.find((item) => item.action === 'CONFIRM_DISPATCH') - const reversed = history.find((item) => item.action === 'REVERSE_DISPATCH') + const confirmations = history.filter((item) => item.action === 'CONFIRM_DISPATCH') + const confirmed = confirmations[0] + const reversed = history.filter((item) => item.action === 'REVERSE_DISPATCH') context.assert(MODULE, '确认出仓真实进入 WAITING_FOR_LOADING', response, fixture.confirmedStatus === 'WAITING_FOR_LOADING' && fixture.confirmedInboundStatus === 'LOADED' && confirmed?.fromStatus === 'READY_FOR_DISPATCH' && confirmed?.toStatus === 'WAITING_FOR_LOADING' && Boolean(confirmed?.operatorId) && Boolean(confirmed?.operatorName) && Boolean(confirmed?.occurredAt), `confirmed=${confirmed?.fromStatus || 'missing'}->${confirmed?.toStatus || 'missing'}, version=${fixture.confirmedVersion ?? 'missing'}`, OPTIONS) - context.assert(MODULE, '撤销后状态恢复且 WAITING 历史不可变', response, - detail?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH' && detail?.aggregate?.inboundStatus === 'LOADED' - && reversed?.fromStatus === 'WAITING_FOR_LOADING' && reversed?.toStatus === 'READY_FOR_DISPATCH' - && Boolean(reversed?.operatorId) && Boolean(reversed?.occurredAt), - `current=${detail?.aggregate?.dispatchStatus || 'missing'}, reversed=${reversed?.fromStatus || 'missing'}->${reversed?.toStatus || 'missing'}`, OPTIONS) + context.assert(MODULE, '实际离仓节点冻结后保持一次确认且零次成功撤销', response, + detail?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING' && detail?.aggregate?.inboundStatus === 'LOADED' + && confirmations.length === 1 && reversed.length === 0, + `current=${detail?.aggregate?.dispatchStatus || 'missing'}, confirmations=${confirmations.length}, reversals=${reversed.length}`, OPTIONS) } 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 f7bee94cc..82436c24a 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 @@ -20,6 +20,7 @@ function fixture(options = {}) { const state = { warehouseStatus: 'ACTIVE', purchase: null, purchaseHash: '', truck: null, truckHash: '', looseboard: null, looseboardHash: '', purchaseHistories: [], truckHistories: [], looseboardHistories: [], + purchaseReviews: new Map(), truckReviews: new Map(), truckStatuses: new Map(), looseboardStatuses: new Map(), } const activeCustomers = options.activeCustomers ?? [{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' }] const warehouseOptions = options.warehouseOptions ?? [ @@ -71,11 +72,19 @@ function fixture(options = {}) { if (requestPath.includes('/warehouse-options')) return response(warehouseOptions) if (requestPath.includes('/overseas-procurements?') && method === 'GET') return response(page(state.purchase ? [state.purchase] : [])) if (requestPath.includes('/overseas-procurements/OP1/review?') && method === 'POST') { + assert.equal(Number.isInteger(Number(body.version)), true) + assert.equal(typeof body.idempotencyKey === 'string' && body.idempotencyKey.length > 0, true) + const hash = JSON.stringify(body) + const replay = state.purchaseReviews.get(body.idempotencyKey) + if (replay) return replay.hash === hash ? response(replay.result) : response(null, 'A0409') if (state.purchase.status !== 'PENDING_REVIEW') return response(null, 'A0409') + if (Number(body.version) !== state.purchase.version) return response(null, 'A0409') const next = body.decision === 'REJECT' ? 'REJECTED' : 'APPROVED' state.purchase = { ...state.purchase, status: next, version: state.purchase.version + 1 } state.purchaseHistories.push({ operationType: 'REVIEW', fromStatus: 'PENDING_REVIEW', toStatus: next, remark: body.remark }) - return response(purchaseDetail()) + const result = structuredClone(purchaseDetail()) + state.purchaseReviews.set(body.idempotencyKey, { hash, result }) + return response(result) } if (requestPath.includes('/overseas-procurements/OP1?') && method === 'GET') return response(purchaseDetail()) if (requestPath.includes('/overseas-procurements/OP1?') && method === 'PATCH') { @@ -102,15 +111,37 @@ function fixture(options = {}) { return response(truckDetail()) } if (requestPath.includes('/us-truck-orders?') && method === 'GET') return response(page(state.truck ? [state.truck] : [])) - if (requestPath.includes('/us-truck-orders/TK1/status?') && method === 'PATCH') { - if (Number(body.version) !== state.truck.version) return response(null, 'A0409') - const allowed = (state.truck.status === 'PENDING_REVIEW' && ['WAITING_INBOUND', 'CANCELLED'].includes(body.status)) - || (state.truck.status === 'WAITING_INBOUND' && body.status === 'CANCELLED') - if (!allowed) return response(null, 'A0409') + if (requestPath.includes('/us-truck-orders/TK1/review?') && method === 'POST') { + assert.equal(Number.isInteger(Number(body.version)), true) + assert.equal(typeof body.idempotencyKey === 'string' && body.idempotencyKey.length > 0, true) + const hash = JSON.stringify(body) + const replay = state.truckReviews.get(body.idempotencyKey) + if (replay) return replay.hash === hash ? response(replay.result) : response(null, 'A0409') + if (state.truck.status !== 'PENDING_REVIEW' || Number(body.version) !== state.truck.version) { + return response(null, 'A0409') + } + const next = body.decision === 'APPROVE' ? 'WAITING_INBOUND' : 'REJECTED' const from = state.truck.status - state.truck = { ...state.truck, status: body.status, version: state.truck.version + 1 } - state.truckHistories.push({ fromStatus: from, toStatus: body.status, remark: body.remark }) - return response(truckDetail()) + state.truck = { ...state.truck, status: next, version: state.truck.version + 1 } + state.truckHistories.push({ fromStatus: from, toStatus: next, remark: body.remark }) + const result = structuredClone(truckDetail()) + state.truckReviews.set(body.idempotencyKey, { hash, result }) + return response(result) + } + if (requestPath.includes('/us-truck-orders/TK1/status?') && method === 'PATCH') { + assert.equal(body.status, 'CANCELLED') + assert.equal(typeof body.idempotencyKey === 'string' && body.idempotencyKey.length > 0, true) + const hash = JSON.stringify(body) + const replay = state.truckStatuses.get(body.idempotencyKey) + if (replay) return replay.hash === hash ? response(replay.result) : response(null, 'A0409') + if (Number(body.version) !== state.truck.version) return response(null, 'A0409') + if (!['PENDING_REVIEW', 'WAITING_INBOUND'].includes(state.truck.status)) return response(null, 'A0409') + const from = state.truck.status + state.truck = { ...state.truck, status: 'CANCELLED', version: state.truck.version + 1 } + state.truckHistories.push({ fromStatus: from, toStatus: 'CANCELLED', remark: body.remark }) + const result = structuredClone(truckDetail()) + state.truckStatuses.set(body.idempotencyKey, { hash, result }) + return response(result) } if (requestPath.includes('/us-truck-orders/TK1?') && method === 'GET') return response(truckDetail()) if (requestPath.includes('/us-truck-orders/TK1?') && method === 'PATCH') { @@ -130,6 +161,10 @@ function fixture(options = {}) { } if (requestPath.includes('/local-looseboard-orders?') && method === 'GET') return response(page(state.looseboard ? [state.looseboard] : [])) if (requestPath.includes('/local-looseboard-orders/LB1/status?') && method === 'PATCH') { + assert.equal(typeof body.idempotencyKey === 'string' && body.idempotencyKey.length > 0, true) + const hash = JSON.stringify(body) + const replay = state.looseboardStatuses.get(body.idempotencyKey) + if (replay) return replay.hash === hash ? response(replay.result) : response(null, 'A0409') if (Number(body.version) !== state.looseboard.version) return response(null, 'A0409') const allowed = (state.looseboard.status === 'PENDING_ACCEPTANCE' && ['DISPATCHED', 'CANCELLED'].includes(body.status)) || (state.looseboard.status === 'DISPATCHED' && body.status === 'CANCELLED') @@ -137,7 +172,9 @@ function fixture(options = {}) { const from = state.looseboard.status state.looseboard = { ...state.looseboard, status: body.status, version: state.looseboard.version + 1 } state.looseboardHistories.push({ operationType: 'STATUS', fromStatus: from, toStatus: body.status, remark: body.remark }) - return response(looseboardDetail()) + const result = structuredClone(looseboardDetail()) + state.looseboardStatuses.set(body.idempotencyKey, { hash, result }) + return response(result) } if (requestPath.includes('/local-looseboard-orders/LB1?') && method === 'GET') return response(looseboardDetail()) if (requestPath.includes('/local-looseboard-orders/LB1?') && method === 'PUT') { @@ -189,20 +226,41 @@ test('complete procurement truck and looseboard lifecycles pass with legal marke const procurementWrites = context.calls.filter((call) => call.path.includes('/overseas-procurements') && call.method !== 'GET') assert.deepEqual(procurementWrites.map((call) => call.method), - ['POST', 'POST', 'POST', 'PATCH', 'PATCH', 'PATCH', 'PATCH', 'POST', 'POST']) + ['POST', 'POST', 'POST', 'PATCH', 'PATCH', 'PATCH', 'PATCH', 'POST', 'POST', 'POST', 'POST']) assert.equal(procurementWrites.filter((call) => call.method === 'PATCH').length, 4) assert.ok(procurementWrites.some((call) => call.method === 'PATCH' && call.body.productSpecification === null && call.body.productUrl === '')) + const procurementReviews = procurementWrites.filter((call) => call.path.includes('/review?')) + assert.equal(procurementReviews.length, 4) + assert.ok(procurementReviews.every((call) => Number.isInteger(Number(call.body.version)) + && typeof call.body.idempotencyKey === 'string' && call.body.idempotencyKey.length > 0)) + assert.equal(new Set(procurementReviews.slice(0, 3).map((call) => call.body.idempotencyKey)).size, 1) + assert.notEqual(procurementReviews[3].body.idempotencyKey, procurementReviews[0].body.idempotencyKey) const truckCalls = context.calls.filter((call) => call.path.includes('/us-truck-orders')) - assert.equal(truckCalls.filter((call) => call.method === 'POST').length, 3) + assert.equal(truckCalls.filter((call) => call.method === 'POST').length, 7) assert.equal(truckCalls.filter((call) => call.method === 'GET' && call.path.includes('page=1')).length, 1) - assert.equal(truckCalls.filter((call) => call.method === 'PATCH' && call.path.includes('/status?')).length, 3) + const truckReviews = truckCalls.filter((call) => call.method === 'POST' && call.path.includes('/review?')) + assert.equal(truckReviews.length, 4) + assert.ok(truckReviews.every((call) => Number.isInteger(Number(call.body.version)) + && typeof call.body.idempotencyKey === 'string' && call.body.idempotencyKey.length > 0)) + assert.equal(new Set(truckReviews.slice(0, 3).map((call) => call.body.idempotencyKey)).size, 1) + assert.notEqual(truckReviews[3].body.idempotencyKey, truckReviews[0].body.idempotencyKey) + const truckStatuses = truckCalls.filter((call) => call.method === 'PATCH' && call.path.includes('/status?')) + assert.equal(truckStatuses.length, 1) + assert.ok(truckStatuses.every((call) => call.body.status === 'CANCELLED' + && typeof call.body.idempotencyKey === 'string' && call.body.idempotencyKey.length > 0)) const looseboardCalls = context.calls.filter((call) => call.path.includes('/local-looseboard-orders')) assert.equal(looseboardCalls.filter((call) => call.method === 'POST').length, 3) assert.equal(looseboardCalls.filter((call) => call.method === 'GET' && call.path.includes('page=1')).length, 1) assert.equal(looseboardCalls.filter((call) => call.method === 'PUT').length, 2) - assert.equal(looseboardCalls.filter((call) => call.method === 'PATCH' && call.path.includes('/status?')).length, 3) + const looseboardStatuses = looseboardCalls.filter((call) => call.method === 'PATCH' && call.path.includes('/status?')) + assert.equal(looseboardStatuses.length, 5) + assert.ok(looseboardStatuses.every((call) => typeof call.body.idempotencyKey === 'string' + && call.body.idempotencyKey.length > 0)) + assert.equal(new Set(looseboardStatuses.slice(0, 3).map((call) => call.body.idempotencyKey)).size, 1) + assert.equal(new Set(looseboardStatuses.slice(3).map((call) => call.body.idempotencyKey)).size, 2) + assert.ok(looseboardStatuses.slice(3).every((call) => call.body.idempotencyKey !== looseboardStatuses[0].body.idempotencyKey)) const registry = context.createdResources.phase4OverseasBusiness assert.equal(registry.purchase.finalStatus, 'REJECTED') @@ -265,5 +323,6 @@ test('contract locks candidates three-state idempotency histories legal terminal '/api/merchant/v1/last-leg/overseas-procurements', '显式 null 与空串清空', '字段缺失不修改', '同幂等键同载荷精确重放', '同幂等键异载荷返回冲突', '旧 version 返回 A0409', '状态正向推进', '状态逆向回退返回冲突', '流水覆盖', "status: 'CANCELLED'", + '/review', 'truck-review-approve', 'looseboard-status-dispatched', 'requestId=', 'traceId=', 'createdResources.phase4OverseasBusiness']) assert.ok(source.includes(token), token) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-business.mjs b/scripts/admin-test-env-modules/phase4-overseas-business.mjs index 5f6075550..0fc381806 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business.mjs @@ -248,14 +248,32 @@ async function procurementFlow(context, prerequisite) { const rejected = await overseas(context, 'POST', '驳回本轮采购订单并进入合法终态', query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), - { decision: 'REJECT', remark: `${context.marker}-REJECTED` }) + { version: Number(afterConflictSummary?.version), decision: 'REJECT', + idempotencyKey: `${context.marker}-procurement-review-reject`.slice(0, 255), + remark: `${context.marker}-REJECTED` }) const rejectedSummary = summary(rejected.data) context.assert(MODULE, '采购审核正向驳回成功', rejected.response, rejectedSummary?.status === 'REJECTED' && Number(rejectedSummary?.version) === Number(clearedSummary?.version) + 1, `status=${rejectedSummary?.status || 'missing'}, version=${rejectedSummary?.version ?? 'missing'}; ${trace(rejected.response)}`, OPTIONS) + const rejectedReplayBody = { version: Number(afterConflictSummary?.version), decision: 'REJECT', + idempotencyKey: `${context.marker}-procurement-review-reject`.slice(0, 255), + remark: `${context.marker}-REJECTED` } + const rejectedReplay = await overseas(context, 'POST', '采购审核同键同载荷精确重放', + query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), + rejectedReplayBody) + context.assert(MODULE, '采购审核重放复用终态且版本不漂移', rejectedReplay.response, + summary(rejectedReplay.data)?.status === 'REJECTED' + && Number(summary(rejectedReplay.data)?.version) === Number(rejectedSummary?.version), + trace(rejectedReplay.response), OPTIONS) + const reviewHashConflict = await context.requestOverseas('POST', + query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), + { ...rejectedReplayBody, remark: 'DIFFERENT-DO-NOT-APPLY' }) + expectedConflict(context, '采购审核同键异载荷返回冲突', reviewHashConflict) const reverseReview = await context.requestOverseas('POST', query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), - { decision: 'APPROVE', remark: 'REVERSE-DO-NOT-APPLY' }) + { version: Number(rejectedSummary?.version), decision: 'APPROVE', + idempotencyKey: `${context.marker}-procurement-review-terminal-reverse`.slice(0, 255), + remark: 'REVERSE-DO-NOT-APPLY' }) expectedConflict(context, '采购审核终态逆向返回冲突', reverseReview) const final = await overseas(context, 'GET', '采购最终状态与流水回查', path) @@ -328,8 +346,11 @@ async function cancelMarkerOrder(context, resource, base, cancellableStatuses, l return } const statusPath = query(`${base}/${encodeURIComponent(resource.orderNo)}/status`, { customerId: resource.customerId }) + const keyScope = label === '卡车' ? 'truck' : 'looseboard' const cancelled = await overseas(context, 'PATCH', `取消 marker ${label}订单`, statusPath, - { version: Number(currentSummary.version), status: 'CANCELLED', remark: `${context.marker}-acceptance-cleanup` }) + { version: Number(currentSummary.version), status: 'CANCELLED', + idempotencyKey: `${context.marker}-${keyScope}-${resource.orderNo}-cancel`.slice(0, 255), + remark: `${context.marker}-acceptance-cleanup` }) const cancelledSummary = summary(cancelled.data) const verified = await overseas(context, 'GET', `回查 marker ${label}订单最终状态`, detailPath) const finalSummary = summary(verified.data) @@ -381,17 +402,29 @@ async function truckFlow(context, customerId) { afterStaleSummary?.destination === after?.destination && Number(afterStaleSummary?.version) === Number(after?.version), `destination=${afterStaleSummary?.destination || 'missing'}, version=${afterStaleSummary?.version ?? 'missing'}; ${trace(afterStale.response)}`, OPTIONS) - const statusPath = query(`${base}/${encodeURIComponent(orderNo)}/status`, { customerId }) - const forwarded = await overseas(context, 'PATCH', '卡车状态正向推进到待入仓', statusPath, - { version: Number(afterStaleSummary?.version), status: 'WAITING_INBOUND', remark: `${context.marker}-FORWARD` }) + const reviewPath = query(`${base}/${encodeURIComponent(orderNo)}/review`, { customerId }) + const reviewBody = { version: Number(afterStaleSummary?.version), decision: 'APPROVE', + idempotencyKey: `${context.marker}-truck-review-approve`.slice(0, 255), + remark: `${context.marker}-FORWARD` } + const forwarded = await overseas(context, 'POST', '卡车审核正向推进到待入仓', reviewPath, reviewBody) const forwardedSummary = summary(forwarded.data) context.assert(MODULE, '卡车状态正向推进成功', forwarded.response, forwardedSummary?.status === 'WAITING_INBOUND' && Number(forwardedSummary?.version) === Number(afterStaleSummary?.version) + 1, `status=${forwardedSummary?.status || 'missing'}, version=${forwardedSummary?.version ?? 'missing'}; ${trace(forwarded.response)}`, OPTIONS) - const reversed = await context.requestOverseas('PATCH', statusPath, - { version: Number(forwardedSummary?.version), status: 'PENDING_REVIEW', remark: 'REVERSE-DO-NOT-APPLY' }) - expectedConflict(context, '卡车状态逆向回退返回冲突', reversed) + const reviewReplay = await overseas(context, 'POST', '卡车审核同键同载荷精确重放', reviewPath, reviewBody) + context.assert(MODULE, '卡车审核重放复用待入仓终态且版本不漂移', reviewReplay.response, + summary(reviewReplay.data)?.status === 'WAITING_INBOUND' + && Number(summary(reviewReplay.data)?.version) === Number(forwardedSummary?.version), + trace(reviewReplay.response), OPTIONS) + const reviewMismatch = await context.requestOverseas('POST', reviewPath, + { ...reviewBody, remark: 'DIFFERENT-DO-NOT-APPLY' }) + expectedConflict(context, '卡车审核同键异载荷返回冲突', reviewMismatch) + const reversed = await context.requestOverseas('POST', reviewPath, + { version: Number(forwardedSummary?.version), decision: 'REJECT', + idempotencyKey: `${context.marker}-truck-review-terminal-reverse`.slice(0, 255), + remark: 'REVERSE-DO-NOT-APPLY' }) + expectedConflict(context, '卡车审核终态逆向返回冲突', reversed) const history = await overseas(context, 'GET', '卡车状态冲突后流水回查', path) const historySummary = summary(history.data) const histories = Array.isArray(history.data?.histories) ? history.data.histories : [] @@ -448,14 +481,30 @@ async function looseboardFlow(context, customerId) { const statusPath = query(`${base}/${encodeURIComponent(orderNo)}/status`, { customerId }) const forwarded = await overseas(context, 'PATCH', '散板状态正向推进到已派车', statusPath, - { version: Number(afterStaleSummary?.version), status: 'DISPATCHED', remark: `${context.marker}-FORWARD` }) + { version: Number(afterStaleSummary?.version), status: 'DISPATCHED', + idempotencyKey: `${context.marker}-looseboard-status-dispatched`.slice(0, 255), + remark: `${context.marker}-FORWARD` }) const forwardedSummary = summary(forwarded.data) context.assert(MODULE, '散板状态正向推进成功', forwarded.response, forwardedSummary?.status === 'DISPATCHED' && Number(forwardedSummary?.version) === Number(afterStaleSummary?.version) + 1, `status=${forwardedSummary?.status || 'missing'}, version=${forwardedSummary?.version ?? 'missing'}; ${trace(forwarded.response)}`, OPTIONS) + const forwardBody = { version: Number(afterStaleSummary?.version), status: 'DISPATCHED', + idempotencyKey: `${context.marker}-looseboard-status-dispatched`.slice(0, 255), + remark: `${context.marker}-FORWARD` } + const forwardReplay = await overseas(context, 'PATCH', '散板状态同键同载荷精确重放', statusPath, + forwardBody) + context.assert(MODULE, '散板状态重放复用已派车终态且版本不漂移', forwardReplay.response, + summary(forwardReplay.data)?.status === 'DISPATCHED' + && Number(summary(forwardReplay.data)?.version) === Number(forwardedSummary?.version), + trace(forwardReplay.response), OPTIONS) + const forwardMismatch = await context.requestOverseas('PATCH', statusPath, + { ...forwardBody, remark: 'DIFFERENT-DO-NOT-APPLY' }) + expectedConflict(context, '散板状态同键异载荷返回冲突', forwardMismatch) const reversed = await context.requestOverseas('PATCH', statusPath, - { version: Number(forwardedSummary?.version), status: 'PENDING_ACCEPTANCE', remark: 'REVERSE-DO-NOT-APPLY' }) + { version: Number(forwardedSummary?.version), status: 'PENDING_ACCEPTANCE', + idempotencyKey: `${context.marker}-looseboard-status-terminal-reverse`.slice(0, 255), + remark: 'REVERSE-DO-NOT-APPLY' }) expectedConflict(context, '散板状态逆向回退返回冲突', reversed) const history = await overseas(context, 'GET', '散板状态冲突后流水回查', path) const historySummary = summary(history.data) @@ -501,7 +550,9 @@ async function finalizePendingPurchase(context, resource) { const rejected = await overseas(context, 'POST', '驳回未完成的本轮采购订单', query(`${BASE}/overseas-procurements/${encodeURIComponent(resource.orderNo)}/review`, { customerId: resource.customerId }), - { decision: 'REJECT', remark: `${context.marker}-prerequisite-cleanup` }) + { version: Number(currentSummary?.version), decision: 'REJECT', + idempotencyKey: `${context.marker}-procurement-review-cleanup`.slice(0, 255), + remark: `${context.marker}-prerequisite-cleanup` }) const rejectedSummary = summary(rejected.data) const verified = await overseas(context, 'GET', '回查未完成采购订单最终状态', path) const finalSummary = summary(verified.data) From 79eb3efe61df537c97830f46c1b9b245f5919cef Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 13:07:01 +0800 Subject: [PATCH 103/115] fix(lastleg): align overseas business incremental schema --- deploy/sql/海外区域统一增量-MySQL8.sql | 62 +++++++++++++++++-- .../LocalLooseboardSchemaContractTest.java | 11 ++++ ...OverseasProcurementSchemaContractTest.java | 19 ++++++ .../UsTruckOrderSchemaContractTest.java | 12 ++++ 4 files changed, 100 insertions(+), 4 deletions(-) diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index a64450395..afb0c1055 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -95,7 +95,8 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', - review_remark VARCHAR(1000) NULL COMMENT '审核备注', reviewed_at DATETIME(3) NULL COMMENT '审核时间', + review_remark VARCHAR(1000) NULL COMMENT '审核备注', review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键', + review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希', reviewed_at DATETIME(3) NULL COMMENT '审核时间', version BIGINT NOT NULL DEFAULT 0 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_ll_overseas_procurement_order_no(customer_id,order_no), @@ -104,15 +105,32 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( KEY idx_ll_overseas_procurement_warehouse(customer_id,purchase_warehouse_id,created_at) ) ENGINE=InnoDB COMMENT='海外采购订单'; +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_idempotency_key' +), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER review_remark')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_request_hash' +), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_request_hash CHAR(64) NULL COMMENT ''审核请求内容哈希'' AFTER review_idempotency_key')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history ( id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、REVIEW或UPDATE', from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', - change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '备注', + idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_overseas_procurement_history(customer_id,order_id,created_at) ) ENGINE=InnoDB COMMENT='海外采购操作流水'; +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='idempotency_key' +), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_status_history ADD COLUMN idempotency_key VARCHAR(255) NULL COMMENT ''操作幂等键'' AFTER operator_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + CREATE TABLE IF NOT EXISTS ll_us_truck_order ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_no VARCHAR(64) NOT NULL COMMENT '订单号', transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运地址', @@ -146,11 +164,32 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet ( CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history ( id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', - from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + operation_type VARCHAR(64) NOT NULL COMMENT '具名操作类型', from_status VARCHAR(32) NULL COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_us_truck_history(customer_id,order_id,created_at) ) ENGINE=InnoDB COMMENT='美国卡车订单状态历史'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_us_truck_order_status_history' AND column_name='operation_type'), 'SELECT 1', + 'ALTER TABLE ll_us_truck_order_status_history ADD COLUMN operation_type VARCHAR(64) NOT NULL DEFAULT ''UPDATE'' COMMENT ''具名操作类型'' AFTER customer_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_us_truck_order_status_history' AND column_name='idempotency_key'), 'SELECT 1', + 'ALTER TABLE ll_us_truck_order_status_history ADD COLUMN idempotency_key VARCHAR(255) NULL COMMENT ''操作幂等键'' AFTER operator_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order_operation ( + id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '美国卡车订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(64) NOT NULL COMMENT '具名操作类型', + idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作请求内容哈希', + result_version BIGINT NOT NULL COMMENT '操作完成后的订单版本', created_by VARCHAR(128) NULL COMMENT '操作人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_us_truck_operation_key(customer_id,order_id,idempotency_key), + KEY idx_ll_us_truck_operation_order(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='美国卡车订单幂等操作账'; + CREATE TABLE IF NOT EXISTS ll_local_looseboard_order ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', lastleg_order_id BIGINT NULL COMMENT '关联尾程主订单ID', order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:拼车或优先拼载', pallet_required VARCHAR(8) NOT NULL COMMENT '是否需要打盘:YES或NO', @@ -182,11 +221,26 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_history ( id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、UPDATE或STATUS', from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', - change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '操作备注', + idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '操作备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_local_looseboard_history(customer_id,order_id,created_at) ) ENGINE=InnoDB COMMENT='本土散板操作流水'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_local_looseboard_history' AND column_name='idempotency_key'), 'SELECT 1', + 'ALTER TABLE ll_local_looseboard_history ADD COLUMN idempotency_key VARCHAR(255) NULL COMMENT ''操作幂等键'' AFTER operator_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS ll_local_looseboard_operation ( + id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型', + idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作请求内容哈希', + result_version BIGINT NOT NULL COMMENT '操作完成后的订单版本', created_by VARCHAR(128) NULL COMMENT '操作人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_local_looseboard_operation_key(customer_id,order_id,idempotency_key), + KEY idx_ll_local_looseboard_operation_order(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='本土散板订单幂等操作账'; + -- 尾程主订单状态历史补齐客户隔离键。代码写入 customer_id,已有表需显式升级; -- 先允许 NULL 以便从主订单回填,再收紧为 NOT NULL,脚本可重复执行。 SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java index b67cd7f22..90f0a93c5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java @@ -2,6 +2,8 @@ package com.baiyue.lastleg.looseboard; import org.junit.jupiter.api.Test; +import java.nio.file.Files; +import java.nio.file.Path; import java.nio.charset.StandardCharsets; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -24,4 +26,13 @@ class LocalLooseboardSchemaContractTest { assertTrue(sql.contains("customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键'")); assertTrue(sql.contains("idx_ll_loading_history_customer_order(customer_id,order_id,created_at)")); } + + @Test + void unifiedIncrementalContainsHistoryIdempotencyAndOperationLedger() throws Exception { + String sql = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_local_looseboard_operation")); + assertTrue(sql.contains("uk_ll_local_looseboard_operation_key(customer_id,order_id,idempotency_key)")); + assertTrue(sql.contains("table_name='ll_local_looseboard_history' AND column_name='idempotency_key'")); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java index a90369d8f..b6c1159a0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java @@ -2,6 +2,8 @@ package com.baiyue.lastleg.procurement; import org.junit.jupiter.api.Test; +import java.nio.file.Files; +import java.nio.file.Path; import java.nio.charset.StandardCharsets; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -23,4 +25,21 @@ class OverseasProcurementSchemaContractTest { org.junit.jupiter.api.Assertions.assertFalse(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound")); org.junit.jupiter.api.Assertions.assertFalse(sql.contains("outbound_quantity BIGINT")); } + + @Test + void unifiedIncrementalContainsHistoryIdempotencyKeyForNewAndExistingDatabases() throws Exception { + String sql = Files.readString( + Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + + assertTrue(sql.contains("idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键'")); + assertTrue(sql.contains( + "ALTER TABLE ll_overseas_procurement_status_history ADD COLUMN idempotency_key VARCHAR(255) NULL")); + assertTrue(sql.contains( + "ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_idempotency_key VARCHAR(255) NULL")); + assertTrue(sql.contains( + "ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_request_hash CHAR(64) NULL")); + assertTrue(sql.contains( + "table_name='ll_overseas_procurement_status_history' AND column_name='idempotency_key'")); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java index 8dd0a31d7..a3bc9d3d8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java @@ -2,6 +2,8 @@ package com.baiyue.lastleg.trucking; import org.junit.jupiter.api.Test; +import java.nio.file.Files; +import java.nio.file.Path; import java.nio.charset.StandardCharsets; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -26,4 +28,14 @@ class UsTruckOrderSchemaContractTest { assertTrue(sql.contains("UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key)")); assertTrue(sql.contains("KEY idx_ll_us_truck_history(customer_id,order_id,created_at)")); } + + @Test + void unifiedIncrementalContainsOperationLedgerAndHistoryColumns() throws Exception { + String sql = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_operation")); + assertTrue(sql.contains("uk_ll_us_truck_operation_key(customer_id,order_id,idempotency_key)")); + assertTrue(sql.contains("table_name='ll_us_truck_order_status_history' AND column_name='operation_type'")); + assertTrue(sql.contains("table_name='ll_us_truck_order_status_history' AND column_name='idempotency_key'")); + } } From 2cfaf8e51b83ed075bd6a5a546f78c9d464c02b6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 13:23:05 +0800 Subject: [PATCH 104/115] fix(lastleg): isolate shipping node version stream --- .../com/baiyue/lastleg/service/LoadingCompletedConsumer.java | 5 ++++- .../baiyue/lastleg/service/LoadingCompletedConsumerTest.java | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 5ef81e214..0896e5b77 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -73,8 +73,11 @@ public class LoadingCompletedConsumer { return countOrders(envelope,payload.containerNo()); } + // The loading-plan and container-shipping events are independent aggregate streams. + // Seeding the shipping version from the loading-plan version makes a valid first node + // (aggregateVersion=0) look stale whenever the loading plan has a higher version. shippingGates.initialize(payload.containerBookingId(), envelope.tenantId(), "", - payload.containerNo(), envelope.aggregateVersion(), envelope.handoverVersion()); + payload.containerNo(), 0, envelope.handoverVersion()); String orderNo = orderNoGenerator.forContainerNo(payload.containerNo()); JsonNode rawOrders = envelope.payload().path("orders"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index c54bb904c..23e55decb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -5,7 +5,7 @@ class LoadingCompletedConsumerTest { @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(inboundPlans.convert(anyString(),anyLong())).thenReturn(new InboundPlanResponse(1L,"LIP-1","100",1L,"MSCU1","PENDING_ASSIGNMENT",1,1,BigDecimal.ONE,List.of(),List.of(),List.of())); } @Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),any(InventoryCommandRequests.InTransit.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } - @Test void loadingCreatesAwaitingPickupReadyGate(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU1",2L,1L); } + @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU1",0L,1L); } @Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } @Test void inboundSnapshotUsesCustomerIdAndHasNoPhantomTenantColumn() throws Exception { From 6d8e4c85c50890fc7320dcd680b44e8efbfc4381 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 13:26:05 +0800 Subject: [PATCH 105/115] test(lastleg): align rest client gateway setup --- .../pda/gateway/HttpUnloadPhotoFileGatewayTest.java | 4 +--- .../pda/gateway/HttpWarehouseResourceGatewayTest.java | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGatewayTest.java index 3149e00d5..734406198 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGatewayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGatewayTest.java @@ -23,10 +23,8 @@ class HttpUnloadPhotoFileGatewayTest { RestClient.Builder builder = RestClient.builder(); server = MockRestServiceServer.bindTo(builder).build(); gateway = new HttpUnloadPhotoFileGateway(); - ReflectionTestUtils.setField(gateway, "restClientBuilder", builder); - ReflectionTestUtils.setField(gateway, "baseUrl", "http://file-service"); + ReflectionTestUtils.setField(gateway, "client", builder.baseUrl("http://file-service").build()); ReflectionTestUtils.setField(gateway, "internalToken", "internal-token"); - gateway.initializeClient(); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java index a4005d745..4b26ac3ca 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java @@ -20,10 +20,8 @@ class HttpWarehouseResourceGatewayTest { RestClient.Builder builder = RestClient.builder(); server = MockRestServiceServer.bindTo(builder).build(); gateway = new HttpWarehouseResourceGateway(); - ReflectionTestUtils.setField(gateway, "restClientBuilder", builder); - ReflectionTestUtils.setField(gateway, "baseUrl", "http://warehouse"); + ReflectionTestUtils.setField(gateway, "client", builder.baseUrl("http://warehouse").build()); ReflectionTestUtils.setField(gateway, "internalToken", "test-token"); - gateway.initializeClient(); } @Test From 0302985f217fadf643e339e68e1e9a73ed0a6c7c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 13:43:22 +0800 Subject: [PATCH 106/115] fix(firstleg): settle zero-package measurements safely --- .../mapper/InboundSplitSettlementMapper.java | 7 ++++--- .../PdaSplitSettlementSqlContractTest.java | 12 ++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java index ed0190ff2..bb29c8c8c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java @@ -205,9 +205,10 @@ public interface InboundSplitSettlementMapper { @Update(""" UPDATE fl_inbound_measurement_item - SET package_count=#{packageCount}, - total_volume_cbm=unit_volume_cbm*#{packageCount}, - volume_weight_kg=volume_weight_kg/NULLIF(package_count,0)*#{packageCount}, + SET total_volume_cbm=COALESCE(unit_volume_cbm,0)*#{packageCount}, + volume_weight_kg=CASE WHEN #{packageCount}=0 THEN 0 + ELSE COALESCE(volume_weight_kg/NULLIF(package_count,0)*#{packageCount},0) END, + package_count=#{packageCount}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{measurementItemId} AND tenant_id=#{accountId} """) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java index 636087f87..a7703c107 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java @@ -136,6 +136,18 @@ class PdaSplitSettlementSqlContractTest { .contains("plan_item.package_count = pick_item.picked_package_count"); } + @Test + void zeroPackageSettlementNeverWritesNullVolumeWeight() throws Exception { + String update = String.join(" ", InboundSplitSettlementMapper.class.getMethod( + "updateMeasurementItemForLoaded", long.class, long.class, int.class) + .getAnnotation(Update.class).value()).replaceAll("\\s+", " "); + + assertThat(update) + .contains("volume_weight_kg=CASE WHEN #{packageCount}=0 THEN 0") + .contains("COALESCE(volume_weight_kg/NULLIF(package_count,0)*#{packageCount},0)"); + assertThat(update.indexOf("volume_weight_kg=")).isLessThan(update.indexOf("package_count=#{packageCount}")); + } + @Test void keepsAllocationConfirmedUntilReleaseStep() throws Exception { String consume = InboundPalletAllocationMapper.class.getMethod("consume", From 72f3f9f9715a2524e36e11d996812bfbcc402209 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 13:46:33 +0800 Subject: [PATCH 107/115] fix(firstleg): execute carrier schedule due queries --- .../mapper/CarrierScheduleQueryMapper.java | 8 +++---- .../CarrierScheduleQuerySqlContractTest.java | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java index 1f3e38eca..274bff0d6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java @@ -49,8 +49,8 @@ public interface CarrierScheduleQueryMapper extends BaseMapper selectDue(@Param("now") LocalDateTime now, @Param("limit") int limit); @@ -59,8 +59,8 @@ public interface CarrierScheduleQueryMapper extends BaseMapper Date: Tue, 25 Aug 2026 13:59:03 +0800 Subject: [PATCH 108/115] fix(firstleg): continue same-plan loading allocations --- .../mapper/SmartLoadingCandidateMapper.java | 13 ++++++++++--- .../repository/SmartLoadingRepository.java | 4 ++-- .../firstleg/service/SmartLoadingService.java | 6 ++++-- .../SmartLoadingCandidateMapperContractTest.java | 8 +++++++- .../firstleg/service/SmartLoadingServiceTest.java | 15 ++++++++------- 5 files changed, 31 insertions(+), 15 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index 955715c40..9d1a34e60 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -79,12 +79,19 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba WHERE inbound_detail_id IS NOT NULL AND package_count>0 GROUP BY tenant_id,measurement_id,inbound_detail_id ) i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id - WHERE o.warehouse_id=#{warehouseId} AND o.id=#{inboundId} AND o.process_status='WAIT_SPLIT' + WHERE o.warehouse_id=#{warehouseId} AND o.id=#{inboundId} + AND (o.process_status='WAIT_SPLIT' OR (o.process_status='LOADING' AND EXISTS ( + SELECT 1 FROM fl_loading_plan_item same_plan_item + WHERE same_plan_item.loading_plan_id=#{planId} + AND same_plan_item.account_id=o.tenant_id + AND same_plan_item.inbound_id=o.id + AND same_plan_item.deleted=0 + ))) AND o.actual_gross_weight_kg IS NOT NULL AND o.actual_net_weight_kg IS NOT NULL AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT')) AND i.inbound_detail_id=#{detailId} ORDER BY m.id DESC LIMIT 1 FOR UPDATE """) - Candidate candidate(@Param("warehouseId") long warehouseId,@Param("inboundId") long inboundId, - @Param("detailId") long detailId); + Candidate candidate(@Param("planId") long planId,@Param("warehouseId") long warehouseId, + @Param("inboundId") long inboundId,@Param("detailId") long detailId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java index ab28e444c..73c5bf050 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -31,8 +31,8 @@ public class SmartLoadingRepository { @Resource private InboundOrderMapper inbounds; public List candidates(long warehouseId) { return candidates.candidates(warehouseId); } - public Candidate candidate(long warehouseId, long inboundId, long detailId) { - return candidates.candidate(warehouseId, inboundId, detailId); + public Candidate candidate(long planId, long warehouseId, long inboundId, long detailId) { + return candidates.candidate(planId, warehouseId, inboundId, detailId); } public InboundOrderEntity inbound(long accountId, long inboundId) { return inbounds.selectOne(new LambdaQueryWrapper() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index a3ac10ebb..c03fafb0b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -132,7 +132,8 @@ public class SmartLoadingService { if (replay != null) return replay; LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); - Candidate candidate = repository.candidate(booking.getWarehouseId(), request.inboundId(), request.inboundDetailId()); + Candidate candidate = repository.candidate(planId, booking.getWarehouseId(), request.inboundId(), + request.inboundDetailId()); if (candidate == null) throw conflict("入库货物不存在或尚未完成入库"); requireSameChannel(booking, candidate); requireSameCustomerScope(booking, candidate); @@ -307,7 +308,8 @@ public class SmartLoadingService { throw conflict("计划箱数不能低于已拣货箱数"); } ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); - Candidate candidate = repository.candidate(booking.getWarehouseId(), item.getInboundId(), item.getInboundDetailId()); + Candidate candidate = repository.candidate(planId, booking.getWarehouseId(), item.getInboundId(), + item.getInboundDetailId()); if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数"); requireSameChannel(booking, candidate); requireSameCustomerScope(booking, candidate); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java index f651828fe..f6477f5de 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -44,7 +44,7 @@ class SmartLoadingCandidateMapperContractTest { @Test void detailUsesSameBatchRule() throws Exception { Method detail = SmartLoadingCandidateMapper.class.getDeclaredMethod( - "candidate", long.class, long.class, long.class); + "candidate", long.class, long.class, long.class, long.class); String sql = String.join(" ", detail.getAnnotation(Select.class).value()); assertThat(sql) @@ -53,6 +53,12 @@ class SmartLoadingCandidateMapperContractTest { .contains("SELECT MAX(m2.id)") .contains("o.warehouse_id=#{warehouseId}") .contains("o.process_status='WAIT_SPLIT'") + .contains("o.process_status='LOADING'") + .contains("same_plan_item.loading_plan_id=#{planId}") + .contains("same_plan_item.account_id=o.tenant_id") + .contains("same_plan_item.inbound_id=o.id") + .contains("same_plan_item.deleted=0") + .doesNotContain("o.process_status IN ('WAIT_SPLIT','LOADING')") .contains("unitGrossWeightKg") .contains("unitNetWeightKg") .doesNotContain("i.unit_weight_kg") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 9b5cba633..8f39dfa00 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -242,7 +242,7 @@ class SmartLoadingServiceTest { LoadingPlanEntity plan = plan("EDITING", 0); when(repository.plan(1)).thenReturn(plan); when(repository.booking(99L)).thenReturn(booking()); - when(repository.candidate(100, 10, 20)).thenReturn(candidate(5)); + when(repository.candidate(1, 100, 10, 20)).thenReturn(candidate(5)); when(repository.activeItem(1L, 9L, 20L)).thenReturn(item(7, 9, 20, 4)); BizException error = catchThrowableOfType(() -> service.assign(1, @@ -259,7 +259,7 @@ class SmartLoadingServiceTest { linked.setCustomerId(88L); when(repository.plan(1L)).thenReturn(plan); when(repository.booking(99L)).thenReturn(linked); - when(repository.candidate(100L, 10L, 20L)).thenReturn(candidate(5)); + when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(candidate(5)); BizException error = catchThrowableOfType(() -> service.assign(1L, new AssignRequest(10L, 20L, 1, 0, "assign-customer-mismatch"), 7L, "u"), BizException.class); @@ -357,7 +357,7 @@ class SmartLoadingServiceTest { when(repository.booking(99)).thenReturn(booking()); when(repository.touch(1, 5, 7, "u")).thenReturn(1); when(repository.items(1)).thenReturn(List.of()); - when(repository.candidate(100, 10, 20)).thenReturn(candidate(10)); + when(repository.candidate(1, 100, 10, 20)).thenReturn(candidate(10)); when(repository.activeItem(1L, 9L, 20L)).thenReturn(null); when(loadingPickService.mutationSnapshot(1L)).thenReturn( new LoadingPickService.MutationSnapshot(77L, "LOADING", 6, Map.of())); @@ -368,6 +368,7 @@ class SmartLoadingServiceTest { Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-loading"), 7, "u"); assertThat(result.status()).isEqualTo("LOADING"); + verify(repository).candidate(1L, 100L, 10L, 20L); var order = inOrder(loadingPickService, splitPreloadService, repository, loadingModelBridge); order.verify(loadingPickService).lockMutable(1L); order.verify(splitPreloadService).ensureForRunningLoadingAllocation(9L, 10L, 7L, "u"); @@ -393,7 +394,7 @@ class SmartLoadingServiceTest { Plan result = service.assign(1, request, 7, "u"); assertThat(result.status()).isEqualTo("LOADING"); - verify(repository, never()).candidate(anyLong(), anyLong(), anyLong()); + verify(repository, never()).candidate(anyLong(), anyLong(), anyLong(), anyLong()); verify(cargoClient, never()).detail(anyString(), anyLong(), anyLong(), anyLong()); } @@ -592,7 +593,7 @@ class SmartLoadingServiceTest { when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 3)); when(repository.item(1L, 8L)).thenReturn(line); when(repository.booking(99L)).thenReturn(booking()); - when(repository.candidate(100L, 10L, 20L)).thenReturn(candidate(10)); + when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(candidate(10)); when(repository.items(1L)).thenReturn(List.of(line)); when(repository.touch(1L, 2, 7L, "u")).thenReturn(1); when(repository.update(line)).thenReturn(1); @@ -617,7 +618,7 @@ class SmartLoadingServiceTest { new AdjustRequest(1, 2, 0, "adjust-picked"), 7L, "u")) .hasMessageContaining("不能低于已拣货箱数"); - verify(repository, never()).candidate(anyLong(), anyLong(), anyLong()); + verify(repository, never()).candidate(anyLong(), anyLong(), anyLong(), anyLong()); verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString()); } @@ -878,7 +879,7 @@ class SmartLoadingServiceTest { when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1)); when(repository.booking(99)).thenReturn(booking()); when(repository.items(1)).thenReturn(List.of()); - when(repository.candidate(100, 10, 20)).thenReturn(candidate(10)); + when(repository.candidate(1, 100, 10, 20)).thenReturn(candidate(10)); when(repository.activeItem(1L, 9L, 20L)).thenReturn(null); when(repository.touch(1, 0, 7, "u")).thenReturn(1); } From 909355061521f6e5ded9647d2ea1f15912f57f13 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 13:59:58 +0800 Subject: [PATCH 109/115] test: verify complete PDA batch history --- ...e3-loading-dynamic-final-contract.test.mjs | 5 +++++ .../admin-test-env-modules/phase3-loading.mjs | 20 ++++++++++++------- .../phase3-pda-convergence.mjs | 18 +++++++++++++---- ...-pda-pending-consistency-contract.test.mjs | 8 ++++++-- .../phase3-producer-chain-contract.test.mjs | 11 ++++++++++ 5 files changed, 49 insertions(+), 13 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index 39cdbb7f6..e11b400de 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -45,6 +45,7 @@ test('default fixture publishes a second marker-owned inbound for the same-plan assert.match(entry, /orders-phase3-loading-incremental-fixture/) assert.match(entry, /inbound-measurement-phase3-loading-incremental-fixture/) assert.match(entry, /phase3LoadingIncrementalPackageCount = 2/) + assert.match(entry, /phase3LoadingIncrementalDetailCount = 4/) assert.match(entry, /phase3LoadingIncremental: incrementalFixture/) assert.match(entry, /marker: originalMarker/) assert.doesNotMatch(entry, /phase3LoadingIncremental[\s\S]{0,300}(history|fallback|latest)/i) @@ -74,6 +75,10 @@ test('HTTP chain refreshes after stale inventory and keeps one stable PDA identi assert.match(loading, /id\(incrementedTask\?\.containerBookingId\) === containerBookingId/) assert.match(loading, /const taskDetailPath = `\$\{pdaBase\}\/tasks\/\$\{canonicalTaskId\}\?warehouseId=/) assert.match(loading, /loadingPlanStatus: 'LOADING', finalStatus: 'AWAITING_REVIEW'/) + assert.match(loading, /expectedBatchOperationIds = \[batchKey, skipBatchKey, partialBatchKey, dynamicBatchKey\]/) + assert.match(loading, /expectedBatchOperationIds, incrementalInboundId/) + assert.match(loading, /skipBatchVerified, partialBatchVerified, dynamicIncrementVerified/) + assert.doesNotMatch(loading, /if \(Number\(dynamicCandidate\?\.availablePackageCount\) < 2\) return/) assert.doesNotMatch(loading, /complete-before-downstream/) assert.doesNotMatch(loading, /\/loading\/complete\?tenantId=/) }) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 19dc4aa1d..4a27a4019 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -566,6 +566,10 @@ export async function run(context) { const batchPath = `${pdaBase}/tasks/${canonicalTaskId}/batch` const batchKey = `${context.marker}-bridge-batch`.slice(0, 128) + const skipBatchKey = `${context.marker}-exception-skip-batch`.slice(0, 128) + const partialBatchKey = `${context.marker}-exception-partial-batch`.slice(0, 128) + const dynamicBatchKey = `${context.marker}-dynamic-pick`.slice(0, 128) + const expectedBatchOperationIds = [batchKey, skipBatchKey, partialBatchKey, dynamicBatchKey] const batchPayload = { warehouseId, loadingPlanId, containerBookingId, expectedVersion: Number(claimed?.version), @@ -1021,6 +1025,8 @@ export async function run(context) { const incrementalFixture = context.createdResources?.orchestrationFixtures?.phase3LoadingIncremental const incrementalInboundId = id(incrementalFixture?.inboundId) + let skipBatchVerified = false + let partialBatchVerified = false let dynamicIncrementVerified = false if (!incrementalInboundId || incrementalFixture?.marker !== context.marker || incrementalInboundId === inboundId) { context.addStep(MODULE, '取得本轮独立动态增量入库单', null, false, @@ -1068,10 +1074,10 @@ export async function run(context) { expectedVersion: Number(skipTask?.version), inventorySnapshotToken: skipInventory?.snapshotToken, items: [{ itemId: id(skipTaskItem?.id), action: 'SKIP', version: Number(skipTaskItem?.version), reason: skipReason }], - }, { 'Idempotency-Key': `${context.marker}-exception-skip-batch`.slice(0, 128) }) + }, { 'Idempotency-Key': skipBatchKey }) const skippedTask = context.apiSuccess(MODULE, 'SKIP 携带异常原因并原子解决待拣明细', skipBatchResponse, '', OPTIONS) const skippedTaskItem = skippedTask?.items?.find?.((item) => id(item?.id) === id(skipTaskItem?.id)) - context.assert(MODULE, '合法 SKIP 保存零实拣事实并恢复 AWAITING_REVIEW', skipBatchResponse, + skipBatchVerified = context.assert(MODULE, '合法 SKIP 保存零实拣事实并恢复 AWAITING_REVIEW', skipBatchResponse, Boolean(skipReason.trim()) && skippedTaskItem?.itemStatus === 'SKIPPED' && Number(skippedTaskItem?.pickedPackageCount) === 0 && skippedTask?.workflowStatus === 'AWAITING_REVIEW' && skippedTask?.taskStatus === 'LOADING', @@ -1125,10 +1131,10 @@ export async function run(context) { inventorySnapshotToken: partialInventory?.snapshotToken, items: [{ itemId: id(unresolvedTaskItem?.id), action: 'PARTIAL', finalPickedPackageCount: 1, releaseMode: 'MANUAL', locationIds: [], version: Number(unresolvedTaskItem?.version) }], - }, { 'Idempotency-Key': `${context.marker}-exception-partial-batch`.slice(0, 128) }) + }, { 'Idempotency-Key': partialBatchKey }) const partialTask = context.apiSuccess(MODULE, '合法 PARTIAL 解决未完成明细', partialBatchResponse, '', OPTIONS) const partialTaskItem = partialTask?.items?.find?.((item) => id(item?.id) === id(unresolvedTaskItem?.id)) - context.assert(MODULE, '部分拣货数量守恒并恢复最终可确认状态', partialBatchResponse, + partialBatchVerified = context.assert(MODULE, '部分拣货数量守恒并恢复最终可确认状态', partialBatchResponse, partialTaskItem?.itemStatus === 'PARTIAL' && Number(partialTaskItem?.plannedPackageCount) === 2 && Number(partialTaskItem?.pickedPackageCount) === 1 && partialTask?.workflowStatus === 'AWAITING_REVIEW' && partialTask?.taskStatus === 'LOADING', @@ -1226,7 +1232,6 @@ export async function run(context) { context.assert(MODULE, '动态增量 fixture 至少保留两件可用数量供调整与最小已拣门禁', dynamicCandidatesResponse, Number(dynamicCandidate?.availablePackageCount) >= 2, `availablePackageCount=${dynamicCandidate?.availablePackageCount ?? 'missing'}`, OPTIONS) - if (Number(dynamicCandidate?.availablePackageCount) < 2) return if (Number(dynamicCandidate?.availablePackageCount) >= 2 && reassignedPlanItem?.id) { const adjustResponse = await request(context, 'PATCH', `${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(reassignedPlanItem.id))}`, { @@ -1259,7 +1264,7 @@ export async function run(context) { inventorySnapshotToken: refreshedDynamicInventory?.snapshotToken, items: [{ itemId: id(refreshedDynamicTaskItem?.id), action: 'PICK', pickedPackageCount: Number(reassignedPlanItem?.packageCount), releaseMode: 'MANUAL', locationIds: [], version: Number(refreshedDynamicTaskItem?.version) }], - }, { 'Idempotency-Key': `${context.marker}-dynamic-pick`.slice(0, 128) }) + }, { 'Idempotency-Key': dynamicBatchKey }) const dynamicPickedTask = context.apiSuccess(MODULE, '刷新后完成动态新增项拣货', dynamicBatchResponse, '', OPTIONS) context.assert(MODULE, '动态新增项拣完后同一任务重新进入 AWAITING_REVIEW', dynamicBatchResponse, id(dynamicPickedTask?.id) === canonicalTaskId && dynamicPickedTask?.workflowStatus === 'AWAITING_REVIEW' @@ -1341,7 +1346,8 @@ export async function run(context) { atomicBatchVerified: awaitingReview && atomicBatchReplayVerified, inventorySnapshotVerified: inventorySnapshotVerified && atomicBatchReplayVerified, inventoryMutationVerified, inventoryMutationCleanup, - dynamicIncrementVerified, incrementalInboundId, + skipBatchVerified, partialBatchVerified, dynamicIncrementVerified, + expectedBatchOperationIds, incrementalInboundId, pdaTaskVersion: Number(stagedTask?.version), marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'LOADING', finalStatus: 'AWAITING_REVIEW', } diff --git a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs index 40a7d541c..f76ab00e3 100644 --- a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs @@ -82,7 +82,9 @@ export async function run(context) { const batchOperations = Array.isArray(operations) ? operations.filter((item) => item.actionType === 'BATCH' && Boolean(item.operatorId) && Boolean(item.createdAt)) : [] - const expectedBatchCount = fixture?.dynamicIncrementVerified === true ? 2 : 1 + const expectedBatchOperationIds = Array.isArray(fixture?.expectedBatchOperationIds) + ? fixture.expectedBatchOperationIds.map(id).filter(Boolean) : [] + const actualBatchOperationIds = batchOperations.map((item) => id(item.operationId)).filter(Boolean) context.assert(MODULE, '最终完成前保留领取操作且不伪造复核或完成历史', operationsResponse, Array.isArray(operations) && operations.some((item) => item.actionType === 'CLAIM' && Boolean(item.operatorId) && Boolean(item.createdAt)) @@ -91,9 +93,13 @@ export async function run(context) { `actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS) context.assert(MODULE, 'PDA 原子批次完成审计包含 BATCH 操作', operationsResponse, - batchOperations.length === expectedBatchCount - && new Set(batchOperations.map((item) => item.operationId)).size === expectedBatchCount, - `expectedBatchCount=${expectedBatchCount}, actualBatchCount=${batchOperations.length}, operationIds=${batchOperations.map((item) => item.operationId || 'missing').join(',')}`, OPTIONS) + expectedBatchOperationIds.length === 4 + && new Set(expectedBatchOperationIds).size === 4 + && actualBatchOperationIds.length === 4 + && new Set(actualBatchOperationIds).size === 4 + && expectedBatchOperationIds.every((operationId) => actualBatchOperationIds.includes(operationId)) + && actualBatchOperationIds.every((operationId) => expectedBatchOperationIds.includes(operationId)), + `expectedBatchCount=${expectedBatchOperationIds.length}, actualBatchCount=${actualBatchOperationIds.length}, expectedIds=${expectedBatchOperationIds.join(',') || 'missing'}, actualIds=${actualBatchOperationIds.join(',') || 'missing'}`, OPTIONS) const forged = await request(context, 'POST', `/api/platform/v1/inbounds/${inboundId}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`, { expectedVersion: Number(projection?.version), expectedCount: 999, pickedCount: 999, remark: 'forged' }, @@ -127,6 +133,10 @@ export async function run(context) { context.addStep(MODULE, 'LOADING 增量重开、刷新续拣与已拣不可删减', null, false, 'phase3-loading 未发布 dynamicIncrementVerified=true;不能跳过增量重开、旧 token A0409、刷新续拣和已拣项保护', OPTIONS) } + if (fixture?.skipBatchVerified !== true || fixture?.partialBatchVerified !== true) { + context.addStep(MODULE, 'SKIP 与 PARTIAL 独立原子批次均完成', null, false, + `skipBatchVerified=${fixture?.skipBatchVerified === true}, partialBatchVerified=${fixture?.partialBatchVerified === true};不能按普通 PICK 批次代替异常处理审计`, OPTIONS) + } context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA Android/HBuilderX 真机完整闭环', '原子 batch 真实 HTTP 契约已自动化;扫码、弱网恢复、在线草稿和设备交互仍需 Android/HBuilderX 真机验收', 'device') diff --git a/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs b/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs index 653a53ab0..96bb8c319 100644 --- a/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs @@ -25,8 +25,12 @@ test('AWAITING_REVIEW derives zero client actions and never needs a repeated bat assert.match(convergence, /AWAITING_REVIEW 已全拣任务 actions 为空且不再提交 batch/) assert.match(convergence, /AWAITING_REVIEW 可进入装柜取证只读入口/) assert.match(convergence, /filter\(\(item\) => item\.actionType === 'BATCH'/) - assert.match(convergence, /expectedBatchCount = fixture\?\.dynamicIncrementVerified === true \? 2 : 1/) - assert.match(convergence, /new Set\(batchOperations\.map\(\(item\) => item\.operationId\)\)\.size === expectedBatchCount/) + assert.match(convergence, /expectedBatchOperationIds\.length === 4/) + assert.match(convergence, /actualBatchOperationIds\.length === 4/) + assert.match(convergence, /new Set\(expectedBatchOperationIds\)\.size === 4/) + assert.match(convergence, /new Set\(actualBatchOperationIds\)\.size === 4/) + assert.match(convergence, /expectedBatchOperationIds\.every[\s\S]*actualBatchOperationIds\.every/) + assert.doesNotMatch(convergence, /dynamicIncrementVerified === true \? 2 : 1/) assert.match(convergence, /LOADING 增量重开、刷新续拣与已拣不可删减/) assert.doesNotMatch(convergence, /pendingActions[\s\S]{0,240}request\(context, 'POST'/) }) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 8a5da135f..2f019a12c 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -406,6 +406,7 @@ test('preload and loading versions use immediate operation baselines and exact p }) test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING audit with operator metadata', () => { + const loading = source('./phase3-loading.mjs') const container = source('./phase3-container-loading.mjs') const convergence = source('./phase3-pda-convergence.mjs') assertOrder(container, [ @@ -426,6 +427,13 @@ test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING au assert.match(container, /Boolean\(item\.operatorId\) && Boolean\(item\.createdAt\)/) assert.match(convergence, /PDA 原子批次完成审计包含 BATCH 操作/) assert.match(convergence, /item\.actionType === 'BATCH'/) + for (const key of ['bridge-batch', 'exception-skip-batch', 'exception-partial-batch', 'dynamic-pick']) { + assert.match(loading, new RegExp(key)) + } + assert.match(loading, /expectedBatchOperationIds = \[batchKey, skipBatchKey, partialBatchKey, dynamicBatchKey\]/) + assert.match(loading, /skipBatchVerified, partialBatchVerified, dynamicIncrementVerified/) + assert.match(convergence, /expectedBatchOperationIds\.length === 4/) + assert.doesNotMatch(convergence, /expectedBatchCount = fixture\?\.dynamicIncrementVerified/) }) test('phase3 loading proves mixed-line rollback across task, all items and operations', () => { @@ -493,6 +501,9 @@ test('phase3 loading resolves legal SKIP and PARTIAL exceptions before downstrea assert.match(measurement, /detailBoxCode = `\$\{inboundCode\}#\$\{originalPackageCount\}-\$\{cartonIndex\}`/) assert.match(measurement, /items: draftItems/) assert.match(measurement, /expectedPackages === originalPackageCount/) + assert.match(loading, /skipBatchVerified = context\.assert/) + assert.match(loading, /partialBatchVerified = context\.assert/) + assert.doesNotMatch(loading, /if \(Number\(dynamicCandidate\?\.availablePackageCount\) < 2\) return/) }) test('phase3 loading fixture forces A0 through reversible public location status APIs', () => { From 5fb80d7624d5bc86bca75e9858817f46d9e28658 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 14:07:52 +0800 Subject: [PATCH 110/115] fix(firstleg): reuse running preload lifecycle --- .../split/service/SplitPreloadService.java | 13 ++++-- .../service/SplitPreloadServiceTest.java | 40 ++++++++++++++++++- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java index 778d1ce6d..4162cc757 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java @@ -83,13 +83,20 @@ public class SplitPreloadService { } private void ensureLoadingAllocationLifecycle(long tenantId, long inboundId, long operatorId, - String operatorName, boolean allowConfirmed) { + String operatorName, boolean running) { InboundSnapshot inbound = requireInbound(tenantId, inboundId); - requireWaitingSplit(inbound, "仅待分柜订单可以建立智能装柜预装计划"); PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId); + boolean waitingSplit = isWaitingSplit(inbound.processStatus()); + boolean loading = "LOADING".equals(inbound.processStatus()); + if (!waitingSplit && !(running && loading)) { + throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以建立智能装柜预装计划"); + } + if (loading && plan == null) { + throw new BizException(ResultCode.CONFLICT, "装柜中的订单缺少既有预装计划,不能新增分配"); + } if (plan != null) { if (!PreloadStatus.PENDING.equals(plan.getStatus()) - && !(allowConfirmed && PreloadStatus.CONFIRMED.equals(plan.getStatus()))) { + && !(running && PreloadStatus.CONFIRMED.equals(plan.getStatus()))) { throw new BizException(ResultCode.CONFLICT, "预装计划已进入确认流程,不能修改智能装柜分配"); } return; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java index 9050c7dd4..039693cf0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java @@ -82,7 +82,7 @@ class SplitPreloadServiceTest { @Test void runningLoadingAllocationMayReuseConfirmedPreload() { - when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3)); + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3)); PreloadPlanEntity plan = new PreloadPlanEntity(); plan.setStatus("CONFIRMED"); when(repository.findPlan(10, 20)).thenReturn(plan); @@ -92,9 +92,45 @@ class SplitPreloadServiceTest { verify(repository, never()).createPlan(any()); } + @Test + void runningLoadingAllocationMayReusePendingPreload() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3)); + PreloadPlanEntity plan = new PreloadPlanEntity(); + plan.setStatus("PENDING"); + when(repository.findPlan(10, 20)).thenReturn(plan); + + service.ensureForRunningLoadingAllocation(10, 20, 7, "admin"); + + verify(repository, never()).createPlan(any()); + } + + @Test + void runningLoadingAllocationMustNotCreateMissingPreload() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3)); + when(repository.findPlan(10, 20)).thenReturn(null); + + assertThatThrownBy(() -> service.ensureForRunningLoadingAllocation(10, 20, 7, "admin")) + .hasMessageContaining("缺少既有预装计划"); + + verify(repository, never()).createPlan(any()); + } + + @Test + void ordinaryLoadingAllocationStillRequiresWaitingSplit() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3)); + PreloadPlanEntity plan = new PreloadPlanEntity(); + plan.setStatus("PENDING"); + when(repository.findPlan(10, 20)).thenReturn(plan); + + assertThatThrownBy(() -> service.ensurePendingForLoadingAllocation(10, 20, 7, "admin")) + .hasMessageContaining("仅待分柜订单"); + + verify(repository, never()).createPlan(any()); + } + @Test void runningLoadingAllocationStillRejectsConfirmingPreload() { - when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3)); + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3)); PreloadPlanEntity plan = new PreloadPlanEntity(); plan.setStatus("CONFIRMING"); when(repository.findPlan(10, 20)).thenReturn(plan); From f1af749616ab7196ac2aeed9c3c8f2d875eabd29 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 14:18:13 +0800 Subject: [PATCH 111/115] test: target dynamic loading detail precisely --- ...e3-loading-dynamic-final-contract.test.mjs | 6 ++++++ .../admin-test-env-modules/phase3-loading.mjs | 19 ++++++++++++++++--- .../phase3-producer-chain-contract.test.mjs | 4 ++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index e11b400de..1d99d7a3f 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -56,6 +56,7 @@ test('HTTP chain refreshes after stale inventory and keeps one stable PDA identi 'PDA 原子批次完成拣货并等待人工确认', '五类装柜证据完整且铅封号持久化', 'LOADING 阶段向同一 PDA task 增加入库单', + '动态新增计划明细按 inboundDetailId 唯一定位', '增量保持稳定四元身份、LOADING 不回退并撤回最终完成门禁', '增量后旧 inventory token 明确 A0409 并要求刷新', '增量旧 token 冲突后 plan/task/items 零写入', @@ -71,6 +72,11 @@ test('HTTP chain refreshes after stale inventory and keeps one stable PDA identi '报关与最终完成前保持 LOADING 且不提前写 COMPLETE_LOADING', ]) assert.match(loading, /String\(staleIncrementBatch\.body\?\.message \|\| ''\)\.includes\('库存已变化,请刷新后重试'\)/) + assert.match(loading, /incrementalPlanItems = \(incrementedPlan\?\.items \|\| \[\]\)\.filter[\s\S]*id\(item\?\.inboundDetailId\) === id\(dynamicCandidate\.inboundDetailId\)/) + assert.match(loading, /reassignedPlanItems = \(reassignedPlan\?\.items \|\| \[\]\)\.filter[\s\S]*id\(item\?\.inboundDetailId\) === id\(dynamicCandidate\.inboundDetailId\)/) + assert.match(loading, /重新加入动态明细仍按 inboundDetailId 唯一定位/) + assert.doesNotMatch(loading, /incrementedPlan\?\.items\?\.find\?\.\(\(item\) => id\(item\?\.inboundId\) === incrementalInboundId\)/) + assert.doesNotMatch(loading, /reassignedPlan\?\.items\?\.find\?\.\(\(item\) => id\(item\?\.inboundId\) === incrementalInboundId\)/) assert.match(loading, /id\(incrementedTask\?\.loadingPlanId\) === loadingPlanId/) assert.match(loading, /id\(incrementedTask\?\.containerBookingId\) === containerBookingId/) assert.match(loading, /const taskDetailPath = `\$\{pdaBase\}\/tasks\/\$\{canonicalTaskId\}\?warehouseId=/) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 4a27a4019..9059f6a91 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1160,9 +1160,15 @@ export async function run(context) { inboundId: incrementalInboundId, inboundDetailId: id(dynamicCandidate.inboundDetailId), packageCount: 1, expectedVersion: Number(planBeforeIncrement?.version), operationId: `${context.marker}-dynamic-assign`.slice(0, 128), - }) + }) const incrementedPlan = context.apiSuccess(MODULE, 'LOADING 阶段向同一 PDA task 增加入库单', incrementResponse, '', OPTIONS) - const incrementalPlanItem = incrementedPlan?.items?.find?.((item) => id(item?.inboundId) === incrementalInboundId) + const incrementalPlanItems = (incrementedPlan?.items || []).filter((item) => + id(item?.inboundId) === incrementalInboundId + && id(item?.inboundDetailId) === id(dynamicCandidate.inboundDetailId)) + const incrementalPlanItem = incrementalPlanItems.length === 1 ? incrementalPlanItems[0] : null + context.assert(MODULE, '动态新增计划明细按 inboundDetailId 唯一定位', incrementResponse, + incrementalPlanItems.length === 1 && id(incrementalPlanItem?.inboundDetailId) === id(dynamicCandidate.inboundDetailId), + `inboundId=${incrementalInboundId}, inboundDetailId=${id(dynamicCandidate.inboundDetailId)}, matches=${incrementalPlanItems.length}, planItemId=${id(incrementalPlanItem?.id) || 'missing'}`, OPTIONS) const preservedRemarkItem = incrementedPlan?.items?.find?.((item) => id(item?.id) === id(assignedItem.id)) const incrementedTaskResponse = await context.request('GET', taskDetailPath) const incrementedTask = context.apiSuccess(MODULE, '增量后回查稳定 PDA 四元身份', incrementedTaskResponse, '', OPTIONS) @@ -1228,7 +1234,14 @@ export async function run(context) { }) const reassignedPlan = context.apiSuccess(MODULE, '删除后重新加入同一动态入库单', reassignResponse, '', OPTIONS) let effectiveDynamicPlan = reassignedPlan - let reassignedPlanItem = reassignedPlan?.items?.find?.((item) => id(item?.inboundId) === incrementalInboundId) + const reassignedPlanItems = (reassignedPlan?.items || []).filter((item) => + id(item?.inboundId) === incrementalInboundId + && id(item?.inboundDetailId) === id(dynamicCandidate.inboundDetailId)) + let reassignedPlanItem = reassignedPlanItems.length === 1 ? reassignedPlanItems[0] : null + context.assert(MODULE, '重新加入动态明细仍按 inboundDetailId 唯一定位', reassignResponse, + reassignedPlanItems.length === 1 + && id(reassignedPlanItem?.inboundDetailId) === id(dynamicCandidate.inboundDetailId), + `inboundId=${incrementalInboundId}, inboundDetailId=${id(dynamicCandidate.inboundDetailId)}, matches=${reassignedPlanItems.length}, planItemId=${id(reassignedPlanItem?.id) || 'missing'}`, OPTIONS) context.assert(MODULE, '动态增量 fixture 至少保留两件可用数量供调整与最小已拣门禁', dynamicCandidatesResponse, Number(dynamicCandidate?.availablePackageCount) >= 2, `availablePackageCount=${dynamicCandidate?.availablePackageCount ?? 'missing'}`, OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 2f019a12c..51e47e9e6 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -454,6 +454,10 @@ test('phase3 loading proves mixed-line rollback across task, all items and opera test('phase3 loading proves stale inventory zero-write and defers final completion audit', () => { const loading = source('./phase3-loading.mjs') assert.match(loading, /taskItemsSignature\(afterStaleTask\) === taskItemsSignature\(beforeMutationTask\)/) + assert.match(loading, /动态新增计划明细按 inboundDetailId 唯一定位/) + assert.match(loading, /重新加入动态明细仍按 inboundDetailId 唯一定位/) + assert.equal((loading.match(/id\(item\?\.inboundDetailId\) === id\(dynamicCandidate\.inboundDetailId\)/g) || []).length, 2) + assert.doesNotMatch(loading, /items\?\.find\?\.\(\(item\) => id\(item\?\.inboundId\) === incrementalInboundId\)/) assertOrder(loading, [ '旧 token 冲突对任务、明细、已拣数量和操作历史零写入', '库存变化后刷新 PDA 实时库存快照', From 7eca39e4fd6ae1d82b89a53e8b8b649cc3318f83 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 14:24:59 +0800 Subject: [PATCH 112/115] fix(firstleg): honor terminal partial pick outcomes --- .../baiyue/firstleg/mapper/LoadingPickItemMapper.java | 10 +++++++++- .../repository/LoadingPickItemMapperContractTest.java | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java index d57e01168..81650f914 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -230,7 +230,15 @@ public interface LoadingPickItemMapper extends BaseMapper WHERE pick_item.loading_plan_item_id = plan_item.id AND pick_task.loading_plan_id = #{planId} AND pick_task.task_status = 'LOADING' - AND pick_item.picked_package_count = plan_item.package_count + AND ( + (pick_item.item_status = 'PICKED' + AND pick_item.picked_package_count = plan_item.package_count) + OR (pick_item.item_status = 'PARTIAL' + AND pick_item.picked_package_count > 0 + AND pick_item.picked_package_count < plan_item.package_count) + OR (pick_item.item_status = 'SKIPPED' + AND pick_item.picked_package_count = 0) + ) ) """) long unfinishedCount(@Param("planId") long planId); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java index 47261f748..df0a6c03e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java @@ -39,7 +39,15 @@ class LoadingPickItemMapperContractTest { .contains("plan_item.deleted = 0") .contains("pick_task.loading_plan_id = #{planId}") .contains("pick_task.task_status = 'LOADING'") - .contains("pick_item.picked_package_count = plan_item.package_count"); + .contains("pick_item.item_status = 'PICKED'") + .contains("pick_item.picked_package_count = plan_item.package_count") + .contains("pick_item.item_status = 'PARTIAL'") + .contains("pick_item.picked_package_count > 0") + .contains("pick_item.picked_package_count < plan_item.package_count") + .contains("pick_item.item_status = 'SKIPPED'") + .contains("pick_item.picked_package_count = 0") + .doesNotContain("pick_item.item_status = 'PENDING'") + .doesNotContain("pick_item.item_status = 'PICKING'"); } @Test From 121e8ea619ef6ae8d4f9674732a852eaf94b3f8d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 14:33:38 +0800 Subject: [PATCH 113/115] fix(firstleg): validate settled partial pick totals --- .../com/baiyue/firstleg/mapper/LoadingPickItemMapper.java | 5 +++-- .../repository/LoadingPickItemMapperContractTest.java | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java index 81650f914..08ba4d734 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -216,7 +216,8 @@ public interface LoadingPickItemMapper extends BaseMapper long unresolvedCount(@Param("taskId") long taskId); /** - * 以装柜计划的全部有效明细为完成口径,不能只检查当前 PDA 任务中的明细。 + * 以结算后的装柜计划事实为完成口径,不能只检查当前 PDA 任务中的明细。 + * PARTIAL 在本查询前已把计划箱数收敛为实际拣货箱数;收敛前的严格小于关系由结算计划校验。 */ @Select(""" SELECT COUNT(*) @@ -235,7 +236,7 @@ public interface LoadingPickItemMapper extends BaseMapper AND pick_item.picked_package_count = plan_item.package_count) OR (pick_item.item_status = 'PARTIAL' AND pick_item.picked_package_count > 0 - AND pick_item.picked_package_count < plan_item.package_count) + AND pick_item.picked_package_count = plan_item.package_count) OR (pick_item.item_status = 'SKIPPED' AND pick_item.picked_package_count = 0) ) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java index df0a6c03e..0810397ec 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java @@ -43,11 +43,13 @@ class LoadingPickItemMapperContractTest { .contains("pick_item.picked_package_count = plan_item.package_count") .contains("pick_item.item_status = 'PARTIAL'") .contains("pick_item.picked_package_count > 0") - .contains("pick_item.picked_package_count < plan_item.package_count") + .doesNotContain("pick_item.picked_package_count < plan_item.package_count") .contains("pick_item.item_status = 'SKIPPED'") .contains("pick_item.picked_package_count = 0") .doesNotContain("pick_item.item_status = 'PENDING'") .doesNotContain("pick_item.item_status = 'PICKING'"); + assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql, + "pick_item.picked_package_count = plan_item.package_count")).isEqualTo(2); } @Test From 5abc7543ae21f6403f03b95ba15d5626827a8c09 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 14:41:48 +0800 Subject: [PATCH 114/115] fix(firstleg): align loading completion trigger --- deploy/sql/头程数据库统一增量-MySQL8.sql | 23 +++++++++++++------ .../db/phase3-smart-loading-schema.sql | 23 +++++++++++++------ .../SmartLoadingSqlContractTest.java | 19 +++++++++++++-- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 271ad12d9..e71adf7d2 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -5688,14 +5688,23 @@ BEGIN SET NEW.preload_pushed_at = COALESCE(NEW.preload_pushed_at, CURRENT_TIMESTAMP(3)); ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN IF EXISTS (SELECT 1 FROM fl_loading_plan_item item - LEFT JOIN fl_loading_pick_item pick_item - ON pick_item.loading_plan_item_id = item.id - LEFT JOIN fl_loading_pick_task pick_task - ON pick_task.id = pick_item.pick_task_id - AND pick_task.loading_plan_id = OLD.id WHERE item.loading_plan_id = OLD.id AND item.deleted = 0 - AND (pick_task.id IS NULL - OR pick_item.picked_package_count <> item.package_count)) THEN + AND NOT EXISTS ( + SELECT 1 FROM fl_loading_pick_item pick_item + JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id + WHERE pick_item.loading_plan_item_id = item.id + AND pick_task.loading_plan_id = OLD.id + AND pick_task.task_status = 'LOADING' + AND ( + (pick_item.item_status = 'PICKED' + AND pick_item.picked_package_count = item.package_count) + OR (pick_item.item_status = 'PARTIAL' + AND pick_item.picked_package_count > 0 + AND pick_item.picked_package_count = item.package_count) + OR (pick_item.item_status = 'SKIPPED' + AND pick_item.picked_package_count = 0) + ) + )) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仍有未完成拣货明细,不能完成装柜'; END IF; SET NEW.confirmed_at = COALESCE(NEW.confirmed_at, CURRENT_TIMESTAMP(3)); 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 bd13ba015..ab4efc31c 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -813,14 +813,23 @@ BEGIN SET NEW.preload_pushed_at = COALESCE(NEW.preload_pushed_at, CURRENT_TIMESTAMP(3)); ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN IF EXISTS (SELECT 1 FROM fl_loading_plan_item item - LEFT JOIN fl_loading_pick_item pick_item - ON pick_item.loading_plan_item_id = item.id - LEFT JOIN fl_loading_pick_task pick_task - ON pick_task.id = pick_item.pick_task_id - AND pick_task.loading_plan_id = OLD.id WHERE item.loading_plan_id = OLD.id AND item.deleted = 0 - AND (pick_task.id IS NULL - OR pick_item.picked_package_count <> item.package_count)) THEN + AND NOT EXISTS ( + SELECT 1 FROM fl_loading_pick_item pick_item + JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id + WHERE pick_item.loading_plan_item_id = item.id + AND pick_task.loading_plan_id = OLD.id + AND pick_task.task_status = 'LOADING' + AND ( + (pick_item.item_status = 'PICKED' + AND pick_item.picked_package_count = item.package_count) + OR (pick_item.item_status = 'PARTIAL' + AND pick_item.picked_package_count > 0 + AND pick_item.picked_package_count = item.package_count) + OR (pick_item.item_status = 'SKIPPED' + AND pick_item.picked_package_count = 0) + ) + )) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仍有未完成拣货明细,不能完成装柜'; END IF; SET NEW.confirmed_at = COALESCE(NEW.confirmed_at, CURRENT_TIMESTAMP(3)); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index 5025d7431..27c6c17a3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -208,8 +208,6 @@ class SmartLoadingSqlContractTest { .contains("SET MESSAGE_TEXT = '装柜状态只允许单向推进'") .contains("IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN") .contains("ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN") - .contains("LEFT JOIN fl_loading_pick_item pick_item") - .contains("pick_item.picked_package_count <> item.package_count") .contains("IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN") .contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))") .contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数") @@ -226,6 +224,23 @@ class SmartLoadingSqlContractTest { .doesNotContain("CONFIRMED") .doesNotContain("ROLLED_BACK") .doesNotContain("WITHDRAWN"); + assertLoadingCompletionTrigger(migration()); + assertLoadingCompletionTrigger(unifiedMigration()); + } + + private static void assertLoadingCompletionTrigger(String sql) { + assertThat(sql) + .contains("AND NOT EXISTS (") + .contains("pick_task.loading_plan_id = OLD.id") + .contains("pick_task.task_status = 'LOADING'") + .contains("pick_item.item_status = 'PICKED'") + .contains("pick_item.item_status = 'PARTIAL'") + .contains("pick_item.picked_package_count > 0") + .contains("pick_item.item_status = 'SKIPPED'") + .contains("pick_item.picked_package_count = 0") + .doesNotContain("pick_item.picked_package_count <> item.package_count"); + assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql, + "pick_item.picked_package_count = item.package_count")).isEqualTo(2); } @Test From f325d57fc142c1b56b16d73b7673a2c2e074a04c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 14:46:06 +0800 Subject: [PATCH 115/115] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitlab-ci.yml | 13 + AGENTS.md | 1 + Jenkinsfile | 4 +- Jenkinsfile.service | 3 +- README.md | 2 +- common-web/AGENTS.md | 2 + common-web/pom.xml | 5 + ...eignTracePropagationAutoConfiguration.java | 18 ++ .../web/FeignTraceRequestInterceptor.java | 41 +++ .../com/baiyue/common/web/TraceParent.java | 8 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../common/web/CorrelationIdFilterTest.java | 2 + ...TracePropagationAutoConfigurationTest.java | 37 +++ .../web/FeignTraceRequestInterceptorTest.java | 80 +++++ .../baiyue/common/web/TraceParentTest.java | 8 + .../nacos/overseas/event-bridge-service.yml | 1 + config/nacos/overseas/gateway-overseas.yml | 6 + .../overseas/overseas-security-service.yml | 7 + deploy/docker-compose.overseas.yml | 16 + deploy/scripts/deploy-built-service.sh | 13 +- deploy/scripts/deploy-services.sh | 15 +- deploy/service-map.yml | 5 + deploy/sql/README.md | 2 +- deploy/sql/海外区域统一增量-MySQL8.sql | 32 +- docs/同步调用依赖与失败传播治理基线.md | 75 ++++- docs/头程项目目录结构与服务说明.md | 6 +- event-bridge-service/AGENTS.md | 4 +- .../docs/development-observability-slo.md | 67 +++++ .../eventbridge/mapper/BridgeInboxMapper.java | 18 ++ .../EventBridgeInboxMetricsSampler.java | 103 +++++++ .../metrics/EventBridgeMetrics.java | 121 ++++++++ .../metrics/InboxMetricsSnapshot.java | 50 ++++ .../service/EventInboxService.java | 5 + .../service/EventReplayService.java | 26 ++ .../service/HttpFirstlegEventClient.java | 24 +- .../HttpSecurityProjectionEventClient.java | 56 ++++ .../service/OverseasEventDispatcher.java | 49 ++- .../SecurityProjectionEventClient.java | 7 + .../src/main/resources/application.yml | 1 + .../EventBridgeInboxMetricsSamplerTest.java | 54 ++++ .../metrics/EventBridgeMetricsTest.java | 58 ++++ .../EventBridgeObservabilityContractTest.java | 41 +++ .../service/EventInboxServiceTest.java | 14 + .../service/EventReplayServiceTest.java | 23 ++ .../service/HttpFirstlegEventClientTest.java | 26 ++ .../service/OverseasEventDispatcherTest.java | 128 +++++++- .../mapper/LoadingPickItemMapper.java | 18 +- .../LoadingCompletedOutboxRepository.java | 2 + .../LoadingCompletedOutboxRepositoryTest.java | 38 +++ .../LoadingPickItemMapperContractTest.java | 25 ++ gateway-overseas/AGENTS.md | 1 + .../gateway/config/GatewaySecurityConfig.java | 3 +- .../OverseasSecurityProjectionProperties.java | 17 ++ .../HttpOverseasSecurityValidationClient.java | 37 +++ .../OverseasSecurityProjectionFilter.java | 74 +++++ .../OverseasSecurityValidationClient.java | 8 + .../src/main/resources/application.yml | 4 + .../OverseasSecurityProjectionFilterTest.java | 83 ++++++ ...astleg-internal-modularization-baseline.md | 199 +++++++++++++ .../scripts/analyze-lastleg-dependencies.mjs | 43 ++- .../analyze-lastleg-dependencies.test.mjs | 76 +++++ .../config/LastlegRestClientFactory.java | 19 +- .../controller/LastlegOrderController.java | 10 +- .../in/web/PlatformVehicleController.java | 103 +++++++ .../out/persistence}/VehicleMapper.java | 7 +- .../VehiclePersistenceAdapter.java | 121 ++++++++ .../out/persistence/VehicleRecord.java} | 4 +- .../VehicleApplicationService.java | 165 ++++++++++ .../model/VehicleModels.java} | 6 +- .../port/out/VehicleRepository.java | 45 +++ .../controller/PlatformVehicleController.java | 58 ---- .../vehicle/service/VehicleService.java | 143 --------- .../UnloadCompletedOutboxRepository.java | 2 + .../service/LoadingCompletedConsumer.java | 7 + .../ContainerShippingNodeConsumer.java | 6 + .../config/LastlegRestClientFactoryTest.java | 84 ++++++ .../LastlegOrderControllerTest.java | 15 + ...PlatformVehicleControllerContractTest.java | 2 +- .../VehicleApplicationServiceTest.java | 64 ++++ .../vehicle/service/VehicleServiceTest.java | 63 ---- .../UnloadCompletedOutboxRepositoryTest.java | 38 +++ .../service/LoadingCompletedConsumerTest.java | 2 + .../ContainerShippingNodeConsumerTest.java | 15 + .../lastleg-slice-dependencies.json | 57 ++++ overseas-security-service/AGENTS.md | 14 + overseas-security-service/pom.xml | 22 ++ .../OverseasSecurityApplication.java | 11 + .../OverseasSecurityController.java | 52 ++++ .../dto/OverseasSecurityDtos.java | 28 ++ .../security/InternalServiceTokenGuard.java | 26 ++ .../OverseasSecurityValidationService.java | 44 +++ .../service/ProjectionMonotonicity.java | 19 ++ .../SecurityProjectionRedisMirror.java | 36 +++ .../service/SecurityProjectionService.java | 153 ++++++++++ .../src/main/resources/application.yml | 28 ++ .../main/resources/db/overseas-security.sql | 31 ++ .../internal-overseas-security-v1.yaml | 26 ++ .../OverseasSecurityContractTest.java | 51 ++++ ...OverseasSecurityValidationServiceTest.java | 36 +++ .../service/ProjectionMonotonicityTest.java | 28 ++ pom.xml | 29 ++ scripts/java-topology-contract.test.mjs | 34 ++- scripts/overseas-deployment-contract.test.mjs | 9 +- .../provision-development-service-jobs.groovy | 2 +- scripts/synchronous-dependency-baseline.mjs | 230 ++++++++++++++ .../synchronous-dependency-contract.test.mjs | 281 +++++++++++------- 106 files changed, 3682 insertions(+), 450 deletions(-) create mode 100644 common-web/src/main/java/com/baiyue/common/web/FeignTracePropagationAutoConfiguration.java create mode 100644 common-web/src/main/java/com/baiyue/common/web/FeignTraceRequestInterceptor.java create mode 100644 common-web/src/test/java/com/baiyue/common/web/FeignTracePropagationAutoConfigurationTest.java create mode 100644 common-web/src/test/java/com/baiyue/common/web/FeignTraceRequestInterceptorTest.java create mode 100644 config/nacos/overseas/overseas-security-service.yml create mode 100644 event-bridge-service/docs/development-observability-slo.md create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSampler.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/InboxMetricsSnapshot.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/service/SecurityProjectionEventClient.java create mode 100644 event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSamplerTest.java create mode 100644 event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java create mode 100644 event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeObservabilityContractTest.java create mode 100644 event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFirstlegEventClientTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java create mode 100644 gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java create mode 100644 lastleg-service/docs/lastleg-internal-modularization-baseline.md create mode 100644 lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java rename lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/{mapper => adapter/out/persistence}/VehicleMapper.java (55%) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java rename lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/{entity/Vehicle.java => adapter/out/persistence/VehicleRecord.java} (88%) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java rename lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/{dto/VehicleDtos.java => application/model/VehicleModels.java} (94%) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleController.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleService.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/config/LastlegRestClientFactoryTest.java rename lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/{controller => adapter/in/web}/PlatformVehicleControllerContractTest.java (96%) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java create mode 100644 lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json create mode 100644 overseas-security-service/AGENTS.md create mode 100644 overseas-security-service/pom.xml create mode 100644 overseas-security-service/src/main/java/com/baiyue/overseassecurity/OverseasSecurityApplication.java create mode 100644 overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java create mode 100644 overseas-security-service/src/main/java/com/baiyue/overseassecurity/dto/OverseasSecurityDtos.java create mode 100644 overseas-security-service/src/main/java/com/baiyue/overseassecurity/security/InternalServiceTokenGuard.java create mode 100644 overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java create mode 100644 overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/ProjectionMonotonicity.java create mode 100644 overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java create mode 100644 overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java create mode 100644 overseas-security-service/src/main/resources/application.yml create mode 100644 overseas-security-service/src/main/resources/db/overseas-security.sql create mode 100644 overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml create mode 100644 overseas-security-service/src/test/java/com/baiyue/overseassecurity/OverseasSecurityContractTest.java create mode 100644 overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationServiceTest.java create mode 100644 overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/ProjectionMonotonicityTest.java create mode 100644 scripts/synchronous-dependency-baseline.mjs diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f05dade2c..865a9e4b2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -29,6 +29,18 @@ OpenAPI有效性: - npm test --prefix scripts/openapi-validation - npm run validate --prefix scripts/openapi-validation +静态分析报告: + stage: verify + allow_failure: true + script: + - mvn $MAVEN_CLI_OPTS -Pstatic-analysis-report -DskipTests verify + artifacts: + when: always + expire_in: 7 days + paths: + - "**/target/spotbugs*.xml" + - "**/target/site/spotbugs.html" + 校验与测试: stage: verify script: @@ -64,6 +76,7 @@ OpenAPI有效性: - finance/target/*.jar - warehouse/target/*.jar - warehouse-overseas-service/target/*.jar + - overseas-security-service/target/*.jar - lastleg-service/target/*.jar - file/target/*.jar - print/target/*.jar diff --git a/AGENTS.md b/AGENTS.md index 2b612f218..5a0872a4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ | 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` | | 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | | 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | +| 海外 IAM 状态与权限版本投影 | `overseas-security-service/` | `overseas-security-service/AGENTS.md`;消费三类 IAM 安全事件并为海外 Gateway 提供内部 fail-closed 校验 | | 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | | 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | | 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | diff --git a/Jenkinsfile b/Jenkinsfile index a06ba1017..be2593cce 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,6 +1,6 @@ def serviceModules = [ 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service' ] def serviceCheckboxes = [ @@ -17,6 +17,7 @@ def serviceCheckboxes = [ 'integration-service': 'SERVICE_INTEGRATION', 'lastleg-service': 'SERVICE_LASTLEG', 'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS', + 'overseas-security-service': 'SERVICE_OVERSEAS_SECURITY', 'event-bridge-service': 'SERVICE_EVENT_BRIDGE', 'audit-service': 'SERVICE_AUDIT' ] @@ -68,6 +69,7 @@ pipeline { booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)') booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)') booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)') + booleanParam(name: 'SERVICE_OVERSEAS_SECURITY', defaultValue: false, description: '海外IAM安全投影服务(overseas-security-service)') booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;默认同时部署国内和海外实例)') choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS'], description: 'BOTH 同时部署国内和海外实例;CN/OVERSEAS 用于单区故障重跑') booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)') diff --git a/Jenkinsfile.service b/Jenkinsfile.service index bd81515d2..88c8bac39 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -1,6 +1,6 @@ def serviceModules = [ 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service' ] def serviceJars = [ @@ -17,6 +17,7 @@ def serviceJars = [ 'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar', 'lastleg-service': 'lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar', 'warehouse-overseas-service': 'warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar', + 'overseas-security-service': 'overseas-security-service/target/overseas-security-service-1.0.0-SNAPSHOT.jar', 'event-bridge-service': 'event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar', 'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar' ] diff --git a/README.md b/README.md index d8202d879..604e465ae 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - `firstleg`:头程揽收、入仓、装载、发运和交接。 - `warehouse`:国内仓储能力。 - `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。 -- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`:海外公网入口、海外尾程与入仓业务、海外仓储资源。 +- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`、`overseas-security-service`:海外公网入口、海外尾程与入仓业务、海外仓储资源及 IAM 安全投影。 - `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。 - `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。 - `platform-bom`:统一管理仓库内公共依赖版本。 diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index 19fdeacb0..d2312b7cf 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -12,6 +12,7 @@ | 链路上下文 | `src/main/java/com/baiyue/common/web/CorrelationContext.java` | | 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` | | W3C Trace Context | `src/main/java/com/baiyue/common/web/TraceParent.java`;严格接收 version 00,响应 `traceparent`、`X-Trace-Id` 与 MDC `traceId` 使用同一 trace ID | +| Feign Trace 传播 | `FeignTraceRequestInterceptor.java`、`FeignTracePropagationAutoConfiguration.java`;仅 Feign 存在时装配,optional 依赖不向无 Feign 服务传递 | | 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 | | 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 | | 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` | @@ -25,5 +26,6 @@ - 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。 - 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。 - 入站 Trace 优先级固定为:合法单值 `traceparent` > 完全缺失 `traceparent` 时的 32 位非零十六进制 `X-Trace-Id` > 新 trace。只要 `traceparent` 已出现但重复、超长、格式非法或含全零 ID,就必须生成新 trace,不得回退信任 `X-Trace-Id`;请求 ID 独立按 `X-Request-Id` 校验或生成。 +- 同步 Feign 出站固定先移除调用模板中的 `X-Request-Id`、`X-Trace-Id`、`traceparent`,再仅从当前 MDC 的合法请求 ID 和 trace ID 重建;`traceparent` 沿用当前 trace ID、每次调用生成新非零 span ID、flags 固定 `01`。不得从业务参数、远端返回头或无效 MDC 建立上下文;本能力不处理 Reactor、异步线程、重试、超时或 fallback。 - 从父目录运行 `mvn -pl common-web -am test`。 - 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。 diff --git a/common-web/pom.xml b/common-web/pom.xml index fa892aee9..3a5a14627 100644 --- a/common-web/pom.xml +++ b/common-web/pom.xml @@ -39,6 +39,11 @@ io.micrometer micrometer-registry-prometheus + + io.github.openfeign + feign-core + true + org.springframework.boot spring-boot-starter-test diff --git a/common-web/src/main/java/com/baiyue/common/web/FeignTracePropagationAutoConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/FeignTracePropagationAutoConfiguration.java new file mode 100644 index 000000000..e4223e528 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/FeignTracePropagationAutoConfiguration.java @@ -0,0 +1,18 @@ +package com.baiyue.common.web; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; + +/** Enables synchronous Feign trace propagation without making Feign a required dependency. */ +@AutoConfiguration(afterName = "org.springframework.cloud.openfeign.FeignAutoConfiguration") +@ConditionalOnClass(name = "feign.RequestInterceptor") +public class FeignTracePropagationAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(FeignTraceRequestInterceptor.class) + FeignTraceRequestInterceptor feignTraceRequestInterceptor() { + return new FeignTraceRequestInterceptor(); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/FeignTraceRequestInterceptor.java b/common-web/src/main/java/com/baiyue/common/web/FeignTraceRequestInterceptor.java new file mode 100644 index 000000000..88303c069 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/FeignTraceRequestInterceptor.java @@ -0,0 +1,41 @@ +package com.baiyue.common.web; + +import feign.RequestInterceptor; +import feign.RequestTemplate; +import org.slf4j.MDC; +import org.springframework.core.Ordered; + +import java.util.regex.Pattern; + +/** Propagates the trusted synchronous request trace context to every Feign invocation. */ +public final class FeignTraceRequestInterceptor implements RequestInterceptor, Ordered { + private static final Pattern SAFE_REQUEST_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}"); + + @Override + public void apply(RequestTemplate template) { + removeManagedHeaders(template); + + String requestId = MDC.get(CorrelationContext.REQUEST_ID); + if (requestId != null && SAFE_REQUEST_ID.matcher(requestId).matches()) { + template.header(CorrelationContext.REQUEST_ID_HEADER, requestId); + } + + String traceId = MDC.get(CorrelationContext.TRACE_ID); + if (TraceParent.validLegacyTraceId(traceId)) { + TraceParent outbound = TraceParent.outboundChild(traceId); + template.header(CorrelationContext.TRACE_ID_HEADER, outbound.traceId()); + template.header(CorrelationContext.TRACE_PARENT_HEADER, outbound.value()); + } + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } + + private void removeManagedHeaders(RequestTemplate template) { + template.removeHeader(CorrelationContext.REQUEST_ID_HEADER); + template.removeHeader(CorrelationContext.TRACE_ID_HEADER); + template.removeHeader(CorrelationContext.TRACE_PARENT_HEADER); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/TraceParent.java b/common-web/src/main/java/com/baiyue/common/web/TraceParent.java index 2fbd3a1ab..2ece6f081 100644 --- a/common-web/src/main/java/com/baiyue/common/web/TraceParent.java +++ b/common-web/src/main/java/com/baiyue/common/web/TraceParent.java @@ -55,6 +55,14 @@ public record TraceParent(String traceId, String parentId, String traceFlags) { return new TraceParent(traceId, randomNonZero(8), "00"); } + /** Creates a sampled outbound child for a trusted current trace ID. */ + public static TraceParent outboundChild(String currentTraceId) { + if (!validLegacyTraceId(currentTraceId)) { + throw new IllegalArgumentException("Current trace ID is invalid"); + } + return new TraceParent(currentTraceId.toLowerCase(java.util.Locale.ROOT), randomNonZero(8), "01"); + } + public static boolean validLegacyTraceId(String value) { return value != null && LEGACY_TRACE_ID.matcher(value).matches() && !ZERO_TRACE_ID.equalsIgnoreCase(value); diff --git a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 66426422f..06a277bae 100644 --- a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,2 +1,3 @@ com.baiyue.common.web.LongIdJacksonConfiguration com.baiyue.common.web.CommonWebAutoConfiguration +com.baiyue.common.web.FeignTracePropagationAutoConfiguration diff --git a/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java b/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java index c73051fbe..67d4bd3be 100644 --- a/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java @@ -33,6 +33,8 @@ class CorrelationIdFilterTest { .matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); assertThat(chain.traceId).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); assertThat(chain.spanId).matches("[0-9a-f]{16}").isNotEqualTo("0000000000000000"); + assertThat(org.slf4j.MDC.get(CorrelationContext.TRACE_ID)).isNull(); + assertThat(org.slf4j.MDC.get(CorrelationContext.SPAN_ID)).isNull(); } @Test diff --git a/common-web/src/test/java/com/baiyue/common/web/FeignTracePropagationAutoConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/FeignTracePropagationAutoConfigurationTest.java new file mode 100644 index 000000000..2aeec044f --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/FeignTracePropagationAutoConfigurationTest.java @@ -0,0 +1,37 @@ +package com.baiyue.common.web; + +import feign.RequestInterceptor; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +class FeignTracePropagationAutoConfigurationTest { + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(FeignTracePropagationAutoConfiguration.class)); + + @Test + void registersTraceInterceptorWhenFeignIsPresent() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(FeignTraceRequestInterceptor.class); + assertThat(context.getBeansOfType(RequestInterceptor.class)).hasSize(1); + }); + } + + @Test + void doesNotLoadFeignConfigurationWhenFeignIsAbsent() { + contextRunner.withClassLoader(new FilteredClassLoader(RequestInterceptor.class)) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean("feignTraceRequestInterceptor"); + }); + } + + @Test + void backsOffOnlyForAnApplicationProvidedTraceInterceptor() { + contextRunner.withBean(FeignTraceRequestInterceptor.class, FeignTraceRequestInterceptor::new) + .run(context -> assertThat(context).hasSingleBean(FeignTraceRequestInterceptor.class)); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/FeignTraceRequestInterceptorTest.java b/common-web/src/test/java/com/baiyue/common/web/FeignTraceRequestInterceptorTest.java new file mode 100644 index 000000000..d87b3d445 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/FeignTraceRequestInterceptorTest.java @@ -0,0 +1,80 @@ +package com.baiyue.common.web; + +import feign.RequestTemplate; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.core.Ordered; + +import static org.assertj.core.api.Assertions.assertThat; + +class FeignTraceRequestInterceptorTest { + private final FeignTraceRequestInterceptor interceptor = new FeignTraceRequestInterceptor(); + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void runsLastSoTrustedContextHasFinalHeaderAuthority() { + assertThat(interceptor.getOrder()).isEqualTo(Ordered.LOWEST_PRECEDENCE); + } + + @Test + void overwritesCallerHeadersFromTrustedMdcAndCreatesNewSampledSpan() { + MDC.put(CorrelationContext.REQUEST_ID, "req-trusted-123"); + MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736"); + RequestTemplate template = templateWithSpoofedHeaders(); + + interceptor.apply(template); + + assertThat(template.headers().get(CorrelationContext.REQUEST_ID_HEADER)) + .containsExactly("req-trusted-123"); + assertThat(template.headers().get(CorrelationContext.TRACE_ID_HEADER)) + .containsExactly("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(template.headers().get(CorrelationContext.TRACE_PARENT_HEADER)) + .singleElement().asString() + .matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); + } + + @Test + void createsDifferentNonZeroSpanForEachSynchronousInvocation() { + MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736"); + RequestTemplate first = new RequestTemplate(); + RequestTemplate second = new RequestTemplate(); + + interceptor.apply(first); + interceptor.apply(second); + + assertThat(singleHeader(first, CorrelationContext.TRACE_PARENT_HEADER)) + .isNotEqualTo(singleHeader(second, CorrelationContext.TRACE_PARENT_HEADER)); + } + + @Test + void stripsManagedCallerHeadersWhenTrustedContextIsMissingOrInvalid() { + MDC.put(CorrelationContext.REQUEST_ID, "invalid request id"); + MDC.put(CorrelationContext.TRACE_ID, "not-a-w3c-trace-id"); + RequestTemplate template = templateWithSpoofedHeaders(); + + interceptor.apply(template); + + assertThat(template.headers()).doesNotContainKeys( + CorrelationContext.REQUEST_ID_HEADER, + CorrelationContext.TRACE_ID_HEADER, + CorrelationContext.TRACE_PARENT_HEADER); + } + + private RequestTemplate templateWithSpoofedHeaders() { + RequestTemplate template = new RequestTemplate(); + template.header(CorrelationContext.REQUEST_ID_HEADER, "req-business-parameter"); + template.header(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + template.header(CorrelationContext.TRACE_PARENT_HEADER, + "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00"); + return template; + } + + private String singleHeader(RequestTemplate template, String name) { + return template.headers().get(name).iterator().next(); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java b/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java index c97edfa34..e59be28aa 100644 --- a/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java @@ -43,4 +43,12 @@ class TraceParentTest { assertThat(root.value()).matches( "00-abcdef0123456789abcdef0123456789-[0-9a-f]{16}-00"); } + + @Test + void createsSampledOutboundChildForTrustedCurrentTrace() { + TraceParent child = TraceParent.outboundChild("4BF92F3577B34DA6A3CE929D0E0E4736"); + + assertThat(child.value()).matches( + "00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); + } } diff --git a/config/nacos/overseas/event-bridge-service.yml b/config/nacos/overseas/event-bridge-service.yml index 27c492a4b..bbc198d5c 100644 --- a/config/nacos/overseas/event-bridge-service.yml +++ b/config/nacos/overseas/event-bridge-service.yml @@ -10,6 +10,7 @@ baiyue: data-residency: ${DATA_RESIDENCY:OVERSEAS} event-dispatcher: consumer-url: ${EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events} + security-consumer-url: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:http://overseas-security-service:9307/internal/overseas-security/v1/events} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} diff --git a/config/nacos/overseas/gateway-overseas.yml b/config/nacos/overseas/gateway-overseas.yml index e16f76e10..11e2deb34 100644 --- a/config/nacos/overseas/gateway-overseas.yml +++ b/config/nacos/overseas/gateway-overseas.yml @@ -1,3 +1,9 @@ springdoc: api-docs.enabled: ${SWAGGER_ENABLED:false} swagger-ui.enabled: ${SWAGGER_ENABLED:false} +baiyue: + security: + overseas-projection: + service-url: ${OVERSEAS_SECURITY_SERVICE_URL:http://overseas-security-service:9307} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + timeout: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:2s} diff --git a/config/nacos/overseas/overseas-security-service.yml b/config/nacos/overseas/overseas-security-service.yml new file mode 100644 index 000000000..80565c445 --- /dev/null +++ b/config/nacos/overseas/overseas-security-service.yml @@ -0,0 +1,7 @@ +springdoc: + api-docs.enabled: ${SWAGGER_ENABLED:false} + swagger-ui.enabled: ${SWAGGER_ENABLED:false} +baiyue: + security: + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index 5eb84191d..f41f67f15 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -47,6 +47,8 @@ services: CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false} AUTH_SESSION_VALIDATION_URL: ${AUTH_SESSION_VALIDATION_URL} PDA_SESSION_VALIDATION_TIMEOUT: ${PDA_SESSION_VALIDATION_TIMEOUT:-2s} + OVERSEAS_SECURITY_SERVICE_URL: ${OVERSEAS_SECURITY_SERVICE_URL:-http://overseas-security-service:9307} + OVERSEAS_SECURITY_VALIDATION_TIMEOUT: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:-2s} lastleg-service: <<: *overseas-common @@ -92,11 +94,25 @@ services: EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_OVERSEAS_DB_USERNAME} EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_OVERSEAS_DB_PASSWORD} EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/events} + OVERSEAS_SECURITY_EVENT_CONSUMER_URL: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:-http://overseas-security-service:9307/internal/overseas-security/v1/events} networks: firstleg-overseas-network: infra-network: aliases: [event-bridge-overseas] + overseas-security-service: + <<: *overseas-common + image: ${IMAGE_REPOSITORY}/overseas-security-service:${IMAGE_TAG} + container_name: firstleg-${DEPLOY_ENV:-test}-overseas-security + ports: ["${PUBLISHED_BIND_ADDRESS:-127.0.0.1}:${OVERSEAS_SECURITY_PORT:-9307}:9307"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/overseas-security-service:/app/logs"] + environment: + <<: *overseas-environment + SERVER_PORT: 9307 + OVERSEAS_SECURITY_DB_URL: ${OVERSEAS_SECURITY_DB_URL} + OVERSEAS_SECURITY_DB_USERNAME: ${OVERSEAS_SECURITY_DB_USERNAME} + OVERSEAS_SECURITY_DB_PASSWORD: ${OVERSEAS_SECURITY_DB_PASSWORD} + networks: firstleg-overseas-network: name: firstleg-${DEPLOY_ENV:-test}-overseas-network diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index f20ca9be2..b8d703d8c 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -67,7 +67,7 @@ env_file_value() { } case "$SERVICE" in - gateway-overseas|lastleg-service|warehouse-overseas-service) + gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) COMPOSE_FILE="deploy/docker-compose.overseas.yml" PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas" ;; @@ -101,6 +101,15 @@ if [ "$SERVICE" = "gateway-overseas" ]; then fi fi +if [ "$SERVICE" = "overseas-security-service" ]; then + for key in OVERSEAS_SECURITY_DB_URL OVERSEAS_SECURITY_DB_USERNAME OVERSEAS_SECURITY_DB_PASSWORD; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 overseas-security-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done +fi + for file in "$ENV_FILE" "$COMPOSE_FILE"; do if [ ! -f "$file" ]; then echo "必需文件不存在: $file" >&2 @@ -118,7 +127,7 @@ if [ "$SERVICE" = "finance" ]; then fi case "$SERVICE" in - gateway-overseas|lastleg-service|warehouse-overseas-service) + gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then echo "部署海外服务时,环境文件必须包含非空 $key" >&2 diff --git a/deploy/scripts/deploy-services.sh b/deploy/scripts/deploy-services.sh index 7c9983f82..abdbafef5 100644 --- a/deploy/scripts/deploy-services.sh +++ b/deploy/scripts/deploy-services.sh @@ -39,7 +39,7 @@ env_file_value() { DEPLOY_REGION=CN for service in $SERVICES; do case "$service" in - gateway-overseas|lastleg-service|warehouse-overseas-service) DEPLOY_REGION=OVERSEAS ;; + gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) DEPLOY_REGION=OVERSEAS ;; event-bridge-service) BRIDGE_REGION="${EVENT_BRIDGE_REGION_OVERRIDE:-}" if [ -z "$BRIDGE_REGION" ] && env_file_has_key REGION_CODE; then @@ -60,7 +60,7 @@ done if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then for service in $SERVICES; do case "$service" in - gateway-overseas|lastleg-service|warehouse-overseas-service|event-bridge-service) ;; + gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service|event-bridge-service) ;; *) echo "一次部署不得混合国内与海外服务: $SERVICE_CSV" >&2; exit 2 ;; esac done @@ -80,6 +80,17 @@ if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then done fi +case " $SERVICES " in + *" overseas-security-service "*) + for key in OVERSEAS_SECURITY_DB_URL OVERSEAS_SECURITY_DB_USERNAME OVERSEAS_SECURITY_DB_PASSWORD; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 overseas-security-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done + ;; +esac + case " $SERVICES " in *" gateway-overseas "*) if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then diff --git a/deploy/service-map.yml b/deploy/service-map.yml index e22eec804..e78b8a6e5 100644 --- a/deploy/service-map.yml +++ b/deploy/service-map.yml @@ -8,6 +8,11 @@ services: jar: gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar port: 9300 regions: [OVERSEAS] + overseas-security-service: + artifactId: overseas-security-service + jar: overseas-security-service/target/overseas-security-service-1.0.0-SNAPSHOT.jar + port: 9307 + regions: [OVERSEAS] auth: artifactId: auth-service jar: auth/target/auth-service-1.0.0-SNAPSHOT.jar diff --git a/deploy/sql/README.md b/deploy/sql/README.md index b81a56b25..dc681dba9 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -3,7 +3,7 @@ ## 唯一数据库初始化与发布入口 - 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 -- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql` 与 `warehouse-overseas-service/src/main/resources/db/warehouse.sql` 基线,再执行该入口。固定入口同时升级 `baiyue_lastleg` 与 `baiyue_lastlag_warehouse`,当前包含海外主数据、业务、入仓库存与海外仓托盘兼容结构,后续继续追加到同一入口,不按日期拆分。 +- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`、`warehouse-overseas-service/src/main/resources/db/warehouse.sql` 与 `overseas-security-service/src/main/resources/db/overseas-security.sql` 基线,再执行该入口。固定入口升级 `baiyue_lastleg`、`baiyue_lastlag_warehouse` 与 `baiyue_overseas_security`,后者保存 IAM 安全事件消费者 Inbox 和可恢复投影;Redis 只作可重建镜像。 - 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。 - 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index afb0c1055..41f10d449 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -919,4 +919,34 @@ END$$ DELIMITER ; CALL assert_phase4_pda_operations_ready(); DROP PROCEDURE assert_phase4_pda_operations_ready; -SELECT 'PASS' AS migration_status, '海外仓报价、月度汇率、车辆、海外业务、入仓库存与海外仓托盘结构已就绪' AS message; + +CREATE DATABASE IF NOT EXISTS baiyue_overseas_security DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_overseas_security; +CREATE TABLE IF NOT EXISTS overseas_security_event_inbox ( + event_id VARCHAR(64) NOT NULL, event_type VARCHAR(96) NOT NULL, payload_hash CHAR(64) NOT NULL, + payload_json JSON NOT NULL, status VARCHAR(16) NOT NULL, result VARCHAR(32) NULL, + received_at DATETIME(3) NOT NULL, processed_at DATETIME(3) NULL, + PRIMARY KEY (event_id), KEY idx_security_inbox_type_time (event_type,received_at) +) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者Inbox'; +CREATE TABLE IF NOT EXISTS overseas_security_projection ( + aggregate_type VARCHAR(32) NOT NULL, aggregate_id VARCHAR(64) NOT NULL, tenant_id BIGINT NOT NULL, + security_status VARCHAR(24) NULL, permission_version BIGINT NULL, aggregate_version BIGINT NOT NULL, + payload_hash CHAR(64) NOT NULL, last_event_id VARCHAR(64) NOT NULL, + changed_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (aggregate_type,aggregate_id), KEY idx_security_projection_tenant (tenant_id), + KEY idx_security_projection_permission (aggregate_id,permission_version) +) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; + +DROP PROCEDURE IF EXISTS assert_overseas_security_projection_ready; +DELIMITER $$ +CREATE PROCEDURE assert_overseas_security_projection_ready() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_overseas_security' + AND table_name IN ('overseas_security_event_inbox','overseas_security_projection')) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外IAM安全Inbox或投影表未完整部署'; + END IF; +END$$ +DELIMITER ; +CALL assert_overseas_security_projection_ready(); +DROP PROCEDURE assert_overseas_security_projection_ready; +SELECT 'PASS' AS migration_status, '海外仓业务、海外仓资源与IAM安全投影结构已就绪' AS message; diff --git a/docs/同步调用依赖与失败传播治理基线.md b/docs/同步调用依赖与失败传播治理基线.md index 5755b0d21..0562b97bb 100644 --- a/docs/同步调用依赖与失败传播治理基线.md +++ b/docs/同步调用依赖与失败传播治理基线.md @@ -11,7 +11,7 @@ - `INFRASTRUCTURE`:审计、网关会话校验等基础设施调用; - `EXTERNAL`:Integration 对第三方供应商的调用。 -离线清单合同为 `scripts/synchronous-dependency-contract.test.mjs`。新增客户端、目标服务、原始 HTTP 实现、强连通分量或超时配置时,必须先更新本基线并完成调用方与被调用方评审,不能仅修改测试期望以放行。 +逐客户端可执行清单位于 `scripts/synchronous-dependency-baseline.mjs`,离线合同为 `scripts/synchronous-dependency-contract.test.mjs`。清单逐项冻结 44 个 Feign 与 16 个原始 HTTP 客户端的执行上下文、`READ/WRITE/READ_WRITE`、幂等类型和说明,并从同一事实源输出 REQUEST、BACKGROUND、INFRASTRUCTURE、EXTERNAL 与全图。新增客户端、目标服务、在线边、强连通分量或超时配置时,必须先更新基线并完成调用方与被调用方评审,不能仅修改测试期望以放行。 ## 当前服务边 @@ -34,7 +34,7 @@ | Finance | File / IAM / Warehouse | 各 1 | 凭证、客户、供应商校验,`REQUEST` | | File | IAM | 2 | 资质登记为 `REQUEST`;导出 Provider 为 `BACKGROUND` | | File | Order / Firstleg / Finance / Warehouse | 各 1 | 导出 Provider,`BACKGROUND` | -| File | Integration | 1 | OCR,`BACKGROUND` 或上传处理线程 | +| File | Integration | 1 | OCR 在上传处理请求线程执行,`REQUEST` | | Lastleg | Event Bridge | 1 | 跨区事件 Outbox 中继,`BACKGROUND` | | Lastleg | 国内 Warehouse | 1 | 合作仓报价快照,`REQUEST` | @@ -45,28 +45,73 @@ | 调用方 | 被调用方 | 实现 | 上下文与当前策略 | |---|---|---|---| | Gateway Overseas | Auth | `HttpPdaSessionValidationClient` | `INFRASTRUCTURE`;总超时 2 秒,异常由网关失败关闭 | -| Lastleg | Warehouse Overseas | 两个 `RestClient` Gateway | `REQUEST`;当前未看到客户端级连接/读取超时,是高优先级缺口 | -| Lastleg | File | 两个照片 `RestClient` Gateway | `REQUEST`;校验响应 code 且失败关闭,但当前未看到客户端级连接/读取超时 | -| Event Bridge | Firstleg / Lastleg | 两个 JDK `HttpClient` | `BACKGROUND`;连接 3 秒、请求 10 秒,持久化租约和退避由 Event Bridge 专项治理 | +| Gateway Overseas | Overseas Security | `HttpOverseasSecurityValidationClient` | `INFRASTRUCTURE`;安全投影实时校验,异常失败关闭,不进入在线业务图 | +| Lastleg | Warehouse Overseas | 两个 `RestClient` Gateway | `REQUEST`;分别使用 3 秒连接、5 秒读取超时,配置可覆盖,不自动重试 | +| Lastleg | File | 两个照片 `RestClient` Gateway | `REQUEST`;分别使用 3 秒连接、30 秒读取超时,配置可覆盖;校验响应 code 且失败关闭,不自动重试 | +| Event Bridge | Firstleg / Lastleg / Overseas Security | 三个 JDK `HttpClient` | `BACKGROUND`;连接 3 秒、请求 10 秒,持久化租约和退避由 Event Bridge 专项治理;Security 投影不进入在线业务图 | | 启用 Common Audit 的服务 | Audit | `HttpAuditSink` | `INFRASTRUCTURE`;当前捕获异常后仅告警,可能让调用方误以为审计已成功,是高优先级失败传播缺口 | | Integration | 外部 OCR/船司 | 六个 JDK `HttpClient` | `EXTERNAL`;OCR 默认 10/60 秒,船司默认 10/30 秒,业务重试必须服从供应商和调度契约 | -## 依赖环基线 +#### Lastleg 四个有界 RestClient -按服务边计算,当前唯一非平凡强连通分量包含 8 个服务: +四个客户端统一从 `LastlegRestClientFactory` 构建 JDK HTTP 传输,由 `LastlegRestClientProperties` 绑定独立超时。读取超时是单次请求的硬边界;客户端没有 Retry、fallback 或默认成功配置。 + +| 客户端 | 构建与调用路径 | 写操作幂等性 | 默认超时 | +|---|---|---|---| +| `HttpUnloadPhotoFileGateway` | `ReceivingContainerController → ReceivingContainerService → UnloadPhotoFileGateway → /internal/file/v1/business-files/**` | `validate` GET 可重复;`upload` 每次写文件和元数据,没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 | +| `HttpRelabelPhotoFileGateway` | `RelabelPdaController/Service → RelabelPhotoFileGateway → /internal/file/v1/business-files/**` | `validate` GET 可重复;`upload` 没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 | +| `HttpWarehouseResourceGateway` | `InboundPdaWorkflowService`、PDA 仓内查询/盘点及 Dropship → Warehouse Resource/Pallet 内部接口 | 查询可重复;`reserve` 使用 `reservationKey`,`ensureDropship` 使用业务绑定,release/scrap 由服务端状态收敛;尽管服务端具备业务幂等,客户端仍不自动重试 | 连接 3 秒、读取 5 秒 | +| `HttpPalletPhysicalGateway` | `PdaPalletOperationService`、`PdaInventoryMoveService` → `/internal/v1/pda-pallet-transitions/**` | 查询可重复;apply/compensate 使用稳定 `operationKey` 与请求哈希,服务端幂等;客户端仍不自动重试 | 连接 3 秒、读取 5 秒 | + +## 分上下文依赖图 + +### 在线 REQUEST 图 + +在线图只包含用户或设备请求线程中实际执行的服务边。Event Bridge、Outbox、船节点定时任务、导出 Provider 和海外 Security 校验均不计入在线深度。 + +当前在线强连通分量为: + +`Auth ↔ IAM ↔ Firstleg ↔ Order ↔ File` + +合同冻结 22 条在线服务边;新增在线边或新增强连通分量直接失败。Finance、Lastleg 只指向该分量或叶子服务,不属于在线环;Gateway Overseas→Security 是 `INFRASTRUCTURE`,不是 REQUEST 边。 + +### 后台 BACKGROUND 图 + +后台图包含 Outbox/Inbox、导出 Worker 和定时任务,当前强连通分量为: + +`Event Bridge ↔ Firstleg ↔ Finance ↔ Lastleg` + +File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Overseas Security 是单向后台叶子边,不形成在线业务环。 + +### 全图 + +全上下文合并后,非平凡强连通分量仍为 8 个服务: `Auth ↔ IAM ↔ Order ↔ Firstleg ↔ Finance ↔ File ↔ Event Bridge ↔ Lastleg` -其中优先关注的直接双向边为: +全图只用于部署影响和故障域观察,不能再用于推导在线调用“无限深度”。 -1. `Auth ↔ IAM`:账号写模型与授权读模型相互同步依赖; -2. `Order ↔ Firstleg`:订单交易与履约同时存在查询、命令和 Outbox 投递; -3. `IAM ↔ Firstleg`:客户规则与客户快照双向查询; -4. `Firstleg ↔ Finance`:费用 Outbox 投递后 Finance 同步回读不可变快照; -5. `Firstleg ↔ File`:业务文件请求边与 File 导出 Worker 回调边叠加; -6. `File ↔ Finance`:凭证请求边与导出 Worker 回调边叠加。 +### 用户入口在线最长简单路径 -强连通分量说明静态图无法给出有限的“最大服务跳数”。在线深度治理必须以入口用例为单位:默认不新增超过 2 个下游服务跳数的在线链;后台 Outbox/导出链单独计算,不得计入在线调用预算,也不得在下游处理期间同步回调原始调用方形成嵌套闭环。 +默认在线预算为最多 2 个下游服务跳数。合同对每个已知用户入口枚举全部最长简单路径;下表列出的具体路径均超过预算: + +| 用户入口 | 最长跳数 | 全部长路径 | +|---|---:|---| +| 认证 API | 5 | `Auth → IAM → Firstleg → Order → File → Integration` | +| IAM API | 4 | `IAM → Firstleg → Order → File → Integration` | +| 订单 API | 4 | `Order → File → IAM → Firstleg → Print`;`Order → File → IAM → Firstleg → Warehouse`;`Order → Firstleg → File → IAM → Auth`;`Order → Firstleg → File → IAM → Warehouse`;`Order → IAM → Firstleg → File → Integration` | +| 头程 API/PDA | 4 | `Firstleg → Order → File → IAM → Auth`;`Firstleg → Order → File → IAM → Warehouse` | +| 财务 API | 5 | `Finance → File → IAM → Firstleg → Order → Warehouse`;`Finance → Firstleg → Order → File → IAM → Auth`;`Finance → Firstleg → Order → File → IAM → Warehouse`;`Finance → IAM → Firstleg → Order → File → Integration` | +| 文件 API | 4 | `File → IAM → Firstleg → Order → Warehouse` | +| 海外尾程 API/PDA | 5 | `Lastleg → File → IAM → Firstleg → Order → Warehouse` | +| 国内仓储 API | 0 | 无下游同步调用 | +| 海外仓储 API | 0 | 无下游同步调用 | + +这些是静态服务边可形成的最长简单路径,不表示每个具体 Controller 都会走完整链;后续入口级调用点治理必须继续以实际 Service 调用证据缩小路径,不能用静态可能性宣称线上已经发生。 + +### 第一个建议退出的 REQUEST 边 + +建议优先将 `IAM → Firstleg` 的 `CustomerDimensionRuleClient` 默认尺寸初始化从 REQUEST WRITE 改为可靠后台编排。该命令已经携带稳定 `initializationKey` 并提供补偿接口,适合使用持久化 Outbox、显式 `PENDING/FAILED` 结果和人工可诊断重试;不得在失败时返回默认成功。退出此边可把当前五服务在线强连通分量拆为更小的 Auth↔IAM 与 Order↔Firstleg 子环,同时不触碰财务、Event Bridge 传输或四期业务状态机。实施前仍需 IAM/Firstleg 业务负责人确认“客户创建成功”与“默认尺寸已就绪”的产品时点。 ## 退出顺序 diff --git a/docs/头程项目目录结构与服务说明.md b/docs/头程项目目录结构与服务说明.md index 0fad3c9d3..2ed49423a 100644 --- a/docs/头程项目目录结构与服务说明.md +++ b/docs/头程项目目录结构与服务说明.md @@ -10,7 +10,7 @@ ## 1. 项目定位 -`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 22 个 Maven 模块,其中 7 个是公共/依赖模块,15 个是可独立启动、构建、制作镜像的微服务。 +`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 23 个 Maven 模块,其中 7 个是公共/依赖模块,16 个是可独立启动、构建、制作镜像的微服务。 当前技术基线: @@ -48,6 +48,7 @@ baiyue-firstleg/ ├─ finance/ # 财务账期、账单、收付款与核销 ├─ lastleg-service/ # 海外尾程、入仓、库存与本土业务 ├─ warehouse-overseas-service/ # 海外仓库、库位、托盘与资源锁 +├─ overseas-security-service/ # 海外IAM安全事件Inbox、投影与实时校验 ├─ file/ # 资质、导出与测量图片文件中心 ├─ print/ # 打印中心 ├─ audit-service/ # 审计日志中心 @@ -101,6 +102,7 @@ baiyue-firstleg/ | `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 | | `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存与海外本土业务;不含海外出库和派送 | | `warehouse-overseas-service` | `warehouse-overseas-service` | 9305 | 海外仓资源库 | 海外仓库、库位、托盘和资源锁 | +| `overseas-security-service` | `overseas-security-service` | 9307 | `baiyue_overseas_security` | IAM安全事件可靠Inbox、单调投影及海外Token即时失效校验 | | `event-bridge-service` | `event-bridge-service` | 9312 | `baiyue_event_bridge` | 按 CN/OVERSEAS 双实例部署的跨区事件 Inbox、重试和目标服务投递 | | `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;平台端可按权限查询已脱敏日志,业务审计发送默认开启 | @@ -281,6 +283,6 @@ HTTP 401 / `A0401`,不得再按 HTTP 200 + 空权限处理;只有使用新 - 平台端和商户端权限分别管理,接口与按钮权限都必须由后端校验。 - Controller 负责协议转换,Service 负责业务编排,Repository/Mapper 负责持久化。 - 服务之间只传 DTO 或事件契约,不能依赖其他服务的 Entity、Mapper。 -- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 15 个服务执行受影响构建。 +- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 16 个服务执行受影响构建。 - 单一 Git 仓库不等于单一部署单元;每个服务仍生成独立 JAR、镜像和容器。 - 国内头程与海外仓共享 Git 仓库,但保持两套数据库、Nacos 命名空间和部署环境。 diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index 75fe1f706..14ed05d26 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -12,11 +12,12 @@ |---|---| | 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` | | Inbox 接收与幂等 | `service/EventInboxService.java` | -| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=OVERSEAS` 投 Lastleg,`targetRegion=CN` 回投 Firstleg | +| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 回投 Firstleg;海外业务事件投 Lastleg,三类 IAM 安全事件按 eventType 投 `overseas-security-service`,传输层不解析 payload | | 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` | | 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` | | 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` | | Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper | +| 传输指标与 Inbox 采样 | `metrics/EventBridgeMetrics.java`、`EventBridgeInboxMetricsSampler.java`;SLO 与告警合同 `docs/development-observability-slo.md` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 数据库 | `src/main/resources/db/event-bridge.sql` | | 测试 | `src/test/java/com/baiyue/eventbridge/` | @@ -27,6 +28,7 @@ - 每次投递保存 attempt、错误和时间;达到上限进入 `DEAD`,经授权人工补投后重新进入重试队列。 - 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。 - 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。 +- 指标标签只允许有限 `target_region`、`event_type`、`result`,未知区域/类型折叠为 `OTHER`,禁止 eventId、customerId、错误消息等高基数标签。Inbox 聚合按现有状态索引定时采样,Prometheus scrape 不得查询数据库;开发阶段 SLO/告警只代表代码合同,不代表监控平台已部署。 ## 修改与验证 diff --git a/event-bridge-service/docs/development-observability-slo.md b/event-bridge-service/docs/development-observability-slo.md new file mode 100644 index 000000000..1c05c13ce --- /dev/null +++ b/event-bridge-service/docs/development-observability-slo.md @@ -0,0 +1,67 @@ +# Event Bridge 开发阶段可观测性与 SLO 合同 + +## 状态与边界 + +本文冻结代码侧指标、SLI/SLO 和告警表达式模板。它不表示任何环境已经部署、接入或启用了 +Prometheus、Alertmanager 或 Grafana,也不授权部署、访问环境或修改环境配置。 + +指标标签只允许 `target_region`、`event_type`、`result`: + +- `target_region` 仅为 `CN`、`OVERSEAS`、`OTHER`。 +- `event_type` 仅为 `EventTypes` 已登记类型或 `OTHER`。 +- `result` 仅为代码中冻结的接收或传输结果枚举。 +- 严禁把 `eventId`、`customerId`、聚合 ID、错误消息、请求 URL 或操作人写入指标标签。 + +## 指标合同 + +| Prometheus 指标 | 含义 | +|---|---| +| `baiyue_event_bridge_receive_total` | 接收结果;`result=RECEIVED|DUPLICATE|CONFLICT` | +| `baiyue_event_bridge_transport_total` | 传输结果;`result=DELIVERED|RETRY|DEAD|ACKNOWLEDGED_LEASE_LOST|FAILED_LEASE_LOST|MANUAL_REPLAY` | +| `baiyue_event_bridge_delivery_latency_seconds_bucket` | 从 Event Bridge `received_at` 到下游首次成功确认;固定包含 60 秒和 900 秒 SLO 桶 | +| `baiyue_event_bridge_inbox_backlog` | 当前 `RECEIVED`、`RETRY`、`PROCESSING` 数量 | +| `baiyue_event_bridge_inbox_oldest_age_seconds` | 当前最老待处理事件自 `received_at` 起的年龄 | +| `baiyue_event_bridge_inbox_dead` | 当前 `DEAD` 数量 | + +`DELIVERED` 与 `ACKNOWLEDGED_LEASE_LOST` 都表示下游已经确认,因此均记录确认延迟;后者仍单独计数, +不得冒充本地 Inbox 已成功更新。`RETRY`、`DEAD`、`FAILED_LEASE_LOST` 不记录成功延迟。 + +Inbox Gauge 由服务每 30 秒执行一次受控聚合查询并缓存;Prometheus scrape 只读取 Micrometer Gauge, +不得触发数据库查询。查询仅处理 `RECEIVED`、`RETRY`、`PROCESSING`、`DEAD`,状态过滤以现有 +`idx_bridge_inbox_consume(status, next_retry_at, lease_until)` 为索引依据;本合同不新增或修改 SQL 结构。 + +## 开发阶段 SLI 与 SLO + +SLI 从事件首次得到 `result=RECEIVED` 开始,到下游首次成功确认结束;`DUPLICATE` 和 `CONFLICT` +不重复进入分母。滚动 30 天目标: + +- 99% 的新接收跨区事件在 60 秒内得到下游确认。 +- 99.9% 的新接收跨区事件在 15 分钟内得到下游确认。 + +代码暴露固定延迟桶,目标查询模板如下;按 `target_region` 汇总,必要时可保留有限 `event_type` 分组: + +```promql +sum(rate(baiyue_event_bridge_delivery_latency_seconds_bucket{le="60.0"}[30d])) +/ +sum(rate(baiyue_event_bridge_receive_total{result="RECEIVED"}[30d])) >= 0.99 +``` + +```promql +sum(rate(baiyue_event_bridge_delivery_latency_seconds_bucket{le="900.0"}[30d])) +/ +sum(rate(baiyue_event_bridge_receive_total{result="RECEIVED"}[30d])) >= 0.999 +``` + +## 告警合同 + +规则由实际监控平台按 30 秒或更短评估周期配置;以下只是表达式合同: + +- 新增 DEAD:下一次评估立即告警,不设置持续等待。 + `sum(increase(baiyue_event_bridge_transport_total{result="DEAD"}[1m])) > 0` +- 最老待处理年龄预警:任一有限标签序列超过 120 秒。 + `max(baiyue_event_bridge_inbox_oldest_age_seconds) > 120` +- 最老待处理年龄严重:任一有限标签序列超过 900 秒。 + `max(baiyue_event_bridge_inbox_oldest_age_seconds) > 900` +- 当前 DEAD 存量用于持续可见性:`sum(baiyue_event_bridge_inbox_dead) > 0`;它不替代“新增 DEAD”告警。 + +告警通知中可通过日志和业务查询再定位 event ID;禁止为了通知便利把高基数标识加入指标标签。 diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java index 568192832..287a5faa0 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java @@ -2,6 +2,7 @@ package com.baiyue.eventbridge.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.metrics.InboxMetricsSnapshot; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; @@ -60,4 +61,21 @@ public interface BridgeInboxMapper extends BaseMapper { WHERE event_id=#{eventId} AND status='DEAD' """) int replayDead(@Param("eventId") String eventId, @Param("reason") String reason); + + /** + * Scheduled observability aggregation. The status predicate uses idx_bridge_inbox_consume; + * Prometheus collection never invokes this query directly. + */ + @Select(""" + SELECT target_region AS targetRegion, event_type AS eventType, + SUM(CASE WHEN status IN ('RECEIVED','RETRY','PROCESSING') THEN 1 ELSE 0 END) AS backlogCount, + COALESCE(TIMESTAMPDIFF(MICROSECOND, + MIN(CASE WHEN status IN ('RECEIVED','RETRY','PROCESSING') THEN received_at END), + CURRENT_TIMESTAMP(3)) / 1000000.0, 0) AS oldestPendingAgeSeconds, + SUM(CASE WHEN status='DEAD' THEN 1 ELSE 0 END) AS deadCount + FROM bridge_inbox + WHERE status IN ('RECEIVED','RETRY','PROCESSING','DEAD') + GROUP BY target_region, event_type + """) + List observabilitySnapshots(); } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSampler.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSampler.java new file mode 100644 index 000000000..667aaadb2 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSampler.java @@ -0,0 +1,103 @@ +package com.baiyue.eventbridge.metrics; + +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.MultiGauge; +import io.micrometer.core.instrument.Tags; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** Periodically samples indexed Inbox aggregates; Prometheus scrapes only the cached gauges. */ +@Component +@Slf4j +public class EventBridgeInboxMetricsSampler { + public static final String BACKLOG_METRIC = "baiyue.event.bridge.inbox.backlog"; + public static final String OLDEST_AGE_METRIC = "baiyue.event.bridge.inbox.oldest.age.seconds"; + public static final String DEAD_METRIC = "baiyue.event.bridge.inbox.dead"; + + private final BridgeInboxMapper inboxMapper; + private final MultiGauge backlog; + private final MultiGauge oldestAge; + private final MultiGauge dead; + + public EventBridgeInboxMetricsSampler(BridgeInboxMapper inboxMapper, MeterRegistry registry) { + this.inboxMapper = inboxMapper; + this.backlog = MultiGauge.builder(BACKLOG_METRIC) + .description("Events waiting for downstream acknowledgement").register(registry); + this.oldestAge = MultiGauge.builder(OLDEST_AGE_METRIC) + .description("Age in seconds of the oldest pending Inbox event").register(registry); + this.dead = MultiGauge.builder(DEAD_METRIC) + .description("Current number of DEAD Inbox events").register(registry); + } + + @Scheduled(initialDelayString = "${baiyue.event-bridge.metrics.initial-delay:5000}", + fixedDelayString = "${baiyue.event-bridge.metrics.sample-delay:30000}") + public void sample() { + final List snapshots; + try { + snapshots = inboxMapper.observabilitySnapshots(); + } catch (RuntimeException exception) { + log.warn("Event Bridge Inbox 指标采样失败,保留上次成功快照", exception); + return; + } + Map aggregates = aggregate(snapshots); + backlog.register(rows(aggregates, MetricValue.BACKLOG), true); + oldestAge.register(rows(aggregates, MetricValue.OLDEST_AGE), true); + dead.register(rows(aggregates, MetricValue.DEAD), true); + } + + private Map aggregate(List snapshots) { + Map result = new LinkedHashMap<>(); + if (snapshots == null) { + return result; + } + for (InboxMetricsSnapshot snapshot : snapshots) { + MetricKey key = new MetricKey(EventBridgeMetrics.targetRegion(snapshot.getTargetRegion()), + EventBridgeMetrics.eventType(snapshot.getEventType())); + result.computeIfAbsent(key, ignored -> new Aggregate()).add(snapshot); + } + return result; + } + + private List> rows(Map aggregates, MetricValue value) { + List> rows = new ArrayList<>(); + aggregates.forEach((key, aggregate) -> rows.add(MultiGauge.Row.of( + Tags.of("target_region", key.targetRegion, "event_type", key.eventType), + value.value(aggregate)))); + return rows; + } + + private record MetricKey(String targetRegion, String eventType) { } + + private static final class Aggregate { + private long backlog; + private double oldestAge; + private long dead; + + void add(InboxMetricsSnapshot snapshot) { + backlog += snapshot.getBacklogCount(); + oldestAge = Math.max(oldestAge, snapshot.getOldestPendingAgeSeconds()); + dead += snapshot.getDeadCount(); + } + } + + private enum MetricValue { + BACKLOG { + @Override Number value(Aggregate aggregate) { return aggregate.backlog; } + }, + OLDEST_AGE { + @Override Number value(Aggregate aggregate) { return aggregate.oldestAge; } + }, + DEAD { + @Override Number value(Aggregate aggregate) { return aggregate.dead; } + }; + + abstract Number value(Aggregate aggregate); + } +} 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 new file mode 100644 index 000000000..50a3bbe66 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java @@ -0,0 +1,121 @@ +package com.baiyue.eventbridge.metrics; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import com.baiyue.eventbridge.domain.BridgeInbox; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Locale; +import java.util.Set; + +/** Low-cardinality transport metrics for the cross-region event bridge. */ +@Component +@Slf4j +public class EventBridgeMetrics { + public static final String RECEIVE_METRIC = "baiyue.event.bridge.receive"; + public static final String TRANSPORT_METRIC = "baiyue.event.bridge.transport"; + public static final String DELIVERY_LATENCY_METRIC = "baiyue.event.bridge.delivery.latency"; + + private static final Set KNOWN_EVENT_TYPES = Set.of( + EventTypes.ORDER_APPROVED, + EventTypes.ORDER_CANCELLED, + EventTypes.FIRST_LEG_SHIPMENT_DEPARTED, + EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, + EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, + EventTypes.OVERSEAS_CONTAINER_UNLOADED, + EventTypes.IAM_USER_STATUS_CHANGED, + EventTypes.IAM_TENANT_STATUS_CHANGED, + EventTypes.IAM_PERMISSION_VERSION_CHANGED, + EventTypes.LAST_MILE_HANDOFF_ACCEPTED, + EventTypes.LAST_MILE_HANDOFF_REJECTED, + EventTypes.LAST_MILE_MILESTONE_CHANGED, + EventTypes.LAST_MILE_COMPLETED, + EventTypes.TRACKING_MILESTONE_PROJECTED); + + private final MeterRegistry registry; + private final Clock clock; + + public EventBridgeMetrics(MeterRegistry registry) { + this(registry, Clock.systemDefaultZone()); + } + + EventBridgeMetrics(MeterRegistry registry, Clock clock) { + this.registry = registry; + this.clock = clock; + } + + public void recordReceive(EventEnvelope envelope, ReceiveResult result) { + try { + registry.counter(RECEIVE_METRIC, + "target_region", targetRegion(envelope.targetRegion().name()), + "event_type", eventType(envelope.eventType()), + "result", result.name()).increment(); + } catch (RuntimeException exception) { + log.warn("Event Bridge 接收指标记录失败,不影响事件事务", exception); + } + } + + public void recordTransport(BridgeInbox event, TransportResult result) { + try { + String targetRegion = targetRegion(event.getTargetRegion()); + String eventType = eventType(event.getEventType()); + registry.counter(TRANSPORT_METRIC, + "target_region", targetRegion, + "event_type", eventType, + "result", result.name()).increment(); + if (result.acknowledged() && event.getReceivedAt() != null) { + Duration latency = Duration.between(event.getReceivedAt(), LocalDateTime.now(clock)); + if (latency.isNegative()) { + latency = Duration.ZERO; + } + Timer.builder(DELIVERY_LATENCY_METRIC) + .description("Time from Event Bridge receipt to downstream acknowledgement") + .serviceLevelObjectives(Duration.ofSeconds(60), Duration.ofMinutes(15)) + .tags("target_region", targetRegion, "event_type", eventType) + .register(registry).record(latency); + } + } catch (RuntimeException exception) { + log.warn("Event Bridge 传输指标记录失败,不影响可靠性状态机", exception); + } + } + + public static String targetRegion(String value) { + String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + return "CN".equals(normalized) || "OVERSEAS".equals(normalized) ? normalized : "OTHER"; + } + + public static String eventType(String value) { + return value != null && KNOWN_EVENT_TYPES.contains(value) ? value : "OTHER"; + } + + public enum ReceiveResult { + RECEIVED, + DUPLICATE, + CONFLICT + } + + public enum TransportResult { + DELIVERED(true), + RETRY(false), + DEAD(false), + ACKNOWLEDGED_LEASE_LOST(true), + FAILED_LEASE_LOST(false), + MANUAL_REPLAY(false); + + private final boolean acknowledged; + + TransportResult(boolean acknowledged) { + this.acknowledged = acknowledged; + } + + boolean acknowledged() { + return acknowledged; + } + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/InboxMetricsSnapshot.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/InboxMetricsSnapshot.java new file mode 100644 index 000000000..24e89b303 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/InboxMetricsSnapshot.java @@ -0,0 +1,50 @@ +package com.baiyue.eventbridge.metrics; + +/** Result row of the bounded, scheduled Inbox observability aggregation. */ +public class InboxMetricsSnapshot { + private String targetRegion; + private String eventType; + private long backlogCount; + private double oldestPendingAgeSeconds; + private long deadCount; + + public String getTargetRegion() { + return targetRegion; + } + + public void setTargetRegion(String targetRegion) { + this.targetRegion = targetRegion; + } + + public String getEventType() { + return eventType; + } + + public void setEventType(String eventType) { + this.eventType = eventType; + } + + public long getBacklogCount() { + return backlogCount; + } + + public void setBacklogCount(long backlogCount) { + this.backlogCount = backlogCount; + } + + public double getOldestPendingAgeSeconds() { + return oldestPendingAgeSeconds; + } + + public void setOldestPendingAgeSeconds(double oldestPendingAgeSeconds) { + this.oldestPendingAgeSeconds = oldestPendingAgeSeconds; + } + + public long getDeadCount() { + return deadCount; + } + + public void setDeadCount(long deadCount) { + this.deadCount = deadCount; + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java index 25b7fc7c3..f4adf8f3d 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java @@ -5,6 +5,7 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.dto.EventReceiveResult; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; @@ -23,6 +24,7 @@ import java.util.Objects; public class EventInboxService { @Resource private InboxStore inboxStore; @Resource private ObjectMapper objectMapper; + @Resource private EventBridgeMetrics metrics; @Value("${baiyue.region:CN}") private String localRegion; @@ -44,6 +46,7 @@ public class EventInboxService { BridgeInbox inbox = toInbox(envelope); try { inboxStore.insert(inbox); + metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.RECEIVED); return new EventReceiveResult(envelope.eventId(), "已接收", inbox.getReceivedAt()); } catch (DuplicateKeyException exception) { BridgeInbox concurrent = inboxStore.findByEventId(envelope.eventId()); @@ -56,8 +59,10 @@ public class EventInboxService { private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) { if (!sameEnvelope(existing, envelope)) { + metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT); throw new IllegalArgumentException("相同 eventId 对应的事件信封或 payloadHash 不一致,拒绝覆盖既有事件"); } + metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.DUPLICATE); return new EventReceiveResult(envelope.eventId(), "重复", existing.getReceivedAt()); } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java index bc8b76dea..632672e95 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java @@ -1,13 +1,23 @@ package com.baiyue.eventbridge.service; +import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; @Service public class EventReplayService { @Resource private BridgeInboxMapper inboxMapper; + @Resource private BridgeDeliveryAttemptMapper attemptMapper; + @Resource private EventBridgeMetrics metrics; + @Transactional public void replayDead(String eventId, String operatorId, String reason) { if (operatorId == null || operatorId.isBlank()) { throw new IllegalArgumentException("人工补投必须提供操作人"); @@ -19,6 +29,22 @@ public class EventReplayService { if (inboxMapper.replayDead(eventId, auditReason) != 1) { throw new IllegalStateException("事件不存在或未处于 DEAD 状态"); } + BridgeInbox event = inboxMapper.selectById(eventId); + if (event == null) { + throw new IllegalStateException("事件补投状态已更新但事件记录不存在"); + } + LocalDateTime now = LocalDateTime.now(); + BridgeDeliveryAttempt audit = new BridgeDeliveryAttempt(); + audit.setEventId(eventId); + audit.setSourceRegion(event.getSourceRegion()); + audit.setTargetRegion(event.getTargetRegion()); + audit.setAttemptNo(inboxMapper.nextAttemptNo(eventId)); + audit.setStatus("MANUAL_REPLAY"); + audit.setErrorMessage(auditReason); + audit.setStartedAt(now); + audit.setFinishedAt(now); + attemptMapper.insert(audit); + metrics.recordTransport(event, EventBridgeMetrics.TransportResult.MANUAL_REPLAY); } private static String abbreviate(String value, int maxLength) { diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java index 0aacdc3f3..fe26409f2 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java @@ -42,11 +42,31 @@ public class HttpFirstlegEventClient implements FirstlegEventClient { .header("X-Internal-Service-Token", serviceToken) .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - JsonNode responseBody = objectMapper.readTree(response.body()); + JsonNode responseBody; + try { + responseBody = objectMapper.readTree(response.body()); + } catch (Exception ignored) { + responseBody = null; + } if (response.statusCode() / 100 != 2 || responseBody == null || !"200".equals(responseBody.path("code").asText())) { - throw new IllegalStateException("头程消费失败: HTTP " + response.statusCode()); + throw new IllegalStateException(failureMessage(response.statusCode(), responseBody)); } return String.valueOf(response.statusCode()); } + + static String failureMessage(int statusCode, JsonNode body) { + String code = body == null ? "missing" : body.path("code").asText("missing"); + String message = body == null ? "non-json response" : body.path("message").asText("missing"); + String requestId = body == null ? "missing" : body.path("requestId").asText("missing"); + String traceId = body == null ? "missing" : body.path("traceId").asText("missing"); + return "头程消费失败: HTTP " + statusCode + ", code=" + bounded(code) + + ", message=" + bounded(message) + ", requestId=" + bounded(requestId) + + ", traceId=" + bounded(traceId); + } + + private static String bounded(String value) { + String safe = value == null || value.isBlank() ? "missing" : value.replaceAll("[\\r\\n]+", " "); + return safe.length() <= 240 ? safe : safe.substring(0, 240); + } } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java new file mode 100644 index 000000000..e18cf8714 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java @@ -0,0 +1,56 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.RegionCode; +import com.baiyue.eventbridge.config.EventDispatcherProperties; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +@Component +public class HttpSecurityProjectionEventClient implements SecurityProjectionEventClient { + private final ObjectMapper objectMapper; + private final EventDispatcherProperties properties; + @Value("${baiyue.event-dispatcher.security-consumer-url}") private URI consumerUrl; + @Value("${baiyue.security.internal-service-token:}") private String serviceToken; + private HttpClient httpClient; + + public HttpSecurityProjectionEventClient(ObjectMapper objectMapper, EventDispatcherProperties properties) { + this.objectMapper = objectMapper; + this.properties = properties; + } + + @PostConstruct void initialize() { + httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build(); + } + + @Override + public String deliver(BridgeInbox row) throws Exception { + EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(), + row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()), + row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(), + row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), + objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); + HttpRequest request = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout()) + .header("Content-Type", "application/json") + .header("X-Internal-Service-Token", serviceToken) + .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + JsonNode body; + try { body = objectMapper.readTree(response.body()); } + catch (Exception exception) { body = null; } + if (response.statusCode() / 100 != 2 || body == null || !"200".equals(body.path("code").asText()) + || !row.getEventId().equals(body.path("data").path("eventId").asText())) { + throw new IllegalStateException("海外安全投影消费失败: HTTP " + response.statusCode()); + } + return String.valueOf(response.statusCode()); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java index b795a7019..54cd77867 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java @@ -1,10 +1,12 @@ package com.baiyue.eventbridge.service; +import com.baiyue.common.event.EventTypes; import com.baiyue.eventbridge.config.EventDispatcherProperties; import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -12,6 +14,7 @@ import org.springframework.stereotype.Component; import java.lang.management.ManagementFactory; import java.time.LocalDateTime; +import java.util.Set; import java.util.UUID; @Component @@ -21,7 +24,9 @@ public class OverseasEventDispatcher { @Resource private BridgeDeliveryAttemptMapper attemptMapper; @Resource private LastlegEventClient eventClient; @Resource private FirstlegEventClient firstlegEventClient; + @Resource private SecurityProjectionEventClient securityProjectionEventClient; @Resource private EventDispatcherProperties properties; + @Resource private EventBridgeMetrics metrics; private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID(); @Scheduled(fixedDelayString = "${baiyue.event-dispatcher.fixed-delay:3000}") @@ -40,26 +45,52 @@ public class OverseasEventDispatcher { int retryAttempt = event.getRetryCount() == null ? 1 : event.getRetryCount() + 1; int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId); BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo); + String responseCode; try { - String responseCode = deliver(event); - if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) { - throw new IllegalStateException("事件租约已失效,无法确认投递结果"); - } - finishAttempt(attempt, "DELIVERED", responseCode, null); + responseCode = deliver(event); } catch (Exception exception) { String error = abbreviate(exception.getMessage(), 1000); - inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), backoffSeconds(retryAttempt), error); - finishAttempt(attempt, retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY", null, error); + int marked = inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), + backoffSeconds(retryAttempt), error); + String attemptStatus = marked == 1 + ? (retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY") + : "FAILED_LEASE_LOST"; + finishAttempt(attempt, attemptStatus, null, error); + metrics.recordTransport(event, EventBridgeMetrics.TransportResult.valueOf(attemptStatus)); log.warn("跨区事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}", eventId, retryAttempt, deliveryAttemptNo, error); + return; } + + if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) { + String error = "下游已确认成功,但事件租约已失效;保留当前 Inbox 状态并由新租约持有者幂等续投"; + finishAttempt(attempt, "ACKNOWLEDGED_LEASE_LOST", responseCode, error); + metrics.recordTransport(event, EventBridgeMetrics.TransportResult.ACKNOWLEDGED_LEASE_LOST); + log.warn("跨区事件成功确认时租约已失效 eventId={}, deliveryAttempt={}", eventId, deliveryAttemptNo); + return; + } + finishAttempt(attempt, "DELIVERED", responseCode, null); + metrics.recordTransport(event, EventBridgeMetrics.TransportResult.DELIVERED); } private String deliver(BridgeInbox event) throws Exception { - return "CN".equals(event.getTargetRegion()) - ? firstlegEventClient.deliver(event) : eventClient.deliver(event); + String eventType = event.getEventType(); + if (eventType == null || eventType.isBlank()) { + throw new IllegalArgumentException("事件类型不能为空,无法选择下游消费者"); + } + return switch (event.getTargetRegion()) { + case "CN" -> firstlegEventClient.deliver(event); + case "OVERSEAS" -> SECURITY_EVENT_TYPES.contains(eventType) + ? securityProjectionEventClient.deliver(event) : eventClient.deliver(event); + default -> throw new IllegalArgumentException("不支持的事件目标区域: " + event.getTargetRegion()); + }; } + private static final Set SECURITY_EVENT_TYPES = Set.of( + EventTypes.IAM_USER_STATUS_CHANGED, + EventTypes.IAM_TENANT_STATUS_CHANGED, + EventTypes.IAM_PERMISSION_VERSION_CHANGED); + private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) { BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt(); attempt.setEventId(event.getEventId()); diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/SecurityProjectionEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/SecurityProjectionEventClient.java new file mode 100644 index 000000000..87fd9e3eb --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/SecurityProjectionEventClient.java @@ -0,0 +1,7 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.domain.BridgeInbox; + +public interface SecurityProjectionEventClient { + String deliver(BridgeInbox event) throws Exception; +} diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml index e80450011..fc5524546 100644 --- a/event-bridge-service/src/main/resources/application.yml +++ b/event-bridge-service/src/main/resources/application.yml @@ -33,6 +33,7 @@ baiyue: event-dispatcher: consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}} firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9202/internal/firstleg/v1/container-events} + security-consumer-url: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9307/internal/overseas-security/v1/events} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSamplerTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSamplerTest.java new file mode 100644 index 000000000..a18746bd5 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSamplerTest.java @@ -0,0 +1,54 @@ +package com.baiyue.eventbridge.metrics; + +import com.baiyue.common.event.EventTypes; +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +class EventBridgeInboxMetricsSamplerTest { + + @Test + void samplesDatabaseOnceAndServesCachedLowCardinalityGauges() { + BridgeInboxMapper mapper = mock(BridgeInboxMapper.class); + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + InboxMetricsSnapshot known = snapshot("OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, + 7, 121.5, 2); + InboxMetricsSnapshot unknown = snapshot("region-123", "dynamic-event-123", 3, 900.0, 1); + when(mapper.observabilitySnapshots()).thenReturn(List.of(known, unknown)); + EventBridgeInboxMetricsSampler sampler = new EventBridgeInboxMetricsSampler(mapper, registry); + + sampler.sample(); + + verify(mapper).observabilitySnapshots(); + assertThat(gauge(registry, EventBridgeInboxMetricsSampler.BACKLOG_METRIC, + "OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)).isEqualTo(7.0); + assertThat(gauge(registry, EventBridgeInboxMetricsSampler.OLDEST_AGE_METRIC, + "OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)).isEqualTo(121.5); + assertThat(gauge(registry, EventBridgeInboxMetricsSampler.DEAD_METRIC, + "OTHER", "OTHER")).isEqualTo(1.0); + verifyNoMoreInteractions(mapper); + } + + private InboxMetricsSnapshot snapshot(String region, String eventType, long backlog, + double oldestAge, long dead) { + InboxMetricsSnapshot snapshot = new InboxMetricsSnapshot(); + snapshot.setTargetRegion(region); + snapshot.setEventType(eventType); + snapshot.setBacklogCount(backlog); + snapshot.setOldestPendingAgeSeconds(oldestAge); + snapshot.setDeadCount(dead); + return snapshot; + } + + private double gauge(SimpleMeterRegistry registry, String name, String region, String eventType) { + return registry.get(name).tags("target_region", region, "event_type", eventType).gauge().value(); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java new file mode 100644 index 000000000..39a229241 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java @@ -0,0 +1,58 @@ +package com.baiyue.eventbridge.metrics; + +import com.baiyue.eventbridge.domain.BridgeInbox; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.micrometer.core.instrument.MeterRegistry; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Modifier; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.mock; + +class EventBridgeMetricsTest { + + @Test + void collapsesUnboundedRegionAndEventTypeLabelsToOther() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + EventBridgeMetrics metrics = new EventBridgeMetrics(registry); + BridgeInbox event = new BridgeInbox(); + event.setTargetRegion("customer-controlled-region"); + event.setEventType("dynamic-event-123456"); + + metrics.recordTransport(event, EventBridgeMetrics.TransportResult.RETRY); + + assertThat(registry.get(EventBridgeMetrics.TRANSPORT_METRIC) + .tags("target_region", "OTHER", "event_type", "OTHER", "result", "RETRY") + .counter().count()).isEqualTo(1.0); + assertThat(registry.getMeters()).allSatisfy(meter -> assertThat(meter.getId().getTags()) + .noneMatch(tag -> tag.getValue().contains("dynamic-event"))); + assertThat(EventBridgeMetrics.eventType(null)).isEqualTo("OTHER"); + } + + @Test + void everyFrozenEventTypeHasABoundedTagValue() { + assertThat(Arrays.stream(com.baiyue.common.event.EventTypes.class.getFields()) + .filter(field -> Modifier.isStatic(field.getModifiers()) && field.getType() == String.class) + .map(field -> { + try { + return (String) field.get(null); + } catch (IllegalAccessException exception) { + throw new AssertionError(exception); + } + })).allMatch(value -> EventBridgeMetrics.eventType(value).equals(value)); + } + + @Test + void metricsFailureDoesNotChangeTransportControlFlow() { + EventBridgeMetrics metrics = new EventBridgeMetrics(mock(MeterRegistry.class)); + BridgeInbox event = new BridgeInbox(); + event.setTargetRegion("CN"); + event.setEventType(com.baiyue.common.event.EventTypes.OVERSEAS_CONTAINER_UNLOADED); + + assertThatCode(() -> metrics.recordTransport(event, EventBridgeMetrics.TransportResult.RETRY)) + .doesNotThrowAnyException(); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeObservabilityContractTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeObservabilityContractTest.java new file mode 100644 index 000000000..5c106df15 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeObservabilityContractTest.java @@ -0,0 +1,41 @@ +package com.baiyue.eventbridge.metrics; + +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventBridgeObservabilityContractTest { + + @Test + void aggregationIsScheduledCacheInputAndUsesExistingStatusIndexBasis() throws Exception { + Select annotation = BridgeInboxMapper.class.getMethod("observabilitySnapshots") + .getAnnotation(Select.class); + String query = String.join(" ", annotation.value()).replaceAll("\\s+", " "); + String schema = Files.readString(Path.of("src/main/resources/db/event-bridge.sql")); + + assertThat(query) + .contains("WHERE status IN ('RECEIVED','RETRY','PROCESSING','DEAD')") + .contains("GROUP BY target_region, event_type") + .doesNotContain("event_id AS", "customer_id AS", "last_error AS"); + assertThat(schema).contains( + "KEY idx_bridge_inbox_consume (status, next_retry_at, lease_until)"); + } + + @Test + void developmentSloAndAlertThresholdsAreFrozenWithoutDeploymentClaim() throws Exception { + String contract = Files.readString(Path.of("docs/development-observability-slo.md")); + + assertThat(contract) + .contains("99% 的新接收跨区事件在 60 秒内") + .contains("99.9% 的新接收跨区事件在 15 分钟内") + .contains("result=\"DEAD\"", "> 120", "> 900") + .contains("不表示任何环境已经部署") + .contains("idx_bridge_inbox_consume(status, next_retry_at, lease_until)") + .doesNotContain("customer_id\"", "event_id\""); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java index 9ae12a6dc..c8ee93232 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java @@ -5,7 +5,9 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.RegionCode; import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; import com.fasterxml.jackson.databind.ObjectMapper; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.Test; import org.springframework.dao.DuplicateKeyException; import org.springframework.test.util.ReflectionTestUtils; @@ -24,6 +26,8 @@ import static org.mockito.Mockito.when; class EventInboxServiceTest { private final ObjectMapper objectMapper = new ObjectMapper(); private final InboxStore store = mock(InboxStore.class); + private final SimpleMeterRegistry registry = new SimpleMeterRegistry(); + private final EventBridgeMetrics metrics = new EventBridgeMetrics(registry); private final EventInboxService service = service("OVERSEAS"); @Test @@ -33,6 +37,7 @@ class EventInboxServiceTest { assertThat(service.receive(envelope).result()).isEqualTo("已接收"); verify(store).insert(any(BridgeInbox.class)); + assertThat(receiveCount("RECEIVED")).isEqualTo(1.0); } @Test @@ -57,6 +62,7 @@ class EventInboxServiceTest { when(store.findByEventId(envelope.eventId())).thenReturn(existing); assertThat(service.receive(envelope).result()).isEqualTo("重复"); + assertThat(receiveCount("DUPLICATE")).isEqualTo(1.0); } @Test @@ -67,6 +73,7 @@ class EventInboxServiceTest { assertThatThrownBy(() -> service.receive(envelope)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("拒绝覆盖"); + assertThat(receiveCount("CONFLICT")).isEqualTo(1.0); } @Test @@ -113,10 +120,17 @@ class EventInboxServiceTest { EventInboxService service = new EventInboxService(); ReflectionTestUtils.setField(service, "inboxStore", store); ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + ReflectionTestUtils.setField(service, "metrics", metrics); ReflectionTestUtils.setField(service, "localRegion", region); return service; } + private double receiveCount(String result) { + return registry.get(EventBridgeMetrics.RECEIVE_METRIC) + .tags("target_region", "OVERSEAS", "event_type", "OrderApproved", "result", result) + .counter().count(); + } + private EventEnvelope withHash(EventEnvelope source) { String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(source.payload())); return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(), diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java index 9bd52210b..49c00c1c8 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java @@ -1,6 +1,11 @@ package com.baiyue.eventbridge.service; +import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; import org.apache.ibatis.annotations.Update; @@ -11,15 +16,31 @@ import static org.mockito.Mockito.*; class EventReplayServiceTest { private final BridgeInboxMapper mapper = mock(BridgeInboxMapper.class); + private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class); + private final SimpleMeterRegistry registry = new SimpleMeterRegistry(); + private final EventBridgeMetrics metrics = new EventBridgeMetrics(registry); private final EventReplayService service = service(); @Test void requeuesDeadEventWithAuditReason() { when(mapper.replayDead(eq("event-1"), contains("incident-42"))).thenReturn(1); + BridgeInbox event = new BridgeInbox(); + event.setEventId("event-1"); event.setSourceRegion("CN"); event.setTargetRegion("OVERSEAS"); + event.setEventType(com.baiyue.common.event.EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED); + when(mapper.selectById("event-1")).thenReturn(event); + when(mapper.nextAttemptNo("event-1")).thenReturn(4); service.replayDead("event-1", "operator-7", "incident-42 approved"); verify(mapper).replayDead(eq("event-1"), contains("operator-7")); + var captor = org.mockito.ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); + verify(attemptMapper).insert(captor.capture()); + assertThat(captor.getValue().getAttemptNo()).isEqualTo(4); + assertThat(captor.getValue().getStatus()).isEqualTo("MANUAL_REPLAY"); + assertThat(captor.getValue().getErrorMessage()).contains("operator-7", "incident-42"); + assertThat(registry.get(EventBridgeMetrics.TRANSPORT_METRIC) + .tags("target_region", "OVERSEAS", "event_type", event.getEventType(), + "result", "MANUAL_REPLAY").counter().count()).isEqualTo(1.0); } @Test @@ -43,6 +64,8 @@ class EventReplayServiceTest { private EventReplayService service() { EventReplayService service = new EventReplayService(); ReflectionTestUtils.setField(service, "inboxMapper", mapper); + ReflectionTestUtils.setField(service, "attemptMapper", attemptMapper); + ReflectionTestUtils.setField(service, "metrics", metrics); return service; } } diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFirstlegEventClientTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFirstlegEventClientTest.java new file mode 100644 index 000000000..69ed8424a --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFirstlegEventClientTest.java @@ -0,0 +1,26 @@ +package com.baiyue.eventbridge.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HttpFirstlegEventClientTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void businessFailureIsDiagnosableAndMustNotBeTreatedAsAcknowledged() throws Exception { + var body = objectMapper.readTree(""" + {"code":"A0409","message":"事件摘要冲突","requestId":"request-cn-1","traceId":"trace-cn-1"} + """); + + assertThat(HttpFirstlegEventClient.failureMessage(200, body)) + .isEqualTo("头程消费失败: HTTP 200, code=A0409, message=事件摘要冲突, requestId=request-cn-1, traceId=trace-cn-1"); + } + + @Test + void nonJsonFailureIsStillDiagnosableWithoutRawBody() { + assertThat(HttpFirstlegEventClient.failureMessage(503, null)) + .isEqualTo("头程消费失败: HTTP 503, code=missing, message=non-json response, requestId=missing, traceId=missing"); + } +} 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 01468d95a..aa9f11d14 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 @@ -1,10 +1,13 @@ package com.baiyue.eventbridge.service; +import com.baiyue.common.event.EventTypes; import com.baiyue.eventbridge.config.EventDispatcherProperties; import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -12,6 +15,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.net.URI; import java.time.Duration; +import java.time.LocalDateTime; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -21,10 +25,14 @@ import static org.mockito.Mockito.*; class OverseasEventDispatcherTest { private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class); private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class); - private final LastlegEventClient eventClient = mock(LastlegEventClient.class); - private final FirstlegEventClient firstlegEventClient = mock(FirstlegEventClient.class); + private final com.baiyue.eventbridge.service.LastlegEventClient eventClient = + mock(com.baiyue.eventbridge.service.LastlegEventClient.class); + private final com.baiyue.eventbridge.service.FirstlegEventClient firstlegEventClient = + mock(com.baiyue.eventbridge.service.FirstlegEventClient.class); + private final SecurityProjectionEventClient securityProjectionEventClient = mock(SecurityProjectionEventClient.class); private OverseasEventDispatcher dispatcher; private BridgeInbox event; + private SimpleMeterRegistry registry; @BeforeEach void setUp() { @@ -33,16 +41,21 @@ class OverseasEventDispatcherTest { Duration.ofSeconds(30), Duration.ofSeconds(5), Duration.ofMinutes(1), Duration.ofSeconds(3), Duration.ofSeconds(10)); dispatcher = new OverseasEventDispatcher(); + registry = new SimpleMeterRegistry(); ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper); ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper); ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient); ReflectionTestUtils.setField(dispatcher, "firstlegEventClient", firstlegEventClient); + ReflectionTestUtils.setField(dispatcher, "securityProjectionEventClient", securityProjectionEventClient); ReflectionTestUtils.setField(dispatcher, "properties", properties); + ReflectionTestUtils.setField(dispatcher, "metrics", new EventBridgeMetrics(registry)); event = new BridgeInbox(); event.setEventId("event-1"); + event.setEventType(EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED); event.setSourceRegion("CN"); event.setTargetRegion("OVERSEAS"); event.setRetryCount(0); + event.setReceivedAt(LocalDateTime.now().minusSeconds(5)); when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1); when(inboxMapper.selectById("event-1")).thenReturn(event); when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1); @@ -56,27 +69,50 @@ class OverseasEventDispatcherTest { dispatcher.dispatchOne("event-1"); verify(inboxMapper).markProcessed(eq("event-1"), anyString()); + verify(eventClient).deliver(event); + verifyNoInteractions(securityProjectionEventClient); assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); + assertThat(transportCount("DELIVERED")).isEqualTo(1.0); + assertThat(registry.get(EventBridgeMetrics.DELIVERY_LATENCY_METRIC).timer().count()).isEqualTo(1); } @Test void failedDeliveryIsScheduledForRetry() throws Exception { when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down")); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down"))) + .thenReturn(1); dispatcher.dispatchOne("event-1"); - verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down")); + verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(5L), eq("network down")); assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY"); + assertThat(transportCount("RETRY")).isEqualTo(1.0); } @Test void finalFailureIsMovedToDeadState() throws Exception { event.setRetryCount(2); when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down")); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("still down"))) + .thenReturn(1); dispatcher.dispatchOne("event-1"); + verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(20L), eq("still down")); assertThat(updatedAttempt().getStatus()).isEqualTo("DEAD"); + assertThat(transportCount("DEAD")).isEqualTo(1.0); + } + + @Test + void exponentialBackoffIsCappedAtConfiguredMaximum() throws Exception { + event.setRetryCount(4); + when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down")); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("still down"))) + .thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(60L), eq("still down")); } @Test @@ -92,6 +128,7 @@ class OverseasEventDispatcherTest { @Test void cnTargetIsDeliveredToFirstleg() throws Exception { + event.setEventType(EventTypes.OVERSEAS_CONTAINER_UNLOADED); event.setSourceRegion("OVERSEAS"); event.setTargetRegion("CN"); when(firstlegEventClient.deliver(event)).thenReturn("200"); @@ -101,9 +138,87 @@ class OverseasEventDispatcherTest { verify(firstlegEventClient).deliver(event); verifyNoInteractions(eventClient); + verifyNoInteractions(securityProjectionEventClient); assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); } + @Test + void iamSecurityEventsAreDeliveredToSecurityProjectionAndNeverLastleg() throws Exception { + for (String eventType : List.of(EventTypes.IAM_USER_STATUS_CHANGED, + EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_PERMISSION_VERSION_CHANGED)) { + reset(inboxMapper, attemptMapper, eventClient, firstlegEventClient, securityProjectionEventClient); + event.setEventType(eventType); + when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1); + when(inboxMapper.selectById("event-1")).thenReturn(event); + when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1); + when(securityProjectionEventClient.deliver(event)).thenReturn("200"); + + dispatcher.dispatchOne("event-1"); + + verify(securityProjectionEventClient).deliver(event); + verifyNoInteractions(eventClient); + verifyNoInteractions(firstlegEventClient); + } + } + + @Test + void unsupportedTargetRegionIsNeverMisroutedToLastleg() { + event.setTargetRegion("UNKNOWN"); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), contains("不支持"))) + .thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verifyNoInteractions(eventClient); + verifyNoInteractions(firstlegEventClient); + verifyNoInteractions(securityProjectionEventClient); + assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY"); + } + + @Test + void missingEventTypeFailsWithExplicitDiagnosticAndIsNeverDelivered() { + event.setEventType(null); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), + eq("事件类型不能为空,无法选择下游消费者"))).thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verifyNoInteractions(eventClient); + verifyNoInteractions(firstlegEventClient); + verifyNoInteractions(securityProjectionEventClient); + BridgeDeliveryAttempt attempt = updatedAttempt(); + assertThat(attempt.getStatus()).isEqualTo("RETRY"); + assertThat(attempt.getErrorMessage()).isEqualTo("事件类型不能为空,无法选择下游消费者"); + } + + @Test + void acknowledgedDeliveryWithLostLeaseIsNotRecordedAsRetryOrDead() throws Exception { + when(eventClient.deliver(event)).thenReturn("200"); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(0); + + dispatcher.dispatchOne("event-1"); + + verify(inboxMapper, never()).markFailed(anyString(), anyString(), anyInt(), anyLong(), anyString()); + BridgeDeliveryAttempt attempt = updatedAttempt(); + assertThat(attempt.getStatus()).isEqualTo("ACKNOWLEDGED_LEASE_LOST"); + assertThat(attempt.getResponseCode()).isEqualTo("200"); + assertThat(attempt.getErrorMessage()).contains("租约已失效"); + assertThat(transportCount("ACKNOWLEDGED_LEASE_LOST")).isEqualTo(1.0); + } + + @Test + void failedDeliveryAfterLeaseTakeoverDoesNotOverwriteNewOwnerState() throws Exception { + when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down")); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down"))) + .thenReturn(0); + + dispatcher.dispatchOne("event-1"); + + assertThat(updatedAttempt().getStatus()).isEqualTo("FAILED_LEASE_LOST"); + assertThat(transportCount("FAILED_LEASE_LOST")).isEqualTo(1.0); + } + @Test void downstream503RetriesThenDeliversWithDiagnosticAttemptsInBothDirections() throws Exception { for (String target : List.of("OVERSEAS", "CN")) { @@ -162,4 +277,11 @@ class OverseasEventDispatcherTest { verify(attemptMapper).updateById(captor.capture()); return captor.getValue(); } + + private double transportCount(String result) { + return registry.get(EventBridgeMetrics.TRANSPORT_METRIC) + .tags("target_region", event.getTargetRegion(), + "event_type", event.getEventType(), "result", result) + .counter().count(); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java index 08ba4d734..a39cba50e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -72,11 +72,25 @@ public interface LoadingPickItemMapper extends BaseMapper @Param("operatorName") String operatorName); @Select(""" - SELECT COALESCE(SUM(plan_item.package_count),0) expectedCount, + SELECT COALESCE(SUM(CASE + WHEN pick_item.item_status='SKIPPED' AND pick_item.picked_package_count=0 THEN 0 + WHEN pick_item.item_status='PARTIAL' AND pick_item.picked_package_count>0 + AND pick_item.picked_package_count<=plan_item.package_count + THEN pick_item.picked_package_count + ELSE plan_item.package_count + END),0) expectedCount, COALESCE(SUM(pick_item.picked_package_count),0) pickedCount, COUNT(DISTINCT pick_task.id) taskCount, COUNT(DISTINCT CASE WHEN pick_task.task_status='COMPLETED' THEN pick_task.id END) completedTaskCount, - SUM(CASE WHEN pick_item.id IS NULL OR pick_item.picked_package_count<>plan_item.package_count THEN 1 ELSE 0 END) unresolvedCount + SUM(CASE + WHEN pick_item.id IS NULL THEN 1 + WHEN pick_item.item_status='PICKED' + AND pick_item.picked_package_count=plan_item.package_count THEN 0 + WHEN pick_item.item_status='PARTIAL' AND pick_item.picked_package_count>0 + AND pick_item.picked_package_count<=plan_item.package_count THEN 0 + WHEN pick_item.item_status='SKIPPED' AND pick_item.picked_package_count=0 THEN 0 + ELSE 1 + END) unresolvedCount FROM fl_loading_plan_item plan_item JOIN fl_inbound_order inbound_order ON inbound_order.id=plan_item.inbound_id AND inbound_order.tenant_id=#{tenantId} AND inbound_order.deleted=0 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java index 89d6f427f..403f5a5c1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java @@ -34,6 +34,8 @@ public class LoadingCompletedOutboxRepository { .eq(InboundOutboxEntity::getId, id) .in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES) .and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING") + .and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt) + .or().le(InboundOutboxEntity::getNextRetryAt, now)) .or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING") .le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore))) .set(InboundOutboxEntity::getPublishStatus, "PROCESSING").set(InboundOutboxEntity::getLockedBy, worker) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java new file mode 100644 index 000000000..4ee884bc3 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java @@ -0,0 +1,38 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.firstleg.entity.InboundOutboxEntity; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LoadingCompletedOutboxRepositoryTest { + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void claimRechecksRetryWindowAfterCandidateSelection() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), + InboundOutboxEntity.class); + InboundOutboxMapper mapper = mock(InboundOutboxMapper.class); + LoadingCompletedOutboxRepository repository = new LoadingCompletedOutboxRepository(); + ReflectionTestUtils.setField(repository, "mapper", mapper); + ArgumentCaptor wrapper = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + when(mapper.update(isNull(InboundOutboxEntity.class), wrapper.capture())).thenReturn(1); + LocalDateTime now = LocalDateTime.of(2026, 8, 25, 12, 0); + + assertThat(repository.claim(1L, "worker", now, now.minusMinutes(1))).isTrue(); + + assertThat(wrapper.getValue().getSqlSegment()) + .contains("next_retry_at", "<=", "publish_status", "locked_at"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java index 0810397ec..6a91c556b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java @@ -29,6 +29,31 @@ class LoadingPickItemMapperContractTest { assertThat(progress.getUnresolvedCount()).isEqualTo(1); } + @Test + void inboundProgressUsesSettledTerminalQuantitiesAndRejectsNonTerminalItems() throws Exception { + Method method = LoadingPickItemMapper.class.getDeclaredMethod( + "selectInboundProgress", long.class, long.class); + String sql = method.getAnnotation(Select.class).value()[0]; + + assertThat(sql) + .contains("WHEN pick_item.item_status='SKIPPED' AND pick_item.picked_package_count=0 THEN 0") + .contains("WHEN pick_item.item_status='PARTIAL' AND pick_item.picked_package_count>0") + .contains("AND pick_item.picked_package_count<=plan_item.package_count") + .contains("THEN pick_item.picked_package_count") + .contains("ELSE plan_item.package_count") + .contains("WHEN pick_item.id IS NULL THEN 1") + .contains("WHEN pick_item.item_status='PICKED'") + .contains("AND pick_item.picked_package_count=plan_item.package_count THEN 0") + .contains("ELSE 1") + .doesNotContain("pick_item.picked_package_count<>plan_item.package_count"); + assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql, + "pick_item.item_status='PARTIAL'")).isEqualTo(2); + assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql, + "pick_item.item_status='SKIPPED'")).isEqualTo(2); + assertThat(sql).doesNotContain("pick_item.item_status='PENDING' THEN 0") + .doesNotContain("pick_item.item_status='PICKING' THEN 0"); + } + @Test void completionCheckCoversAllActivePlanItems() throws Exception { Method method = LoadingPickItemMapper.class.getDeclaredMethod("unfinishedCount", long.class); diff --git a/gateway-overseas/AGENTS.md b/gateway-overseas/AGENTS.md index 72487d3aa..ddd4f2c28 100644 --- a/gateway-overseas/AGENTS.md +++ b/gateway-overseas/AGENTS.md @@ -14,6 +14,7 @@ | Audience 校验 | `config/AudienceJwtValidator.java` | | 必要 Claims 校验 | `config/RequiredClaimsJwtValidator.java` | | PDA 换仓即时失效 | `filter/PdaSessionValidationFilter.java`、`HttpPdaSessionValidationClient.java`、`config/PdaSessionValidationProperties.java`;仅 `/api/pda/**` 本地验签后同步核对 Auth,任何异常 fail-closed | +| 海外安全事件即时失效 | `filter/OverseasSecurityProjectionFilter.java`、`HttpOverseasSecurityValidationClient.java`;全部 `/api/**` 本地验签后核对海外安全投影,用户/客户停用与权限版本落后立即拒绝 | | 安全配置项 | `config/GatewaySecurityProperties.java` | | 可信身份头传递 | `filter/TrustedIdentityHeaderFilter.java` | | 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` | diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java index f9191717c..a23d519e5 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java @@ -24,7 +24,8 @@ import java.util.Map; * 海外网关响应式安全配置,使用国内认证中心公开的 JWKS 验证 JWT。 */ @Configuration -@EnableConfigurationProperties({GatewaySecurityProperties.class, PdaSessionValidationProperties.class}) +@EnableConfigurationProperties({GatewaySecurityProperties.class, PdaSessionValidationProperties.class, + OverseasSecurityProjectionProperties.class}) public class GatewaySecurityConfig { @Bean SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http, diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java new file mode 100644 index 000000000..9edcfad81 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java @@ -0,0 +1,17 @@ +package com.baiyue.gateway.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +@ConfigurationProperties(prefix = "baiyue.security.overseas-projection") +public record OverseasSecurityProjectionProperties(String serviceUrl, String internalServiceToken, Duration timeout) { + public OverseasSecurityProjectionProperties { + if (serviceUrl == null || serviceUrl.isBlank()) throw new IllegalArgumentException("必须配置 OVERSEAS_SECURITY_SERVICE_URL"); + if (internalServiceToken == null || internalServiceToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) { + throw new IllegalArgumentException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); + } + timeout = timeout == null || timeout.isNegative() || timeout.isZero() ? Duration.ofSeconds(2) : timeout; + serviceUrl = serviceUrl.replaceAll("/+$", ""); + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java new file mode 100644 index 000000000..6d75282df --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java @@ -0,0 +1,37 @@ +package com.baiyue.gateway.filter; + +import com.baiyue.gateway.config.OverseasSecurityProjectionProperties; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Component +public class HttpOverseasSecurityValidationClient implements OverseasSecurityValidationClient { + private static final String PATH = "/internal/overseas-security/v1/validate"; + private final WebClient client; + private final OverseasSecurityProjectionProperties properties; + + public HttpOverseasSecurityValidationClient(WebClient.Builder builder, OverseasSecurityProjectionProperties properties) { + this.client = builder.build(); + this.properties = properties; + } + + @Override + public Mono validate(long userId, long tenantId, long tokenVersion, long permissionVersion) { + return client.post().uri(properties.serviceUrl() + PATH).contentType(MediaType.APPLICATION_JSON) + .header("X-Internal-Service-Token", properties.internalServiceToken()) + .bodyValue(new Request(userId, tenantId, tokenVersion, permissionVersion)) + .retrieve().bodyToMono(JsonNode.class).timeout(properties.timeout()).flatMap(body -> { + JsonNode data = body == null ? null : body.path("data"); + if (data == null || data.isMissingNode() || !data.has("valid")) { + return Mono.error(new IllegalStateException("海外安全投影响应无效")); + } + return Mono.just(new Result(data.path("valid").asBoolean(false), + data.path("failureReason").isNull() ? null : data.path("failureReason").asText(null))); + }); + } + + private record Request(long userId, long tenantId, long tokenVersion, long permissionVersion) { } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java new file mode 100644 index 000000000..cdbb4e727 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java @@ -0,0 +1,74 @@ +package com.baiyue.gateway.filter; + +import com.baiyue.gateway.config.GatewaySecurityConfig; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +@Component +public class OverseasSecurityProjectionFilter implements GlobalFilter, Ordered { + private final OverseasSecurityValidationClient client; + private final ObjectMapper objectMapper; + + public OverseasSecurityProjectionFilter(OverseasSecurityValidationClient client, ObjectMapper objectMapper) { + this.client = client; + this.objectMapper = objectMapper; + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + if (!exchange.getRequest().getURI().getPath().startsWith("/api/")) return chain.filter(exchange); + return exchange.getPrincipal().cast(Authentication.class).filter(Authentication::isAuthenticated) + .ofType(JwtAuthenticationToken.class) + .switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "未登录或令牌已失效"))) + .flatMap(authentication -> validate(exchange, chain, authentication)) + .onErrorResume(SecurityProjectionFailure.class, + failure -> write(exchange, failure.code, failure.getMessage())) + .onErrorResume(failure -> write(exchange, "A0401", "海外安全状态校验不可用")); + } + + private Mono validate(ServerWebExchange exchange, GatewayFilterChain chain, + JwtAuthenticationToken authentication) { + Long userId = number(authentication.getToken().getSubject()); + Long tenantId = number(authentication.getToken().getClaim("tenant_id")); + Long tokenVersion = number(authentication.getToken().getClaim("token_version")); + Long permissionVersion = number(authentication.getToken().getClaim("permission_version")); + if (userId == null || tenantId == null || tokenVersion == null || permissionVersion == null) { + return Mono.error(new SecurityProjectionFailure("A0401", "海外令牌安全版本声明缺失")); + } + return client.validate(userId, tenantId, tokenVersion, permissionVersion) + .switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "海外安全状态校验不可用"))) + .flatMap(result -> { + if (result.valid()) return chain.filter(exchange); + if ("USER_DISABLED".equals(result.failureReason())) { + return Mono.error(new SecurityProjectionFailure("A0401", "账号已停用")); + } + return Mono.error(new SecurityProjectionFailure("A0403", "客户或权限状态已变化")); + }); + } + + private Mono write(ServerWebExchange exchange, String code, String message) { + return GatewaySecurityConfig.writeError(exchange, objectMapper, + "A0403".equals(code) ? HttpStatus.FORBIDDEN : HttpStatus.UNAUTHORIZED, code, message); + } + + private static Long number(Object value) { + if (value instanceof Number number) return number.longValue(); + try { return value == null ? null : Long.valueOf(value.toString()); } + catch (NumberFormatException exception) { return null; } + } + + @Override public int getOrder() { return -95; } + + private static final class SecurityProjectionFailure extends RuntimeException { + private final String code; + private SecurityProjectionFailure(String code, String message) { super(message); this.code = code; } + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java new file mode 100644 index 000000000..2ce22a244 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java @@ -0,0 +1,8 @@ +package com.baiyue.gateway.filter; + +import reactor.core.publisher.Mono; + +public interface OverseasSecurityValidationClient { + Mono validate(long userId, long tenantId, long tokenVersion, long permissionVersion); + record Result(boolean valid, String failureReason) { } +} diff --git a/gateway-overseas/src/main/resources/application.yml b/gateway-overseas/src/main/resources/application.yml index 200280692..3e7ce51f2 100644 --- a/gateway-overseas/src/main/resources/application.yml +++ b/gateway-overseas/src/main/resources/application.yml @@ -46,6 +46,10 @@ baiyue: auth-url: ${AUTH_SESSION_VALIDATION_URL} internal-service-token: ${INTERNAL_SERVICE_TOKEN} timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s} + overseas-projection: + service-url: ${OVERSEAS_SECURITY_SERVICE_URL} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + timeout: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:2s} permit-paths: - /actuator/health - /swagger-ui/** diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java new file mode 100644 index 000000000..ff9f935f0 --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java @@ -0,0 +1,83 @@ +package com.baiyue.gateway.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Instant; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class OverseasSecurityProjectionFilterTest { + private final OverseasSecurityValidationClient client = mock(OverseasSecurityValidationClient.class); + private final OverseasSecurityProjectionFilter filter = new OverseasSecurityProjectionFilter(client, new ObjectMapper()); + + @Test void disabledUserFailsWithA0401() { + when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just( + new OverseasSecurityValidationClient.Result(false, "USER_DISABLED"))); + var exchange = exchange("/api/platform/v1/last-leg/orders"); + StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(body(exchange)).contains("A0401"); + } + + @Test void disabledTenantAndStalePermissionFailWithA0403() { + for (String reason : new String[]{"TENANT_DISABLED", "PERMISSION_VERSION_STALE"}) { + when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just( + new OverseasSecurityValidationClient.Result(false, reason))); + var exchange = exchange("/api/merchant/v1/last-leg/orders"); + StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(body(exchange)).contains("A0403"); + } + } + + @Test void projectionUnavailableFailsClosed() { + when(client.validate(42, 0, 3, 8)).thenReturn(Mono.error(new IllegalStateException("down"))); + var exchange = exchange("/api/platform/v1/last-leg/orders"); + StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test void acceptedSecurityStateContinuesAndNonApiPathsDoNotCallProjection() { + when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just( + new OverseasSecurityValidationClient.Result(true, null))); + AtomicBoolean routed = new AtomicBoolean(); + StepVerifier.create(filter.filter(exchange("/api/platform/v1/last-leg/orders"), ignored -> { + routed.set(true); return Mono.empty(); + })).verifyComplete(); + assertThat(routed).isTrue(); + + var isolatedClient = mock(OverseasSecurityValidationClient.class); + var isolatedFilter = new OverseasSecurityProjectionFilter(isolatedClient, new ObjectMapper()); + StepVerifier.create(isolatedFilter.filter(exchange("/actuator/health"), ignored -> Mono.empty())).verifyComplete(); + verifyNoInteractions(isolatedClient); + } + + private static ServerWebExchange exchange(String path) { + Jwt jwt = Jwt.withTokenValue("verified").header("alg", "RS256").subject("42") + .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) + .claim("tenant_id", 0L).claim("token_version", 3L).claim("permission_version", 8L).build(); + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get(path).build()); + return exchange.mutate().principal(Mono.just(new JwtAuthenticationToken(jwt, + List.of(new SimpleGrantedAuthority("ROLE_PLATFORM_USER"))))).build(); + } + + private static String body(ServerWebExchange exchange) { + return ((org.springframework.mock.http.server.reactive.MockServerHttpResponse) exchange.getResponse()) + .getBodyAsString().block(); + } +} diff --git a/lastleg-service/docs/lastleg-internal-modularization-baseline.md b/lastleg-service/docs/lastleg-internal-modularization-baseline.md new file mode 100644 index 000000000..0acced4c7 --- /dev/null +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -0,0 +1,199 @@ +# Lastleg 内部模块化依赖基线与首切片方案 + +## 1. 范围与生成方式 + +本基线只分析 `lastleg-service` 同一部署单元,不拆微服务,不修改业务代码、POM、数据库、OpenAPI、权限、接口或跨服务契约。统计时工作区同时存在 RestClient 超时专项的在途修改;本专项不修改 `client/`、`config/*RestClient*` 或各业务 HTTP Gateway。 + +可重复命令: + +```powershell +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format markdown +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set test --format markdown +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all --format markdown +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format dot --graph package +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set test --format dot --graph package +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all --format dot --graph package +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format dot --graph slice +``` + +分析器解析显式内部 `import`、静态 `import` 及同包类型引用,输出类依赖、物理包图、逻辑切片图、Tarjan SCC、Entity/DTO/Model 泄漏与 Mapper 泄漏。`test` 单独输出测试包节点及测试类之间的边;当前测试主要引用生产类型,因此 test-only 图有 67 个包节点但没有测试类之间的内部边。`all` 把 `src/main/java` 和 `src/test/java` 合并解析,是观察测试对生产类型耦合的主图。Spring 扫描字符串、反射、MyBatis SQL 表引用和跨服务运行时调用不属于 Java 类型图。 + +## 2. 当前规模与 SCC + +| 源集 | Java 文件 | 内部类依赖 | 物理包 | 跨切片类依赖 | 模型泄漏 | Mapper 泄漏 | +|---|---:|---:|---:|---:|---:|---:| +| main | 347 | 672 | 157 | 137 | 60 | 38 | +| test only | 98 | 0 | 67 | 0 | 0 | 0 | +| main + test | 445 | 1,195 | 161 | 256 | 108 | 76 | + +主源码物理包图没有多节点 SCC。主源码逻辑切片图存在一个四节点 SCC: + +```text +dropship +inbound-pda +inventory +tailleg-handoff +``` + +合并测试源码后,出现一个由测试直接绑定横向实现包形成的物理包 SCC: + +```text +com.baiyue.lastleg.looseboard.service +com.baiyue.lastleg.relabel.service +com.baiyue.lastleg.service +com.baiyue.lastleg.transfer.service +``` + +这不是生产运行时包环。合并图的逻辑 SCC 扩大为 `dispatch-delivery`、`dropship`、`inbound-pda`、`inventory`、`procurement-trucking-looseboard`、`relabel-transfer-discard`、`tailleg-handoff` 七个切片,原因是跨域验收测试引用多个业务实现包。 + +## 3. 逻辑切片映射 + +| 逻辑切片 | 当前识别范围 | main 类数 | 跨切片出/入类边 | 当前结论 | +|---|---|---:|---:|---| +| 尾程交接 `tailleg-handoff` | 根 `controller/dto/entity/mapper/model/repository/service` 与 `shipping/` | 33 | 12/48 | 核心尾程单、事件 Inbox/Outbox 和船节点门禁被入仓、库存、换标等直接引用,位于主 SCC | +| 入仓/PDA `inbound-pda` | `inboundplan/`、`palletops/`、根 `pda/` | 100 | 53/14 | 最大切片;与尾程核心、库存和一件代发双向耦合,含大量库存 Entity/Mapper 泄漏 | +| 库存 `inventory` | `inventory/` | 26 | 3/37 | 自身出向较少,但作为多个作业域共享的 Entity/Mapper 实现被直接消费,位于主 SCC | +| 排车/派车 `dispatch-delivery` | `dispatch/`、`delivery/`、未来 `outbound/` | 46 | 20/0 | 主源码无入向 Java 类型边,但涉及明确排除的出库/派送范围,不作为首切片 | +| 换标/转运/废弃 `relabel-transfer-discard` | `relabel/`、`transfer/`、`discard/`,其 PDA 子包保持本域归属 | 61 | 31/0 | 直接使用尾程、库存和一件代发持久化模型;业务事务边界复杂 | +| 一件代发 `dropship` | `dropship/` | 12 | 4/11 | 与尾程消费及废弃库存双向耦合,位于主 SCC | +| 采购/卡车/散板 `procurement-trucking-looseboard` | `procurement/`、`trucking/`、`looseboard/` | 35 | 11/0 | 依赖尾程核心,采购另读取报价目录;仍由四期业务专项维护 | +| 海外主数据 `overseas-master-data` | `masterdata/quote`、`exchange`、`vehicle` | 26 | 3/1 | 不在任何 SCC;仅出向共享权限常量,入向来自采购对报价目录的使用 | + +另有 6 个 `shared-technical` 类:应用启动类、Event Bridge Client、RestClient 配置、内部令牌保护和海外权限常量。该分类不授权移动这些共享类。 + +## 4. 主源码逻辑切片边 + +| 来源 | 目标 | 类边数 | +|---|---|---:| +| dispatch-delivery | inbound-pda | 6 | +| dispatch-delivery | inventory | 4 | +| dispatch-delivery | shared-technical | 2 | +| dispatch-delivery | tailleg-handoff | 8 | +| dropship | inbound-pda | 1 | +| dropship | shared-technical | 1 | +| dropship | tailleg-handoff | 2 | +| inbound-pda | dropship | 1 | +| inbound-pda | inventory | 25 | +| inbound-pda | shared-technical | 8 | +| inbound-pda | tailleg-handoff | 19 | +| inventory | inbound-pda | 1 | +| inventory | shared-technical | 2 | +| overseas-master-data | shared-technical | 3 | +| procurement-trucking-looseboard | overseas-master-data | 1 | +| procurement-trucking-looseboard | shared-technical | 5 | +| procurement-trucking-looseboard | tailleg-handoff | 5 | +| relabel-transfer-discard | dropship | 8 | +| relabel-transfer-discard | inventory | 6 | +| relabel-transfer-discard | shared-technical | 3 | +| relabel-transfer-discard | tailleg-handoff | 14 | +| tailleg-handoff | dropship | 2 | +| tailleg-handoff | inbound-pda | 6 | +| tailleg-handoff | inventory | 2 | +| tailleg-handoff | shared-technical | 2 | + +当前 25 条切片边冻结在 `src/test/resources/architecture/lastleg-slice-dependencies.json`。合同允许删除历史边,不允许增加新边;海外主数据继续锁定为只允许出向 `shared-technical`,只允许采购/卡车/散板切片入向。 + +## 5. Entity/DTO/Mapper 泄漏基线 + +主源码共有 60 条跨切片 Entity/DTO/Model 泄漏、38 条跨切片 Mapper 泄漏。高频边如下: + +| 来源 → 目标 | 模型 | Mapper | 代表性证据 | +|---|---:|---:|---| +| inbound-pda → inventory | 15 | 9 | 托盘、移库、盘点 Service/Mapper 直接使用 `InventoryBalance`、`InventoryPosition`、`InventoryLedger` 及其 Mapper | +| inbound-pda → tailleg-handoff | 9 | 9 | 入仓实收、卸柜完成直接使用根 `LastlegOrder*`、事件 Outbox Entity/Mapper | +| relabel-transfer-discard → dropship | 7 | 1 | 废弃直接读取一件代发箱/SKU 库存 Entity,调整服务写箱流水 Mapper | +| relabel-transfer-discard → tailleg-handoff | 6 | 6 | 换标、转运、废弃直接使用根尾程订单、明细、状态历史 Entity/Mapper | +| dispatch-delivery → tailleg-handoff | 4 | 4 | 拣货归属和派送转移直接使用根尾程订单与状态 Mapper;该范围不进入本专项首切片 | +| dispatch-delivery → inbound-pda | 4 | 2 | 派送转移直接使用入仓计划行、托盘分配 Entity/Mapper;该范围不进入本专项首切片 | +| relabel-transfer-discard → inventory | 4 | 1 | 转运直接使用库存余额/库位模型及库存命令 Mapper | + +当前泄漏体现的是业务核心对其他切片持久化实现和表模型的直接依赖。后续治理应以应用 Port 和切片自有投影替换,不共享领域 Entity;本轮不做替换。 + +## 6. 首切片选择:车辆档案 + +选择 `masterdata.vehicle` 作为首个低耦合切片候选,依据当前主源码图: + +1. 只有 5 个生产类,已经集中在单一子域;现有测试 3 个。 +2. 零跨切片入向类依赖;唯一出向类依赖为 `PlatformVehicleController -> OverseasPermissions`。 +3. 海外主数据不在 main 或 main+test 的逻辑 SCC 内,车辆子域也没有 Entity/DTO/Mapper 跨切片泄漏。 +4. 不包含跨服务 Client、RestClient 或 HTTP Gateway,不受同步依赖超时专项影响。 +5. 车辆档案明确不保存派送履约历史;首切片不触碰 `dispatch/`、`delivery/`、出库或派送代码。 +6. 当前业务面固定为平台车辆档案列表、详情、创建、编辑、状态和 CSV 导出,不需要补齐四期业务缺口。 + +报价子域虽然也不在 SCC,但被采购服务通过 `PartnerWarehouseDirectory` 入向依赖,并包含 `DomesticPartnerWarehouseClient`,因此避让。月度汇率有 8 个生产类,也比车辆档案更大。 + +## 7. 已实施目标目录与精确文件 + +首切片已按下列结构实施: + +```text +masterdata/vehicle/ + adapter/in/web/ + PlatformVehicleController.java + application/ + VehicleApplicationService.java + model/VehicleModels.java + port/out/VehicleRepository.java + adapter/out/persistence/ + VehiclePersistenceAdapter.java + VehicleMapper.java + VehicleRecord.java +``` + +精确动作草案: + +| 原文件 | 已实施动作 | +|---|---| +| `masterdata/vehicle/controller/PlatformVehicleController.java` | 移至 `adapter/in/web/`;URI、权限、HTTP/CSV 响应不变 | +| `masterdata/vehicle/dto/VehicleDtos.java` | 移至 `application/model/VehicleModels.java`;JSON 字段、校验和响应语义不变 | +| `masterdata/vehicle/service/VehicleService.java` | 移至 `application/VehicleApplicationService.java`;业务校验、乐观锁和状态语义不变,只依赖 Out Port | +| 新增 `application/port/out/VehicleRepository.java` | 定义分页、详情、锁定、创建、乐观锁更新、状态更新和导出查询所需的切片内投影;不暴露 MyBatis 类型 | +| `masterdata/vehicle/entity/Vehicle.java` | 移至 `adapter/out/persistence/VehicleRecord.java`;仅作为 `ll_vehicle` 持久化记录 | +| `masterdata/vehicle/mapper/VehicleMapper.java` | 移至 `adapter/out/persistence/VehicleMapper.java`;保持现有 SQL 与 MyBatis 语义 | +| 新增 `adapter/out/persistence/VehiclePersistenceAdapter.java` | 实现 `VehicleRepository`,隔离 MyBatis `Page`、Wrapper、Mapper 和 Record | + +允许依赖方向: + +```text +adapter.in.web -> application -> application.model + application.port.out +adapter.out.persistence -> application.port.out + application.model +``` + +Web Adapter 可继续依赖 Spring Web、common-security 和 `OverseasPermissions`。`application` 禁止依赖 Spring MVC、MyBatis/MyBatis-Plus、全局 Mapper/Entity、RestClient、其他业务切片或出库/派送类型。 + +## 8. 测试与回滚边界 + +本次迁移只修改车辆子域 5 个原生产文件、新增 2 个车辆子域文件,并定向更新: + +- `src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java`:改为 Mock `VehicleRepository`,继续锁定公里码、乐观锁和状态规则。 +- `src/test/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleControllerContractTest.java`:继续锁定 URI 与六项权限。 +- `src/test/java/com/baiyue/lastleg/masterdata/vehicle/VehicleSqlContractTest.java`:继续锁定 `ll_vehicle` SQL、唯一索引和字段注释。 +- `scripts/analyze-lastleg-dependencies.test.mjs`:已锁定目标 7 类与 application 禁止依赖规则。 + +回滚只涉及上述车辆 Java 包移动、两个新增抽象及对应测试引用。不得修改或回滚共享 POM、`AGENTS.md`、RestClient 配置、数据库 SQL、OpenAPI、权限 SQL、接口路径、字段、状态、CSV 格式、Nacos、部署或其他业务域。若 Mapper 扫描或事务代理合同不满足,整体恢复车辆包内变更,不通过扩大全局扫描、兼容 Bean 或跨切片共享 Entity 绕过。 + +## 9. 当前只读合同 + +- `scripts/analyze-lastleg-dependencies.mjs`:生成 JSON、Markdown、逻辑切片 DOT、main/test/all 物理包 DOT 和 Tarjan SCC。 +- `src/test/resources/architecture/lastleg-slice-dependencies.json`:冻结 25 条 main 切片边;历史边可减少,不可增加。 +- `scripts/analyze-lastleg-dependencies.test.mjs`:校验基线、main/test/all 包图输出,以及车辆候选的 5 类、零入向、唯一权限出向、零 Client/RestClient 和不进入 SCC。 + +最小验证: + +```powershell +node --test lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format markdown +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set test --format markdown +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all --format markdown +``` + +## 10. 首切片实施结果 + +- 旧 5 类及旧测试包已删除,没有转发兼容层;目标 7 类已由 Node 合同锁定。 +- `VehicleApplicationService` 只依赖 `VehicleModels` 与 `VehicleRepository`,不依赖 MyBatis/MyBatis-Plus、Spring MVC、RestClient、全局 Mapper/Entity 或其他 Lastleg 切片。 +- `VehiclePersistenceAdapter` 独占 MyBatis `Page`、Wrapper、Mapper 和 `VehicleRecord`,只向 Port 返回 Java 中立投影。 +- 车辆切片保持零入向依赖,唯一出向仍是 Web Adapter 使用 `OverseasPermissions`;全局 25 条切片边、60 条模型泄漏、38 条 Mapper 泄漏及主 SCC 均未增加。 +- 接口路径、六项权限、JSON 字段、校验、乐观锁、状态、CSV、SQL、表和索引保持不变。 +- 验证结果:Node 合同 4/4;`mvn -pl lastleg-service -am test-compile` 成功;车辆定向测试 5/5;`mvn -pl lastleg-service -am test` 中 lastleg-service 370 个测试,0 失败、0 错误、2 个环境验收跳过,构建成功。 + +首切片完成后停止,不进入第二切片。 diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs index 03146b819..c639c1bcd 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs @@ -23,20 +23,22 @@ const sliceRules = [ ]; function parseArgs(argv) { - const options = { sourceSet: 'main', format: 'json', output: null, check: null }; + const options = { sourceSet: 'main', format: 'json', graph: 'slice', output: null, check: null }; for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; if (argument === '--source-set') options.sourceSet = argv[++index]; else if (argument === '--format') options.format = argv[++index]; + else if (argument === '--graph') options.graph = argv[++index]; else if (argument === '--output') options.output = argv[++index]; else if (argument === '--check') options.check = argv[++index]; else if (argument === '--help') { - process.stdout.write('Usage: node scripts/analyze-lastleg-dependencies.mjs [--source-set main|test|all] [--format json|markdown|dot] [--output FILE] [--check CONTRACT.json]\n'); + process.stdout.write('Usage: node scripts/analyze-lastleg-dependencies.mjs [--source-set main|test|all] [--format json|markdown|dot] [--graph slice|package] [--output FILE] [--check CONTRACT.json]\n'); process.exit(0); } else throw new Error(`Unknown argument: ${argument}`); } if (!['main', 'test', 'all'].includes(options.sourceSet)) throw new Error(`Invalid source set: ${options.sourceSet}`); if (!['json', 'markdown', 'dot'].includes(options.format)) throw new Error(`Invalid format: ${options.format}`); + if (!['slice', 'package'].includes(options.graph)) throw new Error(`Invalid graph: ${options.graph}`); return options; } @@ -254,9 +256,14 @@ function renderMarkdown(report) { ].join('\n'); } -function renderDot(report) { - const lines = ['digraph lastleg_slices {', ' rankdir=LR;']; - for (const edge of report.sliceEdges) lines.push(` "${edge.source}" -> "${edge.target}" [label="${edge.count}"];`); +function renderDot(report, graph) { + const edges = graph === 'package' ? report.packageEdges : report.sliceEdges; + const nodes = graph === 'package' + ? [...new Set(report.classes.map(entry => entry.packageName))].sort() + : [...new Set(report.classes.map(entry => entry.slice))].sort(); + const lines = [`digraph lastleg_${graph}s {`, ' rankdir=LR;']; + for (const node of nodes) lines.push(` "${node}";`); + for (const edge of edges) lines.push(` "${edge.source}" -> "${edge.target}" [label="${edge.count}"];`); lines.push('}', ''); return lines.join('\n'); } @@ -267,6 +274,30 @@ function checkContract(report, contractPath) { const allowed = new Set(contract.allowedSliceEdges ?? []); const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`); const violations = actual.filter(edge => !allowed.has(edge)).map(edge => `new slice dependency: ${edge}`); + const classesByName = new Map(report.classes.map(entry => [entry.fqcn, entry])); + for (const [fqcn, expectedFile] of Object.entries(contract.requiredClassLocations ?? {})) { + const actualClass = classesByName.get(fqcn); + if (!actualClass) violations.push(`required class missing: ${fqcn}`); + else if (actualClass.file !== expectedFile) violations.push(`class location changed: ${fqcn} (${actualClass.file})`); + } + const applicationRule = contract.applicationDependencyRule; + if (applicationRule) { + const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(applicationRule.packagePrefix)); + for (const entry of applicationClasses) { + for (const imported of entry.imports) { + if ((applicationRule.forbiddenImportPrefixes ?? []).some(prefix => imported.startsWith(prefix))) { + violations.push(`forbidden application import: ${entry.fqcn} -> ${imported}`); + } + } + } + for (const edge of report.classDependencies) { + if (edge.source.startsWith(applicationRule.packagePrefix) + && edge.target.startsWith(internalPrefix) + && !edge.target.startsWith(applicationRule.allowedInternalPrefix)) { + violations.push(`forbidden application dependency: ${edge.source} -> ${edge.target}`); + } + } + } for (const [slice, rule] of Object.entries(contract.sliceSpecificRules ?? {})) { const outgoing = report.sliceEdges.filter(edge => edge.source === slice).map(edge => edge.target).sort(); const incoming = report.sliceEdges.filter(edge => edge.target === slice).map(edge => edge.source).sort(); @@ -290,6 +321,6 @@ const rendered = options.format === 'json' ? `${JSON.stringify(report, null, 2)}\n` : options.format === 'markdown' ? renderMarkdown(report) - : renderDot(report); + : renderDot(report, options.graph); if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8'); else process.stdout.write(rendered); diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs new file mode 100644 index 000000000..bb8a6a945 --- /dev/null +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import test from 'node:test'; + +const moduleRoot = path.resolve(import.meta.dirname, '..'); +const analyzer = path.join(moduleRoot, 'scripts', 'analyze-lastleg-dependencies.mjs'); +const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'lastleg-slice-dependencies.json'); +const vehiclePrefix = 'com.baiyue.lastleg.masterdata.vehicle'; + +function analyze(sourceSet = 'main') { + return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], { + cwd: moduleRoot, + encoding: 'utf8', + })); +} + +test('current source does not add slice dependencies beyond the Lastleg baseline', () => { + execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'dot', '--check', contract], { + cwd: moduleRoot, + encoding: 'utf8', + }); +}); + +test('main, test, and combined source sets expose package dependency graphs', () => { + for (const sourceSet of ['main', 'test', 'all']) { + const dot = execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'dot', '--graph', 'package'], { + cwd: moduleRoot, + encoding: 'utf8', + }); + assert.match(dot, /^digraph lastleg_packages \{/); + assert.match(dot, /com\.baiyue\.lastleg/); + } +}); + +test('vehicle master data remains the reviewed low-coupling first-slice candidate', () => { + const report = analyze(); + const classes = report.classes.filter(entry => entry.fqcn.startsWith(vehiclePrefix)); + assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [ + 'com.baiyue.lastleg.masterdata.vehicle.adapter.in.web.PlatformVehicleController', + 'com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleMapper', + 'com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehiclePersistenceAdapter', + 'com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleRecord', + 'com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService', + 'com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels', + 'com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository', + ]); + const outgoing = report.classDependencies.filter(edge => edge.source.startsWith(vehiclePrefix) + && !edge.target.startsWith(vehiclePrefix)); + const incoming = report.classDependencies.filter(edge => edge.target.startsWith(vehiclePrefix) + && !edge.source.startsWith(vehiclePrefix)); + assert.deepEqual(outgoing, [{ + source: 'com.baiyue.lastleg.masterdata.vehicle.adapter.in.web.PlatformVehicleController', + target: 'com.baiyue.lastleg.security.OverseasPermissions', + }]); + assert.deepEqual(incoming, []); + assert.equal(report.sliceSccs.some(component => component.includes('overseas-master-data')), false); + assert.equal(classes.some(entry => entry.kind === 'cross-service-adapter'), false); + assert.equal(classes.some(entry => entry.imports.some(imported => /RestClient|\.client\./.test(imported))), false); +}); + +test('vehicle application only depends inward and stays free of adapter frameworks', () => { + const report = analyze(); + const prefix = `${vehiclePrefix}.application`; + const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(prefix)); + assert.equal(applicationClasses.length, 3); + for (const entry of applicationClasses) { + assert.equal(entry.imports.some(imported => imported.startsWith('org.springframework.web')), false); + assert.equal(entry.imports.some(imported => imported.startsWith('org.apache.ibatis')), false); + assert.equal(entry.imports.some(imported => imported.startsWith('com.baomidou.mybatisplus')), false); + assert.equal(entry.imports.some(imported => /RestClient|\.client\./.test(imported)), false); + } + const outwardDependencies = report.classDependencies.filter(edge => edge.source.startsWith(prefix) + && edge.target.startsWith('com.baiyue.lastleg.') && !edge.target.startsWith(prefix)); + assert.deepEqual(outwardDependencies, []); +}); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java index 27a8b5cf0..298124a59 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java @@ -10,12 +10,19 @@ import java.net.http.HttpClient; public class LastlegRestClientFactory { public RestClient build(RestClient.Builder builder, String baseUrl, LastlegRestClientProperties.ClientTimeouts timeouts) { - HttpClient httpClient = HttpClient.newBuilder() - .connectTimeout(timeouts.getConnectTimeout()) - .build(); - JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); - requestFactory.setReadTimeout(timeouts.getReadTimeout()); + JdkClientHttpRequestFactory requestFactory = requestFactory(timeouts); return builder.requestFactory(requestFactory).baseUrl(baseUrl).build(); } -} + HttpClient httpClient(LastlegRestClientProperties.ClientTimeouts timeouts) { + return HttpClient.newBuilder() + .connectTimeout(timeouts.getConnectTimeout()) + .build(); + } + + JdkClientHttpRequestFactory requestFactory(LastlegRestClientProperties.ClientTimeouts timeouts) { + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient(timeouts)); + requestFactory.setReadTimeout(timeouts.getReadTimeout()); + return requestFactory; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index 28c46ba7a..7fd3375d5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -50,8 +50,14 @@ public class LastlegOrderController { @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestBody EventEnvelope event) { tokenGuard.verify(token); - int consumed = EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType()) - ? shippingNodeConsumer.consume(event) : consumer.consume(event); + int consumed; + if (EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType())) { + consumed = shippingNodeConsumer.consume(event); + } else if (EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(event.eventType())) { + consumed = consumer.consume(event); + } else { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Lastleg 不消费该事件类型: " + event.eventType()); + } return ApiResponse.success(consumed); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java new file mode 100644 index 000000000..f2d570a9a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java @@ -0,0 +1,103 @@ +package com.baiyue.lastleg.masterdata.vehicle.adapter.in.web; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService; +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.VehicleFailure; +import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels; +import com.baiyue.lastleg.security.OverseasPermissions; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.util.function.Supplier; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/master-data/vehicles") +@Tag(name = "Vehicle master data / 车辆主数据") +public class PlatformVehicleController { + private final VehicleApplicationService service; + + public PlatformVehicleController(VehicleApplicationService service) { + this.service = service; + } + + @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')") + @Operation(summary = "Page vehicle master data", description = "Required authority: platform:overseas-master-data:vehicle:list") + public ApiResponse> page(@Valid VehicleModels.Query query) { + return execute(() -> ApiResponse.success(service.page(query))); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')") + @Operation(summary = "Get vehicle master data detail", description = "Required authority: platform:overseas-master-data:vehicle:list; excludes delivery fulfillment history") + public ApiResponse detail(@PathVariable long id) { + return execute(() -> ApiResponse.success(service.detail(id))); + } + + @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_CREATE + "')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody VehicleModels.Create request) { + return execute(() -> ApiResponse.success(service.create(operator(user), request))); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EDIT + "')") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long id, + @Valid @RequestBody VehicleModels.Update request) { + return execute(() -> ApiResponse.success(service.update(id, operator(user), request))); + } + + @PatchMapping("/{id}/status") + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_STATUS + "')") + public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long id, + @Valid @RequestBody VehicleModels.ChangeStatus request) { + return execute(() -> ApiResponse.success(service.changeStatus(id, operator(user), request))); + } + + @GetMapping(value = "/export", produces = "text/csv") + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EXPORT + "')") + public ResponseEntity export(@Valid VehicleModels.Query query) { + return execute(() -> ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=vehicles.csv").body(service.exportCsv(query))); + } + + private String operator(AuthenticatedUser user) { + if (user == null) return "UNKNOWN"; + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } + + private T execute(Supplier action) { + try { + return action.get(); + } catch (VehicleFailure failure) { + HttpStatus status = switch (failure.kind()) { + case BAD_REQUEST -> HttpStatus.BAD_REQUEST; + case NOT_FOUND -> HttpStatus.NOT_FOUND; + case CONFLICT -> HttpStatus.CONFLICT; + }; + throw new ResponseStatusException(status, failure.getMessage(), failure); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/mapper/VehicleMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleMapper.java similarity index 55% rename from lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/mapper/VehicleMapper.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleMapper.java index 71198fc52..10a96df7f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/mapper/VehicleMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleMapper.java @@ -1,13 +1,12 @@ -package com.baiyue.lastleg.masterdata.vehicle.mapper; +package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence; import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; @Mapper -public interface VehicleMapper extends BaseMapper { +public interface VehicleMapper extends BaseMapper { @Select("SELECT * FROM ll_vehicle WHERE id=#{id} LIMIT 1 FOR UPDATE") - Vehicle lockById(@Param("id") long id); + VehicleRecord lockById(@Param("id") long id); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java new file mode 100644 index 000000000..cb5252cf7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java @@ -0,0 +1,121 @@ +package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Optional; + +@Repository +public class VehiclePersistenceAdapter implements VehicleRepository { + private final VehicleMapper vehicles; + + public VehiclePersistenceAdapter(VehicleMapper vehicles) { + this.vehicles = vehicles; + } + + @Override + public PageSlice page(SearchCriteria criteria) { + Page page = vehicles.selectPage(Page.of(criteria.page(), criteria.size()), wrapper(criteria)); + return new PageSlice(page.getRecords().stream().map(this::snapshot).toList(), page.getCurrent(), + page.getSize(), page.getTotal(), page.getPages()); + } + + @Override + public List list(SearchCriteria criteria) { + return vehicles.selectList(wrapper(criteria)).stream().map(this::snapshot).toList(); + } + + @Override + public Optional findById(long id) { + return Optional.ofNullable(vehicles.selectById(id)).map(this::snapshot); + } + + @Override + public Optional lockById(long id) { + return Optional.ofNullable(vehicles.lockById(id)).map(this::snapshot); + } + + @Override + public long create(NewVehicle vehicle) { + VehicleRecord record = new VehicleRecord(); + record.setId(IdWorker.getId()); + record.setPlateNumber(vehicle.plateNumber()); + record.setModel(vehicle.model()); + record.setFuelConsumptionPer100Km(vehicle.fuelConsumptionPer100Km()); + record.setMileageKm(vehicle.mileageKm()); + record.setDriverOrDispatchCompany(vehicle.driverOrDispatchCompany()); + record.setPhone(vehicle.phone()); + record.setRegion(vehicle.region()); + record.setOwnership(vehicle.ownership()); + record.setStatus(vehicle.status()); + record.setRemark(vehicle.remark()); + record.setRecordVersion(0L); + record.setCreatedBy(vehicle.operator()); + record.setUpdatedBy(vehicle.operator()); + record.setCreatedAt(vehicle.createdAt()); + record.setUpdatedAt(vehicle.createdAt()); + try { + vehicles.insert(record); + } catch (DuplicateKeyException exception) { + throw new DuplicatePlateException(exception); + } + return record.getId(); + } + + @Override + public boolean update(long id, long expectedVersion, VehicleChanges changes) { + try { + return vehicles.update(null, new LambdaUpdateWrapper() + .eq(VehicleRecord::getId, id).eq(VehicleRecord::getRecordVersion, expectedVersion) + .set(VehicleRecord::getPlateNumber, changes.plateNumber()) + .set(VehicleRecord::getModel, changes.model()) + .set(VehicleRecord::getFuelConsumptionPer100Km, changes.fuelConsumptionPer100Km()) + .set(VehicleRecord::getMileageKm, changes.mileageKm()) + .set(VehicleRecord::getDriverOrDispatchCompany, changes.driverOrDispatchCompany()) + .set(VehicleRecord::getPhone, changes.phone()).set(VehicleRecord::getRegion, changes.region()) + .set(VehicleRecord::getOwnership, changes.ownership()).set(VehicleRecord::getRemark, changes.remark()) + .set(VehicleRecord::getUpdatedBy, changes.operator()).set(VehicleRecord::getUpdatedAt, changes.updatedAt()) + .setSql("record_version = record_version + 1")) == 1; + } catch (DuplicateKeyException exception) { + throw new DuplicatePlateException(exception); + } + } + + @Override + public boolean updateStatus(long id, long expectedVersion, String status, String operator, + java.time.LocalDateTime updatedAt) { + return vehicles.update(null, new LambdaUpdateWrapper() + .eq(VehicleRecord::getId, id).eq(VehicleRecord::getRecordVersion, expectedVersion) + .set(VehicleRecord::getStatus, status).set(VehicleRecord::getUpdatedBy, operator) + .set(VehicleRecord::getUpdatedAt, updatedAt).setSql("record_version = record_version + 1")) == 1; + } + + private LambdaQueryWrapper wrapper(SearchCriteria criteria) { + return new LambdaQueryWrapper() + .and(StringUtils.hasText(criteria.keyword()), wrapper -> wrapper.like(VehicleRecord::getPlateNumber, criteria.keyword()) + .or().like(VehicleRecord::getModel, criteria.keyword()) + .or().like(VehicleRecord::getDriverOrDispatchCompany, criteria.keyword())) + .eq(criteria.ownership() != null, VehicleRecord::getOwnership, criteria.ownership()) + .eq(criteria.region() != null, VehicleRecord::getRegion, criteria.region()) + .eq(criteria.status() != null, VehicleRecord::getStatus, criteria.status()) + .orderByDesc(VehicleRecord::getUpdatedAt).orderByDesc(VehicleRecord::getId); + } + + private VehicleSnapshot snapshot(VehicleRecord value) { + return new VehicleSnapshot(value.getId(), value.getPlateNumber(), value.getModel(), + value.getFuelConsumptionPer100Km(), value.getMileageKm(), value.getDriverOrDispatchCompany(), value.getPhone(), + value.getRegion(), value.getOwnership(), value.getStatus(), value.getRemark(), version(value.getRecordVersion()), + value.getUpdatedBy(), value.getUpdatedAt()); + } + + private static long version(Long value) { + return value == null ? 0L : value; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/entity/Vehicle.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java similarity index 88% rename from lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/entity/Vehicle.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java index 82a303ac5..9518e94e4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/entity/Vehicle.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java @@ -1,4 +1,4 @@ -package com.baiyue.lastleg.masterdata.vehicle.entity; +package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; @@ -10,7 +10,7 @@ import java.time.LocalDateTime; @Data @TableName("ll_vehicle") -public class Vehicle { +public class VehicleRecord { @TableId(type = IdType.INPUT) private Long id; private String plateNumber; private String model; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java new file mode 100644 index 000000000..79fbb24d1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java @@ -0,0 +1,165 @@ +package com.baiyue.lastleg.masterdata.vehicle.application; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.DuplicatePlateException; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.NewVehicle; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.SearchCriteria; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleChanges; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleSnapshot; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Locale; +import java.util.Set; + +@Service +public class VehicleApplicationService { + private static final Set OWNERSHIPS = Set.of("OWNED", "DISPATCHED"); + private static final Set REGIONS = Set.of("DOMESTIC", "OVERSEAS"); + private static final Set STATUSES = Set.of("IN_SERVICE", "DISPATCHED", "MAINTENANCE", "INACTIVE", "RETURNED"); + + private final VehicleRepository vehicles; + + public VehicleApplicationService(VehicleRepository vehicles) { + this.vehicles = vehicles; + } + + public PageResult page(VehicleModels.Query query) { + var page = vehicles.page(criteria(query)); + return new PageResult<>(page.records().stream().map(this::response).toList(), page.page(), + page.size(), page.total(), page.pages()); + } + + public VehicleModels.Response detail(long id) { + return response(required(id)); + } + + @Transactional + public VehicleModels.Response create(String operator, VehicleModels.Create request) { + LocalDateTime now = LocalDateTime.now(); + long id; + try { + id = vehicles.create(new NewVehicle( + requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT), + requiredText(request.model(), "车型"), request.fuelConsumptionPer100Km(), request.mileageKm(), + trim(request.driverOrDispatchCompany()), trim(request.phone()), region(request.region()), + ownership(request.ownership()), status(request.status()), trim(request.remark()), operator, now)); + } catch (DuplicatePlateException exception) { + throw conflict("车牌号已存在"); + } + return detail(id); + } + + @Transactional + public VehicleModels.Response update(long id, String operator, VehicleModels.Update request) { + VehicleSnapshot current = locked(id); + requireVersion(current, request.recordVersion()); + if (request.mileageKm() < current.mileageKm()) throw badRequest("当前公里码不能小于已有公里码"); + boolean updated; + try { + updated = vehicles.update(id, request.recordVersion(), new VehicleChanges( + requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT), + requiredText(request.model(), "车型"), request.fuelConsumptionPer100Km(), request.mileageKm(), + trim(request.driverOrDispatchCompany()), trim(request.phone()), region(request.region()), + ownership(request.ownership()), trim(request.remark()), operator, LocalDateTime.now())); + } catch (DuplicatePlateException exception) { + throw conflict("车牌号已存在"); + } + if (!updated) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); + return detail(id); + } + + @Transactional + public VehicleModels.Response changeStatus(long id, String operator, VehicleModels.ChangeStatus request) { + VehicleSnapshot current = locked(id); + requireVersion(current, request.recordVersion()); + String status = status(request.status()); + if (status.equals(current.status())) return response(current); + boolean updated = vehicles.updateStatus(id, request.recordVersion(), status, operator, LocalDateTime.now()); + if (!updated) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); + return detail(id); + } + + public byte[] exportCsv(VehicleModels.Query query) { + var rows = vehicles.list(criteria(query)).stream().map(this::response).toList(); + StringBuilder csv = new StringBuilder("\uFEFF车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n"); + for (var row : rows) csv.append(row.id()).append(',').append(cell(row.plateNumber())).append(',') + .append(cell(row.model())).append(',').append(row.fuelConsumptionPer100Km()).append(',').append(row.mileageKm()).append(',') + .append(cell(row.driverOrDispatchCompany())).append(',').append(cell(row.phone())).append(',') + .append(row.region()).append(',').append(row.ownership()).append(',').append(row.status()).append(',') + .append(row.updatedAt()).append("\r\n"); + return csv.toString().getBytes(StandardCharsets.UTF_8); + } + + private SearchCriteria criteria(VehicleModels.Query query) { + return new SearchCriteria(query.page(), query.size(), trim(query.keyword()), + optional(query.ownership(), OWNERSHIPS, "车辆归属"), + optional(query.region(), REGIONS, "车辆区域"), + optional(query.status(), STATUSES, "车辆状态")); + } + + private VehicleSnapshot required(long id) { + return vehicles.findById(id).orElseThrow(VehicleApplicationService::notFound); + } + + private VehicleSnapshot locked(long id) { + return vehicles.lockById(id).orElseThrow(VehicleApplicationService::notFound); + } + + private VehicleModels.Response response(VehicleSnapshot value) { + return new VehicleModels.Response(String.valueOf(value.id()), value.plateNumber(), value.model(), + value.fuelConsumptionPer100Km(), value.mileageKm(), value.driverOrDispatchCompany(), value.phone(), + value.region(), value.ownership(), value.status(), value.remark(), value.recordVersion(), + value.updatedBy(), value.updatedAt()); + } + + private void requireVersion(VehicleSnapshot value, long expected) { + if (value.recordVersion() != expected) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); + } + + private static String ownership(String value) { return required(value, OWNERSHIPS, "车辆归属"); } + private static String region(String value) { return required(value, REGIONS, "车辆区域"); } + private static String status(String value) { return required(value, STATUSES, "车辆状态"); } + + private static String optional(String value, Set allowed, String label) { + return StringUtils.hasText(value) ? required(value, allowed, label) : null; + } + + private static String required(String value, Set allowed, String label) { + if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空"); + String normalized = value.trim().toUpperCase(Locale.ROOT); + if (!allowed.contains(normalized)) throw badRequest(label + "不合法"); + return normalized; + } + + private static String requiredText(String value, String label) { + if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空"); + return value.trim(); + } + + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; } + private static VehicleFailure notFound() { return new VehicleFailure(FailureKind.NOT_FOUND, "车辆档案不存在"); } + private static VehicleFailure badRequest(String message) { return new VehicleFailure(FailureKind.BAD_REQUEST, message); } + private static VehicleFailure conflict(String message) { return new VehicleFailure(FailureKind.CONFLICT, message); } + + public enum FailureKind { BAD_REQUEST, NOT_FOUND, CONFLICT } + + public static final class VehicleFailure extends RuntimeException { + private final FailureKind kind; + + public VehicleFailure(FailureKind kind, String message) { + super(message); + this.kind = kind; + } + + public FailureKind kind() { + return kind; + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/dto/VehicleDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java similarity index 94% rename from lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/dto/VehicleDtos.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java index 4c19ceb3e..36a307164 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/dto/VehicleDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java @@ -1,4 +1,4 @@ -package com.baiyue.lastleg.masterdata.vehicle.dto; +package com.baiyue.lastleg.masterdata.vehicle.application.model; import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.Digits; @@ -11,8 +11,8 @@ import jakarta.validation.constraints.Size; import java.math.BigDecimal; import java.time.LocalDateTime; -public final class VehicleDtos { - private VehicleDtos() { } +public final class VehicleModels { + private VehicleModels() { } public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, @Size(max = 128) String keyword, String ownership, String region, String status) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java new file mode 100644 index 000000000..31f0b89a9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.masterdata.vehicle.application.port.out; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface VehicleRepository { + PageSlice page(SearchCriteria criteria); + + List list(SearchCriteria criteria); + + Optional findById(long id); + + Optional lockById(long id); + + long create(NewVehicle vehicle); + + boolean update(long id, long expectedVersion, VehicleChanges changes); + + boolean updateStatus(long id, long expectedVersion, String status, String operator, LocalDateTime updatedAt); + + record SearchCriteria(int page, int size, String keyword, String ownership, String region, String status) { } + + record PageSlice(List records, long page, long size, long total, long pages) { } + + record NewVehicle(String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm, + String driverOrDispatchCompany, String phone, String region, String ownership, String status, + String remark, String operator, LocalDateTime createdAt) { } + + record VehicleChanges(String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm, + String driverOrDispatchCompany, String phone, String region, String ownership, String remark, + String operator, LocalDateTime updatedAt) { } + + record VehicleSnapshot(long id, String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, + long mileageKm, String driverOrDispatchCompany, String phone, String region, + String ownership, String status, String remark, long recordVersion, + String updatedBy, LocalDateTime updatedAt) { } + + final class DuplicatePlateException extends RuntimeException { + public DuplicatePlateException(Throwable cause) { + super(cause); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleController.java deleted file mode 100644 index 67dd55ff4..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleController.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.baiyue.lastleg.masterdata.vehicle.controller; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.core.api.PageResult; -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos; -import com.baiyue.lastleg.masterdata.vehicle.service.VehicleService; -import com.baiyue.lastleg.security.OverseasPermissions; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.annotation.Resource; -import jakarta.validation.Valid; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; - -import java.nio.charset.StandardCharsets; - -@RestController -@RequestMapping("/api/platform/v1/last-leg/master-data/vehicles") -@Tag(name = "Vehicle master data / 车辆主数据") -public class PlatformVehicleController { - @Resource private VehicleService service; - - @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')") - @Operation(summary = "Page vehicle master data", description = "Required authority: platform:overseas-master-data:vehicle:list") - public ApiResponse> page(@Valid VehicleDtos.Query query) { return ApiResponse.success(service.page(query)); } - - @GetMapping("/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')") - @Operation(summary = "Get vehicle master data detail", description = "Required authority: platform:overseas-master-data:vehicle:list; excludes delivery fulfillment history") - public ApiResponse detail(@PathVariable long id) { return ApiResponse.success(service.detail(id)); } - - @PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_CREATE + "')") - public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody VehicleDtos.Create request) { return ApiResponse.success(service.create(operator(user), request)); } - - @PutMapping("/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EDIT + "')") - public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id, - @Valid @RequestBody VehicleDtos.Update request) { return ApiResponse.success(service.update(id, operator(user), request)); } - - @PatchMapping("/{id}/status") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_STATUS + "')") - public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id, - @Valid @RequestBody VehicleDtos.ChangeStatus request) { return ApiResponse.success(service.changeStatus(id, operator(user), request)); } - - @GetMapping(value = "/export", produces = "text/csv") - @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EXPORT + "')") - public ResponseEntity export(@Valid VehicleDtos.Query query) { - return ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=vehicles.csv").body(service.exportCsv(query)); - } - - private String operator(AuthenticatedUser user) { - if (user == null) return "UNKNOWN"; return user.userId() == null ? user.username() : String.valueOf(user.userId()); - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleService.java deleted file mode 100644 index 191cae17b..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleService.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.baiyue.lastleg.masterdata.vehicle.service; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; -import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.baiyue.common.core.api.PageResult; -import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos; -import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle; -import com.baiyue.lastleg.masterdata.vehicle.mapper.VehicleMapper; -import jakarta.annotation.Resource; -import org.springframework.dao.DuplicateKeyException; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; -import org.springframework.web.server.ResponseStatusException; - -import java.nio.charset.StandardCharsets; -import java.time.LocalDateTime; -import java.util.Locale; -import java.util.Set; - -@Service -public class VehicleService { - private static final Set OWNERSHIPS = Set.of("OWNED", "DISPATCHED"); - private static final Set REGIONS = Set.of("DOMESTIC", "OVERSEAS"); - private static final Set STATUSES = Set.of("IN_SERVICE", "DISPATCHED", "MAINTENANCE", "INACTIVE", "RETURNED"); - @Resource private VehicleMapper vehicles; - - public PageResult page(VehicleDtos.Query query) { - Page page = vehicles.selectPage(Page.of(query.page(), query.size()), wrapper(query)); - return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(), - page.getSize(), page.getTotal(), page.getPages()); - } - - public VehicleDtos.Response detail(long id) { return response(required(id)); } - - @Transactional - public VehicleDtos.Response create(String operator, VehicleDtos.Create request) { - LocalDateTime now = LocalDateTime.now(); - Vehicle value = new Vehicle(); value.setId(IdWorker.getId()); - value.setPlateNumber(requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT)); - value.setModel(requiredText(request.model(), "车型")); value.setFuelConsumptionPer100Km(request.fuelConsumptionPer100Km()); - value.setMileageKm(request.mileageKm()); value.setDriverOrDispatchCompany(trim(request.driverOrDispatchCompany())); - value.setPhone(trim(request.phone())); value.setRegion(region(request.region())); value.setOwnership(ownership(request.ownership())); - value.setStatus(status(request.status())); value.setRemark(trim(request.remark())); value.setRecordVersion(0L); - value.setCreatedBy(operator); value.setUpdatedBy(operator); value.setCreatedAt(now); value.setUpdatedAt(now); - try { vehicles.insert(value); } catch (DuplicateKeyException exception) { throw conflict("车牌号已存在"); } - return detail(value.getId()); - } - - @Transactional - public VehicleDtos.Response update(long id, String operator, VehicleDtos.Update request) { - Vehicle current = locked(id); requireVersion(current, request.recordVersion()); - if (request.mileageKm() < current.getMileageKm()) throw badRequest("当前公里码不能小于已有公里码"); - int updated; - try { - updated = vehicles.update(null, new LambdaUpdateWrapper() - .eq(Vehicle::getId, id).eq(Vehicle::getRecordVersion, request.recordVersion()) - .set(Vehicle::getPlateNumber, requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT)) - .set(Vehicle::getModel, requiredText(request.model(), "车型")) - .set(Vehicle::getFuelConsumptionPer100Km, request.fuelConsumptionPer100Km()) - .set(Vehicle::getMileageKm, request.mileageKm()) - .set(Vehicle::getDriverOrDispatchCompany, trim(request.driverOrDispatchCompany())) - .set(Vehicle::getPhone, trim(request.phone())).set(Vehicle::getRegion, region(request.region())) - .set(Vehicle::getOwnership, ownership(request.ownership())).set(Vehicle::getRemark, trim(request.remark())) - .set(Vehicle::getUpdatedBy, operator).set(Vehicle::getUpdatedAt, LocalDateTime.now()) - .setSql("record_version = record_version + 1")); - } catch (DuplicateKeyException exception) { throw conflict("车牌号已存在"); } - if (updated != 1) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); - return detail(id); - } - - @Transactional - public VehicleDtos.Response changeStatus(long id, String operator, VehicleDtos.ChangeStatus request) { - Vehicle current = locked(id); requireVersion(current, request.recordVersion()); - String status = status(request.status()); - if (status.equals(current.getStatus())) return response(current); - int updated = vehicles.update(null, new LambdaUpdateWrapper() - .eq(Vehicle::getId, id).eq(Vehicle::getRecordVersion, request.recordVersion()) - .set(Vehicle::getStatus, status).set(Vehicle::getUpdatedBy, operator) - .set(Vehicle::getUpdatedAt, LocalDateTime.now()).setSql("record_version = record_version + 1")); - if (updated != 1) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); - return detail(id); - } - - public byte[] exportCsv(VehicleDtos.Query query) { - var rows = vehicles.selectList(wrapper(query)).stream().map(this::response).toList(); - StringBuilder csv = new StringBuilder("\uFEFF车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n"); - for (var row : rows) csv.append(row.id()).append(',').append(cell(row.plateNumber())).append(',') - .append(cell(row.model())).append(',').append(row.fuelConsumptionPer100Km()).append(',').append(row.mileageKm()).append(',') - .append(cell(row.driverOrDispatchCompany())).append(',').append(cell(row.phone())).append(',') - .append(row.region()).append(',').append(row.ownership()).append(',').append(row.status()).append(',') - .append(row.updatedAt()).append("\r\n"); - return csv.toString().getBytes(StandardCharsets.UTF_8); - } - - private LambdaQueryWrapper wrapper(VehicleDtos.Query query) { - String keyword = trim(query.keyword()); String ownership = optional(query.ownership(), OWNERSHIPS, "车辆归属"); - String region = optional(query.region(), REGIONS, "车辆区域"); String status = optional(query.status(), STATUSES, "车辆状态"); - return new LambdaQueryWrapper() - .and(StringUtils.hasText(keyword), w -> w.like(Vehicle::getPlateNumber, keyword) - .or().like(Vehicle::getModel, keyword).or().like(Vehicle::getDriverOrDispatchCompany, keyword)) - .eq(ownership != null, Vehicle::getOwnership, ownership).eq(region != null, Vehicle::getRegion, region) - .eq(status != null, Vehicle::getStatus, status).orderByDesc(Vehicle::getUpdatedAt).orderByDesc(Vehicle::getId); - } - private Vehicle required(long id) { - Vehicle value = vehicles.selectById(id); if (value == null) throw notFound(); return value; - } - private Vehicle locked(long id) { - Vehicle value = vehicles.lockById(id); if (value == null) throw notFound(); return value; - } - private VehicleDtos.Response response(Vehicle value) { - return new VehicleDtos.Response(String.valueOf(value.getId()), value.getPlateNumber(), value.getModel(), - value.getFuelConsumptionPer100Km(), value.getMileageKm(), value.getDriverOrDispatchCompany(), value.getPhone(), - value.getRegion(), value.getOwnership(), value.getStatus(), value.getRemark(), version(value.getRecordVersion()), - value.getUpdatedBy(), value.getUpdatedAt()); - } - private void requireVersion(Vehicle value, long expected) { - if (version(value.getRecordVersion()) != expected) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); - } - private static String ownership(String value) { return required(value, OWNERSHIPS, "车辆归属"); } - private static String region(String value) { return required(value, REGIONS, "车辆区域"); } - private static String status(String value) { return required(value, STATUSES, "车辆状态"); } - private static String optional(String value, Set allowed, String label) { - return StringUtils.hasText(value) ? required(value, allowed, label) : null; - } - private static String required(String value, Set allowed, String label) { - if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空"); - String normalized = value.trim().toUpperCase(Locale.ROOT); - if (!allowed.contains(normalized)) throw badRequest(label + "不合法"); return normalized; - } - private static String requiredText(String value, String label) { - if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空"); return value.trim(); - } - private static long version(Long value) { return value == null ? 0L : value; } - private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } - private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; } - private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "车辆档案不存在"); } - private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } - private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java index 72fd13cd9..53e110c8a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java @@ -32,6 +32,8 @@ public class UnloadCompletedOutboxRepository { .eq(LastlegEventOutbox::getId, id) .eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED) .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING") + .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt) + .or().le(LastlegEventOutbox::getNextRetryAt, now)) .or(stale -> stale.eq(LastlegEventOutbox::getStatus, "PROCESSING") .le(LastlegEventOutbox::getLockedAt, expiredLeaseBefore))) .set(LastlegEventOutbox::getStatus, "PROCESSING") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 0896e5b77..fd8c1383f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -54,6 +54,8 @@ public class LoadingCompletedConsumer { throw new IllegalArgumentException("尾程交接事件区域无效"); if (!"LOADING_PLAN".equals(envelope.aggregateType())) throw new IllegalArgumentException("尾程交接事件聚合类型无效"); + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), + envelope.payloadHash())) throw new IllegalArgumentException("payloadHash 与尾程事件负载不一致"); FirstLegLoadingPlanCompletedV1 payload = parsePayload(envelope); if(payload.containerNo()==null||payload.containerNo().isBlank()) throw new IllegalArgumentException("事件没有实际集装箱号 containerNo"); @@ -70,6 +72,11 @@ public class LoadingCompletedConsumer { inbox.setEventVersion(envelope.eventVersion()); inbox.setPayloadHash(envelope.payloadHash()); inbox.setPayloadJson(envelope.payload().toString()); inbox.setStatus("RECEIVED"); inbox.setReceivedAt(LocalDateTime.now()); try { inboxMapper.insert(inbox); } catch(DuplicateKeyException exception) { + LastlegEventInbox raced = inboxMapper.selectById(envelope.eventId()); + if (raced == null || raced.getPayloadHash() == null + || !raced.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { + throw new IllegalArgumentException("事件摘要冲突", exception); + } return countOrders(envelope,payload.containerNo()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java index 938bc4404..b641c1e46 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java @@ -1,6 +1,8 @@ package com.baiyue.lastleg.shipping.service; 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.FirstLegContainerShippingNodeRecordedV1; import com.baiyue.common.event.RegionCode; @@ -91,6 +93,10 @@ public class ContainerShippingNodeConsumer { private void validate(EventEnvelope envelope) { envelope.requireValid(); + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), + envelope.payloadHash())) { + throw new IllegalArgumentException("payloadHash 与船节点事件负载不一致"); + } if (!EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(envelope.eventType()) || envelope.eventVersion() != 1 || envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/config/LastlegRestClientFactoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/config/LastlegRestClientFactoryTest.java new file mode 100644 index 000000000..94c1758ab --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/config/LastlegRestClientFactoryTest.java @@ -0,0 +1,84 @@ +package com.baiyue.lastleg.config; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; + +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LastlegRestClientFactoryTest { + private final LastlegRestClientFactory factory = new LastlegRestClientFactory(); + + @Test + void defaultsArePositiveAndSeparateFileUploadsFromWarehouseCalls() { + LastlegRestClientProperties properties = new LastlegRestClientProperties(); + + assertThat(properties.getUnloadPhotoFile().getConnectTimeout()).isEqualTo(Duration.ofSeconds(3)); + assertThat(properties.getUnloadPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(30)); + assertThat(properties.getRelabelPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(30)); + assertThat(properties.getWarehouseResource().getReadTimeout()).isEqualTo(Duration.ofSeconds(5)); + assertThat(properties.getPalletPhysical().getReadTimeout()).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + void rejectsUnboundedOrNonPositiveTimeouts() { + assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(null, Duration.ofSeconds(1))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(Duration.ZERO, Duration.ofSeconds(1))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(Duration.ofSeconds(1), Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void bindsPerClientTimeoutOverridesFromConfiguration() { + var source = new MapConfigurationPropertySource(Map.of( + "baiyue.http.clients.unload-photo-file.connect-timeout", "125ms", + "baiyue.http.clients.unload-photo-file.read-timeout", "9s", + "baiyue.http.clients.pallet-physical.connect-timeout", "250ms", + "baiyue.http.clients.pallet-physical.read-timeout", "2s")); + + LastlegRestClientProperties properties = new Binder(source) + .bind("baiyue.http.clients", Bindable.of(LastlegRestClientProperties.class)) + .orElseThrow(() -> new AssertionError("Lastleg HTTP client properties were not bound")); + + assertThat(properties.getUnloadPhotoFile().getConnectTimeout()).isEqualTo(Duration.ofMillis(125)); + assertThat(properties.getUnloadPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(9)); + assertThat(properties.getPalletPhysical().getConnectTimeout()).isEqualTo(Duration.ofMillis(250)); + assertThat(properties.getPalletPhysical().getReadTimeout()).isEqualTo(Duration.ofSeconds(2)); + assertThat(properties.getWarehouseResource().getReadTimeout()).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + void appliesConfiguredConnectAndReadTimeoutsWithoutRetryOrFallback() throws Exception { + var timeouts = new LastlegRestClientProperties.ClientTimeouts( + Duration.ofMillis(250), Duration.ofMillis(75)); + assertThat(factory.httpClient(timeouts).connectTimeout()).contains(Duration.ofMillis(250)); + + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/slow", exchange -> { + try { Thread.sleep(500); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + exchange.sendResponseHeaders(200, 0); + exchange.close(); + }); + server.start(); + try { + RestClient client = factory.build(RestClient.builder(), + "http://127.0.0.1:" + server.getAddress().getPort(), timeouts); + assertThatThrownBy(() -> client.get().uri("/slow").retrieve().toBodilessEntity()) + .isInstanceOf(ResourceAccessException.class); + } finally { + server.stop(0); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java index aeee3ebe5..abb7991c9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java @@ -3,6 +3,8 @@ package com.baiyue.lastleg.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.security.InternalServiceTokenGuard; @@ -21,6 +23,8 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.when; class LastlegOrderControllerTest { private final LastlegOrderService service = mock(LastlegOrderService.class); @@ -46,6 +50,17 @@ class LastlegOrderControllerTest { verify(service).page(eq("200"), eq(query)); } + @Test + void iamSecurityEventsAreRejectedBeforeLoadingCompletedConsumer() { + EventEnvelope event = mock(EventEnvelope.class); + when(event.eventType()).thenReturn(EventTypes.IAM_USER_STATUS_CHANGED); + LoadingCompletedConsumer loading = (LoadingCompletedConsumer) ReflectionTestUtils.getField(controller, "consumer"); + + assertThrows(ResponseStatusException.class, () -> controller.receive("internal-token", event)); + + verify(loading, never()).consume(event); + } + private static AuthenticatedUser user(long customerId, String appCode) { return new AuthenticatedUser(1L, customerId, customerId, 1L, "tester", appCode, "WEB", Set.of()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleControllerContractTest.java similarity index 96% rename from lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleControllerContractTest.java rename to lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleControllerContractTest.java index 719dcf8e3..ae1a8d3e1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleControllerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleControllerContractTest.java @@ -1,4 +1,4 @@ -package com.baiyue.lastleg.masterdata.vehicle.controller; +package com.baiyue.lastleg.masterdata.vehicle.adapter.in.web; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java new file mode 100644 index 000000000..91fffa32d --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java @@ -0,0 +1,64 @@ +package com.baiyue.lastleg.masterdata.vehicle.application; + +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.FailureKind; +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.VehicleFailure; +import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleSnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class VehicleApplicationServiceTest { + private VehicleRepository repository; + private VehicleApplicationService service; + + @BeforeEach + void setUp() { + repository = mock(VehicleRepository.class); + service = new VehicleApplicationService(repository); + } + + @Test + void rejectsStaleVersionBeforeUpdating() { + when(repository.lockById(1L)).thenReturn(Optional.of(vehicle(3L, 1000L))); + VehicleModels.Update request = update(2L, 1000L); + assertThatThrownBy(() -> service.update(1L, "9", request)) + .isInstanceOfSatisfying(VehicleFailure.class, + failure -> assertThat(failure.kind()).isEqualTo(FailureKind.CONFLICT)); + } + + @Test + void rejectsDecreasingOdometer() { + when(repository.lockById(1L)).thenReturn(Optional.of(vehicle(3L, 1000L))); + VehicleModels.Update request = update(3L, 999L); + assertThatThrownBy(() -> service.update(1L, "9", request)) + .isInstanceOfSatisfying(VehicleFailure.class, + failure -> assertThat(failure.kind()).isEqualTo(FailureKind.BAD_REQUEST)); + } + + @Test + void rejectsUnknownStatus() { + when(repository.lockById(1L)).thenReturn(Optional.of(vehicle(3L, 1000L))); + assertThatThrownBy(() -> service.changeStatus(1L, "9", new VehicleModels.ChangeStatus("BUSY", 3L))) + .isInstanceOfSatisfying(VehicleFailure.class, + failure -> assertThat(failure.kind()).isEqualTo(FailureKind.BAD_REQUEST)); + } + + private static VehicleModels.Update update(long version, long mileage) { + return new VehicleModels.Update(version, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage, + "Driver", "123", "OVERSEAS", "OWNED", null); + } + + private static VehicleSnapshot vehicle(long version, long mileage) { + return new VehicleSnapshot(1L, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage, + "Driver", "123", "OVERSEAS", "OWNED", "IN_SERVICE", null, version, "9", null); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleServiceTest.java deleted file mode 100644 index b83562614..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleServiceTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.baiyue.lastleg.masterdata.vehicle.service; - -import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos; -import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle; -import com.baiyue.lastleg.masterdata.vehicle.mapper.VehicleMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.web.server.ResponseStatusException; - -import java.math.BigDecimal; - -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class VehicleServiceTest { - private VehicleMapper mapper; - private VehicleService service; - - @BeforeEach - void setUp() { - mapper = mock(VehicleMapper.class); - service = new VehicleService(); - ReflectionTestUtils.setField(service, "vehicles", mapper); - } - - @Test - void rejectsStaleVersionBeforeUpdating() { - when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L)); - VehicleDtos.Update request = update(2L, 1000L); - assertThatThrownBy(() -> service.update(1L, "9", request)) - .isInstanceOf(ResponseStatusException.class) - .hasMessageContaining("409 CONFLICT"); - } - - @Test - void rejectsDecreasingOdometer() { - when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L)); - VehicleDtos.Update request = update(3L, 999L); - assertThatThrownBy(() -> service.update(1L, "9", request)) - .isInstanceOf(ResponseStatusException.class) - .hasMessageContaining("400 BAD_REQUEST"); - } - - @Test - void rejectsUnknownStatus() { - when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L)); - assertThatThrownBy(() -> service.changeStatus(1L, "9", new VehicleDtos.ChangeStatus("BUSY", 3L))) - .isInstanceOf(ResponseStatusException.class) - .hasMessageContaining("400 BAD_REQUEST"); - } - - private static VehicleDtos.Update update(long version, long mileage) { - return new VehicleDtos.Update(version, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage, - "Driver", "123", "OVERSEAS", "OWNED", null); - } - - private static Vehicle vehicle(long version, long mileage) { - Vehicle value = new Vehicle(); value.setId(1L); value.setRecordVersion(version); value.setMileageKm(mileage); - value.setStatus("IN_SERVICE"); return value; - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java new file mode 100644 index 000000000..b2123ef9e --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.repository; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class UnloadCompletedOutboxRepositoryTest { + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void claimRechecksRetryWindowAfterCandidateSelection() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), + LastlegEventOutbox.class); + LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class); + UnloadCompletedOutboxRepository repository = new UnloadCompletedOutboxRepository(); + ReflectionTestUtils.setField(repository, "mapper", mapper); + ArgumentCaptor wrapper = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + when(mapper.update(isNull(LastlegEventOutbox.class), wrapper.capture())).thenReturn(1); + LocalDateTime now = LocalDateTime.of(2026, 8, 25, 12, 0); + + assertThat(repository.claim(1L, "worker", now, now.minusMinutes(1))).isTrue(); + + assertThat(wrapper.getValue().getSqlSegment()) + .contains("next_retry_at", "<=", "status", "locked_at"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 23e55decb..c7ee14c86 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -8,6 +8,8 @@ class LoadingCompletedConsumerTest { @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU1",0L,1L); } @Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } + @Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); } + @Test void concurrentDuplicateKeyWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox raced=new LastlegEventInbox(); raced.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(null,raced); doThrow(new org.springframework.dao.DuplicateKeyException("race")).when(inbox).insert(any(LastlegEventInbox.class)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); assertTrue(error.getMessage().contains("摘要冲突")); verify(orders,never()).countContainer(anyString(),anyString()); } @Test void inboundSnapshotUsesCustomerIdAndHasNoPhantomTenantColumn() throws Exception { assertThrows(NoSuchFieldException.class,()->LastlegInboundOrder.class.getDeclaredField("tenantId")); String schema=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/db/lastleg.sql")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java index 5e1b9c95d..30bec281f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java @@ -96,6 +96,21 @@ class ContainerShippingNodeConsumerTest { .hasMessageContaining("摘要冲突"); } + @Test + void tamperedPayloadHashIsRejectedBeforeInboxMutation() { + EventEnvelope source = event("123e4567-e89b-12d3-a456-426614174105", "PICKUP_READY", 80, 5, + Instant.parse("2026-08-21T10:00:00Z")); + EventEnvelope tampered = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), + source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(), + source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), + source.traceId(), source.correlationId(), source.causationId(), source.payload(), + "sha256:" + "0".repeat(64)); + + assertThatThrownBy(() -> consumer.consume(tampered)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("payloadHash"); + verify(inboxes, never()).insert(any(LastlegEventInbox.class)); + } + private ContainerShippingGate gate(String status, int sequence, long version, Instant actualAt) { ContainerShippingGate gate = new ContainerShippingGate(); gate.setContainerBookingId("CB-1"); gate.setCustomerId("100"); gate.setOceanBookingId("OB-1"); gate.setContainerNo("CONT-1"); diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json new file mode 100644 index 000000000..cc5660ecb --- /dev/null +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -0,0 +1,57 @@ +{ + "schemaVersion": 1, + "description": "Development-stage Lastleg slice dependency baseline. Existing edges may be removed; new edges require architecture review.", + "allowedSliceEdges": [ + "dispatch-delivery->inbound-pda", + "dispatch-delivery->inventory", + "dispatch-delivery->shared-technical", + "dispatch-delivery->tailleg-handoff", + "dropship->inbound-pda", + "dropship->shared-technical", + "dropship->tailleg-handoff", + "inbound-pda->dropship", + "inbound-pda->inventory", + "inbound-pda->shared-technical", + "inbound-pda->tailleg-handoff", + "inventory->inbound-pda", + "inventory->shared-technical", + "overseas-master-data->shared-technical", + "procurement-trucking-looseboard->overseas-master-data", + "procurement-trucking-looseboard->shared-technical", + "procurement-trucking-looseboard->tailleg-handoff", + "relabel-transfer-discard->dropship", + "relabel-transfer-discard->inventory", + "relabel-transfer-discard->shared-technical", + "relabel-transfer-discard->tailleg-handoff", + "tailleg-handoff->dropship", + "tailleg-handoff->inbound-pda", + "tailleg-handoff->inventory", + "tailleg-handoff->shared-technical" + ], + "requiredClassLocations": { + "com.baiyue.lastleg.masterdata.vehicle.adapter.in.web.PlatformVehicleController": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java", + "com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleMapper": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleMapper.java", + "com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehiclePersistenceAdapter": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java", + "com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleRecord": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java", + "com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java", + "com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java", + "com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java" + }, + "applicationDependencyRule": { + "packagePrefix": "com.baiyue.lastleg.masterdata.vehicle.application", + "allowedInternalPrefix": "com.baiyue.lastleg.masterdata.vehicle.application", + "forbiddenImportPrefixes": [ + "org.springframework.web", + "org.apache.ibatis", + "com.baomidou.mybatisplus", + "com.baiyue.lastleg.client", + "com.baiyue.lastleg.config" + ] + }, + "sliceSpecificRules": { + "overseas-master-data": { + "allowedOutgoing": ["shared-technical"], + "allowedIncoming": ["procurement-trucking-looseboard"] + } + } +} diff --git a/overseas-security-service/AGENTS.md b/overseas-security-service/AGENTS.md new file mode 100644 index 000000000..01cb5953f --- /dev/null +++ b/overseas-security-service/AGENTS.md @@ -0,0 +1,14 @@ +# Overseas Security Service 模块指引 + +## 职责边界 + +- 消费 IAM 三类跨区安全事件,维护海外可靠 Inbox 与单调安全投影,并为海外 Gateway 提供内部实时校验。 +- MySQL 是恢复源;Redis 仅为可重建镜像,不能成为授权真相或在失败时默认放行。 +- 不签发 Token、不保存 raw Token/凭据、不承载 Lastleg 业务,也不解释 Event Bridge 的重试状态。 + +## 入口 + +- 内部事件与校验:`controller/OverseasSecurityController.java` +- 幂等、单调投影:`service/SecurityProjectionService.java` +- Token 校验:`service/OverseasSecurityValidationService.java` +- SQL:`src/main/resources/db/overseas-security.sql` diff --git a/overseas-security-service/pom.xml b/overseas-security-service/pom.xml new file mode 100644 index 000000000..a8c5cd7fd --- /dev/null +++ b/overseas-security-service/pom.xml @@ -0,0 +1,22 @@ + + + 4.0.0 + com.baiyue.firstlegbaiyue-firstleg-platform + 1.0.0-SNAPSHOT../pom.xml + overseas-security-service + + com.baiyue.sharedbaiyue-common-core${project.version} + com.baiyue.sharedbaiyue-common-web${project.version} + com.baiyue.sharedbaiyue-common-openapi${project.version} + com.baiyue.sharedcommon-event-contract${project.version} + org.springframework.bootspring-boot-starter-jdbc + org.springframework.bootspring-boot-starter-data-redis + com.mysqlmysql-connector-jruntime + com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery + com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config + org.springframework.bootspring-boot-starter-actuator + org.springframework.bootspring-boot-starter-testtest + + org.springframework.bootspring-boot-maven-plugin + diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/OverseasSecurityApplication.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/OverseasSecurityApplication.java new file mode 100644 index 000000000..cfe7c82a6 --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/OverseasSecurityApplication.java @@ -0,0 +1,11 @@ +package com.baiyue.overseassecurity; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class OverseasSecurityApplication { + public static void main(String[] args) { + SpringApplication.run(OverseasSecurityApplication.class, args); + } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java new file mode 100644 index 000000000..05fe60b65 --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java @@ -0,0 +1,52 @@ +package com.baiyue.overseassecurity.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.Acknowledgement; +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationRequest; +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationResponse; +import com.baiyue.overseassecurity.security.InternalServiceTokenGuard; +import com.baiyue.overseassecurity.service.OverseasSecurityValidationService; +import com.baiyue.overseassecurity.service.SecurityProjectionService; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; + +@Hidden +@RestController +@RequestMapping("/internal/overseas-security/v1") +public class OverseasSecurityController { + private final InternalServiceTokenGuard tokenGuard; + private final SecurityProjectionService projections; + private final OverseasSecurityValidationService validations; + + public OverseasSecurityController(InternalServiceTokenGuard tokenGuard, SecurityProjectionService projections, + OverseasSecurityValidationService validations) { + this.tokenGuard = tokenGuard; + this.projections = projections; + this.validations = validations; + } + + @PostMapping("/events") + public ApiResponse receive( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestBody EventEnvelope envelope) { + tokenGuard.verify(token); + var result = projections.consume(envelope); + return ApiResponse.success(new Acknowledgement(envelope.eventId(), result.result(), Instant.now())); + } + + @PostMapping("/validate") + public ApiResponse validate( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody ValidationRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(validations.validate(request)); + } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/dto/OverseasSecurityDtos.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/dto/OverseasSecurityDtos.java new file mode 100644 index 000000000..7560da9b2 --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/dto/OverseasSecurityDtos.java @@ -0,0 +1,28 @@ +package com.baiyue.overseassecurity.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import java.time.Instant; + +public final class OverseasSecurityDtos { + private OverseasSecurityDtos() { } + + public record Acknowledgement(String eventId, String result, Instant receivedAt) { } + + public record ValidationRequest(@NotNull @Min(1) Long userId, + @NotNull @Min(0) Long tenantId, + @NotNull @Min(0) Long tokenVersion, + @NotNull @Min(0) Long permissionVersion) { } + + public record ValidationResponse(boolean valid, FailureReason failureReason) { + public static ValidationResponse accepted() { return new ValidationResponse(true, null); } + public static ValidationResponse rejected(FailureReason reason) { return new ValidationResponse(false, reason); } + } + + public enum FailureReason { + USER_DISABLED, + TENANT_DISABLED, + PERMISSION_VERSION_STALE + } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/security/InternalServiceTokenGuard.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/security/InternalServiceTokenGuard.java new file mode 100644 index 000000000..1a6aa6b7c --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/security/InternalServiceTokenGuard.java @@ -0,0 +1,26 @@ +package com.baiyue.overseassecurity.security; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@Component +public class InternalServiceTokenGuard { + @Value("${baiyue.security.internal-service-token:}") private String configuredToken; + private byte[] expected; + + @PostConstruct void initialize() { + expected = configuredToken == null ? new byte[0] : configuredToken.getBytes(StandardCharsets.UTF_8); + if (expected.length < 32) throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); + } + + public void verify(String supplied) { + byte[] actual = supplied == null ? new byte[0] : supplied.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(expected, actual)) throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效"); + } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java new file mode 100644 index 000000000..4b7ee66cf --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java @@ -0,0 +1,44 @@ +package com.baiyue.overseassecurity.service; + +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.FailureReason; +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationRequest; +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationResponse; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class OverseasSecurityValidationService { + private final JdbcTemplate jdbc; + + public OverseasSecurityValidationService(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public ValidationResponse validate(ValidationRequest request) { + String userStatus = status("USER", String.valueOf(request.userId())); + if (userStatus != null && !"ACTIVE".equals(userStatus)) { + return ValidationResponse.rejected(FailureReason.USER_DISABLED); + } + String tenantStatus = status("TENANT", String.valueOf(request.tenantId())); + if (tenantStatus != null && !"ACTIVE".equals(tenantStatus)) { + return ValidationResponse.rejected(FailureReason.TENANT_DISABLED); + } + Long projectedVersion = jdbc.queryForObject( + "SELECT MAX(permission_version) FROM overseas_security_projection " + + "WHERE aggregate_id=? AND aggregate_type IN ('USER','AUTHORIZATION')", + Long.class, String.valueOf(request.userId())); + if (projectedVersion != null && request.permissionVersion() < projectedVersion) { + return ValidationResponse.rejected(FailureReason.PERMISSION_VERSION_STALE); + } + return ValidationResponse.accepted(); + } + + private String status(String type, String id) { + List values = jdbc.query("SELECT security_status FROM overseas_security_projection " + + "WHERE aggregate_type=? AND aggregate_id=?", + (rs, row) -> rs.getString(1), type, id); + return values.isEmpty() ? null : values.get(0); + } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/ProjectionMonotonicity.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/ProjectionMonotonicity.java new file mode 100644 index 000000000..958e5e44b --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/ProjectionMonotonicity.java @@ -0,0 +1,19 @@ +package com.baiyue.overseassecurity.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; + +import java.util.Objects; + +final class ProjectionMonotonicity { + private ProjectionMonotonicity() { } + + static Decision decide(Long currentVersion, String currentHash, long incomingVersion, String incomingHash) { + if (currentVersion == null || incomingVersion > currentVersion) return Decision.APPLY; + if (incomingVersion < currentVersion) return Decision.STALE; + if (Objects.equals(currentHash, incomingHash)) return Decision.DUPLICATE_VERSION; + throw new BizException(ResultCode.CONFLICT, "相同聚合版本的安全事件 payloadHash 冲突"); + } + + enum Decision { APPLY, STALE, DUPLICATE_VERSION } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java new file mode 100644 index 000000000..e022e01cb --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java @@ -0,0 +1,36 @@ +package com.baiyue.overseassecurity.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** 非授权真相的可重建 Redis 镜像;失败不影响已提交的 MySQL 投影。 */ +@Component +@Slf4j +public class SecurityProjectionRedisMirror { + private final StringRedisTemplate redis; + private final ObjectMapper objectMapper; + @Value("${baiyue.security.permission-snapshot-key-prefix:baiyue:permission-snapshot}") + private String keyPrefix; + + public SecurityProjectionRedisMirror(StringRedisTemplate redis, ObjectMapper objectMapper) { + this.redis = redis; + this.objectMapper = objectMapper; + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void mirror(SecurityProjectionService.ProjectionChanged event) { + var value = event.projection(); + try { + redis.opsForValue().set(keyPrefix + ':' + value.aggregateType() + ':' + value.aggregateId(), + objectMapper.writeValueAsString(value)); + } catch (Exception exception) { + log.warn("海外安全投影 Redis 镜像写入失败 aggregateType={}, aggregateId={}", + value.aggregateType(), value.aggregateId()); + } + } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java new file mode 100644 index 000000000..d76d77932 --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java @@ -0,0 +1,153 @@ +package com.baiyue.overseassecurity.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.IamSecurityEventsV1; +import com.baiyue.common.event.RegionCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +@Service +public class SecurityProjectionService { + private static final List TYPES = List.of(EventTypes.IAM_USER_STATUS_CHANGED, + EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_PERMISSION_VERSION_CHANGED); + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final ApplicationEventPublisher events; + + public SecurityProjectionService(JdbcTemplate jdbc, ObjectMapper objectMapper, ApplicationEventPublisher events) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + this.events = events; + } + + @Transactional + public ConsumeResult consume(EventEnvelope envelope) { + envelope.requireValid(); + requireSecurityEnvelope(envelope); + String payloadJson = envelope.payload().toString(); + try { + jdbc.update("INSERT INTO overseas_security_event_inbox(event_id,event_type,payload_hash,payload_json,status,received_at) " + + "VALUES (?,?,?,?, 'RECEIVED', NOW(3))", envelope.eventId(), envelope.eventType(), + envelope.payloadHash(), payloadJson); + } catch (DuplicateKeyException duplicate) { + InboxRow existing = jdbc.queryForObject("SELECT event_type,payload_hash,status FROM overseas_security_event_inbox " + + "WHERE event_id=? FOR UPDATE", + (rs, row) -> new InboxRow(rs.getString(1), rs.getString(2), rs.getString(3)), + envelope.eventId()); + if (existing == null || !Objects.equals(existing.eventType(), envelope.eventType()) + || !Objects.equals(existing.payloadHash(), envelope.payloadHash())) { + throw new BizException(ResultCode.CONFLICT, "相同 eventId 的 payloadHash 冲突"); + } + if ("PROCESSED".equals(existing.status())) return new ConsumeResult("DUPLICATE", null); + } + + ProjectionInput input = input(envelope); + ProjectionRow current = findForUpdate(input.aggregateType(), input.aggregateId()); + ProjectionMonotonicity.Decision decision = ProjectionMonotonicity.decide( + current == null ? null : current.aggregateVersion(), current == null ? null : current.payloadHash(), + input.aggregateVersion(), input.payloadHash()); + String result = decision.name(); + if (decision == ProjectionMonotonicity.Decision.APPLY) { + upsert(input); + result = "APPLIED"; + events.publishEvent(new ProjectionChanged(input)); + } + jdbc.update("UPDATE overseas_security_event_inbox SET status='PROCESSED',result=?,processed_at=NOW(3) " + + "WHERE event_id=?", result, envelope.eventId()); + return new ConsumeResult(result, input); + } + + private void requireSecurityEnvelope(EventEnvelope envelope) { + if (!TYPES.contains(envelope.eventType()) || envelope.eventVersion() != IamSecurityEventsV1.VERSION + || envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) { + throw new BizException(ResultCode.BAD_REQUEST, "仅接受 CN→OVERSEAS IAM V1 安全事件"); + } + } + + private ProjectionInput input(EventEnvelope envelope) { + try { + return switch (envelope.eventType()) { + case EventTypes.IAM_USER_STATUS_CHANGED -> { + var value = objectMapper.treeToValue(envelope.payload(), IamSecurityEventsV1.UserStatusChanged.class); + requireIdentity(envelope, "USER", value.authUserId(), value.tenantId()); + requirePayload(value.status(), value.changedAt(), value.permissionVersion(), envelope.aggregateVersion()); + yield new ProjectionInput("USER", String.valueOf(value.authUserId()), value.tenantId(), + value.status(), value.permissionVersion(), envelope.aggregateVersion(), envelope.payloadHash(), + envelope.eventId(), value.changedAt()); + } + case EventTypes.IAM_TENANT_STATUS_CHANGED -> { + var value = objectMapper.treeToValue(envelope.payload(), IamSecurityEventsV1.TenantStatusChanged.class); + requireIdentity(envelope, "TENANT", value.tenantId(), value.tenantId()); + requirePayload(value.status(), value.changedAt(), envelope.aggregateVersion(), envelope.aggregateVersion()); + yield new ProjectionInput("TENANT", String.valueOf(value.tenantId()), value.tenantId(), + value.status(), null, envelope.aggregateVersion(), envelope.payloadHash(), envelope.eventId(), + value.changedAt()); + } + case EventTypes.IAM_PERMISSION_VERSION_CHANGED -> { + var value = objectMapper.treeToValue(envelope.payload(), IamSecurityEventsV1.PermissionVersionChanged.class); + requireIdentity(envelope, "AUTHORIZATION", value.authUserId(), value.tenantId()); + requirePayload(value.appCode(), value.changedAt(), value.permissionVersion(), envelope.aggregateVersion()); + yield new ProjectionInput("AUTHORIZATION", String.valueOf(value.authUserId()), value.tenantId(), + null, value.permissionVersion(), envelope.aggregateVersion(), envelope.payloadHash(), + envelope.eventId(), value.changedAt()); + } + default -> throw new IllegalArgumentException("unsupported type"); + }; + } catch (BizException exception) { + throw exception; + } catch (Exception exception) { + throw new BizException(ResultCode.BAD_REQUEST, "IAM 安全事件 payload 无效"); + } + } + + private static void requireIdentity(EventEnvelope envelope, String aggregateType, long aggregateId, long tenantId) { + if (!aggregateType.equals(envelope.aggregateType()) + || !String.valueOf(aggregateId).equals(envelope.aggregateId()) + || !String.valueOf(tenantId).equals(envelope.tenantId())) { + throw new BizException(ResultCode.CONFLICT, "IAM 安全事件信封与 payload 身份不一致"); + } + } + + private static void requirePayload(String requiredText, Instant changedAt, long payloadVersion, long aggregateVersion) { + if (requiredText == null || requiredText.isBlank() || changedAt == null || payloadVersion != aggregateVersion) { + throw new BizException(ResultCode.CONFLICT, "IAM 安全事件 payload 版本或必填字段无效"); + } + } + + private ProjectionRow findForUpdate(String type, String id) { + List rows = jdbc.query("SELECT aggregate_version,payload_hash FROM overseas_security_projection " + + "WHERE aggregate_type=? AND aggregate_id=? FOR UPDATE", + (rs, row) -> new ProjectionRow(rs.getLong(1), rs.getString(2)), type, id); + return rows.isEmpty() ? null : rows.get(0); + } + + private void upsert(ProjectionInput value) { + jdbc.update("INSERT INTO overseas_security_projection(aggregate_type,aggregate_id,tenant_id,security_status," + + "permission_version,aggregate_version,payload_hash,last_event_id,changed_at,updated_at) " + + "VALUES (?,?,?,?,?,?,?,?,?,NOW(3)) ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id)," + + "security_status=VALUES(security_status),permission_version=VALUES(permission_version)," + + "aggregate_version=VALUES(aggregate_version),payload_hash=VALUES(payload_hash)," + + "last_event_id=VALUES(last_event_id),changed_at=VALUES(changed_at),updated_at=NOW(3)", + value.aggregateType(), value.aggregateId(), value.tenantId(), value.status(), value.permissionVersion(), + value.aggregateVersion(), value.payloadHash(), value.eventId(), value.changedAt()); + } + + private record InboxRow(String eventType, String payloadHash, String status) { } + private record ProjectionRow(long aggregateVersion, String payloadHash) { } + public record ConsumeResult(String result, ProjectionInput projection) { } + public record ProjectionChanged(ProjectionInput projection) { } + public record ProjectionInput(String aggregateType, String aggregateId, long tenantId, String status, + Long permissionVersion, long aggregateVersion, String payloadHash, + String eventId, Instant changedAt) { } +} diff --git a/overseas-security-service/src/main/resources/application.yml b/overseas-security-service/src/main/resources/application.yml new file mode 100644 index 000000000..6817568d2 --- /dev/null +++ b/overseas-security-service/src/main/resources/application.yml @@ -0,0 +1,28 @@ +server: + port: ${OVERSEAS_SECURITY_PORT:9307} +spring: + application.name: overseas-security-service + config.import: + - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true + - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${OVERSEAS_SECURITY_DB_URL} + username: ${OVERSEAS_SECURITY_DB_USERNAME} + password: ${OVERSEAS_SECURITY_DB_PASSWORD} + data.redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + database: ${REDIS_DATABASE:0} + cloud.nacos: + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + username: ${NACOS_USERNAME:nacos} + password: ${NACOS_PASSWORD:nacos} + discovery: + group: ${NACOS_GROUP:BAIYUE} + namespace: ${NACOS_NAMESPACE:prod-overseas} +baiyue.security: + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} +management.endpoints.web.exposure.include: health,info,prometheus diff --git a/overseas-security-service/src/main/resources/db/overseas-security.sql b/overseas-security-service/src/main/resources/db/overseas-security.sql new file mode 100644 index 000000000..7553fba93 --- /dev/null +++ b/overseas-security-service/src/main/resources/db/overseas-security.sql @@ -0,0 +1,31 @@ +CREATE DATABASE IF NOT EXISTS baiyue_overseas_security DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_overseas_security; + +CREATE TABLE IF NOT EXISTS overseas_security_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '跨区事件ID', + event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型', + payload_hash CHAR(64) NOT NULL COMMENT '规范化Payload SHA-256', + payload_json JSON NOT NULL COMMENT '冻结事件Payload', + status VARCHAR(16) NOT NULL COMMENT 'RECEIVED/PROCESSED', + result VARCHAR(32) NULL COMMENT 'APPLIED/STALE/DUPLICATE_VERSION', + received_at DATETIME(3) NOT NULL, + processed_at DATETIME(3) NULL, + PRIMARY KEY (event_id), + KEY idx_security_inbox_type_time (event_type, received_at) +) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者Inbox'; + +CREATE TABLE IF NOT EXISTS overseas_security_projection ( + aggregate_type VARCHAR(32) NOT NULL COMMENT 'USER/TENANT/AUTHORIZATION', + aggregate_id VARCHAR(64) NOT NULL COMMENT '用户或客户聚合ID', + tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID', + security_status VARCHAR(24) NULL COMMENT 'ACTIVE/DISABLED等当前状态', + permission_version BIGINT NULL COMMENT '当前单调权限版本', + aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本', + payload_hash CHAR(64) NOT NULL COMMENT '当前版本Payload哈希', + last_event_id VARCHAR(64) NOT NULL COMMENT '最后应用事件ID', + changed_at DATETIME(3) NOT NULL COMMENT '国内IAM事实发生时间', + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (aggregate_type, aggregate_id), + KEY idx_security_projection_tenant (tenant_id), + KEY idx_security_projection_permission (aggregate_id, permission_version) +) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; diff --git a/overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml b/overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml new file mode 100644 index 000000000..e228e9386 --- /dev/null +++ b/overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml @@ -0,0 +1,26 @@ +openapi: 3.0.3 +info: + title: Overseas Security Projection Internal API + version: 1.0.0 +paths: + /internal/overseas-security/v1/events: + post: + summary: Consume one IAM security EventEnvelope + security: [{ internalServiceToken: [] }] + responses: + '200': + description: "Acknowledgement contains eventId, result and receivedAt" + /internal/overseas-security/v1/validate: + post: + summary: Validate locally verified overseas JWT claim versions + description: Accepts userId, tenantId, tokenVersion and permissionVersion; raw tokens and credentials are forbidden. + security: [{ internalServiceToken: [] }] + responses: + '200': + description: "valid plus optional USER_DISABLED, TENANT_DISABLED or PERMISSION_VERSION_STALE reason" +components: + securitySchemes: + internalServiceToken: + type: apiKey + in: header + name: X-Internal-Service-Token diff --git a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/OverseasSecurityContractTest.java b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/OverseasSecurityContractTest.java new file mode 100644 index 000000000..b626bb89b --- /dev/null +++ b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/OverseasSecurityContractTest.java @@ -0,0 +1,51 @@ +package com.baiyue.overseassecurity; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class OverseasSecurityContractTest { + @Test void sqlFreezesReliableInboxProjectionAndRedisRecoveryBoundary() throws Exception { + String sql = read("overseas-security-service/src/main/resources/db/overseas-security.sql"); + String service = read("overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java"); + String validation = read("overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java"); + String mirror = read("overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java"); + assertThat(sql).contains("PRIMARY KEY (event_id)", "payload_hash CHAR(64)", + "PRIMARY KEY (aggregate_type, aggregate_id)", "aggregate_version BIGINT", "permission_version BIGINT"); + assertThat(service).contains("ProjectionMonotonicity.decide", "Decision.APPLY"); + assertThat(mirror).contains("TransactionPhase.AFTER_COMMIT", "Redis 镜像写入失败"); + assertThat(validation).contains("SELECT MAX(permission_version)", "USER_DISABLED", + "TENANT_DISABLED", "PERMISSION_VERSION_STALE"); + } + + @Test void eventBridgeRoutesIamTypesWithoutInspectingTheirPayload() throws Exception { + String dispatcher = read("event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java"); + String client = read("event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java"); + String lastleg = read("lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java"); + assertThat(dispatcher).contains("IAM_USER_STATUS_CHANGED", "IAM_TENANT_STATUS_CHANGED", + "IAM_PERMISSION_VERSION_CHANGED", "securityProjectionEventClient.deliver(event)"); + assertThat(dispatcher).doesNotContain("IamSecurityEventsV1", "permissionVersion()", "status()"); + assertThat(client).contains("path(\"data\").path(\"eventId\")", "row.getEventId().equals") + .doesNotContain("IamSecurityEventsV1"); + assertThat(lastleg).contains("Lastleg 不消费该事件类型") + .doesNotContain("? shippingNodeConsumer.consume(event) : consumer.consume(event)"); + } + + @Test void internalOpenApiNeverAcceptsRawTokensOrCredentials() throws Exception { + String openapi = read("overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml"); + assertThat(openapi).contains("/internal/overseas-security/v1/events:", + "/internal/overseas-security/v1/validate:", "X-Internal-Service-Token", + "userId, tenantId, tokenVersion and permissionVersion"); + assertThat(openapi).doesNotContain("accessToken", "refreshToken", "password:", "Authorization"); + } + + private static String read(String relative) throws Exception { + Path path = Path.of(relative); + if (!Files.exists(path)) path = Path.of("..").resolve(relative); + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationServiceTest.java b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationServiceTest.java new file mode 100644 index 000000000..f38834f99 --- /dev/null +++ b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationServiceTest.java @@ -0,0 +1,36 @@ +package com.baiyue.overseassecurity.service; + +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.FailureReason; +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationRequest; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class OverseasSecurityValidationServiceTest { + private final JdbcTemplate jdbc = mock(JdbcTemplate.class); + private final OverseasSecurityValidationService service = new OverseasSecurityValidationService(jdbc); + + @Test void disabledUserAndTenantFailClosed() { + when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))) + .thenReturn(List.of("DISABLED")); + assertThat(service.validate(new ValidationRequest(42L, 0L, 3L, 8L)).failureReason()) + .isEqualTo(FailureReason.USER_DISABLED); + } + + @Test void stalePermissionVersionFailsAndCurrentVersionPasses() { + when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))) + .thenReturn(List.of(), List.of()); + when(jdbc.queryForObject(anyString(), any(Class.class), any(Object[].class))).thenReturn(9L, 9L); + assertThat(service.validate(new ValidationRequest(42L, 0L, 3L, 8L)).failureReason()) + .isEqualTo(FailureReason.PERMISSION_VERSION_STALE); + assertThat(service.validate(new ValidationRequest(42L, 0L, 3L, 9L)).valid()).isTrue(); + } +} diff --git a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/ProjectionMonotonicityTest.java b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/ProjectionMonotonicityTest.java new file mode 100644 index 000000000..89560963c --- /dev/null +++ b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/ProjectionMonotonicityTest.java @@ -0,0 +1,28 @@ +package com.baiyue.overseassecurity.service; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ProjectionMonotonicityTest { + @Test void appliesFirstAndNewerVersions() { + assertThat(ProjectionMonotonicity.decide(null, null, 1, "hash-1")) + .isEqualTo(ProjectionMonotonicity.Decision.APPLY); + assertThat(ProjectionMonotonicity.decide(1L, "hash-1", 2, "hash-2")) + .isEqualTo(ProjectionMonotonicity.Decision.APPLY); + } + + @Test void ignoresOlderAndSameHashVersions() { + assertThat(ProjectionMonotonicity.decide(3L, "hash-3", 2, "hash-2")) + .isEqualTo(ProjectionMonotonicity.Decision.STALE); + assertThat(ProjectionMonotonicity.decide(3L, "hash-3", 3, "hash-3")) + .isEqualTo(ProjectionMonotonicity.Decision.DUPLICATE_VERSION); + } + + @Test void rejectsSameVersionWithDifferentHash() { + assertThatThrownBy(() -> ProjectionMonotonicity.decide(3L, "hash-a", 3, "hash-b")) + .isInstanceOf(BizException.class).hasMessageContaining("payloadHash 冲突"); + } +} diff --git a/pom.xml b/pom.xml index 0e706d52c..2cd812fb5 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,7 @@ common-event-contract gateway gateway-overseas + overseas-security-service auth iam order @@ -66,6 +67,7 @@ 3.13.0 3.4.1 0.8.12 + 4.9.8.5 1.5.0 2.3.3 @@ -168,4 +170,31 @@ + + + static-analysis-report + + + + com.github.spotbugs + spotbugs-maven-plugin + ${spotbugs-maven-plugin.version} + + true + true + false + true + + + + 生成静态分析报告 + verify + spotbugs + + + + + + + diff --git a/scripts/java-topology-contract.test.mjs b/scripts/java-topology-contract.test.mjs index 371e7053f..6c094c6e3 100644 --- a/scripts/java-topology-contract.test.mjs +++ b/scripts/java-topology-contract.test.mjs @@ -62,13 +62,13 @@ const parseCompose = (yaml) => { })) } -test('root POM freezes 22 modules and service-map freezes 15 executable services', async () => { +test('root POM freezes 23 modules and service-map freezes 16 executable services', async () => { const modules = parseRootModules(await read('pom.xml')) const services = parseServiceMap(await read('deploy/service-map.yml')) - assert.equal(modules.length, 22) - assert.equal(new Set(modules).size, 22) - assert.equal(services.size, 15) + assert.equal(modules.length, 23) + assert.equal(new Set(modules).size, 23) + assert.equal(services.size, 16) const bootModules = [] for (const module of modules) { @@ -155,6 +155,32 @@ test('Maven verify publishes JaCoCo reports without enforcing a threshold', asyn assert.match(gitlab, /^ - "\*\*\/target\/jacoco\.exec"$/m) }) +test('SpotBugs remains an opt-in non-blocking report without quality thresholds', async () => { + const pom = await read('pom.xml') + const gitlab = await read('.gitlab-ci.yml') + const profile = pom.match(/\s*static-analysis-report<\/id>[\s\S]*?<\/profile>/)?.[0] ?? '' + const spotbugs = profile.match(/\s*com\.github\.spotbugs<\/groupId>[\s\S]*?<\/plugin>/)?.[0] ?? '' + const reportJob = gitlab.match(/^静态分析报告:\n([\s\S]*?)(?=^[^\s#].*:\n)/m)?.[1] ?? '' + const verifyJob = gitlab.match(/^校验与测试:\n([\s\S]*?)(?=^[^\s#].*:\n)/m)?.[1] ?? '' + const packageJob = gitlab.match(/^打包服务:\n([\s\S]*)/m)?.[1] ?? '' + + assert.match(pom, /4\.9\.8\.5<\/spotbugs-maven-plugin\.version>/) + assert.match(spotbugs, /spotbugs-maven-plugin<\/artifactId>/) + assert.match(spotbugs, /verify<\/phase>/) + assert.match(spotbugs, /spotbugs<\/goal>/) + assert.match(spotbugs, /true<\/xmlOutput>/) + assert.match(spotbugs, /true<\/htmlOutput>/) + assert.match(spotbugs, /false<\/failOnError>/) + assert.doesNotMatch(spotbugs, /check<\/goal>||/) + assert.doesNotMatch(pom.replace(profile, ''), /spotbugs-maven-plugin<\/artifactId>/) + assert.match(reportJob, /^ allow_failure: true$/m) + assert.match(reportJob, /-Pstatic-analysis-report/) + assert.match(reportJob, /^ - "\*\*\/target\/spotbugs\*\.xml"$/m) + assert.match(reportJob, /^ - "\*\*\/target\/site\/spotbugs\.html"$/m) + assert.doesNotMatch(verifyJob, /static-analysis-report|spotbugs/i) + assert.doesNotMatch(packageJob, /^ - 静态分析报告$/m) +}) + test('regional Compose and Nacos manifests cover the service-map topology', async () => { const services = parseServiceMap(await read('deploy/service-map.yml')) const composeByRegion = new Map([ diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index 82c4591fd..b1f89decb 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -8,16 +8,18 @@ const read = async (path) => (await readFile(new URL(`../${path}`, import.meta.u test('Jenkins pipelines recognize all overseas deployable modules', async () => { const controller = await read('Jenkinsfile') const service = await read('Jenkinsfile.service') - for (const module of ['gateway-overseas', 'lastleg-service', 'warehouse-overseas-service']) { + for (const module of ['gateway-overseas', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service']) { assert.match(controller, new RegExp(`'${module}'`)) assert.match(service, new RegExp(`'${module}'`)) } assert.match(controller, /'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS'/) assert.match(controller, /'lastleg-service': 'SERVICE_LASTLEG'/) assert.match(controller, /'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS'/) + assert.match(controller, /'overseas-security-service': 'SERVICE_OVERSEAS_SECURITY'/) assert.match(service, /gateway-overseas\/target\/gateway-overseas-1\.0\.0-SNAPSHOT\.jar/) assert.match(service, /lastleg-service\/target\/lastleg-service-1\.0\.0-SNAPSHOT\.jar/) assert.match(service, /warehouse-overseas-service\/target\/warehouse-overseas-service-1\.0\.0-SNAPSHOT\.jar/) + assert.match(service, /overseas-security-service\/target\/overseas-security-service-1\.0\.0-SNAPSHOT\.jar/) assert.match(controller, /EVENT_BRIDGE_REGION/) assert.match(service, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{bridgeRegion\}/) assert.match(controller, /choices: \['BOTH', 'CN', 'OVERSEAS'\]/) @@ -30,10 +32,12 @@ test('service map freezes unique internal ports and moves event bridge away from assert.match(map, /^ gateway-overseas:[\s\S]*?port: 9300$/m) assert.match(map, /^ lastleg-service:[\s\S]*?port: 9303$/m) assert.match(map, /^ warehouse-overseas-service:[\s\S]*?port: 9305$/m) + assert.match(map, /^ overseas-security-service:[\s\S]*?port: 9307$/m) assert.match(map, /^ event-bridge-service:[\s\S]*?port: 9312$/m) assert.match(map, /^ gateway-overseas:[\s\S]*?regions: \[OVERSEAS\]$/m) assert.match(map, /^ lastleg-service:[\s\S]*?regions: \[OVERSEAS\]$/m) assert.match(map, /^ warehouse-overseas-service:[\s\S]*?regions: \[OVERSEAS\]$/m) + assert.match(map, /^ overseas-security-service:[\s\S]*?regions: \[OVERSEAS\]$/m) assert.match(map, /^ event-bridge-service:[\s\S]*?regions: \[CN, OVERSEAS\]$/m) }) @@ -46,6 +50,7 @@ test('overseas compose is isolated from CN defaults and disables implicit schema assert.match(compose, /^ gateway-overseas:\s*$/m) assert.match(compose, /^ lastleg-service:\s*$/m) assert.match(compose, /^ warehouse-overseas-service:\s*$/m) + assert.match(compose, /^ overseas-security-service:\s*$/m) assert.match(compose, /^ event-bridge-service:\s*$/m) assert.match(compose, /EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312/) assert.match(compose, /INTERNAL_SERVICE_TOKEN: \$\{INTERNAL_SERVICE_TOKEN\}/) @@ -53,6 +58,8 @@ test('overseas compose is isolated from CN defaults and disables implicit schema assert.match(compose, /EVENT_BRIDGE_DB_URL: \$\{EVENT_BRIDGE_OVERSEAS_DB_URL\}/) assert.match(compose, /EVENT_BRIDGE_DB_USERNAME: \$\{EVENT_BRIDGE_OVERSEAS_DB_USERNAME\}/) assert.match(compose, /EVENT_BRIDGE_DB_PASSWORD: \$\{EVENT_BRIDGE_OVERSEAS_DB_PASSWORD\}/) + assert.match(compose, /OVERSEAS_SECURITY_DB_URL: \$\{OVERSEAS_SECURITY_DB_URL\}/) + assert.match(compose, /OVERSEAS_SECURITY_EVENT_CONSUMER_URL:/) assert.match(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) assert.match(compose, /FIRSTLEG_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) assert.doesNotMatch(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_URL:/) diff --git a/scripts/provision-development-service-jobs.groovy b/scripts/provision-development-service-jobs.groovy index e4c7ced35..baa57b467 100644 --- a/scripts/provision-development-service-jobs.groovy +++ b/scripts/provision-development-service-jobs.groovy @@ -6,7 +6,7 @@ boolean applyChanges = false String templateName = 'baiyue-firstleg-dev-print' List serviceModules = [ 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service' ] Jenkins jenkins = Jenkins.get() diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs new file mode 100644 index 000000000..0e829bb6f --- /dev/null +++ b/scripts/synchronous-dependency-baseline.mjs @@ -0,0 +1,230 @@ +export const CONTEXTS = Object.freeze(['REQUEST', 'BACKGROUND', 'INFRASTRUCTURE', 'EXTERNAL']) +export const ACCESS_MODES = Object.freeze(['READ', 'WRITE', 'READ_WRITE']) +export const IDEMPOTENCY = Object.freeze([ + 'SAFE_READ', 'IDEMPOTENCY_KEY', 'BUSINESS_KEY', 'EVENT_ID', 'STATE_CONVERGENT', 'NONE', 'MIXED' +]) + +const feign = (path, source, target, contexts, access, idempotency, note) => ({ + id: `FEIGN:${path}`, + transport: 'FEIGN', path, source, target, contexts, access, idempotency, note +}) + +const http = (path, source, target, contexts, access, idempotency, note) => ({ + id: `HTTP:${path}`, + transport: 'HTTP', path, source, target, contexts, access, idempotency, note +}) + +export const SYNCHRONOUS_CLIENTS = Object.freeze([ + feign('auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java', + 'auth-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', 'POST 读取授权快照,不写 IAM'), + feign('auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java', + 'auth-service', 'iam-service', ['REQUEST'], 'WRITE', 'BUSINESS_KEY', '注册/补偿以 authUserId 收敛,不自动重试'), + + feign('file/src/main/java/com/baiyue/file/client/FinanceExportProviderClient.java', + 'file-service', 'finance-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/FirstlegExportProviderClient.java', + 'file-service', 'firstleg-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/IamExportProviderClient.java', + 'file-service', 'iam-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/IamQualificationClient.java', + 'file-service', 'iam-service', ['REQUEST'], 'WRITE', 'STATE_CONVERGENT', '资质登记/替换按客户与资质类型收敛,无通用幂等头'), + feign('file/src/main/java/com/baiyue/file/client/OrderExportProviderClient.java', + 'file-service', 'order-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/WarehouseExportProviderClient.java', + 'file-service', 'warehouse-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/WeightOcrClient.java', + 'file-service', 'integration-service', ['REQUEST'], 'READ', 'NONE', '上传线程内调用 OCR;第三方调用不可假定免费幂等'), + + feign('finance/src/main/java/com/baiyue/finance/client/FinanceFileClient.java', + 'finance-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '元数据 GET 可重复,凭证上传无幂等键'), + feign('finance/src/main/java/com/baiyue/finance/client/FirstlegCostSnapshotClient.java', + 'finance-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取整柜成本分摊快照'), + feign('finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java', + 'finance-service', 'firstleg-service', ['REQUEST', 'BACKGROUND'], 'READ_WRITE', 'MIXED', + '事件消费回读快照;平台作废命令携带 Idempotency-Key 与请求哈希'), + feign('finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java', + 'finance-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取客户主档快照'), + feign('finance/src/main/java/com/baiyue/finance/client/WarehouseSupplierClient.java', + 'finance-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取并校验供应商快照'), + + feign('firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java', + 'firstleg-service', 'integration-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '船节点定时任务查询,不属于用户请求'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java', + 'firstleg-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '装柜/船节点 Outbox 按 eventId 投递'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ', 'SAFE_READ', '校验付款凭证元数据'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/FinanceFeeSourceEventClient.java', + 'firstleg-service', 'finance-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '费用 Outbox 按事件幂等键投递'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java', + 'firstleg-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取客户名称与状态快照'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '上传无幂等键,元数据/下载可重复'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取装柜证据元数据/内容'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/MeasurementImageClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '图片上传/作废非安全重放,查询可重复'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java', + 'firstleg-service', 'order-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取订单货物/来源快照'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClient.java', + 'firstleg-service', 'order-service', ['REQUEST'], 'WRITE', 'IDEMPOTENCY_KEY', 'PDA 结算拆单携带 idempotencyKey/operationId'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '价目文件上传无幂等键,读取可重复'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookWarehouseClient.java', + 'firstleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取仓库/渠道主数据'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java', + 'firstleg-service', 'print-service', ['REQUEST'], 'READ', 'SAFE_READ', '无状态 PDF 渲染,POST 为计算型读取'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/TruckAppointmentFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '附件上传无幂等键,元数据读取可重复'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java', + 'firstleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取仓库主数据'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java', + 'firstleg-service', 'warehouse-service', ['REQUEST'], 'READ_WRITE', 'MIXED', + '查询可重复;预占/选择释放/转移/结算使用业务幂等键,普通确认/释放按状态收敛'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java', + 'firstleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取供应商快照'), + feign('firstleg/src/main/java/com/baiyue/firstleg/customs/client/CustomsFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '报关文件上传无幂等键,读取可重复'), + + feign('iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java', + 'iam-service', 'auth-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '账号查询可重复,写操作按账号/命令业务键收敛'), + feign('iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java', + 'iam-service', 'firstleg-service', ['REQUEST'], 'WRITE', 'BUSINESS_KEY', '初始化/补偿使用 initializationKey'), + feign('iam/src/main/java/com/baiyue/iam/client/OverseasStorageFeeClient.java', + 'iam-service', 'warehouse-service', ['REQUEST'], 'WRITE', 'BUSINESS_KEY', '初始化/补偿使用 initializationKey'), + feign('iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java', + 'iam-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'IAM 安全 Outbox 按 eventId 投递'), + + feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java', + 'lastleg-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '卸柜完成 Outbox 按 eventId 投递'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClient.java', + 'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '报价创建时读取国内合作仓快照'), + + feign('order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java', + 'order-service', 'firstleg-service', ['REQUEST', 'BACKGROUND'], 'READ_WRITE', 'BUSINESS_KEY', + '列表查询/缺失修复在线执行;审核建单由 Outbox 投递,按 orderId/inboundCode 收敛'), + feign('order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java', + 'order-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ', 'POST 校验计价渠道,不写履约状态'), + feign('order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java', + 'order-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ', 'POST 查询统一订单投影'), + feign('order/src/main/java/com/baiyue/order/client/ProductImageClient.java', + 'order-service', 'file-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取商品图片元数据'), + feign('order/src/main/java/com/baiyue/order/client/TenantEligibilityClient.java', + 'order-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取客户下单资格'), + feign('order/src/main/java/com/baiyue/order/client/WarehouseClient.java', + 'order-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取仓库主数据'), + + http('common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java', + 'common-audit', 'audit-service', ['INFRASTRUCTURE'], 'WRITE', 'NONE', '同步审计投递失败当前仅记录告警'), + http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java', + 'event-bridge-service', 'firstleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'Inbox 租约投递,目标按 eventId 幂等'), + http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java', + 'event-bridge-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'Inbox 租约投递,目标按 eventId 幂等'), + http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java', + 'event-bridge-service', 'overseas-security-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'IAM 安全事件投影按 eventId/版本单调幂等'), + http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java', + 'gateway-overseas', 'overseas-security-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST 实时校验,失败关闭'), + http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', + 'gateway-overseas', 'auth-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST PDA 会话校验,失败关闭'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '上传无幂等键,元数据校验可重复'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java', + 'lastleg-service', 'warehouse-overseas-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '查询可重复;写操作按 business/reservation key 或状态收敛'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java', + 'lastleg-service', 'warehouse-overseas-service', ['REQUEST'], 'READ_WRITE', 'IDEMPOTENCY_KEY', '托盘变更/补偿使用 operationKey 与请求哈希'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '上传无幂等键,元数据校验可重复'), + http('integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java', + 'integration-service', 'external-evergreen', ['EXTERNAL'], 'READ', 'SAFE_READ', '船司轨迹查询;遵守外部限流'), + http('integration-service/src/main/java/com/baiyue/integration/provider/HedeTrackingClient.java', + 'integration-service', 'external-hede', ['EXTERNAL'], 'READ', 'SAFE_READ', '船司轨迹查询;遵守外部限流'), + http('integration-service/src/main/java/com/baiyue/integration/provider/MatsonTrackingClient.java', + 'integration-service', 'external-matson', ['EXTERNAL'], 'READ', 'SAFE_READ', '船司轨迹查询;遵守外部限流'), + http('integration-service/src/main/java/com/baiyue/integration/provider/SmlTrackingClient.java', + 'integration-service', 'external-sml', ['EXTERNAL'], 'READ', 'SAFE_READ', '船司轨迹查询;遵守外部限流'), + http('integration-service/src/main/java/com/baiyue/integration/provider/ZimTrackingClient.java', + 'integration-service', 'external-zim', ['EXTERNAL'], 'READ', 'SAFE_READ', '令牌获取与轨迹查询不改变平台业务事实'), + http('integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java', + 'integration-service', 'external-baidu-ocr', ['EXTERNAL'], 'READ', 'NONE', 'OCR 可能计费,不由 HTTP 层自动重试') +]) + +export const USER_ENTRIES = Object.freeze([ + { name: '认证 API', service: 'auth-service' }, + { name: 'IAM API', service: 'iam-service' }, + { name: '订单 API', service: 'order-service' }, + { name: '头程 API/PDA', service: 'firstleg-service' }, + { name: '财务 API', service: 'finance-service' }, + { name: '文件 API', service: 'file-service' }, + { name: '海外尾程 API/PDA', service: 'lastleg-service' }, + { name: '国内仓储 API', service: 'warehouse-service' }, + { name: '海外仓储 API', service: 'warehouse-overseas-service' } +]) + +export function graphEdges(context) { + return [...new Set(SYNCHRONOUS_CLIENTS + .filter(client => context === 'ALL' || client.contexts.includes(context)) + .map(client => `${client.source}->${client.target}`))].sort() +} + +export function stronglyConnectedComponents(edgeNames) { + const adjacency = new Map() + for (const edge of edgeNames) { + const [from, to] = edge.split('->') + if (!adjacency.has(from)) adjacency.set(from, new Set()) + if (!adjacency.has(to)) adjacency.set(to, new Set()) + adjacency.get(from).add(to) + } + let index = 0 + const indexes = new Map() + const lowLinks = new Map() + const stack = [] + const onStack = new Set() + const components = [] + function connect(node) { + indexes.set(node, index) + lowLinks.set(node, index++) + stack.push(node) + onStack.add(node) + for (const target of adjacency.get(node)) { + if (!indexes.has(target)) { + connect(target) + lowLinks.set(node, Math.min(lowLinks.get(node), lowLinks.get(target))) + } else if (onStack.has(target)) lowLinks.set(node, Math.min(lowLinks.get(node), indexes.get(target))) + } + if (lowLinks.get(node) !== indexes.get(node)) return + const component = [] + let current + do { + current = stack.pop() + onStack.delete(current) + component.push(current) + } while (current !== node) + components.push(component.sort()) + } + for (const node of adjacency.keys()) if (!indexes.has(node)) connect(node) + return components.filter(component => component.length > 1) + .sort((left, right) => left.join().localeCompare(right.join())) +} + +export function longestSimplePaths(entryService, edgeNames = graphEdges('REQUEST')) { + const adjacency = new Map() + for (const edge of edgeNames) { + const [from, to] = edge.split('->') + if (!adjacency.has(from)) adjacency.set(from, new Set()) + adjacency.get(from).add(to) + } + let longest = [] + function visit(node, path) { + const targets = [...(adjacency.get(node) ?? [])].sort() + let extended = false + for (const target of targets) { + if (path.includes(target)) continue + extended = true + visit(target, [...path, target]) + } + if (extended) return + if (longest.length === 0 || path.length > longest[0].length) longest = [path] + else if (path.length === longest[0].length) longest.push(path) + } + visit(entryService, [entryService]) + return longest.sort((left, right) => left.join('->').localeCompare(right.join('->'))) +} diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index 60c2af78b..a36533f8c 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -3,72 +3,62 @@ import { readdir, readFile } from 'node:fs/promises' import path from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' +import { + ACCESS_MODES, CONTEXTS, IDEMPOTENCY, SYNCHRONOUS_CLIENTS, USER_ENTRIES, + graphEdges, longestSimplePaths, stronglyConnectedComponents +} from './synchronous-dependency-baseline.mjs' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const serviceModules = [ 'auth', 'iam', 'order', 'firstleg', 'finance', 'file', 'warehouse', 'integration-service', 'print', 'event-bridge-service', 'lastleg-service', - 'warehouse-overseas-service' + 'warehouse-overseas-service', 'overseas-security-service' ] const canonicalSource = new Map(serviceModules.map(name => [name, `${name}${name.endsWith('-service') ? '' : '-service'}`])) -const expectedFeignEdges = new Map(Object.entries({ - 'auth-service->iam-service': 2, - 'file-service->finance-service': 1, - 'file-service->firstleg-service': 1, - 'file-service->iam-service': 2, - 'file-service->integration-service': 1, - 'file-service->order-service': 1, - 'file-service->warehouse-service': 1, - 'finance-service->file-service': 1, - 'finance-service->firstleg-service': 2, - 'finance-service->iam-service': 1, - 'finance-service->warehouse-service': 1, - 'firstleg-service->event-bridge-service': 1, - 'firstleg-service->file-service': 7, - 'firstleg-service->finance-service': 1, - 'firstleg-service->iam-service': 1, - 'firstleg-service->integration-service': 1, - 'firstleg-service->order-service': 2, - 'firstleg-service->print-service': 1, - 'firstleg-service->warehouse-service': 4, - 'iam-service->auth-service': 1, - 'iam-service->event-bridge-service': 1, - 'iam-service->firstleg-service': 1, - 'iam-service->warehouse-service': 1, - 'lastleg-service->event-bridge-service': 1, - 'lastleg-service->warehouse-service': 1, - 'order-service->file-service': 1, - 'order-service->firstleg-service': 3, - 'order-service->iam-service': 1, - 'order-service->warehouse-service': 1 -})) - -const expectedRawHttpImplementations = [ - 'common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java', - 'event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java', - 'event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java', - 'gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', - 'lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', - 'lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java', - 'lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java', - 'lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java', - 'integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java', - 'integration-service/src/main/java/com/baiyue/integration/provider/HedeTrackingClient.java', - 'integration-service/src/main/java/com/baiyue/integration/provider/MatsonTrackingClient.java', - 'integration-service/src/main/java/com/baiyue/integration/provider/SmlTrackingClient.java', - 'integration-service/src/main/java/com/baiyue/integration/provider/ZimTrackingClient.java', - 'integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java' +const expectedOnlineEdges = [ + 'auth-service->iam-service', + 'file-service->iam-service', + 'file-service->integration-service', + 'finance-service->file-service', + 'finance-service->firstleg-service', + 'finance-service->iam-service', + 'finance-service->warehouse-service', + 'firstleg-service->file-service', + 'firstleg-service->iam-service', + 'firstleg-service->order-service', + 'firstleg-service->print-service', + 'firstleg-service->warehouse-service', + 'iam-service->auth-service', + 'iam-service->firstleg-service', + 'iam-service->warehouse-service', + 'lastleg-service->file-service', + 'lastleg-service->warehouse-overseas-service', + 'lastleg-service->warehouse-service', + 'order-service->file-service', + 'order-service->firstleg-service', + 'order-service->iam-service', + 'order-service->warehouse-service' ] -const reviewedRawInternalEdges = [ +const expectedBackgroundEdges = [ 'event-bridge-service->firstleg-service', 'event-bridge-service->lastleg-service', - 'gateway-overseas->auth-service', - 'lastleg-service->file-service', - 'lastleg-service->warehouse-overseas-service' + 'event-bridge-service->overseas-security-service', + 'file-service->finance-service', + 'file-service->firstleg-service', + 'file-service->iam-service', + 'file-service->order-service', + 'file-service->warehouse-service', + 'finance-service->firstleg-service', + 'firstleg-service->event-bridge-service', + 'firstleg-service->finance-service', + 'firstleg-service->integration-service', + 'iam-service->event-bridge-service', + 'lastleg-service->event-bridge-service', + 'order-service->firstleg-service' ] const rawHttpPattern = /\b(?:RestClient|WebClient|HttpClient)\b/ @@ -87,7 +77,7 @@ async function javaFiles(directory) { } async function discoverFeignEdges() { - const edges = new Map() + const clients = [] for (const module of serviceModules) { const sourceRoot = path.join(root, module, 'src', 'main', 'java') for (const file of await javaFiles(sourceRoot)) { @@ -96,66 +86,24 @@ async function discoverFeignEdges() { const annotation = source.match(/@FeignClient\(([\s\S]*?)\)\s*(?:public\s+)?interface/) const target = annotation?.[1].match(/name\s*=\s*"([^"]+)"/)?.[1] assert.ok(target, `cannot resolve Feign target from ${path.relative(root, file)}`) - const edge = `${canonicalSource.get(module)}->${target}` - edges.set(edge, (edges.get(edge) ?? 0) + 1) + clients.push({ + path: path.relative(root, file).replaceAll('\\', '/'), + source: canonicalSource.get(module), target + }) } } - return edges + return clients.sort((left, right) => left.path.localeCompare(right.path)) } -function sortedEntries(map) { - return [...map.entries()].sort(([left], [right]) => left.localeCompare(right)) -} - -function stronglyConnectedComponents(edgeNames) { - const adjacency = new Map() - for (const edge of edgeNames) { - const [from, to] = edge.split('->') - if (!adjacency.has(from)) adjacency.set(from, new Set()) - if (!adjacency.has(to)) adjacency.set(to, new Set()) - adjacency.get(from).add(to) - } - let index = 0 - const indexes = new Map() - const lowLinks = new Map() - const stack = [] - const onStack = new Set() - const components = [] - - function connect(node) { - indexes.set(node, index) - lowLinks.set(node, index++) - stack.push(node) - onStack.add(node) - for (const target of adjacency.get(node)) { - if (!indexes.has(target)) { - connect(target) - lowLinks.set(node, Math.min(lowLinks.get(node), lowLinks.get(target))) - } else if (onStack.has(target)) { - lowLinks.set(node, Math.min(lowLinks.get(node), indexes.get(target))) - } - } - if (lowLinks.get(node) !== indexes.get(node)) return - const component = [] - let current - do { - current = stack.pop() - onStack.delete(current) - component.push(current) - } while (current !== node) - components.push(component.sort()) - } - - for (const node of adjacency.keys()) if (!indexes.has(node)) connect(node) - return components.filter(component => component.length > 1) - .sort((left, right) => left.join().localeCompare(right.join())) -} - -test('all Feign clients remain covered by the reviewed synchronous edge baseline', async () => { - assert.deepEqual(sortedEntries(await discoverFeignEdges()), sortedEntries(expectedFeignEdges)) +test('all 44 Feign clients have reviewed context, access and idempotency metadata', async () => { + const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN') + .map(({ path, source, target }) => ({ path, source, target })) + .sort((left, right) => left.path.localeCompare(right.path)) + assert.equal(reviewed.length, 44) + assert.deepEqual(await discoverFeignEdges(), reviewed) }) -test('raw HTTP transports cannot be added without an explicit graph review', async () => { +test('all 16 raw HTTP transports have reviewed context, access and idempotency metadata', async () => { const discovered = [] for (const module of ['common-audit', 'event-bridge-service', 'gateway-overseas', 'lastleg-service', 'integration-service']) { for (const file of await javaFiles(path.join(root, module, 'src', 'main', 'java'))) { @@ -165,18 +113,127 @@ test('raw HTTP transports cannot be added without an explicit graph review', asy discovered.push(path.relative(root, file).replaceAll('\\', '/')) } } - assert.deepEqual(discovered.sort(), [...expectedRawHttpImplementations].sort()) + const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP') + .map(client => client.path).sort() + assert.equal(reviewed.length, 16) + assert.deepEqual(discovered.sort(), reviewed) }) -test('the current synchronous service graph keeps its reviewed strongly connected component visible', async () => { - const feignEdges = (await discoverFeignEdges()).keys() - const components = stronglyConnectedComponents([...feignEdges, ...reviewedRawInternalEdges]) - assert.deepEqual(components, [[ +test('every reviewed synchronous client has a complete and exclusive executable classification', () => { + assert.equal(new Set(SYNCHRONOUS_CLIENTS.map(client => client.id)).size, SYNCHRONOUS_CLIENTS.length) + for (const client of SYNCHRONOUS_CLIENTS) { + assert.ok(client.path && client.source && client.target && client.note, `${client.id} metadata is incomplete`) + assert.ok(client.contexts.length > 0, `${client.id} has no execution context`) + assert.equal(new Set(client.contexts).size, client.contexts.length, `${client.id} repeats a context`) + for (const context of client.contexts) assert.ok(CONTEXTS.includes(context), `${client.id} has invalid context ${context}`) + assert.ok(ACCESS_MODES.includes(client.access), `${client.id} has invalid access ${client.access}`) + assert.ok(IDEMPOTENCY.includes(client.idempotency), `${client.id} has invalid idempotency ${client.idempotency}`) + } +}) + +test('all four Lastleg RestClient gateways use explicit bounded transports', async () => { + const clients = new Map([ + ['lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', 'getUnloadPhotoFile'], + ['lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java', 'getRelabelPhotoFile'], + ['lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java', 'getWarehouseResource'], + ['lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java', 'getPalletPhysical'] + ]) + for (const [file, propertyAccessor] of clients) { + const source = await readFile(path.join(root, file), 'utf8') + assert.match(source, /restClientFactory\.build\(restClientBuilder, baseUrl,/) + assert.match(source, new RegExp(`restClientProperties\\.${propertyAccessor}\\(\\)`)) + assert.doesNotMatch(source, /RetryTemplate|Retryer|@Retryable|\.retry\(|fallbackFactory|fallback\s*=/i) + } + + const factory = await readFile(path.join(root, + 'lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java'), 'utf8') + assert.match(factory, /connectTimeout\(timeouts\.getConnectTimeout\(\)\)/) + assert.match(factory, /setReadTimeout\(timeouts\.getReadTimeout\(\)\)/) + assert.doesNotMatch(factory, /RetryTemplate|Retryer|@Retryable|\.retry\(|fallbackFactory|fallback\s*=/i) + + for (const configFile of [ + 'lastleg-service/src/main/resources/application.yml', + 'config/nacos/overseas/lastleg-service.yml' + ]) { + const source = await readFile(path.join(root, configFile), 'utf8') + for (const key of ['unload-photo-file', 'relabel-photo-file', 'warehouse-resource', 'pallet-physical']) { + assert.match(source, new RegExp( + `${key}:\\s*\\n\\s+connect-timeout:\\s*\\$\\{[^}]+:[1-9][0-9]*(?:ms|s)\\}` + + `\\s*\\n\\s+read-timeout:\\s*\\$\\{[^}]+:[1-9][0-9]*(?:ms|s)\\}`)) + } + } +}) + +test('REQUEST, BACKGROUND and full graphs remain separate executable baselines', () => { + assert.deepEqual(graphEdges('REQUEST'), expectedOnlineEdges) + assert.deepEqual(graphEdges('BACKGROUND'), expectedBackgroundEdges) + assert.deepEqual(graphEdges('INFRASTRUCTURE'), [ + 'common-audit->audit-service', + 'gateway-overseas->auth-service', + 'gateway-overseas->overseas-security-service' + ]) + assert.deepEqual(stronglyConnectedComponents(graphEdges('REQUEST')), [[ + 'auth-service', 'file-service', 'firstleg-service', 'iam-service', 'order-service' + ]]) + assert.deepEqual(stronglyConnectedComponents(graphEdges('BACKGROUND')), [[ + 'event-bridge-service', 'finance-service', 'firstleg-service', 'lastleg-service' + ]]) + assert.deepEqual(stronglyConnectedComponents(graphEdges('ALL')), [[ 'auth-service', 'event-bridge-service', 'file-service', 'finance-service', 'firstleg-service', 'iam-service', 'lastleg-service', 'order-service' ]]) }) +test('overseas security edges never become online business dependencies', () => { + assert.ok(!graphEdges('REQUEST').some(edge => edge.includes('overseas-security-service'))) + assert.ok(graphEdges('INFRASTRUCTURE').includes('gateway-overseas->overseas-security-service')) + assert.ok(graphEdges('BACKGROUND').includes('event-bridge-service->overseas-security-service')) +}) + +test('known user entries expose every longest simple REQUEST path over the two-hop budget', () => { + const summary = Object.fromEntries(USER_ENTRIES.map(entry => { + const paths = longestSimplePaths(entry.service) + return [entry.service, { hops: paths[0].length - 1, paths: paths.map(path => path.join('->')) }] + })) + assert.deepEqual(summary, { + 'auth-service': { hops: 5, paths: [ + 'auth-service->iam-service->firstleg-service->order-service->file-service->integration-service' + ] }, + 'iam-service': { hops: 4, paths: [ + 'iam-service->firstleg-service->order-service->file-service->integration-service' + ] }, + 'order-service': { hops: 4, paths: [ + 'order-service->file-service->iam-service->firstleg-service->print-service', + 'order-service->file-service->iam-service->firstleg-service->warehouse-service', + 'order-service->firstleg-service->file-service->iam-service->auth-service', + 'order-service->firstleg-service->file-service->iam-service->warehouse-service', + 'order-service->iam-service->firstleg-service->file-service->integration-service' + ] }, + 'firstleg-service': { hops: 4, paths: [ + 'firstleg-service->order-service->file-service->iam-service->auth-service', + 'firstleg-service->order-service->file-service->iam-service->warehouse-service' + ] }, + 'finance-service': { hops: 5, paths: [ + 'finance-service->file-service->iam-service->firstleg-service->order-service->warehouse-service', + 'finance-service->firstleg-service->order-service->file-service->iam-service->auth-service', + 'finance-service->firstleg-service->order-service->file-service->iam-service->warehouse-service', + 'finance-service->iam-service->firstleg-service->order-service->file-service->integration-service' + ] }, + 'file-service': { hops: 4, paths: [ + 'file-service->iam-service->firstleg-service->order-service->warehouse-service' + ] }, + 'lastleg-service': { hops: 5, paths: [ + 'lastleg-service->file-service->iam-service->firstleg-service->order-service->warehouse-service' + ] }, + 'warehouse-service': { hops: 0, paths: ['warehouse-service'] }, + 'warehouse-overseas-service': { hops: 0, paths: ['warehouse-overseas-service'] } + }) + for (const entry of USER_ENTRIES) { + const result = summary[entry.service] + if (result.hops > 2) assert.ok(result.paths.every(path => path.split('->').length > 3)) + } +}) + test('shared Feign timeout defaults and long-running export overrides do not drift silently', async () => { for (const profile of ['local', 'cn', 'overseas']) { const source = await readFile(path.join(root, 'config', 'nacos', profile, 'baiyue-common.yml'), 'utf8')