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',
|
'manual-loading-complete-fixture', 'manual-loading-complete-verify',
|
||||||
'coload-groups']
|
'coload-groups']
|
||||||
const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES])
|
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([
|
const MODULE_DEPENDENCIES = new Map([
|
||||||
['suppliers', ['merchant-qualification-fixture']],
|
['suppliers', ['merchant-qualification-fixture']],
|
||||||
@@ -1236,15 +1263,17 @@ export class AcceptanceRun {
|
|||||||
this.assert('phase3-loading-fixture', 'A0 隔离使用本轮订单收货仓', null,
|
this.assert('phase3-loading-fixture', 'A0 隔离使用本轮订单收货仓', null,
|
||||||
Boolean(warehouseId), `warehouseId=${warehouseId || 'missing'}`,
|
Boolean(warehouseId), `warehouseId=${warehouseId || 'missing'}`,
|
||||||
{ scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true })
|
{ scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true })
|
||||||
const disabledLocations = requireSharedOverflow
|
const requiredPalletCount = Math.max(1, Number(this.context?.waitSplitVariant?.palletCount || 1))
|
||||||
? await this.disableAvailableLocationsForA0(warehouseId) : []
|
const isolation = requireSharedOverflow
|
||||||
this.context.phase3LoadingRequireSharedOverflow = requireSharedOverflow
|
? await this.disableAvailableLocationsForA0(warehouseId, requiredPalletCount)
|
||||||
|
: { forced: false, disabled: [] }
|
||||||
|
this.context.phase3LoadingRequireSharedOverflow = isolation.forced === true
|
||||||
try {
|
try {
|
||||||
const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url))
|
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))
|
await this.executeModule('inbound-measurement-phase3-loading-fixture', () => measurement.run(this))
|
||||||
} finally {
|
} finally {
|
||||||
if (requireSharedOverflow) {
|
if (requireSharedOverflow) {
|
||||||
await this.restoreAvailableLocationsAfterA0(warehouseId, disabledLocations)
|
await this.restoreAvailableLocationsAfterA0(warehouseId, isolation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const fixture = this.createdResources.inboundMeasurement || this.createdResources.orders
|
const fixture = this.createdResources.inboundMeasurement || this.createdResources.orders
|
||||||
@@ -1430,24 +1459,60 @@ export class AcceptanceRun {
|
|||||||
{ scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true })
|
{ 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 module = 'phase3-loading-fixture'
|
||||||
const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true }
|
const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true }
|
||||||
if (!warehouseId) {
|
if (!warehouseId) {
|
||||||
this.assert(module, 'A0 隔离收货仓 ID 完整', null, false, 'warehouseId=missing', options)
|
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 disabled = []
|
||||||
const skippedOccupied = []
|
const skippedOccupied = []
|
||||||
for (const location of locations) {
|
for (const location of isolation.targets) {
|
||||||
const response = await this.request('PATCH',
|
const response = await this.request('PATCH',
|
||||||
`/api/platform/v1/locations/${encodeURIComponent(location.id)}/status?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`,
|
`/api/platform/v1/locations/${encodeURIComponent(location.id)}/status?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`,
|
||||||
{ status: 'DISABLED', version: Number(location.version) })
|
{ status: 'DISABLED', version: Number(location.version) })
|
||||||
@@ -1462,16 +1527,19 @@ export class AcceptanceRun {
|
|||||||
}
|
}
|
||||||
const availableResponse = await this.request('GET',
|
const availableResponse = await this.request('GET',
|
||||||
`/api/platform/v1/locations/available?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&limit=200`)
|
`/api/platform/v1/locations/available?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&limit=200`)
|
||||||
const available = this.apiSuccess(module, '停用后回查收货仓无可用普通库位', availableResponse, '', options)
|
const available = this.apiSuccess(module, '停用后回查收货仓剩余普通库位', availableResponse, '', options)
|
||||||
this.assert(module, '测量前 A0 溢出条件成立', availableResponse,
|
const remainingNormal = Array.isArray(available)
|
||||||
Array.isArray(available) && available.length === 0,
|
? available.filter((item) => item?.sharedOverflow !== true) : null
|
||||||
`disabled=${disabled.length}, skippedOccupied=${skippedOccupied.map((item) => `${item.code}:${item.id}`).join('|') || 'none'}, available=${Array.isArray(available) ? available.length : 'missing'}`, options)
|
const forced = Array.isArray(remainingNormal) && remainingNormal.length < isolation.requiredSlots
|
||||||
return disabled
|
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 module = 'phase3-loading-fixture'
|
||||||
const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true }
|
const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true }
|
||||||
|
const disabledLocations = Array.isArray(result?.disabled) ? result.disabled : []
|
||||||
for (const location of disabledLocations) {
|
for (const location of disabledLocations) {
|
||||||
const response = await this.request('PATCH',
|
const response = await this.request('PATCH',
|
||||||
`/api/platform/v1/locations/${encodeURIComponent(location.id)}/status?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`,
|
`/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)
|
`locationId=${location.id}, restoreVersion=${location.version}`, options)
|
||||||
}
|
}
|
||||||
this.createdResources.phase3LoadingA0Isolation = {
|
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/)
|
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 entry = source('../admin-test-env-acceptance.mjs')
|
||||||
const measurement = source('./inbound-measurement.mjs')
|
|
||||||
const orders = source('./orders.mjs')
|
const orders = source('./orders.mjs')
|
||||||
assertOrder(entry, [
|
assertOrder(entry, [
|
||||||
'disableAvailableLocationsForA0(warehouseId)',
|
'disableAvailableLocationsForA0(warehouseId, requiredPalletCount)',
|
||||||
'this.context.phase3LoadingRequireSharedOverflow = true',
|
'this.context.phase3LoadingRequireSharedOverflow = isolation.forced === true',
|
||||||
"executeModule('inbound-measurement-phase3-loading-fixture'",
|
"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, /\/locations\/\$\{encodeURIComponent\(location\.id\)\}\/status/)
|
||||||
assert.match(entry, /\{ status: 'DISABLED', version: Number\(location\.version\) \}/)
|
assert.match(entry, /\{ status: 'DISABLED', version: Number\(location\.version\) \}/)
|
||||||
assert.match(entry, /\{ status: 'AVAILABLE', version: location\.version \}/)
|
assert.match(entry, /\{ status: 'AVAILABLE', version: location\.version \}/)
|
||||||
assert.match(measurement, /phase3LoadingRequireSharedOverflow !== true/)
|
|
||||||
assert.match(orders, /receivingWarehouseId: id\(master\.receiving\.id\)/)
|
assert.match(orders, /receivingWarehouseId: id\(master\.receiving\.id\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user