Files
baiyue-firstleg/scripts/admin-test-env-modules/finance-full-fixtures.mjs
T
2026-09-16 11:06:33 +08:00

628 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const MODULE = 'finance-full-fixtures'
const OPTIONS = { scenario: 'finance-full-fixture-production', deploymentBlocker: true }
function id(value) { return value == null ? '' : String(value) }
function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) }
function request(context, method, path, body, token = context.token, extra = {}) {
return context.request(method, path, body, token, { 'Idempotency-Key': key(context, path.replaceAll('/', '_')), ...extra })
}
function rows(value) { return Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] }
function dateOffset(days) { const value = new Date(); value.setUTCDate(value.getUTCDate() + days); return value.toISOString().slice(0, 10) }
function blocker(context, name, evidence) { context.addStep(MODULE, name, null, false, evidence, OPTIONS) }
export function normalizeFinanceFixtureSources(sources, orders, allocations, customerId, marker) {
const orderById = new Map(rows(orders).map((order) => [id(order?.orderId ?? order?.id), order]))
const allocationOrders = rows(allocations).map((allocation) => orderById.get(id(allocation?.orderId))).filter(Boolean)
return rows(sources).map((source) => {
const sourceOrder = orderById.get(id(source?.orderId))
|| (source?.billingBasis === 'CONTAINER_SHARED' ? allocationOrders[0] : null)
return {
...source,
customerId: id(source?.customerId ?? customerId) || null,
marker: source?.marker ?? marker,
orderId: id(source?.orderId ?? sourceOrder?.orderId ?? sourceOrder?.id) || null,
waybillId: id(source?.waybillId ?? sourceOrder?.waybillId) || null,
shipmentId: id(source?.shipmentId ?? sourceOrder?.shipmentId) || null,
orderNo: source?.orderNo ?? sourceOrder?.orderNo ?? null,
}
})
}
export function supplierCategoryForFeeItem(item) {
return ['BOOKING', 'CUSTOMS', 'TRUCKING', 'CERTIFICATE'].includes(item?.supplierCategory)
? item.supplierCategory : null
}
export function buildSupplierBody(marker, category) {
const suffix = String(marker || '').replace(/[^0-9A-Za-z]/g, '').slice(-24)
return {
name: `财务验收供应商-${suffix}`,
category,
truckingScope: category === 'TRUCKING' ? 'DOMESTIC' : null,
...(category === 'BOOKING' ? { carrierCodes: ['MATSON'] } : {}),
region: '宁波', legalRepresentative: '财务验收法人', contactName: '财务验收联系人',
phone: '13800000000', email: `finance-supplier-${suffix || 'fixture'}@example.com`,
address: '财务验收地址', businessLicenseNo: `FIN${suffix}`.slice(0, 64),
licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: marker,
}
}
async function createOrder(context, suffix, pricingProfile = null) {
if (context.financeFixtureHooks?.createOrder) return context.financeFixtureHooks.createOrder(context, suffix, pricingProfile)
const originalMarker = context.marker
const original = context.createdResources.orders
const originalFetch = globalThis.fetch
context.marker = `${originalMarker}-${suffix}`
if (pricingProfile && typeof originalFetch === 'function') {
// The orders module discovers candidates independently. Reorder only those
// real responses so its payload uses one exact active price-book row.
globalThis.fetch = async (input, init = {}) => {
const url = String(input)
let effectiveInit = init
if ((init.method || 'GET') === 'POST'
&& url.includes('/api/merchant/v1/first-leg/pricing/estimate')
&& typeof init.body === 'string') {
const body = JSON.parse(init.body)
effectiveInit = {
...init,
body: JSON.stringify({ ...body, chargeMode: pricingProfile.chargeUnit })
}
}
const response = await originalFetch(input, effectiveInit)
if ((init.method || 'GET') === 'GET' && (url.includes('/api/merchant/v1/warehouses?')
|| url.includes('/api/merchant/v1/first-leg/pricing/channels?'))) {
const body = await response.clone().json().catch(() => null)
const data = body?.code === '200' ? body.data : null
if (url.includes('warehouseType=FBA')) {
const records = Array.isArray(data?.records) ? data.records : []
const match = records.find((item) => id(item.warehouseCode || item.code) === id(pricingProfile.destinationCode))
if (match) body.data = { ...data, records: [match, ...records.filter((item) => item !== match)] }
} else if (url.includes('warehouseType=DOMESTIC')) {
const records = Array.isArray(data?.records) ? data.records : []
const match = records.find((item) => id(item.id) === id(pricingProfile.originWarehouseId))
if (match) body.data = { ...data, records: [match, ...records.filter((item) => item !== match)] }
} else {
const channels = Array.isArray(data) ? data : []
const match = channels.find((item) => id(item.channelCode) === id(pricingProfile.channelCode))
if (match) body.data = [{ ...match, pricingTaxModes: [pricingProfile.pricingTaxMode,
...(match.pricingTaxModes || []).filter((mode) => mode !== pricingProfile.pricingTaxMode)] },
...channels.filter((item) => item !== match)]
}
return new Response(JSON.stringify(body), { status: response.status, statusText: response.statusText, headers: response.headers })
}
return response
}
}
try {
const orders = await import(new URL('./orders.mjs', import.meta.url))
await orders.run(context)
const created = context.createdResources.orders
return created?.marker === context.marker && created?.orderId && created?.inboundId
? { ...created, pricingProfile } : null
} finally {
globalThis.fetch = originalFetch
context.marker = originalMarker
if (original) context.createdResources.orders = original
else delete context.createdResources.orders
}
}
async function measureOrder(context, resource) {
if (context.financeFixtureHooks?.measureOrder) return context.financeFixtureHooks.measureOrder(context, resource)
const originalMarker = context.marker
const createdKeys = ['orders', 'inboundMeasurement', 'ocrInbound', 'testInbound']
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound', 'allowAnyExistingPallet', 'waitSplitVariant']
const createdSnapshot = new Map(createdKeys.map((key) => [key, { present: Object.hasOwn(context.createdResources, key), value: context.createdResources[key] }]))
const contextSnapshot = new Map(contextKeys.map((key) => [key, { present: Object.hasOwn(context.context, key), value: context.context[key] }]))
context.marker = resource.marker
context.createdResources.orders = resource
context.createdResources.ocrInbound = resource
context.createdResources.testInbound = resource
context.context.inboundWeightOcr = resource
context.context.ocrInbound = resource
context.context.testInbound = resource
context.context.createdInbound = resource
// Finance pricing profiles can point at any configured domestic warehouse.
// Reuse that warehouse's real pallet master instead of requiring the bulk-data
// generator's two fixed Yiwu pallet codes.
context.context.allowAnyExistingPallet = true
context.context.waitSplitVariant = {
...(context.context.waitSplitVariant || {}),
// The production pricing service chooses the dominant real billing basis.
// Keep the CBM sample's measured gross weight below its measured volume
// weight so the fixture proves the actual VOLUME_CBM path.
grossWeightPerPalletKg: resource.pricingProfile?.chargeUnit === 'CBM' ? 20 : 100,
}
try {
const measurement = await import(new URL('./inbound-measurement.mjs', import.meta.url))
await measurement.run(context)
const result = context.createdResources.inboundMeasurement
const detailResponse = await context.request('GET', `/api/platform/inbounds/${encodeURIComponent(resource.inboundId)}?tenantId=${encodeURIComponent(resource.tenantId)}`)
const detail = context.apiSuccess(MODULE, '回读审核测量实重与总体积', detailResponse, `inboundId=${resource.inboundId}`, OPTIONS)
const summary = detail?.summary || {}
const actualWeightKg = Number(summary.actualGrossWeightKg ?? summary.actualWeightKg)
const totalVolumeCbm = Number(summary.actualVolumeCbm ?? summary.totalVolumeCbm)
context.assert(MODULE, '审核测量快照实重和总体积均大于零', detailResponse,
Number.isFinite(actualWeightKg) && actualWeightKg > 0 && Number.isFinite(totalVolumeCbm) && totalVolumeCbm > 0,
`actualWeightKg=${actualWeightKg || 'missing'}, totalVolumeCbm=${totalVolumeCbm || 'missing'}`, OPTIONS)
return result?.marker === resource.marker ? { ...resource, ...result, actualWeightKg, totalVolumeCbm } : null
} finally {
context.marker = originalMarker
for (const [key, snapshot] of createdSnapshot) {
if (snapshot.present) context.createdResources[key] = snapshot.value
else delete context.createdResources[key]
}
for (const [key, snapshot] of contextSnapshot) {
if (snapshot.present) context.context[key] = snapshot.value
else delete context.context[key]
}
}
}
async function loadFeeItem(context, direction) {
if (context.financeFixtureHooks?.loadFeeItem) return context.financeFixtureHooks.loadFeeItem(context, direction)
const response = await context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200')
const list = context.apiSuccess(MODULE, `查询${direction}人工费用项目`, response, '', OPTIONS)
return rows(list).find((item) => item?.status === 'ACTIVE' && (direction === 'RECEIVABLE'
? item.warehouseReceivable === true
: item.warehousePayable === true)) || null
}
async function createManualFee(context, resource, feeItem, direction, amount, basis, businessDate, supplier = null) {
if (context.financeFixtureHooks?.createManualFee) return context.financeFixtureHooks.createManualFee(context, resource, feeItem, direction, amount, basis, businessDate)
const basePath = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fee-details`
const listPath = `${basePath}?accountId=${encodeURIComponent(resource.tenantId)}`
const body = { feeItemId: String(feeItem.id), direction, businessDate,
unitPrice: amount, currency: 'CNY', amount,
...(direction === 'PAYABLE' ? { settlementMode: 'ON_ACCOUNT' } : {}),
supplierId: supplier?.id ? String(supplier.id) : null,
remark: `${context.marker}-${basis}` }
const response = await request(context, 'POST', listPath, body)
const detail = context.apiSuccess(MODULE, `${basis}人工费用明细`, response, `inboundId=${resource.inboundId}`, OPTIONS)
const feeId = id(detail?.id)
context.assert(MODULE, `${basis}费用来源身份和金额`, response,
Boolean(feeId) && id(detail?.inboundId) === id(resource.inboundId) && Number(detail?.amount) === Number(amount),
`feeId=${feeId || 'missing'}, amount=${detail?.amount ?? 'missing'}`, OPTIONS)
if (!feeId) return null
const approve = await request(context, 'POST', `${basePath}/${feeId}/approve?accountId=${encodeURIComponent(resource.tenantId)}`)
context.apiSuccess(MODULE, `${basis}费用审核`, approve, '', OPTIONS)
return { feeSourceId: feeId, sourceType: detail?.sourceType || 'MANUAL', direction, amount: Number(amount), businessDate,
currency: 'CNY', billingBasis: basis, inboundId: id(resource.inboundId), orderId: id(resource.orderId),
chargeableWeightKg: detail?.chargeableWeightKg ?? null, chargeableVolumeCbm: detail?.chargeableVolumeCbm ?? null }
}
async function loadPublishedAutomaticFee(context, resource) {
if (context.financeFixtureHooks?.loadPublishedAutomaticFee) return context.financeFixtureHooks.loadPublishedAutomaticFee(context, resource)
const pricingInboundResponse = await context.request('GET',
`/api/platform/inbounds/${encodeURIComponent(resource.inboundId)}?tenantId=${encodeURIComponent(resource.tenantId)}`)
const pricingInbound = context.apiSuccess(MODULE, '回读审核完成后的入库主表状态', pricingInboundResponse,
`inboundId=${resource.inboundId}`, OPTIONS)
const pricingSummary = pricingInbound?.summary || {}
const actualWeightKg = Number(pricingSummary.actualGrossWeightKg ?? pricingSummary.actualWeightKg)
const actualVolumeCbm = Number(pricingSummary.actualVolumeCbm ?? pricingSummary.totalVolumeCbm)
if (!(actualWeightKg > 0) || !(actualVolumeCbm > 0)) {
blocker(context, '自动计费前审核测量快照完整',
`服务端计费实际读取 fl_inbound_order.actual_gross_weight_kg/actual_volume_cbm;inboundId=${resource.inboundId}, actualWeightKg=${actualWeightKg || 'missing'}, actualVolumeCbm=${actualVolumeCbm || 'missing'};测量表有值但主表未同步`)
return null
}
const pricingBase = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fees`
const tenantQuery = `tenantId=${encodeURIComponent(resource.tenantId)}`
const latestResponse = await context.request('GET', `${pricingBase}/latest?${tenantQuery}`)
let latest = context.apiSuccess(MODULE, '回读测量审核生成的自动费用试算', latestResponse,
`inboundId=${resource.inboundId}`, OPTIONS)
if (!latest?.feeId || !['CALCULATED', 'CONFIRMED', 'SENT'].includes(latest?.feeStatus)) {
blocker(context, '测量审核已生成可确认的自动费用试算',
`inboundId=${resource.inboundId}, feeId=${id(latest?.feeId) || 'missing'}, feeStatus=${latest?.feeStatus || 'missing'}, requestId=${latestResponse?.requestId || 'missing'}, traceId=${latestResponse?.traceId || 'missing'};审核只负责初算,Finance fixture 不重复试算`)
return null
}
if (latest.feeStatus === 'CALCULATED') {
const confirmResponse = await request(context, 'POST', `${pricingBase}/confirm?${tenantQuery}`)
latest = context.apiSuccess(MODULE, '确认测量审核生成的自动费用试算', confirmResponse,
`inboundId=${resource.inboundId}, feeId=${id(latest.feeId)}`, OPTIONS)
}
if (latest?.feeStatus === 'CONFIRMED') {
const sendResponse = await request(context, 'POST', `${pricingBase}/send?${tenantQuery}`)
latest = context.apiSuccess(MODULE, '发送已确认自动费用并发布来源', sendResponse,
`inboundId=${resource.inboundId}, feeId=${id(latest.feeId)}`, OPTIONS)
}
if (latest?.feeStatus !== 'SENT') {
blocker(context, '自动费用确认发送状态闭环',
`inboundId=${resource.inboundId}, feeId=${id(latest?.feeId) || 'missing'}, feeStatus=${latest?.feeStatus || 'missing'}`)
return null
}
const listPath = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fee-details?accountId=${encodeURIComponent(resource.tenantId)}`
const listResponse = await context.request('GET', listPath)
const details = context.apiSuccess(MODULE, '回读审核链已发布自动费用来源', listResponse,
`inboundId=${resource.inboundId}`, OPTIONS)
const published = rows(details).find((item) => item?.sourceType === 'FBA_PRICING'
&& item?.reviewStatus === 'APPROVED' && item?.publicationStatus === 'PUBLISHED')
if (!published) {
blocker(context, '审核链已生成可结算自动费用来源',
`inboundId=${resource.inboundId}, processStatus=${pricingSummary.processStatus || pricingInbound?.processStatus || 'missing'}, feeId=${id(latest.feeId)}, feeStatus=${latest.feeStatus}, feeDetails=${rows(details).length};确认和发送成功后应同步生成 APPROVED + PUBLISHED 的 FBA_PRICING 来源`)
return null
}
const feeSourceId = id(published.sourceFeeId)
const billingBasis = published.chargeUnit === 'KG' ? 'WEIGHT_KG'
: published.chargeUnit === 'CBM' ? 'VOLUME_CBM' : null
const responseWeightKg = Number(published.actualGrossWeightKg ?? actualWeightKg)
const responseVolumeCbm = Number(published.actualVolumeCbm ?? actualVolumeCbm)
context.assert(MODULE, '已发布自动费用来源、金额和计费依据完整', listResponse,
Boolean(feeSourceId) && Boolean(billingBasis) && Number(published.amount) >= 0
&& responseWeightKg > 0 && responseVolumeCbm > 0,
`feeSourceId=${feeSourceId || 'missing'}, detailId=${id(published.id) || 'missing'}, chargeUnit=${published.chargeUnit || 'missing'}, amount=${published.amount ?? 'missing'}, actualWeightKg=${responseWeightKg || 'missing'}, actualVolumeCbm=${responseVolumeCbm || 'missing'}`, OPTIONS)
if (!feeSourceId || !billingBasis) return null
return { feeSourceId, orderId: id(resource.orderId), inboundId: id(resource.inboundId), sourceType: 'FBA_PRICING',
direction: 'RECEIVABLE', currency: published.currency,
amount: Number(published.amount), businessDate: String(published.businessDate || published.publishedAt || '').slice(0, 10),
billingBasis, chargeableQuantity: published.chargeableQuantity,
chargeUnit: published.chargeUnit,
actualWeightKg: responseWeightKg, actualVolumeCbm: responseVolumeCbm }
}
async function loadSupplier(context, category) {
if (context.financeFixtureHooks?.loadSupplier) return context.financeFixtureHooks.loadSupplier(context, category)
const effectiveCategory = category || 'BOOKING'
const body = buildSupplierBody(context.marker, effectiveCategory)
const response = await request(context, 'POST', '/api/platform/v1/suppliers?tenantId=0', body)
const supplierId = id(context.apiSuccess(MODULE, '创建本轮 Finance 隔离供应商', response,
`category=${effectiveCategory}`, OPTIONS))
if (!supplierId) return null
const detailResponse = await context.request('GET', `/api/platform/v1/suppliers/${encodeURIComponent(supplierId)}?tenantId=0`)
const detail = context.apiSuccess(MODULE, '回读本轮 Finance 隔离供应商', detailResponse,
`supplierId=${supplierId}`, OPTIONS)
context.assert(MODULE, 'Finance 供应商为 ACTIVE 且类别匹配费用项目', detailResponse,
id(detail?.id) === supplierId && detail?.status === 'ACTIVE' && (!category || detail?.category === category)
&& detail?.name === body.name,
`supplierId=${supplierId}, status=${detail?.status || 'missing'}, category=${detail?.category || 'missing'}`, OPTIONS)
return detail?.status === 'ACTIVE' && (!category || detail?.category === category) ? detail : null
}
async function uploadVoucher(context, customerId) {
if (context.financeFixtureHooks?.uploadVoucher) return context.financeFixtureHooks.uploadVoucher(context, customerId)
const path = `/api/platform/finance/v1/vouchers?customerId=${encodeURIComponent(customerId)}`
const form = new FormData()
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')
form.append('file', new Blob([png], { type: 'image/png' }), `finance-${context.marker}.png`)
try {
const response = await fetch(`${context.options.gateway}${path}`, { method: 'POST',
headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, body: form,
signal: AbortSignal.timeout(30_000) })
const body = await response.json().catch(() => null)
const evidence = { method: 'POST', path, http: response.status, body, raw: null,
requestId: body?.requestId || response.headers.get('x-request-id'), traceId: body?.traceId || response.headers.get('x-trace-id') }
const data = context.apiSuccess(MODULE, '上传本轮 Finance 凭证', evidence, '', OPTIONS)
return id(data?.fileId) || null
} catch (error) {
blocker(context, '上传本轮 Finance 凭证', `multipart request failed: ${String(error)}`)
return null
}
}
async function loadPricingEvidence(context) {
if (context.financeFixtureHooks?.loadPricingEvidence) return context.financeFixtureHooks.loadPricingEvidence(context)
const fbaInternalCodes = new Set()
let warehousePage = 1
let warehouseTotal = 0
do {
const warehouseResponse = await context.request('GET',
`/api/platform/v1/warehouse-admin?tenantId=0&page=${warehousePage}&size=200&warehouseType=FBA&status=ACTIVE`)
const warehouseResult = context.apiSuccess(MODULE, `读取具备内部编号的 FBA 仓第 ${warehousePage} 页`,
warehouseResponse, '', OPTIONS)
const warehouses = rows(warehouseResult)
warehouseTotal = Number(warehouseResult?.total || warehouses.length)
warehouses.filter((item) => item?.internalCode && item?.warehouseCode)
.forEach((item) => fbaInternalCodes.add(String(item.warehouseCode).trim().toUpperCase()))
if (warehouses.length === 0) break
warehousePage += 1
} while ((warehousePage - 1) * 200 < warehouseTotal)
context.assert(MODULE, '自动计费候选使用已配置内部编号的 FBA 仓', null,
fbaInternalCodes.size > 0, `warehouses=${fbaInternalCodes.size}`, OPTIONS)
const response = await context.request('GET', '/api/platform/v1/fba-pricing/carrier-rates/price-books?status=ACTIVE')
const versions = context.apiSuccess(MODULE, '读取真实生效价格配置', response, '', OPTIONS)
const active = rows(versions).filter((item) => item?.status === 'ACTIVE')
const profiles = []
for (const version of active) {
const detailResponse = await context.request('GET', `/api/platform/v1/fba-pricing/carrier-rates/price-books/${encodeURIComponent(version.id)}`)
const detail = context.apiSuccess(MODULE, '读取 ACTIVE 价格簿真实费率行', detailResponse, `versionId=${version.id}`, OPTIONS)
const rates = Array.isArray(detail?.rates) ? detail.rates : []
for (const rate of rates) {
if (rate?.destinationType !== 'FBA' || !rate?.originWarehouseId || !rate?.channelCode || !rate?.pricingTaxMode || !rate?.destinationCode) continue
if (!fbaInternalCodes.has(String(rate.destinationCode).trim().toUpperCase())) continue
if (!['KG', 'CBM'].includes(rate.chargeUnit) || profiles.some((item) => item.chargeUnit === rate.chargeUnit)) continue
const matches = rates.filter((item) => id(item.originWarehouseId) === id(rate.originWarehouseId)
&& item.channelCode === rate.channelCode && item.destinationType === rate.destinationType
&& String(item.destinationCode).toUpperCase() === String(rate.destinationCode).toUpperCase()
&& item.pricingTaxMode === rate.pricingTaxMode)
if (matches.length !== 1) continue
profiles.push({ versionId: id(version.id), rateId: id(rate.id), chargeUnit: rate.chargeUnit,
originWarehouseId: id(rate.originWarehouseId), channelCode: rate.channelCode,
pricingTaxMode: rate.pricingTaxMode, destinationCode: rate.destinationCode,
businessType: rate.businessType || 'FBA' })
}
}
context.assert(MODULE, 'ACTIVE 价格簿包含 KG 与 CBM 可用真实组合', response,
profiles.some((item) => item.chargeUnit === 'KG') && profiles.some((item) => item.chargeUnit === 'CBM'),
`units=${profiles.map((item) => item.chargeUnit).join(',') || 'none'}`, OPTIONS)
return { activeVersionIds: active.map((item) => id(item.id)).filter(Boolean), profiles }
}
async function ensureCustomerChargeRule(context, resource, profile) {
if (context.financeFixtureHooks?.ensureCustomerChargeRule) {
return context.financeFixtureHooks.ensureCustomerChargeRule(context, resource, profile)
}
if (!resource?.tenantId || profile?.pricingTaxMode !== 'TAX_EXCLUDED') return null
const tenantId = id(resource.tenantId)
const inboundResponse = await context.request('GET',
`/api/platform/inbounds/${encodeURIComponent(resource.inboundId)}?tenantId=${encodeURIComponent(tenantId)}`)
const inbound = context.apiSuccess(MODULE, '读取不包税入库计费业务类型', inboundResponse,
`inboundId=${resource.inboundId}`, OPTIONS)
const businessType = inbound?.summary?.businessType || profile.businessType
if (!businessType) {
blocker(context, '取得不包税入库计费业务类型',
`inboundId=${resource.inboundId} 未返回 businessType,不能创建猜测的客户计费规则`)
return null
}
const listPath = `/api/platform/v1/fba-pricing/customer-rules?tenantId=${encodeURIComponent(tenantId)}&customerId=${encodeURIComponent(tenantId)}`
const listResponse = await context.request('GET', listPath)
const listed = context.apiSuccess(MODULE, '查询不包税客户计费规则', listResponse,
`customerId=${tenantId}, businessType=${businessType}`, OPTIONS)
const applicable = rows(listed).filter((rule) => rule?.status === 'ACTIVE'
&& rule?.businessType === businessType && rule?.taxMode === 'TAX_EXCLUDED')
let desired = applicable.find((rule) => rule?.chargeMode === profile.chargeUnit)
if (!desired) {
const effectiveFrom = new Date(Date.now() - 86_400_000).toISOString().slice(0, 19)
const body = {
ruleCode: `FIN_P1_${profile.chargeUnit}_${tenantId}`.slice(0, 50), customerId: tenantId,
customerName: String(inbound?.summary?.customerName || `财务P1验收客户-${tenantId}`).slice(0, 128), businessType,
chargeMode: profile.chargeUnit, taxMode: 'TAX_EXCLUDED', conversionFactor: 1,
roundingScale: 3, roundingMode: 'HALF_UP', currency: 'CNY', effectiveFrom,
effectiveTo: null, remark: `${context.marker}-VOLUME_CBM`
}
const createResponse = await request(context, 'POST',
`/api/platform/v1/fba-pricing/customer-rules/versions?tenantId=${encodeURIComponent(tenantId)}`, body)
desired = context.apiSuccess(MODULE, '创建不包税客户计费规则', createResponse,
`customerId=${tenantId}, chargeMode=${profile.chargeUnit}`, OPTIONS)
}
const needsDefault = applicable.length > 1
|| (applicable.length === 1 && id(applicable[0]?.id) !== id(desired?.id))
if (desired?.id && needsDefault && desired.defaultRule !== true) {
const defaultResponse = await request(context, 'PUT',
`/api/platform/v1/fba-pricing/customer-rules/${encodeURIComponent(desired.id)}/default?tenantId=${encodeURIComponent(tenantId)}`)
context.apiSuccess(MODULE, '设置不包税客户唯一默认计费规则', defaultResponse,
`ruleId=${desired.id}, chargeMode=${profile.chargeUnit}`, OPTIONS)
}
context.assert(MODULE, '不包税自动计费具备客户计费方式', listResponse,
Boolean(desired?.id) && desired?.chargeMode === profile.chargeUnit,
`customerId=${tenantId}, chargeMode=${desired?.chargeMode || 'missing'}, ruleId=${id(desired?.id) || 'missing'}`, OPTIONS)
return desired || null
}
async function createMarkerContainer(context, resource) {
if (context.financeFixtureHooks?.createMarkerContainer) return context.financeFixtureHooks.createMarkerContainer(context, resource)
if (!resource) {
blocker(context, '取得可创建柜次的测量入库单',
'本轮没有完成测量的 marker-owned 入库单;跳过柜次及整柜成本 fixture,等待订单/测量前置修复')
return null
}
if (context.financeFixtureHooks) return null
const original = context.createdResources.phase3ContainerLoading
const originalMarker = context.marker
const snapshots = ['orders', 'inboundMeasurement'].map((key) => [key,
{ present: Object.hasOwn(context.createdResources, key), value: context.createdResources[key] }])
context.createdResources.orders = resource
context.createdResources.inboundMeasurement = resource
context.marker = resource.marker || originalMarker
try {
const module = await import(new URL('./phase3-container-loading.mjs', import.meta.url))
await module.run(context)
const created = context.createdResources.phase3ContainerLoading
return created?.marker === context.marker && created?.containerBookingId && created?.loadingPlanId ? { ...created } : null
} finally {
context.marker = originalMarker
for (const [key, snapshot] of snapshots) {
if (snapshot.present) context.createdResources[key] = snapshot.value
else delete context.createdResources[key]
}
if (original) context.createdResources.phase3ContainerLoading = original
else delete context.createdResources.phase3ContainerLoading
}
}
export function buildContainerCostBody(marker, customerId, container, voucherFileId) {
return { customerId: String(customerId), costType: 'CONTAINER_SHARED', orderId: null,
// Finance names this field containerId, but its Firstleg cost snapshot contract
// resolves it as the loading plan ID, not the physical container booking ID.
containerId: String(container.loadingPlanId), expenseName: `${marker}-整柜成本`, amount: 300,
currency: 'CNY', voucherFileIds: [voucherFileId], remark: marker }
}
async function createContainerCost(context, customerId, container, voucherFileId, snapshotInput) {
if (context.financeFixtureHooks?.createContainerCost) return context.financeFixtureHooks.createContainerCost(context, customerId, container, voucherFileId, snapshotInput)
const body = buildContainerCostBody(context.marker, customerId, container, voucherFileId)
const response = await request(context, 'POST', '/api/platform/finance/v1/cost-entries', body)
if (response?.body?.code === 'FIN_400') {
const first = snapshotInput?.items?.[0]
blocker(context, 'Finance 整柜成本请求关联真实装柜计划',
`message=${response.body.message || 'missing'}, loadingPlanId=${container.loadingPlanId || 'missing'}, containerBookingId=${container.containerBookingId || 'missing'}, voucherFileId=${voucherFileId || 'missing'}, publicSnapshotValid=${snapshotInput?.valid === true}, validItems=${snapshotInput?.items?.length || 0}, accountId=${first?.accountId ?? 'missing'}, orderId=${first?.orderId ?? 'missing'}, volumeCbm=${first?.volumeCbm ?? 'missing'}, requestId=${response.requestId || 'missing'}, traceId=${response.traceId || 'missing'};若 publicSnapshotValid=true,说明 fixture ID 与公开装柜明细有效,阻断位于 Finance -> Firstleg 内部快照服务部署/契约`)
}
const cost = context.apiSuccess(MODULE, '创建整柜成本并触发真实体积分摊', response, '', OPTIONS)
if (!cost?.id) return null
const detailResponse = await context.request('GET',
`/api/platform/finance/v1/cost-entries/${encodeURIComponent(cost.id)}?customerId=${encodeURIComponent(customerId)}`)
const detail = context.apiSuccess(MODULE, '回读整柜成本分摊结果', detailResponse,
`costId=${cost.id}`, OPTIONS)
return { costId: id(cost.id), allocations: Array.isArray(detail?.allocations) ? detail.allocations : null }
}
async function verifyContainerCostSnapshotInput(context, customerId, container) {
if (context.financeFixtureHooks?.verifyContainerCostSnapshotInput) {
return context.financeFixtureHooks.verifyContainerCostSnapshotInput(context, customerId, container)
}
const path = `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(container.loadingPlanId)}`
const response = await context.request('GET', path)
const plan = context.apiSuccess(MODULE, '回读 Finance 柜次体积快照输入', response,
`loadingPlanId=${container.loadingPlanId}`, OPTIONS)
const items = Array.isArray(plan?.items) ? plan.items : []
const valid = items.filter((item) => id(item?.accountId) === id(customerId)
&& Number(item?.orderId) > 0 && Number(item?.volumeCbm) > 0)
context.assert(MODULE, '装柜明细满足 Finance 内部快照筛选条件', response,
plan?.status === 'COMPLETED' && valid.length > 0,
`status=${plan?.status || 'missing'}, items=${items.length}, validItems=${valid.length}, accountId=${items[0]?.accountId ?? 'missing'}, orderId=${items[0]?.orderId ?? 'missing'}, volumeCbm=${items[0]?.volumeCbm ?? 'missing'}`, OPTIONS)
return { valid: plan?.status === 'COMPLETED' && valid.length > 0, items: valid }
}
export async function run(context) {
const start = context.steps.length
const pricingEvidence = await loadPricingEvidence(context)
const resources = []
const profiles = pricingEvidence.profiles || []
for (const [index, suffix] of ['AUTO1', 'AUTO2', 'AUTO3', 'AUTO4', 'MANUAL', 'CONTAINER'].entries()) {
const resource = await createOrder(context, suffix, profiles[index % profiles.length] || null)
context.assert(MODULE, `创建 ${suffix} marker 隔离订单与入库单`, null, Boolean(resource),
resource ? `orderId=${resource.orderId}, inboundId=${resource.inboundId}` : `marker=${context.marker}-${suffix} 未返回独立入库单`, OPTIONS)
if (resource) resources.push(resource)
}
for (const resource of resources.filter((item) => item.pricingProfile?.pricingTaxMode === 'TAX_EXCLUDED')) {
await ensureCustomerChargeRule(context, resource, resource.pricingProfile)
}
const measured = []
for (const resource of resources) {
const result = await measureOrder(context, resource)
if (result) measured.push({ ...resource, ...result })
}
const feeItems = {
RECEIVABLE: await loadFeeItem(context, 'RECEIVABLE'),
PAYABLE: await loadFeeItem(context, 'PAYABLE'),
}
const payableSupplierCategory = supplierCategoryForFeeItem(feeItems.PAYABLE)
const supplier = await loadSupplier(context, payableSupplierCategory)
if (!supplier?.id) blocker(context, '取得 Warehouse PAYABLE 供应商',
`未找到 ACTIVE ${payableSupplierCategory || '任意类别'} 供应商;不能伪造 supplierId`)
for (const direction of ['RECEIVABLE', 'PAYABLE']) {
if (!feeItems[direction]) context.addCoverageGap(MODULE, `missing-${direction.toLowerCase()}-fee-item`, `${direction} 人工费用 fixture`,
`没有找到带 warehouse${direction === 'RECEIVABLE' ? 'Receivable' : 'Payable'}=true 的 ACTIVE 费用项目,禁止猜测费用项目 ID`, 'missing-deployed-fee-item')
}
const fixtures = []
for (const resource of measured.slice(0, 4)) {
const fee = await loadPublishedAutomaticFee(context, resource)
if (fee) fixtures.push(fee)
}
for (const basis of ['WEIGHT_KG', 'VOLUME_CBM']) {
if (!fixtures.some((fee) => fee.billingBasis === basis)) {
blocker(context, `${basis} 自动计费 fixture`,
`activePriceVersions=${pricingEvidence.activeVersionIds?.join(',') || 'none'};本轮隔离订单未产生 ${basis} 来源,价格配置/订单条件不支持,不能伪装成功`)
}
}
const receivableResources = measured.slice(0, 4)
if (feeItems.RECEIVABLE) {
for (const [index, resource] of receivableResources.entries()) {
const fee = await createManualFee(context, resource, feeItems.RECEIVABLE, 'RECEIVABLE', 100 + index * 10,
'MANUAL_RECEIVABLE', dateOffset(-1 - (index % 2)))
if (fee) fixtures.push(fee)
}
}
const manualResources = [measured[4]].filter(Boolean)
if (feeItems.PAYABLE && supplier?.id) {
for (const [index, manualResource] of manualResources.entries()) {
const fee = await createManualFee(context, manualResource, feeItems.PAYABLE, 'PAYABLE', 80 + index * 10,
'MANUAL_ORDER_FEE', dateOffset(-3 - index), supplier)
if (fee) fixtures.push({ ...fee, supplierId: id(supplier.id),
supplierName: supplier.name || null, supplierCategory: supplier.category || payableSupplierCategory })
}
}
// A source whose fee has already been sent is intentionally frozen against
// loading-plan membership changes. Use a dedicated measured order for the
// container fixture so finance setup never conflicts with that invariant.
const containerResource = measured[5]
const container = (containerResource ? await createMarkerContainer(context, containerResource) : null)
|| context.createdResources?.phase3ContainerLoading
const containerOwned = (container?.marker === context.marker || String(container?.marker || '').startsWith(`${context.marker}-`))
&& container?.loadingPlanId && container?.containerBookingId
let containerFixture = null
if (containerOwned && feeItems.PAYABLE && supplier?.id) {
const basePath = `/api/platform/v1/loading-plans/${encodeURIComponent(container.loadingPlanId)}/fee-details`
const path = `${basePath}?accountId=${encodeURIComponent(container.tenantId)}`
const body = { feeItemId: String(feeItems.PAYABLE.id), direction: 'PAYABLE', businessDate: dateOffset(-2),
amount: 300, currency: 'CNY', supplierId: id(supplier.id), settlementMode: 'ON_ACCOUNT',
remark: `${context.marker}-CONTAINER_SHARED` }
const response = await request(context, 'POST', path, body)
if (response?.http === 404) {
blocker(context, '按柜费用来源接口已部署并可路由',
`POST ${path} returned HTTP 404 before Controller handling; Firstleg Controller contract is /api/platform/v1/loading-plans/{loadingPlanId}/fee-details, but current Gateway route does not expose /api/platform/v1/loading-plans/**; requestId=${response.requestId || 'missing'}, traceId=${response.traceId || 'missing'}`)
}
const detail = context.apiSuccess(MODULE, '创建按柜费用来源', response, '', OPTIONS)
const feeId = id(detail?.id)
if (feeId) { const approval = await request(context, 'POST', `${basePath}/${feeId}/approve?accountId=${encodeURIComponent(container.tenantId)}`); context.apiSuccess(MODULE, '审核按柜费用来源', approval, '', OPTIONS) }
containerFixture = { feeSourceId: feeId || null, sourceType: 'MANUAL', direction: 'PAYABLE', amount: 300,
businessDate: body.businessDate, currency: 'CNY', billingBasis: 'CONTAINER_SHARED',
supplierId: supplier?.id ? id(supplier.id) : null,
supplierName: supplier?.name || null, supplierCategory: supplier?.category || payableSupplierCategory,
containerBookingId: id(container.containerBookingId), containerNo: container.containerNo || null,
loadingPlanId: id(container.loadingPlanId) }
} else if (!containerOwned) {
context.addStep(MODULE, '取得同 marker 柜次用于整柜费用分摊', null, false,
'没有同 marker 的已完成柜次和可核验 containerBookingId;不读取历史柜次、不猜测柜号', OPTIONS)
context.addCoverageGap(MODULE, 'container-shared-cost-allocation', '按柜添加费用并按订单/运单体积分摊',
'当前上下文没有同 marker 的已完成柜次及真实 Firstleg 体积快照,禁止借用历史柜次或模拟 Finance 分摊成功', 'no-marker-owned-container-snapshot')
} else {
context.addCoverageGap(MODULE, 'container-shared-cost-allocation', '按柜添加费用并按订单/运单体积分摊',
'已识别同 marker 的 COMPLETED 柜次,但缺少 PAYABLE 费用项目或符合其显式可空类别约束的 ACTIVE 供应商', 'missing-payable-supplier')
}
if (containerFixture) fixtures.push(containerFixture)
const customerId = id(measured[0]?.tenantId) || null
const voucherFileId = customerId ? await uploadVoucher(context, customerId) : null
if (!voucherFileId) blocker(context, '取得本轮 Finance voucherFileId',
'未通过 /api/platform/finance/v1/vouchers multipart 上传取得真实 fileId;整柜成本不得模拟成功')
let allocations = []
if (containerFixture?.feeSourceId && voucherFileId && supplier?.id) {
const snapshotInput = await verifyContainerCostSnapshotInput(context, customerId, container)
const cost = snapshotInput.valid ? await createContainerCost(context, customerId, container, voucherFileId, snapshotInput) : null
if (Array.isArray(cost?.allocations)) allocations = cost.allocations
else blocker(context, '回读整柜成本分摊结果',
`costId=${cost?.costId || 'missing'};Finance 成本详情未返回真实 fin_cost_allocation,禁止从金额和体积自行伪造持久化结果`)
}
if (allocations.length === 0) blocker(context, '输出真实订单/运单分摊',
'缺少已完成柜次、凭证、供应商或 Finance 分摊响应;不伪造 allocation ID/金额')
const distinctBusinessDates = new Set(fixtures.map((item) => item.businessDate).filter(Boolean))
if (distinctBusinessDates.size < 2) blocker(context, '费用来源覆盖不同业务日期',
`businessDates=${[...distinctBusinessDates].join(',') || 'none'};至少需要两个真实日期以验收按日期排除`)
context.assert(MODULE, '取得订单真实运单标识', null,
measured.length > 0 && measured.every((item) => item.waybillId || item.shipmentId || item.orderNo),
'使用订单域真实 waybillId、shipmentId 或 orderNo,不使用数据库主键冒充运单标识', OPTIONS)
const fixtureOrders = measured.map((item) => ({ orderId: id(item.orderId), orderNo: item.orderNo || null,
shipmentId: item.shipmentId ? id(item.shipmentId) : null,
waybillId: item.waybillId ? id(item.waybillId) : (item.shipmentId ? id(item.shipmentId) : null), inboundId: id(item.inboundId),
actualWeightKg: item.actualWeightKg, totalVolumeCbm: item.totalVolumeCbm,
businessDate: fixtures.find((fee) => fee.orderId === id(item.orderId))?.businessDate || null }))
const fixtureSources = normalizeFinanceFixtureSources(fixtures, fixtureOrders, allocations, customerId, context.marker)
const invalidFixtureSources = fixtureSources.filter((source) => !source.feeSourceId || !source.customerId
|| !source.businessDate || !(source.waybillId || source.shipmentId || source.orderNo) || source.marker !== context.marker
|| (source.direction === 'PAYABLE' && (!source.supplierId || !source.supplierName
|| (payableSupplierCategory && source.supplierCategory !== payableSupplierCategory))))
context.assert(MODULE, '输出完整 Finance 来源契约', null, invalidFixtureSources.length === 0,
`sources=${fixtureSources.length}, invalid=${invalidFixtureSources.length};字段必须来自本轮费用来源、客户、订单/运单和真实柜次分摊`, OPTIONS)
context.createdResources.financeFullFixtures = {
marker: context.marker, customerId,
prerequisites: {
container: containerOwned && Boolean(containerFixture?.feeSourceId) && allocations.length > 0
? { ready: true, category: null }
: { ready: false, category: 'container-prerequisite', evidence: '缺少同 marker 已完成柜次、有效 containerBookingId、按柜费用来源或真实分摊' },
financeSources: fixtureSources.length > 0 && invalidFixtureSources.length === 0
? { ready: true, category: null }
: { ready: false, category: 'finance-source-prerequisite', evidence: '本轮 marker 未产生字段完整的可消费 Finance 费用来源' },
},
orders: fixtureOrders,
feeSources: fixtureSources,
container: containerFixture ? { containerBookingId: containerFixture.containerBookingId,
containerNo: containerFixture.containerNo } : { containerBookingId: null, containerNo: null },
allocations, supplier: supplier?.id ? { supplierId: id(supplier.id),
supplierName: supplier.name || null, category: supplier.category || payableSupplierCategory } : null,
voucherFileId, pricingEvidence,
deploymentBlocker: context.steps.slice(start).filter((step) => step.deploymentBlocker).map((step) => step.evidence),
}
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 })
}