2026-08-24 15:38:28 +08:00
|
|
|
const MODULE = 'phase4-overseas-pda'
|
2026-08-24 19:58:35 +08:00
|
|
|
const SCENARIO = 'phase4-overseas-pda-container-receiving'
|
2026-08-24 15:38:28 +08:00
|
|
|
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
2026-08-24 19:58:35 +08:00
|
|
|
const PHOTO_ROLES = ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN']
|
|
|
|
|
const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'))
|
2026-08-24 15:38:28 +08:00
|
|
|
|
|
|
|
|
function id(value) { return value == null ? '' : String(value) }
|
|
|
|
|
function longId(value) { return /^\d+$/.test(id(value)) && id(value) !== '0' }
|
2026-08-24 16:40:25 +08:00
|
|
|
function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 }
|
2026-08-24 15:38:28 +08:00
|
|
|
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
|
2026-08-24 19:58:35 +08:00
|
|
|
function code(response) { return String(response?.body?.code ?? '') }
|
|
|
|
|
function traced(response) { return nonBlank(response?.requestId) && nonBlank(response?.traceId) }
|
|
|
|
|
function conflict(response) { return (response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response)))) && traced(response) }
|
|
|
|
|
function conflictMessage(response, pattern) { return conflict(response) && pattern.test(String(response?.body?.message || '')) }
|
|
|
|
|
function notFound(response) { return (response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(code(response)))) && traced(response) }
|
|
|
|
|
function forbidden(response) { return [401, 403].includes(response?.http) || (response?.http === 200 && ['A0401', 'A0403'].includes(code(response))) }
|
|
|
|
|
function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` }
|
2026-08-24 22:14:41 +08:00
|
|
|
function key(context, action, suffix = '') { return `${context.marker}-${context.pdaContainerSuffix || ''}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) }
|
|
|
|
|
function markerSuffix(context) { return `${context.marker}${context.pdaContainerSuffix || ''}`.replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() }
|
2026-08-24 23:35:26 +08:00
|
|
|
function records(data) { return Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : [] }
|
2026-08-24 15:38:28 +08:00
|
|
|
|
2026-08-24 19:58:35 +08:00
|
|
|
function blockerGap(context, name, reason, category = 'fixture-unavailable', response = null) {
|
|
|
|
|
context.addCoverageGap(MODULE, SCENARIO, name, reason, category, { deploymentBlocker: true })
|
|
|
|
|
context.assert(MODULE, `${name}覆盖前置`, response, false, reason, OPTIONS)
|
2026-08-24 15:38:28 +08:00
|
|
|
}
|
|
|
|
|
|
2026-08-24 19:58:35 +08:00
|
|
|
async function pda(context, token, method, name, path, body = undefined) {
|
|
|
|
|
const response = await context.requestOverseas(method, path, body, {}, token)
|
|
|
|
|
context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS)
|
|
|
|
|
return { response, data: success(response) ? response.body?.data : null }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function expected(context, token, method, name, path, body, predicate) {
|
|
|
|
|
const response = await context.requestOverseas(method, path, body, {}, token)
|
|
|
|
|
context.addStep(MODULE, name, response, predicate(response), trace(response), OPTIONS)
|
|
|
|
|
return response
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 23:35:26 +08:00
|
|
|
async function captureCompletedOrder(context, containerNo, expectedCustomerId) {
|
|
|
|
|
const response = await context.requestOverseas('GET',
|
|
|
|
|
`/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`)
|
|
|
|
|
const candidates = records(response?.body?.data)
|
|
|
|
|
.filter((item) => id(item?.containerNo) === id(containerNo))
|
|
|
|
|
const exactCustomer = expectedCustomerId == null ? candidates
|
|
|
|
|
: candidates.filter((item) => id(item?.customerId) === id(expectedCustomerId))
|
|
|
|
|
const order = exactCustomer[0] || candidates[0]
|
|
|
|
|
const mapped = success(response) && longId(order?.id) && nonBlank(id(order?.customerId))
|
|
|
|
|
context.assert(MODULE, '完成柜回查平台尾程订单并冻结跨区身份', response, mapped,
|
|
|
|
|
`containerNo=${containerNo}, orderId=${id(order?.id) || 'missing'}, customerId=${id(order?.customerId) || 'missing'}; ${trace(response)}`, OPTIONS)
|
|
|
|
|
if (mapped) {
|
|
|
|
|
context.createdResources.phase4OverseasPda.orderId = id(order.id)
|
|
|
|
|
context.createdResources.phase4OverseasPda.customerId = id(order.customerId)
|
2026-08-25 11:25:13 +08:00
|
|
|
const detailPath = `/api/platform/v1/last-leg/orders/${encodeURIComponent(id(order.id))}?customerId=${encodeURIComponent(id(order.customerId))}`
|
|
|
|
|
const detail = await context.requestOverseas('GET', detailPath)
|
|
|
|
|
context.assert(MODULE, '完成柜回查尾程订单货物明细供后续PDA作业', detail,
|
|
|
|
|
success(detail) && id(detail.body?.data?.order?.id) === id(order.id)
|
|
|
|
|
&& Array.isArray(detail.body?.data?.cargo) && detail.body.data.cargo.length > 0,
|
|
|
|
|
`orderId=${id(order.id)}, cargo=${detail.body?.data?.cargo?.length ?? 'missing'}; ${trace(detail)}`, OPTIONS)
|
|
|
|
|
return success(detail) ? detail.body?.data : null
|
2026-08-24 23:35:26 +08:00
|
|
|
}
|
2026-08-25 11:25:13 +08:00
|
|
|
return null
|
2026-08-24 23:35:26 +08:00
|
|
|
}
|
|
|
|
|
|
2026-08-24 21:00:22 +08:00
|
|
|
async function loginMerchantForNegativeProbe(context) {
|
2026-08-24 19:58:35 +08:00
|
|
|
const fixture = context.context?.merchantQualificationFixture
|
2026-08-24 21:00:22 +08:00
|
|
|
if (!fixture?.username || !fixture?.password) return null
|
2026-08-24 19:58:35 +08:00
|
|
|
const response = await context.request('POST', '/api/auth/login', { username: fixture.username, password: fixture.password }, '')
|
|
|
|
|
const data = success(response) ? response.body?.data : null
|
2026-08-24 21:00:22 +08:00
|
|
|
context.addStep(MODULE, '本轮客户登录取得海外令牌用于PDA越权反证', response,
|
2026-08-24 19:58:35 +08:00
|
|
|
success(response) && nonBlank(data?.overseasAccessToken),
|
|
|
|
|
`overseasTokenPresent=${nonBlank(data?.overseasAccessToken)}; ${trace(response)}`, OPTIONS)
|
|
|
|
|
return nonBlank(data?.overseasAccessToken) ? data.overseasAccessToken : null
|
2026-08-24 15:38:28 +08:00
|
|
|
}
|
|
|
|
|
|
2026-08-24 21:46:19 +08:00
|
|
|
async function resolvePickerAccess(context) {
|
2026-08-24 21:00:22 +08:00
|
|
|
const rolesResponse = await context.request('GET', '/api/platform/v1/iam/roles')
|
2026-08-24 21:46:19 +08:00
|
|
|
const roles = context.apiSuccess(MODULE, '查询海外PDA仓库操作员角色', rolesResponse, '', OPTIONS)
|
2026-08-24 21:00:22 +08:00
|
|
|
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')
|
2026-08-24 21:46:19 +08:00
|
|
|
const permissions = context.apiSuccess(MODULE, '查询海外PDA权限资源', permissionsResponse, '', OPTIONS)
|
2026-08-24 21:00:22 +08:00
|
|
|
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)))
|
2026-08-24 21:46:19 +08:00
|
|
|
context.assert(MODULE, 'WAREHOUSE_PICKER精确具备海外PDA权限', rolesResponse, assigned,
|
2026-08-24 21:00:22 +08:00
|
|
|
`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
|
|
|
|
|
}
|
2026-08-24 21:46:19 +08:00
|
|
|
return { role, operate }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function createPicker(context, index, overseasWarehouseId, access) {
|
|
|
|
|
const role = access.role
|
2026-08-24 21:00:22 +08:00
|
|
|
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,
|
2026-08-24 21:46:19 +08:00
|
|
|
email: `${username}@example.com`, tenantId: '0', roleIds: [id(role.id)],
|
|
|
|
|
overseasWarehouseId, password, status: 'ACTIVE' }
|
2026-08-24 21:00:22 +08:00
|
|
|
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,
|
2026-08-24 21:46:19 +08:00
|
|
|
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)
|
2026-08-24 21:00:22 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 19:58:35 +08:00
|
|
|
async function createPallet(context, inbound, index) {
|
2026-08-24 21:46:19 +08:00
|
|
|
const warehouseId = id(inbound?.overseasWarehouseId)
|
2026-08-24 19:58:35 +08:00
|
|
|
if (!longId(warehouseId)) return null
|
|
|
|
|
const suffix = markerSuffix(context)
|
2026-08-25 10:50:58 +08:00
|
|
|
const locationCode = `PDA-${suffix}-${index + 1}`.slice(0, 64)
|
2026-08-24 19:58:35 +08:00
|
|
|
const location = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, {
|
2026-08-25 10:50:58 +08:00
|
|
|
zoneCode: `PDA-${index + 1}`, locationCode,
|
2026-08-24 19:58:35 +08:00
|
|
|
locationType: 'STORAGE', maxLoadKg: 5000, status: 'IDLE', remark: `${context.marker}-receiving`,
|
2026-08-24 15:38:28 +08:00
|
|
|
})
|
2026-08-24 19:58:35 +08:00
|
|
|
context.addStep(MODULE, `创建当前柜实收库位${index + 1}`, location, success(location), trace(location), OPTIONS)
|
|
|
|
|
const locationId = id(location?.body?.data)
|
|
|
|
|
if (!longId(locationId)) return null
|
|
|
|
|
context.createdResources.phase4OverseasPda.locationIds.push(locationId)
|
|
|
|
|
const palletCode = `PDA-${suffix}-${index + 1}`.slice(0, 128)
|
|
|
|
|
const pallet = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, {
|
|
|
|
|
palletCode, palletType: '木托盘', capacityCbm: 10, unitPrice: 0, status: 'IDLE', locationId,
|
|
|
|
|
remark: `${context.marker}-receiving-retained`,
|
|
|
|
|
fbaWarehouseCode: inbound.destinationType === 'FBA' ? inbound.fbaWarehouseCode : null,
|
|
|
|
|
})
|
|
|
|
|
context.addStep(MODULE, `创建当前柜实收托盘${index + 1}`, pallet, success(pallet), trace(pallet), OPTIONS)
|
|
|
|
|
const palletId = id(pallet?.body?.data)
|
2026-08-25 10:50:58 +08:00
|
|
|
return longId(palletId) ? { warehouseId, locationId, locationCode, palletId, palletCode, retained: true } : null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function verifyWarehouseQueries(context, token, inboundCode, pallet) {
|
|
|
|
|
const inventory = await pda(context, token, 'POST', '完成入库后按入仓码现场查询库存',
|
|
|
|
|
'/api/pda/v1/last-leg/inventory/resolve', { code: inboundCode })
|
|
|
|
|
context.assert(MODULE, '现场库存查询只返回Token绑定仓且包含库存位置', inventory.response,
|
|
|
|
|
success(inventory.response) && inventory.data?.queryCode === inboundCode
|
|
|
|
|
&& Array.isArray(inventory.data?.items) && inventory.data.items.length > 0
|
|
|
|
|
&& inventory.data.items.every((item) => id(item?.palletId) === pallet.palletId
|
|
|
|
|
&& id(item?.locationId) === pallet.locationId && Number(item?.availablePackages) > 0),
|
|
|
|
|
`items=${inventory.data?.items?.length ?? 'missing'}; ${trace(inventory.response)}`, OPTIONS)
|
|
|
|
|
|
|
|
|
|
const location = await pda(context, token, 'POST', '完成入库后扫码查询库位',
|
|
|
|
|
'/api/pda/v1/last-leg/locations/resolve', { code: pallet.locationCode })
|
|
|
|
|
context.assert(MODULE, '库位查询返回当前仓真实占用汇总', location.response,
|
|
|
|
|
success(location.response) && id(location.data?.locationId) === pallet.locationId
|
|
|
|
|
&& location.data?.locationCode === pallet.locationCode
|
|
|
|
|
&& Number(location.data?.occupiedPackages) > 0 && Number(location.data?.inventoryPositionCount) > 0,
|
|
|
|
|
`locationId=${id(location.data?.locationId) || 'missing'}; ${trace(location.response)}`, OPTIONS)
|
|
|
|
|
|
|
|
|
|
const resolvedPallet = await pda(context, token, 'POST', '完成入库后扫码查询托盘内容',
|
|
|
|
|
'/api/pda/v1/last-leg/pallets/resolve', { code: pallet.palletCode })
|
|
|
|
|
context.assert(MODULE, '托盘查询返回当前位置与库存明细', resolvedPallet.response,
|
|
|
|
|
success(resolvedPallet.response) && id(resolvedPallet.data?.palletId) === pallet.palletId
|
|
|
|
|
&& id(resolvedPallet.data?.locationId) === pallet.locationId
|
|
|
|
|
&& Array.isArray(resolvedPallet.data?.inventory) && resolvedPallet.data.inventory.length > 0
|
|
|
|
|
&& Number(resolvedPallet.data?.occupiedPackages) > 0,
|
|
|
|
|
`palletId=${id(resolvedPallet.data?.palletId) || 'missing'}; ${trace(resolvedPallet.response)}`, OPTIONS)
|
|
|
|
|
|
|
|
|
|
const history = await pda(context, token, 'GET', '查询当前仓管PDA作业历史',
|
|
|
|
|
'/api/pda/v1/last-leg/operations?page=1&size=50')
|
|
|
|
|
const historyRows = records(history.data)
|
|
|
|
|
context.assert(MODULE, '作业历史包含本轮柜号收货动作且分页稳定', history.response,
|
|
|
|
|
success(history.response) && historyRows.length > 0
|
|
|
|
|
&& historyRows.some((item) => String(item?.operationType || '').startsWith('RECEIVING_'))
|
|
|
|
|
&& Number(history.data?.page) === 1 && Number(history.data?.size) === 50,
|
|
|
|
|
`records=${historyRows.length}; ${trace(history.response)}`, OPTIONS)
|
2026-08-25 11:25:13 +08:00
|
|
|
return success(inventory.response) && success(location.response) && success(resolvedPallet.response)
|
|
|
|
|
? { inboundCode, pallet: resolvedPallet.data, location: location.data, inventory: inventory.data?.items || [] }
|
|
|
|
|
: null
|
2026-08-24 15:38:28 +08:00
|
|
|
}
|
|
|
|
|
|
2026-08-24 19:58:35 +08:00
|
|
|
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)
|
2026-08-24 21:46:19 +08:00
|
|
|
context.addStep(MODULE, '伪造图片内容安全扫描返回A0400', malicious,
|
|
|
|
|
malicious?.http === 200 && code(malicious) === 'A0400' && traced(malicious), trace(malicious), OPTIONS)
|
2026-08-24 19:58:35 +08:00
|
|
|
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', {
|
|
|
|
|
name: `${context.marker}-${role.toLowerCase()}.png`, type: 'image/png', content: PNG,
|
|
|
|
|
}, token)
|
|
|
|
|
const fileId = id(response?.body?.data?.fileId)
|
|
|
|
|
context.addStep(MODULE, `上传${role}真实签收照片`, response, success(response) && longId(fileId),
|
|
|
|
|
`role=${role}, fileId=${fileId || 'missing'}; ${trace(response)}`, OPTIONS)
|
|
|
|
|
if (!longId(fileId)) return null
|
|
|
|
|
photos.push({ role, fileId })
|
2026-08-24 15:38:28 +08:00
|
|
|
}
|
2026-08-24 19:58:35 +08:00
|
|
|
return photos
|
2026-08-24 15:38:28 +08:00
|
|
|
}
|
|
|
|
|
|
2026-08-25 15:27:26 +08:00
|
|
|
async function awaitProducerConsumption(context, producers) {
|
2026-08-24 22:32:37 +08:00
|
|
|
const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))]
|
2026-08-25 15:27:26 +08:00
|
|
|
if (!expected.length) return true
|
|
|
|
|
const consumed = new Set()
|
|
|
|
|
const lastResponses = new Map()
|
|
|
|
|
for (let attempt = 1; attempt <= 30; attempt += 1) {
|
|
|
|
|
const missing = expected.filter((containerNo) => !consumed.has(containerNo))
|
|
|
|
|
const results = await Promise.all(missing.map(async (containerNo) => {
|
|
|
|
|
const response = await context.requestOverseas('GET',
|
|
|
|
|
`/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`)
|
|
|
|
|
return { containerNo, response }
|
|
|
|
|
}))
|
|
|
|
|
for (const { containerNo, response } of results) {
|
|
|
|
|
lastResponses.set(containerNo, response)
|
|
|
|
|
const rows = records(response?.body?.data)
|
|
|
|
|
if (success(response) && rows.some((item) => id(item?.containerNo) === containerNo)) {
|
|
|
|
|
consumed.add(containerNo)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (expected.every((containerNo) => consumed.has(containerNo))) break
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
|
|
|
}
|
|
|
|
|
for (const containerNo of expected) {
|
|
|
|
|
const response = lastResponses.get(containerNo) || null
|
|
|
|
|
context.addStep(MODULE, `跨区消费生成尾程订单 ${containerNo}`, response,
|
|
|
|
|
consumed.has(containerNo),
|
|
|
|
|
`containerNo=${containerNo}, consumed=${consumed.has(containerNo)}; ${trace(response)}`, OPTIONS)
|
|
|
|
|
}
|
|
|
|
|
return expected.every((containerNo) => consumed.has(containerNo))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function awaitPickupReadyCandidates(context, token, producers, allProducers) {
|
|
|
|
|
const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))]
|
|
|
|
|
const expectedSet = new Set(expected)
|
|
|
|
|
const awaiting = [...new Set(allProducers
|
|
|
|
|
.filter((item) => item?.shippingReady === false && !expectedSet.has(id(item?.containerNo)))
|
|
|
|
|
.map((item) => id(item?.containerNo)).filter(nonBlank))]
|
|
|
|
|
const known = new Set(allProducers.map((item) => id(item?.containerNo)).filter(nonBlank))
|
2026-08-24 22:32:37 +08:00
|
|
|
if (!expected.length) return false
|
|
|
|
|
let response = null
|
|
|
|
|
let seen = new Set()
|
|
|
|
|
for (let attempt = 1; attempt <= 30; attempt += 1) {
|
|
|
|
|
response = await context.requestOverseas('GET',
|
|
|
|
|
'/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, token)
|
2026-08-25 15:27:26 +08:00
|
|
|
const candidateRows = success(response) && Array.isArray(response.body?.data) ? response.body.data : []
|
|
|
|
|
seen = new Set(candidateRows.map((item) => id(item?.containerNo)).filter(nonBlank))
|
|
|
|
|
if (expected.every((containerNo) => seen.has(containerNo))
|
|
|
|
|
&& awaiting.every((containerNo) => !seen.has(containerNo))) break
|
2026-08-24 22:32:37 +08:00
|
|
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
|
|
|
}
|
2026-08-25 15:27:26 +08:00
|
|
|
const seenExpected = expected.filter((containerNo) => seen.has(containerNo))
|
|
|
|
|
const missingPickupReady = expected.filter((containerNo) => !seen.has(containerNo))
|
|
|
|
|
const unexpectedAwaiting = awaiting.filter((containerNo) => seen.has(containerNo))
|
|
|
|
|
const unexpectedHistorical = [...seen].filter((containerNo) => !known.has(containerNo))
|
|
|
|
|
const ready = missingPickupReady.length === 0 && unexpectedAwaiting.length === 0
|
|
|
|
|
context.addStep(MODULE, '实际处理柜全部达到PICKUP_READY并进入PDA候选', response, ready,
|
|
|
|
|
`expected=${expected.join('|')}, awaiting=${awaiting.join('|') || 'none'}, seenExpected=${seenExpected.join('|') || 'none'}, missingPickupReady=${missingPickupReady.join('|') || 'none'}, unexpectedAwaiting=${unexpectedAwaiting.join('|') || 'none'}, unexpectedHistorical=${unexpectedHistorical.join('|') || 'none'}; ${trace(response)}`, OPTIONS)
|
2026-08-24 22:32:37 +08:00
|
|
|
return ready
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-25 09:23:28 +08:00
|
|
|
function skuFixtures(context, producer) {
|
|
|
|
|
const frozenLines = Array.isArray(producer?.skuLines)
|
|
|
|
|
? producer.skuLines
|
|
|
|
|
.map((line) => ({ skuCode: id(line?.skuCode), packageCount: Number(line?.packageCount) }))
|
|
|
|
|
.filter((line) => nonBlank(line.skuCode) && Number.isInteger(line.packageCount) && line.packageCount > 0)
|
|
|
|
|
: []
|
|
|
|
|
if (frozenLines.length) return frozenLines
|
|
|
|
|
const frozen = Array.isArray(producer?.skuCodes)
|
|
|
|
|
? producer.skuCodes.map(id).filter(nonBlank)
|
|
|
|
|
: []
|
|
|
|
|
if (frozen.length) return [...new Set(frozen)].map((skuCode) => ({ skuCode, packageCount: 1 }))
|
2026-08-24 19:58:35 +08:00
|
|
|
const boxes = context.createdResources?.orders?.boxTypes
|
|
|
|
|
const orchestration = context.createdResources?.orchestrationFixtures || {}
|
|
|
|
|
const markers = [context.marker, orchestration.phase3Loading?.sourceMarker,
|
|
|
|
|
orchestration.phase3LoadingIncremental?.sourceMarker].filter(nonBlank)
|
|
|
|
|
return Array.isArray(boxes)
|
|
|
|
|
? [...new Set(markers.flatMap((marker) => boxes.map((boxType) => `SKU-${boxType}-${marker}`.slice(0, 64))))]
|
2026-08-25 09:23:28 +08:00
|
|
|
.map((skuCode) => ({ skuCode, packageCount: 1 }))
|
2026-08-24 19:58:35 +08:00
|
|
|
: []
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-25 09:23:28 +08:00
|
|
|
async function receiveFba(context, token, inbound, pallet, producer) {
|
|
|
|
|
const skus = skuFixtures(context, producer)
|
2026-08-24 19:58:35 +08:00
|
|
|
if (!skus.length) {
|
|
|
|
|
blockerGap(context, `FBA入仓码${inbound.inboundCode} SKU`, '本轮订单 fixture 未交接冻结 SKU,无法安全猜测实收 SKU')
|
|
|
|
|
return false
|
2026-08-24 15:38:28 +08:00
|
|
|
}
|
2026-08-24 19:58:35 +08:00
|
|
|
let accepted = 0
|
2026-08-25 09:23:28 +08:00
|
|
|
for (const [index, sku] of skus.entries()) {
|
|
|
|
|
const { skuCode, packageCount } = sku
|
2026-08-24 19:58:35 +08:00
|
|
|
const resolvedResponse = await context.requestOverseas('POST',
|
|
|
|
|
'/api/pda/v1/last-leg/receiving-containers/current/skus/resolve', { skuCode }, {}, token)
|
|
|
|
|
const resolvable = success(resolvedResponse) || notFound(resolvedResponse)
|
|
|
|
|
context.addStep(MODULE, `解析FBA SKU候选 ${index + 1}`, resolvedResponse, resolvable, trace(resolvedResponse), OPTIONS)
|
|
|
|
|
const resolved = { response: resolvedResponse, data: success(resolvedResponse) ? resolvedResponse.body?.data : null }
|
|
|
|
|
if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode || resolved.data?.destinationType !== 'FBA') continue
|
|
|
|
|
const body = { inboundCode: inbound.inboundCode, skuCode, palletCode: pallet.palletCode,
|
2026-08-25 09:27:45 +08:00
|
|
|
packageCount, idempotencyKey: key(context, `fba-bulk-${index}`) }
|
2026-08-24 19:58:35 +08:00
|
|
|
const first = await pda(context, token, 'POST', `FBA SKU ${skuCode}按箱数实收`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body)
|
|
|
|
|
if (!success(first.response)) return false
|
|
|
|
|
accepted += Number(first.data?.acceptedPackages || 0)
|
|
|
|
|
const replay = await pda(context, token, 'POST', `FBA SKU ${skuCode}同键同载荷重放`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body)
|
|
|
|
|
context.assert(MODULE, `FBA SKU ${skuCode}幂等结果一致`, replay.response,
|
|
|
|
|
success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS)
|
|
|
|
|
await expected(context, token, 'POST', `FBA SKU ${skuCode}同键异载荷冲突`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk',
|
|
|
|
|
{ ...body, packageCount: packageCount + 1 }, conflict)
|
|
|
|
|
await expected(context, token, 'POST', `FBA SKU ${skuCode}超收被拒绝`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk',
|
2026-08-25 09:27:45 +08:00
|
|
|
{ ...body, packageCount: Number(inbound.totalPackages) + 1, idempotencyKey: key(context, `fba-over-${index}`) }, conflict)
|
2026-08-24 19:58:35 +08:00
|
|
|
}
|
|
|
|
|
if (accepted <= 0) {
|
|
|
|
|
blockerGap(context, `FBA入仓码${inbound.inboundCode}实收`, `候选 SKU 均未唯一解析到当前入仓码;skuCount=${skus.length}`)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 23:28:24 +08:00
|
|
|
async function receiveOverseas(context, token, inbounds, pallets, producerCartonMarks = []) {
|
|
|
|
|
const cartonMarks = [...new Set((Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks])
|
|
|
|
|
.map(id).filter(nonBlank))]
|
|
|
|
|
if (!cartonMarks.length) {
|
|
|
|
|
blockerGap(context, 'OVERSEAS逐箱链', '缺少本轮PDA任务冻结箱唛,逐箱实收不能使用伪造箱号')
|
2026-08-24 19:58:35 +08:00
|
|
|
return false
|
|
|
|
|
}
|
2026-08-24 23:28:24 +08:00
|
|
|
for (const [index, cartonMark] of cartonMarks.entries()) {
|
|
|
|
|
const resolved = await pda(context, token, 'POST', `解析OVERSEAS冻结箱唛${index + 1}`,
|
|
|
|
|
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark })
|
|
|
|
|
const inbound = inbounds.find((item) => item.inboundCode === resolved.data?.inboundCode)
|
|
|
|
|
const pallet = inbound ? pallets.get(inbound.inboundCode) : null
|
|
|
|
|
if (!success(resolved.response) || !inbound || !pallet) {
|
|
|
|
|
blockerGap(context, `OVERSEAS箱唛${cartonMark}归属`,
|
|
|
|
|
`冻结箱唛未解析到带真实托盘的本柜入仓码;inboundCode=${resolved.data?.inboundCode || 'missing'}`,
|
|
|
|
|
'fixture-mismatch', resolved.response)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
const body = { cartonMark, palletCode: pallet.palletCode,
|
2026-08-25 09:27:45 +08:00
|
|
|
idempotencyKey: key(context, `overseas-carton-${index}`) }
|
2026-08-24 23:28:24 +08:00
|
|
|
const first = await pda(context, token, 'POST', `OVERSEAS逐箱实收${index + 1}`,
|
|
|
|
|
'/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body)
|
|
|
|
|
if (!success(first.response)) return false
|
|
|
|
|
const replay = await pda(context, token, 'POST', `OVERSEAS逐箱${index + 1}同键同载荷重放`,
|
|
|
|
|
'/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body)
|
|
|
|
|
context.assert(MODULE, `OVERSEAS逐箱${index + 1}幂等结果一致`, replay.response,
|
|
|
|
|
success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS)
|
|
|
|
|
await expected(context, token, 'POST', `OVERSEAS逐箱${index + 1}同键异载荷冲突`,
|
|
|
|
|
'/api/pda/v1/last-leg/receiving-containers/current/receipts/carton',
|
|
|
|
|
{ ...body, palletCode: `${pallet.palletCode}-MUTATED` }, conflict)
|
|
|
|
|
await expected(context, token, 'POST', `OVERSEAS重复箱新键被拒绝${index + 1}`,
|
|
|
|
|
'/api/pda/v1/last-leg/receiving-containers/current/receipts/carton',
|
2026-08-25 09:27:45 +08:00
|
|
|
{ ...body, idempotencyKey: key(context, `overseas-duplicate-${index}`) }, conflict)
|
2026-08-24 19:58:35 +08:00
|
|
|
}
|
|
|
|
|
return true
|
2026-08-24 15:38:28 +08:00
|
|
|
}
|
|
|
|
|
|
2026-08-24 22:14:41 +08:00
|
|
|
async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true) {
|
|
|
|
|
if (runAuthorizationProbes && context.overseasToken) {
|
2026-08-24 19:58:35 +08:00
|
|
|
const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken)
|
2026-08-24 21:00:22 +08:00
|
|
|
context.addStep(MODULE, '平台管理员海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS)
|
|
|
|
|
}
|
2026-08-24 22:14:41 +08:00
|
|
|
if (runAuthorizationProbes && merchantToken) {
|
2026-08-24 21:00:22 +08:00
|
|
|
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)
|
2026-08-24 19:58:35 +08:00
|
|
|
}
|
|
|
|
|
const before = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token)
|
|
|
|
|
const noCurrent = notFound(before)
|
|
|
|
|
context.addStep(MODULE, '无当前柜返回404属于正常空态', before, noCurrent || success(before),
|
|
|
|
|
`${noCurrent ? 'empty=true' : `resumeStatus=${before?.body?.data?.status || 'missing'}`}; ${trace(before)}`, OPTIONS)
|
|
|
|
|
if (success(before)) {
|
|
|
|
|
blockerGap(context, '可重复的完整柜号验收', `账号已有未完成柜 ${before.body?.data?.containerNo || 'unknown'},本轮不覆盖或篡改遗留现场会话`, 'dirty-fixture', before)
|
2026-08-24 15:38:28 +08:00
|
|
|
return
|
|
|
|
|
}
|
2026-08-24 19:58:35 +08:00
|
|
|
const candidates = await pda(context, token, 'GET', '查询当前客户待收柜号', '/api/pda/v1/last-leg/receiving-containers/candidates')
|
|
|
|
|
if (!Array.isArray(candidates.data) || candidates.data.length === 0) {
|
|
|
|
|
blockerGap(context, 'PDA柜号候选', `当前客户没有由三期装柜完成事件生成的待收入仓计划;${trace(candidates.response)}`, 'producer-chain', candidates.response)
|
|
|
|
|
return
|
2026-08-24 15:38:28 +08:00
|
|
|
}
|
2026-08-24 22:14:41 +08:00
|
|
|
const preferredContainer = id(preferredProducer?.containerNo)
|
|
|
|
|
|| context.createdResources?.phase3Loading?.containerNo
|
|
|
|
|
const candidate = preferredContainer
|
|
|
|
|
? candidates.data.find((item) => item.containerNo === preferredContainer)
|
|
|
|
|
: candidates.data[0]
|
|
|
|
|
if (!candidate) {
|
|
|
|
|
blockerGap(context, `本轮柜号${preferredContainer || 'missing'}候选`,
|
|
|
|
|
'三期装柜完成生产者已落地,但海外候选尚未出现该柜;禁止回退处理历史或其他柜', 'producer-chain', candidates.response)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-24 19:58:35 +08:00
|
|
|
const containerNo = id(candidate?.containerNo)
|
|
|
|
|
context.assert(MODULE, '柜号候选来自本轮生产链且包含进度', candidates.response,
|
|
|
|
|
nonBlank(containerNo) && Number(candidate?.totalPackages) > 0 && Number(candidate?.receivedPackages) >= 0,
|
|
|
|
|
`containerNo=${containerNo || 'missing'}, total=${candidate?.totalPackages ?? 'missing'}; ${trace(candidates.response)}`, OPTIONS)
|
|
|
|
|
if (!containerNo) return
|
|
|
|
|
context.createdResources.phase4OverseasPda.containerNo = containerNo
|
|
|
|
|
const detail = await pda(context, token, 'GET', '查询柜号完整入仓码详情', `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}`)
|
|
|
|
|
if (!Array.isArray(detail.data?.inbounds) || detail.data.inbounds.length === 0) return
|
|
|
|
|
const prepareBody = { idempotencyKey: key(context, 'prepare') }
|
|
|
|
|
const prepared = await pda(context, token, 'POST', '选择柜号并建立WAIT_SIGN会话',
|
|
|
|
|
`/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, prepareBody)
|
|
|
|
|
if (!success(prepared.response)) return
|
|
|
|
|
const prepareReplay = await pda(context, token, 'POST', '选择柜号同键重放',
|
|
|
|
|
`/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)
|
2026-08-24 21:00:22 +08:00
|
|
|
if (secondaryToken) {
|
|
|
|
|
await expected(context, secondaryToken, 'POST', '第二仓库操作员抢占同柜返回409',
|
|
|
|
|
`/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`,
|
|
|
|
|
{ idempotencyKey: key(context, 'secondary-prepare') }, conflict)
|
|
|
|
|
}
|
2026-08-24 19:58:35 +08:00
|
|
|
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)
|
|
|
|
|
else blockerGap(context, '跨柜锁409', '当前客户只有一个待收柜,无法在不伪造柜数据的前提下验证跨柜锁')
|
|
|
|
|
const photos = await uploadPhotos(context, token)
|
|
|
|
|
if (!photos) return
|
|
|
|
|
const signBody = { photos, idempotencyKey: key(context, 'sign') }
|
|
|
|
|
const signed = await pda(context, token, 'POST', '绑定车牌封签开柜三角色照片并签收', '/api/pda/v1/last-leg/receiving-containers/current/sign', signBody)
|
|
|
|
|
if (!success(signed.response)) return
|
|
|
|
|
const signReplay = await pda(context, token, 'POST', '签收同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/sign', signBody)
|
|
|
|
|
context.assert(MODULE, '三角色照片各一次且签收幂等', signReplay.response,
|
|
|
|
|
success(signReplay.response) && signReplay.data?.status === 'SIGNED'
|
|
|
|
|
&& PHOTO_ROLES.every((role) => signReplay.data?.photos?.filter((item) => item.role === role).length === 1), trace(signReplay.response), OPTIONS)
|
|
|
|
|
await expected(context, token, 'POST', '签收同键异载荷冲突', '/api/pda/v1/last-leg/receiving-containers/current/sign',
|
|
|
|
|
{ ...signBody, photos: [...photos].reverse() }, conflict)
|
|
|
|
|
const current = await pda(context, token, 'GET', '签收后按账号恢复当前柜', '/api/pda/v1/last-leg/receiving-containers/current')
|
|
|
|
|
if (!success(current.response)) return
|
|
|
|
|
const pallets = new Map()
|
|
|
|
|
for (const [index, inbound] of current.data.inbounds.entries()) {
|
|
|
|
|
const pallet = await createPallet(context, inbound, index)
|
|
|
|
|
if (!pallet) {
|
|
|
|
|
blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}实物托盘`,
|
2026-08-24 21:46:19 +08:00
|
|
|
`无法在实际海外收货仓 ${inbound.overseasWarehouseId || 'missing'} 创建可解析真实托盘;目的仓 ${inbound.destinationWarehouseId || 'missing'} 不得代替物理仓`, 'warehouse-resource')
|
2026-08-24 19:58:35 +08:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
pallets.set(inbound.inboundCode, pallet)
|
|
|
|
|
context.createdResources.phase4OverseasPda.palletIds.push(pallet.palletId)
|
|
|
|
|
const resolved = await pda(context, token, 'POST', `解析${inbound.destinationType}实物托盘`,
|
|
|
|
|
'/api/pda/v1/last-leg/receiving-containers/current/pallets/resolve', { palletCode: pallet.palletCode, inboundCode: inbound.inboundCode })
|
|
|
|
|
context.assert(MODULE, `${inbound.destinationType}托盘目的模式匹配`, resolved.response,
|
|
|
|
|
success(resolved.response) && resolved.data?.compatible === true && resolved.data?.destinationType === inbound.destinationType, trace(resolved.response), OPTIONS)
|
|
|
|
|
}
|
|
|
|
|
const fbaInbounds = current.data.inbounds.filter((item) => item.destinationType === 'FBA')
|
2026-08-24 23:28:24 +08:00
|
|
|
const overseasInbounds = current.data.inbounds.filter((item) => item.destinationType === 'OVERSEAS')
|
2026-08-24 19:58:35 +08:00
|
|
|
if (fbaInbounds.length && fbaInbounds.every((item) => pallets.has(item.inboundCode))) {
|
|
|
|
|
const firstFba = fbaInbounds[0]
|
|
|
|
|
await expected(context, token, 'POST', 'FBA模式硬拒绝逐箱收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', {
|
|
|
|
|
cartonMark: context.createdResources?.inboundMeasurement?.boxCode || 'MODE-REJECT-FBA',
|
|
|
|
|
palletCode: pallets.get(firstFba.inboundCode).palletCode, idempotencyKey: key(context, 'fba-carton-forbidden'),
|
|
|
|
|
}, (response) => conflictMessage(response, /FBA.*bulk|bulk.*FBA/i))
|
2026-08-25 09:23:28 +08:00
|
|
|
for (const inbound of fbaInbounds) {
|
|
|
|
|
await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode), preferredProducer)
|
|
|
|
|
}
|
2026-08-24 22:32:37 +08:00
|
|
|
} else if (preferredProducer?.destinationType !== 'OVERSEAS') {
|
|
|
|
|
blockerGap(context, 'FBA SKU按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码')
|
|
|
|
|
}
|
2026-08-24 23:28:24 +08:00
|
|
|
if (overseasInbounds.length && overseasInbounds.every((item) => pallets.has(item.inboundCode))) {
|
|
|
|
|
const firstOverseas = overseasInbounds[0]
|
|
|
|
|
const pallet = pallets.get(firstOverseas.inboundCode)
|
2026-08-24 19:58:35 +08:00
|
|
|
await expected(context, token, 'POST', 'OVERSEAS模式硬拒绝批量收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', {
|
2026-08-24 23:28:24 +08:00
|
|
|
inboundCode: firstOverseas.inboundCode, skuCode: 'MODE-REJECT', palletCode: pallet.palletCode,
|
2026-08-24 19:58:35 +08:00
|
|
|
packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'),
|
|
|
|
|
}, (response) => conflictMessage(response, /OVERSEAS.*carton|bulk.*forbidden/i))
|
2026-08-24 23:28:24 +08:00
|
|
|
await receiveOverseas(context, token, overseasInbounds, pallets,
|
|
|
|
|
preferredProducer?.cartonMarks?.length ? preferredProducer.cartonMarks : [preferredProducer?.cartonMark])
|
2026-08-24 22:32:37 +08:00
|
|
|
} else if (preferredProducer?.destinationType !== 'FBA') {
|
|
|
|
|
blockerGap(context, 'OVERSEAS逐箱链', '当前OVERSEAS柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码')
|
|
|
|
|
}
|
2026-08-24 19:58:35 +08:00
|
|
|
const refreshed = await pda(context, token, 'GET', '回查柜内逐入仓码实收进度', '/api/pda/v1/last-leg/receiving-containers/current')
|
|
|
|
|
if (!success(refreshed.response) || Number(refreshed.data?.pendingPackages) !== 0) {
|
|
|
|
|
blockerGap(context, '完成当前柜', `柜内实收尚未精确核平;pendingPackages=${refreshed.data?.pendingPackages ?? 'missing'}`, 'receiving-incomplete', refreshed.response)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const completeBody = { idempotencyKey: key(context, 'complete') }
|
|
|
|
|
const completed = await pda(context, token, 'POST', '完成当前柜并写入库存与卸柜事件', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody)
|
|
|
|
|
if (!success(completed.response)) return
|
|
|
|
|
const completeReplay = await pda(context, token, 'POST', '完成当前柜同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody)
|
|
|
|
|
context.assert(MODULE, '完成幂等重放仍返回COMPLETED', completeReplay.response,
|
|
|
|
|
success(completeReplay.response) && completeReplay.data?.status === 'COMPLETED', trace(completeReplay.response), OPTIONS)
|
|
|
|
|
const after = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token)
|
|
|
|
|
context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS)
|
2026-08-24 15:38:28 +08:00
|
|
|
context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null
|
2026-08-24 23:35:26 +08:00
|
|
|
if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') {
|
2026-08-25 11:25:13 +08:00
|
|
|
const order = await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId)
|
|
|
|
|
for (const inbound of current.data.inbounds.filter((item) => pallets.has(item.inboundCode))) {
|
|
|
|
|
const warehouseFacts = await verifyWarehouseQueries(context, token, inbound.inboundCode,
|
|
|
|
|
pallets.get(inbound.inboundCode))
|
|
|
|
|
if (warehouseFacts && context.context?.phase4OverseasPdaOperations) {
|
|
|
|
|
context.context.phase4OverseasPdaOperations.completedFixtures.push({
|
|
|
|
|
containerNo, order, ...warehouseFacts,
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-08-25 10:50:58 +08:00
|
|
|
}
|
2026-08-24 23:35:26 +08:00
|
|
|
}
|
2026-08-24 15:38:28 +08:00
|
|
|
}
|
2026-08-24 21:00:22 +08:00
|
|
|
|
|
|
|
|
export async function run(context) {
|
|
|
|
|
context.createdResources.phase4OverseasPda = {
|
2026-08-24 23:35:26 +08:00
|
|
|
marker: context.marker, containerNo: null, customerId: null, orderId: null,
|
2026-08-24 22:14:41 +08:00
|
|
|
palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], completedContainers: [],
|
2026-08-24 21:46:19 +08:00
|
|
|
overseasWarehouseId: null, overseasWarehouseName: null,
|
|
|
|
|
cleanup: 'Shared warehouse, pallets and locations used by completed inventory are retained as acceptance evidence',
|
2026-08-24 21:00:22 +08:00
|
|
|
}
|
|
|
|
|
const merchantToken = await loginMerchantForNegativeProbe(context)
|
2026-08-24 21:46:19 +08:00
|
|
|
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
|
2026-08-24 21:00:22 +08:00
|
|
|
for (const picker of [primary, secondary]) if (picker?.membershipId) {
|
|
|
|
|
context.createdResources.phase4OverseasPda.pickerMembershipIds.push(picker.membershipId)
|
|
|
|
|
}
|
2026-08-25 11:25:13 +08:00
|
|
|
let handoffReady = false
|
|
|
|
|
const cleanup = async () => {
|
|
|
|
|
await disablePicker(context, secondary, 2)
|
|
|
|
|
await disablePicker(context, primary, 1)
|
|
|
|
|
}
|
|
|
|
|
context.context.phase4OverseasPdaOperations = {
|
|
|
|
|
marker: context.marker,
|
|
|
|
|
primary: primary ? { membershipId: primary.membershipId, username: primary.username, token: primary.token } : null,
|
|
|
|
|
secondary: secondary ? { membershipId: secondary.membershipId, username: secondary.username, token: secondary.token } : null,
|
|
|
|
|
overseasWarehouseId: sharedWarehouse.id,
|
|
|
|
|
completedFixtures: [],
|
|
|
|
|
cleanup,
|
|
|
|
|
}
|
2026-08-24 21:00:22 +08:00
|
|
|
try {
|
|
|
|
|
if (!primary?.token || !secondary?.token) {
|
|
|
|
|
blockerGap(context, '海外PDA双操作员账号', '未能创建并登录两个WAREHOUSE_PICKER平台账号,无法验证全局一柜锁')
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-24 22:14:41 +08:00
|
|
|
const matrix = context.createdResources?.phase4InboundProducerFixtures
|
2026-08-25 08:40:27 +08:00
|
|
|
const dropship = matrix?.containers?.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP')
|
|
|
|
|
const producers = [dropship || matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]]
|
2026-08-24 22:14:41 +08:00
|
|
|
.filter((item) => nonBlank(item?.containerNo))
|
2026-08-24 22:32:37 +08:00
|
|
|
const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers
|
2026-08-25 15:27:26 +08:00
|
|
|
if (allProducers.length && !(await awaitProducerConsumption(context, allProducers))) {
|
|
|
|
|
blockerGap(context, '跨区消费生产矩阵',
|
|
|
|
|
'三期已发布的四柜尚未全部由海外消费者生成尾程订单,禁止把PICKUP_READY缺口与事件消费混为一谈')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (producers.length && !(await awaitPickupReadyCandidates(context, primary.token, producers, allProducers))) {
|
|
|
|
|
blockerGap(context, '实际处理柜PICKUP_READY门禁',
|
|
|
|
|
'Dropship/FBA与OVERSEAS实际处理柜尚未全部达到PICKUP_READY;跨区消费已独立验证,不回退处理历史柜')
|
2026-08-24 22:32:37 +08:00
|
|
|
return
|
|
|
|
|
}
|
2026-08-24 22:14:41 +08:00
|
|
|
for (const [index, producer] of (producers.length ? producers : [null]).entries()) {
|
|
|
|
|
context.pdaContainerSuffix = producer?.containerNo || `CONTAINER-${index + 1}`
|
|
|
|
|
context.createdResources.phase4OverseasPda.finalStatus = null
|
|
|
|
|
await runReceiving(context, primary.token, secondary.token,
|
|
|
|
|
index === 0 ? merchantToken : null, producer, index === 0)
|
|
|
|
|
if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') {
|
|
|
|
|
context.createdResources.phase4OverseasPda.completedContainers.push(producer?.containerNo || null)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-25 11:25:13 +08:00
|
|
|
handoffReady = context.context.phase4OverseasPdaOperations.completedFixtures.length > 0
|
2026-08-24 21:00:22 +08:00
|
|
|
} finally {
|
2026-08-24 22:14:41 +08:00
|
|
|
delete context.pdaContainerSuffix
|
2026-08-25 11:25:13 +08:00
|
|
|
if (!handoffReady) {
|
|
|
|
|
try { await cleanup() }
|
|
|
|
|
finally { delete context.context.phase4OverseasPdaOperations }
|
|
|
|
|
}
|
2026-08-24 21:00:22 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-24 21:46:19 +08:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|