diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index aa295168b..6d1612664 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -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 链路已全部完成且无活动余量。', + }) }