test: complete phase3 phase4 acceptance coverage
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
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?.plan?.planNo) && replay?.plan?.planNo === plan.plan.planNo,
|
||||
`first=${plan?.plan?.planNo || 'missing'}, replay=${replay?.plan?.planNo || 'missing'}`, OPTIONS)
|
||||
const planNo = plan?.plan?.planNo
|
||||
const fbaLines = (plan?.lines || []).filter((line) => line?.destinationType === 'FBA'
|
||||
&& 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)
|
||||
}
|
||||
Reference in New Issue
Block a user