Files
baiyue-firstleg/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs
T

328 lines
20 KiB
JavaScript
Raw Normal View History

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: '40HC', capacityCbm: 76, maxWeightKg: 28000,
})
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 })
}