385 lines
24 KiB
JavaScript
385 lines
24 KiB
JavaScript
const MODULE = 'phase3-order-splitting'
|
|
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 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
|
|
}
|
|
|
|
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 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.originalPackageCount))
|
|
|| !Number.isInteger(Number(tree.cumulativeLoadedPackageCount))
|
|
|| !Number.isInteger(Number(tree.currentRemainderPackageCount))) return false
|
|
const rootNo = tree.parentOrderNo
|
|
const nestedCode = /-[0-9]+-[0-9]+$/
|
|
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 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 tenantId = id(fixture?.tenantId)
|
|
const inboundId = id(fixture?.inboundId)
|
|
const warehouseId = id(fixture?.warehouseId)
|
|
const containerBookingId = id(fixture?.containerBookingId)
|
|
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,
|
|
`tenantId=${tenantId}, inboundId=${inboundId}`, OPTIONS)
|
|
const orderId = id(inbound?.summary?.orderId)
|
|
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 firstTreeResponse = await context.request('GET', treePath(orderId, tenantId))
|
|
const firstTree = context.apiSuccess(MODULE, '查询首次拆分后的根履约树', firstTreeResponse,
|
|
`orderId=${orderId}, tenantId=${tenantId}`, 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)
|
|
|
|
const merchantLogin = await context.request('POST', '/api/auth/login', {
|
|
username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD,
|
|
}, '')
|
|
const merchantAuth = context.apiSuccess(MODULE, '客户账号登录验证隔离与权限', merchantLogin, '', OPTIONS)
|
|
const merchantToken = merchantAuth?.accessToken
|
|
if (merchantToken) {
|
|
const merchantTreeResponse = await context.request('GET',
|
|
`/api/merchant/v1/orders/${encodeURIComponent(orderId)}/fulfillment-tree`, undefined, merchantToken)
|
|
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 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 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 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)
|
|
|
|
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)
|
|
}
|
|
|
|
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 链路已全部完成且无活动余量。',
|
|
})
|
|
}
|