diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index d6b356220..9476688fd 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -10,7 +10,8 @@ const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitt 'manual-preload-push-fixture', 'manual-preload-push-verify', 'manual-pda-ready-fixture', 'manual-pda-ready-verify', 'manual-pda-batch-fixture', 'manual-pda-batch-verify', - 'manual-loading-complete-fixture', 'manual-loading-complete-verify'] + 'manual-loading-complete-fixture', 'manual-loading-complete-verify', + 'coload-groups'] const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES]) const MODULE_DEPENDENCIES = new Map([ @@ -19,6 +20,7 @@ const MODULE_DEPENDENCIES = new Map([ ['orders', ['merchant-qualification-fixture']], ['inbound-measurement', ['orders']], ['manual-order-splitting-fixture', ['inbound-measurement']], + ['coload-groups', ['inbound-measurement']], ['phase3-container-loading', ['inbound-measurement']], ['phase3-loading', ['phase3-container-loading']], ['phase3-container-overview', ['phase3-loading']], @@ -370,7 +372,7 @@ export function validateExecutionOptions(options) { } if (options.waitSplitCount > 1) { const fixtureModules = new Set(['merchant-qualification-fixture', 'orders', 'inbound-measurement', - 'manual-order-splitting-fixture']) + 'manual-order-splitting-fixture', 'coload-groups']) if (options.modules.some((module) => !fixtureModules.has(module))) { throw new Error('批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement、manual-order-splitting-fixture') } diff --git a/scripts/admin-test-env-modules/coload-groups-contract.test.mjs b/scripts/admin-test-env-modules/coload-groups-contract.test.mjs new file mode 100644 index 000000000..df85d161d --- /dev/null +++ b/scripts/admin-test-env-modules/coload-groups-contract.test.mjs @@ -0,0 +1,21 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' + +test('同柜不可拆组HTTP验收锁定公开接口与防绕过矩阵', async () => { + const source = await readFile(new URL('./coload-groups.mjs', import.meta.url), 'utf8') + for (const fragment of [ + '/api/platform/v1/first-leg/coload-groups', + '/api/platform/v1/first-leg/coload-groups/batch-query', + '/group-assignments', + '/inbound-assignments', + '/recommend', + '/push-preload', + '创建同键同载荷精确重放', + '创建同键异载荷返回并发冲突', + '组成员不能单独调整箱数', + '组成员不能单独删除', + '整组原子移除', + '智能推荐对整组全取或全不取', + ]) assert.match(source, new RegExp(fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) +}) diff --git a/scripts/admin-test-env-modules/coload-groups.mjs b/scripts/admin-test-env-modules/coload-groups.mjs new file mode 100644 index 000000000..029458e08 --- /dev/null +++ b/scripts/admin-test-env-modules/coload-groups.mjs @@ -0,0 +1,322 @@ +const MODULE = 'coload-groups' +const OPTIONS = { scenario: 'indivisible-coload-group', deploymentBlocker: true } + +function id(value) { return value == null ? '' : String(value) } +function code(response) { return id(response?.body?.code) || 'missing' } +function evidence(response, extra = '') { + return `HTTP=${response?.http ?? 'missing'}, code=${code(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}${extra ? `, ${extra}` : ''}` +} +function conflict(context, name, response, extra = '') { + const passed = response?.http === 200 && code(response) === 'A0409' + context.assert(MODULE, name, response, passed, evidence(response, extra), OPTIONS) + return passed +} +function planSignature(plan) { + return JSON.stringify({ + id: id(plan?.id), version: Number(plan?.version), status: plan?.status || null, + items: [...(plan?.items || [])].map((item) => ({ + id: id(item?.id), accountId: id(item?.accountId), inboundId: id(item?.inboundId), + packageCount: Number(item?.packageCount), version: Number(item?.version), + })).sort((left, right) => left.id.localeCompare(right.id)), + }) +} +function memberFacts(group) { + return [...(group?.members || [])].map((item) => ({ + orderId: id(item?.orderId), inboundId: id(item?.inboundId), + fullPackageCount: Number(item?.fullPackageCount), + })).sort((left, right) => left.orderId.localeCompare(right.orderId)) +} +async function readPlan(context, planId, name) { + const response = await context.request('GET', `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}`) + return { response, data: context.apiSuccess(MODULE, name, response, `planId=${planId}`, OPTIONS) } +} + +async function createDifferentRouteFixture(context, base) { + const orders = await import(new URL('./orders.mjs', import.meta.url)) + const measurement = await import(new URL('./inbound-measurement.mjs', import.meta.url)) + const originalMarker = context.marker + const originalOrder = context.createdResources.orders + const originalMeasurement = context.createdResources.inboundMeasurement + const originalProfile = context.context.orderProfile + const originalDestination = context.context.orderDestinationType + const originalFulfillment = context.context.orderFulfillmentType + const originalRoute = context.context.waitSplitRoute + const originalVariant = context.context.waitSplitVariant + try { + for (let attempt = 1; attempt <= 3; attempt += 1) { + context.marker = `${originalMarker}-ROUTE-${attempt}` + context.context.orderProfile = { + destinationType: attempt === 3 ? 'OVERSEAS' : 'FBA', fulfillmentType: 'STANDARD', + pickupMethod: 'PICKUP', taxMode: 'BUY_DECLARATION', containerMode: 'LCL', customsInspection: false, + } + context.context.orderDestinationType = context.context.orderProfile.destinationType + context.context.orderFulfillmentType = 'STANDARD' + context.context.waitSplitVariant = { + sequence: 90 + attempt, skuCount: 1, packageCountPerSku: 2, packageCounts: [2], + totalPackageCount: 2, palletCount: 1, grossWeightPerPalletKg: 45, + measurementProfile: { code: 'SMALL', first: { length: 29.1, width: 19.1, height: 18.1 }, + replacement: { length: 29.5, width: 19.5, height: 18.5 }, outer: { length: 34, width: 24, height: 24 } }, + } + context.context.waitSplitRoute = { + originWarehouseName: base.receivingWarehouseName || '', randomize: true, + randomIndex: 50 + attempt * 31, destinationIndex: 80 + attempt * 37, taxModeIndex: attempt, + } + await orders.run(context) + await measurement.run(context) + const order = context.createdResources.orders || {} + const inbound = context.createdResources.inboundMeasurement || {} + const candidate = { + marker: context.marker, orderId: id(order.orderId), inboundId: id(inbound.inboundId), + tenantId: id(order.tenantId), warehouseId: id(order.receivingWarehouseId), + receivingWarehouseName: order.receivingWarehouseName || null, + channelCode: order.channelCode || null, destinationType: order.destinationType || null, + destinationWarehouseId: id(order.destinationWarehouseId), + destinationWarehouseCode: order.destinationWarehouseCode || null, + packages: (order.skuLines || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0), + processStatus: inbound.processStatus, + } + const differs = candidate.warehouseId !== base.warehouseId + || candidate.channelCode !== base.channelCode + || candidate.destinationType !== base.destinationType + || candidate.destinationWarehouseId !== id(base.destinationWarehouseId) + || candidate.destinationWarehouseCode !== id(base.destinationWarehouseCode) + if (candidate.orderId && candidate.inboundId && candidate.processStatus === 'WAIT_SPLIT' && differs) return candidate + } + return null + } finally { + context.marker = originalMarker + if (originalOrder === undefined) delete context.createdResources.orders + else context.createdResources.orders = originalOrder + if (originalMeasurement === undefined) delete context.createdResources.inboundMeasurement + else context.createdResources.inboundMeasurement = originalMeasurement + context.context.orderProfile = originalProfile + context.context.orderDestinationType = originalDestination + context.context.orderFulfillmentType = originalFulfillment + context.context.waitSplitRoute = originalRoute + context.context.waitSplitVariant = originalVariant + } +} + +export async function run(context) { + const fixtures = (context.createdResources?.waitSplitFixtures || []).map((item) => ({ + ...item, orderId: id(item.orderId), inboundId: id(item.inboundId), tenantId: id(item.tenantId), + warehouseId: id(item.receivingWarehouseId), packages: Number(item.totalPackageCount), + })) + const usable = fixtures.filter((item) => item.orderId && item.inboundId && item.tenantId + && item.warehouseId && item.channelCode && item.packages > 0) + context.assert(MODULE, '取得至少四票本轮 WAIT_SPLIT 新数据', null, usable.length >= 4, + `count=${usable.length}, marker=${context.marker}`, OPTIONS) + if (usable.length < 4) return + + const [first, second, third, fourth] = usable + const sameRoute = [second, third, fourth].every((item) => item.warehouseId === first.warehouseId + && item.channelCode === first.channelCode && item.destinationType === first.destinationType + && id(item.destinationWarehouseId) === id(first.destinationWarehouseId) + && id(item.destinationWarehouseCode) === id(first.destinationWarehouseCode)) + context.assert(MODULE, '正向样本属于同一仓库渠道和目的仓港', null, sameRoute, + `warehouse=${first.warehouseId}, channel=${first.channelCode}, destination=${first.destinationType}/${id(first.destinationWarehouseId) || id(first.destinationWarehouseCode)}`, + OPTIONS) + if (!sameRoute) return + + const groupOrders = [first.orderId, second.orderId] + const createKey = `${context.marker}-coload-create`.slice(0, 128) + const createdResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups', + { orderIds: groupOrders }, undefined, { 'Idempotency-Key': createKey }) + const group = context.apiSuccess(MODULE, '创建同柜不可拆组', createdResponse, + evidence(createdResponse, `orders=${groupOrders.join(',')}`), OPTIONS) + const groupId = id(group?.groupId) + const frozen = memberFacts(group) + context.assert(MODULE, '组冻结两票权威入库单与满箱数', createdResponse, + groupId && group?.status === 'ACTIVE' && frozen.length === 2 + && frozen.every((member) => [first, second].some((item) => item.orderId === member.orderId + && item.inboundId === member.inboundId && item.packages === member.fullPackageCount)), + `groupId=${groupId || 'missing'}, status=${group?.status || 'missing'}, members=${JSON.stringify(frozen)}`, OPTIONS) + if (!groupId) return + + const replayResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups', + { orderIds: groupOrders }, undefined, { 'Idempotency-Key': createKey }) + const replay = context.apiSuccess(MODULE, '创建同键同载荷精确重放', replayResponse, + evidence(replayResponse, `groupId=${id(replayResponse?.body?.data?.groupId)}`), OPTIONS) + context.assert(MODULE, '创建重放不生成第二组', replayResponse, + id(replay?.groupId) === groupId && replay?.version === group?.version + && JSON.stringify(memberFacts(replay)) === JSON.stringify(frozen), + `original=${groupId}, replay=${id(replay?.groupId)}, version=${replay?.version ?? 'missing'}`, OPTIONS) + + const changedPayloadResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups', + { orderIds: [first.orderId, third.orderId] }, undefined, { 'Idempotency-Key': createKey }) + conflict(context, '创建同键异载荷返回并发冲突', changedPayloadResponse) + + const differentRoute = await createDifferentRouteFixture(context, first) + context.assert(MODULE, '创建本轮不同仓库渠道或目的仓港反向样本', null, Boolean(differentRoute), + differentRoute + ? `orderId=${differentRoute.orderId}, warehouse=${differentRoute.warehouseId}, channel=${differentRoute.channelCode}, destination=${differentRoute.destinationType}/${differentRoute.destinationWarehouseId || differentRoute.destinationWarehouseCode}` + : '三次正式下单均未取得不同线路,未借用历史订单', OPTIONS) + if (differentRoute) { + const routeConflictResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups', + { orderIds: [third.orderId, differentRoute.orderId] }, undefined, + { 'Idempotency-Key': `${context.marker}-coload-route-conflict`.slice(0, 128) }) + conflict(context, '跨仓库渠道或目的仓港创建整组被拒绝且不落组', routeConflictResponse, + `baseOrder=${third.orderId}, mismatchOrder=${differentRoute.orderId}`) + const zeroDriftResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query', + { orderIds: [third.orderId, differentRoute.orderId] }) + const zeroDrift = context.apiSuccess(MODULE, '回读跨线路拒绝后的组事实', zeroDriftResponse, + evidence(zeroDriftResponse), OPTIONS) || [] + context.assert(MODULE, '跨线路拒绝后两个订单均无组事实', zeroDriftResponse, zeroDrift.length === 0, + `count=${zeroDrift.length}`, OPTIONS) + } + + const queryResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query', + { orderIds: [fourth.orderId, second.orderId, first.orderId, third.orderId] }) + const queried = context.apiSuccess(MODULE, '按订单批量回读组事实', queryResponse, + evidence(queryResponse), OPTIONS) || [] + context.assert(MODULE, '批查只返回组内订单且长ID保持字符串', queryResponse, + queried.length === 2 && queried.every((item) => groupOrders.includes(id(item?.orderId)) + && id(item?.group?.groupId) === groupId && typeof item?.group?.groupId === 'string'), + `count=${queried.length}, orderIds=${queried.map((item) => id(item?.orderId)).join(',')}`, OPTIONS) + + const simulationResponse = await context.request('POST', '/api/platform/v1/containers', { + channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '40HC', + capacityCbm: 76, maxWeightKg: 28000, + }) + const simulation = context.apiSuccess(MODULE, '创建本轮组装柜模拟柜', simulationResponse, + evidence(simulationResponse), OPTIONS) + const bookingId = id(simulation?.id) + if (!bookingId) return + const planResponse = await context.request('POST', '/api/platform/v1/first-leg/smart-loading/plans', { + bookingId, remark: `${context.marker} 同柜不可拆组验收`, + }) + let plan = context.apiSuccess(MODULE, '创建同柜组装柜计划', planResponse, + evidence(planResponse, `bookingId=${bookingId}`), OPTIONS) + const planId = id(plan?.id) + if (!planId) return + + const candidateResponse = await context.request('GET', + `/api/platform/v1/first-leg/smart-loading/inbound-candidates?bookingId=${encodeURIComponent(bookingId)}&warehouseId=${encodeURIComponent(first.warehouseId)}`) + const candidates = context.apiSuccess(MODULE, '读取整票智能装柜候选', candidateResponse, + evidence(candidateResponse), OPTIONS) || [] + const groupedCandidates = candidates.filter((item) => id(item?.coLoadGroupId) === groupId) + context.assert(MODULE, '每个组成员候选携带相同整组事实', candidateResponse, + groupedCandidates.length === 2 && groupedCandidates.every((item) => Number(item?.memberCount) === 2 + && Number(item?.groupTotalBoxes) === first.packages + second.packages && item?.locked !== true), + `count=${groupedCandidates.length}, groupTotal=${first.packages + second.packages}`, OPTIONS) + + const beforeIndividual = await readPlan(context, planId, '读取单票绕过前计划快照') + const individualResponse = await context.request('POST', + `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/inbound-assignments`, { + inboundId: first.inboundId, packageCount: first.packages, + expectedVersion: Number(beforeIndividual.data?.version), + operationId: `${context.marker}-coload-individual`.slice(0, 128), + }) + conflict(context, '组成员不能通过单票入口分配', individualResponse) + const afterIndividual = await readPlan(context, planId, '回读单票拒绝后计划快照') + context.assert(MODULE, '单票分配拒绝后计划零漂移', afterIndividual.response, + planSignature(beforeIndividual.data) === planSignature(afterIndividual.data), + `before=${planSignature(beforeIndividual.data)}, after=${planSignature(afterIndividual.data)}`, OPTIONS) + + const assignResponse = await context.request('POST', + `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/group-assignments`, { + inboundId: first.inboundId, expectedVersion: Number(afterIndividual.data?.version), + operationId: `${context.marker}-coload-group-assign`.slice(0, 128), + }) + plan = context.apiSuccess(MODULE, '整组原子分配全部成员', assignResponse, + evidence(assignResponse, `groupId=${groupId}`), OPTIONS) + const assigned = (plan?.items || []).filter((item) => [first.inboundId, second.inboundId].includes(id(item?.inboundId))) + context.assert(MODULE, '整组分配同计划且每票均为满箱数', assignResponse, + assigned.length === 2 && assigned.every((item) => [first, second].some((fixture) => + fixture.inboundId === id(item?.inboundId) && fixture.packages === Number(item?.packageCount))), + `items=${assigned.map((item) => `${id(item?.inboundId)}:${Number(item?.packageCount)}`).join(',')}`, OPTIONS) + + const lockedQueryResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query', + { orderIds: groupOrders }) + const lockedQuery = context.apiSuccess(MODULE, '回读整组锁定事实', lockedQueryResponse, + evidence(lockedQueryResponse), OPTIONS) || [] + context.assert(MODULE, '整组只锁定到当前计划一次', lockedQueryResponse, + lockedQuery.length === 2 && lockedQuery.every((item) => item?.group?.status === 'LOCKED' + && id(item?.group?.lockedPlanId) === planId), + `status=${lockedQuery[0]?.group?.status || 'missing'}, lockedPlanId=${id(lockedQuery[0]?.group?.lockedPlanId)}`, OPTIONS) + + const targetItem = assigned[0] + const beforeMutation = planSignature(plan) + const adjustResponse = await context.request('PATCH', + `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/items/${encodeURIComponent(id(targetItem?.id))}`, { + packageCount: Math.max(1, Number(targetItem?.packageCount) - 1), expectedPlanVersion: Number(plan?.version), + expectedItemVersion: Number(targetItem?.version), operationId: `${context.marker}-coload-adjust`.slice(0, 128), + }) + conflict(context, '组成员不能单独调整箱数', adjustResponse) + const deleteResponse = await context.request('DELETE', + `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/items/${encodeURIComponent(id(targetItem?.id))}`, { + expectedPlanVersion: Number(plan?.version), expectedItemVersion: Number(targetItem?.version), + operationId: `${context.marker}-coload-delete`.slice(0, 128), + }) + conflict(context, '组成员不能单独删除', deleteResponse) + const afterMutation = await readPlan(context, planId, '回读组成员变更拒绝后计划') + context.assert(MODULE, '单成员调整删除拒绝后整组零漂移', afterMutation.response, + beforeMutation === planSignature(afterMutation.data), + `before=${beforeMutation}, after=${planSignature(afterMutation.data)}`, OPTIONS) + + const cancelLockedResponse = await context.request('POST', + `/api/platform/v1/first-leg/coload-groups/${encodeURIComponent(groupId)}/cancel`, { + expectedVersion: Number(lockedQuery[0]?.group?.version), reason: '锁定组取消反向验收', + }, undefined, { 'Idempotency-Key': `${context.marker}-coload-cancel-locked`.slice(0, 128) }) + conflict(context, '已锁定组不能取消', cancelLockedResponse) + + const removeResponse = await context.request('DELETE', + `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/group-assignments/${encodeURIComponent(groupId)}`, { + expectedPlanVersion: Number(afterMutation.data?.version), + operationId: `${context.marker}-coload-group-remove`.slice(0, 128), + }) + plan = context.apiSuccess(MODULE, '整组原子移除', removeResponse, + evidence(removeResponse), OPTIONS) + context.assert(MODULE, '整组移除后两个成员同时消失', removeResponse, + !(plan?.items || []).some((item) => [first.inboundId, second.inboundId].includes(id(item?.inboundId))), + `remainingItems=${plan?.items?.length ?? 'missing'}`, OPTIONS) + + const activeQueryResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query', + { orderIds: groupOrders }) + const activeQuery = context.apiSuccess(MODULE, '回读整组解锁事实', activeQueryResponse, + evidence(activeQueryResponse), OPTIONS) || [] + context.assert(MODULE, '整组移除后恢复ACTIVE且不残留计划锁', activeQueryResponse, + activeQuery.length === 2 && activeQuery.every((item) => item?.group?.status === 'ACTIVE' + && item?.group?.lockedPlanId == null), + `status=${activeQuery[0]?.group?.status || 'missing'}, lockedPlanId=${id(activeQuery[0]?.group?.lockedPlanId) || 'null'}`, OPTIONS) + + const recommendResponse = await context.request('POST', + `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/recommend`, { + expectedVersion: Number(plan?.version), operationId: `${context.marker}-coload-recommend`.slice(0, 128), + }) + plan = context.apiSuccess(MODULE, '智能推荐按超级项处理同柜组', recommendResponse, + evidence(recommendResponse), OPTIONS) + const recommendedIds = new Set((plan?.items || []).map((item) => id(item?.inboundId))) + const recommendedMembers = [first.inboundId, second.inboundId].filter((value) => recommendedIds.has(value)) + context.assert(MODULE, '智能推荐对整组全取或全不取', recommendResponse, + recommendedMembers.length === 0 || recommendedMembers.length === 2, + `recommendedMembers=${recommendedMembers.join(',') || 'none'}`, OPTIONS) + if (recommendedMembers.length === 0) { + const manualGroupResponse = await context.request('POST', + `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/group-assignments`, { + inboundId: first.inboundId, expectedVersion: Number(plan?.version), + operationId: `${context.marker}-coload-reassign`.slice(0, 128), + }) + plan = context.apiSuccess(MODULE, '推荐容量未纳入时整组手工回配', manualGroupResponse, + evidence(manualGroupResponse), OPTIONS) + } + if (!plan) return + + const pushKey = `${context.marker}-coload-push`.slice(0, 128) + const pushResponse = await context.request('POST', + `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/push-preload`, { + expectedVersion: Number(plan.version), + }, undefined, { 'Idempotency-Key': pushKey }) + const pushed = context.apiSuccess(MODULE, '整组完整时推送预装成功', pushResponse, + evidence(pushResponse), OPTIONS) + context.assert(MODULE, '推送后整组仍保持同计划满量事实', pushResponse, + [first, second].every((fixture) => (pushed?.items || []).some((item) => + id(item?.inboundId) === fixture.inboundId && Number(item?.packageCount) === fixture.packages)), + `planId=${planId}, itemCount=${pushed?.items?.length ?? 'missing'}`, OPTIONS) + + context.createdResources.coloadGroupAcceptance = { + marker: context.marker, groupId, groupNo: group?.groupNo || null, planId, bookingId, + orderIds: groupOrders, inboundIds: [first.inboundId, second.inboundId], + publicBypassResult: 'individual assignment/adjust/delete rejected before push', + } +}