Files
baiyue-firstleg/scripts/admin-test-env-modules/client-relabel-fulfillment.mjs
T

179 lines
15 KiB
JavaScript

import { mkdir, writeFile } from 'node:fs/promises'
const MODULE = 'phase4-overseas-client-services'
const PALLETS = '/api/pda/v1/last-leg/pallet-operations'
const validId = value => /^[1-9]\d*$/.test(String(value))
const count = value => /^\d+$/.test(String(value)) && Number.isSafeInteger(Number(value))
const stable = value => JSON.stringify(value, (_, item) => item && typeof item === 'object' && !Array.isArray(item)
? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item)
/** 原申请单箱贴标作业链;独立贴标准备排车,关联转运恢复原单预留。 */
export async function verifyClientRelabelFulfillment(context, { proof, transferProof, merchantToken, pdaToken, checkpoint, delay = () => new Promise(resolve => setTimeout(resolve, 1000)) }) {
// 已有库存转运的换标必须回到原转运预留,不进入普通贴标排车链。
if (transferProof && (context.createdResources?.phase4OverseasClientServices?.transferFulfillment !== transferProof
|| transferProof.relabelFulfillment !== proof || transferProof.marker !== context.marker
|| transferProof.status !== 'WAIT_RELABEL' || transferProof.allocation?.status !== 'ALLOCATED_WAIT_RELABEL'
|| transferProof.relabelNo !== proof?.relabelNo || transferProof.transferNo !== proof?.sourceOrderNo
|| transferProof.customerId !== proof?.customerId || transferProof.warehouseId !== proof?.warehouseId
|| transferProof.sourcePositionId !== proof?.sourcePositionId || transferProof.sourcePalletNo !== proof?.sourcePalletNo)) {
throw new Error('转运关联换标缺少原分配证据')
}
if (proof?.marker !== context.marker || !proof?.relabelNo || !merchantToken || !pdaToken
|| proof.customerId !== String(context.context?.merchantQualificationFixture?.tenantId)
|| !validId(proof.warehouseId) || !validId(proof.sourcePositionId) || !proof.sourcePalletNo) {
throw new Error('贴标后续缺少本轮客户申请、来源仓位及会话')
}
if (proof.execution) throw new Error('已有贴标执行证据,不自动重跑')
if (!/^[A-Za-z0-9_-]{1,100}$/.test(context.marker)) throw new Error('贴标批次标识不合法')
const evidence = proof.execution = { status: 'RUNNING', requests: [] }
let initialized = false
const directory = new URL(transferProof ? '../../reports/transfer-relabel-checkpoints/' : '../../reports/client-relabel-checkpoints/', import.meta.url)
const persist = checkpoint || (async () => {
await mkdir(directory, { recursive: true })
await writeFile(new URL(`${context.marker}.json`, directory), JSON.stringify(transferProof || proof, null, 2),
{ encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
initialized = true
})
const require = (name, passed) => {
context.assert(MODULE, name, null, Boolean(passed), '', { scenario: 'client-relabel-fulfillment', deploymentBlocker: true })
if (!passed) throw new Error(name)
}
const key = action => `${context.marker}-CLIENT-RELABEL-${proof.relabelNo}-${action}`
const platform = `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(proof.relabelNo)}`
const merchant = `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(proof.relabelNo)}`
const work = `/api/pda/v1/last-leg/relabel-tasks/${encodeURIComponent(proof.relabelNo)}`
async function command(method, path, body, token, upload = false) {
const entry = { method, path, body: upload ? { syntheticImage: true } : body, status: 'SENDING' }
evidence.requests.push(entry); await persist()
let response
try { response = upload ? await context.requestMultipart(method, path, body, token)
: await context.request(method, path, body, token) }
catch (error) { entry.status = 'UNKNOWN_RESULT'; await persist(); throw error }
Object.assign(entry, { status: response?.http === 0 || response?.http >= 500 || !response?.body ? 'UNKNOWN_RESULT' : 'RESPONDED',
http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
await persist()
require(`贴标后续 ${method} ${path}`, response?.http === 200 && String(response?.body?.code) === '200')
return response.body.data
}
const read = () => command('GET', platform, undefined, context.token)
const sameApplication = detail => detail?.application?.relabelNo === proof.relabelNo
&& detail.application.customerOrderNo === proof.sourceOrderNo
&& String(detail.application.overseasWarehouseId) === proof.warehouseId
const resolve = palletCode => command('POST', `${PALLETS}/pallets/resolve`, { code: palletCode }, pdaToken)
try {
await persist()
const profile = await command('GET', '/api/merchant/v1/iam/tenant/profile', undefined, merchantToken)
require('贴标当前客户会话身份有效', String(profile?.id) === proof.customerId && profile?.status === 'ACTIVE')
const owned = await command('GET', merchant, undefined, merchantToken)
require('客户可读取同一贴标申请', sameApplication(owned))
const original = await read()
require('贴标原申请客户、仓库、来源仓位与待审核状态一致', sameApplication(original)
&& original.application.status === 'PENDING_REVIEW' && original.items?.length === 1
&& String(original.items[0].sourceInventoryPositionId) === proof.sourcePositionId)
require('贴标审核渠道与业务来源一致', original.application.reviewChannel === (transferProof ? 'RELABEL_ORDER' : 'TAIL_ORDER')
&& validId(original.application.lastlegOrderId))
const reviewPath = transferProof ? `${platform}/status`
: `/api/platform/v1/last-leg/orders/${original.application.lastlegOrderId}/relabel-review?customerId=${encodeURIComponent(proof.customerId)}`
const reviewed = await command('PUT', reviewPath, { status: 'PENDING_PROCESSING', remark: context.marker }, context.token)
require('贴标审核推进同一申请', sameApplication(reviewed) && reviewed.application.status === 'PENDING_PROCESSING')
let funded
for (let attempt = 0; attempt < 31; attempt++) {
funded = await read()
require('贴标等待资金期间申请身份不变', sameApplication(funded))
if (funded.application.walletStatus === 'FROZEN') break
if (attempt < 30) await delay()
}
require('贴标费用已冻结后才开始PDA作业', funded.application.walletStatus === 'FROZEN')
const claim = { idempotencyKey: key('claim') }
let task = await command('POST', `${work}/claim`, claim, pdaToken)
const replay = await command('POST', `${work}/claim`, claim, pdaToken)
require('认领原键重放不改变任务', stable(task) === stable(replay))
require('PDA原申请单箱任务及版本有效', task.relabelNo === proof.relabelNo && task.taskStatus === 'PROCESSING'
&& count(task.version) && task.items?.length === 1 && Number(task.items[0].expectedPackages) === 1)
const item = task.items[0]
require('PDA箱唛及SKU校验来自原申请', validId(item.itemId) && Boolean(item.cartonMark)
&& ['CARTON_AND_SKU', 'SKU_LABEL'].includes(item.operationType) && item.skuItems?.length > 0
&& item.skuItems.every(sku => sku.skuCode && sku.newSkuCode && count(sku.quantity) && Number(sku.quantity) > 0))
if (item.operationType === 'CARTON_AND_SKU') task = await command('POST', `${work}/cartons/scan`,
{ cartonMark: item.cartonMark, expectedVersion: Number(task.version), idempotencyKey: key('carton') }, pdaToken)
const scan = { itemId: String(item.itemId), oldLabel: item.cartonMark, newLabel: item.fbaCartonMark || item.newSkuCode,
skuVerifications: item.skuItems.map(sku => ({ skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity) })),
expectedVersion: Number(task.version), idempotencyKey: key('unit') }
task = await command('POST', `${work}/units`, scan, pdaToken)
const scannedReplay = await command('POST', `${work}/units`, scan, pdaToken)
require('单箱贴标完成且扫描重放不重复计数', task.relabelNo === proof.relabelNo && Number(task.relabelledPackages) === 1
&& Number(task.skippedPackages) === 0 && Number(task.exceptionPackages) === 0 && stable(task) === stable(scannedReplay))
const detail = await read()
require('组托前来源位置和箱唛仍属于原申请', sameApplication(detail) && detail.items?.length === 1
&& String(detail.items[0].sourceInventoryPositionId) === proof.sourcePositionId && detail.items[0].cartonMark === item.cartonMark)
const target = await command('POST', `${PALLETS}/relabel-pallet-label`, { relabelNo: proof.relabelNo, idempotencyKey: key('label') }, pdaToken)
require('换标生成本次业务目标托盘', validId(target?.palletId) && Boolean(target.palletCode) && target.palletCode !== proof.sourcePalletNo)
const from = await resolve(proof.sourcePalletNo), to = await resolve(target.palletCode)
require('源目标托盘数量版本有效', from?.palletCode === proof.sourcePalletNo && to?.palletCode === target.palletCode
&& [from, to].every(value => count(value?.totalPackages) && count(value?.palletVersion)))
const binding = { relabelNo: proof.relabelNo, source: { palletCode: from.palletCode, expectedVersion: Number(from.palletVersion) },
target: { palletCode: to.palletCode, expectedVersion: Number(to.palletVersion) }, cartonMarks: [item.cartonMark],
reason: context.marker, idempotencyKey: key('bind') }
const bound = await command('POST', `${PALLETS}/relabel-disassemble`, binding, pdaToken)
const boundReplay = await command('POST', `${PALLETS}/relabel-disassemble`, binding, pdaToken)
const fromAfter = await resolve(proof.sourcePalletNo), toAfter = await resolve(target.palletCode)
require('换标原箱源减一目标增一且组托重放复用操作', validId(bound.operationId) && bound.status === 'COMPLETED' && boundReplay.replayed === true
&& String(bound.operationId) === String(boundReplay.operationId)
&& Number(fromAfter.totalPackages) === Number(from.totalPackages) - 1 && Number(toAfter.totalPackages) === Number(to.totalPackages) + 1)
if (transferProof) {
// 明确的受控TEST补测输入;不按箱数、原托尺寸或计费重量推算,也不作为真实硬件实测证据。
const input = { lengthCm: 120, widthCm: 100, heightCm: 80, grossWeightKg: 12.5 }
proof.palletMeasurementInput = { syntheticTestMaterials: true, input }
let measurements = await command('POST', `${PALLETS}/relabel-pallet-measurements`, { relabelNo: proof.relabelNo }, pdaToken)
const codes = new Set([from.palletCode, target.palletCode])
require('补测只接受本次源目标非空托盘及当前库存版本', Array.isArray(measurements) && measurements.length > 0
&& measurements.every(row => validId(row.palletId) && codes.has(row.palletCode) && Number(row.packages) > 0 && row.stockVersion))
for (const row of [...measurements]) {
if (['grossWeightKg', 'volumeCbm'].every(field => Number(row[field]) > 0)
&& (row.cartonAggregated || ['lengthCm', 'widthCm', 'heightCm'].every(field => Number(row[field]) > 0))) continue
require('箱级实测不完整时不能用整托输入绕过', row.cartonAggregated !== true)
measurements = await command('POST', `${PALLETS}/relabel-pallet-measure`, { relabelNo: proof.relabelNo,
palletCode: row.palletCode, stockVersion: row.stockVersion, idempotencyKey: key(`measure-${row.palletId}`), ...input }, pdaToken)
const measured = measurements.find(value => String(value.palletId) === String(row.palletId))
require('补测回执保持本托库存版本和明确TEST输入', measured?.stockVersion === row.stockVersion
&& Object.entries(input).every(([field, value]) => Number(measured[field]) === value) && Number(measured.volumeCbm) > 0)
}
proof.palletMeasurements = measurements
}
task = await command('GET', work, undefined, pdaToken)
const photo = await command('POST', `${work}/photos/files`, { name: `${context.marker}-synthetic-relabel.png`, type: 'image/png',
content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') }, pdaToken, true)
require('换标完工图片返回正式文件引用', validId(photo?.fileId))
const complete = { expectedVersion: Number(task.version), photoFileIds: [String(photo.fileId)], manualCompletion: false,
idempotencyKey: key('complete'), remark: context.marker }
const completed = await command('POST', `${work}/complete`, complete, pdaToken)
const completedReplay = await command('POST', `${work}/complete`, complete, pdaToken)
require('PDA完工及重放等待客户确认', completed.relabelNo === proof.relabelNo && completed.applicationStatus === 'CUSTOMER_CONFIRM_PENDING'
&& stable(completed) === stable(completedReplay))
const confirm = { idempotencyKey: key('confirm'), remark: context.marker }
const confirmed = await command('POST', `${merchant}/confirm-completion`, confirm, merchantToken)
const finalReplay = await command('POST', `${merchant}/confirm-completion`, confirm, merchantToken)
require('客户确认原申请进入对应出库流程且原键重放零漂移', sameApplication(confirmed)
&& confirmed.application.status === 'READY_FOR_OUTBOUND'
&& confirmed.application.fulfillmentStatus === (transferProof ? 'WAIT_TRANSFER_OUTBOUND' : 'WAIT_PRE_DISPATCH')
&& stable(confirmed) === stable(finalReplay))
if (transferProof) {
const transfer = await command('GET', `/api/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferProof.transferNo)}?customerId=${encodeURIComponent(proof.customerId)}`, undefined, context.token)
const active = transfer?.allocations?.filter(row => row.status === 'RESERVED') || []
require('关联换标完成后原转运保留新SKU实托唯一预留', transfer?.transferNo === transferProof.transferNo
&& transfer.status === 'ALLOCATED' && transfer.relabelNo === proof.relabelNo
&& String(transfer.relabelApplicationId) === transferProof.relabelApplicationId
&& active.length === 1 && active[0].packageCount === 1 && String(active[0].palletId) === String(target.palletId)
&& active[0].inboundCode === transferProof.inboundCode && validId(active[0].inventoryPositionId)
&& active[0].skuCode === item.skuItems[0].newSkuCode)
Object.assign(transferProof, { status: 'ALLOCATED', outboundAllocations: active })
}
Object.assign(proof, { status: 'READY_FOR_OUTBOUND', targetPalletIds: [String(target.palletId)],
sourceCartonMarks: [item.cartonMark], relabelCartonMarks: [item.fbaCartonMark].filter(Boolean) })
evidence.status = transferProof ? 'HTTP_VERIFIED_READY_FOR_TRANSFER_OUTBOUND' : 'HTTP_VERIFIED_READY_FOR_DISPATCH'
await persist(); return proof
} catch (error) {
evidence.status = 'FAILED'; evidence.failure = error.message; await persist(); throw error
}
}