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

234 lines
16 KiB
JavaScript
Raw Normal View History

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, headers = {}) {
const response = await context.request(method, path, body, undefined, headers)
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 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,
2026-08-25 08:12:58 +08:00
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.request('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.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)
}