test: bound A0 location isolation writes
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
@@ -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\)/)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user