415 lines
29 KiB
JavaScript
415 lines
29 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) }
|
||
|
||
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 response = await originalFetch(input, init)
|
||
const url = String(input)
|
||
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']
|
||
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
|
||
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) {
|
||
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: Number(feeItem.id), direction, businessDate,
|
||
unitPrice: amount, currency: 'CNY', amount, settlementIncluded: true, 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 calculateAutomaticFee(context, resource) {
|
||
if (context.financeFixtureHooks?.calculateAutomaticFee) return context.financeFixtureHooks.calculateAutomaticFee(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 base = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fees`
|
||
const query = `tenantId=${encodeURIComponent(resource.tenantId)}`
|
||
const calculateResponse = await request(context, 'POST', `${base}/calculate?${query}`, {})
|
||
const calculated = context.apiSuccess(MODULE, '按审核测量快照执行真实自动计费', calculateResponse,
|
||
`orderId=${resource.orderId}, inboundId=${resource.inboundId}`, OPTIONS)
|
||
const feeSourceId = id(calculated?.feeId)
|
||
const billingBasis = calculated?.chargeUnit === 'KG' ? 'WEIGHT_KG'
|
||
: calculated?.chargeUnit === 'CBM' ? 'VOLUME_CBM' : null
|
||
const responseWeightKg = Number(calculated?.actualWeightKg)
|
||
const responseVolumeCbm = Number(calculated?.actualVolumeCbm)
|
||
context.assert(MODULE, '自动费用返回来源、金额和计费依据', calculateResponse,
|
||
Boolean(feeSourceId) && Boolean(billingBasis) && Number(calculated?.totalAmount) >= 0
|
||
&& responseWeightKg > 0 && responseVolumeCbm > 0,
|
||
`feeSourceId=${feeSourceId || 'missing'}, chargeUnit=${calculated?.chargeUnit || 'missing'}, amount=${calculated?.totalAmount ?? 'missing'}, actualWeightKg=${responseWeightKg || 'missing'}, actualVolumeCbm=${responseVolumeCbm || 'missing'}`, OPTIONS)
|
||
if (!feeSourceId || !billingBasis) return null
|
||
const confirmResponse = await request(context, 'POST', `${base}/confirm?${query}`)
|
||
const confirmed = context.apiSuccess(MODULE, '确认真实自动费用快照', confirmResponse, '', OPTIONS)
|
||
const sendResponse = await request(context, 'POST', `${base}/send?${query}`)
|
||
const sent = context.apiSuccess(MODULE, '发布真实自动费用来源', sendResponse, '', OPTIONS)
|
||
return { feeSourceId, orderId: id(resource.orderId), inboundId: id(resource.inboundId), sourceType: 'FBA_PRICING',
|
||
direction: 'RECEIVABLE', currency: sent?.currency || confirmed?.currency || calculated.currency,
|
||
amount: Number(sent?.totalAmount ?? confirmed?.totalAmount ?? calculated.totalAmount),
|
||
businessDate: String(sent?.sentAt || confirmed?.confirmedAt || calculated.calculatedAt || '').slice(0, 10),
|
||
billingBasis, chargeableQuantity: sent?.chargeableQuantity ?? calculated.chargeableQuantity,
|
||
chargeUnit: sent?.chargeUnit || calculated.chargeUnit,
|
||
actualWeightKg: responseWeightKg, actualVolumeCbm: responseVolumeCbm }
|
||
}
|
||
|
||
async function loadSupplier(context) {
|
||
if (context.financeFixtureHooks?.loadSupplier) return context.financeFixtureHooks.loadSupplier(context)
|
||
const response = await context.request('GET', '/api/platform/v1/partners?page=1&size=200&status=ACTIVE')
|
||
const page = context.apiSuccess(MODULE, '查询真实供应商往来单位', response, '', OPTIONS)
|
||
return rows(page).find((item) => item?.status === 'ACTIVE') || 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 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 (!['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 })
|
||
}
|
||
}
|
||
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 createMarkerContainer(context, resource) {
|
||
if (context.financeFixtureHooks?.createMarkerContainer) return context.financeFixtureHooks.createMarkerContainer(context, resource)
|
||
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
|
||
}
|
||
}
|
||
|
||
async function createContainerCost(context, customerId, container, voucherFileId, snapshotInput) {
|
||
if (context.financeFixtureHooks?.createContainerCost) return context.financeFixtureHooks.createContainerCost(context, customerId, container, voucherFileId, snapshotInput)
|
||
const body = { customerId: Number(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: `${context.marker}-整柜成本`, amount: 300,
|
||
currency: 'CNY', voucherFileIds: [voucherFileId], remark: context.marker }
|
||
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'].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)
|
||
}
|
||
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 supplier = await loadSupplier(context)
|
||
if (!supplier?.id) blocker(context, '取得真实 PAYABLE 往来单位', '未找到 ACTIVE 往来单位;不能伪造 supplierId/partnerId')
|
||
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 calculateAutomaticFee(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.at(-1)].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))
|
||
if (fee) fixtures.push({ ...fee, supplierId: id(supplier.id), partnerId: id(supplier.id), supplierName: supplier.partnerName || null })
|
||
}
|
||
}
|
||
|
||
const container = await createMarkerContainer(context, measured[0])
|
||
|| 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: Number(feeItems.PAYABLE.id), direction: 'PAYABLE', businessDate: dateOffset(-2), amount: 300, currency: 'CNY', settlementIncluded: true, 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, partnerId: supplier?.id ? id(supplier.id) : null,
|
||
containerBookingId: id(container.containerBookingId), containerNo: container.containerNo || null,
|
||
loadingPlanId: id(container.loadingPlanId) }
|
||
} else {
|
||
context.addStep(MODULE, '取得同 marker 柜次用于整柜费用分摊', null, false,
|
||
'没有同 marker 的已完成柜次和可核验 containerBookingId;不读取历史柜次、不猜测柜号', OPTIONS)
|
||
context.addCoverageGap(MODULE, 'container-shared-cost-allocation', '按柜添加费用并按订单/运单体积分摊',
|
||
'当前上下文没有同 marker 的已完成柜次及真实 Firstleg 体积快照,禁止借用历史柜次或模拟 Finance 分摊成功', 'no-marker-owned-container-snapshot')
|
||
}
|
||
|
||
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'};至少需要两个真实日期以验收按日期排除`)
|
||
if (measured.length > 0 && measured.every((item) => !item.waybillId)) blocker(context, '取得订单真实 waybillId',
|
||
'当前订单/入库接口未返回独立 waybillId;输出 null,不使用 orderId、orderNo 或 inboundId 冒充运单 ID')
|
||
context.createdResources.financeFullFixtures = {
|
||
marker: context.marker, customerId,
|
||
orders: measured.map((item) => ({ orderId: id(item.orderId), orderNo: item.orderNo || null,
|
||
waybillId: item.waybillId ? id(item.waybillId) : null, inboundId: id(item.inboundId),
|
||
actualWeightKg: item.actualWeightKg, totalVolumeCbm: item.totalVolumeCbm,
|
||
businessDate: fixtures.find((fee) => fee.orderId === id(item.orderId))?.businessDate || null })),
|
||
feeSources: fixtures,
|
||
container: containerFixture ? { containerBookingId: containerFixture.containerBookingId,
|
||
containerNo: containerFixture.containerNo } : { containerBookingId: null, containerNo: null },
|
||
allocations, supplier: supplier?.id ? { supplierId: id(supplier.id), partnerId: id(supplier.id), supplierName: supplier.partnerName || null } : 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 })
|
||
}
|