647 lines
44 KiB
JavaScript
647 lines
44 KiB
JavaScript
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 }
|
||
}
|
||
|
||
export async function loadSupplier(context, category) {
|
||
if (context.financeFixtureHooks?.loadSupplier) return context.financeFixtureHooks.loadSupplier(context, category)
|
||
if (context.options?.fixedCustomerId) {
|
||
for (let page = 1; ; page += 1) {
|
||
const response = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&status=ACTIVE&page=${page}&size=100`)
|
||
const data = context.apiSuccess(MODULE, '读取可复用的现有供应商', response, '', OPTIONS)
|
||
if (!data) return null
|
||
const records = rows(data)
|
||
const candidate = records.find(item => id(item?.id) && item.status === 'ACTIVE' && (!category || item.category === category))
|
||
if (candidate) {
|
||
const detailResponse = await request(context, 'GET', `/api/platform/v1/suppliers/${encodeURIComponent(candidate.id)}?tenantId=0`)
|
||
const detail = context.apiSuccess(MODULE, '核对复用供应商状态与类别', detailResponse, '', OPTIONS)
|
||
if (id(detail?.id) === id(candidate.id) && detail?.status === 'ACTIVE' && (!category || detail.category === category)) return detail
|
||
blocker(context, '复用供应商已变化', '候选详情不匹配,不创建替代供应商')
|
||
return null
|
||
}
|
||
if (!records.length || page * 100 >= Number(data.total ?? records.length)) break
|
||
}
|
||
blocker(context, '缺少可复用供应商', `需要 ACTIVE ${category || '任意类别'} 供应商;禁止自动创建`)
|
||
return null
|
||
}
|
||
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 })
|
||
}
|