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/',