407 lines
34 KiB
JavaScript
407 lines
34 KiB
JavaScript
import { NODE_SEQUENCES, STANDARD_NODES, manualNodePayload } from './phase4-carrier-nodes.mjs'
|
|
|
|
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) === v.packageCount && count(restored.availablePackages) === 0
|
|
&& restored.verification?.version === v.cargoVerificationVersion && restored.sourceChanged === false, v.sourceKey)
|
|
}
|
|
const active = await request(context, 'PUT', '在原批次重新安排本轮有效预约', detailPath,
|
|
{ version: cancelled.version, idempotencyKey: `${createBody.idempotencyKey}-active`, details: createBody.details })
|
|
requireProof(context, '重新安排复用原批次且逐票数量不变', active.batchKey === cancelled.batchKey
|
|
&& active.appointmentNo === appointmentNo && active.version === cancelled.version + 1
|
|
&& 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)
|
|
|
|
await ensureAppointmentDeparture(context, producer, order.id)
|
|
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,
|
|
}
|
|
}
|
|
|
|
/** 使用本轮自动船期任务推进实际开船事实,再等待尾程消费,不能用人工订单节点绕过来源校验。 */
|
|
export async function ensureAppointmentDeparture(context, producer, orderId) {
|
|
if (producer?.marker !== context.marker || !id(producer.carrierScheduleQueryId)
|
|
|| !id(producer.containerBookingId) || !id(producer.tenantId) || !id(orderId))
|
|
throw Error('预约开船前置缺少本轮船期任务身份')
|
|
const base = `/api/platform/v1/carrier-schedule-queries/${encodeURIComponent(id(producer.carrierScheduleQueryId))}`
|
|
let detail = await request(context, 'GET', '核对预约来源的原生船期任务', base)
|
|
const task = detail?.query?.query
|
|
if (id(task?.id) !== id(producer.carrierScheduleQueryId) || id(task?.customerId) !== id(producer.tenantId)
|
|
|| id(task?.containerBookingId) !== id(producer.containerBookingId) || task?.trackingNo !== producer.containerNo)
|
|
throw Error('预约来源船期任务客户或柜身份不一致')
|
|
const proof = context.createdResources.appointmentDeparturePrerequisite = {
|
|
marker: context.marker, queryId: id(task.id), containerBookingId: id(producer.containerBookingId), commands: [], status: 'PREPARING',
|
|
}
|
|
for (const node of STANDARD_NODES.slice(0, 4)) {
|
|
const current = detail.query.query
|
|
if ((NODE_SEQUENCES[current.latestActualNode] || 0) >= NODE_SEQUENCES[node]) continue
|
|
const now = new Date()
|
|
const local = value => new Date(value.getTime() - value.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
|
|
const priorAt = current.latestActualAt && Date.parse(current.latestActualAt)
|
|
const actualAt = Number.isFinite(priorAt) ? new Date(priorAt + 60000) : new Date(now.getTime() - 10 * 60000)
|
|
const body = manualNodePayload({ expectedVersion: Number(current.version), standardNode: node,
|
|
observedAt: local(now), actualAt: local(actualAt), marker: context.marker })
|
|
if (!Number.isSafeInteger(body.expectedVersion) || body.expectedVersion < 0) throw Error('预约来源船期版本无效')
|
|
const headers = { 'Idempotency-Key': key(context, `appointment-departure-${node}`) }
|
|
const command = { body, idempotencyKey: headers['Idempotency-Key'], status: 'PREPARED' }
|
|
proof.commands.push(command); await context.checkpoint?.()
|
|
command.status = 'UNKNOWN_RESULT'
|
|
await request(context, 'POST', '补齐预约来源原生实际船节点', `${base}/manual-nodes`, body, headers)
|
|
command.status = 'SUCCEEDED'
|
|
detail = await request(context, 'GET', '回查原生实际船节点', base)
|
|
if ((NODE_SEQUENCES[detail?.query?.query?.latestActualNode] || 0) < NODE_SEQUENCES[node])
|
|
throw Error('实际船节点补录未推进原船期任务')
|
|
}
|
|
for (let attempt = 1; attempt <= 30; attempt++) {
|
|
const response = await context.request('GET', query(`/api/platform/v1/last-leg/orders/${encodeURIComponent(id(orderId))}`,
|
|
{ customerId: id(producer.tenantId) }))
|
|
const order = response?.body?.data?.order
|
|
if (ok(response) && id(order?.customerId) === id(producer.tenantId)
|
|
&& id(order?.sourceContainerBookingId) === id(producer.containerBookingId)
|
|
&& !order.manualNodeOverride && (NODE_SEQUENCES[order.containerNode] || 0) >= 40) {
|
|
proof.status = 'CONSUMED'; return proof
|
|
}
|
|
if (attempt < 30) await new Promise(resolve => setTimeout(resolve, 1000))
|
|
}
|
|
throw Error('预约来源开船事实未被尾程消费,停止预约写入')
|
|
}
|
|
|
|
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)
|
|
}
|