Files
X-Financial/web/tests/cfo-value-dashboard.test.mjs

427 lines
18 KiB
JavaScript
Raw Normal View History

import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
buildCfoValueSearch,
buildSavingsOpportunitySearch,
normalizeAnalyticsValuePayload,
recordSavingsRealization
} from '../src/services/analyticsValue.js'
import {
buildBreakdownGroups,
buildDataQualityRows,
buildValueKpis,
classifyCfoDashboardState,
formatMoneyValues,
isDashboardStale,
readValueFiltersFromQuery,
resolveRangeWindow,
writeValueFiltersToQuery
} from '../src/views/scripts/cfoValueDashboardModel.js'
import {
BUDGET_CONFIGURATION_NOTICE,
buildCfoOpportunitySourceLinks,
hasValueOpportunityQuery,
normalizeValueOpportunityId,
opportunityMatchesValueContext,
readBudgetConfigurationFocus,
readValueOpportunityId,
shouldClearValueOpportunityError,
writeValueOpportunityToQuery
} from '../src/views/scripts/cfoValueSourceLinks.js'
const overviewView = readSource('../src/views/OverviewView.vue')
const topBarRange = readSource('../src/components/layout/useTopBarOverviewRange.js')
const appShell = readSource('../src/views/AppShellRouteView.vue')
const appShellComposable = readSource('../src/composables/useAppShell.js')
const budgetCenter = readSource('../src/views/BudgetCenterView.vue')
const budgetCenterScript = readSource('../src/views/scripts/BudgetCenterView.js')
const dashboardComponent = readSource('../src/components/dashboard/CfoValueDashboard.vue')
const dashboardComposable = readSource('../src/composables/useCfoValueDashboard.js')
const dashboardModel = readSource('../src/views/scripts/cfoValueDashboardModel.js')
const valueService = readSource('../src/services/analyticsValue.js')
const actionDialog = readSource('../src/components/dashboard/CfoValueActionDialog.vue')
const opportunityDrawer = readSource('../src/components/dashboard/CfoValueOpportunityDrawer.vue')
const trendChart = readSource('../src/components/charts/CfoValueTrendChart.vue')
const dashboardStyles = readSource('../src/assets/styles/components/cfo-value-dashboard.css')
test('经营价值接口把后端 snake_case 契约规范为前端字段且保留币种分账', () => {
const normalized = normalizeAnalyticsValuePayload({
source: { data_status: 'complete', opportunity_count: 2 },
funnel: { realization_rate_by_currency: { CNY: '0.2500', USD: null } },
data_quality: { pending_confirmation_count: 1 }
})
assert.equal(normalized.source.dataStatus, 'complete')
assert.equal(normalized.source.opportunityCount, 2)
assert.equal(normalized.funnel.realizationRateByCurrency.CNY, '0.2500')
assert.equal(normalized.funnel.realizationRateByCurrency.USD, null)
assert.equal(normalized.dataQuality.pendingConfirmationCount, 1)
})
test('经营价值与机会台账查询只发送真实筛选字段', () => {
const dashboardSearch = buildCfoValueSearch({
start: '2026-07-01T00:00:00.000Z',
end: '2026-07-16T00:00:00.000Z',
departmentId: 'dept-1',
projectCode: '',
valueKind: 'cash'
})
const opportunitySearch = buildSavingsOpportunitySearch({
page: 2,
pageSize: 12,
status: 'in_progress',
ownerId: 'finance-1'
})
assert.equal(dashboardSearch.get('department_id'), 'dept-1')
assert.equal(dashboardSearch.get('value_kind'), 'cash')
assert.equal(dashboardSearch.has('project_code'), false)
assert.equal(opportunitySearch.get('page_size'), '12')
assert.equal(opportunitySearch.get('status'), 'in_progress')
assert.equal(opportunitySearch.get('owner_id'), 'finance-1')
})
test('看板明确区分 loading permission error empty partial stale 与 ready', () => {
const complete = dashboardFixture({ dataStatus: 'complete' })
const partial = dashboardFixture({ dataStatus: 'partial' })
const empty = dashboardFixture({ dataStatus: 'empty', opportunityCount: 0, realizationCount: 0 })
const stale = dashboardFixture({ dataStatus: 'complete', freshnessAt: '2026-07-01T00:00:00Z' })
assert.equal(classifyCfoDashboardState({ loading: true }), 'loading')
assert.equal(classifyCfoDashboardState({ error: { status: 403 } }), 'permission')
assert.equal(classifyCfoDashboardState({ error: new Error('offline') }), 'error')
assert.equal(classifyCfoDashboardState({ dashboard: empty }), 'empty')
assert.equal(classifyCfoDashboardState({ dashboard: partial }), 'partial')
assert.equal(classifyCfoDashboardState({ dashboard: dashboardFixture({ dataStatus: 'complete', freshnessAt: '' }) }), 'partial')
assert.equal(classifyCfoDashboardState({ dashboard: complete, now: new Date('2026-07-16T01:00:00Z') }), 'ready')
assert.equal(classifyCfoDashboardState({ dashboard: stale, now: new Date('2026-07-16T01:00:00Z') }), 'stale')
assert.equal(isDashboardStale(stale, new Date('2026-07-16T01:00:00Z')), true)
})
test('数据质量缺字段显示不可用而不是伪装成零缺口', () => {
const rows = buildDataQualityRows({ dataQuality: { pendingConfirmationCount: 0 } })
assert.deepEqual(rows[0], {
key: 'pendingConfirmationCount',
label: '待财务确认',
count: 0,
displayValue: '0',
tone: 'ok'
})
assert.equal(rows[1].count, null)
assert.equal(rows[1].displayValue, '—')
assert.equal(rows[1].tone, 'unavailable')
})
test('零节省、空台账与缺失工时基线不会混为默认数字', () => {
const dashboard = dashboardFixture({ dataStatus: 'complete', opportunityCount: 2, realizationCount: 1 })
dashboard.kpis = {
verifiedCash: { label: '财务确认净现金节省', status: 'empty', values: [], confirmedRealizationCount: 0 },
releasableLabor: { label: '财务确认可释放工时价值', status: 'collecting', reason: '缺少工时基线', requiredInputs: ['人工分钟'] },
safeStraightThrough: { label: '安全智能直通率', status: 'collecting', reason: '缺少审计样本', requiredInputs: ['审计结果'] }
}
dashboard.funnel = { stages: [{ key: 'estimated', values: [{ currency: 'USD', amount: '50.00' }] }] }
const [cash, labor, straightThrough] = buildValueKpis(dashboard)
assert.equal(cash.state, 'zero')
assert.match(cash.displayValue, /US\$0|\$0/u)
assert.equal(labor.state, 'baseline-missing')
assert.equal(labor.displayValue, '待采集')
assert.equal(straightThrough.displayValue, '待采集')
assert.match(
formatMoneyValues([{ currency: 'CNY', amount: '10' }, { currency: 'USD', amount: '20' }]),
/¥10 \/ (?:US)?\$20/u
)
dashboard.filters = { valueKind: 'labor' }
const [excludedCash] = buildValueKpis(dashboard)
assert.equal(excludedCash.state, 'unavailable')
assert.equal(excludedCash.displayValue, '未纳入筛选')
})
test('价值分解按币种拆行,绝不把不同币种相加后比较', () => {
const groups = buildBreakdownGroups({
breakdowns: [{
dimension: 'department',
items: [{
dimensionId: 'dept-1',
dimensionName: '财务部',
opportunityCount: 2,
verifiedValues: [
{ currency: 'CNY', amount: '100' },
{ currency: 'USD', amount: '20' }
],
estimatedValues: [
{ currency: 'CNY', amount: '180' },
{ currency: 'USD', amount: '30' }
]
}]
}]
})
assert.equal(groups[0].items.length, 2)
assert.deepEqual(groups[0].items.map((item) => item.currency), ['CNY', 'USD'])
assert.deepEqual(groups[0].items.map((item) => item.verifiedMagnitude), [100, 20])
assert.ok(groups[0].items.every((item) => item.width === '100%'))
})
test('筛选条件和时间窗口可以通过 URL 恢复', () => {
const filters = readValueFiltersFromQuery({
value_department_id: 'dept-7',
value_value_kind: 'cash',
value_status: 'verified'
})
assert.deepEqual(
{ departmentId: filters.departmentId, valueKind: filters.valueKind, status: filters.status },
{ departmentId: 'dept-7', valueKind: 'cash', status: 'verified' }
)
const query = writeValueFiltersToQuery({ dashboard: 'value', value_city: 'old' }, {
...filters,
city: ''
})
assert.equal(query.dashboard, 'value')
assert.equal(query.value_department_id, 'dept-7')
assert.equal(Object.hasOwn(query, 'value_city'), false)
const range = resolveRangeWindow('custom', { start: '2026-07-01', end: '2026-07-16' })
assert.match(range.start, /^2026-06-30T16:00:00\.000Z$|^2026-07-01T00:00:00\.000Z$/u)
assert.ok(new Date(range.end) > new Date(range.start))
})
test('机会抽屉 URL 可恢复、关闭可清理且非法 ID 不会残留', () => {
const opportunityId = '247b5e9d-f9ee-463f-a88e-6fd41757769b'
const opened = writeValueOpportunityToQuery({ dashboard: 'value', value_city: '上海' }, opportunityId)
assert.equal(readValueOpportunityId(opened), opportunityId)
assert.equal(hasValueOpportunityQuery(opened), true)
const closed = writeValueOpportunityToQuery(opened)
assert.equal(hasValueOpportunityQuery(closed), false)
assert.equal(closed.value_city, '上海')
const malformed = { ...opened, value_opportunity: '../tenant-b/opportunity' }
assert.equal(normalizeValueOpportunityId(malformed.value_opportunity), '')
assert.equal(readValueOpportunityId(malformed), '')
assert.equal(hasValueOpportunityQuery(writeValueOpportunityToQuery(malformed)), false)
assert.equal(shouldClearValueOpportunityError({ status: 403 }), true)
assert.equal(shouldClearValueOpportunityError({ status: 404 }), true)
assert.equal(shouldClearValueOpportunityError({ status: 500 }), false)
})
test('打开机会必须仍属于当前筛选和时间窗口', () => {
const opportunity = {
departmentId: 'ignored-top-level',
dimensionJson: {
department_id: 'dept-7',
project_code: 'PROJECT-1',
expense_type: 'hotel',
supplier_id: 'supplier-1',
city: '上海'
},
ownerId: 'finance-1',
sourceType: 'risk_observation',
valueKind: 'cash',
status: 'identified',
createdAt: '2026-07-15T08:00:00Z'
}
const filters = {
departmentId: 'dept-7', projectCode: 'PROJECT-1', expenseType: 'hotel',
supplierId: 'supplier-1', city: '上海', ownerId: 'finance-1',
sourceType: 'risk_observation', valueKind: 'cash', status: 'identified'
}
const window = { start: '2026-07-01T00:00:00Z', end: '2026-07-16T23:59:59Z' }
assert.equal(opportunityMatchesValueContext(opportunity, filters, window), true)
assert.equal(opportunityMatchesValueContext(opportunity, { ...filters, departmentId: 'dept-other' }, window), false)
assert.equal(opportunityMatchesValueContext(opportunity, filters, { ...window, end: '2026-07-14T00:00:00Z' }), false)
})
test('来源动作可定位风险单据、预算配置和 CFO 维度且不伪造预算事实', () => {
const opportunityId = '247b5e9d-f9ee-463f-a88e-6fd41757769b'
const links = buildCfoOpportunitySourceLinks({
id: opportunityId,
claimId: '8a25e85a-b7d8-41dc-8717-57bd1d458fd2',
claimNoSnapshot: 'BX-20260716-001',
expenseCaseId: '04452d7a-cdb8-4665-bb9d-99b7f8fc7f1b',
aiDecisionId: 'b100ca12-8ce3-47eb-a80d-35883410545a',
sourceType: 'risk_observation',
sourceId: 'risk-fallback',
category: 'risk_avoidance',
ownerId: 'finance-1',
dimensionJson: {
department_id: 'dept-7',
department_name: '财务部',
project_code: 'PROJECT-1',
expense_type: 'hotel',
city: '上海'
},
evidence: [{ resourceType: 'risk_observation', resourceId: 'risk-observation-7' }]
}, {
dashboardQuery: {
dashboard: 'value',
range: '本月',
value_city: '上海',
value_opportunity: opportunityId
}
})
const risk = links.find((item) => item.key === 'risk-claim')
assert.equal(risk.label, '查看风险来源单据')
assert.equal(risk.to.name, 'app-document-detail')
assert.equal(risk.to.params.requestId, '8a25e85a-b7d8-41dc-8717-57bd1d458fd2')
assert.equal(risk.to.query.returnTo, 'value')
assert.equal(risk.to.query.focus, 'risk')
assert.equal(risk.to.query.risk_observation_id, 'risk-observation-7')
assert.equal(risk.to.query.ai_decision_id, 'b100ca12-8ce3-47eb-a80d-35883410545a')
assert.equal(risk.to.hash, '#risk-observation-active-detail')
const budget = links.find((item) => item.key === 'budget-configuration')
assert.equal(budget.label, '查看预算配置视图')
assert.equal(budget.to.name, 'app-budget')
assert.equal(budget.to.query.budget_department_id, 'dept-7')
assert.equal(budget.to.query.budget_expense_type, 'hotel')
assert.equal(budget.description, BUDGET_CONFIGURATION_NOTICE)
assert.match(budget.description, /不代表当前节省机会的真实预算金额/u)
const dimension = links.find((item) => item.key === 'dimension-expenseType')
assert.equal(dimension.to.name, 'app-overview')
assert.equal(dimension.to.query.dashboard, 'value')
assert.equal(dimension.to.query.value_expense_type, 'hotel')
assert.equal(Object.hasOwn(dimension.to.query, 'value_opportunity'), false)
assert.deepEqual(readBudgetConfigurationFocus(budget.to.query), {
active: true,
departmentId: 'dept-7',
departmentName: '财务部',
expenseType: 'hotel'
})
})
test('带可追溯凭证的实际结果递归序列化为后端 snake_case', async () => {
const originalFetch = globalThis.fetch
let requestBody = null
globalThis.fetch = async (_url, options) => {
requestBody = JSON.parse(options.body)
return new Response(JSON.stringify({ realization: {}, opportunity: {}, event: {}, replayed: false }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
}
try {
await recordSavingsRealization('opp-1', {
requestId: 'request-12345678',
expectedVersion: 2,
comment: '平台付款完成',
actualGross: '100',
incrementalCost: '10',
realizedAt: '2026-07-16T00:00:00Z',
evidenceLevel: 'external_document',
evidence: [{
evidenceKey: 'payment-receipt-001',
evidenceRole: 'payment_receipt',
resourceType: 'payment_receipt',
resourceId: 'receipt-001',
sourceSystem: 'erp',
contentHash: '0123456789abcdef0123456789abcdef',
occurredAt: '2026-07-16T00:00:00Z',
verificationStatus: 'unverified',
metadataJson: { connector: 'erp' }
}]
})
} finally {
globalThis.fetch = originalFetch
}
assert.equal(requestBody.request_id, 'request-12345678')
assert.equal(requestBody.expected_version, 2)
assert.equal(requestBody.incremental_cost, '10')
assert.equal(requestBody.evidence_level, 'external_document')
assert.equal(requestBody.evidence[0].evidence_key, 'payment-receipt-001')
assert.equal(requestBody.evidence[0].metadata_json.connector, 'erp')
})
test('经营价值入口、独立模块、URL 深链与完整状态 UI 已接入分析看板', () => {
assert.match(topBarRange, /label: '经营价值看板', value: 'value'/u)
assert.match(overviewView, /<CfoValueDashboard/u)
assert.match(overviewView, /props\.dashboard === 'value'/u)
assert.match(appShell, /OVERVIEW_DASHBOARDS = new Set\(\['finance', 'value'/u)
assert.match(appShell, /dashboard: resolveOverviewDashboard\(overviewDashboard\.value\)/u)
assert.match(dashboardComposable, /readValueFiltersFromQuery\(route\.query\)/u)
assert.match(dashboardComposable, /writeValueFiltersToQuery\(route\.query/u)
assert.match(dashboardComponent, /dashboardState === 'loading'/u)
assert.match(dashboardComponent, /dashboardState === 'permission'/u)
assert.match(dashboardComponent, /dashboardState === 'error'/u)
assert.match(dashboardComponent, /dashboardState === 'empty'/u)
assert.match(dashboardComponent, /dashboardState === 'partial'/u)
assert.match(dashboardComponent, /dashboardStale/u)
assert.match(dashboardComponent, /kpi\.state/u)
assert.match(dashboardComponent, /机会状态 <em>仅筛选下方台账<\/em>/u)
assert.match(dashboardModel, /baseline-missing/u)
assert.match(dashboardModel, /真实零值/u)
assert.match(dashboardComposable, /valueOpportunityRouteSignature/u)
assert.match(dashboardComposable, /clearUnavailableOpportunity/u)
assert.match(dashboardComposable, /router\.replace\(\{ query: nextQuery \}\)/u)
assert.match(appShellComposable, /DOCUMENT_DETAIL_RETURN_TARGETS = new Set\(\['workbench', 'conversation', 'value'\]\)/u)
assert.match(appShellComposable, /name: 'app-overview', query: buildValueDashboardReturnQuery\(\)/u)
assert.match(opportunityDrawer, /id="value-source-links-title">查看来源/u)
assert.match(opportunityDrawer, /\{\{ link\.label \}\}/u)
assert.match(budgetCenter, /预算配置视图 · \{\{ budgetConfigurationFocusSummary \}\}/u)
assert.match(budgetCenter, /isFocusedBudgetCategory\(item\)/u)
assert.match(budgetCenterScript, /readBudgetConfigurationFocus\(route\.query\)/u)
assert.match(budgetCenterScript, /\.filter\(\(row\) => matchesBudgetConfigurationExpense\(row\)\)/u)
assert.match(budgetCenterScript, /这不代表预算为零/u)
})
test('经营价值实现不包含 demo 或 fallback 指标数字', () => {
assert.doesNotMatch(valueService, /FALLBACK|DEMO|mock/iu)
assert.doesNotMatch(dashboardComposable, /fallback|demo|mock/iu)
assert.match(dashboardComponent, /不会展示旧的演示数字/u)
})
test('移动端、键盘与读屏交互具备可验证的生产语义', () => {
assert.match(dashboardStyles, /@media \(max-width: 760px\)/u)
assert.match(dashboardStyles, /min-height: 44px/u)
assert.match(dashboardComponent, /role="alert"/u)
assert.match(dashboardComponent, /aria-live="polite"/u)
assert.match(actionDialog, /<label/u)
assert.match(actionDialog, /role="alert"/u)
assert.match(actionDialog, /:disabled="submitting"/u)
assert.match(actionDialog, /requestId: form\.requestId/u)
assert.match(dashboardComposable, /command\.requestId \|\| createRequestId/u)
assert.doesNotMatch(dashboardComposable, /recordSavingsRealization/u)
assert.match(dashboardComposable, /实际结果必须由付款事件或具备可追溯凭证的连接器写入/u)
assert.match(opportunityDrawer, /action !== 'record_realization'/u)
assert.match(opportunityDrawer, /当前页面不会提交空证据/u)
assert.match(opportunityDrawer, /financeConfirmerName/u)
assert.match(opportunityDrawer, /确认说明:/u)
assert.doesNotMatch(actionDialog, /实际毛节省/u)
assert.match(opportunityDrawer, /aria-label="关闭节省机会详情"/u)
assert.match(trendChart, /role="img"/u)
assert.match(trendChart, /查看趋势明细表/u)
assert.match(trendChart, /<caption class="sr-only">/u)
})
function dashboardFixture({
dataStatus,
opportunityCount = 1,
realizationCount = 1,
freshnessAt = '2026-07-16T00:00:00Z'
}) {
return {
source: { dataStatus, opportunityCount, realizationCount, freshnessAt },
kpis: {},
funnel: { stages: [] },
trend: [],
breakdowns: [],
guardrails: [],
dataQuality: {}
}
}
function readSource(path) {
return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
}