213 lines
14 KiB
JavaScript
213 lines
14 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'
|
|
|
|
function id(value) { return value == null ? '' : String(value) }
|
|
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 response?.http === 200 && ['A0409', '409'].includes(String(response?.body?.code)) }
|
|
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) {
|
|
const response = await context.requestOverseas(method, path, body)
|
|
context.addStep(MODULE, name, response, ok(response), trace(response), OPTIONS)
|
|
return ok(response) ? response.body?.data : null
|
|
}
|
|
|
|
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 convertBody = { orderId: id(order.id), customerId }
|
|
const plan = await request(context, 'POST', '尾程入仓单转换为入仓计划', `${PLAN_BASE}/convert`, convertBody)
|
|
const replay = await request(context, 'POST', '重复转换返回同一入仓计划', `${PLAN_BASE}/convert`, convertBody)
|
|
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 fbaLines = (plan?.lines || []).filter((line) => line?.fbaWarehouseCode
|
|
&& line?.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 suffix = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase()
|
|
const appointmentNo = `FBA-${suffix}`.slice(0, 64)
|
|
const now = Date.now()
|
|
const appointmentBody = {
|
|
customerId, lineIds: fbaLines.map((line) => id(line.id)), appointmentNo,
|
|
appointmentAt: new Date(now + 24 * 60 * 60_000).toISOString().slice(0, 19),
|
|
expectedLoadingAt: new Date(now + 26 * 60 * 60_000).toISOString().slice(0, 19),
|
|
appointmentStatus: 'PENDING', appointmentRemark: context.marker,
|
|
}
|
|
const appointment = await request(context, 'POST', '批量创建FBA预约', `${PLAN_BASE}/fba-appointments/batch`, appointmentBody)
|
|
const appointmentReplay = await request(context, 'POST', 'FBA预约同单号同载荷重放', `${PLAN_BASE}/fba-appointments/batch`, appointmentBody)
|
|
context.assert(MODULE, 'FBA预约重放返回同一聚合且明细一致', null,
|
|
id(appointmentReplay?.id) === id(appointment?.id)
|
|
&& appointmentReplay?.items?.length === fbaLines.length,
|
|
`appointmentId=${id(appointment?.id) || 'missing'}, items=${appointmentReplay?.items?.length ?? 'missing'}`, OPTIONS)
|
|
const appointmentConflict = await context.requestOverseas('POST', `${PLAN_BASE}/fba-appointments/batch`, {
|
|
...appointmentBody, appointmentRemark: `${context.marker}-different`,
|
|
})
|
|
context.addStep(MODULE, 'FBA预约同单号异载荷返回A0409', appointmentConflict,
|
|
conflict(appointmentConflict), trace(appointmentConflict), OPTIONS)
|
|
const detail = await request(context, 'GET', '查询FBA预约详情', query(
|
|
`${PLAN_BASE}/fba-appointments/${encodeURIComponent(appointmentNo)}`, { customerId }))
|
|
context.assert(MODULE, 'FBA预约详情冻结仓码和所选明细', null,
|
|
detail?.appointmentNo === appointmentNo && detail?.fbaWarehouseCode === sample.fbaWarehouseCode
|
|
&& detail?.items?.length === fbaLines.length,
|
|
`appointmentNo=${detail?.appointmentNo || 'missing'}, warehouse=${detail?.fbaWarehouseCode || 'missing'}`, OPTIONS)
|
|
const cancelled = await request(context, 'PUT', '取消本轮FBA预约收口',
|
|
`${PLAN_BASE}/fba-appointments/${encodeURIComponent(appointmentNo)}`, {
|
|
customerId, appointmentAt: appointmentBody.appointmentAt,
|
|
expectedLoadingAt: appointmentBody.expectedLoadingAt,
|
|
appointmentStatus: 'CANCELLED', appointmentRemark: `${context.marker}-cancelled`,
|
|
})
|
|
context.assert(MODULE, 'FBA预约最终为CANCELLED', null,
|
|
cancelled?.appointmentStatus === 'CANCELLED', `status=${cancelled?.appointmentStatus || 'missing'}`, OPTIONS)
|
|
context.createdResources.phase4OverseasInboundAdmin = {
|
|
marker: context.marker, customerId, orderId: id(order.id), planNo,
|
|
supplementedPalletId: id(supplement?.id) || null, appointmentNo,
|
|
terminalStatus: cancelled?.appointmentStatus || null,
|
|
}
|
|
}
|
|
|
|
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.requestOverseas('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.requestOverseas('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)
|
|
}
|