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, /仅筛选下方台账<\/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, /