Files
baiyue-firstleg/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs
T

429 lines
28 KiB
JavaScript

import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs'
const MODULE = 'phase4-overseas-return-followups'
const SCENARIO = 'phase4-overseas-return-followups'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const RETURN_ORDERS = '/api/merchant/v1/last-leg/return-orders'
const PDA_RECEIPTS = '/api/pda/v1/last-leg/return-receipts'
const RETURN_INVENTORY = '/api/merchant/v1/last-leg/return-inventory'
function id(value) { return value == null ? '' : String(value) }
function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' }
function code(response) { return String(response?.body?.code ?? '') }
function success(response) { return response?.http === 200 && code(response) === '200' }
function conflict(response) {
return (response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response))))
&& nonBlank(response?.requestId) && nonBlank(response?.traceId)
}
function notFound(response) {
return (response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(code(response))))
&& nonBlank(response?.requestId) && nonBlank(response?.traceId)
}
function records(value) { return Array.isArray(value?.records) ? value.records : Array.isArray(value) ? value : [] }
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) {
return `${context.marker}-RETURN-${action}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 255)
}
function markerSuffix(context) {
return String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase()
}
function add(context, name, response, passed, evidence = '') {
context.addStep(MODULE, name, response, passed, `${evidence}${evidence ? '; ' : ''}${trace(response)}`, OPTIONS)
}
async function overseas(context, token, method, name, path, body = undefined) {
const response = await context.request(method, path, body, token, {})
add(context, name, response, success(response))
return { response, data: success(response) ? response.body?.data : null }
}
async function resolveWarehouse(context) {
const existing = id(context.createdResources?.phase4OverseasPda?.overseasWarehouseId)
const response = await context.request('GET',
'/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE')
const rows = records(response?.body?.data)
const fixedIds = new Set(['193000000000200003', '193000000000200004'])
const warehouse = rows.find((item) => id(item?.id) === existing)
|| rows.find((item) => fixedIds.has(id(item?.id)))
|| rows[0]
add(context, '读取可执行退货签收的海外仓', response,
success(response) && nonBlank(id(warehouse?.id)), `warehouseId=${id(warehouse?.id) || 'missing'}`)
return success(response) && warehouse ? { id: id(warehouse.id), name: warehouse.warehouseName || warehouse.name } : null
}
async function loginMerchant(context) {
const fixture = context.context?.merchantQualificationFixture
if (fixture?.marker !== context.marker || !fixture?.username || !fixture?.password) {
add(context, '取得本轮客户账号', null, false, `marker=${fixture?.marker || 'missing'}`)
return null
}
const response = await context.request('POST', '/api/auth/login', {
loginId: fixture.username, password: fixture.password,
}, '')
const token = success(response) ? response.body?.data?.accessToken : null
add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`)
return nonBlank(token) ? token : null
}
function itemMap(items) {
return new Map((Array.isArray(items) ? items : []).map((item) => [item.skuCode, item]))
}
export async function run(context) {
const source = context.createdResources.phase4OverseasReturnReceipt
if (source?.marker !== context.marker || !source?.returnNo) {
add(context, '后续关联要求本轮退货签收合箱前置', null, false, '缺少本轮退货前置')
return
}
const merchantToken = await loginMerchant(context)
const warehouse = await resolveWarehouse(context)
const access = await resolvePickerAccess(context)
if (!merchantToken || !warehouse || !access) return
const picker = await createPicker(context, 92, warehouse.id, access)
if (!picker?.token) {
add(context, '创建后续关联PDA操作员', null, false, '未取得绑定仓PDA令牌')
if (picker?.membershipId) await disablePicker(context, picker, 92)
return
}
const returnNo = source.returnNo
const customerId = id(context.context?.merchantQualificationFixture?.tenantId)
const suffix = markerSuffix(context)
try {
const relabelCandidates = await overseas(context, merchantToken, 'GET', '客户查询本轮退货换标候选',
`${RETURN_ORDERS}/${encodeURIComponent(returnNo)}/relabel-candidates`)
const relabelCandidate = (Array.isArray(relabelCandidates.data) ? relabelCandidates.data : [])
.find(row => source.cartonMarks?.includes(row?.cartonMark) && row?.status === 'AVAILABLE')
context.assert(MODULE, '退货换标候选来自本轮已签收可用箱', relabelCandidates.response,
Boolean(relabelCandidate) && relabelCandidate.sourceReturnNo === returnNo,
`cartonMark=${relabelCandidate?.cartonMark || 'missing'}`, OPTIONS)
if (relabelCandidate) {
let destination
for (let page = 1; page <= 20; page += 1) {
const destinations = await context.request('GET', `/api/platform/v1/warehouse-admin?page=${page}&size=200&warehouseType=FBA&status=ACTIVE`)
if (!success(destinations)) throw new Error('读取FBA目的仓失败')
const data = destinations.body.data
destination = records(data).find(row => row.warehouseCode === 'SCK8' && row.status === 'ACTIVE')
if (destination || page * 200 >= Number(data.total)) break
if (!records(data).length) throw new Error('FBA目的仓分页不完整')
}
if (!destination?.id) throw new Error('完整分页未找到启用的SCK8目的仓,停止退货转换标')
const sku = relabelCandidate.items?.[0]?.skuCode
if (!sku) throw new Error('本轮候选缺少SKU')
const relabelBody = {
items: [{ skuCode: sku, quantity: 1 }], idempotencyKey: key(context, 'RELABEL'),
fileScope: 'CARTON_TOTAL', destinationFbaWarehouseId: id(destination.id),
destinationFbaWarehouseCode: destination.warehouseCode, cartonRemark: context.marker,
}
const relabel = await overseas(context, merchantToken, 'POST', '客户从退货箱创建换标单',
`${RETURN_ORDERS}/${encodeURIComponent(returnNo)}/relabel`, relabelBody)
const relabelNo = id(relabel.data?.relabelNo)
context.assert(MODULE, '退货换标单绑定原退货单与选中SKU', relabel.response,
nonBlank(relabelNo) && relabel.data?.returnNo === returnNo
&& relabel.data?.status === 'PENDING_REVIEW'
&& relabel.data?.skuCodes?.includes?.(sku),
`relabelNo=${relabelNo || 'missing'}`, OPTIONS)
if (relabelNo) {
const form = new FormData()
form.append('file', new Blob([new TextEncoder().encode(`%PDF-1.4\n${context.marker}\n%%EOF`)],
{ type: 'application/pdf' }), `${suffix}-return-relabel.pdf`)
const uploaded = await context.requestMultipart('POST',
`/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files?scope=CARTON_TOTAL`,
form, merchantToken)
add(context, '客户编辑退货换标总资料', uploaded, success(uploaded))
const relabelDetail = await overseas(context, merchantToken, 'GET', '客户回查退货换标资料修改',
`/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}`)
context.assert(MODULE, '退货换标详情持久化总资料且业务状态未漂移', relabelDetail.response,
relabelDetail.data?.application?.relabelNo === relabelNo
&& relabelDetail.data?.application?.status === 'PENDING_REVIEW'
&& nonBlank(relabelDetail.data?.application?.cartonTotalFileId), trace(relabelDetail.response), OPTIONS)
context.createdResources.phase4OverseasReturnReceipt.relabelNo = relabelNo
}
}
const outboundCandidates = await overseas(context, context.token, 'GET', '平台查询本轮退货出库候选',
query('/api/platform/v1/last-leg/outbound-orders/return/candidates', {
customerId, warehouseId: warehouse.id,
}))
const outboundCandidate = (Array.isArray(outboundCandidates.data) ? outboundCandidates.data : [])
.find(row => row?.returnNo === returnNo && nonBlank(row?.cartonMark)
&& row.cartonMark !== relabelCandidate?.cartonMark && row.status === 'AVAILABLE')
context.assert(MODULE, '退货出库候选排除已冻结换标箱', outboundCandidates.response,
Boolean(outboundCandidate), `cartonMark=${outboundCandidate?.cartonMark || 'missing'}`, OPTIONS)
if (outboundCandidate) {
const outboundBody = {
recipient: `${context.marker} RETURN OUTBOUND`, overseasWarehouseId: warehouse.id,
returnNo, cartonMarks: [outboundCandidate.cartonMark], remark: context.marker,
idempotencyKey: key(context, 'OUTBOUND'),
}
const outbound = await overseas(context, context.token, 'POST', '平台创建独立退货出库单',
query('/api/platform/v1/last-leg/outbound-orders/return', { customerId }), outboundBody)
const taskNo = id(outbound.data?.taskNo)
const outboundNo = id(outbound.data?.outboundNo)
context.assert(MODULE, 'RETURN_OUTBOUND创建PDA待拣任务', outbound.response,
nonBlank(taskNo) && nonBlank(outboundNo) && outbound.data?.status === 'PENDING',
`outboundNo=${outboundNo || 'missing'}, taskNo=${taskNo || 'missing'}`, OPTIONS)
if (taskNo) {
await verifyReturnOutbound(context, merchantToken, picker.token, {
taskNo, outboundNo, returnNo, warehouseId: warehouse.id, cartonMark: outboundCandidate.cartonMark,
action: 'OUTBOUND',
})
context.createdResources.phase4OverseasReturnReceipt.returnOutboundNo = outboundNo
}
}
if (source.relabelNo) {
await completeReturnRelabel(context, merchantToken, picker.token, source.relabelNo, returnNo)
await outboundRelabelledReturn(context, merchantToken, picker.token, source.relabelNo, returnNo, warehouse.id)
}
} finally {
await disablePicker(context, picker, 92)
}
}
export async function outboundRelabelledReturn(context, merchantToken, pdaToken, relabelNo, returnNo, warehouseId) {
const customerId = id(context.context.merchantQualificationFixture.tenantId)
const root = '/api/platform/v1/last-leg/outbound-orders'
const inventory = '/api/merchant/v1/last-leg/return-inventory/cartons'
const command = async (token, method, name, path, body) => {
const result = await overseas(context, token, method, name, path, body)
if (!success(result.response)) throw new Error(`${name}: ${trace(result.response)}`)
return result.data
}
const require = (name, value) => {
context.assert(MODULE, name, null, Boolean(value), `relabelNo=${relabelNo}`, OPTIONS)
if (!value) throw new Error(name)
}
const candidates = await command(context.token, 'GET', '查询换标完成后的退货出库候选',
query(`${root}/return/candidates`, { customerId, warehouseId }))
const selected = []
for (const row of (Array.isArray(candidates) ? candidates : []).filter(row => row.returnNo === returnNo)) {
const detail = await command(merchantToken, 'GET', '客户核对出库候选的真实换标归属', `${inventory}/${encodeURIComponent(row.cartonMark)}`)
if (detail.relabelNo !== relabelNo) continue
require('已换标候选归属数量与完成状态正确', detail.customerId === customerId
&& detail.overseasWarehouseId === warehouseId && detail.sourceReturnNo === returnNo
&& detail.status === 'AVAILABLE' && detail.relabelStatus === 'COMPLETED'
&& Number(detail.totalQuantity) === 1 && detail.items?.length === 1)
selected.push(detail)
}
require('只出库本轮实际换标的一件库存', selected.length === 1)
const mark = selected[0].cartonMark
const body = { recipient: `${context.marker} RELABELLED RETURN`, overseasWarehouseId: warehouseId,
returnNo, cartonMarks: [mark], remark: context.marker, idempotencyKey: key(context, 'RELABELLED-OUTBOUND') }
const task = await command(context.token, 'POST', '创建本轮已换标库存出库', query(`${root}/return`, { customerId }), body)
const replay = await command(context.token, 'POST', '已换标库存出库创建原键重放', query(`${root}/return`, { customerId }), body)
require('出库重放保持同一任务和业务单', nonBlank(task.taskNo) && nonBlank(task.outboundNo)
&& replay.taskNo === task.taskNo && replay.outboundNo === task.outboundNo)
await verifyReturnOutbound(context, merchantToken, pdaToken, {
taskNo: task.taskNo, outboundNo: task.outboundNo, returnNo, warehouseId, cartonMark: mark,
action: 'RELABELLED',
})
context.createdResources.phase4OverseasReturnReceipt.relabelOutboundNo = task.outboundNo
}
// Compare persisted business facts, retaining duplicates and ignoring only response ordering.
function facts(value) {
if (Array.isArray(value)) return JSON.stringify(value.map(facts).sort())
if (value && typeof value === 'object') return JSON.stringify(Object.fromEntries(
Object.keys(value).sort().map(name => [name, facts(value[name])])))
return value
}
export async function verifyReturnOutbound(context, merchantToken, pdaToken, {
taskNo, outboundNo, returnNo, warehouseId, cartonMark, action,
}) {
const customerId = id(context.context.merchantQualificationFixture.tenantId)
const path = `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}`
const platform = '/api/platform/v1/last-leg/return-inventory'
const outboundPath = query(`/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId })
const require = (name, passed, evidence = '') => {
context.assert(MODULE, name, null, Boolean(passed), `outboundNo=${outboundNo}; ${evidence}`, OPTIONS)
if (!passed) throw new Error(name)
}
const command = async (token, method, name, url, body) => {
const result = await overseas(context, token, method, name, url, body)
if (!success(result.response)) throw new Error(`${name}: ${trace(result.response)}`)
return result.data
}
const pageAll = async (url) => {
const rows = []
let expectedTotal
for (let page = 1; page <= 100; page += 1) {
const data = await command(context.token, 'GET', '读取本轮客户仓库退货记账事实',
query(url, { customerId, overseasWarehouseId: warehouseId, page, size: 200 }))
const total = Number(data?.total)
require('退货记账分页完整且作用域明确', Array.isArray(data?.records) && Number.isSafeInteger(total)
&& total >= 0 && (expectedTotal === undefined || expectedTotal === total)
&& data.records.every(row => id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId))
expectedTotal = total
rows.push(...data.records)
require('退货记账分页无溢出或空页截断', rows.length <= total && (rows.length === total || data.records.length > 0))
if (rows.length === total) return rows
}
throw new Error('退货记账分页超过安全上限,不能以部分结果验收')
}
// Only these three stock counters are normalized; IDs and other string facts stay exact.
const normalizeBalances = rows => rows.map(row => {
const normalized = { ...row }
for (const field of ['totalQuantity', 'availableQuantity', 'cartonQuantity']) {
const value = row[field]
require(`库存${field}必须为非负安全整数`,
(typeof value === 'number' || (typeof value === 'string' && /^(0|[1-9][0-9]*)$/.test(value)))
&& Number.isSafeInteger(Number(value)) && Number(value) >= 0)
normalized[field] = Number(value)
}
return normalized
})
const snapshot = async () => ({
balances: normalizeBalances(await pageAll(platform)),
movements: await pageAll(`${platform}/movements`),
carton: await command(merchantToken, 'GET', '客户回查退货历史箱明细', `${RETURN_INVENTORY}/cartons/${encodeURIComponent(cartonMark)}`),
task: await command(pdaToken, 'GET', 'PDA回查退货出库事实', path),
outbound: await command(context.token, 'GET', '平台回查退货出库事实', outboundPath),
})
const stockFacts = value => facts({ balances: value.balances, movements: value.movements, carton: value.carton })
const before = await snapshot()
const carton = before.carton
require('出库箱历史明细归属及SKU数量有效', carton?.cartonMark === cartonMark && carton.customerId === customerId
&& carton.overseasWarehouseId === warehouseId && carton.sourceReturnNo === returnNo && carton.status === 'AVAILABLE'
&& carton.items?.length > 0 && new Set(carton.items.map(row => row.skuCode)).size === carton.items.length
&& Number(carton.skuTypes) === carton.items.length
&& carton.items.every(row => nonBlank(row.skuCode) && Number.isSafeInteger(Number(row.quantity)) && Number(row.quantity) > 0)
&& carton.items.reduce((sum, row) => sum + Number(row.quantity), 0) === Number(carton.totalQuantity))
const items = before.task?.items || []
const item = items[0]
const position = item?.positions?.[0]
const frozen = before.outbound
require('PDA必须返回真实退货箱扫描位置且不得回退首行或伪造字段', items.length === 1
&& before.task.task?.taskNo === taskNo && before.task.task?.outboundNo === outboundNo
&& item.positions?.length === 1 && nonBlank(id(item.outboundPalletItemId))
&& item.inboundCode === returnNo && Number(item.packageCount) === 1 && Number(item.pickedPackageCount) === 0
&& position.cartonMark === cartonMark && position.inboundCode === returnNo
&& (position.inventoryPositionId === null || position.inventoryPositionId === undefined) && Number(position.availablePackages) === 1 && Number(position.pickedPackages) === 0
&& frozen.outbound?.outboundNo === outboundNo && frozen.outbound?.outboundType === 'RETURN_OUTBOUND'
&& frozen.pallets?.length === 1 && frozen.pallets[0].cartonMarks === cartonMark
&& id(frozen.pallets[0].id) === id(item.outboundPalletItemId))
require('新出库单尚无OUTBOUND流水', !before.movements.some(row => row.movementType === 'OUTBOUND' && row.referenceNo === outboundNo))
const quantities = new Map(carton.items.map(row => [row.skuCode, Number(row.quantity)]))
require('待出库SKU库存唯一且足够', new Set(before.balances.map(row => row.skuCode)).size === before.balances.length
&& [...quantities].every(([sku, quantity]) => before.balances.some(row => row.skuCode === sku
&& Number(row.totalQuantity) >= quantity && Number(row.availableQuantity) >= quantity && Number(row.cartonQuantity) >= 1)))
const pickBody = { outboundPalletItemId: item.outboundPalletItemId, inventoryPositionId: null,
packages: 1, cartonMarks: [position.cartonMark], idempotencyKey: key(context, `${action}-PICK`) }
await command(pdaToken, 'POST', 'PDA扫描真实退货箱', `${path}/pick`, pickBody)
const picked = await snapshot()
const expectedBalances = before.balances.map(row => {
const quantity = quantities.get(row.skuCode)
return quantity === undefined ? row : { ...row, totalQuantity: Number(row.totalQuantity) - quantity,
availableQuantity: Number(row.availableQuantity) - quantity, cartonQuantity: Number(row.cartonQuantity) - 1 }
})
const withoutTime = rows => rows.map(({ updatedAt, ...row }) => row)
require('逐SKU扣减实际件数与一个可用箱且其他SKU不变', facts(withoutTime(picked.balances)) === facts(withoutTime(expectedBalances)),
JSON.stringify({ before: before.balances, after: picked.balances, quantities: [...quantities] }))
const outboundMovements = picked.movements.filter(row => row.movementType === 'OUTBOUND' && row.referenceNo === outboundNo)
require('每个出库SKU只有一条等量负OUTBOUND流水且历史流水不变', outboundMovements.length === quantities.size
&& [...quantities].every(([sku, quantity]) => outboundMovements.filter(row => row.skuCode === sku
&& Number(row.totalDelta) === -quantity && Number(row.availableDelta) === -quantity).length === 1)
&& facts(picked.movements.filter(row => !(row.movementType === 'OUTBOUND' && row.referenceNo === outboundNo))) === facts(before.movements),
JSON.stringify(outboundMovements))
require('出库后历史箱明细与来源保留', facts(picked.carton) === facts({ ...carton, status: 'OUTBOUNDED' }))
require('拣货任务和单箱进度持久化', picked.task.task?.status === 'IN_PROGRESS'
&& picked.task.items?.length === 1 && picked.task.items[0].status === 'PICKED'
&& Number(picked.task.items[0].pickedPackageCount) === 1
&& Number(picked.task.items[0].positions?.[0]?.availablePackages) === 0)
await command(pdaToken, 'POST', '退货拣货同键同载荷重放', `${path}/pick`, pickBody)
require('同键重放后全部记账与任务事实不变', facts(await snapshot()) === facts(picked))
for (const [label, body] of [
['同键错误箱唛', { ...pickBody, cartonMarks: [`${cartonMark}-WRONG`] }],
['同键错误件数', { ...pickBody, packages: 2 }],
['新键重复拣货', { ...pickBody, idempotencyKey: key(context, `${action}-PICK-AGAIN`) }],
]) {
const rejected = await context.request('POST', `${path}/pick`, body, pdaToken, {})
add(context, `${label}拒绝`, rejected, conflict(rejected))
require(`${label}必须返回带诊断的冲突`, conflict(rejected), trace(rejected))
require(`${label}后全部事实不变`, facts(await snapshot()) === facts(picked))
}
const completeBody = { idempotencyKey: key(context, `${action}-COMPLETE`) }
await command(pdaToken, 'POST', 'PDA完成退货出库', `${path}/complete`, completeBody)
const completed = await snapshot()
require('完成退货出库不二次扣库存且历史箱明细流水保留', stockFacts(completed) === stockFacts(picked)
&& completed.task.task?.status === 'COMPLETED' && completed.outbound.outbound?.status === 'OUTBOUNDED')
await command(pdaToken, 'POST', '退货出库完成同键重放', `${path}/complete`, completeBody)
require('完成重放后全部事实不变', facts(await snapshot()) === facts(completed))
}
export async function completeReturnRelabel(context, merchantToken, pdaToken, relabelNo, returnNo) {
const platform = `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}`
const merchant = `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}`
const work = `/api/pda/v1/last-leg/relabel-tasks/${encodeURIComponent(relabelNo)}`
const pallets = '/api/pda/v1/last-leg/pallet-operations'
const command = async (token, method, name, path, body) => {
const result = await overseas(context, token, method, name, path, body)
if (!success(result.response)) throw new Error(`${name}: ${trace(result.response)}`)
return result.data
}
const require = (name, passed) => {
context.assert(MODULE, name, null, Boolean(passed), `relabelNo=${relabelNo}`, OPTIONS)
if (!passed) throw new Error(name)
}
const reviewed = await command(context.token, 'PUT', '审核本轮退货换标申请', `${platform}/status`,
{ status: 'PENDING_PROCESSING', remark: context.marker })
require('换标审核后等待PDA作业', reviewed.application?.relabelNo === relabelNo && reviewed.application.status === 'PENDING_PROCESSING')
let task = await command(pdaToken, 'POST', 'PDA认领本轮退货换标', `${work}/claim`, { idempotencyKey: key(context, 'RETURN-CLAIM') })
require('本轮任务全部为退货SKU换标', task.relabelNo === relabelNo && task.items?.length > 0
&& task.items.every(item => item.operationType === 'RETURN_BULK_SKU_LABEL'))
for (const item of task.items) {
task = await command(pdaToken, 'POST', 'PDA完成本轮退货SKU数量', `${work}/return-skus/complete`, {
itemId: item.itemId, quantity: Number(item.expectedPackages), expectedVersion: Number(task.version),
idempotencyKey: key(context, `RETURN-SKU-${item.itemId}`),
})
}
const sources = await command(pdaToken, 'POST', '读取本轮换标实际来源托盘', `${pallets}/relabel-sources`, { relabelNo })
require('退货换标具有明确来源托盘', Array.isArray(sources) && sources.length > 0)
for (const [index, source] of sources.entries()) {
const target = await command(pdaToken, 'POST', '生成本轮带目的仓换标托盘', `${pallets}/relabel-pallet-label`,
{ relabelNo, idempotencyKey: key(context, `RETURN-PALLET-${index}`) })
const from = await command(pdaToken, 'POST', '回查来源托盘版本', `${pallets}/pallets/resolve`, { code: source.palletCode })
const to = await command(pdaToken, 'POST', '回查目标托盘版本', `${pallets}/pallets/resolve`, { code: target.palletCode })
require('组托仅使用本轮任务箱唛', source.cartonMarks?.length > 0
&& source.cartonMarks.every(mark => task.items.some(item => item.cartonMark === mark)))
const body = { relabelNo, source: { palletCode: from.palletCode, expectedVersion: Number(from.palletVersion) },
target: { palletCode: to.palletCode, expectedVersion: Number(to.palletVersion) },
actualSource: { positionId: source.positionId, expectedPositionVersion: Number(source.positionVersion),
packages: task.items.filter(item => source.cartonMarks.includes(item.cartonMark))
.reduce((sum, item) => sum + Number(item.expectedPackages), 0) },
cartonMarks: source.cartonMarks, idempotencyKey: key(context, `RETURN-BIND-${index}`), reason: context.marker }
const bound = await command(pdaToken, 'POST', '退货换标按实际数量绑定目标托盘', `${pallets}/relabel-disassemble`, body)
const replay = await command(pdaToken, 'POST', '退货换标组托原键重放', `${pallets}/relabel-disassemble`, body)
require('换标组托重放不重复移动库存', bound.status === 'COMPLETED' && replay.replayed === true && id(bound.operationId) === id(replay.operationId))
}
task = await command(pdaToken, 'GET', '组托后回查换标任务版本', work)
const form = new FormData()
form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'synthetic-return-relabel.png')
const photo = await context.requestMultipart('POST', `${work}/photos/files`, form, pdaToken)
add(context, '上传合成换标完工照片', photo, success(photo))
if (!success(photo) || !photo.body.data?.fileId) throw new Error('换标完工照片失败')
task = await command(pdaToken, 'POST', 'PDA提交退货换标完工', `${work}/complete`, {
photoFileIds: [id(photo.body.data.fileId)], expectedVersion: Number(task.version),
idempotencyKey: key(context, 'RETURN-COMPLETE'), remark: context.marker,
})
require('换标完工等待客户确认', task.applicationStatus === 'CUSTOMER_CONFIRM_PENDING')
const body = { idempotencyKey: key(context, 'RETURN-CONFIRM'), remark: context.marker }
const confirmed = await command(merchantToken, 'POST', '客户确认本轮退货换标完成', `${merchant}/confirm-completion`, body)
const replay = await command(merchantToken, 'POST', '客户确认换标原键重放', `${merchant}/confirm-completion`, body)
require('退货SKU换标客户确认完成且不重复建单', confirmed.application?.status === 'COMPLETED'
&& replay.application?.relabelNo === relabelNo && replay.application?.status === 'COMPLETED')
const order = await command(merchantToken, 'GET', '回查原退货单换标状态', `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`)
require('原退货单保持签收完成且关联同一已完成换标', order.order?.status === 'COMPLETED'
&& order.order?.relabelNo === relabelNo && order.order?.relabelStatus === 'COMPLETED')
context.createdResources.phase4OverseasReturnReceipt.relabelCompleted = true
}