fix: bind overseas receiving to shared warehouse

This commit is contained in:
2026-08-24 21:46:19 +08:00
parent 3607bebbfd
commit 8cc4c6f6da
61 changed files with 774 additions and 118 deletions
@@ -17,9 +17,13 @@ test('default full acceptance includes the retained manual state matrix after li
test('focused matrix execution injects real container lifecycle prerequisites and removes its internal group', () => {
const expected = [
'merchant-qualification-fixture',
'orders',
'inbound-measurement',
'phase3-container-loading',
'phase3-loading',
'ocean-bookings',
'phase3-pda-convergence',
'phase3-evidence-gate',
'phase3-loading-finalize',
'phase3-customs',
'manual-state-matrix',
@@ -33,9 +37,13 @@ test('focused matrix execution injects real container lifecycle prerequisites an
test('focused splitting and manual groups resolve producers before consumers without duplicate internal runs', () => {
const expected = [
'merchant-qualification-fixture',
'orders',
'inbound-measurement',
'phase3-container-loading',
'phase3-loading',
'ocean-bookings',
'phase3-pda-convergence',
'phase3-evidence-gate',
'phase3-loading-finalize',
'phase3-order-splitting',
'phase3-customs',
@@ -13,3 +13,12 @@ test('phase3 loading fixture identifiers are deterministic and distinct across m
assert.match(primary, /^\d{8}-[0-9A-F]{8}$/)
assert.ok(`P3B-${primary}`.length <= 64)
})
test('phase3 loading uses the collision-safe marker suffix for inventory mutation locations', async () => {
const source = await import('node:fs/promises').then((fs) => fs.readFile(
new URL('./phase3-loading.mjs', import.meta.url), 'utf8'))
assert.match(source, /const mutationSuffix = markerSuffix\(context\.marker\)/)
assert.doesNotMatch(source, /mutationSuffix = context\.marker\.replace\(\/\\D\/g/)
assert.match(source, /if \(!targetLocationId\)[\s\S]+库存变化fixture创建失败后停止旧snapshot提交[\s\S]+return/)
assert.match(source, /if \(!reboundValid\)[\s\S]+库存换绑未成功后停止旧snapshot提交[\s\S]+return/)
})
@@ -635,7 +635,7 @@ export async function run(context) {
const overflowResource = inventoryInbound?.resources?.find?.((resource) =>
resource?.sharedOverflow === true && id(resource?.occupancyId))
if (overflowResource) {
const mutationSuffix = context.marker.replace(/\D/g, '').slice(-10)
const mutationSuffix = markerSuffix(context.marker)
const targetLocationResponse = await context.request('POST', '/api/platform/v1/locations?tenantId=0', {
warehouseId, zoneCode: `SNAP-${mutationSuffix.slice(-6)}`, zoneName: '库存快照验收区',
locationCode: `SNAP-${mutationSuffix}`, locationName: '库存快照换绑库位',
@@ -644,6 +644,11 @@ export async function run(context) {
})
const targetLocationId = id(context.apiSuccess(MODULE, '创建库存快照变化目标库位',
targetLocationResponse, '', OPTIONS))
if (!targetLocationId) {
context.addStep(MODULE, '库存变化fixture创建失败后停止旧snapshot提交', targetLocationResponse,
false, responseEvidence(targetLocationResponse), OPTIONS)
return
}
if (targetLocationId) {
inventoryMutationCleanup = {
cleaned: false, targetLocationId,
@@ -664,11 +669,17 @@ export async function run(context) {
})
: targetLocationResponse
const rebound = context.apiSuccess(MODULE, '通过公开 A0 换绑接口制造真实库存变化', rebindResponse, '', OPTIONS)
const reboundValid = id(rebound?.occupancyId) === id(overflowResource.occupancyId)
&& id(rebound?.previousLocationId) === id(overflowResource.locationId)
&& id(rebound?.locationId) === targetLocationId
context.assert(MODULE, '真实库存换绑保持同仓且位置发生变化', rebindResponse,
id(rebound?.occupancyId) === id(overflowResource.occupancyId)
&& id(rebound?.previousLocationId) === id(overflowResource.locationId)
&& id(rebound?.locationId) === targetLocationId,
reboundValid,
`occupancyId=${id(rebound?.occupancyId) || 'missing'}, previousLocationId=${id(rebound?.previousLocationId) || 'missing'}, locationId=${id(rebound?.locationId) || 'missing'}`, OPTIONS)
if (!reboundValid) {
context.addStep(MODULE, '库存换绑未成功后停止旧snapshot提交', rebindResponse,
false, responseEvidence(rebindResponse), OPTIONS)
return
}
const staleInventoryBatch = await request(context, 'POST', batchPath, batchPayload,
{ 'Idempotency-Key': `${context.marker}-bridge-batch-stale-inventory`.slice(0, 128) })
@@ -522,9 +522,11 @@ test('default acceptance order finalizes LOADED before customs reads fulfillment
test('container overview always follows phase3 loading in default and custom module selections', () => {
const reordered = normalizeModuleOrder(['phase3-container-overview', 'orders', 'phase3-loading', 'finance'])
assert.deepEqual(reordered, ['orders', 'phase3-loading', 'phase3-container-overview', 'finance'])
assert.deepEqual(reordered, ['merchant-qualification-fixture', 'orders', 'inbound-measurement',
'phase3-container-loading', 'phase3-loading', 'phase3-container-overview', 'finance'])
assert.deepEqual(parseArguments(['--modules', 'phase3-container-overview,phase3-loading']).modules,
['phase3-loading', 'phase3-container-overview'])
['merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading',
'phase3-loading', 'phase3-container-overview'])
const entry = source('../admin-test-env-acceptance.mjs')
assert.match(entry, /const DEFAULT_MODULES = normalizeModuleOrder\(CONFIGURED_MODULES\)/)
@@ -61,8 +61,9 @@ export async function run(context) {
`permissionCount=${tallyCodes.size}`, OPTIONS)
context.assert(MODULE, '仓库系统角色不包含任何客户侧权限', rolesResponse,
[...operatorCodes, ...pickerCodes, ...tallyCodes].every((code) => code.startsWith('platform:')),
'all warehouse role permissions must use platform scope', OPTIONS)
[...operatorCodes, ...pickerCodes, ...tallyCodes]
.every((code) => code.startsWith('platform:') || code === 'lastleg:pda:operate'),
'warehouse roles may use platform:* plus the single overseas service authority lastleg:pda:operate', OPTIONS)
const unauthorized = await context.request('GET',
'/api/platform/v1/first-leg/pda-loading/tasks?cabinetNo=UNAUTHORIZED&warehouseId=1', undefined, '')
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'
import { EXCEPTION_CODES, NODE_SEQUENCES, QUERY_STATUSES, STANDARD_NODES,
manualNodePayload, markerOwnedCarrierQuery, run } from './phase4-carrier-nodes.mjs'
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'phase4-carrier-nodes.mjs'), 'utf8')
@@ -24,6 +25,18 @@ function conflict(requestId) {
}
function clone(value) { return structuredClone(value) }
test('focused carrier node execution injects the complete outbound-confirm producer chain', () => {
const modules = normalizeModuleOrder(['phase4-carrier-nodes'])
const required = ['merchant-qualification-fixture', 'orders', 'inbound-measurement',
'phase3-container-loading', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate',
'phase3-loading-finalize', 'phase3-customs', 'phase3-outbound-dispatch',
'phase3-carrier-schedule-queries', 'phase4-carrier-nodes']
for (const module of required) assert.ok(modules.includes(module), module)
for (let index = 1; index < required.length; index += 1) {
assert.ok(modules.indexOf(required[index - 1]) < modules.indexOf(required[index]), required[index])
}
})
test('canonical states, ten ordered nodes, exception codes and complete payload are frozen', () => {
assert.deepEqual(QUERY_STATUSES, ['READY', 'MANUAL_REQUIRED', 'STOPPED'])
assert.deepEqual(STANDARD_NODES, [
@@ -17,6 +17,7 @@ function nullableText(value) { return value == null || typeof value === 'string'
function nullableId(value) { return value == null || validLongId(value) }
function nullableIso(value) { return value == null || (typeof value === 'string' && !Number.isNaN(Date.parse(value))) }
function validLongId(value) { return typeof value === 'string' && /^\d{16,}$/.test(value) }
function validNumericId(value) { return typeof value === 'string' && /^\d+$/.test(value) && value !== '0' }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
function rejected(response) { return response?.http === 200 && ['A0409', '409'].includes(codeOf(response)) }
function localIso(value) { return value.toISOString().slice(0, 19) }
@@ -37,7 +38,7 @@ export function manualNodePayload({ expectedVersion, standardNode, observedAt, e
}
function validTask(task) {
return validLongId(task?.id) && Boolean(task?.queryNo) && validLongId(task?.customerId)
return validLongId(task?.id) && Boolean(task?.queryNo) && validNumericId(task?.customerId)
&& validLongId(task?.oceanBookingId) && QUERY_STATUSES.includes(task?.status)
&& nullableId(task?.containerBookingId) && nullableText(task?.latestActualNode)
&& (task?.latestActualNode == null || STANDARD_NODES.includes(task.latestActualNode))
@@ -77,6 +77,12 @@ test('cross-region normalization keeps existing producers ahead of every phase-t
assert.deepEqual(normalizeModuleOrder(modules), modules)
})
test('phase-four producer variants restore the primary orchestration fixture after each matrix branch', async () => {
const source = await import('node:fs/promises').then((fs) => fs.readFile(
new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8'))
assert.match(source, /const createdKeys = \['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures'\]/)
})
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',
@@ -36,7 +36,12 @@ test('PDA module covers the container-number receiving contract and excludes out
assert.match(source, /重复箱新键被拒绝/)
assert.match(source, /模式硬拒绝/)
assert.match(source, /current返回404正常空态/)
assert.match(source, /安全扫描被拒绝/)
assert.match(source, /安全扫描返回A0400/)
assert.match(source, /customerId: 0/)
assert.match(source, /overseasWarehouseId, password/)
assert.match(source, /inbound\?\.overseasWarehouseId/)
assert.doesNotMatch(source, /const warehouseId = id\(inbound\?\.destinationWarehouseId\)/)
assert.match(source, /目的仓 .* 不得代替物理仓/)
assert.match(source, /retained as acceptance evidence/)
})
@@ -81,7 +86,8 @@ test('empty candidate response cannot become a zero-business-request pass', asyn
{ 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',
membershipId: String(9000 + userSequence), username: body.username, tenantId: '0', roleIds: [roleId],
overseasWarehouseId: String(body.overseasWarehouseId), 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)
@@ -89,6 +95,7 @@ test('empty candidate response cannot become a zero-business-request pass', asyn
},
async requestOverseas(method, requestPath, body, headers, token) {
calls.push({ channel: 'overseas', method, requestPath })
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('900', requestPath, method)
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)
@@ -46,32 +46,40 @@ async function loginMerchantForNegativeProbe(context) {
return nonBlank(data?.overseasAccessToken) ? data.overseasAccessToken : null
}
async function createPicker(context, index) {
async function resolvePickerAccess(context) {
const rolesResponse = await context.request('GET', '/api/platform/v1/iam/roles')
const roles = context.apiSuccess(MODULE, `查询海外PDA仓库操作员角色${index}`, rolesResponse, '', OPTIONS)
const roles = context.apiSuccess(MODULE, '查询海外PDA仓库操作员角色', 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 permissions = context.apiSuccess(MODULE, '查询海外PDA权限资源', 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,
context.assert(MODULE, 'WAREHOUSE_PICKER精确具备海外PDA权限', 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
}
return { role, operate }
}
async function createPicker(context, index, overseasWarehouseId, access) {
const role = access.role
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' }
email: `${username}@example.com`, tenantId: '0', roleIds: [id(role.id)],
overseasWarehouseId, 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)
longId(membershipId) && id(created?.tenantId) === '0'
&& id(created?.overseasWarehouseId) === id(overseasWarehouseId)
&& (created?.roleIds || []).map(id).includes(id(role.id)),
`membershipId=${membershipId || 'missing'}, tenantId=${id(created?.tenantId) || 'missing'}, overseasWarehouseId=${id(created?.overseasWarehouseId) || '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
@@ -88,7 +96,7 @@ async function disablePicker(context, picker, index) {
}
async function createPallet(context, inbound, index) {
const warehouseId = id(inbound?.destinationWarehouseId)
const warehouseId = id(inbound?.overseasWarehouseId)
if (!longId(warehouseId)) return null
const suffix = markerSuffix(context)
const location = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, {
@@ -114,7 +122,8 @@ async function uploadPhotos(context, token) {
const malicious = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', {
name: `${context.marker}-forged.png`, type: 'image/png', content: Uint8Array.from(Buffer.from('<script>alert(1)</script>')),
}, token)
context.addStep(MODULE, '伪造图片内容安全扫描被拒绝', malicious, !success(malicious) && traced(malicious), trace(malicious), OPTIONS)
context.addStep(MODULE, '伪造图片内容安全扫描返回A0400', malicious,
malicious?.http === 200 && code(malicious) === 'A0400' && traced(malicious), trace(malicious), OPTIONS)
const photos = []
for (const role of PHOTO_ROLES) {
const response = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', {
@@ -265,7 +274,7 @@ async function runReceiving(context, token, secondaryToken, merchantToken) {
const pallet = await createPallet(context, inbound, index)
if (!pallet) {
blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}实物托盘`,
`无法在目的仓 ${inbound.destinationWarehouseId || 'missing'} 创建可解析真实托盘`, 'warehouse-resource')
`无法在实际海外收货仓 ${inbound.overseasWarehouseId || 'missing'} 创建可解析真实托盘;目的仓 ${inbound.destinationWarehouseId || 'missing'} 不得代替物理仓`, 'warehouse-resource')
continue
}
pallets.set(inbound.inboundCode, pallet)
@@ -313,11 +322,21 @@ 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',
overseasWarehouseId: null, overseasWarehouseName: null,
cleanup: 'Shared warehouse, 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
const pickerAccess = await resolvePickerAccess(context)
if (!pickerAccess) return
const sharedWarehouse = await createSharedWarehouse(context)
if (!sharedWarehouse) {
blockerGap(context, '海外PDA共享实际收货仓', '无法通过平台正式API创建customerId=0共享物理仓')
return
}
context.createdResources.phase4OverseasPda.overseasWarehouseId = sharedWarehouse.id
context.createdResources.phase4OverseasPda.overseasWarehouseName = sharedWarehouse.name
const primary = await createPicker(context, 1, sharedWarehouse.id, pickerAccess)
const secondary = primary?.token ? await createPicker(context, 2, sharedWarehouse.id, pickerAccess) : null
for (const picker of [primary, secondary]) if (picker?.membershipId) {
context.createdResources.phase4OverseasPda.pickerMembershipIds.push(picker.membershipId)
}
@@ -332,3 +351,22 @@ export async function run(context) {
await disablePicker(context, primary, 1)
}
}
async function createSharedWarehouse(context) {
const suffix = markerSuffix(context)
const payload = {
customerId: 0, warehouseCode: `PDA-${suffix}`.slice(0, 50),
warehouseName: `PDA共享收货仓-${suffix}`.slice(0, 120), warehouseType: 'OVERSEAS',
countryCode: 'US', timezone: 'America/Los_Angeles', contactName: 'PDA Acceptance',
contactPhone: '+1-213-555-0198', contactEmail: `pda-${suffix.toLowerCase()}@example.com`,
status: 'ACTIVE', remark: `${context.marker}-shared-receiving`,
address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'CA', city: 'Los Angeles',
district: 'Commerce', postalCode: '90040', addressLine1: '1 Acceptance Logistics Ave' },
capabilities: [{ capabilityCode: 'RECEIVING', capabilityName: '收货', enabled: true }],
}
const created = await context.requestOverseas('POST', '/api/platform/v1/warehouse-admin', payload)
const warehouseId = id(created?.body?.data)
context.addStep(MODULE, '创建本轮PDA共享实际收货仓', created,
success(created) && longId(warehouseId), `warehouseId=${warehouseId || 'missing'}; ${trace(created)}`, OPTIONS)
return longId(warehouseId) ? { id: warehouseId, name: payload.warehouseName } : null
}