test(admin): verify chained remainder fulfillment splits
This commit is contained in:
@@ -1,168 +1,384 @@
|
||||
const MODULE = 'phase3-order-splitting'
|
||||
const SCENARIO = 'parent-child-multi-container-consolidation'
|
||||
const SCENARIO = 'source-loaded-single-remainder-chain'
|
||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin'
|
||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!'
|
||||
const CONTAINERS = '/api/platform/v1/containers'
|
||||
const SMART = '/api/platform/v1/first-leg/smart-loading'
|
||||
const PDA = '/api/platform/v1/first-leg/pda-loading'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
function failure(response) {
|
||||
return [400, 401, 403, 404, 409, 503].includes(response?.http)
|
||||
|| (response?.http === 200 && codeOf(response) !== '200')
|
||||
function isPositiveId(value) { return /^[1-9][0-9]*$/.test(id(value)) }
|
||||
function isConflict(response) {
|
||||
return response?.http === 409
|
||||
|| (response?.http === 200 && ['A0409', '409'].includes(codeOf(response)))
|
||||
}
|
||||
function isForbidden(response) {
|
||||
return response?.http === 403
|
||||
|| (response?.http === 200 && ['A0403', '403'].includes(codeOf(response)))
|
||||
}
|
||||
function hasTrace(response) {
|
||||
return typeof response?.requestId === 'string' && response.requestId.length > 0
|
||||
&& typeof response?.traceId === 'string' && response.traceId.length > 0
|
||||
}
|
||||
|
||||
function isPositiveId(value) { return /^[1-9][0-9]*$/.test(id(value)) }
|
||||
async function request(context, method, path, body, headers = {}, token = context.token) {
|
||||
try {
|
||||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }),
|
||||
...headers,
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
})
|
||||
const raw = await response.text()
|
||||
let payload = null
|
||||
try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence below */ }
|
||||
return {
|
||||
method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null,
|
||||
requestId: payload?.requestId || response.headers.get('x-request-id'),
|
||||
traceId: payload?.traceId || response.headers.get('x-trace-id'),
|
||||
}
|
||||
} catch (error) {
|
||||
return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
|
||||
}
|
||||
}
|
||||
|
||||
function treeShape(tree, orderId) {
|
||||
if (!tree || !isPositiveId(tree.parentOrderId) || id(tree.parentOrderId) !== orderId
|
||||
function treePath(orderId, tenantId) {
|
||||
return `/api/platform/v1/orders/${encodeURIComponent(orderId)}/fulfillment-tree?tenantId=${encodeURIComponent(tenantId)}`
|
||||
}
|
||||
|
||||
function activeRemainders(tree) {
|
||||
const rows = Array.isArray(tree?.children) ? tree.children : []
|
||||
const consumedSources = new Set(rows.filter((row) => row.splitRole === 'LOADED').map((row) => id(row.sourceOrderId)))
|
||||
return rows.filter((row) => row.splitRole === 'REMAINDER' && !consumedSources.has(id(row.orderId)))
|
||||
}
|
||||
|
||||
function treeShape(tree, rootOrderId) {
|
||||
if (!tree || id(tree.parentOrderId) !== rootOrderId || !isPositiveId(tree.parentOrderId)
|
||||
|| typeof tree.parentOrderNo !== 'string' || !Number.isInteger(Number(tree.parentVersion))
|
||||
|| !Array.isArray(tree.children) || !Number.isInteger(Number(tree.allocatedPackageCount))
|
||||
|| !Number.isInteger(Number(tree.remainingPackageCount))
|
||||
|| !Number.isInteger(Number(tree.originalPackageCount))
|
||||
|| !Array.isArray(tree.children) || !Number.isInteger(Number(tree.originalPackageCount))
|
||||
|| !Number.isInteger(Number(tree.cumulativeLoadedPackageCount))
|
||||
|| !Number.isInteger(Number(tree.currentRemainderPackageCount))) return false
|
||||
return tree.children.every((child) => isPositiveId(child.orderId)
|
||||
&& typeof child.orderNo === 'string'
|
||||
&& Number.isInteger(Number(child.splitSequence))
|
||||
&& ['LOADED', 'REMAINDER'].includes(child.splitRole)
|
||||
&& Number.isInteger(Number(child.packageCount)) && Number(child.packageCount) >= 0
|
||||
&& isPositiveId(child.sourceOrderId) && isPositiveId(child.loadingPlanId)
|
||||
&& isPositiveId(child.containerBookingId))
|
||||
}
|
||||
|
||||
function newSingleRemainderShape(tree) {
|
||||
const loaded = tree.children.filter((child) => child.splitRole === 'LOADED')
|
||||
const remainder = tree.children.filter((child) => child.splitRole === 'REMAINDER')
|
||||
const activeRemainder = remainder.filter((child) => !loaded.some((item) => id(item.sourceOrderId) === id(child.orderId)))
|
||||
const rootNo = tree.parentOrderNo
|
||||
const nestedCode = /-[0-9]+-[0-9]+$/
|
||||
const rootCode = tree.parentOrderNo
|
||||
const codesAreRootBased = tree.children.every((child) => typeof child.orderNo === 'string'
|
||||
&& (child.orderNo === rootCode || child.orderNo.startsWith(`${rootCode}-`))
|
||||
&& !nestedCode.test(child.orderNo))
|
||||
const sourceCarriesLoaded = loaded.length > 0 && loaded.every((child) => id(child.orderId) === id(child.sourceOrderId))
|
||||
const original = Number(tree.originalPackageCount)
|
||||
const cumulative = Number(tree.cumulativeLoadedPackageCount)
|
||||
const current = Number(tree.currentRemainderPackageCount)
|
||||
return sourceCarriesLoaded
|
||||
&& activeRemainder.length <= 1
|
||||
&& (!activeRemainder.length || Number(activeRemainder[0].packageCount) === current)
|
||||
&& original === cumulative + current
|
||||
&& codesAreRootBased
|
||||
return tree.children.every((row) => {
|
||||
const role = row?.splitRole
|
||||
const sourceCarriesLoaded = role !== 'LOADED' || id(row.orderId) === id(row.sourceOrderId)
|
||||
const remainderShape = role !== 'REMAINDER'
|
||||
|| (id(row.orderId) !== id(row.sourceOrderId) && Number.isInteger(Number(row.splitSequence))
|
||||
&& Number(row.splitSequence) > 0 && row.orderNo === `${rootNo}-${Number(row.splitSequence)}`)
|
||||
return ['LOADED', 'REMAINDER'].includes(role)
|
||||
&& isPositiveId(row.orderId) && isPositiveId(row.sourceOrderId)
|
||||
&& typeof row.orderNo === 'string' && !nestedCode.test(row.orderNo)
|
||||
&& Number.isInteger(Number(row.packageCount)) && Number(row.packageCount) > 0
|
||||
&& isPositiveId(row.loadingPlanId) && isPositiveId(row.containerBookingId)
|
||||
&& sourceCarriesLoaded && remainderShape
|
||||
})
|
||||
}
|
||||
|
||||
function sliceShape(row, containerBookingId, tenantId) {
|
||||
return row && isPositiveId(row.containerBookingId) && id(row.containerBookingId) === containerBookingId
|
||||
&& isPositiveId(row.tenantId) && id(row.tenantId) === tenantId
|
||||
&& isPositiveId(row.parentOrderId) && typeof row.parentOrderNo === 'string'
|
||||
&& isPositiveId(row.childOrderId) && typeof row.childOrderNo === 'string'
|
||||
&& isPositiveId(row.sourceOrderId) && typeof row.sourceOrderNo === 'string'
|
||||
&& ['LOADED', 'REMAINDER'].includes(row.splitRole)
|
||||
&& Number.isInteger(Number(row.packageCount)) && Number(row.packageCount) >= 0
|
||||
&& isPositiveId(row.loadingPlanId)
|
||||
function quantityConserved(tree) {
|
||||
const rows = Array.isArray(tree?.children) ? tree.children : []
|
||||
const loadedRows = rows.filter((row) => row.splitRole === 'LOADED')
|
||||
const remainders = rows.filter((row) => row.splitRole === 'REMAINDER')
|
||||
const rootOriginal = Number(tree?.originalPackageCount)
|
||||
const rootNo = tree?.parentOrderNo
|
||||
return loadedRows.every((loaded) => {
|
||||
const remainder = remainders.find((row) => id(row.sourceOrderId) === id(loaded.sourceOrderId)
|
||||
&& id(row.loadingPlanId) === id(loaded.loadingPlanId)
|
||||
&& id(row.containerBookingId) === id(loaded.containerBookingId))
|
||||
const sourceCount = id(loaded.sourceOrderId) === id(tree.parentOrderId)
|
||||
? rootOriginal
|
||||
: Number(remainders.find((row) => id(row.orderId) === id(loaded.sourceOrderId))?.packageCount)
|
||||
return Number.isInteger(sourceCount) && sourceCount > 0
|
||||
&& sourceCount === Number(loaded.packageCount) + Number(remainder?.packageCount || 0)
|
||||
&& (id(loaded.sourceOrderId) === id(tree.parentOrderId) || loaded.orderNo.startsWith(`${rootNo}-`))
|
||||
})
|
||||
}
|
||||
|
||||
function snapshotMatches(tree, original, loaded, remaining, activeSuffix) {
|
||||
const active = activeRemainders(tree)
|
||||
const activeMatches = activeSuffix == null
|
||||
? active.length === 0
|
||||
: active.length === 1 && active[0].orderNo === `${tree.parentOrderNo}-${activeSuffix}`
|
||||
&& Number(active[0].packageCount) === remaining
|
||||
return Number(tree.originalPackageCount) === original
|
||||
&& Number(tree.cumulativeLoadedPackageCount) === loaded
|
||||
&& Number(tree.currentRemainderPackageCount) === remaining
|
||||
&& Number(tree.allocatedPackageCount) === loaded
|
||||
&& Number(tree.remainingPackageCount) === remaining
|
||||
&& original === loaded + remaining
|
||||
&& activeMatches && quantityConserved(tree)
|
||||
}
|
||||
|
||||
function containerPayload(marker, sequence, warehouseId, candidate) {
|
||||
const suffix = `${marker}-${sequence}`.replace(/[^A-Za-z0-9]/g, '').slice(-18)
|
||||
return {
|
||||
containerNo: `SPLIT-${suffix}`.slice(0, 64), cabinetNo: `SI-${suffix}`.slice(0, 64),
|
||||
warehouseId, warehouseName: null, billOfLadingNo: `BL-${suffix}`.slice(0, 64),
|
||||
vesselName: `SPLIT-VESSEL-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64),
|
||||
containerType: '40HQ', shippingCompanyId: null, shippingCompanyName: `SPLIT-SHIPPING-${suffix}`.slice(0, 128),
|
||||
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||
destinationWarehouseCode: candidate.destinationWarehouseCode || null,
|
||||
destinationWarehouseName: candidate.destinationWarehouseName || null,
|
||||
capacityCbm: 100, maxWeightKg: 100000, terminalTruckingCompany: `SPLIT-TRUCK-${suffix}`.slice(0, 128),
|
||||
loadingAt: '2026-09-10T08:00:00', estimatedSailingAt: '2026-09-11T08:00:00',
|
||||
estimatedPortArrivalAt: '2026-09-28T08:00:00', estimatedWarehouseArrivalAt: '2026-09-29T08:00:00',
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOneFragment(context, state) {
|
||||
const { tenantId, rootOrderId, sourceOrderId, sourceOrderNo, warehouseId, sourcePackageCount, sequence } = state
|
||||
const candidatesResponse = await context.request('GET', `${SMART}/candidates?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const candidates = context.apiSuccess(MODULE, `查询余量 ${sourceOrderNo} 的真实装柜候选`, candidatesResponse, '', OPTIONS)
|
||||
const candidate = Array.isArray(candidates)
|
||||
? candidates.find((row) => id(row.orderId) === sourceOrderId && id(row.accountId) === tenantId)
|
||||
: null
|
||||
context.assert(MODULE, `余量 ${sourceOrderNo} 初始已拣为0且可用箱数正确`, candidatesResponse,
|
||||
Boolean(candidate?.inboundId) && Boolean(candidate?.inboundDetailId)
|
||||
&& Number(candidate?.availablePackageCount) === sourcePackageCount,
|
||||
`orderId=${sourceOrderId}, available=${candidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
|
||||
if (!candidate?.inboundId || !candidate?.inboundDetailId) return null
|
||||
|
||||
const createContainer = await request(context, 'POST', CONTAINERS,
|
||||
containerPayload(context.marker, sequence, warehouseId, candidate))
|
||||
const container = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 创建隔离柜次`, createContainer, '', OPTIONS)
|
||||
const containerId = id(container?.id)
|
||||
if (!containerId) return null
|
||||
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(containerId)
|
||||
|
||||
const createPlan = await request(context, 'POST', `${SMART}/plans`, { bookingId: containerId, remark: context.marker })
|
||||
const plan = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 创建装柜计划`, createPlan, '', OPTIONS)
|
||||
const planId = id(plan?.id)
|
||||
if (!planId) return null
|
||||
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(planId)
|
||||
|
||||
const operationBase = `${context.marker}-chain-${sequence}`.slice(0, 100)
|
||||
const assignPayload = {
|
||||
inboundId: id(candidate.inboundId), inboundDetailId: id(candidate.inboundDetailId), packageCount: 1,
|
||||
expectedVersion: Number(plan.version), operationId: `${operationBase}-assign`,
|
||||
}
|
||||
const assign = await request(context, 'POST', `${SMART}/plans/${planId}/assignments`, assignPayload)
|
||||
const assigned = context.apiSuccess(MODULE, `从 ${sourceOrderNo} 分配1箱`, assign, '', OPTIONS)
|
||||
const planItem = assigned?.items?.find?.((row) => id(row.inboundId) === id(candidate.inboundId))
|
||||
context.assert(MODULE, `来源 ${sourceOrderNo} 仅承接本次1箱`, assign,
|
||||
Boolean(planItem?.id) && Number(planItem?.packageCount) === 1,
|
||||
`planItemId=${id(planItem?.id) || 'missing'}, packages=${planItem?.packageCount ?? 'missing'}`, OPTIONS)
|
||||
if (!planItem?.id) return null
|
||||
|
||||
const staleAssign = await request(context, 'POST', `${SMART}/plans/${planId}/assignments`, {
|
||||
...assignPayload, expectedVersion: 0, operationId: `${operationBase}-stale`,
|
||||
})
|
||||
context.addStep(MODULE, `余量 ${sourceOrderNo} 的旧计划版本被拒绝`, staleAssign,
|
||||
isConflict(staleAssign) && hasTrace(staleAssign),
|
||||
`HTTP=${staleAssign.http}, code=${codeOf(staleAssign)}, requestId=${staleAssign.requestId || 'missing'}, traceId=${staleAssign.traceId || 'missing'}`, OPTIONS)
|
||||
|
||||
const push = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`,
|
||||
{ expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': `${operationBase}-push` })
|
||||
const pushed = context.apiSuccess(MODULE, `推送 ${sourceOrderNo} 的真实 PDA 任务`, push, '', OPTIONS)
|
||||
if (pushed?.status !== 'LOADING') return null
|
||||
|
||||
const taskResponse = await context.request('GET',
|
||||
`${PDA}/tasks?containerNo=${encodeURIComponent(container.containerNo)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const task = context.apiSuccess(MODULE, `读取 ${sourceOrderNo} 的 PDA 任务`, taskResponse, '', OPTIONS)
|
||||
const taskId = id(task?.id)
|
||||
const pickItem = task?.items?.find?.((row) => id(row.loadingPlanItemId) === id(planItem.id))
|
||||
context.assert(MODULE, `余量 ${sourceOrderNo} 不继承已拣数量`, taskResponse,
|
||||
Boolean(taskId) && Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1
|
||||
&& Number(pickItem?.pickedPackageCount) === 0,
|
||||
`taskId=${taskId || 'missing'}, picked=${pickItem?.pickedPackageCount ?? 'missing'}, planned=${pickItem?.plannedPackageCount ?? 'missing'}`, OPTIONS)
|
||||
if (!taskId || !pickItem?.id) return null
|
||||
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(taskId)
|
||||
|
||||
const claim = await request(context, 'POST', `${PDA}/tasks/${taskId}/claim`, {
|
||||
expectedAssignmentVersion: 0, operationId: `${operationBase}-claim`,
|
||||
})
|
||||
context.apiSuccess(MODULE, `领取 ${sourceOrderNo} 的 PDA 任务`, claim, '', OPTIONS)
|
||||
|
||||
const stalePick = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, {
|
||||
warehouseId, pickedPackageCount: 1, locationIds: [], version: Number(pickItem.version) + 1,
|
||||
operationId: `${operationBase}-pick-stale`,
|
||||
})
|
||||
context.addStep(MODULE, `余量 ${sourceOrderNo} 的旧明细版本被拒绝`, stalePick,
|
||||
isConflict(stalePick) && hasTrace(stalePick),
|
||||
`HTTP=${stalePick.http}, code=${codeOf(stalePick)}, requestId=${stalePick.requestId || 'missing'}, traceId=${stalePick.traceId || 'missing'}`, OPTIONS)
|
||||
|
||||
const pickPayload = {
|
||||
warehouseId, pickedPackageCount: 1, locationIds: [], version: Number(pickItem.version),
|
||||
operationId: `${operationBase}-pick`,
|
||||
}
|
||||
const pick = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, pickPayload)
|
||||
const picked = context.apiSuccess(MODULE, `真实拣货并拆分 ${sourceOrderNo}`, pick, '', OPTIONS)
|
||||
context.assert(MODULE, `${sourceOrderNo} 本分片只承接1箱`, pick,
|
||||
picked?.items?.some?.((row) => id(row.id) === id(pickItem.id)
|
||||
&& Number(row.pickedPackageCount) === 1 && Number(row.plannedPackageCount) === 1),
|
||||
`workflow=${picked?.workflowStatus || 'missing'}, taskStatus=${picked?.taskStatus || 'missing'}`, OPTIONS)
|
||||
|
||||
const replay = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, pickPayload)
|
||||
const replayed = context.apiSuccess(MODULE, `${sourceOrderNo} 拆分幂等重放`, replay, '', OPTIONS)
|
||||
context.assert(MODULE, `${sourceOrderNo} 幂等重放不重复累计`, replay,
|
||||
id(replayed?.id) === taskId && replayed?.items?.some?.((row) => id(row.id) === id(pickItem.id)
|
||||
&& Number(row.pickedPackageCount) === 1),
|
||||
`taskId=${id(replayed?.id) || 'missing'}, picked=${replayed?.items?.find?.((row) => id(row.id) === id(pickItem.id))?.pickedPackageCount ?? 'missing'}`, OPTIONS)
|
||||
|
||||
if (sourcePackageCount > 1) {
|
||||
const differentReplay = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, {
|
||||
...pickPayload, pickedPackageCount: 2,
|
||||
})
|
||||
context.addStep(MODULE, `${sourceOrderNo} 同幂等键异参被拒绝`, differentReplay,
|
||||
isConflict(differentReplay) && hasTrace(differentReplay),
|
||||
`HTTP=${differentReplay.http}, code=${codeOf(differentReplay)}, requestId=${differentReplay.requestId || 'missing'}, traceId=${differentReplay.traceId || 'missing'}`, OPTIONS)
|
||||
}
|
||||
|
||||
const treeResponse = await context.request('GET', treePath(rootOrderId, tenantId))
|
||||
const tree = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 拆分后的根聚合`, treeResponse, '', OPTIONS)
|
||||
const candidatesAfter = await context.request('GET', `${SMART}/candidates?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const candidateRowsAfter = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 不可重复消费`, candidatesAfter, '', OPTIONS)
|
||||
context.assert(MODULE, `来源 ${sourceOrderNo} 同时最多成功拆分一次`, candidatesAfter,
|
||||
Array.isArray(candidateRowsAfter) && !candidateRowsAfter.some((row) => id(row.orderId) === sourceOrderId),
|
||||
`sourceOrderId=${sourceOrderId}, stillCandidate=${Array.isArray(candidateRowsAfter) && candidateRowsAfter.some((row) => id(row.orderId) === sourceOrderId)}`, OPTIONS)
|
||||
|
||||
return { tree, treeResponse, containerId, planId, taskId, sourceInboundId: id(candidate.inboundId) }
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const fixture = context.createdResources?.phase3Loading
|
||||
|| context.createdResources?.phase3ContainerLoading
|
||||
const fixture = context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading
|
||||
const tenantId = id(fixture?.tenantId)
|
||||
const inboundId = id(fixture?.inboundId)
|
||||
const warehouseId = id(fixture?.warehouseId)
|
||||
const containerBookingId = id(fixture?.containerBookingId)
|
||||
if (!tenantId || !inboundId || !containerBookingId || fixture?.marker !== context.marker) {
|
||||
context.addCoverageGap(MODULE, SCENARIO, '父子订单与柜次聚合真实验收 fixture',
|
||||
'本轮未取得 marker 隔离的 phase3Loading 入库单和柜次,未使用历史资源;需要先运行 phase3-loading 生成真实 fixture',
|
||||
'fixture', { fixture: { available: false, marker: fixture?.marker || null, inboundId: inboundId || null, containerBookingId: containerBookingId || null } })
|
||||
if (!tenantId || !inboundId || !warehouseId || !containerBookingId || fixture?.marker !== context.marker) {
|
||||
context.addStep(MODULE, '取得本轮 marker 隔离拆分 fixture', null, false,
|
||||
`marker=${fixture?.marker || 'missing'}, inboundId=${inboundId || 'missing'}, warehouseId=${warehouseId || 'missing'}`, OPTIONS)
|
||||
return
|
||||
}
|
||||
|
||||
const inboundPath = `/api/platform/inbounds/${encodeURIComponent(inboundId)}?tenantId=${encodeURIComponent(tenantId)}`
|
||||
const inboundResponse = await context.request('GET', inboundPath)
|
||||
const inbound = context.apiSuccess(MODULE, '读取本轮入库单以解析来源订单', inboundResponse,
|
||||
const inbound = context.apiSuccess(MODULE, '读取本轮来源入库单', inboundResponse,
|
||||
`tenantId=${tenantId}, inboundId=${inboundId}`, OPTIONS)
|
||||
const orderId = id(inbound?.summary?.orderId)
|
||||
context.assert(MODULE, '入库单返回可查询的来源订单 ID', inboundResponse,
|
||||
context.assert(MODULE, '来源入库单归属本轮客户与根订单', inboundResponse,
|
||||
isPositiveId(orderId) && id(inbound?.summary?.tenantId) === tenantId,
|
||||
`orderId=${orderId || 'missing'}, tenantId=${id(inbound?.summary?.tenantId) || 'missing'}`, OPTIONS)
|
||||
if (!orderId) return
|
||||
|
||||
const treePath = `/api/platform/v1/orders/${encodeURIComponent(orderId)}/fulfillment-tree?tenantId=${encodeURIComponent(tenantId)}`
|
||||
const treeResponse = await context.request('GET', treePath)
|
||||
const tree = context.apiSuccess(MODULE, '平台查询真实父子订单履约树', treeResponse,
|
||||
const firstTreeResponse = await context.request('GET', treePath(orderId, tenantId))
|
||||
const firstTree = context.apiSuccess(MODULE, '查询首次拆分后的根履约树', firstTreeResponse,
|
||||
`orderId=${orderId}, tenantId=${tenantId}`, OPTIONS)
|
||||
context.assert(MODULE, '父子订单履约树字段和客户归属完整', treeResponse,
|
||||
treeShape(tree, orderId),
|
||||
`parent=${id(tree?.parentOrderId) || 'missing'}, children=${tree?.children?.length ?? 'n/a'}, allocated=${tree?.allocatedPackageCount ?? 'n/a'}, remaining=${tree?.remainingPackageCount ?? 'n/a'}`, OPTIONS)
|
||||
if (!tree) return
|
||||
context.assert(MODULE, '单余量模型由来源单承接已装且根数量守恒', treeResponse,
|
||||
newSingleRemainderShape(tree),
|
||||
`original=${tree.originalPackageCount}, cumulativeLoaded=${tree.cumulativeLoadedPackageCount}, currentRemainder=${tree.currentRemainderPackageCount}`,
|
||||
OPTIONS)
|
||||
context.assert(MODULE, '新批次由来源单承接已装且只创建一个余量子单', firstTreeResponse,
|
||||
treeShape(firstTree, orderId) && snapshotMatches(firstTree, 4, 1, 3, 1)
|
||||
&& firstTree.children.filter((row) => row.splitRole === 'LOADED').length === 1
|
||||
&& firstTree.children.filter((row) => row.splitRole === 'REMAINDER').length === 1,
|
||||
`original=${firstTree?.originalPackageCount ?? 'missing'}, loaded=${firstTree?.cumulativeLoadedPackageCount ?? 'missing'}, remaining=${firstTree?.currentRemainderPackageCount ?? 'missing'}`, OPTIONS)
|
||||
if (!treeShape(firstTree, orderId)) return
|
||||
|
||||
const firstLoaded = firstTree.children.find((row) => row.splitRole === 'LOADED')
|
||||
context.assert(MODULE, '新拆分未生成 LOADED 子单且完成目标保持来源单', firstTreeResponse,
|
||||
id(firstLoaded?.orderId) === orderId && id(firstLoaded?.sourceOrderId) === orderId
|
||||
&& firstLoaded?.orderNo === firstTree.parentOrderNo && Number(firstLoaded?.packageCount) === 1,
|
||||
`loadedOrderId=${id(firstLoaded?.orderId) || 'missing'}, sourceOrderId=${id(firstLoaded?.sourceOrderId) || 'missing'}`, OPTIONS)
|
||||
|
||||
// Re-query through the real merchant contract as well. The customer scope is
|
||||
// taken from the token; no tenant/customer id is accepted by this endpoint.
|
||||
const merchantLogin = await context.request('POST', '/api/auth/login', {
|
||||
username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD,
|
||||
}, '')
|
||||
const merchantAuth = context.apiSuccess(MODULE, '客户账号登录以验证履约树隔离', merchantLogin, '', OPTIONS)
|
||||
const merchantAuth = context.apiSuccess(MODULE, '客户账号登录验证隔离与权限', merchantLogin, '', OPTIONS)
|
||||
const merchantToken = merchantAuth?.accessToken
|
||||
if (merchantToken) {
|
||||
const merchantTree = await context.request('GET',
|
||||
const merchantTreeResponse = await context.request('GET',
|
||||
`/api/merchant/v1/orders/${encodeURIComponent(orderId)}/fulfillment-tree`, undefined, merchantToken)
|
||||
const merchantTreeData = context.apiSuccess(MODULE, '客户查询本客户父子履约树', merchantTree,
|
||||
`orderId=${orderId}`, OPTIONS)
|
||||
context.assert(MODULE, '客户履约树不返回平台内部字段且归属当前客户', merchantTree,
|
||||
treeShape(merchantTreeData, orderId),
|
||||
`parent=${id(merchantTreeData?.parentOrderId) || 'missing'}, children=${merchantTreeData?.children?.length ?? 'n/a'}`, OPTIONS)
|
||||
}
|
||||
const merchantTree = context.apiSuccess(MODULE, '客户查询本客户根履约树', merchantTreeResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '客户履约树与平台数量聚合一致', merchantTreeResponse,
|
||||
treeShape(merchantTree, orderId) && snapshotMatches(merchantTree, 4, 1, 3, 1),
|
||||
`children=${merchantTree?.children?.length ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const slicesPath = `/api/platform/v1/orders/container-bookings/${encodeURIComponent(containerBookingId)}/fulfillment-slices?tenantId=${encodeURIComponent(tenantId)}`
|
||||
const slicesResponse = await context.request('GET', slicesPath)
|
||||
const slices = context.apiSuccess(MODULE, '平台按柜次查询真实履约分片聚合', slicesResponse,
|
||||
`containerBookingId=${containerBookingId}, tenantId=${tenantId}`, OPTIONS)
|
||||
context.assert(MODULE, '柜次分片响应为客户隔离数组且字段完整', slicesResponse,
|
||||
Array.isArray(slices) && slices.every((row) => sliceShape(row, containerBookingId, tenantId)),
|
||||
`rows=${Array.isArray(slices) ? slices.length : 'n/a'}`, OPTIONS)
|
||||
|
||||
if (Array.isArray(slices) && slices.length > 0) {
|
||||
const loaded = slices.filter((row) => row.splitRole === 'LOADED')
|
||||
.reduce((sum, row) => sum + Number(row.packageCount), 0)
|
||||
const remainder = slices.filter((row) => row.splitRole === 'REMAINDER')
|
||||
.reduce((sum, row) => sum + Number(row.packageCount), 0)
|
||||
context.assert(MODULE, '柜次分片按来源单承载已装且与根树数量守恒', slicesResponse,
|
||||
slices.filter((row) => row.splitRole === 'LOADED').every((row) => id(row.childOrderId) === id(row.sourceOrderId))
|
||||
&& loaded === Number(tree.cumulativeLoadedPackageCount)
|
||||
&& remainder >= 0 && Number(tree.currentRemainderPackageCount) >= remainder,
|
||||
`loaded=${loaded}, treeAllocated=${tree.allocatedPackageCount}, remainder=${remainder}, treeRemaining=${tree.remainingPackageCount}`, OPTIONS)
|
||||
} else {
|
||||
context.addCoverageGap(MODULE, SCENARIO, '一单多柜与多单拼柜数量守恒',
|
||||
'真实柜次分片查询端点已公开并完成字段/客户隔离验证,但本轮 phase3Loading 仅生成单来源订单且未通过公开接口生成跨订单分片,无法伪造一单多柜或一柜多单数量守恒 fixture',
|
||||
'fixture', { fixture: { available: true, marker: context.marker, inboundId, containerBookingId, orderId } })
|
||||
const forbidden = await request(context, 'GET', treePath(orderId, tenantId), undefined, {}, merchantToken)
|
||||
context.addStep(MODULE, '客户账号无权调用平台父子订单接口', forbidden,
|
||||
isForbidden(forbidden) && hasTrace(forbidden),
|
||||
`HTTP=${forbidden.http}, code=${codeOf(forbidden)}, requestId=${forbidden.requestId || 'missing'}, traceId=${forbidden.traceId || 'missing'}`, OPTIONS)
|
||||
}
|
||||
|
||||
const wrongTenant = (BigInt(tenantId) + 1n).toString()
|
||||
const wrongTreePath = `/api/platform/v1/orders/${encodeURIComponent(orderId)}/fulfillment-tree?tenantId=${encodeURIComponent(wrongTenant)}`
|
||||
const wrongTree = await context.request('GET', wrongTreePath)
|
||||
context.addStep(MODULE, '父子履约树跨客户归属拒绝', wrongTree,
|
||||
failure(wrongTree), `HTTP=${wrongTree.http}, code=${codeOf(wrongTree)}, tenantId=${wrongTenant}`, OPTIONS)
|
||||
const wrongTree = await context.request('GET', treePath(orderId, wrongTenant))
|
||||
context.addStep(MODULE, '根履约树跨客户访问拒绝且返回追踪信息', wrongTree,
|
||||
wrongTree.http === 200 && codeOf(wrongTree) !== '200' && hasTrace(wrongTree),
|
||||
`HTTP=${wrongTree.http}, code=${codeOf(wrongTree)}, requestId=${wrongTree.requestId || 'missing'}, traceId=${wrongTree.traceId || 'missing'}`, OPTIONS)
|
||||
|
||||
const wrongSlicesPath = `/api/platform/v1/orders/container-bookings/${encodeURIComponent(containerBookingId)}/fulfillment-slices?tenantId=${encodeURIComponent(wrongTenant)}`
|
||||
const wrongSlices = await context.request('GET', wrongSlicesPath)
|
||||
const wrongSlicesData = context.apiSuccess(MODULE, '柜次分片跨客户查询返回隔离结果', wrongSlices,
|
||||
`containerBookingId=${containerBookingId}, tenantId=${wrongTenant}`, OPTIONS)
|
||||
context.assert(MODULE, '柜次分片不泄露其他客户数据', wrongSlices,
|
||||
Array.isArray(wrongSlicesData) && wrongSlicesData.length === 0,
|
||||
`rows=${Array.isArray(wrongSlicesData) ? wrongSlicesData.length : 'n/a'}`, OPTIONS)
|
||||
const initialSlices = await context.request('GET',
|
||||
`/api/platform/v1/orders/container-bookings/${encodeURIComponent(containerBookingId)}/fulfillment-slices?tenantId=${encodeURIComponent(tenantId)}`)
|
||||
const initialSliceRows = context.apiSuccess(MODULE, '按首次柜次查询来源与余量分片', initialSlices, '', OPTIONS)
|
||||
context.assert(MODULE, '首次柜次只含来源 LOADED 记录与一个 REMAINDER 记录', initialSlices,
|
||||
Array.isArray(initialSliceRows) && initialSliceRows.length === 2
|
||||
&& initialSliceRows.some((row) => row.splitRole === 'LOADED' && id(row.childOrderId) === id(row.sourceOrderId))
|
||||
&& initialSliceRows.some((row) => row.splitRole === 'REMAINDER' && id(row.childOrderId) !== id(row.sourceOrderId)),
|
||||
`rows=${Array.isArray(initialSliceRows) ? initialSliceRows.length : 'missing'}`, OPTIONS)
|
||||
|
||||
if (merchantToken) {
|
||||
const merchantSlices = await context.request('GET',
|
||||
`/api/merchant/v1/orders/container-bookings/${encodeURIComponent(containerBookingId)}/fulfillment-slices`,
|
||||
undefined, merchantToken)
|
||||
const merchantSlicesData = context.apiSuccess(MODULE, '客户查询本客户柜次履约分片', merchantSlices, '', OPTIONS)
|
||||
context.assert(MODULE, '客户柜次分片仅返回当前客户数据', merchantSlices,
|
||||
Array.isArray(merchantSlicesData) && merchantSlicesData.every((row) => sliceShape(row, containerBookingId, tenantId)),
|
||||
`rows=${Array.isArray(merchantSlicesData) ? merchantSlicesData.length : 'n/a'}`, OPTIONS)
|
||||
context.createdResources.phase3OrderSplitting = {
|
||||
marker: context.marker, tenantId, rootOrderId: orderId, rootOrderNo: firstTree.parentOrderNo,
|
||||
retainedAuditRecordIds: [], activeRemainderCount: activeRemainders(firstTree).length,
|
||||
finalLoadedPackageCount: Number(firstTree.cumulativeLoadedPackageCount),
|
||||
finalRemainingPackageCount: Number(firstTree.currentRemainderPackageCount), cleaned: false,
|
||||
cleanupNote: '公开 API 不允许删除订单/柜次/PDA审计记录;只有全部分片完成且无活动余量时标记业务清理完成。',
|
||||
}
|
||||
let tree = firstTree
|
||||
for (const step of [
|
||||
{ sourcePackageCount: 3, expectedLoaded: 2, expectedRemaining: 2, sourceSuffix: 1, activeSuffix: 2 },
|
||||
{ sourcePackageCount: 2, expectedLoaded: 3, expectedRemaining: 1, sourceSuffix: 2, activeSuffix: 3 },
|
||||
{ sourcePackageCount: 1, expectedLoaded: 4, expectedRemaining: 0, sourceSuffix: 3, activeSuffix: null },
|
||||
]) {
|
||||
const active = activeRemainders(tree)
|
||||
const source = active[0]
|
||||
context.assert(MODULE, `链式拆分来源固定为根编号 -${step.sourceSuffix}`, null,
|
||||
active.length === 1 && source?.orderNo === `${tree.parentOrderNo}-${step.sourceSuffix}`,
|
||||
`active=${active.map((row) => row.orderNo).join(',') || 'none'}`, OPTIONS)
|
||||
if (!source) return
|
||||
|
||||
const result = await loadOneFragment(context, {
|
||||
tenantId, rootOrderId: orderId, sourceOrderId: id(source.orderId), sourceOrderNo: source.orderNo,
|
||||
warehouseId, sourcePackageCount: step.sourcePackageCount, sequence: step.sourceSuffix,
|
||||
})
|
||||
if (!result?.tree) return
|
||||
tree = result.tree
|
||||
Object.assign(context.createdResources.phase3OrderSplitting, {
|
||||
activeRemainderCount: activeRemainders(tree).length,
|
||||
finalLoadedPackageCount: Number(tree.cumulativeLoadedPackageCount),
|
||||
finalRemainingPackageCount: Number(tree.currentRemainderPackageCount),
|
||||
})
|
||||
context.assert(MODULE, `完成第 ${step.sourceSuffix + 1} 个分片后根数量守恒`, result.treeResponse,
|
||||
treeShape(tree, orderId)
|
||||
&& snapshotMatches(tree, 4, step.expectedLoaded, step.expectedRemaining, step.activeSuffix),
|
||||
`original=${tree.originalPackageCount}, loaded=${tree.cumulativeLoadedPackageCount}, remaining=${tree.currentRemainderPackageCount}`, OPTIONS)
|
||||
}
|
||||
|
||||
context.addStep(MODULE, '父子订单拆分、装载数量守恒与剩余自动子订单已由真实履约链路产生', treeResponse,
|
||||
newSingleRemainderShape(tree),
|
||||
`root=${orderId}, fragments=${tree.children.length}, currentRemainder=${tree.currentRemainderPackageCount}`, OPTIONS)
|
||||
const remainderCodes = tree.children.filter((row) => row.splitRole === 'REMAINDER').map((row) => row.orderNo)
|
||||
context.assert(MODULE, '余量子单始终按根编号全局递增且禁止嵌套编号', null,
|
||||
JSON.stringify(remainderCodes) === JSON.stringify([
|
||||
`${tree.parentOrderNo}-1`, `${tree.parentOrderNo}-2`, `${tree.parentOrderNo}-3`,
|
||||
]) && remainderCodes.every((code) => !/-[0-9]+-[0-9]+$/.test(code)),
|
||||
`codes=${remainderCodes.join(',')}`, OPTIONS)
|
||||
context.assert(MODULE, '最后一次全量装完不再生成子单且根整体完成', null,
|
||||
activeRemainders(tree).length === 0 && Number(tree.currentRemainderPackageCount) === 0
|
||||
&& Number(tree.cumulativeLoadedPackageCount) === Number(tree.originalPackageCount),
|
||||
`loaded=${tree.cumulativeLoadedPackageCount}, remaining=${tree.currentRemainderPackageCount}`, OPTIONS)
|
||||
|
||||
const nestedSearch = await context.request('GET',
|
||||
`/api/platform/v1/orders?keyword=${encodeURIComponent(`${tree.parentOrderNo}-1-1`)}&page=1&size=20`)
|
||||
const nestedPage = context.apiSuccess(MODULE, '搜索非法嵌套编号不命中本轮子单', nestedSearch, '', OPTIONS)
|
||||
context.assert(MODULE, '不存在根编号 -1-1 的嵌套子单', nestedSearch,
|
||||
Array.isArray(nestedPage?.records) && !nestedPage.records.some((row) => id(row.id) === orderId
|
||||
|| String(row.orderNo || '').startsWith(`${tree.parentOrderNo}-1-1`)),
|
||||
`records=${nestedPage?.records?.length ?? 'missing'}`, OPTIONS)
|
||||
|
||||
Object.assign(context.createdResources.phase3OrderSplitting, {
|
||||
activeRemainderCount: activeRemainders(tree).length,
|
||||
finalLoadedPackageCount: Number(tree.cumulativeLoadedPackageCount),
|
||||
finalRemainingPackageCount: Number(tree.currentRemainderPackageCount),
|
||||
cleaned: activeRemainders(tree).length === 0,
|
||||
cleanupNote: '公开 API 不允许删除已完成订单/柜次/PDA审计记录;本轮 marker 链路已全部完成且无活动余量。',
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user