Files
baiyue-firstleg/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs
T

354 lines
30 KiB
JavaScript

const MODULE = 'phase4-overseas-inbound-operations'
const OPTIONS = { scenario: 'phase4-inbound-admin-and-inventory-commands', deploymentBlocker: true }
const PLAN_BASE = '/api/platform/v1/last-leg/inbound-plans'
const INVENTORY_BASE = '/api/platform/v1/last-leg/inventory'
const APPOINTMENT_BASE = '/api/platform/v1/last-leg/appointment-planning'
function id(value) { return value == null ? '' : String(value) }
function count(value) {
const parsed = value == null || value === '' ? NaN : Number(value)
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : NaN
}
function ok(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
function rows(data) { return Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : [] }
function conflict(response) { return [200, 409].includes(response?.http) && String(response?.body?.code) === 'A0409' && response.body?.data == null }
function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? '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 != null && value !== '') params.set(name, String(value))
return `${path}?${params.toString()}`
}
function key(context, operation) { return `${context.marker}:P4-INBOUND:${operation}`.slice(0, 255) }
async function request(context, method, name, path, body, headers = {}) {
const response = await context.request(method, path, body, undefined, headers)
context.addStep(MODULE, name, response, ok(response), trace(response), OPTIONS)
if (!ok(response)) throw Error(`${name}: ${trace(response)}; stop, retain original request and sample`)
return response.body?.data
}
function requireProof(context, name, condition, evidence) {
context.assert(MODULE, name, null, condition, evidence, OPTIONS)
if (!condition) throw Error(`${name}: ${evidence}`)
}
/** Current-run original-domain producer: verifies actual sources, proves cancellation, retains a fresh active batch. */
export async function prepareAppointmentFixture(context, { customerId, plan, fbaLines }) {
const sample = fbaLines[0]
requireProof(context, '本轮预约只选择同接仓点同目的仓真实来源', Boolean(customerId && plan?.planNo && plan?.overseasWarehouseId && sample?.fbaWarehouseCode)
&& fbaLines.length > 0 && fbaLines.length <= 100 && fbaLines.every(l => l.orderId && l.id && l.fbaWarehouseCode === sample.fbaWarehouseCode
&& id(l.destinationWarehouseId) === id(sample.destinationWarehouseId)), `planNo=${plan?.planNo || 'missing'}`)
const suffix = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase()
const verified = []
for (const orderId of [...new Set(fbaLines.map(l => id(l.orderId)))]) {
const sourceKey = `ORDER:${orderId}`, destinationKey = `FBA:${sample.fbaWarehouseCode}`
const sourcePath = `${APPOINTMENT_BASE}/sources/${encodeURIComponent(sourceKey)}/cargo-verification`
const orderPath = query(`/api/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}`, { customerId })
const orderBefore = await request(context, 'GET', '核实前读取原订单货量', orderPath)
const orderFacts = value => ({ id: id(value?.order?.id), customerId: id(value?.order?.customerId), packageCount: value?.order?.packageCount,
receivedPackageCount: value?.order?.receivedPackageCount, sourceInboundId: value?.order?.sourceInboundId, version: value?.order?.version, cargo: value?.cargo })
requireProof(context, '核实前原订单身份和箱数真实可读', id(orderBefore?.order?.id) === orderId && id(orderBefore.order.customerId) === customerId
&& Number.isSafeInteger(orderBefore.order.packageCount) && orderBefore.order.packageCount > 0, sourceKey)
const before = await request(context, 'GET', '读取本轮逐票核实上下文', query(sourcePath, { customerId, destinationKey }))
requireProof(context, '核实数量和版本必须来自当前货源', before?.source?.sourceKey === sourceKey && id(before.source.customerId) === customerId
&& before.source.destinationKey === destinationKey && id(before.source.originWarehouseId) === id(plan.overseasWarehouseId)
&& before.source.canFollowup === true && typeof before.source.sourceVersion === 'string' && before.source.sourceVersion.length > 0
&& Number.isSafeInteger(before.source.packages) && before.source.packages > 0 && count(before.allocatedPackages) === 0 && count(before.unknownAllocationCount) === 0
&& before.sourceChanged === false,
`sourceKey=${sourceKey}, allocated=${before?.allocatedPackages ?? 'missing'}`)
const body = { destinationKey, expectedSourceVersion: before.source.sourceVersion, expectedVersion: before.verification?.version ?? 0,
packageCount: before.source.packages > 1 ? before.source.packages - 1 : before.source.packages, shipmentReferences: before.verification?.shipmentReferences ?? [],
remark: `${context.marker} PENDING预约仅核实真实箱数`, idempotencyKey: `verify-${suffix}-${verified.length}` }
const stale = await context.request('POST', query(sourcePath, { customerId }), { ...body,
expectedSourceVersion: `stale-${before.source.sourceVersion}`.slice(0, 160), idempotencyKey: `${body.idempotencyKey}-stale` })
context.addStep(MODULE, '陈旧货源版本拒绝保存核实资料', stale, conflict(stale), trace(stale), OPTIONS)
requireProof(context, '陈旧货源拒绝必须明确返回冲突', conflict(stale), trace(stale))
const unchanged = await request(context, 'GET', '拒绝后回查核实记录未变', query(sourcePath, { customerId, destinationKey }))
requireProof(context, '陈旧货源写入未生成核实记录', JSON.stringify(unchanged.verification) === JSON.stringify(before.verification)
&& unchanged.allocatedPackages === before.allocatedPackages, sourceKey)
let saved = await request(context, 'POST', '保存独立预约核实箱数和当前来源版本', query(sourcePath, { customerId }), body)
const replay = await request(context, 'POST', '逐票核实原键原载荷重放', query(sourcePath, { customerId }), body)
requireProof(context, '核实重放版本和事实零重复', JSON.stringify(saved) === JSON.stringify(replay)
&& saved.version === body.expectedVersion + 1 && saved.sourceVersion === body.expectedSourceVersion && saved.packageCount === body.packageCount, sourceKey)
const orderAfter = await request(context, 'GET', '核实箱数减少后原订单仍保持原事实', orderPath)
requireProof(context, '预约核实不覆盖原订单箱数版本及货物明细', JSON.stringify(orderFacts(orderAfter)) === JSON.stringify(orderFacts(orderBefore)), sourceKey)
if (body.packageCount !== before.source.packages) saved = await request(context, 'POST', '将预约核实恢复为本轮完整货量', query(sourcePath, { customerId }), {
...body, expectedVersion: saved.version, packageCount: before.source.packages, idempotencyKey: `${body.idempotencyKey}-full` })
const current = await request(context, 'GET', '回查当前核实及可分配箱数', query(sourcePath, { customerId, destinationKey }))
requireProof(context, '核实来源未漂移且箱数完整可分配', current.sourceChanged === false && current.verification?.version === saved.version
&& count(current.availablePackages) === before.source.packages && count(current.allocatedPackages) === 0, sourceKey)
verified.push({ customerId, sourceKey, destinationKey, packageCount: before.source.packages, sourceVersion: saved.sourceVersion, cargoVerificationVersion: saved.version })
}
const createBody = { originWarehouseId: id(plan.overseasWarehouseId), fbaWarehouseCode: sample.fbaWarehouseCode,
lineIds: fbaLines.map(l => id(l.id)), idempotencyKey: `fba-${suffix}`,
details: { plannedDate: new Date(Date.now() + 86400000).toISOString().slice(0, 10), appointmentStatus: 'PENDING', appointmentRemark: context.marker } }
const appointment = await request(context, 'POST', '批量创建FBA预约', `${PLAN_BASE}/fba-appointments/batch`, createBody)
const appointmentNo = appointment?.appointmentNo
requireProof(context, '原预约必须返回不可变批次及业务号', Boolean(appointmentNo && appointment.id && appointment.batchKey === `FBA_APPOINTMENT:${appointment.id}`), 'stable batch identity')
const replay = await request(context, 'POST', 'FBA预约同单号同载荷重放', `${PLAN_BASE}/fba-appointments/batch`, createBody)
requireProof(context, 'FBA创建重放保留不可变批次和全部逐票分配', JSON.stringify(replay) === JSON.stringify(appointment), appointmentNo)
const changed = await context.request('POST', `${PLAN_BASE}/fba-appointments/batch`, { ...createBody, details: { ...createBody.details, appointmentRemark: `${context.marker}-different` } })
context.addStep(MODULE, 'FBA预约同单号异载荷返回A0409', changed, conflict(changed), trace(changed), OPTIONS)
requireProof(context, 'FBA异载荷必须明确拒绝', conflict(changed), trace(changed))
const detailPath = `${PLAN_BASE}/fba-appointments/${encodeURIComponent(appointmentNo)}`
const detail = await request(context, 'GET', '查询FBA预约详情', detailPath)
const exactItems = value => (value?.items || []).map(i => JSON.stringify({ customerId: id(i.customerId), sourceKey: i.sourceKey,
destinationKey: i.destinationKey, packageCount: i.packageCount, sourceVersion: i.sourceVersion, cargoVerificationVersion: i.cargoVerificationVersion })).sort()
requireProof(context, 'FBA预约逐票归属数量及核实版本冻结', detail.batchKey === appointment.batchKey && detail.appointmentStatus === 'PENDING'
&& JSON.stringify(exactItems(detail)) === JSON.stringify(verified.map(v => JSON.stringify(v)).sort()), appointmentNo)
const batch = await request(context, 'GET', '按不可变批次查询原预约上下文', `${APPOINTMENT_BASE}/batches/${encodeURIComponent(detail.batchKey)}`)
requireProof(context, '稳定批次上下文返回同一原预约', batch?.appointment?.batchKey === detail.batchKey && batch.appointment.appointmentNo === appointmentNo
&& JSON.stringify(exactItems(batch.appointment)) === JSON.stringify(exactItems(detail)), detail.batchKey)
const cancelBody = { version: detail.version, cancelReason: `${context.marker}-cancelled` }
await request(context, 'DELETE', '按正式取消操作取消本轮预约', detailPath, cancelBody)
await request(context, 'DELETE', '原取消版本和原因重放', detailPath, cancelBody)
const cancelled = await request(context, 'GET', '回查已取消预约的原批次与历史事实', detailPath)
requireProof(context, '取消保留原批次且仅推进一次版本', cancelled.appointmentStatus === 'CANCELLED' && cancelled.batchKey === detail.batchKey
&& cancelled.version === detail.version + 1 && JSON.stringify(exactItems(cancelled)) === JSON.stringify(exactItems(detail)), appointmentNo)
const history = await request(context, 'GET', '核对唯一取消历史和保留的原计划', `${detailPath}/cancellations?page=1&size=20`)
requireProof(context, '取消重放不重复历史且保留原计划日期', count(history?.total) === 1 && history.records?.length === 1
&& history.records[0].cancelReason === cancelBody.cancelReason && history.records[0].version === cancelled.version
&& history.records[0].snapshot?.appointmentStatus === 'CANCELLED'
&& history.records[0].snapshot?.plannedDate === detail.plannedDate, appointmentNo)
for (const v of verified) {
const restored = await request(context, 'GET', '取消后逐票可分配量恢复', query(`${APPOINTMENT_BASE}/sources/${encodeURIComponent(v.sourceKey)}/cargo-verification`, { customerId, destinationKey: v.destinationKey }))
requireProof(context, '取消未删除核实事实且恢复剩余需求', count(restored.allocatedPackages) === 0 && count(restored.availablePackages) === v.packageCount
&& restored.verification?.version === v.cargoVerificationVersion && restored.sourceChanged === false, v.sourceKey)
}
const active = await request(context, 'POST', '为本轮后续验收重新建立有效批次', `${PLAN_BASE}/fba-appointments/batch`, { ...createBody, idempotencyKey: `${createBody.idempotencyKey}-active` })
requireProof(context, '重新安排生成新批次且逐票数量不变', active.batchKey === `FBA_APPOINTMENT:${active.id}` && active.batchKey !== cancelled.batchKey
&& active.appointmentNo !== appointmentNo && active.appointmentStatus === 'PENDING' && JSON.stringify(exactItems(active)) === JSON.stringify(exactItems(detail)), active?.appointmentNo || 'missing')
const activeContext = await request(context, 'GET', '回查保留的有效批次', `${APPOINTMENT_BASE}/batches/${encodeURIComponent(active.batchKey)}`)
requireProof(context, '保留批次上下文完整可供后续HTTP核对', activeContext?.appointment?.batchKey === active.batchKey
&& activeContext.appointment.appointmentStatus === 'PENDING', active.batchKey)
const repartitioned = await verifyRepartition(context, active, verified, suffix, createBody.details.plannedDate)
return { appointmentNo: repartitioned.active.appointmentNo, batchKey: repartitioned.active.batchKey, terminalStatus: repartitioned.active.appointmentStatus,
cancelledAppointmentNo: appointmentNo, cancelledBatchKey: cancelled.batchKey, cancelledVersion: cancelled.version,
replacedBatchKeys: repartitioned.replacedBatchKeys, splitBatchKeys: repartitioned.splitBatchKeys,
verifiedSources: verified, plannedDate: createBody.details.plannedDate, timezone: repartitioned.active.timezone }
}
async function verifyRepartition(context, initial, verified, suffix, plannedDate) {
const total = verified.reduce((n,v)=>n+v.packageCount,0)
requireProof(context, '分批合并使用本轮至少两箱真实来源', total >= 2, `packages=${total}; use at least two actual packages`)
const detailPath = no => `${PLAN_BASE}/fba-appointments/${encodeURIComponent(no)}`
const active = await request(context,'PUT','推进原预约版本以验证陈旧版本拒绝',detailPath(initial.appointmentNo),{
version:initial.version,idempotencyKey:`version-${suffix}`,details:{plannedDate,appointmentStatus:'PENDING',appointmentRemark:`${context.marker}-version-proof`} })
const allocation = (v,count=v.packageCount) => ({customerId:v.customerId,sourceKey:v.sourceKey,packageCount:count,
expectedSourceVersion:v.sourceVersion,expectedVerificationVersion:v.cargoVerificationVersion})
let left=Math.floor(total/2);const first=[],second=[]
for(const v of verified){const count=Math.min(v.packageCount,left);if(count)first.push(allocation(v,count));if(count<v.packageCount)second.push(allocation(v,v.packageCount-count));left-=count}
const body={originals:[{appointmentNo:active.appointmentNo,version:active.version}],batches:[first,second].map(allocations=>({plannedDate,remark:context.marker,allocations})),reason:`${context.marker}-split`,idempotencyKey:`split-${suffix}`}
const endpoint=`${APPOINTMENT_BASE}/appointments/repartition`
for(const [name,negative,expected]of [
['旧预约版本分批拒绝',{...body,originals:[{appointmentNo:active.appointmentNo,version:initial.version}],idempotencyKey:`split-stale-${suffix}`},'A0409'],
['分批逐票总量不守恒拒绝',{...body,batches:[{...body.batches[0],allocations:body.batches[0].allocations.map((a,i)=>i? a:{...a,packageCount:a.packageCount+1})},body.batches[1]],idempotencyKey:`split-count-${suffix}`},'A0400']]){
const denied=await context.request('POST',endpoint,negative)
const rejected=[200,expected==='A0409'?409:400].includes(denied?.http)&&denied?.body?.code===expected&&denied.body.data==null
context.addStep(MODULE,name,denied,rejected,trace(denied),OPTIONS);requireProof(context,name,rejected,trace(denied))
const unchanged=await request(context,'GET','拒绝分批后原批次保持不变',detailPath(active.appointmentNo))
requireProof(context,'拒绝未改写原批次版本分配或替代链',JSON.stringify(unchanged)===JSON.stringify(active),active.appointmentNo)
}
const sumSources = batches => {
const result=new Map()
for(const b of batches)for(const i of b.items){const k=`${i.customerId}/${i.sourceKey}/${i.destinationKey}`;result.set(k,(result.get(k)||0)+i.packageCount)}
return JSON.stringify([...result].sort((a,b)=>a[0].localeCompare(b[0])))
}
const split=await request(context,'POST','原批次按真实货量分成两批',endpoint,body)
const splitReplay=await request(context,'POST','分批原键原载荷重放',endpoint,body)
requireProof(context,'分批重放只返回原两批且逐票总量守恒',JSON.stringify(split)===JSON.stringify(splitReplay)&&split.appointments?.length===2
&&new Set(split.appointments.map(a=>a.batchKey)).size===2&&split.appointments.every(a=>a.batchKey===`FBA_APPOINTMENT:${a.id}`&&a.batchKey!==active.batchKey)
&&sumSources(split.appointments)===sumSources([active]),active.batchKey)
const old=await request(context,'GET','旧批次保存明确分批替代链',detailPath(active.appointmentNo))
requireProof(context,'旧批次不可变键保留并指向真实新批次',old.batchKey===active.batchKey&&JSON.stringify([...old.replacementBatchKeys].sort())===JSON.stringify(split.appointments.map(a=>a.batchKey).sort()),active.batchKey)
const changed=await context.request('POST',endpoint,{...body,reason:`${body.reason}-different`})
requireProof(context,'分批原键异载荷拒绝',conflict(changed),trace(changed))
const mergeBody={originals:split.appointments.map(a=>({appointmentNo:a.appointmentNo,version:a.version})),batches:[{plannedDate,remark:context.marker,allocations:verified.map(v=>allocation(v))}],reason:`${context.marker}-merge`,idempotencyKey:`merge-${suffix}`}
const merged=await request(context,'POST','两批按原来源数量合并',endpoint,mergeBody)
const mergeReplay=await request(context,'POST','合并原键原载荷重放',endpoint,mergeBody)
requireProof(context,'合并新键唯一且逐票总量不变',JSON.stringify(merged)===JSON.stringify(mergeReplay)&&merged.appointments?.length===1
&&merged.appointments[0].batchKey===`FBA_APPOINTMENT:${merged.appointments[0].id}`&&!split.appointments.some(a=>a.batchKey===merged.appointments[0].batchKey)
&&sumSources(merged.appointments)===sumSources([active]),active.appointmentNo)
for(const child of split.appointments){const replaced=await request(context,'GET','分批历史指向合并后的稳定批次',detailPath(child.appointmentNo));requireProof(context,'合并保留旧批次及唯一替代指向',replaced.batchKey===child.batchKey&&JSON.stringify(replaced.replacementBatchKeys)===JSON.stringify([merged.appointments[0].batchKey]),child.appointmentNo)}
return {active:merged.appointments[0],replacedBatchKeys:[active.batchKey,...split.appointments.map(a=>a.batchKey)],splitBatchKeys:split.appointments.map(a=>a.batchKey)}
}
async function inboundAdminLifecycle(context) {
const matrix = context.createdResources?.phase4InboundProducerFixtures
const completed = new Set((context.createdResources?.phase4OverseasPda?.completedContainers || []).map(id))
const producer = (matrix?.byDestinationType?.FBA || []).find((item) => !completed.has(id(item?.containerNo)))
context.assert(MODULE, '选择本轮未被PDA完成的FBA第二柜作为Admin写链夹具', null,
Boolean(producer?.containerNo && producer?.tenantId),
`containerNo=${producer?.containerNo || 'missing'}, customerId=${producer?.tenantId || 'missing'}`, OPTIONS)
if (!producer?.containerNo || !producer?.tenantId) return
const customerId = id(producer.tenantId)
const orderPage = await request(context, 'GET', '按第二柜查询尾程订单', query('/api/platform/v1/last-leg/orders', {
page: 1, size: 200, customerId, containerNo: producer.containerNo,
}))
const order = rows(orderPage).find((item) => id(item?.customerId) === customerId
&& id(item?.containerNo) === id(producer.containerNo) && item?.destinationType === 'FBA')
context.assert(MODULE, '第二柜尾程订单身份稳定', null, Boolean(order?.id),
`orderId=${id(order?.id) || 'missing'}, containerNo=${producer.containerNo}`, OPTIONS)
if (!order?.id) return
const bookingId = id(producer.containerBookingId)
let preview = await request(context, 'GET', '预览第二柜待生成入仓计划', query(`${PLAN_BASE}/container-preview`, {
sourceContainerBookingId: bookingId,
}))
const arrivalQuery = await context.request('POST', '/api/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: [bookingId] })
const arrival = ok(arrivalQuery) && Array.isArray(arrivalQuery.body?.data)
? arrivalQuery.body.data.find((item) => id(item?.sourceContainerBookingId) === bookingId) : null
if (arrival && !arrival.estimatedWarehouseArrivalAt) {
await context.request('PUT', `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`, { estimatedWarehouseArrivalAt: new Date(Date.now() + 2 * 86400_000).toISOString().slice(0, 19),
expectedVersion: Number(arrival.version), remark: `${context.marker}-inbound-plan-prerequisite` }, undefined, { 'Idempotency-Key': `${context.marker}-inbound-plan-arrival-${bookingId}`.slice(0, 128) })
preview = await request(context, 'GET', '刷新补齐时间后的入仓计划预览', query(`${PLAN_BASE}/container-preview`, {
sourceContainerBookingId: bookingId,
}))
}
const convertBody = { sourceContainerBookingId: bookingId,
snapshotVersion: Number(preview?.snapshotVersion), snapshotHash: preview?.snapshotHash }
const convertHeaders = { 'Idempotency-Key': key(context, 'convert-container') }
const plans = await request(context, 'POST', '尾程入仓单转换为入仓计划',
`${PLAN_BASE}/convert-container`, convertBody, convertHeaders)
const replayedPlans = await request(context, 'POST', '重复转换返回同一入仓计划',
`${PLAN_BASE}/convert-container`, convertBody, convertHeaders)
const plan = (plans || []).find((item) => (item?.lines || []).some((line) => id(line?.orderId) === id(order.id)))
|| plans?.[0]
const replay = (replayedPlans || []).find((item) => item?.planNo === plan?.planNo) || replayedPlans?.[0]
context.assert(MODULE, '入仓计划转换天然幂等', null,
Boolean(plan?.planNo) && replay?.planNo === plan.planNo,
`first=${plan?.planNo || 'missing'}, replay=${replay?.planNo || 'missing'}`, OPTIONS)
const planNo = plan?.planNo
const eligibleLines = (plan?.lines || []).filter((line) => line?.fbaWarehouseCode
&& line?.destinationWarehouseId)
const fbaLines = eligibleLines.filter(line => line.fbaWarehouseCode === eligibleLines[0]?.fbaWarehouseCode
&& id(line.destinationWarehouseId) === id(eligibleLines[0]?.destinationWarehouseId))
context.assert(MODULE, 'FBA入仓计划包含可预约明细', null,
Boolean(planNo) && fbaLines.length > 0,
`planNo=${planNo || 'missing'}, fbaLines=${fbaLines.length}`, OPTIONS)
if (!planNo || fbaLines.length === 0) return
const sample = fbaLines[0]
const supplement = await request(context, 'POST', '为未完成FBA计划补充可打印托盘',
`${PLAN_BASE}/${encodeURIComponent(planNo)}/pallets/supplement`, {
destinationWarehouseId: id(sample.destinationWarehouseId),
destinationWarehouseCode: sample.destinationWarehouseCode,
destinationWarehouseName: sample.destinationWarehouseName,
customerId,
})
context.assert(MODULE, '补充托盘绑定计划内目的仓且保持未分配', null,
Boolean(supplement?.id) && supplement?.assignmentStatus === 'UNASSIGNED'
&& id(supplement?.destinationWarehouseId) === id(sample.destinationWarehouseId),
`palletId=${id(supplement?.id) || 'missing'}, status=${supplement?.assignmentStatus || 'missing'}`, OPTIONS)
const appointmentProof = await prepareAppointmentFixture(context, { customerId, plan, fbaLines })
context.createdResources.phase4OverseasInboundAdmin = {
marker: context.marker, customerId, orderId: id(order.id), planNo,
supplementedPalletId: id(supplement?.id) || null, ...appointmentProof,
}
}
async function inventoryCommandLifecycle(context) {
const pda = context.createdResources?.phase4OverseasPda
const customerId = id(pda?.customerId)
const palletIds = new Set((pda?.palletIds || []).map(id))
context.assert(MODULE, '库存命令只使用本轮PDA完成柜形成的库存', null,
Boolean(customerId && pda?.finalStatus === 'COMPLETED' && palletIds.size > 0),
`customerId=${customerId || 'missing'}, pallets=${palletIds.size}, status=${pda?.finalStatus || 'missing'}`, OPTIONS)
if (!customerId || pda?.finalStatus !== 'COMPLETED' || palletIds.size === 0) return
const balancePage = await request(context, 'GET', '查询本轮客户库存余额', query(`${INVENTORY_BASE}/balances`, {
customerId, overseasWarehouseId: pda.overseasWarehouseId, page: 1, size: 200,
}))
let selected = null
for (const balance of rows(balancePage)) {
const positions = await request(context, 'GET', `查询库存余额${id(balance.id)}库位`, query(
`${INVENTORY_BASE}/balances/${encodeURIComponent(id(balance.id))}/positions`, { customerId }))
const position = rows(positions).find((item) => palletIds.has(id(item?.palletId))
&& Number(item?.availablePackages) >= 1)
if (position) { selected = { balance, position }; break }
}
context.assert(MODULE, '定位本轮PDA托盘上的可用库存位置', null, Boolean(selected?.position?.id),
`positionId=${id(selected?.position?.id) || 'missing'}, palletId=${id(selected?.position?.palletId) || 'missing'}`, OPTIONS)
if (!selected?.position?.id) return
const positionId = id(selected.position.id)
const baseline = {
available: Number(selected.position.availablePackages), hold: Number(selected.position.holdPackages),
allocated: Number(selected.position.allocatedPackages), picked: Number(selected.position.pickedPackages),
}
const businessOrderNo = `${context.marker}-INVENTORY`
const holdBody = { positionId, packages: 1, businessOrderNo,
idempotencyKey: key(context, 'HOLD'), remark: context.marker }
const held = await request(context, 'POST', '冻结一箱本轮PDA库存', `${INVENTORY_BASE}/hold`, holdBody)
const heldReplay = await request(context, 'POST', '库存冻结同键重放', `${INVENTORY_BASE}/hold`, holdBody)
context.assert(MODULE, '库存冻结重放不重复扣减', null,
held?.operationType === 'HOLD' && heldReplay?.operationId === held?.operationId && heldReplay?.replayed === true,
`operationId=${id(held?.operationId) || 'missing'}, replayed=${heldReplay?.replayed}`, OPTIONS)
const holdConflict = await context.request('POST', `${INVENTORY_BASE}/hold`, {
...holdBody, remark: `${context.marker}-different`,
})
context.addStep(MODULE, '库存冻结同键异载荷返回A0409', holdConflict,
conflict(holdConflict), trace(holdConflict), OPTIONS)
const releaseBody = { positionId, packages: 1, businessOrderNo,
idempotencyKey: key(context, 'RELEASE'), remark: context.marker }
const released = await request(context, 'POST', '释放一箱冻结库存', `${INVENTORY_BASE}/release`, releaseBody)
const releasedReplay = await request(context, 'POST', '库存释放同键重放', `${INVENTORY_BASE}/release`, releaseBody)
context.assert(MODULE, '库存释放重放不重复增加可用数', null,
released?.operationType === 'RELEASE' && releasedReplay?.operationId === released?.operationId
&& releasedReplay?.replayed === true,
`operationId=${id(released?.operationId) || 'missing'}, replayed=${releasedReplay?.replayed}`, OPTIONS)
const increaseBody = { positionId, packageDelta: 1, reason: 'ACCEPTANCE_COUNT_GAIN',
businessOrderNo, idempotencyKey: key(context, 'ADJUST-UP'), remark: context.marker }
const increased = await request(context, 'POST', '盘点调增一箱', `${INVENTORY_BASE}/adjust`, increaseBody)
const increaseReplay = await request(context, 'POST', '盘点调增同键重放', `${INVENTORY_BASE}/adjust`, increaseBody)
context.assert(MODULE, '盘点调增重放不重复入账', null,
increased?.operationType === 'ADJUST' && increaseReplay?.operationId === increased?.operationId
&& increaseReplay?.replayed === true,
`operationId=${id(increased?.operationId) || 'missing'}, replayed=${increaseReplay?.replayed}`, OPTIONS)
const adjustConflict = await context.request('POST', `${INVENTORY_BASE}/adjust`, {
...increaseBody, reason: 'DIFFERENT_COUNT_REASON',
})
context.addStep(MODULE, '盘点调整同键异载荷返回A0409', adjustConflict,
conflict(adjustConflict), trace(adjustConflict), OPTIONS)
await request(context, 'POST', '盘点调减一箱恢复基线', `${INVENTORY_BASE}/adjust`, {
positionId, packageDelta: -1, reason: 'ACCEPTANCE_COUNT_RESTORE', businessOrderNo,
idempotencyKey: key(context, 'ADJUST-DOWN'), remark: context.marker,
})
const finalPositions = await request(context, 'GET', '回查库存命令后的托盘位置', query(
`${INVENTORY_BASE}/balances/${encodeURIComponent(id(selected.balance.id))}/positions`, { customerId }))
const finalPosition = rows(finalPositions).find((item) => id(item?.id) === positionId)
context.assert(MODULE, '冻结释放与盘点正反向后库存数量恢复基线', null,
Number(finalPosition?.availablePackages) === baseline.available
&& Number(finalPosition?.holdPackages) === baseline.hold
&& Number(finalPosition?.allocatedPackages) === baseline.allocated
&& Number(finalPosition?.pickedPackages) === baseline.picked,
`before=${JSON.stringify(baseline)}, after=${JSON.stringify({ available: finalPosition?.availablePackages,
hold: finalPosition?.holdPackages, allocated: finalPosition?.allocatedPackages,
picked: finalPosition?.pickedPackages })}`, OPTIONS)
const ledger = await request(context, 'GET', '按本轮业务号回查库存流水', query(`${INVENTORY_BASE}/ledger`, {
customerId, businessOrderNo, page: 1, size: 100,
}))
const types = rows(ledger).map((item) => item?.businessType)
context.assert(MODULE, '库存流水包含HOLD、RELEASE及两条ADJUST且重放零重复', null,
types.filter((type) => type === 'HOLD').length === 1
&& types.filter((type) => type === 'RELEASE').length === 1
&& types.filter((type) => type === 'ADJUST').length === 2,
`types=${types.join(',') || 'missing'}`, OPTIONS)
context.createdResources.phase4OverseasInventoryCommands = {
marker: context.marker, customerId, balanceId: id(selected.balance.id), positionId,
terminalState: 'RESTORED',
}
}
export async function run(context) {
context.createdResources.phase4OverseasInboundAdmin = { marker: context.marker }
context.createdResources.phase4OverseasInventoryCommands = { marker: context.marker }
await inboundAdminLifecycle(context)
await inventoryCommandLifecycle(context)
}