Files
baiyue-firstleg/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs
T

536 lines
36 KiB
JavaScript

import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs'
const MODULE = 'phase5-overseas-fulfillment'
const SCENARIO = 'phase5-dispatch-outbound-delivery-exception-visit'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches'
const LASTLEG_BASE = '/api/platform/v1/last-leg'
const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'
const MERCHANT_DELIVERY_BASE = '/api/merchant/v1/last-leg/delivery-orders'
function id(value) { return value == null ? '' : String(value) }
function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' }
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
function code(response) { return String(response?.body?.code ?? '') }
function conflict(response) {
return response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response)))
}
function trace(response) {
return `HTTP=${response?.http ?? 'missing'}, code=${code(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
}
function query(path, values) {
const params = new URLSearchParams()
for (const [name, value] of Object.entries(values || {})) {
if (value !== undefined && value !== null && String(value) !== '') params.set(name, String(value))
}
return params.size ? `${path}?${params}` : path
}
function key(context, action, suffix = '') {
return `${context.marker}-P5-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 200)
}
function blocker(context, name, reason, response = null) {
context.addCoverageGap(MODULE, SCENARIO, name, reason, 'fixture-unavailable', { deploymentBlocker: true })
context.assert(MODULE, `${name}覆盖前置`, response, false, `${reason}; ${trace(response)}`, OPTIONS)
}
async function platform(context, method, name, path, body = undefined) {
const response = await context.request(method, path, body)
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
return { response, data }
}
async function pda(context, token, method, name, path, body = undefined) {
const response = await context.request(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 merchant(context, token, method, name, path, body = undefined) {
const response = await context.request(method, path, body, token, {})
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
return { response, data }
}
function transferDeliveries(data) {
return Array.isArray(data?.deliveries) ? data.deliveries.filter((value) => nonBlank(value?.deliveryNo)) : []
}
function deliveryNumbers(data) {
return transferDeliveries(data).map((value) => id(value.deliveryNo)).sort()
}
async function poll(action, predicate, attempts = 45, intervalMs = 1_000) {
let result = null
for (let attempt = 1; attempt <= attempts; attempt += 1) {
result = await action()
if (predicate(result)) return { result, attempts: attempt }
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, intervalMs))
}
return { result, attempts }
}
export async function saveReceiptAndComplete(context, delivery, customerId, index) {
const deliveryNo = id(delivery.deliveryNo)
const documentsPath = query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents`, { customerId })
const before = await platform(context, 'GET', `读取第${index + 1}张派送单资料版本`, documentsPath)
const version = Number(before.data?.version)
context.assert(MODULE, `第${index + 1}张派送单资料提供乐观锁版本`, before.response,
Number.isInteger(version) && version >= 0, `version=${before.data?.version ?? 'missing'}; ${trace(before.response)}`, OPTIONS)
if (!success(before.response) || !Number.isInteger(version) || version < 0) return null
const uploadResponse = await context.requestMultipart('POST',
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents/RECEIPT/files`, { customerId }), {
name: `${context.marker}-${deliveryNo}-receipt.txt`, type: 'text/plain',
content: new TextEncoder().encode(`${context.marker} receipt acceptance for ${deliveryNo}`),
})
const uploaded = context.apiSuccess(MODULE, `上传第${index + 1}张派送单签收文件`, uploadResponse,
trace(uploadResponse), OPTIONS)
const fileId = id(uploaded?.fileId)
context.assert(MODULE, `第${index + 1}张派送单签收文件返回业务绑定ID`, uploadResponse,
/^\d+$/.test(fileId), `fileIdPresent=${/^\d+$/.test(fileId)}; ${trace(uploadResponse)}`, OPTIONS)
if (!success(uploadResponse) || !fileId) return null
const existing = (before.data?.categories || []).map((category) => ({
category: category.category,
fileIds: (category.files || []).map((file) => id(file.fileId)).filter((value) => /^\d+$/.test(value)),
})).filter((binding) => binding.category && binding.category !== 'RECEIPT' && binding.fileIds.length > 0)
const saveBody = {
expectedVersion: version, idempotencyKey: key(context, 'SAVE-RECEIPT', String(index)),
bolNo: before.data?.bolNo || null,
documents: [...existing, { category: 'RECEIPT', fileIds: [fileId] }],
}
const saved = await platform(context, 'PUT', `保存第${index + 1}张派送单签收资料并自动完成`, documentsPath, saveBody)
context.assert(MODULE, `第${index + 1}张派送单签收资料保存后已上传`, saved.response,
saved.data?.categories?.some((category) => category?.category === 'RECEIPT'
&& category?.status === 'UPLOADED' && Number(category?.fileCount) === 1), trace(saved.response), OPTIONS)
if (!success(saved.response)) return null
const savedReplay = await platform(context, 'PUT', `第${index + 1}张派送单签收资料同键重放`, documentsPath, saveBody)
context.assert(MODULE, `第${index + 1}张派送单资料重放版本稳定`, savedReplay.response,
savedReplay.data?.replayed === true && Number(savedReplay.data?.version) === Number(saved.data?.version),
trace(savedReplay.response), OPTIONS)
const completed = await platform(context, 'GET', `回查第${index + 1}张派送单自动完成`,
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}`, { customerId }))
context.assert(MODULE, `第${index + 1}张派送单签收保存即进入DELIVERED`, completed.response,
completed.data?.order?.status === 'DELIVERED'
&& completed.data?.operations?.some((operation) => operation?.operationNode === 'RECEIPT_AUTO_COMPLETE'),
trace(completed.response), OPTIONS)
return success(completed.response) && completed.data?.order?.status === 'DELIVERED' ? completed.data : null
}
export function relabelOutboundMarksMatch(detail, relabel) {
const marks = (detail?.pallets || []).flatMap(item => String(item.cartonMarks || '')
.split(',').map(mark => mark.trim()).filter(Boolean))
const expected = relabel?.relabelCartonMarks || []
return expected.length > 0 && marks.length === expected.length
&& new Set(marks).size === marks.length && expected.every(mark => marks.includes(mark))
&& (relabel.sourceCartonMarks || []).every(mark => !marks.includes(mark))
&& Number(detail?.outbound?.packageCount) === expected.length
}
async function verifyRelabelDispatchOutbound(context, relabel, transferBody) {
if (relabel?.marker !== context.marker || !nonBlank(relabel?.dispatchNo)) {
blocker(context, '换标排车箱唛传递前置', '缺少本轮换标排车,不借用历史数据')
return
}
const transferred = await platform(context, 'POST', '本轮换标排车转派送并生成出库快照',
`${DISPATCH_BASE}/${encodeURIComponent(relabel.dispatchNo)}/transfer-delivery`,
{ ...transferBody, idempotencyKey: key(context, 'RELABEL-TRANSFER') })
const outboundNo = transferred.data?.outboundNo
if (!success(transferred.response) || !nonBlank(outboundNo)) return
const detail = await platform(context, 'GET', '回查换标排车的真实出库箱唛',
query(`${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { page: 1, size: 200 }))
context.assert(MODULE, '排车出库快照精确保留全部新箱唛且无原箱唛或重复箱', detail.response,
success(detail.response) && detail.data?.outbound?.outboundNo === outboundNo
&& detail.data?.outbound?.dispatchNo === relabel.dispatchNo
&& relabelOutboundMarksMatch(detail.data, relabel), trace(detail.response), OPTIONS)
Object.assign(relabel, { outboundNo, deliveryNos: deliveryNumbers(transferred.data) })
}
async function loginMerchant(context, customerId) {
const merchantFixture = context.context?.merchantQualificationFixture
const credentials = nonBlank(merchantFixture?.username) && nonBlank(merchantFixture?.password)
? { username: merchantFixture.username, password: merchantFixture.password }
: { username: context.options?.merchantUsername, password: context.options?.merchantPassword }
if (!nonBlank(credentials.username) || !nonBlank(credentials.password)) {
blocker(context, '取得本轮客户工作台账号', '既无本轮动态客户凭据,也未配置开发验收客户凭据')
return null
}
const response = await context.request('POST', '/api/auth/login', {
loginId: credentials.username,
password: credentials.password,
}, '')
const auth = context.apiSuccess(MODULE, '本轮客户工作台账号登录', response, trace(response), OPTIONS)
context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.accessToken),
`tokenPresent=${Boolean(auth?.accessToken)}; ${trace(response)}`, OPTIONS)
if (!nonBlank(auth?.accessToken) || !nonBlank(auth?.accessToken)) return null
const profileResponse = await context.request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, auth.accessToken)
const profile = context.apiSuccess(MODULE, '回查客户工作台企业身份', profileResponse,
trace(profileResponse), OPTIONS)
context.assert(MODULE, '客户工作台企业与五期履约客户严格一致', profileResponse,
id(profile?.id) === customerId,
`merchantCustomer=${id(profile?.id) || 'missing'}, fulfillmentCustomer=${customerId}; ${trace(profileResponse)}`, OPTIONS)
return id(profile?.id) === customerId ? auth.accessToken : null
}
function fixture(context) {
const dispatch = context.createdResources?.phase4OverseasDispatch
const receiving = context.createdResources?.phase4OverseasPda
if (dispatch?.marker !== context.marker || dispatch?.finalStatus !== 'PENDING_DEPARTURE'
|| !nonBlank(dispatch?.dispatchNo)) return null
if (receiving?.marker !== context.marker || receiving?.finalStatus !== 'COMPLETED'
|| !id(receiving?.overseasWarehouseId) || !id(receiving?.customerId)) return null
return { dispatch, receiving }
}
export async function pickAll(context, token, dispatchNo) {
let detail = await pda(context, token, 'GET', 'PDA读取本轮排车拣货任务',
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
const items = Array.isArray(detail.data?.items) ? detail.data.items : []
if (!success(detail.response) || items.length === 0) {
blocker(context, 'PDA排车拣货任务', '本轮排车未生成可操作PDA托盘任务', detail.response)
return false
}
for (const [itemIndex, item] of items.entries()) {
let remaining = Number(item.scheduledPackages || 0) - Number(item.pickedPackages || 0)
const positions = Array.isArray(item.positions) ? item.positions : []
for (const [positionIndex, position] of positions.entries()) {
if (remaining <= 0) break
const available = Number(position.availablePackages || 0)
if (available <= 0) continue
const packages = Math.min(remaining, available)
const body = {
positionId: id(position.id), packages, productQuantity: packages,
idempotencyKey: key(context, 'DISPATCH-PICK', `${itemIndex}-${positionIndex}`),
remark: `${context.marker}-phase5-dispatch-pick`,
}
const picked = await pda(context, token, 'POST', `PDA拣货托盘${itemIndex + 1}库存位${positionIndex + 1}`,
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.dispatchItemId))}/pick`, body)
if (!success(picked.response)) return false
const replay = await pda(context, token, 'POST', `PDA拣货托盘${itemIndex + 1}同键重放`,
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.dispatchItemId))}/pick`, body)
context.assert(MODULE, `PDA拣货托盘${itemIndex + 1}重放数量稳定`, replay.response,
success(replay.response) && Number(replay.data?.pickedPackages) === Number(picked.data?.pickedPackages),
trace(replay.response), OPTIONS)
remaining -= packages
}
if (remaining !== 0) {
blocker(context, `PDA托盘${itemIndex + 1}足量库存`,
`scheduled=${item.scheduledPackages}, picked=${item.pickedPackages}, remaining=${remaining}`, detail.response)
return false
}
}
const completeBody = { idempotencyKey: key(context, 'DISPATCH-COMPLETE') }
const completed = await pda(context, token, 'POST', 'PDA确认排车拣货全部完成',
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/complete`, completeBody)
context.assert(MODULE, '排车进入READY_TO_DEPART且数量守恒', completed.response,
completed.data?.status === 'READY_TO_DEPART'
&& Number(completed.data?.plannedPackages) === Number(completed.data?.pickedPackages)
&& Number(completed.data?.remainingPackages) === 0,
trace(completed.response), OPTIONS)
if (!success(completed.response)) return false
const replay = await pda(context, token, 'POST', 'PDA完成排车拣货同键重放',
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/complete`, completeBody)
context.assert(MODULE, 'PDA完成拣货重放明确标记且结果稳定', replay.response,
replay.data?.replayed === true && replay.data?.status === 'READY_TO_DEPART', trace(replay.response), OPTIONS)
return success(replay.response)
}
export async function run(context) {
context.createdResources.phase5OverseasFulfillment = {
marker: context.marker, dispatchNo: null, outboundNo: null, deliveryNo: null, deliveryNos: [],
visitId: null, manualExceptionNo: null, returnExceptionNo: null, finalStatus: null,
cleanup: 'marker-owned completed delivery, customer visit and return exception are retained as acceptance evidence',
}
const source = fixture(context)
if (!source) {
blocker(context, '取得本轮四期已收货排车前置',
`dispatchMarker=${context.createdResources?.phase4OverseasDispatch?.marker || 'missing'}, dispatchStatus=${context.createdResources?.phase4OverseasDispatch?.finalStatus || 'missing'}, receivingStatus=${context.createdResources?.phase4OverseasPda?.finalStatus || 'missing'}`)
return
}
const dispatchNo = source.dispatch.dispatchNo
const customerId = id(source.receiving.customerId)
const warehouseId = id(source.receiving.overseasWarehouseId)
context.createdResources.phase5OverseasFulfillment.dispatchNo = dispatchNo
const merchantToken = await loginMerchant(context, customerId)
if (!merchantToken) return
const beforeEdit = await platform(context, 'GET', '编辑车辆前读取服务端目的仓线路', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
if (!success(beforeEdit.response) || beforeEdit.data?.dispatchNo !== dispatchNo) return
const updateBody = {
vehiclePlate: `DEV-${context.marker.slice(-8)}`.slice(0, 64),
driverName: `五期验收司机-${context.marker.slice(-6)}`,
driverPhone: '19900000000', carrierName: '五期开发验收车队',
estimatedArrivalAt: new Date(Date.now() + 86_400_000).toISOString(),
}
const updated = await platform(context, 'PUT', '补齐本轮排车车辆司机与线路',
`${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`, updateBody)
context.assert(MODULE, '排车编辑字段完整回显', updated.response,
updated.data?.dispatchNo === dispatchNo && updated.data?.vehiclePlate === updateBody.vehiclePlate
&& updated.data?.driverName === updateBody.driverName && updated.data?.route === beforeEdit.data.route,
trace(updated.response), OPTIONS)
if (!success(updated.response)) return
const bol = await context.requestBinary('GET', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/prints/bol`)
context.addStep(MODULE, '排车BOL下载为真实PDF而非JSON错误', bol,
bol.http === 200 && /application\/pdf/i.test(bol.contentType || '')
&& new TextDecoder().decode(bol.bytes?.slice(0, 5)) === '%PDF-', trace(bol), OPTIONS)
const transferBody = {
idempotencyKey: key(context, 'TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172_800_000).toISOString(),
deliveryMethod: 'TRUCK_PALLET', waybillNo: `${context.marker}-WAYBILL`.slice(0, 64),
carrierName: updateBody.carrierName, pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET',
vehicleType: 'TRUCK', vehicleSize: '15托', driverName: updateBody.driverName,
driverPhone: updateBody.driverPhone, vehiclePlate: updateBody.vehiclePlate,
appointmentAccount: 'SELF', sellerName: null, remark: `${context.marker}-phase5-transfer`,
fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE',
currency: 'USD', amount: 10, remark: `${context.marker}-acceptance` }],
}
await verifyRelabelDispatchOutbound(context, context.createdResources?.phase4OverseasRelabel, transferBody)
const transferred = await merchant(context, merchantToken, 'POST', '客户创建派送并原子转出库单',
query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody)
const outboundNo = id(transferred.data?.outboundNo)
const deliveries = transferDeliveries(transferred.data)
const deliveryNos = deliveryNumbers(transferred.data)
const destinationKeys = deliveries.map((value) => id(value.destinationWarehouseCode || value.destinationWarehouseId))
const deliveryNo = deliveryNos[0] || ''
context.assert(MODULE, '转派送按目的FBA仓生成WAIT_DELIVERY单据数组', transferred.response,
nonBlank(outboundNo) && deliveries.length > 0 && new Set(deliveryNos).size === deliveries.length
&& new Set(destinationKeys).size === deliveries.length && destinationKeys.every(nonBlank)
&& transferred.data?.outboundStatus === 'WAIT_OUTBOUND'
&& deliveries.every((value) => value?.deliveryStatus === 'WAIT_DELIVERY')
&& deliveries.reduce((sum, value) => sum + Number(value?.packageCount || 0), 0) === Number(transferred.data?.packageCount)
&& Number(transferred.data?.packageCount) > 0,
`outboundNo=${outboundNo || 'missing'}, deliveries=${deliveryNos.join(',') || 'missing'}, destinations=${destinationKeys.join(',') || 'missing'}; ${trace(transferred.response)}`, OPTIONS)
if (!success(transferred.response) || !outboundNo || deliveries.length === 0) return
Object.assign(context.createdResources.phase5OverseasFulfillment, { outboundNo, deliveryNo, deliveryNos })
const adminReplay = await platform(context, 'POST', '后台转派送复用客户已创建的同一出库派送单',
`${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/transfer-delivery`, transferBody)
context.assert(MODULE, '后台与客户转派送共享同一业务事实', adminReplay.response,
adminReplay.data?.replayed === true && id(adminReplay.data?.outboundNo) === outboundNo
&& JSON.stringify(deliveryNumbers(adminReplay.data)) === JSON.stringify(deliveryNos), trace(adminReplay.response), OPTIONS)
const lockedItem = updated.data?.items?.[0]
if (lockedItem?.id) {
const deniedRemove = await context.request('DELETE', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(lockedItem.id))}`)
context.addStep(MODULE, '已生成出库单禁止移除排车托盘', deniedRemove, conflict(deniedRemove), trace(deniedRemove), OPTIONS)
const stable = await platform(context, 'GET', '移除拒绝后回查排车托盘零漂移', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
context.assert(MODULE, '移除失败不改变箱数和托盘身份', stable.response,
Number(stable.data?.packageCount) === Number(updated.data?.packageCount)
&& JSON.stringify(stable.data?.items?.map(item => id(item.id))) === JSON.stringify(updated.data?.items?.map(item => id(item.id))), trace(stable.response), OPTIONS)
} else blocker(context, '转派送后移除门禁前置', '本轮排车缺少真实明细ID')
const transferReplay = await merchant(context, merchantToken, 'POST', '客户创建派送同键重放',
query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody)
context.assert(MODULE, '转派送重放复用同一业务单据', transferReplay.response,
transferReplay.data?.replayed === true && id(transferReplay.data?.outboundNo) === outboundNo
&& JSON.stringify(deliveryNumbers(transferReplay.data)) === JSON.stringify(deliveryNos), trace(transferReplay.response), OPTIONS)
const transferConflict = await context.request('POST', query(MERCHANT_DELIVERY_BASE, { dispatchNo }), { ...transferBody, idempotencyKey: key(context, 'TRANSFER-DIFFERENT') }, merchantToken, {})
context.addStep(MODULE, '同一排车使用另一幂等键仍返回既有单据', transferConflict,
success(transferConflict) && id(transferConflict.body?.data?.outboundNo) === outboundNo
&& transferConflict.body?.data?.replayed === true
&& JSON.stringify(deliveryNumbers(transferConflict.body?.data)) === JSON.stringify(deliveryNos), trace(transferConflict), OPTIONS)
const merchantPage = await merchant(context, merchantToken, 'GET', '客户分页回查自身派送单',
query(MERCHANT_DELIVERY_BASE, { page: 1, size: 20, dispatchNo }))
const merchantRows = Array.isArray(merchantPage.data?.records) ? merchantPage.data.records : []
context.assert(MODULE, '客户派送列表包含本轮全部FBA派送单且不依赖customerId参数', merchantPage.response,
deliveryNos.every((number) => merchantRows.some((value) => id(value?.deliveryNo) === number
&& id(value?.dispatchNo) === dispatchNo)),
`records=${merchantRows.length}; ${trace(merchantPage.response)}`, OPTIONS)
const merchantDetail = await merchant(context, merchantToken, 'GET', '客户读取自身派送详情',
`${MERCHANT_DELIVERY_BASE}/${encodeURIComponent(deliveryNo)}`)
context.assert(MODULE, '客户派送详情返回订单、货物、费用与操作四段正式契约', merchantDetail.response,
id(merchantDetail.data?.order?.deliveryNo) === deliveryNo
&& id(merchantDetail.data?.order?.dispatchNo) === dispatchNo
&& nonBlank(id(merchantDetail.data?.order?.destinationWarehouseCode
|| merchantDetail.data?.order?.destinationWarehouseId))
&& Array.isArray(merchantDetail.data?.skus) && merchantDetail.data.skus.length > 0
&& Array.isArray(merchantDetail.data?.fees) && Array.isArray(merchantDetail.data?.operations),
`skus=${merchantDetail.data?.skus?.length ?? 'missing'}, fees=${merchantDetail.data?.fees?.length ?? 'missing'}, operations=${merchantDetail.data?.operations?.length ?? 'missing'}; ${trace(merchantDetail.response)}`,
OPTIONS)
const access = await resolvePickerAccess(context)
const picker = access ? await createPicker(context, 5, warehouseId, access) : null
try {
if (!nonBlank(picker?.token)) {
blocker(context, '创建本轮五期PDA操作员', '正式IAM账号已创建但海外安全投影或登录未就绪')
return
}
if (!await pickAll(context, picker.token, dispatchNo)) return
const departBody = { idempotencyKey: key(context, 'DISPATCH-DEPART'), remark: `${context.marker}-depart` }
const departed = await pda(context, picker.token, 'POST', 'PDA确认车辆发车并完成真实出库',
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody)
context.assert(MODULE, '发车推进排车DEPARTED并扣减实物库存一次', departed.response,
departed.data?.status === 'DEPARTED' && Number(departed.data?.shippedPackages) > 0,
trace(departed.response), OPTIONS)
if (!success(departed.response)) return
const departReplay = await pda(context, picker.token, 'POST', 'PDA发车同键重放',
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody)
context.assert(MODULE, 'PDA发车重放明确标记且发运数量稳定', departReplay.response,
departReplay.data?.replayed === true
&& Number(departReplay.data?.shippedPackages) === Number(departed.data?.shippedPackages),
trace(departReplay.response), OPTIONS)
} finally {
await disablePicker(context, picker, 5)
if (picker) picker.token = null
}
const outbound = await platform(context, 'GET', '回查发车后出库单状态',
query(`${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { page: 1, size: 20 }))
context.assert(MODULE, '排车PDA发车同步出库单OUTBOUNDED', outbound.response,
outbound.data?.outbound?.outboundNo === outboundNo && outbound.data?.outbound?.status === 'OUTBOUNDED',
trace(outbound.response), OPTIONS)
for (const [index, delivery] of deliveries.entries()) {
const currentNo = id(delivery.deliveryNo)
const beforeStatus = await platform(context, 'GET', `读取第${index + 1}张派送单状态版本`,
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(currentNo)}`, { customerId }))
const deliveryVersion = Number(beforeStatus.data?.order?.version)
context.assert(MODULE, `第${index + 1}张派送单提供状态乐观锁版本`, beforeStatus.response,
Number.isInteger(deliveryVersion) && deliveryVersion >= 0,
`version=${beforeStatus.data?.order?.version ?? 'missing'}; ${trace(beforeStatus.response)}`, OPTIONS)
if (!Number.isInteger(deliveryVersion) || deliveryVersion < 0) return
const delivering = await platform(context, 'PUT', `第${index + 1}张派送单推进配送中`,
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(currentNo)}/status`, { customerId }),
{ status: 'DELIVERING', expectedVersion: deliveryVersion, remark: `${context.marker}-delivering-${index + 1}` })
context.assert(MODULE, `第${index + 1}张派送状态进入DELIVERING`, delivering.response,
delivering.data?.deliveryStatus === 'DELIVERING', trace(delivering.response), OPTIONS)
if (!success(delivering.response)) return
}
const completedDeliveries = []
for (const [index, delivery] of deliveries.entries()) {
const completed = await saveReceiptAndComplete(context, delivery, customerId, index)
if (!completed) return
completedDeliveries.push(completed)
}
context.assert(MODULE, '每个目的FBA仓派送单均由各自签收文件自动完成', null,
completedDeliveries.length === deliveries.length
&& completedDeliveries.every((value) => value?.order?.status === 'DELIVERED'),
`deliveries=${deliveryNos.join(',')}, completed=${completedDeliveries.length}`, OPTIONS)
const removedComplete = await context.request('POST', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }), { status: 'DELIVERED', expectedVersion: 0, remark: `${context.marker}-removed-complete-probe` })
context.addStep(MODULE, '人工确认派送完成接口已移除', removedComplete,
removedComplete?.http === 404 || removedComplete?.http === 405
|| (removedComplete?.http === 200 && ['A0404', 'A0405'].includes(code(removedComplete))),
trace(removedComplete), OPTIONS)
const visits = await platform(context, 'GET', '按派送单回查签收自动生成的客户回访',
query(`${LASTLEG_BASE}/customer-visits`, { page: 1, size: 200, customerId, keyword: deliveryNo }))
const visitRow = Array.isArray(visits.data?.records)
? visits.data.records.find((value) => id(value?.deliveryNo) === deliveryNo) : null
const visitId = id(visitRow?.id)
context.assert(MODULE, '签收自动完成生成PENDING回访记录', visits.response,
/^\d+$/.test(visitId) && visitRow?.status === 'PENDING',
`visitIdPresent=${/^\d+$/.test(visitId)}; ${trace(visits.response)}`, OPTIONS)
if (!visitId) return
context.createdResources.phase5OverseasFulfillment.visitId = visitId
const visitDetail = await platform(context, 'GET', '读取本轮客户回访详情',
query(`${LASTLEG_BASE}/customer-visits/${encodeURIComponent(visitId)}`, { customerId }))
const inbound = Array.isArray(visitDetail.data?.inboundOrders)
? visitDetail.data.inboundOrders.find((value) => id(value?.inboundOrderId) && nonBlank(value?.lastMileOrderNo)) : null
context.assert(MODULE, '回访详情包含派送关联的真实入仓单与尾程单', visitDetail.response,
Boolean(inbound), `inboundOption=${Boolean(inbound)}; ${trace(visitDetail.response)}`, OPTIONS)
if (!inbound) return
const domesticInbound = await poll(async () => context.request('GET', query('/api/platform/inbounds', {
tenantId: customerId, inboundCode: inbound.inboundCode, page: 1, size: 20,
})), (response) => success(response) && Array.isArray(response.body?.data?.records)
&& response.body.data.records.some((value) => value?.inboundCode === inbound.inboundCode
&& value?.processStatus === 'DELIVERED'))
const inboundSummary = domesticInbound.result?.body?.data?.records?.find(
(value) => value?.inboundCode === inbound.inboundCode)
context.addStep(MODULE, '海外签收完成事件回推头程入仓单DELIVERED', domesticInbound.result,
success(domesticInbound.result) && inboundSummary?.processStatus === 'DELIVERED',
`inboundCode=${inbound.inboundCode}, pollRequests=${domesticInbound.attempts}, processStatus=${inboundSummary?.processStatus || 'missing'}; ${trace(domesticInbound.result)}`, OPTIONS)
const sourceOrderId = id(inboundSummary?.orderId)
if (!sourceOrderId) {
blocker(context, '取得签收对应头程订单', '头程入仓详情缺少来源订单ID', domesticInbound.result)
return
}
const domesticOrder = await poll(async () => context.request('GET',
query(`/api/platform/v1/orders/${encodeURIComponent(sourceOrderId)}`, { tenantId: customerId })),
(response) => success(response) && response.body?.data?.summary?.mainStatus === 'COMPLETED')
context.addStep(MODULE, '同订单全部入仓签收后统一订单进入COMPLETED', domesticOrder.result,
success(domesticOrder.result) && domesticOrder.result?.body?.data?.summary?.mainStatus === 'COMPLETED',
`orderId=${sourceOrderId}, pollRequests=${domesticOrder.attempts}, mainStatus=${domesticOrder.result?.body?.data?.summary?.mainStatus || 'missing'}; ${trace(domesticOrder.result)}`, OPTIONS)
const savedVisit = await platform(context, 'PUT', '保存无附件客户回访结果',
query(`${LASTLEG_BASE}/customer-visits/${encodeURIComponent(visitId)}`, { customerId }), {
selectedInboundOrderId: id(inbound.inboundOrderId), visitResult: 'SATISFIED',
customerFeedback: `${context.marker}-customer-feedback`, remark: `${context.marker}-visit-completed`, attachments: [],
})
context.assert(MODULE, '客户回访完成并冻结所选业务入仓码', savedVisit.response,
savedVisit.data?.visit?.status === 'COMPLETED'
&& savedVisit.data?.visit?.selectedInboundCode === inbound.inboundCode
&& savedVisit.data?.visit?.visitResult === 'SATISFIED', trace(savedVisit.response), OPTIONS)
const manualExceptionBody = {
customerId, inboundCode: inbound.inboundCode, lastMileOrderNo: inbound.lastMileOrderNo,
exceptionType: 'DAMAGED', reason: `${context.marker}-manual-exception`, packageCount: 1, skuCode: null,
}
let manualException = await platform(context, 'POST', '为本轮尾程单创建人工异常',
`${LASTLEG_BASE}/exception-orders`, manualExceptionBody)
if (manualException.response.http === 0) {
manualException = await platform(context, 'POST', '人工异常连接失败后使用同载荷重试',
`${LASTLEG_BASE}/exception-orders`, manualExceptionBody)
}
const manualExceptionNo = id(manualException.data?.exceptionNo)
context.assert(MODULE, '人工异常进入PENDING并返回业务单号', manualException.response,
nonBlank(manualExceptionNo) && manualException.data?.status === 'PENDING', trace(manualException.response), OPTIONS)
context.createdResources.phase5OverseasFulfillment.manualExceptionNo = manualExceptionNo || null
const returnOptions = await platform(context, 'GET', '查询派送退回可选入仓单',
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/return-inbound-options`, { customerId }))
const returnInbound = Array.isArray(returnOptions.data)
? returnOptions.data.find((value) => Array.isArray(value?.returnSources)
&& value.returnSources.some((source) => id(source?.outboundPalletItemId)
&& id(source?.inventoryPositionId) && Number(source?.maxReturnPackages) > 0)) : null
context.assert(MODULE, '退回候选严格属于当前派送单', returnOptions.response,
id(returnInbound?.inboundOrderId) && returnInbound?.deliveryNo === deliveryNo && returnInbound?.outboundNo === outboundNo,
`candidate=${Boolean(returnInbound)}; ${trace(returnOptions.response)}`, OPTIONS)
if (!returnInbound) return
const returnSource = Array.isArray(returnInbound.returnSources)
? returnInbound.returnSources.find((value) => id(value?.outboundPalletItemId)
&& id(value?.inventoryPositionId) && Number(value?.maxReturnPackages) > 0) : null
context.assert(MODULE, '退回候选包含可追溯的出库托盘库存来源', returnOptions.response,
Boolean(returnSource), `source=${Boolean(returnSource)}; ${trace(returnOptions.response)}`, OPTIONS)
if (!returnSource) return
const returnBody = {
inboundOrderId: id(returnInbound.inboundOrderId), reason: `${context.marker}-delivery-return`,
returnDirection: 'OVERSEAS_WAREHOUSE', returnLogisticsNo: `${context.marker}-RETURN`.slice(0, 128),
items: [{
outboundPalletItemId: id(returnSource.outboundPalletItemId),
inventoryPositionId: id(returnSource.inventoryPositionId),
packageCount: 1,
}],
idempotencyKey: key(context, 'DELIVERY-RETURN'),
}
const returned = await platform(context, 'POST', '标记派送退回海外仓并创建退货异常',
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/mark-return`, { customerId }), returnBody)
const returnExceptionNo = id(returned.data?.exceptionNo)
context.assert(MODULE, '派送退回生成独立PENDING异常单', returned.response,
nonBlank(returnExceptionNo) && returned.data?.status === 'PENDING'
&& returnExceptionNo !== manualExceptionNo, trace(returned.response), OPTIONS)
context.createdResources.phase5OverseasFulfillment.returnExceptionNo = returnExceptionNo || null
const returnReplay = await platform(context, 'POST', '派送退回同键同载荷重放',
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/mark-return`, { customerId }), returnBody)
context.assert(MODULE, '派送退回重放复用同一异常单', returnReplay.response,
returnReplay.data?.replayed === true && id(returnReplay.data?.exceptionNo) === returnExceptionNo,
trace(returnReplay.response), OPTIONS)
const delivery = await platform(context, 'GET', '回查派送退回终态',
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}`, { customerId }))
context.assert(MODULE, '派送退回后单据状态为RETURN_PENDING且保留出库关联', delivery.response,
delivery.data?.order?.status === 'RETURN_PENDING' && delivery.data?.order?.outboundNo === outboundNo,
trace(delivery.response), OPTIONS)
const exceptionPage = await platform(context, 'GET', '分页回查本轮两类异常',
query(`${LASTLEG_BASE}/exception-orders`, { page: 1, size: 20, customerId, keyword: context.marker }))
const exceptionRows = Array.isArray(exceptionPage.data?.records) ? exceptionPage.data.records : []
context.assert(MODULE, '异常列表同时包含人工异常与派送退回异常', exceptionPage.response,
exceptionRows.some((value) => id(value?.exceptionNo) === manualExceptionNo)
&& exceptionRows.some((value) => id(value?.exceptionNo) === returnExceptionNo),
`records=${exceptionRows.length}; ${trace(exceptionPage.response)}`, OPTIONS)
context.createdResources.phase5OverseasFulfillment.finalStatus = 'RETURN_PENDING_WITH_COMPLETED_VISIT'
}