2026-08-24 15:38:28 +08:00
|
|
|
const MODULE = 'phase4-overseas-pda'
|
|
|
|
|
const SCENARIO = 'phase4-overseas-pda-inbound-lifecycle'
|
|
|
|
|
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
|
|
|
|
|
|
|
|
|
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 records(page) { return Array.isArray(page?.records) ? page.records : [] }
|
|
|
|
|
function query(path, params) {
|
|
|
|
|
const search = new URLSearchParams()
|
|
|
|
|
for (const [key, value] of Object.entries(params)) if (value !== undefined && value !== null && value !== '') search.set(key, String(value))
|
|
|
|
|
return `${path}?${search}`
|
|
|
|
|
}
|
|
|
|
|
function trace(response) {
|
|
|
|
|
return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
|
|
|
|
|
}
|
|
|
|
|
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
|
|
|
|
|
function key(context, action, suffix = '') { return `${context.marker}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) }
|
|
|
|
|
|
|
|
|
|
async function domesticGet(context, name, path) {
|
|
|
|
|
const response = await context.request('GET', path)
|
|
|
|
|
const passed = success(response)
|
|
|
|
|
context.addStep(MODULE, name, response, passed, trace(response), OPTIONS)
|
|
|
|
|
return passed ? response.body.data : null
|
|
|
|
|
}
|
|
|
|
|
async function overseas(context, method, name, path, body = undefined) {
|
|
|
|
|
const response = await context.requestOverseas(method, path, body)
|
|
|
|
|
const passed = success(response)
|
|
|
|
|
context.addStep(MODULE, name, response, passed, trace(response), OPTIONS)
|
|
|
|
|
return { response, data: passed ? response.body.data : null }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function findUnplannedOrder(context) {
|
|
|
|
|
const tenants = records(await domesticGet(context, '查询PDA可用客户', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE'))
|
|
|
|
|
for (const tenant of tenants) {
|
|
|
|
|
const customerId = id(tenant?.id)
|
|
|
|
|
if (!longId(customerId)) continue
|
|
|
|
|
const plans = await overseas(context, 'GET', `查询客户${customerId}既有入仓计划`, query('/api/platform/v1/last-leg/inbound-plans', { customerId, page: 1, size: 200 }))
|
|
|
|
|
if (!success(plans.response)) continue
|
|
|
|
|
const planned = new Set(records(plans.data).map((item) => id(item?.sourceOrderId)).filter(Boolean))
|
|
|
|
|
const orders = await overseas(context, 'GET', `查询客户${customerId}待规划尾程单`, query('/api/platform/v1/last-leg/orders', { customerId, currentStatus: 'PENDING_ASSIGNMENT', page: 1, size: 200 }))
|
|
|
|
|
if (!success(orders.response)) continue
|
2026-08-24 16:40:25 +08:00
|
|
|
const order = records(orders.data).find((item) => longId(item?.id) && nonBlank(item?.containerNo)
|
|
|
|
|
&& !planned.has(id(item.id)))
|
2026-08-24 15:38:28 +08:00
|
|
|
if (order) return { customerId, order }
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function createWarehouse(context, customerId) {
|
|
|
|
|
const suffix = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase()
|
|
|
|
|
const warehouseCode = `PDA-${suffix}`.slice(0, 50)
|
|
|
|
|
const created = await overseas(context, 'POST', '创建PDA入仓目标海外仓', '/api/platform/v1/warehouse-admin', {
|
|
|
|
|
customerId, warehouseCode, warehouseName: `${context.marker} PDA入仓仓`, warehouseType: 'OVERSEAS',
|
|
|
|
|
countryCode: 'US', timezone: 'America/Los_Angeles', contactName: 'Phase4 PDA',
|
|
|
|
|
contactPhone: '+1-213-555-0199', contactEmail: null, status: 'ACTIVE', remark: `${context.marker}-pda-inbound`,
|
|
|
|
|
address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles',
|
|
|
|
|
district: 'Commerce', postalCode: '90040', addressLine1: '502 Phase Four PDA Avenue', addressLine2: null,
|
|
|
|
|
latitude: null, longitude: null },
|
|
|
|
|
capabilities: [{ capabilityCode: 'STORAGE', capabilityName: 'Storage', enabled: true, cutoffTime: null,
|
|
|
|
|
description: `${context.marker} PDA inbound acceptance` }],
|
|
|
|
|
})
|
|
|
|
|
const warehouseId = id(created.data)
|
|
|
|
|
context.assert(MODULE, 'PDA目标海外仓返回长ID', created.response, longId(warehouseId),
|
|
|
|
|
`warehouseId=${warehouseId || 'missing'}; ${trace(created.response)}`, OPTIONS)
|
|
|
|
|
return { warehouseId, warehouseCode, warehouseName: `${context.marker} PDA入仓仓` }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function createResource(context, warehouseId, index, fbaWarehouseCode) {
|
|
|
|
|
const suffix = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-14).toUpperCase()
|
|
|
|
|
const location = await overseas(context, 'POST', `创建PDA库位${index + 1}`,
|
|
|
|
|
`/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,
|
|
|
|
|
})
|
|
|
|
|
const locationId = id(location.data)
|
|
|
|
|
context.assert(MODULE, `PDA库位${index + 1}返回长ID`, location.response, longId(locationId),
|
|
|
|
|
`locationId=${locationId || 'missing'}; ${trace(location.response)}`, OPTIONS)
|
|
|
|
|
const pallet = await overseas(context, 'POST', `创建PDA实物托盘${index + 1}`,
|
|
|
|
|
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, {
|
|
|
|
|
palletCode: `PDA-${suffix}-${index + 1}`.slice(0, 128), palletType: '木托盘', capacityCbm: 2,
|
|
|
|
|
unitPrice: 0, status: 'IDLE', locationId: longId(locationId) ? locationId : undefined,
|
|
|
|
|
remark: context.marker, fbaWarehouseCode: fbaWarehouseCode || null,
|
|
|
|
|
})
|
|
|
|
|
const palletId = id(pallet.data)
|
|
|
|
|
context.assert(MODULE, `PDA实物托盘${index + 1}返回长ID`, pallet.response, longId(palletId),
|
|
|
|
|
`palletId=${palletId || 'missing'}; ${trace(pallet.response)}`, OPTIONS)
|
|
|
|
|
return { locationId, palletId }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function operateExpectedCartons(context, customerId, planNo, task) {
|
|
|
|
|
for (const [index, carton] of (task.expectedCartons || []).entries()) {
|
|
|
|
|
if (carton.status === 'ON_PALLET' || carton.status === 'AVAILABLE') continue
|
|
|
|
|
const scanned = await overseas(context, 'POST', `扫描国内冻结箱唛${index + 1}`,
|
|
|
|
|
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/cartons/scan`, { customerId }), {
|
|
|
|
|
cartonMark: carton.cartonMark, planPalletId: id(carton.planPalletId),
|
|
|
|
|
idempotencyKey: key(context, 'scan', String(index + 1)), operatorId: 'phase4-http', operatorName: '四期HTTP验收',
|
|
|
|
|
})
|
|
|
|
|
context.assert(MODULE, `箱唛${index + 1}已绑定托盘`, scanned.response,
|
|
|
|
|
scanned.data?.result === 'SCANNED' && scanned.data?.cartonMark === carton.cartonMark,
|
|
|
|
|
`cartonMark=${carton.cartonMark || 'missing'}, result=${scanned.data?.result || 'missing'}; ${trace(scanned.response)}`, OPTIONS)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function operateLegacyAllocations(context, customerId, planNo, task) {
|
|
|
|
|
for (const [index, allocation] of (task.allocations || []).entries()) {
|
|
|
|
|
const remaining = Number(allocation.plannedPackageCount || 0) - Number(allocation.pickedPackageCount || 0)
|
|
|
|
|
if (remaining <= 0) continue
|
|
|
|
|
await overseas(context, 'POST', `清点入仓分配${index + 1}`,
|
|
|
|
|
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/allocations/${encodeURIComponent(id(allocation.allocationId))}/count`, { customerId }), {
|
|
|
|
|
packageCount: Number(allocation.plannedPackageCount), idempotencyKey: key(context, 'count', String(index + 1)),
|
|
|
|
|
operatorId: 'phase4-http', operatorName: '四期HTTP验收',
|
|
|
|
|
})
|
|
|
|
|
await overseas(context, 'POST', `实收入仓分配${index + 1}`,
|
|
|
|
|
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/allocations/${encodeURIComponent(id(allocation.allocationId))}/pick`, { customerId }), {
|
|
|
|
|
packageCount: remaining, idempotencyKey: key(context, 'pick', String(index + 1)),
|
|
|
|
|
operatorId: 'phase4-http', operatorName: '四期HTTP验收',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function run(context) {
|
|
|
|
|
context.createdResources.phase4OverseasPda = { marker: context.marker, customerId: null, orderId: null,
|
|
|
|
|
planNo: null, warehouseId: null, taskId: null, finalStatus: null, retained: true }
|
|
|
|
|
const source = await findUnplannedOrder(context)
|
|
|
|
|
if (!source) {
|
|
|
|
|
context.addCoverageGap(MODULE, SCENARIO, 'PDA入仓来源尾程单',
|
2026-08-24 16:40:25 +08:00
|
|
|
'所有ACTIVE客户均无尚未转换、状态为PENDING_ASSIGNMENT且具有柜号的尾程单;请先运行三期装柜完成事件链', 'fixture-unavailable')
|
2026-08-24 15:38:28 +08:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const { customerId, order } = source
|
|
|
|
|
const warehouse = await createWarehouse(context, customerId)
|
|
|
|
|
if (!longId(warehouse.warehouseId)) return
|
|
|
|
|
Object.assign(context.createdResources.phase4OverseasPda, { customerId, orderId: id(order.id), warehouseId: warehouse.warehouseId })
|
|
|
|
|
|
|
|
|
|
const transferred = await overseas(context, 'PUT', '尾程单指定本轮PDA目标仓',
|
|
|
|
|
query(`/api/platform/v1/last-leg/orders/${encodeURIComponent(id(order.id))}/destination`, { customerId }), {
|
|
|
|
|
warehouseId: warehouse.warehouseId, warehouseCode: warehouse.warehouseCode,
|
|
|
|
|
warehouseName: warehouse.warehouseName, remark: `${context.marker} PDA lifecycle`,
|
|
|
|
|
})
|
|
|
|
|
if (!success(transferred.response)) return
|
|
|
|
|
const converted = await overseas(context, 'POST', '尾程单转换入仓计划', '/api/platform/v1/last-leg/inbound-plans/convert', {
|
|
|
|
|
orderId: id(order.id), customerId,
|
|
|
|
|
})
|
|
|
|
|
const plan = converted.data
|
|
|
|
|
const planNo = plan?.planNo || ''
|
|
|
|
|
context.assert(MODULE, '入仓计划转换返回完整作业结构', converted.response,
|
|
|
|
|
Boolean(planNo) && Array.isArray(plan?.pallets) && plan.pallets.length > 0 && Array.isArray(plan?.allocations) && plan.allocations.length > 0,
|
|
|
|
|
`planNo=${planNo || 'missing'}, pallets=${plan?.pallets?.length ?? 'missing'}, allocations=${plan?.allocations?.length ?? 'missing'}; ${trace(converted.response)}`, OPTIONS)
|
|
|
|
|
if (!planNo || !Array.isArray(plan?.pallets) || !plan.pallets.length) return
|
|
|
|
|
context.createdResources.phase4OverseasPda.planNo = planNo
|
|
|
|
|
|
|
|
|
|
for (const [index, planPallet] of plan.pallets.entries()) {
|
|
|
|
|
const resource = await createResource(context, warehouse.warehouseId, index, planPallet.destinationWarehouseCode)
|
|
|
|
|
if (!longId(resource.locationId) || !longId(resource.palletId)) return
|
|
|
|
|
const assigned = await overseas(context, 'POST', `分配计划托盘${index + 1}`,
|
|
|
|
|
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/pallets/${encodeURIComponent(id(planPallet.id))}/assign`, { customerId }), {
|
|
|
|
|
warehouseId: warehouse.warehouseId, palletId: resource.palletId, locationId: resource.locationId,
|
|
|
|
|
idempotencyKey: key(context, 'assign', String(index + 1)),
|
|
|
|
|
})
|
|
|
|
|
if (!success(assigned.response)) return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const pushed = await overseas(context, 'POST', '推送PDA入仓任务',
|
|
|
|
|
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/push`, { customerId }), {
|
|
|
|
|
idempotencyKey: key(context, 'push'), operatorId: 'phase4-http', operatorName: '四期HTTP验收',
|
|
|
|
|
})
|
|
|
|
|
const task = pushed.data
|
|
|
|
|
context.assert(MODULE, 'PDA任务包含托盘、分配和箱唛指令', pushed.response,
|
|
|
|
|
longId(task?.taskId) && Array.isArray(task?.pallets) && task.pallets.length === plan.pallets.length
|
|
|
|
|
&& Array.isArray(task?.allocations) && task.allocations.length > 0 && Array.isArray(task?.expectedCartons),
|
|
|
|
|
`taskId=${id(task?.taskId) || 'missing'}, pallets=${task?.pallets?.length ?? 'missing'}, allocations=${task?.allocations?.length ?? 'missing'}, cartons=${task?.expectedCartons?.length ?? 'missing'}; ${trace(pushed.response)}`, OPTIONS)
|
|
|
|
|
if (!longId(task?.taskId)) return
|
|
|
|
|
context.createdResources.phase4OverseasPda.taskId = id(task.taskId)
|
|
|
|
|
|
|
|
|
|
if ((task.expectedCartons || []).length > 0) await operateExpectedCartons(context, customerId, planNo, task)
|
|
|
|
|
else await operateLegacyAllocations(context, customerId, planNo, task)
|
|
|
|
|
|
|
|
|
|
const photo = await context.requestOverseasMultipart('POST',
|
|
|
|
|
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/unload-photos/files`, { customerId }), {
|
|
|
|
|
name: `${context.marker}-unload.png`, type: 'image/png',
|
|
|
|
|
content: Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')),
|
|
|
|
|
})
|
|
|
|
|
context.addStep(MODULE, '上传真实卸柜照片文件', photo, success(photo), trace(photo), OPTIONS)
|
|
|
|
|
const fileId = id(photo?.body?.data?.fileId)
|
|
|
|
|
context.assert(MODULE, '卸柜照片返回长文件ID', photo, longId(fileId), `fileId=${fileId || 'missing'}; ${trace(photo)}`, OPTIONS)
|
|
|
|
|
if (!longId(fileId)) return
|
|
|
|
|
const bound = await overseas(context, 'POST', '绑定卸柜照片并生成预计报空时间',
|
|
|
|
|
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/unload-photos`, { customerId }), {
|
|
|
|
|
fileIds: [fileId], idempotencyKey: key(context, 'photos'), operatorId: 'phase4-http', operatorName: '四期HTTP验收',
|
|
|
|
|
})
|
|
|
|
|
context.assert(MODULE, '照片凭证含上传时间和预计报空时间', bound.response,
|
|
|
|
|
bound.data?.unloadPhotoFileIds?.map(id).includes(fileId) && Boolean(bound.data?.unloadPhotoUploadedAt) && Boolean(bound.data?.estimatedEmptyReturnAt),
|
|
|
|
|
`fileIds=${bound.data?.unloadPhotoFileIds?.map(id).join(',') || 'missing'}, estimated=${bound.data?.estimatedEmptyReturnAt || 'missing'}; ${trace(bound.response)}`, OPTIONS)
|
|
|
|
|
|
|
|
|
|
const completed = await overseas(context, 'POST', '完成PDA入仓并写库存',
|
|
|
|
|
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/complete`, { customerId }))
|
|
|
|
|
context.assert(MODULE, 'PDA任务进入COMPLETED终态', completed.response,
|
|
|
|
|
completed.data?.status === 'COMPLETED' && Boolean(completed.data?.completedAt),
|
|
|
|
|
`status=${completed.data?.status || 'missing'}, completedAt=${completed.data?.completedAt || 'missing'}; ${trace(completed.response)}`, OPTIONS)
|
|
|
|
|
context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null
|
|
|
|
|
|
|
|
|
|
const planAfter = await overseas(context, 'GET', '回查入仓计划完成状态',
|
|
|
|
|
query(`/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}`, { customerId }))
|
|
|
|
|
context.assert(MODULE, '入仓计划与PDA任务同步完成', planAfter.response, planAfter.data?.status === 'COMPLETED',
|
|
|
|
|
`planStatus=${planAfter.data?.status || 'missing'}; ${trace(planAfter.response)}`, OPTIONS)
|
|
|
|
|
const balances = await overseas(context, 'GET', '回查PDA入仓SKU库存',
|
|
|
|
|
query('/api/platform/v1/last-leg/inventory/balances', { customerId, page: 1, size: 200 }))
|
|
|
|
|
context.assert(MODULE, 'PDA完成后生成SKU库存余额', balances.response,
|
|
|
|
|
records(balances.data).some((item) => Number(item?.totalPackages) > 0 && id(item?.customerId) === customerId),
|
|
|
|
|
`balances=${records(balances.data).length}; ${trace(balances.response)}`, OPTIONS)
|
|
|
|
|
}
|