import assert from 'node:assert/strict' import test from 'node:test' import { buildDetailAlerts } from '../src/utils/detailAlerts.js' import { canViewRiskForContext, filterRiskCardsForVisibility, resolveRiskActionability, resolveRiskDomain, resolveRiskVisibilityScope } from '../src/utils/riskVisibility.js' const submitter = { id: 'EMP-001', employeeId: 'EMP-001', name: '张三', roleCodes: [] } const budgetManager = { id: 'EMP-P8', name: '预算管理员', grade: 'P8', departmentName: '研发部', roleCodes: ['budget_monitor'] } const financeUser = { id: 'FIN-001', name: '财务', roleCodes: ['finance'] } const applicationRequest = { id: 'AP-202606010001', claimId: 'application-claim-id', documentTypeCode: 'application', approvalKey: 'in_progress', node: '预算管理者审批', employeeId: 'EMP-001', departmentName: '研发部', typeCode: 'travel_application', typeLabel: '差旅费用申请', reason: '北京出差申请', location: '北京', occurredDisplay: '2026-06-01 至 2026-06-03', amountValue: 10000, riskFlags: [ { source: 'budget_control', severity: 'high', label: '预算可用余额不足', message: '当前部门预算余额不足。', business_stage: 'expense_application', risk_domain: 'budget', visibility_scope: 'budget_manager', actionability: 'budget_governance' } ], expenseItems: [] } test('application submitter cannot see budget governance alerts in detail topbar', () => { const alerts = buildDetailAlerts(applicationRequest, { currentUser: submitter }) assert.deepEqual(alerts.map((item) => item.label), ['SLA 催单次数 0']) }) test('budget manager can see application budget governance alerts', () => { const alerts = buildDetailAlerts(applicationRequest, { currentUser: budgetManager }) assert.equal(alerts[0].label, '高风险 1 项') assert.equal(alerts[0].tone, 'danger') }) test('reimbursement submitter sees only fixable claim risks', () => { const request = { id: 'RE-202606010001', claimId: 'reimbursement-claim-id', documentTypeCode: 'claim', approvalKey: 'draft', node: '待提交', employeeId: 'EMP-001', typeCode: 'travel', expenseItems: [] } const cards = [ { id: 'ticket-date', businessStage: 'reimbursement', tone: 'high', risk: '票据日期早于申请行程。', risk_domain: 'trip', visibility_scope: 'submitter', actionability: 'fixable_by_submitter' }, { id: 'budget-detail', businessStage: 'reimbursement', tone: 'medium', risk: '预算占用率超过 90%。', risk_domain: 'budget', visibility_scope: 'budget_manager', actionability: 'budget_governance' } ] const visibleCards = filterRiskCardsForVisibility(cards, { request, currentUser: submitter }) assert.deepEqual(visibleCards.map((card) => card.id), ['ticket-date']) }) test('reimbursement detail still shows submitter-fixable attachment risks when viewer identity is incomplete', () => { const request = { id: 'RE-20260603083825-876B85XW', claimId: '2ad80b25-b153-407e-91be-ed2651045fb1', documentTypeCode: 'claim', approvalKey: 'draft', node: 'pending-submit', employeeId: 'EMP-CLAIM-OWNER', typeCode: 'travel', expenseItems: [] } const currentUserWithoutEmployeeMatch = { id: 'FRONTEND-AUTH-SNAPSHOT', employeeId: '', name: '', roleCodes: [] } const cards = [ { id: 'hotel-limit-risk', source: 'attachment_analysis', businessStage: 'reimbursement', tone: 'high', risk: 'hotel limit exceeded', risk_domain: 'invoice', visibility_scope: 'submitter', actionability: 'fixable_by_submitter' } ] const visibleCards = filterRiskCardsForVisibility(cards, { request, currentUser: currentUserWithoutEmployeeMatch }) assert.deepEqual(visibleCards.map((card) => card.id), ['hotel-limit-risk']) }) test('finance can see reimbursement compliance risks but not budget governance detail', () => { const request = { id: 'RE-202606010002', documentTypeCode: 'claim', approvalKey: 'in_progress', node: '财务审批', employeeId: 'EMP-001', typeCode: 'travel', expenseItems: [] } assert.equal( canViewRiskForContext( { businessStage: 'reimbursement', risk: '发票抬头与报销主体不一致。', risk_domain: 'invoice', actionability: 'fixable_by_submitter' }, { request, currentUser: financeUser } ), true ) assert.equal( canViewRiskForContext( { businessStage: 'reimbursement', risk: '预算余额不足。', risk_domain: 'budget', actionability: 'budget_governance' }, { request, currentUser: financeUser } ), false ) }) test('legacy risk text falls back to semantic visibility defaults', () => { const legacyFlag = { source: 'submission_review', severity: 'high', message: '住宿发票城市与行程城市不一致。', business_stage: 'reimbursement' } assert.equal(resolveRiskDomain(legacyFlag), 'trip') assert.equal(resolveRiskActionability(legacyFlag, { businessStage: 'reimbursement' }), 'fixable_by_submitter') assert.equal(resolveRiskVisibilityScope(legacyFlag, { businessStage: 'reimbursement' }), 'submitter') }) test('application submitter can see fixable policy/trip risks in detail', () => { // 申请单申请人在详情页可见可自行整改的风险(信息完整性、差旅、金额), // 以便申请时知晓风险及原因并补充修正。 const cards = [ { id: 'application-fields-missing', businessStage: 'expense_application', tone: 'low', risk: '差旅申请基础信息不完整,请补充地点、事由、起止时间和预计金额。', risk_domain: 'policy', visibility_scope: 'submitter', actionability: 'fixable_by_submitter' }, { id: 'budget-detail', businessStage: 'expense_application', tone: 'high', risk: '预算可用余额不足。', risk_domain: 'budget', visibility_scope: 'budget_manager', actionability: 'budget_governance' }, { id: 'profile-detail', businessStage: 'expense_application', tone: 'medium', risk: '历史差旅画像异常。', risk_domain: 'profile', visibility_scope: 'leader', actionability: 'review_decision' } ] const visibleCards = filterRiskCardsForVisibility(cards, { request: applicationRequest, currentUser: submitter }) // 申请人只可见 fixable_by_submitter 的信息完整性类风险, // budget 走预算审批人、profile 走领导,申请人均不可见。 assert.deepEqual(visibleCards.map((card) => card.id), ['application-fields-missing']) }) test('application leader can see review_decision risks that submitter cannot', () => { // 审批人可见 review_decision 类风险(画像、审批流程等), // 满足诉求2:提交后领导能看到风险点。 const cards = [ { id: 'profile-detail', businessStage: 'expense_application', tone: 'medium', risk: '历史差旅画像异常。', risk_domain: 'profile', visibility_scope: 'leader', actionability: 'review_decision' }, { id: 'application-fields-missing', businessStage: 'expense_application', tone: 'low', risk: '差旅申请基础信息不完整。', risk_domain: 'policy', visibility_scope: 'submitter', actionability: 'fixable_by_submitter' } ] const visibleCards = filterRiskCardsForVisibility(cards, { request: applicationRequest, currentUser: { id: 'EMP-P7', name: '直属领导' }, canViewApprovalRiskAdvice: true }) assert.deepEqual(visibleCards.map((card) => card.id), ['profile-detail', 'application-fields-missing']) }) test('application fixable risks derive submitter semantics without hardcoded leader fallback', () => { // 验证申请单阶段 policy/trip/amount 域不再被硬编码为 leader/review_decision, // 而是沿用与报销单一致的 fixable_by_submitter 语义。 const policyFlag = { source: 'submission_review', severity: 'low', message: '差旅申请基础信息不完整。', business_stage: 'expense_application' } assert.equal(resolveRiskActionability(policyFlag, { businessStage: 'expense_application' }), 'fixable_by_submitter') assert.equal(resolveRiskVisibilityScope(policyFlag, { businessStage: 'expense_application' }), 'submitter') })