diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 9476688fd..3739f0d36 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -13,6 +13,33 @@ const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitt 'manual-loading-complete-fixture', 'manual-loading-complete-verify', 'coload-groups'] const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES]) +const A0_LOCATION_PAGE_SIZE = 200 +const A0_MAX_LOCATION_MUTATIONS = 8 + +export function planA0LocationIsolation(locations, requiredSlots = 1, + maxMutations = A0_MAX_LOCATION_MUTATIONS) { + const slots = Math.max(1, Number.isInteger(Number(requiredSlots)) ? Number(requiredSlots) : 1) + const limit = Math.max(0, Number.isInteger(Number(maxMutations)) ? Number(maxMutations) : 0) + const unique = new Map() + for (const location of Array.isArray(locations) ? locations : []) { + const locationId = location?.id == null ? '' : String(location.id) + if (!locationId || location?.sharedOverflow === true || unique.has(locationId)) continue + unique.set(locationId, location) + } + const normal = [...unique.values()].sort((left, right) => + String(left?.locationCode || '').localeCompare(String(right?.locationCode || '')) + || String(left.id).localeCompare(String(right.id))) + const remainingNormal = Math.max(0, slots - 1) + const mutationsRequired = Math.max(0, normal.length - remainingNormal) + return { + enabled: mutationsRequired <= limit, + requiredSlots: slots, + normalCount: normal.length, + expectedRemainingNormal: Math.min(normal.length, remainingNormal), + mutationsRequired, + targets: mutationsRequired <= limit ? normal.slice(0, mutationsRequired) : [], + } +} const MODULE_DEPENDENCIES = new Map([ ['suppliers', ['merchant-qualification-fixture']], @@ -1236,15 +1263,17 @@ export class AcceptanceRun { this.assert('phase3-loading-fixture', 'A0 隔离使用本轮订单收货仓', null, Boolean(warehouseId), `warehouseId=${warehouseId || 'missing'}`, { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true }) - const disabledLocations = requireSharedOverflow - ? await this.disableAvailableLocationsForA0(warehouseId) : [] - this.context.phase3LoadingRequireSharedOverflow = requireSharedOverflow + const requiredPalletCount = Math.max(1, Number(this.context?.waitSplitVariant?.palletCount || 1)) + const isolation = requireSharedOverflow + ? await this.disableAvailableLocationsForA0(warehouseId, requiredPalletCount) + : { forced: false, disabled: [] } + this.context.phase3LoadingRequireSharedOverflow = isolation.forced === true try { const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url)) await this.executeModule('inbound-measurement-phase3-loading-fixture', () => measurement.run(this)) } finally { if (requireSharedOverflow) { - await this.restoreAvailableLocationsAfterA0(warehouseId, disabledLocations) + await this.restoreAvailableLocationsAfterA0(warehouseId, isolation) } } const fixture = this.createdResources.inboundMeasurement || this.createdResources.orders @@ -1430,24 +1459,60 @@ export class AcceptanceRun { { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) } - async disableAvailableLocationsForA0(warehouseId) { + async listAvailableLocationsForA0(warehouseId, module, options) { + const locations = [] + const seen = new Set() + let expectedTotal = null + for (let pageNumber = 1; pageNumber <= 100; pageNumber += 1) { + const response = await this.request('GET', + `/api/platform/v1/locations?tenantId=0&page=${pageNumber}&size=${A0_LOCATION_PAGE_SIZE}&warehouseId=${encodeURIComponent(warehouseId)}&status=AVAILABLE`) + const page = this.apiSuccess(module, `查询 A0 隔离前可用库位第 ${pageNumber} 页`, response, + `page=${pageNumber}`, options) + if (!page) return null + const pageTotal = Number(page.total) + const records = Array.isArray(page.records) ? page.records : [] + if (!Number.isInteger(pageTotal) || pageTotal < 0 + || (expectedTotal !== null && pageTotal !== expectedTotal)) { + this.assert(module, 'A0 隔离库位分页总数稳定', response, false, + `page=${pageNumber}, firstTotal=${expectedTotal ?? 'missing'}, currentTotal=${page.total ?? 'missing'}`, options) + return null + } + expectedTotal ??= pageTotal + for (const location of records) { + const locationId = location?.id == null ? '' : String(location.id) + if (locationId && !seen.has(locationId)) { + seen.add(locationId) + locations.push(location) + } + } + if (locations.length >= expectedTotal) break + if (records.length === 0) break + } + const complete = locations.length === expectedTotal + this.assert(module, 'A0 隔离库位分页完整且无重复', null, complete, + `total=${expectedTotal ?? 'missing'}, uniqueRecords=${locations.length}`, options) + return complete ? locations : null + } + + async disableAvailableLocationsForA0(warehouseId, requiredSlots = 1) { const module = 'phase3-loading-fixture' const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true } if (!warehouseId) { this.assert(module, 'A0 隔离收货仓 ID 完整', null, false, 'warehouseId=missing', options) - return [] + return { forced: false, disabled: [] } + } + const locations = await this.listAvailableLocationsForA0(warehouseId, module, options) + if (!locations) return { forced: false, disabled: [] } + const isolation = planA0LocationIsolation(locations, requiredSlots) + if (!isolation.enabled) { + this.addStep(module, 'A0 大仓保护:不批量停用普通库位', null, true, + `normal=${isolation.normalCount}, requiredSlots=${isolation.requiredSlots}, mutationsRequired=${isolation.mutationsRequired}, safetyLimit=${A0_MAX_LOCATION_MUTATIONS}`, + { scenario: options.scenario }) + return { forced: false, disabled: [], isolation } } - const pageResponse = await this.request('GET', - `/api/platform/v1/locations?tenantId=0&page=1&size=200&warehouseId=${encodeURIComponent(warehouseId)}&status=AVAILABLE`) - const page = this.apiSuccess(module, '查询 A0 隔离前全部可用普通库位', pageResponse, '', options) - const records = Array.isArray(page?.records) ? page.records : [] - const locations = records.filter((item) => item.sharedOverflow !== true) - this.assert(module, 'A0 隔离库位分页完整', pageResponse, - Number(page?.total) === records.length && records.length < 200, - `total=${page?.total ?? 'missing'}, records=${records.length}, normal=${locations.length}`, options) const disabled = [] const skippedOccupied = [] - for (const location of locations) { + for (const location of isolation.targets) { const response = await this.request('PATCH', `/api/platform/v1/locations/${encodeURIComponent(location.id)}/status?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`, { status: 'DISABLED', version: Number(location.version) }) @@ -1462,16 +1527,19 @@ export class AcceptanceRun { } const availableResponse = await this.request('GET', `/api/platform/v1/locations/available?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&limit=200`) - const available = this.apiSuccess(module, '停用后回查收货仓无可用普通库位', availableResponse, '', options) - this.assert(module, '测量前 A0 溢出条件成立', availableResponse, - Array.isArray(available) && available.length === 0, - `disabled=${disabled.length}, skippedOccupied=${skippedOccupied.map((item) => `${item.code}:${item.id}`).join('|') || 'none'}, available=${Array.isArray(available) ? available.length : 'missing'}`, options) - return disabled + const available = this.apiSuccess(module, '停用后回查收货仓剩余普通库位', availableResponse, '', options) + const remainingNormal = Array.isArray(available) + ? available.filter((item) => item?.sharedOverflow !== true) : null + const forced = Array.isArray(remainingNormal) && remainingNormal.length < isolation.requiredSlots + this.assert(module, '测量前 A0 溢出条件成立', availableResponse, forced, + `requiredSlots=${isolation.requiredSlots}, disabled=${disabled.length}, skippedOccupied=${skippedOccupied.map((item) => `${item.code}:${item.id}`).join('|') || 'none'}, remainingNormal=${Array.isArray(remainingNormal) ? remainingNormal.length : 'missing'}`, options) + return { forced, disabled, isolation } } - async restoreAvailableLocationsAfterA0(warehouseId, disabledLocations) { + async restoreAvailableLocationsAfterA0(warehouseId, result) { const module = 'phase3-loading-fixture' const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true } + const disabledLocations = Array.isArray(result?.disabled) ? result.disabled : [] for (const location of disabledLocations) { const response = await this.request('PATCH', `/api/platform/v1/locations/${encodeURIComponent(location.id)}/status?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`, @@ -1481,7 +1549,10 @@ export class AcceptanceRun { `locationId=${location.id}, restoreVersion=${location.version}`, options) } this.createdResources.phase3LoadingA0Isolation = { - warehouseId, restored: disabledLocations.map((item) => item.id), marker: this.marker, + warehouseId, forced: result?.forced === true, + normalCount: result?.isolation?.normalCount ?? null, + mutationsRequired: result?.isolation?.mutationsRequired ?? 0, + restored: disabledLocations.map((item) => item.id), marker: this.marker, } } diff --git a/scripts/admin-test-env-modules/phase3-a0-isolation-contract.test.mjs b/scripts/admin-test-env-modules/phase3-a0-isolation-contract.test.mjs new file mode 100644 index 000000000..3b624b32f --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-a0-isolation-contract.test.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { planA0LocationIsolation } from '../admin-test-env-acceptance.mjs' + +function locations(count) { + return Array.from({ length: count }, (_, index) => ({ + id: String(index + 1), locationCode: `L-${String(index + 1).padStart(4, '0')}`, + sharedOverflow: false, version: 0, + })) +} + +test('large warehouse is never selected for default mass disable', () => { + const plan = planA0LocationIsolation(locations(614), 1) + assert.equal(plan.enabled, false) + assert.equal(plan.mutationsRequired, 614) + assert.deepEqual(plan.targets, []) +}) + +test('minimum mutations leave fewer normal locations than required pallets', () => { + const plan = planA0LocationIsolation(locations(3), 3) + assert.equal(plan.enabled, true) + assert.equal(plan.mutationsRequired, 1) + assert.equal(plan.targets.length, 1) + assert.equal(plan.expectedRemainingNormal, 2) + assert.ok(plan.expectedRemainingNormal < plan.requiredSlots) +}) + +test('natural shortage proves A0 fallback without any location mutation', () => { + const plan = planA0LocationIsolation(locations(1), 2) + assert.equal(plan.enabled, true) + assert.equal(plan.mutationsRequired, 0) + assert.deepEqual(plan.targets, []) + assert.ok(plan.normalCount < plan.requiredSlots) +}) + +test('shared A0 and duplicate rows never inflate the normal location plan', () => { + const normal = locations(2) + const plan = planA0LocationIsolation([ + ...normal, normal[0], { id: 'A0', locationCode: 'A0', sharedOverflow: true, version: 0 }, + ], 2) + assert.equal(plan.normalCount, 2) + assert.equal(plan.mutationsRequired, 1) + assert.deepEqual(plan.targets.map((item) => item.id), ['1']) +}) 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 b1c923ebc..c36677567 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 @@ -511,20 +511,22 @@ test('phase3 loading resolves legal SKIP and PARTIAL exceptions before downstrea assert.doesNotMatch(loading, /if \(Number\(dynamicCandidate\?\.availablePackageCount\) < 2\) return/) }) -test('phase3 loading fixture forces A0 through reversible public location status APIs', () => { +test('phase3 loading fixture paginates A0 candidates and refuses mass location mutations', () => { const entry = source('../admin-test-env-acceptance.mjs') - const measurement = source('./inbound-measurement.mjs') const orders = source('./orders.mjs') assertOrder(entry, [ - 'disableAvailableLocationsForA0(warehouseId)', - 'this.context.phase3LoadingRequireSharedOverflow = true', + 'disableAvailableLocationsForA0(warehouseId, requiredPalletCount)', + 'this.context.phase3LoadingRequireSharedOverflow = isolation.forced === true', "executeModule('inbound-measurement-phase3-loading-fixture'", - 'restoreAvailableLocationsAfterA0(warehouseId, disabledLocations)', + 'restoreAvailableLocationsAfterA0(warehouseId, isolation)', ]) + assert.match(entry, /page=\$\{pageNumber\}&size=\$\{A0_LOCATION_PAGE_SIZE\}/) + assert.match(entry, /A0_MAX_LOCATION_MUTATIONS = 8/) + assert.match(entry, /A0 大仓保护:不批量停用普通库位/) + assert.match(entry, /remainingNormal\.length < isolation\.requiredSlots/) assert.match(entry, /\/locations\/\$\{encodeURIComponent\(location\.id\)\}\/status/) assert.match(entry, /\{ status: 'DISABLED', version: Number\(location\.version\) \}/) assert.match(entry, /\{ status: 'AVAILABLE', version: location\.version \}/) - assert.match(measurement, /phase3LoadingRequireSharedOverflow !== true/) assert.match(orders, /receivingWarehouseId: id\(master\.receiving\.id\)/) })