2026-08-10 12:23:43 +08:00
|
|
|
const MODULE = 'phase3-order-splitting'
|
|
|
|
|
const SCENARIO = 'parent-child-multi-container-consolidation'
|
2026-08-10 14:15:04 +08:00
|
|
|
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
2026-08-10 14:31:03 +08:00
|
|
|
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin'
|
|
|
|
|
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!'
|
2026-08-10 14:15:04 +08:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
2026-08-10 12:23:43 +08:00
|
|
|
|
|
|
|
|
export async function run(context) {
|
|
|
|
|
const fixture = context.createdResources?.phase3Loading
|
|
|
|
|
|| context.createdResources?.phase3ContainerLoading
|
2026-08-10 14:15:04 +08:00
|
|
|
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
|
2026-08-10 12:23:43 +08:00
|
|
|
}
|
2026-08-10 14:15:04 +08:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-08-10 14:18:24 +08:00
|
|
|
// 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', {
|
2026-08-10 14:31:03 +08:00
|
|
|
username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD,
|
2026-08-10 14:18:24 +08:00
|
|
|
}, '')
|
|
|
|
|
const merchantAuth = context.apiSuccess(MODULE, '客户账号登录以验证履约树隔离', merchantLogin, '', OPTIONS)
|
|
|
|
|
const merchantToken = merchantAuth?.accessToken
|
|
|
|
|
if (merchantToken) {
|
|
|
|
|
const merchantTree = 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 14:15:04 +08:00
|
|
|
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)
|
|
|
|
|
|
2026-08-10 14:18:24 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 14:15:04 +08:00
|
|
|
context.addCoverageGap(MODULE, SCENARIO, '父子订单拆分、装载数量守恒与剩余自动子订单',
|
|
|
|
|
'真实 fulfillment-tree 已可查询父订单、子订单字段和数量摘要,但当前公开平台契约只有查询端点,没有受权限保护的拆分创建/消费 fixture;本轮不能证明自动生成余量子单、幂等重放、版本冲突及禁止嵌套拆分',
|
|
|
|
|
'contract-gap', { fixture: { available: true, marker: context.marker, inboundId, containerBookingId, orderId } })
|
2026-08-10 12:23:43 +08:00
|
|
|
}
|