Merge origin/dev into test preserving warehouse and PDA workflows
This commit is contained in:
@@ -103,11 +103,11 @@ test('dropship measurement completes the formal palletization workflow before sm
|
||||
assert.match(source, /pda-dropship\/tasks\/\$\{taskId\}\/claim/)
|
||||
assert.match(source, /cartons\/resolve/)
|
||||
assert.match(source, /cartons\/count/)
|
||||
assert.match(source, /pallets\/create-and-bind/)
|
||||
assert.match(source, /系统发码重放不新增托盘且业务码和计数稳定/)
|
||||
assert.match(source, /拒绝创建没有空托或计数漂移/)
|
||||
assert.match(source, /pallets\/bind-pending/)
|
||||
assert.match(source, /预生成托盘绑定重放不新增托盘且计数稳定/)
|
||||
assert.match(source, /预生成托盘覆盖测量冻结的全部箱数/)
|
||||
assert.doesNotMatch(source, /palletHash|palletDate/)
|
||||
assert.match(source, /pallets\/\$\{palletId\}\/seal/)
|
||||
assert.match(source, /pallets\/\$\{asId\(pallet.palletId\)\}\/seal/)
|
||||
assert.match(source, /重复清点操作按operationId幂等返回/)
|
||||
assert.doesNotMatch(source, /pallets\/open/)
|
||||
assert.doesNotMatch(source, /pallets\/\$\{palletId\}\/scan/)
|
||||
@@ -124,7 +124,7 @@ test('confirmed package count reconciles an uncertain transport result before re
|
||||
|
||||
async function exerciseSystemPalletScenario(fault = '') {
|
||||
const task = { taskId: '81', inboundId: '82', warehouseId: '83', taskStatus: 'CLAIMED',
|
||||
totalCartons: 2, countedCartons: 0, boundCartons: 0, unboundCartons: 0, pallets: [] }
|
||||
totalCartons: 2, countedCartons: 0, boundCartons: 0, unboundCartons: 0, pallets: [{ palletId: '84', palletNo: 'DP-260909-0001', cartonCount: 0, status: 'OPEN', version: 1 }] }
|
||||
const seenCounts = new Map()
|
||||
const seenCreates = new Set()
|
||||
const checks = []
|
||||
@@ -139,32 +139,28 @@ async function exerciseSystemPalletScenario(fault = '') {
|
||||
async request(method, path, body) {
|
||||
if (path.includes('/pending?')) return response([task])
|
||||
if (path.endsWith('/claim') || (method === 'GET' && path.includes('/tasks/81?'))) return response(task)
|
||||
if (path.includes('/cartons/resolve?')) return response({ boxCode: new URL(path, 'http://fixture').searchParams.get('boxCode'), canCount: true, expectedSkuQuantity: 10 })
|
||||
if (path.includes('/cartons/resolve?')) return response({ boxCode: new URL(path, 'http://fixture').searchParams.get('boxCode'), canCount: true, skuLines: [{ inboundDetailId: '91', skuCode: 'SKU-1', expectedQuantity: 10 }] })
|
||||
if (path.endsWith('/cartons/count')) {
|
||||
if (seenCounts.has(body.operationId)) return seenCounts.get(body.operationId)
|
||||
task.countedCartons += 1
|
||||
const targetPallet = task.pallets.find(pallet => pallet.palletNo === body.palletNo)
|
||||
if (body.palletNo) { task.boundCartons += 1; targetPallet.cartonCount += 1; targetPallet.version += 1 }
|
||||
else task.unboundCartons += 1
|
||||
const result = response({ ...task, carton: { boxCode: body.boxCode, expectedSkuQuantity: 10, actualSkuQuantity: 10,
|
||||
discrepancyStatus: 'MATCH' }, pallet: body.palletNo ? targetPallet : null })
|
||||
const result = response({ ...task, carton: { boxCode: body.boxCode, skuLines: [{ inboundDetailId: '91', skuCode: 'SKU-1', expectedQuantity: 10, actualQuantity: 10, discrepancyStatus: 'MATCH' }] }, pallet: body.palletNo ? targetPallet : null })
|
||||
seenCounts.set(body.operationId, result)
|
||||
return result
|
||||
}
|
||||
if (path.endsWith('/create-and-bind')) {
|
||||
assert.deepEqual(Object.keys(body).sort(), ['operationId', 'warehouseId'])
|
||||
if (path.endsWith('/bind-pending')) {
|
||||
assert.deepEqual(Object.keys(body).sort(), ['operationId', 'palletNo', 'warehouseId'])
|
||||
if (seenCreates.has(body.operationId)) {
|
||||
if (fault === 'duplicate-replay') task.pallets.push({ ...task.pallets[0], palletId: '999', palletNo: 'DP-260909-0999' })
|
||||
return response(task)
|
||||
}
|
||||
if (!task.unboundCartons) {
|
||||
if (fault === 'empty-pallet') task.pallets.push({ palletId: '998', palletNo: 'DP-260909-0998', cartonCount: 0, status: 'OPEN', version: 0 })
|
||||
return response(null, 'A0409')
|
||||
}
|
||||
seenCreates.add(body.operationId)
|
||||
task.pallets.push({ palletId: '84', palletNo: 'DP-260909-0001', cartonCount: task.unboundCartons, status: 'OPEN', version: 0 })
|
||||
task.pallets[0].cartonCount += task.unboundCartons
|
||||
task.boundCartons += task.unboundCartons
|
||||
task.unboundCartons = 0
|
||||
if (fault === 'empty-pallet') task.pallets.push({ palletId: '998', palletNo: 'DP-260909-0998', cartonCount: 0, status: 'SEALED', version: 1 })
|
||||
return response(task)
|
||||
}
|
||||
if (path.endsWith('/84/seal')) {
|
||||
@@ -175,21 +171,21 @@ async function exerciseSystemPalletScenario(fault = '') {
|
||||
throw new Error(`Unexpected scenario request: ${method} ${path}`)
|
||||
},
|
||||
}
|
||||
const sealed = await completeDropshipPalletization(context, { inboundId: '82', inboundCode: 'CODE', warehouseId: '83', measurement: { actualPackageCount: 2 } })
|
||||
const sealed = await completeDropshipPalletization(context, { inboundId: '82', inboundCode: 'CODE', warehouseId: '83', measurement: { actualPackageCount: 2, expectedPalletCount: 1 } })
|
||||
assert.deepEqual(sealed.sealedCartonMarks, ['CODE#2-1', 'CODE#2-2'])
|
||||
assert.deepEqual(sealed.sealedSourcePalletNos, ['DP-260909-0001'])
|
||||
if (!fault) assert.deepEqual(sealed.sealedSourcePalletNos, ['DP-260909-0001'])
|
||||
return checks
|
||||
}
|
||||
|
||||
test('system-issued pallet HTTP scenario covers count, replay, empty rejection and completed replay', async () => {
|
||||
test('pregenerated pallet scenario covers multi-SKU count, bind replay and sealing', async () => {
|
||||
const checks = await exerciseSystemPalletScenario()
|
||||
assert.ok(checks.length >= 15)
|
||||
for (const name of ['审核通过时已按预计托数预生成全部DP托盘码', '重复清点没有重复累计箱数', '待组托箱绑定到OPEN托盘且不再游离', '预生成托盘绑定重放不新增托盘且计数稳定', '预生成托盘覆盖测量冻结的全部箱数', '全部箱数封托后入仓单允许进入WAIT_SPLIT']) assert.ok(checks.some(check => check.name === name), name)
|
||||
assert.deepEqual(checks.filter(check => !check.passed), [])
|
||||
})
|
||||
|
||||
test('system-issued pallet HTTP regression detects duplicate replay and orphan empty pallets', async () => {
|
||||
test('pregenerated pallet regression detects duplicate replay and orphan empty pallets', async () => {
|
||||
const replayChecks = await exerciseSystemPalletScenario('duplicate-replay')
|
||||
assert.ok(replayChecks.some(check => check.name.includes('系统发码重放不新增托盘') && !check.passed))
|
||||
assert.ok(replayChecks.some(check => check.name.includes('预生成托盘绑定重放不新增托盘') && !check.passed))
|
||||
const emptyChecks = await exerciseSystemPalletScenario('empty-pallet')
|
||||
assert.ok(emptyChecks.some(check => check.name.includes('拒绝创建没有空托或计数漂移') && !check.passed))
|
||||
assert.ok(emptyChecks.some(check => check.name.includes('预生成托盘覆盖测量冻结的全部箱数') && !check.passed))
|
||||
})
|
||||
|
||||
@@ -191,6 +191,13 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
|
||||
&& pallet?.status === 'OPEN' && pallet?.palletNo === firstPallet.palletNo
|
||||
&& bound?.pallets?.length === expectedPalletCount,
|
||||
`unbound=${bound?.unboundCartons ?? 'missing'}, palletStatus=${pallet?.status ?? 'missing'}`, OPTIONS)
|
||||
const replayBindResponse = await context.request('POST',
|
||||
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/bind-pending`, bindPayload)
|
||||
const replayBind = context.apiSuccess(MODULE, '预生成托盘绑定同键重放', replayBindResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '预生成托盘绑定重放不新增托盘且计数稳定', replayBindResponse,
|
||||
replayBind?.pallets?.length === expectedPalletCount
|
||||
&& Number(replayBind?.boundCartons) === Number(bound?.boundCartons)
|
||||
&& Number(replayBind?.unboundCartons) === 0, '', OPTIONS)
|
||||
} else {
|
||||
const expectedPallet = generatedPallets[Math.min(index, generatedPallets.length - 1)]
|
||||
context.assert(MODULE, `第${index + 1}箱直接进入当前OPEN托盘`, countResponse,
|
||||
|
||||
@@ -113,12 +113,12 @@ test('海外空库基线为固定平台管理员提供完整version0授权投影
|
||||
'platform:overseas-warehouse:product:create',
|
||||
'platform:overseas-warehouse:product:edit',
|
||||
])
|
||||
assert.equal(authorities.length, 105)
|
||||
assert.equal(authorities.length, 106)
|
||||
assert.equal(new Set(authorities).size, authorities.length, 'baseline authorities must not contain duplicates')
|
||||
assert.equal(authorities.filter((item) => item.startsWith('platform:finance:')).length, 34)
|
||||
assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-business:')).length, 11)
|
||||
assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-master-data:')).length, 15)
|
||||
assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-warehouse:')).length, 45)
|
||||
assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-warehouse:')).length, 46)
|
||||
for (const authority of authorities) {
|
||||
assert.ok(sql.includes(`'${authority}'`), `authority is absent from the domestic IAM baseline: ${authority}`)
|
||||
}
|
||||
@@ -186,3 +186,10 @@ test('三期权限迁移删除退役权限并只保留 canonical 权限', () =>
|
||||
assert.match(sql, /status='ACTIVE' AND deleted=0[\s\S]*?id BETWEEN 5102140 AND 5102149/)
|
||||
assert.match(sql, /status='ACTIVE' AND deleted=0[\s\S]*?id BETWEEN 5102150 AND 5102154/)
|
||||
})
|
||||
|
||||
test('海外仓物理托盘初始化与模块基线一致', () => {
|
||||
const baseline = readFileSync(new URL('../../warehouse-overseas-service/src/main/resources/db/warehouse.sql', import.meta.url), 'utf8')
|
||||
const inline = overseasSql.match(/-- BEGIN INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE\r?\n([\s\S]*?)\r?\n\r?\n-- END INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE/)?.[1]
|
||||
assert.ok(inline)
|
||||
assert.equal(inline.slice(inline.indexOf('-- This script is run')).replace(/\r\n/g, '\n').trim(), baseline.replace(/\r\n/g, '\n').trim())
|
||||
})
|
||||
|
||||
@@ -50,7 +50,8 @@ test('批量待分柜参数固定同一客户并限制为订单测量链', () =>
|
||||
assert.match(acceptance, /orderBlockingFailure[\s\S]*step\.deploymentBlocker/)
|
||||
assert.match(acceptance, /measurementBlockingFailure[\s\S]*step\.deploymentBlocker/)
|
||||
assert.doesNotMatch(acceptance, /this\.modules\.at\(-1\)\?\.success !== true/)
|
||||
assert.match(acceptance, /measurement\.processStatus === 'WAIT_SPLIT'/)
|
||||
assert.match(acceptance, /measurement\.processStatus === expectedProcessStatus/)
|
||||
assert.match(acceptance, /prepareDropshipManualCount \? 'WAIT_PALLETIZE' : 'WAIT_SPLIT'/)
|
||||
assert.match(acceptance, /measurement\.finalStatus === 'APPROVED'/)
|
||||
assert.match(acceptance, /waitSplitVariant\(sequence\)/)
|
||||
assert.match(acceptance, /packageCountPerSku/)
|
||||
|
||||
@@ -18,7 +18,7 @@ test('order fixture defaults to FBA and preserves explicit OVERSEAS or PRIVATE r
|
||||
const orders = read('orders.mjs')
|
||||
assert.match(orders, /warehouseType=\$\{encodeURIComponent\(destinationType\)\}/)
|
||||
assert.match(orders, /destinationType: master\.destinationType/)
|
||||
assert.match(orders, /fulfillmentType: String\(context\.context\?\.orderFulfillmentType \|\| 'STANDARD'\)/)
|
||||
assert.match(orders, /fulfillmentType: dropship \? 'ONE_CLICK_DROPSHIP' : 'STANDARD'/)
|
||||
assert.match(orders, /skuCodes: payload\.cargoes\.map\(\(cargo\) => cargo\.skuNo\)/)
|
||||
assert.match(orders, /skuLines: payload\.cargoes\.map\(\(cargo\) => \(\{/)
|
||||
assert.match(orders, /destinationType,\s*\n\s*\}\)/)
|
||||
|
||||
@@ -49,3 +49,19 @@ test('module records the marker-owned dispatch without attempting excluded clean
|
||||
assert.match(source, /finalStatus = arranged\.data\?\.status/)
|
||||
assert.match(source, /PENDING_DEPARTURE dispatch is retained as marker-owned acceptance evidence/)
|
||||
})
|
||||
|
||||
test('module prefers marker-owned relabel pallets and proves only new FBA carton marks are frozen', () => {
|
||||
assert.match(source, /phase4OverseasRelabel/)
|
||||
assert.match(source, /sourceStage === 'RELABEL'/)
|
||||
assert.match(source, /relabel\.targetPalletIds\.includes/)
|
||||
assert.match(source, /relabelCartonMarks/)
|
||||
assert.match(source, /!String\(value\?\.cartonMarks \|\| ''\)\.trim\(\)/)
|
||||
assert.match(source, /relabelNo: relabel\.relabelNo/)
|
||||
})
|
||||
|
||||
test('relabel settlement follows pre-arrange and does not replace the ordinary dispatch lifecycle', () => {
|
||||
assert.match(source, /await runRelabelDispatch\(context, relabel\)[\s\S]*if \(relabel.dispatchNo\) await settleRelabelFulfillment\(context, relabel\)/)
|
||||
const branch = source.slice(source.indexOf('const relabel = context.createdResources'), source.indexOf('const fixture = markerFixture(context)', source.indexOf('const relabel = context.createdResources')))
|
||||
assert.doesNotMatch(branch, /\breturn\b/)
|
||||
assert.match(source, /Object.assign\(relabel, \{/)
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { settleRelabelFulfillment } from './phase4-overseas-pda-operations.mjs'
|
||||
|
||||
const MODULE = 'phase4-overseas-dispatch'
|
||||
const SCENARIO = 'phase4-overseas-dispatch-lifecycle'
|
||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||
@@ -39,6 +41,49 @@ function markerFixture(context) {
|
||||
return { ...fixture, palletIds }
|
||||
}
|
||||
|
||||
async function runRelabelDispatch(context, relabel) {
|
||||
const candidateResult = await api(context, 'GET', '查询本轮换标托盘智能排车候选',
|
||||
query(`${BASE}/candidates`, { orderNo: relabel.relabelNo, sourceStage: 'RECEIVED' }))
|
||||
const candidates = (Array.isArray(candidateResult.data) ? candidateResult.data : [])
|
||||
.filter((value) => value?.sourceStage === 'RELABEL'
|
||||
&& relabel.targetPalletIds.includes(id(value?.warehousePalletId)))
|
||||
const actualMarks = candidates.flatMap((value) => String(value?.relabelCartonMarks || '')
|
||||
.split(',').map((mark) => mark.trim()).filter(Boolean))
|
||||
context.assert(MODULE, '智能排车只带出本轮换标后的FBA箱唛', candidateResult.response,
|
||||
candidates.length === relabel.targetPalletIds.length
|
||||
&& relabel.relabelCartonMarks.every((mark) => actualMarks.includes(mark))
|
||||
&& candidates.every((value) => !String(value?.cartonMarks || '').trim()),
|
||||
`pallets=${candidates.length}, relabelMarks=${actualMarks.join(',')}, normalMarks=${candidates.map((value) => value?.cartonMarks).filter(Boolean).join(',')}; ${trace(candidateResult.response)}`,
|
||||
OPTIONS)
|
||||
if (candidates.length !== relabel.targetPalletIds.length) return
|
||||
|
||||
const arranged = await api(context, 'POST', '将本轮换标托盘创建待出库排车', `${BASE}/pre-arrange`, {
|
||||
planPalletIds: candidates.map((value) => id(value.warehousePalletId)),
|
||||
route: `${context.marker}-RELABEL-ROUTE`, fbaAppointmentNo: null,
|
||||
fbaAppointmentStatus: null, vehicleAppointmentStatus: null, relabelNo: relabel.relabelNo,
|
||||
})
|
||||
const dispatchNo = id(arranged.data?.dispatchNo)
|
||||
context.assert(MODULE, '换标多托盘原子预排并冻结换标箱唛', arranged.response,
|
||||
Boolean(dispatchNo) && arranged.data?.status === 'PENDING_DEPARTURE'
|
||||
&& Number(arranged.data?.palletCount) === candidates.length
|
||||
&& Array.isArray(arranged.data?.items)
|
||||
&& arranged.data.items.every((item) => item?.sourceStage === 'RELABEL'),
|
||||
`dispatchNo=${dispatchNo || 'missing'}, pallets=${arranged.data?.palletCount ?? 'missing'}; ${trace(arranged.response)}`,
|
||||
OPTIONS)
|
||||
if (!dispatchNo) return
|
||||
Object.assign(relabel, {
|
||||
dispatchNo, dispatchId: id(arranged.data?.id), finalStatus: arranged.data?.status || null,
|
||||
relabelNo: relabel.relabelNo, planPalletId: null,
|
||||
})
|
||||
const detail = await api(context, 'GET', '回查本轮换标排车详情', `${BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
const detailMarks = (detail.data?.items || []).flatMap((item) => String(item?.cartonMarks || '')
|
||||
.split(',').map((mark) => mark.trim()).filter(Boolean))
|
||||
context.assert(MODULE, '排车详情冻结全部换标后箱唛且不混入原箱唛', detail.response,
|
||||
relabel.relabelCartonMarks.every((mark) => detailMarks.includes(mark))
|
||||
&& relabel.sourceCartonMarks.every((mark) => !detailMarks.includes(mark)),
|
||||
`frozenMarks=${detailMarks.join(',')}; ${trace(detail.response)}`, OPTIONS)
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
context.createdResources.phase4OverseasDispatch = {
|
||||
marker: context.marker, dispatchNo: null, dispatchId: null, draftToken: null,
|
||||
@@ -53,6 +98,12 @@ export async function run(context) {
|
||||
`records=${records(initialPage.data).length}; ${trace(initialPage.response)}`, OPTIONS)
|
||||
if (!success(initialPage.response)) return
|
||||
|
||||
const relabel = context.createdResources?.phase4OverseasRelabel
|
||||
if (relabel?.marker === context.marker && relabel?.status === 'READY_FOR_OUTBOUND') {
|
||||
await runRelabelDispatch(context, relabel)
|
||||
if (relabel.dispatchNo) await settleRelabelFulfillment(context, relabel)
|
||||
}
|
||||
|
||||
const fixture = markerFixture(context)
|
||||
if (!fixture) {
|
||||
blocker(context, '取得本轮海外PDA已收货托盘前置',
|
||||
|
||||
@@ -43,7 +43,7 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho
|
||||
'/api/pda/v1/last-leg/stocktakes',
|
||||
'/api/pda/v1/last-leg/inventory-moves',
|
||||
'/api/pda/v1/last-leg/pallet-operations/pallets/resolve',
|
||||
'/swap', '/disassemble', '/assemble', '/unbind',
|
||||
'/swap', '/disassemble', '/assemble', '/unbind', '/relabel-pallet-label', '/relabel-disassemble',
|
||||
'/api/pda/v1/last-leg/relabel-tasks',
|
||||
'/candidates', '/claim', '/units', '/skip', '/exceptions', '/photos/files', '/complete', '/review',
|
||||
]) assert.ok(source.includes(endpoint), endpoint)
|
||||
@@ -52,15 +52,16 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho
|
||||
'chargeAmount: 0.01', 'cartonMark:', 'fbaCartonMark:',
|
||||
'inventory-candidates', 'sourceInventoryBalanceId:', 'sourceInventoryPositionId:',
|
||||
'sourceInboundOrderId:', 'operationWarehouseId:', 'relabelItemForUnit', 'sourceLines.map',
|
||||
'const packageCount = 1', 'line.packageCount += additionalPackages', 'oldLabel: item.cartonMark',
|
||||
'sourceLines.push({ source, position, packageCount: 1 })', 'oldLabel: item.cartonMark',
|
||||
'id(candidate.itemId) !== id(item.itemId)', 'oldLabel: null',
|
||||
'newLabel: item.fbaCartonMark || item.newSkuCode',
|
||||
"operationType: changesCartonLabel ? 'CARTON_AND_SKU' : 'SKU_LABEL'", 'skuItems:',
|
||||
"deliveryMethod: 'TRUCK'", 'printShippingLabel: false',
|
||||
'P5换标费用冻结后开放PDA作业', 'P5换标完成后钱包结算收敛']) {
|
||||
'P5换标费用冻结后开放PDA作业', 'P5换标完成后钱包结算收敛', 'CUSTOMER_CONFIRM_PENDING', 'confirm-completion',
|
||||
'P5库存流水记录换标拆托原因和正负变动', 'phase4OverseasRelabel']) {
|
||||
assert.ok(source.includes(marker), marker)
|
||||
}
|
||||
for (const forbidden of ['/outbound', '/returns/outbound', '/dispatch', '/delivery', '/internal/']) {
|
||||
for (const forbidden of ['/returns/outbound', '/dispatch', '/delivery', '/internal/']) {
|
||||
assert.equal(source.includes(forbidden), false, forbidden)
|
||||
}
|
||||
assert.doesNotMatch(source, /fetch\(|https?:\/\//)
|
||||
|
||||
@@ -354,22 +354,17 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
for (const source of orderedCandidates) {
|
||||
if (!id(source?.inventoryBalanceId) || !id(source?.inboundOrderId)) continue
|
||||
for (const position of source?.positions || []) {
|
||||
if (!id(position?.positionId) || Number(position?.availablePackages) <= 0) continue
|
||||
const packageCount = 1
|
||||
sourceLines.push({ source, position, packageCount })
|
||||
requiredPackages -= packageCount
|
||||
const available = Number(position?.availablePackages)
|
||||
if (!id(position?.positionId) || available <= 0) continue
|
||||
const selected = Math.min(requiredPackages, available)
|
||||
for (let packageIndex = 0; packageIndex < selected; packageIndex += 1) {
|
||||
sourceLines.push({ source, position, packageCount: 1 })
|
||||
}
|
||||
requiredPackages -= selected
|
||||
if (requiredPackages === 0) break
|
||||
}
|
||||
if (requiredPackages === 0) break
|
||||
}
|
||||
for (const line of sourceLines) {
|
||||
if (requiredPackages === 0) break
|
||||
const remainingCapacity = Number(line.position.availablePackages) - line.packageCount
|
||||
if (remainingCapacity <= 0) continue
|
||||
const additionalPackages = Math.min(requiredPackages, remainingCapacity)
|
||||
line.packageCount += additionalPackages
|
||||
requiredPackages -= additionalPackages
|
||||
}
|
||||
context.addStep(MODULE, 'P5客户库存候选返回换标权威来源', inventoryCandidatesResponse,
|
||||
requiredPackages === 0,
|
||||
`sourceLines=${sourceLines.length}, selectedPackages=${3 - requiredPackages}; ${trace(inventoryCandidatesResponse)}`, OPTIONS)
|
||||
@@ -387,6 +382,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
contactName: 'PDA换标验收', contactPhone: '13800000006', chargeAmount: 0.01, remark: context.marker,
|
||||
deliveryMethod: 'TRUCK', printShippingLabel: false,
|
||||
idempotencyKey: key(context, 'RELABEL-CREATE'),
|
||||
deliveryMethod: 'TRUCK', printShippingLabel: false,
|
||||
items: sourceLines.map(({ source, position, packageCount }, index) => {
|
||||
const newSkuCode = `${source.skuCode}-NEW-${markerSuffix}-${index + 1}`.slice(0, 128)
|
||||
const changesCartonLabel = nonBlank(source.destinationFbaWarehouseCode)
|
||||
@@ -513,18 +509,140 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
idempotencyKey: key(context, 'RELABEL-APPROVE'), remark: `${context.marker}-approved` }
|
||||
const approved = await request(context, token, 'POST', 'P5复核批准换标完成', reviewPath, approveBody)
|
||||
const approveReplay = await request(context, token, 'POST', 'P5批准同键重放', reviewPath, approveBody)
|
||||
context.assert(MODULE, 'P5仓库复核完成并进入客户确认', approveReplay.response,
|
||||
approved.data?.taskStatus === 'COMPLETED'
|
||||
&& approved.data?.applicationStatus === 'CUSTOMER_CONFIRM_PENDING'
|
||||
&& approveReplay.data?.taskStatus === 'COMPLETED'
|
||||
&& approveReplay.data?.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', trace(approveReplay.response), OPTIONS)
|
||||
const customerConfirm = await context.requestOverseas('POST',
|
||||
`${merchantRelabelBase}/${encodeURIComponent(relabelNo)}/confirm-completion`, {
|
||||
idempotencyKey: key(context, 'RELABEL-CUSTOMER-CONFIRM'), remark: `${context.marker}-customer-confirm`,
|
||||
}, {}, merchantToken)
|
||||
context.addStep(MODULE, 'P5客户确认换标完成进入待出库', customerConfirm,
|
||||
success(customerConfirm) && customerConfirm.body?.data?.application?.status === 'READY_FOR_OUTBOUND',
|
||||
trace(customerConfirm), OPTIONS)
|
||||
context.assert(MODULE, 'P5 PDA批准后等待客户确认', approveReplay.response,
|
||||
approved.data?.taskStatus === 'COMPLETED' && approved.data?.applicationStatus === 'CUSTOMER_CONFIRM_PENDING'
|
||||
&& approveReplay.data?.taskStatus === 'COMPLETED', trace(approveReplay.response), OPTIONS)
|
||||
if (approved.data?.applicationStatus !== 'CUSTOMER_CONFIRM_PENDING') return false
|
||||
|
||||
const confirmBody = { idempotencyKey: key(context, 'RELABEL-CUSTOMER-CONFIRM'),
|
||||
remark: `${context.marker}-customer-confirmed` }
|
||||
const confirmedResponse = await context.requestOverseas('POST',
|
||||
`${merchantRelabelBase}/${encodeURIComponent(relabelNo)}/confirm-completion`, confirmBody, {}, merchantToken)
|
||||
const confirmed = success(confirmedResponse) ? confirmedResponse.body?.data : null
|
||||
context.addStep(MODULE, 'P5客户确认换标完成进入待排车', confirmedResponse,
|
||||
confirmed?.application?.status === 'READY_FOR_OUTBOUND'
|
||||
&& confirmed?.application?.fulfillmentStatus === 'WAIT_PRE_DISPATCH', trace(confirmedResponse), OPTIONS)
|
||||
if (confirmed?.application?.status !== 'READY_FOR_OUTBOUND') return false
|
||||
|
||||
const operationBase = '/api/pda/v1/last-leg/pallet-operations'
|
||||
const printKeyA = key(context, 'RELABEL-PRINT-A')
|
||||
const printedA = await request(context, token, 'POST', 'P5打印换标新托盘A',
|
||||
`${operationBase}/relabel-pallet-label`, { relabelNo, idempotencyKey: printKeyA })
|
||||
const printedAReplay = await request(context, token, 'POST', 'P5换标新托盘A打印同键重放',
|
||||
`${operationBase}/relabel-pallet-label`, { relabelNo, idempotencyKey: printKeyA })
|
||||
context.assert(MODULE, 'P5新托盘标签幂等且返回可打印HTML', printedAReplay.response,
|
||||
id(printedA.data?.palletId) === id(printedAReplay.data?.palletId)
|
||||
&& printedA.data?.palletCode === printedAReplay.data?.palletCode
|
||||
&& String(printedA.data?.printableHtml || '').includes(printedA.data?.palletCode || '__missing__'),
|
||||
trace(printedAReplay.response), OPTIONS)
|
||||
const printedB = await request(context, token, 'POST', 'P5打印换标新托盘B',
|
||||
`${operationBase}/relabel-pallet-label`, {
|
||||
relabelNo, idempotencyKey: key(context, 'RELABEL-PRINT-B'),
|
||||
})
|
||||
if (!printedA.data?.palletCode || !printedB.data?.palletCode) return false
|
||||
|
||||
const targets = [printedA.data, printedB.data]
|
||||
const beforeBySource = new Map()
|
||||
const movedFacts = []
|
||||
for (const [index, itemDetail] of confirmed.items.entries()) {
|
||||
const sourcePositionId = id(itemDetail?.sourceInventoryPositionId)
|
||||
const sourceLine = sourceLines.find((line) => id(line.position.positionId) === sourcePositionId)
|
||||
const sourcePalletCode = sourceLine?.position?.palletNo
|
||||
if (!sourcePalletCode || !itemDetail?.cartonMark) {
|
||||
blocker(context, 'P5换标拆托来源快照', `item=${index + 1}缺少来源托盘或箱唛`, confirmedResponse)
|
||||
return false
|
||||
}
|
||||
const sourceBefore = (await resolvePallet(context, token, sourcePalletCode,
|
||||
`P5拆托${index + 1}前刷新来源托盘`)).data
|
||||
if (!sourceBefore) return false
|
||||
if (!beforeBySource.has(sourcePalletCode)) beforeBySource.set(sourcePalletCode, Number(sourceBefore.totalPackages))
|
||||
// A receives one carton, then B receives one and is reused as an occupied merge target.
|
||||
// If the first two cartons share a source, the same source is split across A and B.
|
||||
const targetSeed = index === 0 ? targets[0] : targets[1]
|
||||
const targetBefore = (await resolvePallet(context, token, targetSeed.palletCode,
|
||||
`P5拆托${index + 1}前刷新目标托盘`)).data
|
||||
const reason = index === 0 ? null : `${context.marker}-场景${index + 1}`
|
||||
const body = {
|
||||
idempotencyKey: key(context, `RELABEL-DISASSEMBLE-${index + 1}`), relabelNo,
|
||||
source: { palletCode: sourceBefore.palletCode, expectedVersion: Number(sourceBefore.palletVersion) },
|
||||
target: { palletCode: targetBefore.palletCode, expectedVersion: Number(targetBefore.palletVersion) },
|
||||
cartonMarks: [itemDetail.cartonMark], reason,
|
||||
}
|
||||
const moved = await request(context, token, 'POST', `P5换标箱${index + 1}拆托`,
|
||||
`${operationBase}/relabel-disassemble`, body)
|
||||
const replay = await request(context, token, 'POST', `P5换标箱${index + 1}拆托同键重放`,
|
||||
`${operationBase}/relabel-disassemble`, body)
|
||||
const sourceAfter = operationPallet(moved.data, sourceBefore.palletCode)
|
||||
const targetAfter = operationPallet(moved.data, targetBefore.palletCode)
|
||||
context.assert(MODULE, `P5换标拆托${index + 1}源减目标增且重放稳定`, replay.response,
|
||||
moved.data?.operationType === 'RELABEL_PALLET_DISASSEMBLE' && replay.data?.replayed === true
|
||||
&& Number(sourceAfter?.totalPackages) === Number(sourceBefore.totalPackages) - 1
|
||||
&& Number(targetAfter?.totalPackages) === Number(targetBefore.totalPackages) + 1,
|
||||
`source=${sourceBefore.totalPackages}->${sourceAfter?.totalPackages}, target=${targetBefore.totalPackages}->${targetAfter?.totalPackages}; ${trace(replay.response)}`,
|
||||
OPTIONS)
|
||||
if (!success(moved.response)) return false
|
||||
movedFacts.push({ sourcePalletCode, targetPalletId: id(targetAfter?.palletId),
|
||||
targetPalletCode: targetAfter?.palletCode, cartonMark: itemDetail.cartonMark,
|
||||
fbaCartonMark: itemDetail.fbaCartonMark, balanceId: id(itemDetail.sourceInventoryBalanceId),
|
||||
inboundOrderId: id(itemDetail.sourceInboundOrderId), inboundOrderNo: itemDetail.sourceInboundOrderNo,
|
||||
inboundCode: itemDetail.sourceInboundCode, packages: Number(itemDetail.notifiedPackageCount), reason })
|
||||
}
|
||||
|
||||
for (const [sourcePalletCode, totalBefore] of beforeBySource) {
|
||||
const sourceAfter = (await resolvePallet(context, token, sourcePalletCode, 'P5拆托后核对来源托盘')).data
|
||||
const movedPackages = movedFacts.filter((fact) => fact.sourcePalletCode === sourcePalletCode)
|
||||
.reduce((sum, fact) => sum + fact.packages, 0)
|
||||
context.assert(MODULE, 'P5来源托盘累计箱数按拆托批次减少', null,
|
||||
Number(sourceAfter?.totalPackages) === totalBefore - movedPackages,
|
||||
`pallet=${sourcePalletCode}, before=${totalBefore}, moved=${movedPackages}, after=${sourceAfter?.totalPackages}`, OPTIONS)
|
||||
}
|
||||
context.assert(MODULE, 'P5覆盖新托盘、既有托盘合托和同源分批拆托', null,
|
||||
new Set(movedFacts.map((fact) => fact.targetPalletId)).size === 2
|
||||
&& movedFacts.filter((fact) => fact.targetPalletId === id(printedB.data.palletId)).length >= 2
|
||||
&& [...beforeBySource.keys()].some((source) => new Set(movedFacts.filter((fact) => fact.sourcePalletCode === source)
|
||||
.map((fact) => fact.targetPalletId)).size >= 2),
|
||||
`targets=${new Set(movedFacts.map((fact) => fact.targetPalletId)).size}, targetBMoves=${movedFacts.filter((fact) => fact.targetPalletId === id(printedB.data.palletId)).length}`, OPTIONS)
|
||||
|
||||
for (const fact of movedFacts) {
|
||||
const positionsResponse = await context.requestOverseas('GET', query(
|
||||
`/api/platform/v1/last-leg/inventory/balances/${encodeURIComponent(fact.balanceId)}/positions`,
|
||||
{ customerId }), undefined)
|
||||
const positions = success(positionsResponse) && Array.isArray(positionsResponse.body?.data)
|
||||
? positionsResponse.body.data : []
|
||||
const targetPosition = positions.find((position) => id(position?.palletId) === fact.targetPalletId)
|
||||
context.addStep(MODULE, `P5换标箱${fact.cartonMark}保留客户和原入仓归属`, positionsResponse,
|
||||
id(targetPosition?.customerId) === customerId
|
||||
&& id(targetPosition?.inboundOrderId) === fact.inboundOrderId
|
||||
&& targetPosition?.inboundOrderNo === fact.inboundOrderNo
|
||||
&& targetPosition?.inboundCode === fact.inboundCode,
|
||||
`targetPallet=${fact.targetPalletCode}, inbound=${targetPosition?.inboundOrderNo || 'missing'}; ${trace(positionsResponse)}`, OPTIONS)
|
||||
}
|
||||
const ledgerResponse = await context.requestOverseas('GET', query(
|
||||
'/api/platform/v1/last-leg/inventory/ledger', {
|
||||
customerId, page: 1, size: 100, businessType: 'RELABEL_PALLET_DISASSEMBLE',
|
||||
occurredFrom: new Date(Date.now() - 10 * 60_000).toISOString().slice(0, 19),
|
||||
}))
|
||||
const ledgerRows = success(ledgerResponse) ? records(ledgerResponse.body?.data) : []
|
||||
const relabelLedgers = ledgerRows.filter((row) => movedFacts.some((fact) =>
|
||||
row?.palletNo === fact.sourcePalletCode || row?.palletNo === fact.targetPalletCode))
|
||||
context.addStep(MODULE, 'P5库存流水记录换标拆托原因和正负变动', ledgerResponse,
|
||||
relabelLedgers.some((row) => Number(row?.deltaPackages) < 0)
|
||||
&& relabelLedgers.some((row) => Number(row?.deltaPackages) > 0)
|
||||
&& relabelLedgers.every((row) => String(row?.remark || '').startsWith('换标拆托')),
|
||||
`rows=${relabelLedgers.length}, deltas=${relabelLedgers.map((row) => row.deltaPackages).join(',')}; ${trace(ledgerResponse)}`, OPTIONS)
|
||||
|
||||
context.createdResources.phase4OverseasRelabel = {
|
||||
marker: context.marker, relabelNo, customerId, overseasWarehouseId: id(fixture.overseasWarehouseId),
|
||||
targetPalletIds: [...new Set(movedFacts.map((fact) => fact.targetPalletId))],
|
||||
relabelCartonMarks: movedFacts.map((fact) => fact.fbaCartonMark).filter(nonBlank),
|
||||
sourceCartonMarks: movedFacts.map((fact) => fact.cartonMark), status: 'READY_FOR_OUTBOUND',
|
||||
}
|
||||
return success(approved.response)
|
||||
}
|
||||
|
||||
export async function settleRelabelFulfillment(context, relabel) {
|
||||
const relabelNo = relabel.relabelNo
|
||||
const relabelBase = '/api/platform/v1/last-leg/relabel-applications'
|
||||
const terminal = await context.requestOverseas('PUT',
|
||||
`${relabelBase}/${encodeURIComponent(relabelNo)}/status`, {
|
||||
status: 'COMPLETED', remark: `${context.marker}-fulfillment-complete`,
|
||||
@@ -536,7 +654,8 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
context.addStep(MODULE, 'P5换标完成后钱包结算收敛', settled.result,
|
||||
settled.result?.body?.data?.application?.walletStatus === 'SETTLED',
|
||||
`pollRequests=${settled.attempts}, walletStatus=${settled.result?.body?.data?.application?.walletStatus || 'missing'}; ${trace(settled.result)}`, OPTIONS)
|
||||
return success(approved.response) && settled.result?.body?.data?.application?.walletStatus === 'SETTLED'
|
||||
relabel.status = terminal.body?.data?.application?.status || relabel.status
|
||||
return success(terminal) && settled.result?.body?.data?.application?.walletStatus === 'SETTLED'
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
|
||||
Reference in New Issue
Block a user