From ce25a0a72dadc0062b417e5391cb49cae80af701 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 10 Aug 2026 14:15:04 +0800 Subject: [PATCH] test(phase3): verify fulfillment aggregates and update schedule gaps --- .../phase3-order-splitting.mjs | 131 ++++++++++++++---- .../phase3-port-schedules.mjs | 6 +- 2 files changed, 106 insertions(+), 31 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index 78a620060..a2e104378 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -1,36 +1,111 @@ const MODULE = 'phase3-order-splitting' const SCENARIO = 'parent-child-multi-container-consolidation' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } + +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 treeShape(tree, orderId) { + if (!tree || !isPositiveId(tree.parentOrderId) || id(tree.parentOrderId) !== orderId + || typeof tree.parentOrderNo !== 'string' || !Number.isInteger(Number(tree.parentVersion)) + || !Array.isArray(tree.children) || !Number.isInteger(Number(tree.allocatedPackageCount)) + || !Number.isInteger(Number(tree.remainingPackageCount))) 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 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) +} export async function run(context) { const fixture = context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading - const metadata = { - fixture: { - available: Boolean(fixture?.inboundId && fixture?.containerBookingId), - marker: fixture?.marker || null, - inboundId: fixture?.inboundId || null, - containerBookingId: fixture?.containerBookingId || null, - }, - activation: { - state: 'pending-public-contract', - assertions: [ - '父单与自动剩余子单关系可由 HTTP 查询', - '一单多柜关系由 container aggregate 返回', - '一柜多单关系由 container aggregate 返回', - '已装数量 + 自动子单剩余数量 = 拆分前数量', - '同一 Idempotency-Key 与相同参数重放返回同一拆分结果', - '同一 Idempotency-Key 与不同参数被冲突拒绝且无部分写入', - '旧 expectedVersion 被冲突拒绝且父子订单数量不变', - '平台与客户 fulfillment-tree 均隔离其他客户数据', - '子订单继续拆分被拒绝,不产生嵌套层级', - '已消费 REMAINDER 再次消费被拒绝且数量不变', - ], - }, + const tenantId = id(fixture?.tenantId) + const inboundId = id(fixture?.inboundId) + 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 } }) + return } - context.addCoverageGap(MODULE, SCENARIO, '父/子订单拆分、装载数量守恒与剩余自动子订单', - 'PDA partial/skip 内部结算已实现,但公开 InboundOrderSummary/Detail 未返回 inboundRole、parentInboundId、rootInboundId、splitStatus 或拆分批次,HTTP 总控无法证明父子关系和守恒,不能用同 orderId 的记录数量伪造成功', - 'contract-gap', metadata) - context.addCoverageGap(MODULE, SCENARIO, '一单多柜与多单拼柜', - 'SmartLoadingController 支持按明细分配,但当前公开契约没有订单-多柜/柜次-多订单关系聚合,也没有本轮安全 fixture 一次生成两个可装柜入库单;待真实查询 DTO 与专用 fixture 接口公开后转为断言', - 'contract-gap', metadata) + + 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, '入库单返回可查询的来源订单 ID', 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, + `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 + + 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, + loaded === Number(tree.allocatedPackageCount) + && remainder >= 0 && Number(tree.remainingPackageCount) >= 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 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 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) + + context.addCoverageGap(MODULE, SCENARIO, '父子订单拆分、装载数量守恒与剩余自动子订单', + '真实 fulfillment-tree 已可查询父订单、子订单字段和数量摘要,但当前公开平台契约只有查询端点,没有受权限保护的拆分创建/消费 fixture;本轮不能证明自动生成余量子单、幂等重放、版本冲突及禁止嵌套拆分', + 'contract-gap', { fixture: { available: true, marker: context.marker, inboundId, containerBookingId, orderId } }) } diff --git a/scripts/admin-test-env-modules/phase3-port-schedules.mjs b/scripts/admin-test-env-modules/phase3-port-schedules.mjs index 0cc38a07a..afeaa0717 100644 --- a/scripts/admin-test-env-modules/phase3-port-schedules.mjs +++ b/scripts/admin-test-env-modules/phase3-port-schedules.mjs @@ -79,9 +79,9 @@ function scheduleData(context, suffix, oceanBookingId, containerBookingId, expir } export async function run(context) { - context.addCoverageGap(MODULE, OPTIONS.scenario, '官网船期查询任务、3天窗口最多3次及失败/过期状态', - '当前 PlatformPortScheduleController/OpenAPI 仅公开来源观测 CRUD,没有官网查询 task、3天计数、FAILED/EXPIRED 或任务级人工兜底端点', - 'contract-gap') + // 官网船期查询任务、频率限制和 FAILED/EXPIRED 终态由 + // phase3-carrier-schedule-queries 模块通过真实 HTTP 契约验收;此处只保留 + // 仍受第三方交互能力限制的 ZIM/COSCO 外部缺口,避免重复报告已实现能力。 context.addCoverageGap(MODULE, OPTIONS.scenario, 'ZIM 官网 hCaptcha 自动查询', 'ZIM hCaptcha 需要第三方交互能力,仓库未公开 provider/task 契约;保留为外部依赖缺口', 'external-third-party')