fix: close phase4 overseas acceptance gaps
This commit is contained in:
@@ -315,7 +315,8 @@ function assertCargoContract(context, response, cargoes, payload) {
|
||||
&& Number(actual?.productLengthCm) === expected.productLengthCm
|
||||
&& Number(actual?.productWidthCm) === expected.productWidthCm
|
||||
&& Number(actual?.productHeightCm) === expected.productHeightCm
|
||||
&& actual?.shipmentId === expected.shipmentId && actual?.referenceId === expected.referenceId
|
||||
&& (actual?.shipmentId ?? null) === (expected.shipmentId ?? null)
|
||||
&& (actual?.referenceId ?? null) === (expected.referenceId ?? null)
|
||||
&& actual?.upcNo === expected.upcNo && actual?.cargoRemark.includes(context.marker)
|
||||
})
|
||||
context.assert(MODULE, '四箱型产品字段及图片契约完整回显', response, complete,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { markerSuffix } from './phase3-loading.mjs'
|
||||
|
||||
test('phase3 loading fixture identifiers are deterministic and distinct across marker variants', () => {
|
||||
const base = 'ADMIN-API-20260824123024536'
|
||||
const primary = markerSuffix(`${base}-P4-OVERSEAS-PRIMARY`)
|
||||
const secondary = markerSuffix(`${base}-P4-SECONDARY`)
|
||||
|
||||
assert.equal(primary, markerSuffix(`${base}-P4-OVERSEAS-PRIMARY`))
|
||||
assert.notEqual(primary, secondary)
|
||||
assert.match(primary, /^\d{8}-[0-9A-F]{8}$/)
|
||||
assert.ok(`P3B-${primary}`.length <= 64)
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
const MODULE = 'phase3-loading'
|
||||
const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true }
|
||||
@@ -6,6 +7,12 @@ const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin'
|
||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
export function markerSuffix(marker) {
|
||||
const value = String(marker || '')
|
||||
const digits = value.replace(/\D/g, '').slice(-8).padStart(8, '0')
|
||||
const digest = createHash('sha256').update(value).digest('hex').slice(0, 8).toUpperCase()
|
||||
return `${digits}-${digest}`
|
||||
}
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
function responseEvidence(response) {
|
||||
return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
|
||||
@@ -119,7 +126,7 @@ export async function run(context) {
|
||||
if (initialStatus !== 'WAIT_SPLIT' || !Number.isInteger(initialVersion)) return
|
||||
|
||||
const base = `/api/platform/v1/inbounds/${inboundId}`
|
||||
const suffix = context.marker.replace(/\D/g, '').slice(-10)
|
||||
const suffix = markerSuffix(context.marker)
|
||||
const warehouseId = id(initial?.summary?.warehouseId)
|
||||
const containerNo = `P3B-${suffix}`.slice(0, 64)
|
||||
const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading'
|
||||
|
||||
@@ -33,10 +33,11 @@ export async function run(context) {
|
||||
const roleCodes = (roleCode) => new Set((warehouseRoles.get(roleCode)?.permissionIds || [])
|
||||
.map((permissionId) => permissionById.get(id(permissionId))).filter(Boolean))
|
||||
const pickerCodes = roleCodes('WAREHOUSE_PICKER')
|
||||
context.assert(MODULE, '拣货员只具备PDA查询、作业和现场拍照权限', rolesResponse,
|
||||
pickerCodes.size === 2
|
||||
context.assert(MODULE, '拣货员只具备国内PDA查询作业与海外PDA入仓操作权限', rolesResponse,
|
||||
pickerCodes.size === 3
|
||||
&& pickerCodes.has('platform:firstleg:pda:view')
|
||||
&& pickerCodes.has('platform:firstleg:pda:work'),
|
||||
&& pickerCodes.has('platform:firstleg:pda:work')
|
||||
&& pickerCodes.has('lastleg:pda:operate'),
|
||||
`permissions=${[...pickerCodes].sort().join(',')}`, OPTIONS)
|
||||
|
||||
const operatorCodes = roleCodes('WAREHOUSE_OPERATOR')
|
||||
|
||||
@@ -58,6 +58,25 @@ test('entry orders marker-owned prerequisites before cross-region acceptance', (
|
||||
}
|
||||
})
|
||||
|
||||
test('cross-region normalization keeps existing producers ahead of every phase-three consumer', () => {
|
||||
const requested = [
|
||||
'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading',
|
||||
'phase3-loading-model-bridge', 'phase3-loading', 'ocean-bookings', 'phase3-loading-finalize',
|
||||
'phase3-customs', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate',
|
||||
'phase4-overseas-pda', 'phase4-cross-region-events',
|
||||
]
|
||||
const modules = normalizeModuleOrder(requested)
|
||||
for (const [producer, consumer] of [
|
||||
['merchant-qualification-fixture', 'orders'], ['orders', 'inbound-measurement'],
|
||||
['inbound-measurement', 'phase3-container-loading'], ['phase3-container-loading', 'phase3-loading'],
|
||||
['phase3-loading', 'phase3-loading-model-bridge'], ['phase3-loading', 'ocean-bookings'],
|
||||
['phase3-loading-finalize', 'phase3-customs'], ['phase3-customs', 'phase3-outbound-dispatch'],
|
||||
['phase3-outbound-dispatch', 'phase3-outbound-aggregate'],
|
||||
['phase4-overseas-pda', 'phase4-cross-region-events'],
|
||||
]) assert.ok(modules.indexOf(producer) < modules.indexOf(consumer), `${producer} -> ${consumer}`)
|
||||
assert.deepEqual(normalizeModuleOrder(modules), modules)
|
||||
})
|
||||
|
||||
test('contract covers dual audience, canonical 403, both event directions and diagnostics', () => {
|
||||
for (const token of [
|
||||
'context.token)', 'context.overseasToken)', 'cn-overseas-event-not-delivered',
|
||||
|
||||
@@ -29,6 +29,9 @@ test('PDA module covers the container-number receiving contract and excludes out
|
||||
assert.match(source, /同键同载荷重放/)
|
||||
assert.match(source, /同键异载荷冲突/)
|
||||
assert.match(source, /跨柜切换锁返回409/)
|
||||
assert.match(source, /第二仓库操作员抢占同柜返回409/)
|
||||
assert.match(source, /WAREHOUSE_PICKER/)
|
||||
assert.match(source, /lastleg:pda:operate/)
|
||||
assert.match(source, /超收被拒绝/)
|
||||
assert.match(source, /重复箱新键被拒绝/)
|
||||
assert.match(source, /模式硬拒绝/)
|
||||
@@ -37,43 +40,60 @@ test('PDA module covers the container-number receiving contract and excludes out
|
||||
assert.match(source, /retained as acceptance evidence/)
|
||||
})
|
||||
|
||||
test('missing customer fixture is a deployment blocker and performs no HTTP request', async () => {
|
||||
test('missing WAREHOUSE_PICKER overseas authority is a deployment blocker', async () => {
|
||||
const calls = []; const gaps = []; const steps = []
|
||||
const context = {
|
||||
marker: 'PDA-CONTRACT', context: {}, createdResources: {},
|
||||
async request(...args) { calls.push(args); throw new Error('must not request without credentials') },
|
||||
async request(method, requestPath) {
|
||||
calls.push({ method, requestPath })
|
||||
return { method, path: requestPath, http: 200, body: { code: '200', data: [] },
|
||||
requestId: 'request-contract-1', traceId: 'trace-contract-0001' }
|
||||
},
|
||||
async requestOverseas(...args) { calls.push(args); throw new Error('must not request without credentials') },
|
||||
async requestOverseasMultipart(...args) { calls.push(args); throw new Error('must not request without credentials') },
|
||||
apiSuccess(module, name, response) { return response.body.data },
|
||||
addStep(...args) { steps.push(args) },
|
||||
assert(module, name, response, passed, evidence, options) { steps.push({ module, name, passed, evidence, options }) },
|
||||
addCoverageGap(...args) { gaps.push(args) },
|
||||
}
|
||||
await run(context)
|
||||
assert.equal(calls.length, 0)
|
||||
assert.equal(gaps.length, 1)
|
||||
assert.equal(gaps[0][5].deploymentBlocker, true)
|
||||
assert.equal(steps.length, 1)
|
||||
assert.equal(steps[0].passed, false)
|
||||
assert.equal(steps[0].options.deploymentBlocker, true)
|
||||
assert.ok(calls.some((call) => call.requestPath === '/api/platform/v1/iam/roles'))
|
||||
assert.ok(calls.some((call) => call.requestPath === '/api/platform/v1/iam/permissions'))
|
||||
assert.ok(gaps.some((gap) => gap[2] === '海外PDA仓库操作员权限'))
|
||||
assert.ok(steps.some((step) => step?.passed === false && step?.options?.deploymentBlocker))
|
||||
})
|
||||
|
||||
test('empty candidate response cannot become a zero-business-request pass', async () => {
|
||||
const calls = []; const gaps = []; const steps = []
|
||||
const response = (data, requestPath, method = 'GET') => ({ method, path: requestPath, http: 200,
|
||||
body: { code: '200', data }, requestId: 'req-contract', traceId: 'trace-contract' })
|
||||
let userSequence = 0
|
||||
const roleId = '4000005'; const permissionId = '5107015'
|
||||
const context = {
|
||||
marker: 'PDA-CONTRACT', overseasToken: '', createdResources: {},
|
||||
context: { merchantQualificationFixture: { tenantId: '9', username: 'merchant', password: 'generated' } },
|
||||
async request(method, requestPath) {
|
||||
marker: 'PDA-CONTRACT', overseasToken: '', createdResources: {}, context: {},
|
||||
async findAvailablePhone() { userSequence += 1; return `1750000000${userSequence}` },
|
||||
async request(method, requestPath, body) {
|
||||
calls.push({ channel: 'domestic', method, requestPath })
|
||||
return response({ overseasAccessToken: 'pda-token' }, requestPath, method)
|
||||
if (method === 'GET' && requestPath.endsWith('/roles')) return response([
|
||||
{ id: roleId, roleCode: 'WAREHOUSE_PICKER', status: 'ACTIVE', permissionIds: [permissionId] },
|
||||
], requestPath, method)
|
||||
if (method === 'GET' && requestPath.endsWith('/permissions')) return response([
|
||||
{ id: permissionId, permissionCode: 'lastleg:pda:operate' },
|
||||
], requestPath, method)
|
||||
if (method === 'POST' && requestPath.endsWith('/users')) return response({
|
||||
membershipId: String(9000 + userSequence), username: body.username, tenantId: '0', roleIds: [roleId], status: 'ACTIVE',
|
||||
}, requestPath, method)
|
||||
if (method === 'POST' && requestPath === '/api/auth/login') return response({ overseasAccessToken: `pda-token-${userSequence}` }, requestPath, method)
|
||||
if (method === 'PUT' && /\/users\/\d+\/status$/.test(requestPath)) return response({ status: 'DISABLED' }, requestPath, method)
|
||||
throw new Error(`unexpected domestic request ${method} ${requestPath}`)
|
||||
},
|
||||
async requestOverseas(method, requestPath) {
|
||||
async requestOverseas(method, requestPath, body, headers, token) {
|
||||
calls.push({ channel: 'overseas', method, requestPath })
|
||||
if (requestPath.endsWith('/current')) return { method, path: requestPath, http: 200,
|
||||
body: { code: 'A0404', message: 'not found' }, requestId: 'req-current', traceId: 'trace-current' }
|
||||
return response([], requestPath, method)
|
||||
},
|
||||
apiSuccess(module, name, result) { return result.body.data },
|
||||
addStep(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed, evidence, options }) },
|
||||
assert(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed, evidence, options }) },
|
||||
addCoverageGap(...args) { gaps.push(args) },
|
||||
@@ -84,4 +104,6 @@ test('empty candidate response cannot become a zero-business-request pass', asyn
|
||||
assert.ok(steps.some((step) => step.name.includes('PDA柜号候选覆盖前置') && !step.passed && step.options.deploymentBlocker))
|
||||
assert.equal(gaps.length, 1)
|
||||
assert.equal(context.createdResources.phase4OverseasPda.finalStatus, null)
|
||||
assert.equal(context.createdResources.phase4OverseasPda.pickerMembershipIds.length, 2)
|
||||
assert.equal(calls.filter((call) => /\/users\/\d+\/status$/.test(call.requestPath)).length, 2)
|
||||
})
|
||||
|
||||
@@ -35,20 +35,58 @@ async function expected(context, token, method, name, path, body, predicate) {
|
||||
return response
|
||||
}
|
||||
|
||||
async function loginPda(context) {
|
||||
async function loginMerchantForNegativeProbe(context) {
|
||||
const fixture = context.context?.merchantQualificationFixture
|
||||
if (!fixture?.username || !fixture?.password) {
|
||||
blockerGap(context, 'PDA客户账号', '缺少 merchant-qualification-fixture 生成的本轮客户账号;请保留默认全量前置模块')
|
||||
return null
|
||||
}
|
||||
if (!fixture?.username || !fixture?.password) return null
|
||||
const response = await context.request('POST', '/api/auth/login', { username: fixture.username, password: fixture.password }, '')
|
||||
const data = success(response) ? response.body?.data : null
|
||||
context.addStep(MODULE, '本轮客户登录并取得海外PDA令牌', response,
|
||||
context.addStep(MODULE, '本轮客户登录取得海外令牌用于PDA越权反证', response,
|
||||
success(response) && nonBlank(data?.overseasAccessToken),
|
||||
`overseasTokenPresent=${nonBlank(data?.overseasAccessToken)}; ${trace(response)}`, OPTIONS)
|
||||
return nonBlank(data?.overseasAccessToken) ? data.overseasAccessToken : null
|
||||
}
|
||||
|
||||
async function createPicker(context, index) {
|
||||
const rolesResponse = await context.request('GET', '/api/platform/v1/iam/roles')
|
||||
const roles = context.apiSuccess(MODULE, `查询海外PDA仓库操作员角色${index}`, rolesResponse, '', OPTIONS)
|
||||
const role = (Array.isArray(roles) ? roles : []).find((item) => item?.roleCode === 'WAREHOUSE_PICKER' && item?.status === 'ACTIVE')
|
||||
const permissionsResponse = await context.request('GET', '/api/platform/v1/iam/permissions')
|
||||
const permissions = context.apiSuccess(MODULE, `查询海外PDA权限资源${index}`, permissionsResponse, '', OPTIONS)
|
||||
const operate = (Array.isArray(permissions) ? permissions : []).find((item) => item?.permissionCode === 'lastleg:pda:operate')
|
||||
const assigned = Boolean(role && operate && (role.permissionIds || []).map(id).includes(id(operate.id)))
|
||||
context.assert(MODULE, `WAREHOUSE_PICKER精确具备海外PDA权限${index}`, rolesResponse, assigned,
|
||||
`roleId=${id(role?.id) || 'missing'}, permissionId=${id(operate?.id) || 'missing'}`, OPTIONS)
|
||||
if (!assigned) {
|
||||
blockerGap(context, '海外PDA仓库操作员权限', 'WAREHOUSE_PICKER未获得lastleg:pda:operate;必须先执行最新国内统一增量SQL并部署IAM')
|
||||
return null
|
||||
}
|
||||
const suffix = markerSuffix(context)
|
||||
const username = `pda_overseas_${suffix.toLowerCase()}_${index}`.slice(0, 64)
|
||||
const password = `Pda${suffix.replace(/[^A-Za-z0-9]/g, '')}A${index}9`
|
||||
const phone = await context.findAvailablePhone(MODULE)
|
||||
const payload = { username, displayName: `海外PDA验收操作员${index}-${suffix}`, phone,
|
||||
email: `${username}@example.com`, tenantId: '0', roleIds: [id(role.id)], password, status: 'ACTIVE' }
|
||||
const createdResponse = await context.request('POST', '/api/platform/v1/iam/users', payload)
|
||||
const created = context.apiSuccess(MODULE, `创建海外PDA仓库操作员${index}`, createdResponse, '', OPTIONS)
|
||||
const membershipId = id(created?.membershipId)
|
||||
context.assert(MODULE, `海外PDA操作员${index}冻结平台范围与角色`, createdResponse,
|
||||
longId(membershipId) && id(created?.tenantId) === '0' && (created?.roleIds || []).map(id).includes(id(role.id)),
|
||||
`membershipId=${membershipId || 'missing'}, tenantId=${id(created?.tenantId) || 'missing'}`, OPTIONS)
|
||||
if (!longId(membershipId)) return null
|
||||
const login = await context.request('POST', '/api/auth/login', { username, password }, '')
|
||||
const token = success(login) ? login.body?.data?.overseasAccessToken : null
|
||||
context.addStep(MODULE, `海外PDA操作员${index}登录取得双区域海外令牌`, login, nonBlank(token),
|
||||
`overseasTokenPresent=${nonBlank(token)}; ${trace(login)}`, OPTIONS)
|
||||
return { membershipId, username, token: nonBlank(token) ? token : null }
|
||||
}
|
||||
|
||||
async function disablePicker(context, picker, index) {
|
||||
if (!longId(picker?.membershipId)) return
|
||||
const response = await context.request('PUT', `/api/platform/v1/iam/users/${picker.membershipId}/status`, { status: 'DISABLED' })
|
||||
const disabled = success(response) && response.body?.data?.status === 'DISABLED'
|
||||
context.addStep(MODULE, `finally停用海外PDA仓库操作员${index}`, response, disabled, trace(response), OPTIONS)
|
||||
}
|
||||
|
||||
async function createPallet(context, inbound, index) {
|
||||
const warehouseId = id(inbound?.destinationWarehouseId)
|
||||
if (!longId(warehouseId)) return null
|
||||
@@ -160,17 +198,14 @@ async function receiveOverseas(context, token, inbound, pallet) {
|
||||
return true
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
context.createdResources.phase4OverseasPda = {
|
||||
marker: context.marker, containerNo: null, customerId: context.context?.merchantQualificationFixture?.tenantId || null,
|
||||
palletIds: [], locationIds: [], finalStatus: null,
|
||||
cleanup: 'Pallets and locations used by completed inventory are retained as acceptance evidence',
|
||||
}
|
||||
const token = await loginPda(context)
|
||||
if (!token) return
|
||||
async function runReceiving(context, token, secondaryToken, merchantToken) {
|
||||
if (context.overseasToken) {
|
||||
const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken)
|
||||
context.addStep(MODULE, '平台海外令牌不能冒充客户PDA账号', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS)
|
||||
context.addStep(MODULE, '平台管理员海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS)
|
||||
}
|
||||
if (merchantToken) {
|
||||
const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, merchantToken)
|
||||
context.addStep(MODULE, '客户海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS)
|
||||
}
|
||||
const before = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token)
|
||||
const noCurrent = notFound(before)
|
||||
@@ -203,6 +238,11 @@ export async function run(context) {
|
||||
`/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, prepareBody)
|
||||
context.assert(MODULE, '选择柜号幂等结果一致', prepareReplay.response,
|
||||
success(prepareReplay.response) && prepareReplay.data?.containerNo === containerNo && prepareReplay.data?.status === 'WAIT_SIGN', trace(prepareReplay.response), OPTIONS)
|
||||
if (secondaryToken) {
|
||||
await expected(context, secondaryToken, 'POST', '第二仓库操作员抢占同柜返回409',
|
||||
`/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`,
|
||||
{ idempotencyKey: key(context, 'secondary-prepare') }, conflict)
|
||||
}
|
||||
const other = candidates.data.find((item) => item.containerNo !== containerNo)
|
||||
if (other) await expected(context, token, 'GET', '当前账号跨柜切换锁返回409',
|
||||
`/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(other.containerNo)}`, undefined, conflict)
|
||||
@@ -268,3 +308,27 @@ export async function run(context) {
|
||||
context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS)
|
||||
context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
context.createdResources.phase4OverseasPda = {
|
||||
marker: context.marker, containerNo: null, customerId: null,
|
||||
palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [],
|
||||
cleanup: 'Pallets and locations used by completed inventory are retained as acceptance evidence',
|
||||
}
|
||||
const merchantToken = await loginMerchantForNegativeProbe(context)
|
||||
const primary = await createPicker(context, 1)
|
||||
const secondary = primary?.token ? await createPicker(context, 2) : null
|
||||
for (const picker of [primary, secondary]) if (picker?.membershipId) {
|
||||
context.createdResources.phase4OverseasPda.pickerMembershipIds.push(picker.membershipId)
|
||||
}
|
||||
try {
|
||||
if (!primary?.token || !secondary?.token) {
|
||||
blockerGap(context, '海外PDA双操作员账号', '未能创建并登录两个WAREHOUSE_PICKER平台账号,无法验证全局一柜锁')
|
||||
return
|
||||
}
|
||||
await runReceiving(context, primary.token, secondary.token, merchantToken)
|
||||
} finally {
|
||||
await disablePicker(context, secondary, 2)
|
||||
await disablePicker(context, primary, 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'phase4-overseas-warehouse.mjs'), 'utf8')
|
||||
|
||||
function response(data, code = '200') { return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, requestId: 'request-test', traceId: 'trace-test' } }
|
||||
function voidResponse() { return { http: 200, body: { code: '200', message: 'success' }, requestId: 'request-test', traceId: 'trace-test' } }
|
||||
function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } }
|
||||
function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehouseRecords = [{ id: '20001', status: 'ACTIVE', warehouseCode: 'LAX', warehouseName: '洛杉矶仓' }] } = {}) {
|
||||
const calls = []; const steps = []; const gaps = []; let createdPallet = null; let createdLocation = null; let createdRelabel = null
|
||||
@@ -21,12 +22,12 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
|
||||
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001')
|
||||
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null)
|
||||
if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/locations')) { createdLocation = { id: '72002', customerId: '0', warehouseId: '20001', ...body }; return response('72002') }
|
||||
if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return response(null) }
|
||||
if (method === 'DELETE' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = null; return response(null) }
|
||||
if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return voidResponse() }
|
||||
if (method === 'DELETE' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = null; return voidResponse() }
|
||||
if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', version: 0, ...body }; return response('71001') }
|
||||
if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) {
|
||||
if (Number(body.version) !== Number(createdPallet.version)) return response(null, 'A0409')
|
||||
createdPallet = { ...createdPallet, ...body, version: createdPallet.version + 1 }; return response(null)
|
||||
createdPallet = { ...createdPallet, ...body, version: createdPallet.version + 1 }; return voidResponse()
|
||||
}
|
||||
if (method === 'POST' && requestPath.startsWith('/api/platform/v1/last-leg/relabel-applications?')) {
|
||||
if (createdRelabel && body.remark !== createdRelabel.remark) return response(null, 'A0409')
|
||||
@@ -51,7 +52,8 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
|
||||
if (requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '10001', status: 'ACTIVE' } })
|
||||
if (requestPath.endsWith('/warehouse-admin/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', zoneCode: 'A', locationCode: 'A-01', locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE' }]))
|
||||
if (requestPath.endsWith('/warehouse-admin/20001/locations/72001')) return response({ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', status: 'ACTIVE' })
|
||||
if (requestPath.endsWith('/warehouse-admin/20001/locations/72002')) return response(createdLocation)
|
||||
if (requestPath.endsWith('/warehouse-admin/20001/locations/72002')) return createdLocation == null
|
||||
? response(undefined, 'A0404') : response(createdLocation)
|
||||
if (requestPath.endsWith('/warehouse-admin/20001/pallets?page=1&size=20')) return response(page([{ id: '70001', customerId: '0', warehouseId: '20001', palletCode: 'PLT-1', status: 'IDLE' }]))
|
||||
if (requestPath.endsWith('/warehouse-admin/20001/pallets/70001')) return response({ id: '70001', customerId: '0', warehouseId: '20001' })
|
||||
if (requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) return response(createdPallet)
|
||||
@@ -86,6 +88,14 @@ test('既有仓库场景只写本轮库位托盘与换标生命周期', () => {
|
||||
assert.ok(writes.some((call) => call.path.includes('/warehouse-admin/20001/pallets')))
|
||||
assert.ok(writes.some((call) => call.path.includes('/relabel-applications')))
|
||||
assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/') || call.path.includes('/relabel-applications')))
|
||||
for (const name of ['编辑本轮公共库位', '删除本轮公共库位', '报废本轮公共托盘']) {
|
||||
const step = context.steps.find((item) => item.name === name)
|
||||
assert.ok(step?.passed, name)
|
||||
assert.equal(Object.hasOwn(step.result?.body || {}, 'data'), false, name)
|
||||
}
|
||||
const deletedReadback = context.steps.find((step) => step.name === '本轮公共库位已删除且回查不存在')
|
||||
assert.equal(deletedReadback?.result?.body?.code, 'A0404')
|
||||
assert.ok(deletedReadback?.passed)
|
||||
assert.ok(context.steps.every((step) => step.passed), context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,6 +55,13 @@ function assertPage(context, name, result) {
|
||||
function businessConflict(response) {
|
||||
return response?.http === 409 || (response?.http === 200 && String(response?.body?.code) === 'A0409')
|
||||
}
|
||||
function voidMutationSucceeded(result) {
|
||||
return result?.response?.http === 200 && String(result?.response?.body?.code) === '200'
|
||||
&& result.data == null
|
||||
}
|
||||
function businessNotFound(response) {
|
||||
return response?.http === 404 || (response?.http === 200 && String(response?.body?.code) === 'A0404')
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
context.createdResources.phase4OverseasWarehouse = { marker: context.marker, customerId: null, warehouseId: null, palletId: null, terminalStatus: null }
|
||||
@@ -216,7 +223,7 @@ async function verifyLocationLifecycle(context, warehouseId) {
|
||||
zoneCode: 'ACC', locationCode, locationType: 'STORAGE', maxLoadKg: 1200,
|
||||
status: 'ACTIVE', remark: `${context.marker}-edited`,
|
||||
})
|
||||
context.assert(MODULE, '公共库位编辑成功', updated.response, updated.data === null,
|
||||
context.assert(MODULE, '公共库位编辑成功', updated.response, voidMutationSucceeded(updated),
|
||||
`locationId=${locationId}; ${trace(updated.response)}`, OPTIONS)
|
||||
const after = await overseasGet(context, '回查公共库位编辑结果', detailPath)
|
||||
context.assert(MODULE, '公共库位编辑字段已生效', after.response,
|
||||
@@ -226,9 +233,12 @@ async function verifyLocationLifecycle(context, warehouseId) {
|
||||
if (locationId) {
|
||||
const removed = await overseasMutation(context, 'DELETE', '删除本轮公共库位',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`)
|
||||
context.createdResources.phase4OverseasWarehouse.locationTerminalStatus = removed.data === null ? 'DELETED' : null
|
||||
context.assert(MODULE, '本轮公共库位已删除收口', removed.response, removed.data === null,
|
||||
`locationId=${locationId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.locationTerminalStatus || 'missing'}; ${trace(removed.response)}`, OPTIONS)
|
||||
const deletedDetail = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`)
|
||||
const deleted = voidMutationSucceeded(removed) && businessNotFound(deletedDetail)
|
||||
context.createdResources.phase4OverseasWarehouse.locationTerminalStatus = deleted ? 'DELETED' : null
|
||||
context.assert(MODULE, '本轮公共库位已删除且回查不存在', deletedDetail, deleted,
|
||||
`locationId=${locationId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.locationTerminalStatus || 'missing'}; delete=${trace(removed.response)}, readback=${trace(deletedDetail)}`, OPTIONS)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -354,7 +364,7 @@ async function verifyPalletLifecycle(context, warehouseId) {
|
||||
palletCode, palletType: '木托盘', capacityCbm: 2.5, unitPrice: 1, status: 'IDLE',
|
||||
locationId: id(detail.data?.locationId) || undefined, remark: `${context.marker}-edited`, version: Number(detail.data?.version),
|
||||
})
|
||||
context.assert(MODULE, '公共托盘编辑成功', updated.response, updated.data === null,
|
||||
context.assert(MODULE, '公共托盘编辑成功', updated.response, voidMutationSucceeded(updated),
|
||||
`palletId=${palletId}; ${trace(updated.response)}`, OPTIONS)
|
||||
const after = await overseasGet(context, '回查公共托盘编辑结果', detailPath)
|
||||
context.assert(MODULE, '公共托盘编辑字段已生效', after.response,
|
||||
@@ -376,8 +386,8 @@ async function verifyPalletLifecycle(context, warehouseId) {
|
||||
'读取公共托盘报废前最新版本',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)).data?.version),
|
||||
})
|
||||
context.createdResources.phase4OverseasWarehouse.terminalStatus = retired.data === null ? 'SCRAPPED' : null
|
||||
context.assert(MODULE, '本轮公共托盘已报废收口', retired.response, retired.data === null,
|
||||
context.createdResources.phase4OverseasWarehouse.terminalStatus = voidMutationSucceeded(retired) ? 'SCRAPPED' : null
|
||||
context.assert(MODULE, '本轮公共托盘已报废收口', retired.response, voidMutationSucceeded(retired),
|
||||
`palletId=${palletId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.terminalStatus || 'missing'}; ${trace(retired.response)}`, OPTIONS)
|
||||
const retiredDetail = await overseasGet(context, '回查本轮公共托盘报废状态',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)
|
||||
|
||||
Reference in New Issue
Block a user