贴标申请,仓储转运

This commit is contained in:
2026-09-17 17:37:29 +08:00
parent 3b8b6f5aa2
commit 29f05b7b09
106 changed files with 5019 additions and 471 deletions
+63 -30
View File
@@ -13,6 +13,8 @@ const directMerchantToken = process.env.MERCHANT_OVERSEAS_ACCESS_TOKEN
const directPdaToken = process.env.PDA_OVERSEAS_ACCESS_TOKEN
const output = process.env.OVERSEAS_ACCEPTANCE_OUTPUT || 'reports/overseas-relabel-real-acceptance.json'
const marker = `TAIL-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
const chargeAmount = Number(process.env.OVERSEAS_RELABEL_CHARGE_AMOUNT ?? '0.01')
if (!Number.isFinite(chargeAmount) || chargeAmount < 0) throw new Error('Invalid charge amount')
for (const [name, value] of Object.entries({ customerId, warehouseId })) {
if (!value) throw new Error(`Missing environment variable: ${name}`)
@@ -21,7 +23,7 @@ if (!directPlatformToken && (!platformUser || !platformPassword)) throw new Erro
if (!directMerchantToken && (!platformUser || !platformPassword)) throw new Error('Missing merchant token or platform credentials')
if (!directPdaToken && (!pdaUser || !pdaPassword)) throw new Error('Missing PDA credentials or token')
const report = { marker, startedAt: new Date().toISOString(), scope: 'OVERSEAS_LASTLEG_ONLY', scenarios: [], passed: false }
const report = { marker, startedAt: new Date().toISOString(), scope: 'OVERSEAS_LASTLEG_ONLY', chargeAmount, photoEvidence: 'SYNTHETIC_PNG_NOT_PHYSICAL_WORK', scenarios: [], passed: false }
function key(value) { return `${marker}-${value}`.slice(0, 200) }
function id(value) { return value == null ? '' : String(value) }
async function api(base, token, method, path, body, headers = {}) {
@@ -70,7 +72,7 @@ async function resolvePallet(palletCode) {
return api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode })
}
async function createAndApprove(name, cartonCount) {
async function createAndConfirm(name, cartonCount, operationType = 'CARTON_AND_SKU', prepare = null) {
const suffix = `${name}-${Date.now()}`
const items = Array.from({ length: cartonCount }, (_, index) => ({
sourceInventoryBalanceId: id(source.inventoryBalanceId), sourceInventoryPositionId: id(sourcePosition.positionId),
@@ -78,49 +80,68 @@ async function createAndApprove(name, cartonCount) {
productNameZh: source.productNameZh || source.skuCode, totalPackageCount: 1, notifiedPackageCount: 1,
shipmentNo: `SHIP-${suffix}-${index + 1}`, newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128),
labelRequirement: marker, cartonMark: `CM-${suffix}-${index + 1}`,
fbaCartonMark: `FBA-${suffix}-${index + 1}`, destinationFbaWarehouseCode: source.destinationFbaWarehouseCode,
operationType: 'CARTON_AND_SKU', skuItems: [{ skuCode: source.skuCode,
fbaCartonMark: operationType === 'CARTON_AND_SKU' ? `FBA-${suffix}-${index + 1}` : null,
destinationFbaWarehouseCode: source.destinationFbaWarehouseCode,
operationType, skuItems: [{ skuCode: source.skuCode,
newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), quantity: 1, remark: marker }],
}))
const created = await api(overseas, platformToken, 'POST',
`/api/platform/v1/last-leg/relabel-applications?customerId=${customerId}`, {
customerOrderNo: `TAIL-${suffix}`.slice(0, 128), operationWarehouseId: warehouseId,
labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19),
contactName: '尾程验收', contactPhone: '13800000006', chargeAmount: 0.01,
remark: `${marker}-${name}`, idempotencyKey: key(`${name}-CREATE`), deliveryMethod: 'TRUCK',
contactName: '尾程验收', contactPhone: '13800000006', ...(chargeAmount > 0 ? { chargeAmount } : {}),
remark: `${marker}-${name}`, idempotencyKey: key(`${name}-CREATE`),
deliveryMethod: operationType === 'SKU_LABEL' ? null : 'TRUCK',
printShippingLabel: false, items,
})
const relabelNo = created.application.relabelNo
await poll(() => api(overseas, platformToken, 'GET',
if (chargeAmount > 0) await poll(() => api(overseas, platformToken, 'GET',
`/api/platform/v1/last-leg/relabel-applications/${relabelNo}`),
(detail) => detail.application.walletStatus === 'FROZEN')
let detail = await api(overseas, pdaToken, 'POST',
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key(`${name}-CLAIM`) })
for (const [index, item] of detail.items.entries()) {
if (operationType === 'CARTON_AND_SKU') {
detail = await api(overseas, pdaToken, 'POST',
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, {
cartonMark: item.cartonMark, expectedVersion: Number(detail.version),
idempotencyKey: key(`${name}-CARTON-${index}`),
})
const stageItem = detail.items.find((row) => id(row.itemId) === id(item.itemId))
assert(Number(stageItem?.cartonProcessedPackages) === 1
&& Number(stageItem?.skuProcessedPackages) === 0,
`${relabelNo} carton stage incorrectly locked or completed SKU stage`)
}
detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, {
itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: item.fbaCartonMark,
itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: null,
skuVerifications: (item.skuItems || []).map((sku) => ({
skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity),
})),
expectedVersion: Number(detail.version), idempotencyKey: key(`${name}-SCAN-${index}`),
})
}
const sourceBefore = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages)
const scenario = { name, operationType, relabelNo, items: detail.items, sourceBefore }
if (prepare) await prepare(scenario)
const form = new FormData()
form.append('file', new Blob([Buffer.from('89504e470d0a1a0a', 'hex')], { type: 'image/png' }), `${marker}.png`)
// Valid synthetic PNG for HTTP validation; not a photograph of real warehouse work.
form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), `${marker}.png`)
const photo = await api(overseas, pdaToken, 'POST',
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/photos/files`, form)
detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/complete`, {
photoFileIds: [id(photo.fileId)], expectedVersion: Number(detail.version),
idempotencyKey: key(`${name}-COMPLETE`), remark: marker,
})
detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/review`, {
decision: 'APPROVE', expectedVersion: Number(detail.version),
idempotencyKey: key(`${name}-APPROVE`), remark: marker,
})
assert(detail.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', `${relabelNo} did not reach customer confirmation`)
const confirmed = await api(overseas, merchantToken, 'POST',
`/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/confirm-completion`, {
idempotencyKey: key(`${name}-CONFIRM`), remark: marker,
})
assert(confirmed.application.status === 'READY_FOR_OUTBOUND', `${relabelNo} did not reach READY_FOR_OUTBOUND`)
return { name, relabelNo, items: confirmed.items, sourceBefore: Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) }
const expectedStatus = operationType === 'SKU_LABEL' ? 'COMPLETED' : 'READY_FOR_OUTBOUND'
assert(confirmed.application.status === expectedStatus, `${relabelNo} did not reach ${expectedStatus}`)
assert(confirmed.items.every((item) => item.destinationFbaWarehouseCode === source.destinationFbaWarehouseCode),
`${relabelNo} did not preserve the destination warehouse`)
return { ...scenario, items: confirmed.items }
}
async function printPallet(relabelNo, suffix) {
@@ -141,26 +162,38 @@ async function moveCarton(scenario, item, target, index) {
return result
}
const fresh = await createAndApprove('NEW-PALLET', 1)
const sharedTarget = await printPallet(fresh.relabelNo, 'SHARED')
const printReplay = await printPallet(fresh.relabelNo, 'SHARED')
assert(id(sharedTarget.palletId) === id(printReplay.palletId), 'Pallet print replay was not idempotent')
await moveCarton(fresh, fresh.items[0], sharedTarget, 0)
fresh.targets = [sharedTarget]
const skuOnly = await createAndConfirm('SKU-ONLY', 1, 'SKU_LABEL')
skuOnly.sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages)
assert(skuOnly.sourceAfter === skuOnly.sourceBefore, 'SKU-only relabel must not split or regroup pallets')
skuOnly.targetPallets = []
skuOnly.destinationFbaWarehouseCode = source.destinationFbaWarehouseCode
delete skuOnly.items
let sharedTarget
const fresh = await createAndConfirm('NEW-PALLET', 1, 'CARTON_AND_SKU', async scenario => {
sharedTarget = await printPallet(scenario.relabelNo, 'SHARED')
const printReplay = await printPallet(scenario.relabelNo, 'SHARED')
assert(id(sharedTarget.palletId) === id(printReplay.palletId), 'Pallet print replay was not idempotent')
await moveCarton(scenario, scenario.items[0], sharedTarget, 0)
scenario.targets = [sharedTarget]
})
const merged = await createAndApprove('MERGE-EXISTING', 1)
const sharedBefore = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages)
await moveCarton(merged, merged.items[0], sharedTarget, 0)
const merged = await createAndConfirm('MERGE-EXISTING', 1, 'CARTON_AND_SKU', async scenario => {
await moveCarton(scenario, scenario.items[0], sharedTarget, 0)
scenario.targets = [sharedTarget]
})
const sharedAfter = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages)
assert(sharedAfter === sharedBefore + 1, 'Existing target pallet carton count did not increase')
merged.targets = [sharedTarget]
const split = await createAndApprove('SPLIT-TWO-TARGETS', 2)
const splitA = await printPallet(split.relabelNo, 'SPLIT-A')
const splitB = await printPallet(split.relabelNo, 'SPLIT-B')
await moveCarton(split, split.items[0], splitA, 0)
await moveCarton(split, split.items[1], splitB, 1)
split.targets = [splitA, splitB]
const split = await createAndConfirm('SPLIT-TWO-TARGETS', 2, 'CARTON_AND_SKU', async scenario => {
const splitA = await printPallet(scenario.relabelNo, 'SPLIT-A')
const splitB = await printPallet(scenario.relabelNo, 'SPLIT-B')
await moveCarton(scenario, scenario.items[0], splitA, 0)
await moveCarton(scenario, scenario.items[1], splitB, 1)
scenario.targets = [splitA, splitB]
})
for (const scenario of [fresh, merged, split]) {
const sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages)
@@ -191,7 +224,7 @@ const ledgerRows = ledger.records.filter((row) => String(row.remark || '').inclu
assert(ledgerRows.some((row) => Number(row.deltaPackages) < 0), 'Missing negative relabel pallet ledger')
assert(ledgerRows.some((row) => Number(row.deltaPackages) > 0), 'Missing positive relabel pallet ledger')
assert(ledgerRows.every((row) => String(row.remark).startsWith('换标拆托')), 'Unexpected relabel ledger reason')
report.scenarios = [fresh, merged, split]
report.scenarios = [skuOnly, fresh, merged, split]
report.ledger = { rows: ledgerRows.length, deltas: ledgerRows.map((row) => Number(row.deltaPackages)) }
report.passed = true
report.finishedAt = new Date().toISOString()