Files
baiyue-firstleg/scripts/admin-test-env-modules/phase4-overseas-pda.mjs
T
2026-08-24 19:58:35 +08:00

271 lines
20 KiB
JavaScript

const MODULE = 'phase4-overseas-pda'
const SCENARIO = 'phase4-overseas-pda-container-receiving'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const PHOTO_ROLES = ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN']
const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'))
function id(value) { return value == null ? '' : String(value) }
function longId(value) { return /^\d+$/.test(id(value)) && id(value) !== '0' }
function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 }
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
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'}` }
function key(context, action, suffix = '') { return `${context.marker}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) }
function markerSuffix(context) { return String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() }
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)
}
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
}
async function loginPda(context) {
const fixture = context.context?.merchantQualificationFixture
if (!fixture?.username || !fixture?.password) {
blockerGap(context, 'PDA客户账号', '缺少 merchant-qualification-fixture 生成的本轮客户账号;请保留默认全量前置模块')
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,
success(response) && nonBlank(data?.overseasAccessToken),
`overseasTokenPresent=${nonBlank(data?.overseasAccessToken)}; ${trace(response)}`, OPTIONS)
return nonBlank(data?.overseasAccessToken) ? data.overseasAccessToken : null
}
async function createPallet(context, inbound, index) {
const warehouseId = id(inbound?.destinationWarehouseId)
if (!longId(warehouseId)) return null
const suffix = markerSuffix(context)
const location = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, {
zoneCode: `PDA-${index + 1}`, locationCode: `PDA-${suffix}-${index + 1}`.slice(0, 64),
locationType: 'STORAGE', maxLoadKg: 5000, status: 'IDLE', remark: `${context.marker}-receiving`,
})
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)
return longId(palletId) ? { warehouseId, locationId, palletId, palletCode, retained: true } : null
}
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)
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 })
}
return photos
}
function skuFixtures(context) {
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))))]
: []
}
async function receiveFba(context, token, inbound, pallet) {
const skus = skuFixtures(context)
if (!skus.length) {
blockerGap(context, `FBA入仓码${inbound.inboundCode} SKU`, '本轮订单 fixture 未交接冻结 SKU,无法安全猜测实收 SKU')
return false
}
let accepted = 0
for (const [index, skuCode] of skus.entries()) {
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 packageCount = skuCode.includes('-LOAD-INC') ? 2 : 1
const body = { inboundCode: inbound.inboundCode, skuCode, palletCode: pallet.palletCode,
packageCount, idempotencyKey: key(context, 'fba-bulk', skuCode) }
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',
{ ...body, packageCount: Number(inbound.totalPackages) + 1, idempotencyKey: key(context, 'fba-over', skuCode) }, conflict)
}
if (accepted <= 0) {
blockerGap(context, `FBA入仓码${inbound.inboundCode}实收`, `候选 SKU 均未唯一解析到当前入仓码;skuCount=${skus.length}`)
return false
}
return true
}
async function receiveOverseas(context, token, inbound, pallet) {
const cartonMark = context.createdResources?.inboundMeasurement?.boxCode
if (!nonBlank(cartonMark)) {
blockerGap(context, `OVERSEAS入仓码${inbound.inboundCode}逐箱`, '缺少本轮冻结箱唛,逐箱实收不能使用伪造箱号')
return false
}
const resolved = await pda(context, token, 'POST', '解析OVERSEAS冻结箱唛', '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark })
if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode) {
blockerGap(context, `OVERSEAS入仓码${inbound.inboundCode}箱唛归属`, `冻结箱唛未解析到目标入仓码;cartonMark=${cartonMark}`, 'fixture-mismatch', resolved.response)
return false
}
const body = { cartonMark, palletCode: pallet.palletCode, idempotencyKey: key(context, 'overseas-carton') }
const first = await pda(context, token, 'POST', 'OVERSEAS逐箱实收', '/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逐箱同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body)
context.assert(MODULE, 'OVERSEAS逐箱幂等结果一致', replay.response,
success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS)
await expected(context, token, 'POST', 'OVERSEAS逐箱同键异载荷冲突', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton',
{ ...body, palletCode: `${pallet.palletCode}-MUTATED` }, conflict)
await expected(context, token, 'POST', 'OVERSEAS重复箱新键被拒绝', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton',
{ ...body, idempotencyKey: key(context, 'overseas-duplicate') }, conflict)
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
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)
}
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)
return
}
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
}
const preferredContainer = context.createdResources?.phase3Loading?.containerNo
const candidate = candidates.data.find((item) => item.containerNo === preferredContainer) || candidates.data[0]
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)
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}实物托盘`,
`无法在目的仓 ${inbound.destinationWarehouseId || 'missing'} 创建可解析真实托盘`, 'warehouse-resource')
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')
const overseasInbound = current.data.inbounds.find((item) => item.destinationType === 'OVERSEAS')
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))
for (const inbound of fbaInbounds) await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode))
} else blockerGap(context, 'FBA SKU按箱数链', '当前柜没有带真实托盘的 FBA 入仓码')
if (overseasInbound && pallets.has(overseasInbound.inboundCode)) {
const pallet = pallets.get(overseasInbound.inboundCode)
await expected(context, token, 'POST', 'OVERSEAS模式硬拒绝批量收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', {
inboundCode: overseasInbound.inboundCode, skuCode: 'MODE-REJECT', palletCode: pallet.palletCode,
packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'),
}, (response) => conflictMessage(response, /OVERSEAS.*carton|bulk.*forbidden/i))
await receiveOverseas(context, token, overseasInbound, pallet)
} else blockerGap(context, 'OVERSEAS逐箱链', '当前柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码')
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)
context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null
}