const MODULE = 'firstleg-container-fee-allocation' const OPTIONS = { scenario: 'firstleg-container-fee-allocation-http', deploymentBlocker: true } import { run as createMerchant } from './merchant-qualification-fixture.mjs' import { channelGroup } from './smart-loading-channel-group.mjs' function id(value) { return value == null ? '' : String(value) } function rows(value) { return Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] } function code(response) { return String(response?.body?.code ?? response?.http ?? '') } function evidence(response, extra = '') { return [`HTTP=${response?.http ?? 'missing'}`, `code=${code(response) || 'missing'}`, `requestId=${response?.requestId || response?.body?.requestId || 'missing'}`, `traceId=${response?.traceId || response?.body?.traceId || 'missing'}`, extra].filter(Boolean).join(', ') } function operationKey(context, suffix) { return `${context.marker}-container-fee-${suffix}`.slice(0, 128) } function post(context, path, body, suffix) { return context.request('POST', path, body, context.token, { 'Idempotency-Key': operationKey(context, suffix) }) } function blocker(context, name, detail) { context.addStep(MODULE, name, null, false, detail, OPTIONS) } function expectBusinessFailure(context, name, response, expectedCodes = ['A0400', 'A0404', 'A0409', 'A0422']) { context.addStep(MODULE, name, response, response?.http >= 400 || (response?.http === 200 && expectedCodes.includes(code(response))), evidence(response, `message=${response?.body?.message || 'missing'}`), OPTIONS) } function money(value) { return Number(Number(value || 0).toFixed(2)) } export function candidateLoadingPlanIds(createdResources = {}) { const candidates = [ createdResources.phase3ContainerLoading?.loadingPlanId, createdResources.coloadGroupAcceptance?.fullPlanId, createdResources.coloadGroupAcceptance?.planId, createdResources.manualLoadingComplete?.loadingPlanId, createdResources.phase3ContainerDocuments?.loadingPlanId, ...rows(createdResources.financeFullFixtures?.feeSources).map((item) => item?.loadingPlanId), ].map(id).filter((value) => /^[1-9]\d*$/.test(value)) return [...new Set(candidates)] } export function ownershipAllocations(planItems = [], totalAmount = 120) { const unique = new Map() for (const item of rows(planItems)) { const customerId = id(item?.accountId ?? item?.customerId ?? item?.tenantId) const orderId = id(item?.orderId) const orderNo = String(item?.orderNo || '').trim() const inboundId = id(item?.inboundId) const inboundCode = String(item?.inboundCode || '').trim() if (!customerId || !orderId || !orderNo || !inboundId || !inboundCode) continue const key = `${customerId}|${orderId}|${inboundId}` if (!unique.has(key)) unique.set(key, { customerId, orderId, orderNo, inboundId, inboundCode }) } const byCustomer = new Map() for (const allocation of unique.values()) if (!byCustomer.has(allocation.customerId)) byCustomer.set(allocation.customerId, allocation) const selected = [...byCustomer.values()] if (selected.length < 2) return [] const totalCents = Math.round(Number(totalAmount) * 100) const base = Math.floor(totalCents / selected.length) return selected.map((allocation, index) => ({ ...allocation, amount: ((base + (index === selected.length - 1 ? totalCents - base * selected.length : 0)) / 100).toFixed(2) })) } export function withoutInbound(allocation) { const { inboundId: _inboundId, inboundCode: _inboundCode, ...required } = allocation return required } function groupRows(allRows, groupId) { return allRows.filter((item) => id(item?.allocationGroupId) === id(groupId)) } function signature(group) { return group.map((item) => [id(item?.id), id(item?.tenantId), item?.reviewStatus, item?.sourceStatus, Number(item?.sourceVersion), money(item?.amount)].join(':')).sort().join('|') } async function readOwnedRows(context, loadingPlanId, customerIds, label) { const result = [] for (const customerId of customerIds) { const response = await context.request('GET', `/api/platform/v1/loading-plans/${encodeURIComponent(loadingPlanId)}/fee-details?accountId=${encodeURIComponent(customerId)}`) const data = context.apiSuccess(MODULE, `${label}:客户 ${customerId} 回读费用`, response, evidence(response, `loadingPlanId=${loadingPlanId}, customerId=${customerId}`), OPTIONS) const owned = rows(data) context.assert(MODULE, `${label}:客户费用隔离`, response, owned.every((item) => id(item?.tenantId) === customerId), `customerId=${customerId}, returnedTenants=${[...new Set(owned.map((item) => id(item?.tenantId)))].join('|') || 'none'}`, OPTIONS) result.push(...owned) } return result } async function findCrossCustomerPlan(context) { for (const loadingPlanId of candidateLoadingPlanIds(context.createdResources)) { const response = await context.request('GET', `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}`) const plan = context.apiSuccess(MODULE, '回读本轮柜次费用分摊候选', response, evidence(response, `loadingPlanId=${loadingPlanId}`), OPTIONS) const allocations = ownershipAllocations(plan?.items, 120) if (allocations.length >= 2) return { loadingPlanId, plan, allocations } } return null } async function createCustomerWaitSplit(context, suffix, fixedRoute = null) { const runMarker = context.marker context.marker = `${runMarker}-${suffix}` if (fixedRoute) context.context.waitSplitRoute = fixedRoute await createMerchant(context) const orders = await import(new URL(`./orders.mjs?allocation=${encodeURIComponent(suffix)}-${Date.now()}`, import.meta.url)) const measurementModule = await import(new URL(`./inbound-measurement.mjs?allocation=${encodeURIComponent(suffix)}-${Date.now()}`, import.meta.url)) await orders.run(context) await measurementModule.run(context) const order = structuredClone(context.createdResources.orders || {}) const measurement = structuredClone(context.createdResources.inboundMeasurement || {}) context.assert(MODULE, `${suffix}客户产生真实WAIT_SPLIT入仓单`, null, Boolean(order.tenantId && order.orderId && order.inboundId && order.inboundCode && measurement.processStatus === 'WAIT_SPLIT'), `customerId=${order.tenantId || 'missing'}, inboundCode=${order.inboundCode || 'missing'}, status=${measurement.processStatus || 'missing'}`, OPTIONS) context.marker = runMarker return { ...order, processStatus: measurement.processStatus } } async function provisionCrossCustomerPlan(context) { const originalMarker = context.marker const originalUsername = process.env.MERCHANT_TEST_USERNAME const originalPassword = process.env.MERCHANT_TEST_PASSWORD const originalRoute = context.context.waitSplitRoute const originalMerchant = context.context.merchantQualificationFixture try { const first = await createCustomerWaitSplit(context, 'ALLOC-A') const route = { originWarehouseName: first.receivingWarehouseName, channelCode: first.channelCode, destinationType: first.destinationType, destinationWarehouseId: first.destinationWarehouseId, taxModeIndex: 0, } const second = await createCustomerWaitSplit(context, 'ALLOC-B', route) if (id(first.tenantId) === id(second.tenantId) || id(first.receivingWarehouseId) !== id(second.receivingWarehouseId) || first.channelCode !== second.channelCode) return null const containerResponse = await context.request('POST', '/api/platform/v1/containers', { channelGroupCode: channelGroup(first.channelCode), warehouseId: id(first.receivingWarehouseId), containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, }) const container = context.apiSuccess(MODULE, '创建跨客户费用分摊专用模拟柜', containerResponse, evidence(containerResponse), OPTIONS) const bookingId = id(container?.id) if (!bookingId) return null const planResponse = await context.request('GET', `/api/platform/v1/first-leg/smart-loading/plans/by-booking/${encodeURIComponent(bookingId)}`) let plan = context.apiSuccess(MODULE, '读取跨客户费用分摊专用计划', planResponse, evidence(planResponse), OPTIONS) const loadingPlanId = id(plan?.id) if (!loadingPlanId) return null for (const [index, item] of [first, second].entries()) { const assigned = await context.request('POST', `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}/inbound-assignments`, { inboundId: id(item.inboundId), packageCount: rows(item.skuLines).reduce((sum, line) => sum + Number(line.packageCount || 0), 0), expectedVersion: Number(plan?.version), operationId: operationKey(context, `fixture-assign-${index + 1}`), }) plan = context.apiSuccess(MODULE, `原子分配第${index + 1}个客户入仓单`, assigned, evidence(assigned, `inboundCode=${item.inboundCode}`), OPTIONS) if (!plan) return null } const allocations = ownershipAllocations(plan.items, 120) return allocations.length >= 2 ? { loadingPlanId, plan, allocations } : null } finally { context.marker = originalMarker context.context.waitSplitRoute = originalRoute context.context.merchantQualificationFixture = originalMerchant if (originalUsername === undefined) delete process.env.MERCHANT_TEST_USERNAME else process.env.MERCHANT_TEST_USERNAME = originalUsername if (originalPassword === undefined) delete process.env.MERCHANT_TEST_PASSWORD else process.env.MERCHANT_TEST_PASSWORD = originalPassword } } export async function run(context) { const start = context.steps.length const fixture = await findCrossCustomerPlan(context) || await provisionCrossCustomerPlan(context) if (!fixture) { blocker(context, '取得本轮跨客户真实柜次', `marker=${context.marker}, candidateLoadingPlanIds=${candidateLoadingPlanIds(context.createdResources).join('|') || 'none'};必须由本轮前置创建至少包含两个客户、真实订单和入仓码的柜次,禁止借用历史数据`) context.createdResources.firstlegContainerFeeAllocation = { marker: context.marker, ready: false } context.scenarios.push({ name: OPTIONS.scenario, module: MODULE, success: false, deploymentBlocked: true, passed: context.steps.slice(start).filter((step) => step.passed).length, failed: context.steps.slice(start).filter((step) => !step.passed).length }) return } const { loadingPlanId, allocations } = fixture const customerIds = allocations.map((item) => item.customerId) const feeItemsResponse = await context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200') const feeItems = context.apiSuccess(MODULE, '查询柜次应收费用项目', feeItemsResponse, evidence(feeItemsResponse), OPTIONS) const feeItem = rows(feeItems).find((item) => item?.status === 'ACTIVE' && (item?.warehouseReceivable === true || item?.pickupReceivable === true)) if (!feeItem?.id) { blocker(context, '取得可用应收费用项目', '没有 ACTIVE 且启用仓储/提货应收的费用项目,禁止猜测 feeItemId') return } const base = `/api/platform/v1/loading-plans/${encodeURIComponent(loadingPlanId)}/fee-details` const groupPath = `${base}/allocation-groups` const fee = { feeItemId: id(feeItem.id), direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10), amount: 120, currency: 'CNY', remark: `${context.marker}-cross-customer-allocation` } const initial = await readOwnedRows(context, loadingPlanId, customerIds, '创建前') const invalidTotal = await post(context, groupPath, { fee, allocations: allocations.map((item, index) => ({ ...withoutInbound(item), amount: index === 0 ? '1.00' : item.amount })) }, 'invalid-total') expectBusinessFailure(context, '分摊金额合计不一致被拒绝', invalidTotal, ['A0409']) const missingCustomer = await post(context, groupPath, { fee, allocations: allocations.map((item, index) => index === 0 ? { ...withoutInbound(item), customerId: null } : withoutInbound(item)) }, 'missing-customer') expectBusinessFailure(context, '分摊客户必填', missingCustomer, ['A0422']) const missingOrder = await post(context, groupPath, { fee, allocations: allocations.map((item, index) => index === 0 ? { ...withoutInbound(item), orderId: null } : withoutInbound(item)) }, 'missing-order') expectBusinessFailure(context, '分摊订单必填', missingOrder, ['A0422']) const forgedOrder = allocations.map(withoutInbound) forgedOrder[0] = { ...forgedOrder[0], orderId: '999999999999999999' } const forgedOrderResponse = await post(context, groupPath, { fee, allocations: forgedOrder }, 'forged-order') expectBusinessFailure(context, '伪造非柜内订单归属被拒绝', forgedOrderResponse, ['A0409']) const forgedInbound = allocations.map((item) => ({ ...item })) forgedInbound[0] = { ...forgedInbound[0], inboundCode: `${context.marker}-FORGED-INBOUND`.slice(0, 64) } const forgedInboundResponse = await post(context, groupPath, { fee, allocations: forgedInbound }, 'forged-inbound') expectBusinessFailure(context, '伪造非柜内入仓码被拒绝', forgedInboundResponse, ['A0409']) const afterNegatives = await readOwnedRows(context, loadingPlanId, customerIds, '负向请求后') context.assert(MODULE, '负向请求未产生部分分摊行', null, signature(initial) === signature(afterNegatives), `before=${signature(initial) || 'empty'}, after=${signature(afterNegatives) || 'empty'}`, OPTIONS) const createResponse = await post(context, groupPath, { fee, allocations: allocations.map(withoutInbound) }, 'create') const created = context.apiSuccess(MODULE, '不填写入仓码原子创建跨客户分摊组', createResponse, evidence(createResponse, `loadingPlanId=${loadingPlanId}`), OPTIONS) const createdRows = rows(created) const groupId = id(createdRows[0]?.allocationGroupId) context.assert(MODULE, '分摊组客户订单完整且未伪造入仓码', createResponse, Boolean(groupId) && createdRows.length === allocations.length && new Set(createdRows.map((item) => id(item?.tenantId))).size >= 2 && createdRows.every((item) => item?.allocationInboundId == null && !item?.allocationInboundCode) && money(createdRows.reduce((sum, item) => sum + Number(item?.amount || 0), 0)) === 120, `groupId=${groupId || 'missing'}, rows=${createdRows.length}, customers=${new Set(createdRows.map((item) => id(item?.tenantId))).size}, total=${createdRows.reduce((sum, item) => sum + Number(item?.amount || 0), 0)}`, OPTIONS) if (!groupId || createdRows.length < 2) return const first = createdRows[0] const singleApprove = await post(context, `${base}/${encodeURIComponent(id(first.id))}/approve?accountId=${encodeURIComponent(id(first.tenantId))}`, null, 'single-approve') expectBusinessFailure(context, '单行审核不能破坏分摊组一致性', singleApprove, ['A0409']) const beforeApprove = await readOwnedRows(context, loadingPlanId, customerIds, '单行审核拒绝后') context.assert(MODULE, '单行审核拒绝后整组仍为待审核', singleApprove, groupRows(beforeApprove, groupId).length === createdRows.length && groupRows(beforeApprove, groupId).every((item) => item?.reviewStatus === 'PENDING'), `group=${signature(groupRows(beforeApprove, groupId))}`, OPTIONS) const approveResponse = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/approve`, null, 'approve') const approved = rows(context.apiSuccess(MODULE, '整组审核原子成功', approveResponse, evidence(approveResponse, `groupId=${groupId}`), OPTIONS)) context.assert(MODULE, '整组审核后全部行已审核且版本一致', approveResponse, approved.length === createdRows.length && approved.every((item) => item?.reviewStatus === 'APPROVED' && item?.sourceStatus === 'ACTIVE' && Number(item?.sourceVersion) === 1), `group=${signature(approved)}`, OPTIONS) const partialAdjustBody = { expectedSourceVersion: 1, businessDate: fee.businessDate, totalAmount: 120, allocations: [{ feeId: id(approved[0]?.id), amount: 120 }] } const partialAdjust = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/adjust`, partialAdjustBody, 'partial-adjust') expectBusinessFailure(context, '缺少组成员的调整被拒绝', partialAdjust, ['A0409']) const afterPartialAdjust = groupRows(await readOwnedRows(context, loadingPlanId, customerIds, '部分调整拒绝后'), groupId) context.assert(MODULE, '部分调整失败后整组零漂移', partialAdjust, signature(afterPartialAdjust) === signature(approved), `approved=${signature(approved)}, after=${signature(afterPartialAdjust)}`, OPTIONS) const adjustedAmounts = approved.map((item, index) => ({ feeId: id(item.id), amount: index === 0 ? '70.00' : index === 1 ? '50.00' : '0.01' })) if (adjustedAmounts.length > 2) { const overflow = (adjustedAmounts.length - 2) * 0.01 adjustedAmounts[1].amount = (50 - overflow).toFixed(2) } const adjustResponse = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/adjust`, { expectedSourceVersion: 1, businessDate: fee.businessDate, totalAmount: 120, allocations: adjustedAmounts }, 'adjust') const adjusted = rows(context.apiSuccess(MODULE, '整组调整原子成功', adjustResponse, evidence(adjustResponse, `groupId=${groupId}`), OPTIONS)) context.assert(MODULE, '整组调整后全部行统一升版并待复核', adjustResponse, adjusted.length === approved.length && adjusted.every((item) => Number(item?.sourceVersion) === 2 && item?.reviewStatus === 'PENDING' && item?.sourceStatus === 'ACTIVE') && money(adjusted.reduce((sum, item) => sum + Number(item?.amount || 0), 0)) === 120, `group=${signature(adjusted)}`, OPTIONS) const reapproveResponse = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/approve`, null, 'reapprove') const reapproved = rows(context.apiSuccess(MODULE, '调整后整组复核原子成功', reapproveResponse, evidence(reapproveResponse, `groupId=${groupId}`), OPTIONS)) const voidResponse = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/void`, { expectedSourceVersion: 2 }, 'void') context.apiSuccess(MODULE, '整组作废原子成功', voidResponse, evidence(voidResponse, `groupId=${groupId}`), OPTIONS) const voided = groupRows(await readOwnedRows(context, loadingPlanId, customerIds, '整组作废后'), groupId) context.assert(MODULE, '整组作废后全部行冻结且无活动残留', voidResponse, reapproved.length === createdRows.length && voided.length === createdRows.length && voided.every((item) => item?.sourceStatus === 'VOIDED' && Number(item?.sourceVersion) === 2), `group=${signature(voided)}`, OPTIONS) const frozenAdjust = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/adjust`, { expectedSourceVersion: 2, businessDate: fee.businessDate, totalAmount: 120, allocations: adjustedAmounts }, 'frozen-adjust') expectBusinessFailure(context, '已作废冻结分摊组拒绝继续调整', frozenAdjust, ['A0409']) const afterFrozen = groupRows(await readOwnedRows(context, loadingPlanId, customerIds, '冻结调整拒绝后'), groupId) context.assert(MODULE, '冻结门禁拒绝后整组零漂移', frozenAdjust, signature(afterFrozen) === signature(voided), `voided=${signature(voided)}, after=${signature(afterFrozen)}`, OPTIONS) const foreignResponse = await context.request('GET', `${base}?accountId=999999999999999999`) expectBusinessFailure(context, '非柜内客户不能读取柜次费用', foreignResponse, ['A0404']) context.createdResources.firstlegContainerFeeAllocation = { marker: context.marker, ready: true, loadingPlanId, groupId, customerIds, feeIds: createdRows.map((item) => id(item.id)), finalStatus: 'VOIDED', } const steps = context.steps.slice(start) context.scenarios.push({ name: OPTIONS.scenario, module: MODULE, success: steps.every((step) => step.passed), deploymentBlocked: steps.some((step) => step.deploymentBlocker), passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length }) }