import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'

import {
  buildAiAdviceViewModel,
  buildAttachmentInsightViewModel,
  buildAttachmentRiskCards,
  buildClaimSummaryRiskCards,
  buildItemClaimRiskState,
  extractRiskTagsFromText,
  filterRiskCardsByBusinessStage,
  resolveRiskTags,
  resolveRiskTagTone
} from '../src/views/scripts/travelRequestDetailInsights.js'
import {
  buildExpenseDraftIssues,
  buildExpenseItemViewModel,
  buildDraftBlockingIssues,
  rebuildExpenseItems,
  buildStandardAdjustmentMap,
  isApplicationDocumentRequest
} from '../src/views/scripts/travelRequestDetailExpenseModel.js'
import {
  buildEmployeeProfileAdviceItems,
  buildTravelReceiptMaterialPrompts
} from '../src/views/scripts/travelRequestDetailAdviceModel.js'
import {
  buildStandardAdjustmentPayload,
  filterSubmitterResolvedRiskCards,
  isRiskCardMissingExpenseNote
} from '../src/views/scripts/travelRequestDetailStandardAdjustment.js'

const detailViewTemplate = readFileSync(
  fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
  'utf8'
)
const detailViewComponentScript = readFileSync(
  fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
  'utf8'
)
const detailViewInsights = readFileSync(
  fileURLToPath(new URL('../src/views/scripts/travelRequestDetailInsights.js', import.meta.url)),
  'utf8'
)
const detailAiAdviceModelScript = readFileSync(
  fileURLToPath(new URL('../src/views/scripts/travelRequestDetailAiAdviceModel.js', import.meta.url)),
  'utf8'
)
const detailExpenseModelScript = readFileSync(
  fileURLToPath(new URL('../src/views/scripts/travelRequestDetailExpenseModel.js', import.meta.url)),
  'utf8'
)
const detailViewSetupScript = readFileSync(
  fileURLToPath(new URL('../src/views/scripts/travelRequestDetailSetup.js', import.meta.url)),
  'utf8'
)
const detailSmartEntryScript = readFileSync(
  fileURLToPath(new URL('../src/views/scripts/travelRequestDetailSmartEntryRecognition.js', import.meta.url)),
  'utf8'
)
const detailAttachmentPreviewScript = readFileSync(
  fileURLToPath(new URL('../src/views/scripts/useTravelRequestDetailAttachmentPreview.js', import.meta.url)),
  'utf8'
)
const detailExpenseEditorScript = readFileSync(
  fileURLToPath(new URL('../src/views/scripts/useTravelRequestDetailExpenseEditor.js', import.meta.url)),
  'utf8'
)
const detailRiskSubmitScript = readFileSync(
  fileURLToPath(new URL('../src/views/scripts/useTravelRequestDetailRiskSubmit.js', import.meta.url)),
  'utf8'
)
const detailApprovalFlowScript = readFileSync(
  fileURLToPath(new URL('../src/views/scripts/useTravelRequestDetailApprovalFlow.js', import.meta.url)),
  'utf8'
)
const detailEmployeeRiskProfileScript = readFileSync(
  fileURLToPath(new URL('../src/views/scripts/useTravelRequestEmployeeRiskProfile.js', import.meta.url)),
  'utf8'
)
const detailViewScript = [
  detailViewComponentScript,
  detailViewSetupScript,
  detailSmartEntryScript,
  detailAttachmentPreviewScript,
  detailExpenseEditorScript,
  detailRiskSubmitScript,
  detailApprovalFlowScript,
  detailEmployeeRiskProfileScript,
  detailExpenseModelScript
].join('\n')
const detailViewStyle = readFileSync(
  fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
  'utf8'
)
const requestsComposableScript = readFileSync(
  fileURLToPath(new URL('../src/composables/useRequests.js', import.meta.url)),
  'utf8'
)
const approvalCenterTemplate = readFileSync(
  fileURLToPath(new URL('../src/views/ApprovalCenterView.vue', import.meta.url)),
  'utf8'
)
const approvalCenterScript = readFileSync(
  fileURLToPath(new URL('../src/views/scripts/ApprovalCenterView.js', import.meta.url)),
  'utf8'
)
const returnReasonDialog = readFileSync(
  fileURLToPath(new URL('../src/components/shared/ReturnReasonDialog.vue', import.meta.url)),
  'utf8'
)
const stageRiskAdviceCard = readFileSync(
  fileURLToPath(new URL('../src/components/travel/StageRiskAdviceCard.vue', import.meta.url)),
  'utf8'
)
const stageRiskAdviceStyles = readFileSync(
  fileURLToPath(new URL('../src/assets/styles/components/stage-risk-advice-card.css', import.meta.url)),
  'utf8'
)
const detailHeroTemplate = readFileSync(
  fileURLToPath(new URL('../src/components/travel/TravelRequestDetailHero.vue', import.meta.url)),
  'utf8'
)
const relatedApplicationCardTemplate = readFileSync(
  fileURLToPath(new URL('../src/components/travel/TravelRequestRelatedApplicationCard.vue', import.meta.url)),
  'utf8'
)
const applicationFactsTemplate = readFileSync(
  fileURLToPath(new URL('../src/components/travel/TravelRequestApplicationFacts.vue', import.meta.url)),
  'utf8'
)
const applicationFactsStyle = readFileSync(
  fileURLToPath(new URL('../src/assets/styles/components/travel-request-application-facts.css', import.meta.url)),
  'utf8'
)
const progressCardTemplate = readFileSync(
  fileURLToPath(new URL('../src/components/travel/TravelRequestProgressCard.vue', import.meta.url)),
  'utf8'
)

const attachmentMeta = {
  file_name: 'taxi-invoice.pdf',
  media_type: 'application/pdf',
  previewable: true,
  document_info: {
    document_type: 'taxi_receipt',
    document_type_label: '出租车/网约车票据',
    fields: [
      { label: '金额', value: '121.54' },
      { label: '日期', value: '2026-03-04' }
    ]
  },
  requirement_check: {
    matches: false,
    message: '附件类型与当前费用项目不匹配。'
  },
  analysis: {
    severity: 'high',
    label: '高风险',
    headline: '票据类型不匹配',
    summary: '交通票据挂在办公用品费明细下。',
    points: ['票据识别为出租车/网约车票据', '当前费用项目为办公用品费'],
    suggestion: '把费用项目调整为交通费，或更换为办公用品票据。'
  }
}

test('attachment insight exposes recognition fields and rule basis', () => {
  const insight = buildAttachmentInsightViewModel(attachmentMeta, {
    name: '办公用品费',
    itemType: 'office'
  })

  assert.equal(insight.documentTypeLabel, '出租车/网约车票据')
  assert.equal(insight.requirementLabel, '不符合当前费用类型')
  assert.deepEqual(insight.fields, ['金额：121.54', '日期：2026-03-04'])
  assert.ok(insight.ruleBasis.some((item) => item.includes('附件类型与当前费用项目不匹配')))
})

test('AI advice card splits every attachment risk point with basis and suggestion', () => {
  const riskCards = buildAttachmentRiskCards({
    expenseItems: [
      {
        id: 'item-1',
        name: '办公用品费',
        invoiceId: 'taxi-invoice.pdf'
      }
    ],
    attachmentMetaByItemId: {
      'item-1': attachmentMeta
    }
  })
  const advice = buildAiAdviceViewModel({
    completionItems: [],
    riskCards
  })

  assert.equal(riskCards.length, 2)
  assert.equal(advice.badge, '优先整改')
  assert.equal(advice.riskCards.length, 2)
  assert.ok(advice.riskCards.every((card) => card.ruleBasis.length > 0))
  assert.ok(advice.riskCards.every((card) => card.suggestion.includes('费用项目调整为交通费')))
})

test('risk cards carry severity and domain tags for statistics', () => {
  const hotelRisk = {
    tone: 'high',
    title: '住宿超标待说明',
    risk: '住宿标准：北京酒店 800 元/晚超出报销标准。'
  }
  const trafficRisk = {
    tone: 'medium',
    title: '交通票据提醒',
    risk: '火车票说明格式待调整。'
  }

  assert.deepEqual(resolveRiskTags(hotelRisk), ['#high_risk', '#hotel'])
  assert.deepEqual(resolveRiskTags(trafficRisk), ['#middle_risk', '#traffic'])
  assert.equal(resolveRiskTagTone('#hotel'), 'hotel')
  assert.deepEqual(extractRiskTagsFromText('超标说明：#high_risk #hotel 原因'), ['#high_risk', '#hotel'])
})

test('AI advice splits claim attachment risk flags into specific points', () => {
  const riskCards = buildAttachmentRiskCards({
    claimRiskFlags: [
      {
        source: 'attachment_analysis',
        severity: 'medium',
        label: '中风险',
        message: '费用明细第 2 条：日期字段：未识别到开票日期。',
        summary: '当前附件可见部分内容，但金额、用途、日期或附件类型仍有缺失或不一致。',
        points: [
          '日期字段：未识别到开票日期或业务发生日期。',
          '金额字段：附件识别金额 300.00 元与报销金额 88.00 元不一致。'
        ]
      }
    ]
  })

  assert.equal(riskCards.length, 2)
  assert.equal(riskCards[0].risk, '日期字段：未识别到开票日期或业务发生日期。')
  assert.equal(riskCards[1].risk, '金额字段：附件识别金额 300.00 元与报销金额 88.00 元不一致。')
  assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('风险汇总')))
})

test('AI advice keeps visible risk flags when backend uses tone instead of severity', () => {
  const riskCards = buildAttachmentRiskCards({
    claimRiskFlags: [
      {
        source: 'submission_review',
        tone: 'medium',
        label: '中风险',
        message: '直属领导缺失，当前单据需审批环节补充分配。'
      }
    ]
  })

  assert.equal(riskCards.length, 1)
  assert.equal(riskCards[0].tone, 'medium')
  assert.equal(riskCards[0].risk, '直属领导缺失，当前单据需审批环节补充分配。')
  assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('审批链校验')))
  assert.ok(riskCards[0].suggestion.includes('员工档案'))
})

test('risk card badge only shows severity while title keeps business risk name', () => {
  const riskCards = buildAttachmentRiskCards({
    claimRiskFlags: [
      {
        source: 'attachment_analysis',
        severity: 'high',
        label: '票据日期超出差旅行程高风险',
        message: '酒店发票日期为 2 月，晚于已批准 6 月差旅行程范围。'
      }
    ]
  })

  assert.equal(riskCards.length, 1)
  assert.equal(riskCards[0].tone, 'high')
  assert.equal(riskCards[0].label, '高风险')
  assert.equal(riskCards[0].title, '票据日期超出差旅行程高风险')
})

test('AI advice falls back to claim risk summary instead of showing an empty risk area', () => {
  const riskCards = buildClaimSummaryRiskCards({
    riskSummary: '自动检测发现 1 条中风险附件，已随单流转给审批人复核。'
  })

  assert.equal(riskCards.length, 1)
  assert.equal(riskCards[0].businessStage, 'reimbursement')
  assert.equal(riskCards[0].tone, 'medium')
  assert.equal(riskCards[0].label, '中风险')
  assert.match(riskCards[0].risk, /中风险附件/)
  assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('风险汇总')))
  assert.ok(riskCards[0].suggestion.includes('附件预览'))
})

test('risk cards carry structured business stage for approval advice filtering', () => {
  const applicationCards = buildAttachmentRiskCards({
    businessStage: 'expense_application',
    claimRiskFlags: [
      {
        source: 'policy_review',
        severity: 'medium',
        label: '预算风险',
        message: '申请金额可能占用预算余额，需要预算管理者复核。'
      }
    ]
  })
  const reimbursementCards = buildAttachmentRiskCards({
    businessStage: 'expense_application',
    claimRiskFlags: [
      {
        source: 'attachment_analysis',
        business_stage: 'reimbursement',
        severity: 'high',
        label: '票据风险',
        message: '报销票据城市与行程城市不一致。'
      }
    ]
  })
  const legacyAttachmentCards = buildAttachmentRiskCards({
    businessStage: 'expense_application',
    claimRiskFlags: [
      {
        source: 'attachment_analysis',
        severity: 'medium',
        label: '附件风险',
        message: '差旅附件暂未识别到有效票据信息，请重新上传清晰附件或人工补录。'
      }
    ]
  })
  const summaryCards = buildClaimSummaryRiskCards({
    documentTypeCode: 'application',
    riskSummary: '预算余额不足，申请需要复核。'
  })
  const attachmentSummaryCards = buildClaimSummaryRiskCards({
    documentTypeCode: 'application',
    businessStage: 'expense_application',
    riskSummary: '差旅附件暂未识别到有效票据信息，请重新上传清晰附件或人工补录。'
  })

  assert.equal(applicationCards.length, 1)
  assert.equal(applicationCards[0].businessStage, 'expense_application')
  assert.equal(reimbursementCards.length, 1)
  assert.equal(reimbursementCards[0].businessStage, 'reimbursement')
  assert.equal(legacyAttachmentCards.length, 1)
  assert.equal(legacyAttachmentCards[0].businessStage, 'reimbursement')
  assert.deepEqual(
    filterRiskCardsByBusinessStage(
      [...applicationCards, ...reimbursementCards, ...legacyAttachmentCards],
      'expense_application'
    ).map((card) => card.risk),
    ['申请金额可能占用预算余额，需要预算管理者复核。']
  )
  assert.equal(summaryCards.length, 1)
  assert.equal(summaryCards[0].businessStage, 'expense_application')
  assert.equal(attachmentSummaryCards.length, 1)
  assert.equal(attachmentSummaryCards[0].businessStage, 'reimbursement')
  assert.deepEqual(filterRiskCardsByBusinessStage(attachmentSummaryCards, 'expense_application'), [])
})

test('stage risk advice card focuses on document risks without profile or budget boards', () => {
  assert.match(stageRiskAdviceCard, /employee-risk-decision-panel/)
  assert.match(stageRiskAdviceCard, /综合审核结论/)
  assert.match(stageRiskAdviceCard, /是否建议通过/)
  assert.match(stageRiskAdviceCard, /\{\{ decisionBadgeLabel \}\}/)
  assert.match(stageRiskAdviceCard, /employee-risk-review-summary/)
  assert.match(stageRiskAdviceCard, /reviewSummaryItems/)
  assert.match(stageRiskAdviceCard, /风险概览/)
  assert.match(stageRiskAdviceCard, /重点依据/)
  assert.match(stageRiskAdviceCard, /审核建议/)
  assert.match(stageRiskAdviceCard, /stageRiskFactSummary/)
  assert.match(stageRiskAdviceCard, /stageReviewBasisSummary/)
  assert.match(stageRiskAdviceCard, /compactEvidenceItems/)
  assert.match(stageRiskAdviceCard, /stageBasisTitle/)
  assert.match(stageRiskAdviceCard, /stageBasisHint/)
  assert.match(stageRiskAdviceCard, /employee-risk-profile-section/)
  assert.match(stageRiskAdviceCard, /employee-risk-profile-list/)
  assert.match(stageRiskAdviceCard, /<details/)
  assert.match(stageRiskAdviceCard, /申请审核建议/)
  assert.match(stageRiskAdviceCard, /AI建议/)
  assert.doesNotMatch(stageRiskAdviceCard, /报销审核建议/)
  assert.match(stageRiskAdviceCard, /classifyReimbursementRiskCards/)
  assert.match(stageRiskAdviceCard, /stripEmbeddedExplanationText/)
  assert.match(stageRiskAdviceCard, /if \(summary\) \{[\s\S]*return \[`已补充异常说明：\$\{summary\}`\]/)
  assert.match(stageRiskAdviceCard, /employee-risk-tone-pill/)
  assert.match(stageRiskAdviceStyles, /\.employee-risk-decision-panel \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) minmax\(320px, \.72fr\);/)
  assert.match(stageRiskAdviceStyles, /\.employee-risk-review-summary \{[\s\S]*display: grid;[\s\S]*grid-template-columns: repeat\(3, minmax\(0, 1fr\)\);/)
  assert.match(stageRiskAdviceStyles, /\.employee-risk-review-item \{[\s\S]*min-height: 66px;/)
  assert.match(stageRiskAdviceStyles, /\.employee-risk-profile-list \{[\s\S]*grid-template-columns: 1fr;/)
  assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row summary \{[\s\S]*cursor: pointer;/)
  assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-title \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) minmax\(72px, auto\) 48px;/)
  assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-title::after \{[\s\S]*content: '展开';/)
  assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row li \{[\s\S]*white-space: normal;/)
  assert.doesNotMatch(stageRiskAdviceStyles, /grid-row: span 2/)
  assert.match(stageRiskAdviceCard, /建议退回补充票据、行程说明或超标原因/)
  assert.match(stageRiskAdviceCard, /riskExplanationItems/)
  assert.match(stageRiskAdviceCard, /请核对已补充说明是否覆盖风险点/)
  assert.match(stageRiskAdviceCard, /已补充异常说明/)
  assert.match(stageRiskAdviceCard, /可按权限继续审批/)
  assert.match(stageRiskAdviceCard, /申请单关键依据/)
  assert.match(stageRiskAdviceCard, /报销单关键依据/)
  assert.doesNotMatch(stageRiskAdviceCard, /人员行为画像/)
  assert.doesNotMatch(stageRiskAdviceCard, /部门预算执行/)
  assert.doesNotMatch(stageRiskAdviceCard, /title: '说明与佐证'/)
  assert.doesNotMatch(stageRiskAdviceCard, /fetchExpenseClaimBudgetAnalysis/)
  assert.doesNotMatch(stageRiskAdviceCard, /reviewDimensionCards/)
  assert.doesNotMatch(stageRiskAdviceCard, /documentRiskMetrics/)
  assert.doesNotMatch(stageRiskAdviceCard, /profileAdviceItems/)
  assert.doesNotMatch(stageRiskAdviceCard, /profileContextItems/)
  assert.doesNotMatch(stageRiskAdviceCard, /画像风险/)
  assert.doesNotMatch(stageRiskAdviceCard, /退单\/补正/)
  assert.doesNotMatch(stageRiskAdviceCard, /材料质量/)
  assert.doesNotMatch(stageRiskAdviceCard, /申请人：/)
  assert.doesNotMatch(stageRiskAdviceCard, /部门\/岗位：/)
  assert.doesNotMatch(stageRiskAdviceCard, /budgetContextMetrics/)
  assert.doesNotMatch(stageRiskAdviceCard, /BUDGET_FIELD_KEYS/)
  assert.doesNotMatch(stageRiskAdviceCard, /预算池/)
  assert.doesNotMatch(stageRiskAdviceCard, /未匹配预算池/)
  assert.doesNotMatch(stageRiskAdviceCard, /科目未管控/)
  assert.doesNotMatch(stageRiskAdviceCard, /占用比例/)
  assert.doesNotMatch(stageRiskAdviceCard, /剩余比例/)
  assert.doesNotMatch(stageRiskAdviceCard, /超预算风险/)
  assert.doesNotMatch(stageRiskAdviceCard, /explanationContextMetrics/)
  assert.doesNotMatch(stageRiskAdviceCard, /employee-risk-context-grid/)
  assert.doesNotMatch(stageRiskAdviceCard, /class="employee-risk-action"/)
  assert.doesNotMatch(stageRiskAdviceStyles, /employee-risk-ai-note/)
})

test('AI advice ignores approval opinions and flow logs as risks', () => {
  const riskCards = buildAttachmentRiskCards({
    claimRiskFlags: [
      {
        source: 'manual_approval',
        severity: 'info',
        label: '领导审批通过',
        message: '同意'
      },
      {
        source: 'finance_approval',
        severity: 'info',
        label: '财务审核通过',
        message: '周晓彤 已完成财务审核，进入归档入账。'
      },
      {
        source: 'application_link_sync',
        event_type: 'expense_application_reimbursement_deleted',
        severity: 'warning',
        label: '关联报销单已删除',
        message: '关联报销单 RDELETE01 已删除，申请单已回到待关联状态。'
      }
    ]
  })

  assert.deepEqual(riskCards, [])
  assert.deepEqual(buildClaimSummaryRiskCards({ riskSummary: '同意' }), [])
  assert.deepEqual(buildClaimSummaryRiskCards({ riskSummary: '周晓彤 已完成财务审核，进入归档入账。' }), [])
})

test('expense row risk state falls back to claim item risk flags', () => {
  const state = buildItemClaimRiskState(
    {
      id: 'hotel-item',
      name: '住宿费'
    },
    [
      {
        source: 'attachment_analysis',
        item_id: 'hotel-item',
        severity: 'high',
        label: '高风险',
        message: '费用明细第 2 条：住宿标准：当前酒店识别金额约 880.00 元/晚。',
        summary: '当前住宿票据金额超过规则中心差旅住宿标准。',
        points: ['住宿标准：当前酒店识别金额约 880.00 元/晚。']
      }
    ]
  )

  assert.equal(state.tone, 'high')
  assert.equal(state.label, '高风险')
  assert.match(state.summary, /住宿票据金额超过/)
  assert.deepEqual(state.points, ['住宿标准：当前酒店识别金额约 880.00 元/晚。'])
})

test('attachment risk cards do not duplicate claim fallback flags for the same item', () => {
  const riskCards = buildAttachmentRiskCards({
    expenseItems: [
      {
        id: 'hotel-item',
        name: '住宿费',
        invoiceId: 'hotel-risk.png'
      }
    ],
    attachmentMetaByItemId: {
      'hotel-item': {
        analysis: {
          severity: 'high',
          label: '高风险',
          headline: 'AI提示：住宿金额超出报销标准',
          summary: '当前住宿票据金额超过规则中心差旅住宿标准。',
          points: ['住宿标准：当前酒店识别金额约 880.00 元/晚。'],
          suggestion: '请补充超标说明。'
        }
      }
    },
    claimRiskFlags: [
      {
        source: 'attachment_analysis',
        item_id: 'hotel-item',
        severity: 'high',
        label: '高风险',
        message: '费用明细第 1 条：住宿标准：当前酒店识别金额约 880.00 元/晚。',
        summary: '当前住宿票据金额超过规则中心差旅住宿标准。',
        points: ['住宿标准：当前酒店识别金额约 880.00 元/晚。']
      }
    ]
  })

  assert.equal(riskCards.length, 1)
  assert.equal(riskCards[0].risk, '住宿标准：当前酒店识别金额约 880.00 元/晚。')
})

test('AI advice hides generic auto review summaries when a specific hotel over-standard risk exists', () => {
  const riskCards = buildAttachmentRiskCards({
    expenseItems: [
      {
        id: 'hotel-over-standard-item',
        name: '住宿票',
        itemType: 'hotel_ticket',
        invoiceId: 'hotel-over-standard.png'
      }
    ],
    attachmentMetaByItemId: {
      'hotel-over-standard-item': {
        analysis: {
          severity: 'high',
          label: '高风险',
          headline: 'AI提示：住宿金额超出报销标准',
          summary: '当前住宿票据金额超过规则中心差旅住宿标准。',
          points: ['住宿标准：P5在上海的住宿标准为 250.00 元/晚，当前酒店识别金额约 362.00 元/晚。'],
          suggestion: '请补充超标说明。'
        }
      }
    },
    claimRiskFlags: [
      {
        source: 'submission_review',
        severity: 'high',
        label: '自动检测重点复核',
        message: '自动检测发现 1 条高风险附件，已随单流转给审批人重点复核。'
      },
      {
        source: 'submission_review',
        severity: 'high',
        label: '住宿超标待说明',
        message: 'P5 职级在上海的住宿标准为 250.00 元/晚，当前酒店识别金额约 362.00 元/晚。 当前未识别到超标说明，请先补充原因。'
      },
      {
        source: 'submission_review',
        severity: 'medium',
        label: '自动检测重点复核',
        message: '自动检测发现需审批重点关注事项：存在高风险票据，需审批人重点复核；住宿金额超出当前职级差标，且未补充超标说明。'
      }
    ]
  })

  assert.equal(riskCards.length, 1)
  assert.equal(riskCards[0].title, '住宿金额超出报销标准')
  assert.equal(riskCards[0].tone, 'high')
})

test('AI advice view model exposes grouped completion and risk sections', () => {
  const advice = buildAiAdviceViewModel({
    completionItems: ['补充业务地点', '补充报销金额'],
    riskCards: [
      {
        id: 'risk-1',
        tone: 'high',
        label: '高风险',
        title: '票据类型不匹配',
        risk: '交通票据挂在办公用品费明细下。',
        ruleBasis: ['附件类型与当前费用项目不匹配。'],
        suggestion: '把费用项目调整为交通费。'
      }
    ]
  })

  assert.equal(advice.sections.length, 2)
  assert.deepEqual(
    advice.sections.map((section) => ({ title: section.title, kind: section.kind })),
    [
      { title: '建议补充字段', kind: 'completion' },
      { title: '已知存在风险（1项）', kind: 'risk' }
    ]
  )
  assert.deepEqual(advice.sections[0].items, ['补充业务地点', '补充报销金额'])
  assert.equal(advice.sections[1].items.length, 1)
})

test('AI advice view model sorts and displays every risk card', () => {
  const riskCards = [
    { id: 'risk-1', tone: 'medium', label: '中风险', title: '中风险一', risk: '中风险一。' },
    { id: 'risk-2', tone: 'high', label: '高风险', title: '高风险一', risk: '高风险一。' },
    { id: 'risk-3', tone: 'medium', label: '中风险', title: '中风险二', risk: '中风险二。' },
    { id: 'risk-4', tone: 'high', label: '高风险', title: '高风险二', risk: '高风险二。' }
  ]
  const advice = buildAiAdviceViewModel({
    completionItems: [],
    riskCards
  })
  const riskSection = advice.sections.find((section) => section.kind === 'risk')

  assert.equal(advice.riskCards.length, 4)
  assert.equal(riskSection.items.length, 4)
  assert.equal(riskSection.hiddenCount, undefined)
  assert.deepEqual(riskSection.items.map((item) => item.id), ['risk-2', 'risk-4', 'risk-1', 'risk-3'])
})

test('AI advice hides lower severity duplicate route explanation risks', () => {
  const advice = buildAiAdviceViewModel({
    riskCards: [
      {
        id: 'route-high',
        tone: 'high',
        label: '高风险',
        title: '多城市行程待说明',
        risk: '检测到本次差旅涉及 深圳 多个目的地，但当前报销事由未说明中转、多地拜访或改签原因。',
        itemIds: ['train-transfer', 'train-transfer-return']
      },
      {
        id: 'route-medium',
        tone: 'medium',
        label: '中风险',
        title: '多城市行程缺少说明中风险',
        risk: '本次报销识别到多城市行程（上海、武汉、深圳），但事由中未说明中转、多地拜访或改签原因。',
        itemIds: ['train-transfer', 'train-transfer-return']
      },
      {
        id: 'hotel-high',
        tone: 'high',
        label: '高风险',
        title: '住宿金额超出报销标准',
        risk: '住宿金额超出当前职级报销标准。',
        itemIds: ['hotel-item']
      }
    ]
  })
  const riskSection = advice.sections.find((section) => section.kind === 'risk')

  assert.deepEqual(advice.riskCards.map((item) => item.id), ['route-high', 'hotel-high'])
  assert.deepEqual(riskSection.items.map((item) => item.id), ['route-high', 'hotel-high'])
  assert.equal(riskSection.totalCount, 2)
})

test('AI advice view model omits empty sections', () => {
  const readyAdvice = buildAiAdviceViewModel({
    completionItems: [],
    riskCards: []
  })
  const completionOnlyAdvice = buildAiAdviceViewModel({
    completionItems: ['补充业务地点'],
    riskCards: []
  })
  const riskOnlyAdvice = buildAiAdviceViewModel({
    completionItems: [],
    riskCards: [
      {
        id: 'risk-1',
        tone: 'medium',
        label: '中风险',
        title: '说明不完整',
        risk: '缺少业务背景。',
        ruleBasis: ['系统预审规则命中该风险提示。'],
        suggestion: '补充说明。'
      }
    ]
  })

  assert.deepEqual(readyAdvice.sections, [])
  assert.equal(readyAdvice.badge, '可以提交')
  assert.deepEqual(completionOnlyAdvice.sections.map((section) => section.title), ['建议补充字段'])
  assert.deepEqual(riskOnlyAdvice.sections.map((section) => section.title), ['已知存在风险（1项）'])
})

test('AI advice separates material prompts and profile advice from risk cards', () => {
  const advice = buildAiAdviceViewModel({
    completionItems: [],
    materialPrompts: ['当前包含 1 条住宿费用明细，但暂未关联住宿发票或酒店水单。'],
    profileAdviceItems: ['历史退单建议：近 90 天存在 1 次退单或退回记录。'],
    riskCards: []
  })

  assert.equal(advice.riskCards.length, 0)
  assert.equal(advice.badge, '建议关注')
  assert.deepEqual(
    advice.sections.map((section) => ({ kind: section.kind, title: section.title })),
    [
      { kind: 'material', title: '材料补充提示' },
      { kind: 'profile', title: '历史操作建议' }
    ]
  )
})

test('AI advice template renders grouped section titles with completion before risk', () => {
  assert.match(detailViewTemplate, /v-if="showAiAdvicePanel" class="detail-card panel validation-card"/)
  assert.match(detailViewTemplate, /<h3>\{\{ aiAdviceTitle \}\}<\/h3>/)
  assert.match(detailViewTemplate, /<p v-if="aiAdviceHint">\{\{ aiAdviceHint \}\}<\/p>/)
  assert.doesNotMatch(detailViewScript, /AI预审已完成，请按风险提示补充原因或进入下一步。/)
  assert.match(detailViewScript, /businessStage: currentBusinessStage/)
  assert.match(detailViewScript, /filterRiskCardsByBusinessStage/)
  assert.match(detailViewScript, /const summaryRiskCards = filterRiskCardsByBusinessStage/)
  assert.match(detailViewScript, /buildClaimSummaryRiskCards\(\{/)
  assert.match(detailViewScript, /const canViewApprovalRiskAdvice = computed/)
  assert.match(detailViewScript, /!isCurrentApplicant\.value/)
  assert.match(detailViewScript, /const hasVisibleRiskCards = computed/)
  assert.match(detailViewScript, /const showCompactSafeAdvice = computed/)
  assert.match(detailViewScript, /const showAiAdvicePanel = computed\(\(\) => \(/)
  assert.match(detailViewScript, /isCurrentApplicant\.value && hasVisibleRiskCards\.value/)
  assert.match(detailViewScript, /isApplicationDocument\.value \? '申请风险提示' : '风险提示'/)
  assert.match(detailViewScript, /return isEditableRequest\.value \? 'AI建议' : '风险提示'/)
  assert.doesNotMatch(detailViewScript, /return '报销风险提示'/)
  assert.match(detailViewScript, /canViewApprovalRiskAdvice\.value && aiAdvice\.value\.riskCards\.length > 0/)
  assert.match(detailViewScript, /buildTravelReceiptMaterialPrompts\(request\.value, expenseItems\.value\)/)
  assert.match(detailViewScript, /buildEmployeeProfileAdviceItems\(employeeRiskProfile\.value\)/)
  assert.match(detailViewScript, /fetchEmployeeLatestProfile\(employeeId/)
  assert.doesNotMatch(detailViewScript, /hasAiPreReviewResult\.value/)
  assert.match(detailViewTemplate, /v-if="aiAdvice\.sections\.length" class="validation-sections"/)
  assert.match(detailViewTemplate, /v-for="section in aiAdvice\.sections"/)
  assert.match(detailViewTemplate, /validation-section--\$\{section\.kind\}/)
  assert.match(detailViewTemplate, /<h4 class="validation-section-title">\{\{ section\.title \}\}<\/h4>/)
  assert.match(detailViewTemplate, /v-if="section\.kind !== 'risk'" class="validation-list"/)
  assert.match(detailViewTemplate, /v-else class="risk-advice-list"/)
  assert.ok(
    detailViewTemplate.indexOf("section.kind !== 'risk'") < detailViewTemplate.indexOf('risk-advice-card')
  )
})

test('AI advice risk section keeps compact risk prompt styling', () => {
  assert.match(detailViewTemplate, /class="\['risk-advice-card', card\.tone, \{ 'is-highlighted': isHighlightedRiskCard\(card\) \}\]"/)
  assert.match(detailViewTemplate, /class="risk-advice-point"/)
  assert.match(detailViewTemplate, /class="risk-advice-compact-meta"/)
  assert.match(detailViewTemplate, /\{\{ card\.ruleBasis\[0\] \}\}/)
  assert.doesNotMatch(detailViewTemplate, /risk-advice-detail-grid/)
  assert.doesNotMatch(detailViewTemplate, /<dt>风险事实<\/dt>/)
  assert.doesNotMatch(detailViewTemplate, /section\.hiddenCount/)
  assert.doesNotMatch(detailViewTemplate, /risk-advice-more/)
  assert.doesNotMatch(detailViewTemplate, /card\.tags\?\.length/)
  assert.doesNotMatch(detailViewTemplate, /risk-card-tag-list/)
  assert.doesNotMatch(detailViewTemplate, /risk-note-tag/)
  assert.match(detailViewScript, /tags: resolveRiskTags\(card\)/)
  assert.match(detailAiAdviceModelScript, /const sortedRiskCards = sortRiskCardsByTone\(normalizedRiskCards\)/)
  assert.doesNotMatch(detailViewInsights, /visibleRiskCards/)
  assert.doesNotMatch(detailViewInsights, /hiddenCount/)
  assert.match(detailViewStyle, /\.validation-card \{\s*border: 1px solid #e5e7eb;/)
  assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-list \{\s*display: grid;\s*gap: 8px;\s*max-height: 360px;/)
  assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card \{\s*position: relative;\s*display: grid;\s*grid-template-columns: minmax\(0, 1\.1fr\) minmax\(220px, \.9fr\);/)
  assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card\.low/)
  assert.match(detailViewStyle, /\.risk-advice-card\.low/)
  assert.doesNotMatch(detailViewStyle, /\.risk-note-tag/)
  assert.match(detailViewStyle, /\.risk-advice-compact-meta span,\s*\.risk-advice-compact-meta em \{\s*margin: 0;/)
  assert.doesNotMatch(detailViewStyle, /\.risk-advice-detail-grid/)
  assert.doesNotMatch(detailViewStyle, /\.risk-advice-more/)
})

test('expense rows show a major-risk warning icon before time', () => {
  assert.match(detailViewTemplate, /'has-major-risk': hasExpenseRiskIndicator\(item\)/)
  assert.match(detailViewTemplate, /class="expense-time-content"/)
  assert.match(detailViewTemplate, /class="expense-risk-indicator"/)
  assert.match(detailViewTemplate, /class="expense-risk-indicator-placeholder"/)
  assert.match(detailViewTemplate, /@click="focusExpenseRisk\(item\)"/)
  assert.match(detailViewStyle, /\.expense-time-content \{/)
  assert.match(detailViewStyle, /\.expense-risk-indicator \{/)
  assert.match(detailViewStyle, /\.expense-risk-indicator,\s*\.expense-risk-indicator-placeholder \{/)
  assert.match(detailViewScript, /function hasExpenseRiskIndicator\(item\)/)
  assert.match(detailViewScript, /buildItemClaimRiskState\(item, resolveClaimRiskFlags\(\)\)/)
})

test('expense risk indicator can focus and flash related risk card', () => {
  assert.match(detailViewTemplate, /:id="resolveRiskCardDomId\(card\)"/)
  assert.match(detailViewTemplate, /:data-risk-card-id="card\.id"/)
  assert.match(detailViewTemplate, /'is-highlighted': isHighlightedRiskCard\(card\)/)
  assert.match(detailViewScript, /async function focusExpenseRisk\(item\)/)
  assert.match(detailViewScript, /document\.getElementById\(resolveRiskCardDomId\(card\)\)/)
  assert.match(detailViewScript, /scrollIntoView\(\{ behavior: 'smooth', block: 'nearest', inline: 'nearest' \}\)/)
  assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card\.is-highlighted/)
  assert.match(detailViewStyle, /@keyframes risk-card-flash/)
})

test('route-level risk cards keep related item ids for every affected expense row', () => {
  const riskCards = buildAttachmentRiskCards({
    expenseItems: [
      { id: 'travel-item-1', name: '火车票', category: '火车票' },
      { id: 'travel-item-2', name: '火车票', category: '火车票' },
      { id: 'travel-item-3', name: '火车票', category: '火车票' }
    ],
    claimRiskFlags: [
      {
        source: 'submission_review',
        severity: 'high',
        label: '多城市行程待说明',
        message: '检测到本次差旅涉及 深圳 多个目的地，但当前报销事由未说明中转、多地拜访或改签原因。',
        item_ids: ['travel-item-2', 'travel-item-3']
      }
    ]
  })

  assert.equal(riskCards.length, 1)
  assert.deepEqual(riskCards[0].itemIds, ['travel-item-2', 'travel-item-3'])
  assert.equal(riskCards[0].title, '多城市行程待说明')
  assert.match(detailViewScript, /cardItemIds\.includes\(itemId\)/)
})

test('claim risk cards expose related expense explanations to reviewers', () => {
  const riskCards = buildAttachmentRiskCards({
    expenseItems: [
      {
        id: 'hotel-row',
        name: '住宿票',
        desc: '上海喜来登酒店',
        itemNote: '时间紧，没有合适的酒店，只能住宿超过金额的酒店。'
      },
      {
        id: 'route-extra-out',
        name: '火车票',
        desc: '上海-深圳',
        itemNote: '中间去深圳，公司要求。'
      },
      {
        id: 'route-extra-back',
        name: '火车票',
        desc: '深圳-上海',
        itemNote: '中间去深圳，公司要求。'
      }
    ],
    claimRiskFlags: [
      {
        source: 'submission_review',
        severity: 'high',
        label: '多城市行程待说明',
        message: '检测到本次差旅涉及 深圳 多个目的地，但当前报销事由未说明中转、多地拜访或改签原因。',
        item_ids: ['route-extra-out', 'route-extra-back']
      }
    ]
  })

  assert.equal(riskCards.length, 1)
  assert.deepEqual(riskCards[0].itemIds, ['route-extra-out', 'route-extra-back'])
  assert.match(riskCards[0].risk, /用户已在相关费用明细补充异常说明/)
  assert.doesNotMatch(riskCards[0].risk, /未说明/)
  assert.match(riskCards[0].suggestion, /用户已在费用明细补充异常说明/)
  assert.match(riskCards[0].suggestion, /上海-深圳：中间去深圳，公司要求/)
  assert.match(riskCards[0].relatedExplanationSummary, /深圳-上海：中间去深圳，公司要求/)
  assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('用户已补充异常说明')))
})

test('claim risk cards infer hotel explanations when risk flag has no item ids', () => {
  const riskCards = buildAttachmentRiskCards({
    expenseItems: [
      {
        id: 'hotel-row',
        name: '住宿票',
        desc: '上海喜来登酒店',
        itemType: 'hotel_ticket',
        itemNote: '时间紧，没有合适的酒店，只能住宿超过金额的酒店。'
      },
      {
        id: 'route-row',
        name: '火车票',
        desc: '上海-深圳',
        itemType: 'train_ticket',
        itemNote: '中间去深圳，公司要求。'
      }
    ],
    claimRiskFlags: [
      {
        source: 'submission_review',
        severity: 'high',
        label: '住宿金额超出报销标准',
        message: '住宿标准：P5在上海的住宿标准为 250.00 元/晚，票据识别金额 1086.00 元 / 3 晚，约 362.00 元/晚，超出 112.00 元/晚。'
      }
    ]
  })

  assert.equal(riskCards.length, 1)
  assert.deepEqual(riskCards[0].itemIds, ['hotel-row'])
  assert.match(riskCards[0].relatedExplanationSummary, /上海喜来登酒店：时间紧，没有合适的酒店/)
  assert.doesNotMatch(riskCards[0].relatedExplanationSummary, /上海-深圳/)
  assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('用户已补充异常说明')))
})

test('legacy route-level risk cards infer affected travel rows when backend has no item ids', () => {
  const riskCards = buildAttachmentRiskCards({
    expenseItems: [
      {
        id: 'train-outbound',
        name: '火车票',
        category: '火车票',
        desc: '武汉-上海',
        detail: '起始地-目的地',
        invoiceId: 'outbound.png'
      },
      {
        id: 'train-transfer',
        name: '火车票',
        category: '火车票',
        desc: '上海-深圳',
        detail: '起始地-目的地',
        invoiceId: 'transfer.png'
      },
      {
        id: 'train-transfer-return',
        name: '火车票',
        category: '火车票',
        desc: '深圳-上海',
        detail: '起始地-目的地',
        invoiceId: 'transfer-return.png'
      },
      {
        id: 'train-return',
        name: '火车票',
        category: '火车票',
        desc: '上海-武汉',
        detail: '起始地-目的地',
        invoiceId: 'return.png'
      },
      {
        id: 'allowance-row',
        name: '出差补贴',
        category: '出差补贴',
        desc: '系统自动计算',
        detail: '直辖市/特区'
      }
    ],
    claimRiskFlags: [
      {
        source: 'submission_review',
        severity: 'high',
        label: '多城市行程待说明',
        message: '检测到本次差旅涉及 深圳 多个目的地，但当前报销事由未说明中转、多地拜访或改签原因。'
      }
    ]
  })

  assert.equal(riskCards.length, 1)
  assert.deepEqual(riskCards[0].itemIds, ['train-transfer', 'train-transfer-return'])
})

test('route-level risk cards narrow broad backend item ids to abnormal route rows', () => {
  const expenseItems = [
    {
      id: 'train-outbound',
      name: '火车票',
      category: '火车票',
      desc: '武汉-上海',
      detail: '起始地-目的地',
      invoiceId: 'outbound.png'
    },
    {
      id: 'train-transfer',
      name: '火车票',
      category: '火车票',
      desc: '上海-深圳',
      detail: '起始地-目的地',
      invoiceId: 'transfer.png'
    },
    {
      id: 'train-transfer-return',
      name: '火车票',
      category: '火车票',
      desc: '深圳-上海',
      detail: '起始地-目的地',
      invoiceId: 'transfer-return.png'
    },
    {
      id: 'train-return',
      name: '火车票',
      category: '火车票',
      desc: '上海-武汉',
      detail: '起始地-目的地',
      invoiceId: 'return.png'
    }
  ]
  const riskCards = buildAttachmentRiskCards({
    expenseItems,
    claimRiskFlags: [
      {
        source: 'submission_review',
        severity: 'high',
        label: '多城市行程待说明',
        message: '本次报销识别到多城市行程（上海、武汉、深圳），但事由中未说明中转、多地拜访或改签原因。',
        item_ids: expenseItems.map((item) => item.id)
      }
    ]
  })

  assert.equal(riskCards.length, 1)
  assert.deepEqual(riskCards[0].itemIds, ['train-transfer', 'train-transfer-return'])
})

test('AI advice shows only the latest manual return while preserving return count context', () => {
  const riskCards = buildAttachmentRiskCards({
    claimRiskFlags: [
      {
        source: 'manual_return',
        severity: 'medium',
        label: '人工退回',
        message: '第一次退回：缺少附件。',
        reason: '缺少附件。',
        return_count: 1,
        return_stage: '直属领导审批',
        risk_points: ['附件缺失或不清晰']
      },
      {
        source: 'manual_return',
        severity: 'medium',
        label: '人工退回',
        message: '第二次退回：超标说明不完整。',
        reason: '超标说明不完整。',
        return_count: 2,
        return_stage: '财务审批',
        risk_points: ['超出制度标准或缺少超标说明']
      }
    ]
  })

  assert.equal(riskCards.length, 1)
  assert.equal(riskCards[0].risk, '第二次退回：超标说明不完整。')
  assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('累计退回 2 次')))
  assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('财务审批')))
})

test('expense attachment actions keep preview as the only recognition entry point', () => {
  assert.match(detailViewTemplate, /:aria-label="resolveAttachmentPreviewTitle\(item\)"/)
  assert.match(detailViewScript, /return fileName \? `预览附件：\$\{fileName\}` : '预览附件'/)
  assert.match(detailViewScript, /\.filter\(\(item\) => canPreviewAttachment\(item\)\)/)
  assert.match(detailViewScript, /function hasStoredAttachmentReference\(item\) \{[\s\S]*return String\(item\?\.invoiceId \|\| ''\)\.includes\('\/'\)/)
  assert.match(detailViewScript, /if \(metadata\) \{[\s\S]*return metadata\.previewable !== false[\s\S]*return true/)
  assert.match(detailViewScript, /原件尚未保存到单据中，请重新上传后预览/)
  assert.doesNotMatch(detailViewTemplate, /aria-label="识别附件"/)
  assert.doesNotMatch(detailViewTemplate, /点击识别按钮/)
  assert.doesNotMatch(detailViewScript, /recognizeExpenseAttachment/)
  assert.doesNotMatch(detailViewScript, /recognizingExpenseId/)
})

test('expense detail table shows the amount total below detail rows', () => {
  assert.match(detailViewTemplate, /<div[^>]*class="detail-expense-table"/)
  assert.match(detailViewTemplate, /当前还没有费用明细/)
  assert.match(detailViewTemplate, /通过智能录入上传票据后由系统自动归集/)
  assert.doesNotMatch(detailViewTemplate, /增加明细/)
  assert.doesNotMatch(detailViewTemplate, /handleAddExpenseItem/)
  assert.doesNotMatch(detailViewScript, /handleAddExpenseItem/)
  assert.doesNotMatch(detailViewTemplate, /class="total-row"/)
  assert.match(detailViewTemplate, /class="expense-total-under-table"[\s\S]*金额合计[\s\S]*\{\{ expenseTotal \}\}/)
  assert.doesNotMatch(detailViewTemplate, /\{\{ uploadedExpenseCount \}\} 项已关联票据/)
  assert.doesNotMatch(detailViewTemplate, /\{\{ expenseSummaryText \}\}/)
})

test('related application information is shown above expense details for reimbursement check', () => {
  assert.ok(
    detailViewTemplate.indexOf('<TravelRequestRelatedApplicationCard')
      < detailViewTemplate.indexOf("isApplicationDocument ? '申请详情' : '费用明细'")
  )
  assert.match(relatedApplicationCardTemplate, /<article v-if="!isApplicationDocument" class="detail-card panel">/)
  assert.match(relatedApplicationCardTemplate, /<h3>关联单据信息<\/h3>/)
  assert.match(relatedApplicationCardTemplate, /展示本次报销关联的前置申请/)
  assert.match(relatedApplicationCardTemplate, /relatedApplicationFactItems/)
  assert.match(relatedApplicationCardTemplate, /暂未识别到关联申请单/)
  assert.match(detailViewScript, /buildRelatedApplicationFactItems/)
  assert.match(detailExpenseModelScript, /label:\s*'关联单据'/)
  assert.match(detailExpenseModelScript, /label:\s*'已归档'/)
  assert.match(detailViewStyle, /\.related-application-empty/)
  assert.doesNotMatch(detailViewTemplate, /v-if="canEditDetailNote" class="detail-note-editor"/)
  assert.doesNotMatch(detailViewTemplate, /v-model="detailNoteEditorView"/)
})

test('split detail page header cards keep their scoped styles', () => {
  assert.match(
    detailHeroTemplate,
    /<style scoped src="\.\.\/\.\.\/assets\/styles\/components\/travel-request-detail-hero\.css"><\/style>/
  )
  assert.match(
    progressCardTemplate,
    /<style scoped src="\.\.\/\.\.\/assets\/styles\/components\/travel-request-progress-card\.css"><\/style>/
  )
  assert.match(
    relatedApplicationCardTemplate,
    /<style scoped src="\.\.\/\.\.\/assets\/styles\/components\/travel-request-related-application-card\.css"><\/style>/
  )
})

test('detail note model is retained for risk override persistence', () => {
  assert.match(detailViewScript, /const canEditDetailNote = computed\(\(\) => isDraftRequest\.value\)/)
  assert.match(detailViewScript, /function normalizeDetailNoteDraftValue\(value\)/)
  assert.match(detailViewScript, /function stripRiskTagsForDisplay\(value\)/)
  assert.match(detailViewScript, /function mergeVisibleNoteWithHiddenTags\(visibleText, rawText\)/)
  assert.match(detailViewScript, /const detailNoteSource = computed\(\(\) => normalizeDetailNoteDraftValue\(request\.value\.note\)\)/)
  assert.match(detailViewScript, /updateExpenseClaim\(request\.value\.claimId/)
  assert.match(detailViewScript, /emit\('request-updated', \{ claimId: request\.value\.claimId \}\)/)
  assert.match(detailViewScript, /暂无附加说明。请补充本次出差或办事事由/)
  assert.match(detailViewScript, /去北京客户现场出差，拜访 XX 客户并处理项目验收事项/)
  assert.match(detailViewStyle, /\.detail-note-editor textarea/)
  assert.match(detailViewStyle, /\.detail-note\.readonly/)
})

test('ticket item types and system allowance row are visible but read only', () => {
  assert.match(detailViewScript, /value: 'train_ticket', label: '火车票'/)
  assert.match(detailViewScript, /value: 'flight_ticket', label: '机票'/)
  assert.match(detailViewScript, /value: 'hotel_ticket', label: '住宿票'/)
  assert.match(detailViewScript, /value: 'ride_ticket', label: '乘车'/)
  assert.match(detailViewScript, /value: 'travel_allowance', label: '出差补贴'/)
  assert.match(detailViewScript, /const SYSTEM_GENERATED_EXPENSE_TYPES = new Set\(\['travel_allowance'\]\)/)
  assert.match(detailExpenseModelScript, /const OPTIONAL_ATTACHMENT_EXPENSE_TYPES = new Set\(\['ride_ticket', 'travel_allowance'\]\)/)
  assert.match(detailViewTemplate, /'system-generated-row': item\.isSystemGenerated/)
  assert.match(detailViewTemplate, /v-if="item\.isSystemGenerated" class="system-row-lock"/)
  assert.match(detailViewTemplate, /v-else-if="item\.isSystemGenerated" class="system-attachment-note"/)
  assert.match(detailViewScript, /系统自动计算的补贴行不能手动编辑/)
  assert.match(detailViewScript, /系统自动计算的补贴行不能删除/)
})

test('expense item rebuild hides empty placeholders but keeps generated allowance row', () => {
  const items = rebuildExpenseItems(
    [
      {
        id: 'hotel-uploaded',
        itemType: 'hotel_ticket',
        itemDate: '2026-02-20',
        itemReason: '上海喜来登酒店',
        itemLocation: '上海',
        itemAmount: 1086,
        invoiceId: 'claim-1/hotel-uploaded/hotel-invoice.png'
      },
      {
        id: 'empty-travel-placeholder',
        itemType: 'travel',
        itemDate: '2026-02-23',
        itemReason: '',
        itemLocation: '',
        itemAmount: 0,
        invoiceId: ''
      },
      {
        id: 'allowance',
        itemType: 'travel_allowance',
        itemDate: '2026-02-23',
        itemReason: '系统自动计算出差补贴：上海，4天，100.00元/天',
        itemLocation: '直辖市/特区',
        itemAmount: 400,
        invoiceId: ''
      }
    ],
    { typeCode: 'travel', detailVariant: 'travel' }
  )

  assert.deepEqual(items.map((item) => item.id), ['hotel-uploaded', 'allowance'])
  assert.equal(items[1].isSystemGenerated, true)
  assert.equal(items[1].attachmentStatus, '无需附件')
})

test('travel item date caption distinguishes departure return and trip events', () => {
  assert.match(detailViewTemplate, /<span>\{\{ item\.dayLabel \}\}<\/span>/)
  assert.match(detailViewScript, /const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set\(\['train_ticket', 'flight_ticket'\]\)/)
  assert.match(detailViewScript, /function buildTravelTimeLabelMap\(items, requestModel\)/)
  assert.match(detailViewScript, /labels\.set\(item\.id, '出发时间'\)/)
  assert.match(detailViewScript, /labels\.set\(item\.id, '返回时间'\)/)
  assert.match(detailViewScript, /return '乘车时间'/)
  assert.match(detailViewScript, /return '住宿时间'/)
  assert.doesNotMatch(detailViewScript, /第 \$\{index \+ 1\} 项/)
})

test('expense detail table shows each item filled time from item creation time', () => {
  assert.match(detailViewTemplate, /<th class="col-filled-at">填写时间<\/th>[\s\S]*<th class="col-time">发生时间<\/th>/)
  assert.match(detailViewTemplate, /<td class="expense-filled-at col-filled-at">[\s\S]*\{\{ item\.filledAt \}\}[\s\S]*<td :class="\['expense-time col-time'/)
  assert.match(detailViewTemplate, /<span>条款填写时间<\/span>/)
  assert.match(detailViewScript, /function formatExpenseFilledTime\(value\)/)
  assert.match(detailViewScript, /source\?\.filledAt[\s\S]*source\?\.created_at/)
  assert.match(detailViewScript, /expenseTableColumnCount = computed\(\s*\(\) => 7 \+ \(isEditableRequest\.value \? 1 : 0\)/)
})

test('expense detail table has per-item risk explanation column', () => {
  assert.match(detailViewTemplate, /<th class="col-risk-note">异常说明<\/th>/)
  assert.match(detailViewTemplate, /<EnterpriseSelect[\s\S]*class="editor-select"/)
  assert.match(detailViewTemplate, /<ElDatePicker[\s\S]*v-model="expenseEditor\.itemDate"/)
  assert.match(detailViewTemplate, /<ElInput[\s\S]*v-model="expenseEditor\.itemReason"/)
  assert.match(detailViewTemplate, /<ElInput[\s\S]*v-model="expenseEditor\.itemAmount"/)
  assert.match(detailViewTemplate, /<ElInput[\s\S]*v-model="expenseEditor\.itemNote"[\s\S]*type="textarea"[\s\S]*:rows="1"/)
  assert.doesNotMatch(detailViewTemplate, /<input[\s\S]*v-model="expenseEditor\./)
  assert.doesNotMatch(detailViewTemplate, /<textarea[\s\S]*v-model="expenseEditor\./)
  assert.match(detailViewScript, /import \{ ElDatePicker \} from 'element-plus\/es\/components\/date-picker\/index\.mjs'/)
  assert.match(detailViewScript, /import \{ ElInput \} from 'element-plus\/es\/components\/input\/index\.mjs'/)
  assert.match(detailViewScript, /ElDatePicker,[\s\S]*ElInput,/)
  assert.match(detailViewTemplate, /v-model="expenseEditor\.itemNote"/)
  assert.doesNotMatch(detailViewTemplate, /@input="resizeExpenseNoteInput"/)
  assert.doesNotMatch(detailViewTemplate, /用于说明改签、绕行、超标、票据异常等情况/)
  assert.match(detailViewStyle, /\.detail-expense-table \.col-type \{ width: 14%; \}/)
  assert.match(detailViewStyle, /\.detail-expense-table \.col-attachment \{ width: 15%; \}/)
  assert.match(detailViewStyle, /\.detail-expense-table \{[\s\S]*--expense-editor-control-height: 34px;[\s\S]*--expense-editor-control-line-height: 16px;/)
  assert.match(detailViewStyle, /\.editor-control \{/)
  assert.match(detailViewStyle, /\.editor-control:not\(\.risk-note-editor-input\),[\s\S]*\.editor-select \{[\s\S]*height: var\(--expense-editor-control-height\);/)
  assert.match(detailViewStyle, /\.editor-control :deep\(\.el-input__wrapper\),[\s\S]*\.editor-date-picker :deep\(\.el-input__wrapper\) \{/)
  assert.match(detailViewStyle, /\.editor-control :deep\(\.el-input__wrapper\),[\s\S]*height: var\(--expense-editor-control-height\);[\s\S]*line-height: var\(--expense-editor-control-line-height\);/)
  assert.match(detailViewStyle, /\.editor-control :deep\(\.el-input__inner\),[\s\S]*height: var\(--expense-editor-control-line-height\)( !important)?;[\s\S]*line-height: var\(--expense-editor-control-line-height\)( !important)?;/)
  assert.match(detailViewStyle, /\.editor-control :deep\(\.el-input__prefix\),[\s\S]*height: var\(--expense-editor-control-height\);/)
  assert.doesNotMatch(detailViewStyle, /\.editor-input,\s*\.editor-select,\s*\.editor-textarea \{/)
  assert.match(detailViewStyle, /\.editor-select \{[\s\S]*padding: 0;[\s\S]*border: 0;/)
  assert.match(detailViewStyle, /\.editor-select :deep\(\.el-select__wrapper\) \{[\s\S]*min-height: var\(--expense-editor-control-height\);[\s\S]*height: var\(--expense-editor-control-height\);/)
  assert.match(detailViewStyle, /\.risk-note-editor-input\.el-textarea \{[\s\S]*min-height: var\(--expense-editor-control-height\);[\s\S]*height: var\(--expense-editor-control-height\);/)
  assert.match(detailViewStyle, /\.risk-note-editor-input :deep\(\.el-textarea__inner\) \{[\s\S]*min-height: var\(--expense-editor-control-height\) !important;[\s\S]*height: var\(--expense-editor-control-height\);[\s\S]*line-height: var\(--expense-editor-control-line-height\)( !important)?;[\s\S]*max-height: calc\(var\(--expense-editor-control-height\) \+ var\(--expense-editor-control-line-height\) \* 2\)( !important)?;[\s\S]*resize: none( !important)?;/)
  assert.doesNotMatch(detailViewScript, /resizeExpenseNoteInput/)
  assert.doesNotMatch(detailViewScript, /scrollHeight/)
  assert.match(detailViewTemplate, /hasExpenseRiskOrAbnormal\(item\)[\s\S]*待补充异常说明/)
  assert.match(detailViewScript, /itemNote: ''/)
  assert.match(detailViewScript, /expenseEditor\.itemNote = item\.itemNote \|\| ''/)
  assert.match(detailViewScript, /item_note: expenseEditor\.itemNote\.trim\(\)/)
  assert.match(detailViewScript, /itemNote: expenseEditor\.itemNote\.trim\(\)/)
  assert.match(detailViewScript, /function hasExpenseRiskOrAbnormal\(item\)/)
  assert.match(detailExpenseModelScript, /const itemNote = String\(source\?\.itemNote \?\? source\?\.item_note \?\? ''\)\.trim\(\)/)
})

test('expense detail shows standard-adjusted reimbursable amount separately from receipt amount', () => {
  assert.match(detailViewTemplate, /v-if="item\.hasStandardAdjustment" class="expense-adjusted-amount"/)
  assert.match(detailViewTemplate, /class="expense-original-amount"[\s\S]*item\.originalAmountDisplay/)
  assert.match(detailViewTemplate, /class="expense-reimbursable-label"[\s\S]*职级测算/)
  assert.match(detailViewTemplate, /class="expense-reimbursable-amount"[\s\S]*item\.reimbursableAmountDisplay/)
  assert.match(detailViewTemplate, /submitConfirmAmountDisplay/)
  assert.match(detailViewStyle, /\.expense-original-amount[\s\S]*text-decoration-line: line-through/)
  assert.match(detailViewScript, /const expenseTotal = computed\(\(\) => \{[\s\S]*item\.reimbursableAmount/)
  assert.match(detailViewScript, /filterSubmitterResolvedRiskCards/)
  assert.doesNotMatch(detailViewScript, /filterSubmitterStandardAdjustedRiskCards/)
  assert.match(detailViewScript, /acceptExpenseClaimStandardAdjustment/)
  assert.match(detailExpenseModelScript, /STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment'/)

  const riskFlags = [
    {
      source: 'reimbursement_standard_adjustment',
      item_id: 'expense-item-1',
      original_amount: '880.00',
      reimbursable_amount: '600.00',
      employee_absorbed_amount: '280.00'
    }
  ]
  const adjustmentMap = buildStandardAdjustmentMap({ riskFlags })
  assert.equal(adjustmentMap.get('expense-item-1').reimbursableAmount, 600)

  const item = buildExpenseItemViewModel(
    {
      id: 'expense-item-1',
      itemType: 'hotel_ticket',
      itemReason: '北京住宿',
      itemAmount: 880,
      invoiceId: 'hotel.pdf'
    },
    0,
    { riskFlags }
  )

  assert.equal(item.itemAmount, 880)
  assert.equal(item.reimbursableAmount, 600)
  assert.equal(item.employeeAbsorbedAmount, 280)
  assert.equal(item.hasStandardAdjustment, true)
})

test('standard adjustment payload carries application days for policy recalculation', async () => {
  const payload = await buildStandardAdjustmentPayload({
    warnings: [
      {
        id: 'risk-hotel-days-1',
        itemId: 'expense-item-days-1',
        title: '住宿超标待说明',
        risk: '住宿票据金额超过职级标准。'
      }
    ],
    expenseItems: [
      {
        id: 'expense-item-days-1',
        itemType: 'hotel_ticket',
        itemLocation: '北京',
        itemAmount: 2000
      }
    ],
    request: {
      relatedApplication: { days: '4天' },
      employeeGrade: 'P4',
      location: '北京'
    },
    calculateTravelReimbursement: async (query) => {
      assert.equal(query.days, 4)
      assert.equal(query.location, '北京')
      assert.equal(query.grade, 'P4')
      return { hotel_amount: 1800 }
    }
  })

  assert.equal(payload.risks.length, 1)
  assert.equal(payload.risks[0].application_days, 4)
  assert.equal(payload.risks[0].original_amount, 2000)
  assert.equal(payload.risks[0].reimbursable_amount, 1800)
})

test('plain reimbursable amount does not mark an item as standard-adjusted during detail rebuild', () => {
  const item = buildExpenseItemViewModel(
    {
      id: 'hotel-risk-item',
      itemType: 'hotel_ticket',
      itemReason: '上海住宿',
      itemAmount: 1086,
      reimbursableAmount: 1086,
      originalItemAmount: 1086,
      invoiceId: 'hotel-risk.jpg',
      standardAdjustmentAccepted: false,
      hasStandardAdjustment: false
    },
    0,
    { riskFlags: [] }
  )

  assert.equal(item.standardAdjustmentAccepted, false)
  assert.equal(item.hasStandardAdjustment, false)
  assert.equal(item.reimbursableAmount, 1086)

  const rebuiltItems = rebuildExpenseItems([item], { riskFlags: [] })
  assert.equal(rebuiltItems[0].standardAdjustmentAccepted, false)
  assert.equal(rebuiltItems[0].hasStandardAdjustment, false)

  const riskCards = [
    {
      id: 'hotel-risk',
      source: 'attachment_analysis',
      itemId: 'hotel-risk-item',
      tone: 'high',
      risk: '住宿标准超标。'
    }
  ]
  const visibleCards = filterSubmitterResolvedRiskCards({
    cards: riskCards,
    businessStage: 'reimbursement',
    isCurrentApplicant: true,
    expenseItems: rebuiltItems,
    standardAdjustmentMap: new Map()
  })
  assert.deepEqual(visibleCards.map((card) => card.id), ['hotel-risk'])
})

test('standard adjustment resolves submitter risk prompt only after accepted while reviewer still sees notice', () => {
  const originalRiskCard = {
    id: 'risk-hotel-1',
    source: 'attachment_analysis',
    itemId: 'expense-item-1',
    tone: 'high',
    risk: '住宿票据金额超过职级标准。'
  }
  const reviewerNoticeCard = {
    id: 'standard-adjustment-1',
    source: 'reimbursement_standard_adjustment',
    itemId: 'expense-item-1',
    tone: 'medium',
    risk: '提交人已选择按职级标准报销，超出部分由员工自担。'
  }
  const expenseItems = [{ id: 'expense-item-1' }]
  const standardAdjustmentMap = buildStandardAdjustmentMap({
    riskFlags: [
      {
        source: 'reimbursement_standard_adjustment',
        item_id: 'expense-item-1',
        original_amount: '880.00',
        reimbursable_amount: '600.00',
        employee_absorbed_amount: '280.00'
      }
    ]
  })

  assert.deepEqual(
    filterSubmitterResolvedRiskCards({
      cards: [originalRiskCard],
      businessStage: 'reimbursement',
      isCurrentApplicant: true,
      expenseItems,
      standardAdjustmentMap: new Map()
    }),
    [originalRiskCard]
  )
  assert.deepEqual(
    filterSubmitterResolvedRiskCards({
      cards: [originalRiskCard],
      businessStage: 'reimbursement',
      isCurrentApplicant: true,
      expenseItems,
      standardAdjustmentMap
    }),
    []
  )
  assert.deepEqual(
    filterSubmitterResolvedRiskCards({
      cards: [originalRiskCard, reviewerNoticeCard],
      businessStage: 'reimbursement',
      isCurrentApplicant: false,
      expenseItems,
      standardAdjustmentMap
    }),
    [originalRiskCard, reviewerNoticeCard]
  )
  assert.deepEqual(
    filterSubmitterResolvedRiskCards({
      cards: [originalRiskCard, reviewerNoticeCard],
      businessStage: 'reimbursement',
      isCurrentApplicant: true,
      isPrivilegedRiskViewer: true,
      expenseItems,
      standardAdjustmentMap
    }),
    [originalRiskCard, reviewerNoticeCard]
  )
})

test('multi item risk is not missing explanation when every related row has note', () => {
  const card = {
    id: 'risk-multi-city',
    itemIds: ['route-extra-out', 'route-extra-back'],
    tone: 'high',
    risk: '多城市行程待说明。'
  }
  const explainedItems = [
    { id: 'route-extra-out', itemNote: '中间去深圳，项目现场要求。' },
    { id: 'route-extra-back', itemNote: '从深圳返回上海继续支撑部署。' }
  ]
  const partlyMissingItems = [
    { id: 'route-extra-out', itemNote: '中间去深圳，项目现场要求。' },
    { id: 'route-extra-back', itemNote: '' }
  ]

  assert.equal(isRiskCardMissingExpenseNote(card, explainedItems), false)
  assert.equal(isRiskCardMissingExpenseNote(card, partlyMissingItems), true)
})

test('expense item upload remains limited to one receipt per detail row', () => {
  assert.match(detailViewTemplate, /ref="expenseUploadInput"[\s\S]*type="file"/)
  assert.doesNotMatch(
    detailViewTemplate,
    /ref="expenseUploadInput"[\s\S]*\bmultiple\b[\s\S]*@change="handleExpenseFileChange"/
  )
  assert.equal(
    (detailViewTemplate.match(/v-if="isEditableRequest && !item\.invoiceId && !item\.isSystemGenerated"/g) || []).length,
    2
  )
  assert.match(detailViewScript, /const attachments = invoiceId \? \[attachmentName \|\| invoiceId\] : \[\]/)
  assert.match(detailViewScript, /attachmentStatus: isSystemGenerated \? '无需附件' : attachments\.length \? '已关联票据' : '未上传'/)
  assert.match(detailViewScript, /if \(item\?\.invoiceId\) \{[\s\S]*每条费用明细只能关联一张单据/)
  assert.match(detailViewScript, /const fileCount = fileList\?\.length \|\| 0/)
  assert.match(detailViewScript, /fileCount > 1[\s\S]*一条费用明细只能上传一张单据/)
})

test('detail smart entry confirms receipt upload before running recognition', () => {
  assert.match(detailViewTemplate, /@click="triggerSmartEntryUpload"/)
  assert.match(detailViewTemplate, /ref="smartEntryUploadInput"[\s\S]*\bmultiple\b[\s\S]*@change="handleSmartEntryFileChange"/)
  assert.match(detailViewTemplate, /:open="smartEntryUploadDialogOpen"/)
  assert.match(detailViewTemplate, /v-if="smartEntryRecognitionBusy" class="expense-recognition-banner"/)
  assert.match(detailViewTemplate, /uploadingExpenseId === item\.id" class="system-attachment-note pending"/)
  assert.match(detailViewTemplate, /title="上传报销附件"/)
  assert.match(detailViewTemplate, /@click="chooseSmartEntryFile"/)
  assert.match(detailViewTemplate, /@click="clearSmartEntryFile"/)
  assert.match(detailViewTemplate, /@confirm="confirmSmartEntryUpload"/)
  assert.match(detailViewScript, /const smartEntryUploadDialogOpen = ref\(false\)/)
  assert.match(detailViewScript, /const smartEntryRecognitionBusy = ref\(false\)/)
  assert.match(detailViewScript, /actionBusy = computed\(\(\) =>[\s\S]*smartEntryRecognitionBusy\.value/)
  assert.match(detailViewScript, /const smartEntrySelectedFiles = ref\(\[\]\)/)
  assert.match(detailViewScript, /function triggerSmartEntryUpload\(\)[\s\S]*smartEntryUploadDialogOpen\.value = true/)
  assert.match(detailViewScript, /function handleSmartEntryFileChange\(event\)/)
  assert.match(detailViewScript, /smartEntrySelectedFiles\.value = files/)
  assert.match(detailViewScript, /function startSmartEntryRecognitionTask\(\{ claimId, files, itemSnapshots \}\)/)
  assert.match(detailViewScript, /function subscribeSmartEntryRecognitionTask\(claimId, listener\)/)
  assert.match(detailViewScript, /const smartEntryRecognitionCurrent = ref\(0\)/)
  assert.match(detailViewScript, /return `附件识别中（\$\{current\}\/\$\{total\}），请稍候。识别完成前暂不可编辑费用明细。`/)
  assert.match(detailViewScript, /const \{ task, reused \} = startSmartEntryRecognitionTask\(\{[\s\S]*claimId: request\.value\.claimId[\s\S]*itemSnapshots: expenseItems\.value/)
  assert.match(detailViewScript, /bindSmartEntryRecognitionTask\(request\.value\.claimId\)/)
  assert.match(detailViewScript, /void runSmartEntryRecognitionTask\(task, pendingFiles\)/)
  assert.match(detailViewScript, /const payload = await uploadExpenseClaimItemAttachment\(task\.claimId, targetItem\.id, file\)/)
  assert.doesNotMatch(detailViewScript, /function openAiEntry\(\)[\s\S]*emit\('openAssistant'/)
})

test('expense item upload patches OCR amount into the visible detail row', () => {
  assert.match(detailViewScript, /const recognizedItemAmount = Number\(payload\?\.item_amount \?\? payload\?\.itemAmount\)/)
  assert.match(detailViewScript, /const recognizedItemDate = normalizeIsoDateValue\(payload\?\.item_date \?\? payload\?\.itemDate\)/)
  assert.match(detailViewScript, /const recognizedItemType = String\(payload\?\.item_type \?\? payload\?\.itemType \?\? ''\)\.trim\(\)/)
  assert.match(detailViewScript, /const recognizedItemReason = String\(payload\?\.item_reason \?\? payload\?\.itemReason \?\? ''\)\.trim\(\)/)
  assert.match(detailViewScript, /itemPatch\.itemAmount = recognizedItemAmount/)
  assert.match(detailViewScript, /itemPatch\.amount = formatCurrency\(recognizedItemAmount\)/)
  assert.match(detailViewScript, /populateExpenseEditor\(\{ \.\.\.item, \.\.\.itemPatch \}\)/)
})

test('expense detail edit keeps delete but removes cancel and allows draft placeholders', () => {
  assert.doesNotMatch(detailViewTemplate, /@click="cancelExpenseEdit"/)
  assert.doesNotMatch(detailViewScript, /function cancelExpenseEdit/)
  assert.match(detailViewScript, /if \(expenseEditor\.itemDate && !isValidIsoDate\(expenseEditor\.itemDate\)\)/)
  assert.doesNotMatch(detailViewScript, /请输入费用说明。/)
  assert.doesNotMatch(detailViewScript, /请输入大于 0 的费用金额。/)
  assert.match(detailViewScript, /const amountText = String\(expenseEditor\.itemAmount \|\| ''\)\.trim\(\)/)
  assert.match(detailViewScript, /const nextAmount = amountText \? Number\(amountText\) : 0/)
  assert.match(detailViewScript, /if \(expenseEditor\.itemDate\) \{[\s\S]*itemPayload\.item_date = expenseEditor\.itemDate/)
  assert.match(detailViewScript, /itemPayload = \{[\s\S]*item_note: expenseEditor\.itemNote\.trim\(\)/)
})

test('travel detail AI advice uses material prompts only for required hotel receipts', () => {
  assert.match(detailViewScript, /buildTravelReceiptMaterialPrompts\(request\.value, expenseItems\.value\)/)
  assert.doesNotMatch(detailViewScript, /buildOptionalTravelReceiptRiskCards/)
  assert.doesNotMatch(detailViewScript, /travel-optional-ride-ticket/)
  assert.deepEqual(
    buildTravelReceiptMaterialPrompts(
      { typeCode: 'travel', detailVariant: 'travel' },
      [{ id: 'ride', itemType: 'ride_ticket', itemReason: '打车', invoiceId: '' }]
    ),
    []
  )
  assert.deepEqual(
    buildTravelReceiptMaterialPrompts(
      { typeCode: 'travel', detailVariant: 'travel' },
      [{ id: 'hotel', itemType: 'hotel_ticket', itemReason: '住宿', invoiceId: '' }]
    ),
    ['当前包含 1 条住宿费用明细，但暂未关联住宿发票或酒店水单。请补充住宿材料，避免后续被退回补件。']
  )
  assert.deepEqual(
    buildDraftBlockingIssues(
      {
        profileName: '张三',
        typeCode: 'transport',
        typeLabel: '交通费',
        reason: '客户现场打车',
        occurredDisplay: '2026-06-01',
        amountValue: 42
      },
      [
        buildExpenseItemViewModel(
          {
            id: 'ride',
            itemType: 'ride_ticket',
            itemReason: '园区-客户现场',
            itemDate: '2026-06-01',
            itemAmount: 42,
            invoiceId: ''
          },
          0,
          { typeCode: 'transport' }
        )
      ]
    ),
    []
  )
  assert.ok(
    buildDraftBlockingIssues(
      {
        profileName: '张三',
        typeCode: 'hotel',
        typeLabel: '住宿费',
        reason: '住宿报销',
        occurredDisplay: '2026-06-01',
        amountValue: 450
      },
      [
        buildExpenseItemViewModel(
          {
            id: 'hotel',
            itemType: 'hotel_ticket',
            itemReason: '北京中心酒店',
            itemDate: '2026-06-01',
            itemAmount: 450,
            invoiceId: ''
          },
          0,
          { typeCode: 'hotel' }
        )
      ]
    ).some((item) => item.includes('缺少票据标识'))
  )
})

test('expense detail save is blocked while attachment recognition is running', () => {
  assert.match(detailViewScript, /const uploadingExpenseId = ref\(''\)/)
  assert.match(detailViewScript, /actionBusy = computed\(\(\) =>[\s\S]*Boolean\(uploadingExpenseId\.value\)/)
  assert.match(detailViewScript, /const canSubmit = computed\(\(\) => isEditableRequest\.value && !getActionBusy\(\)\)/)
  assert.match(detailViewScript, /if \(draftBlockingIssues\.value\.length\) \{[\s\S]*请先补全草稿信息，再提交审批。/)
  assert.match(
    detailViewTemplate,
    /@click="saveExpenseEdit\(item\)"[\s\S]*:disabled="actionBusy"/
  )
  assert.match(
    detailViewScript,
    /if \(getActionBusy\(\)\) \{[\s\S]*toast\(uploadingExpenseId\.value \? '附件识别中，请等待识别完成后再保存。' : '当前操作处理中，请稍后再保存。'\)[\s\S]*return/
  )
})

test('application detail uses application labels instead of reimbursement labels', () => {
  assert.match(progressCardTemplate, /isApplicationDocument \? '申请进度'/)
  assert.match(detailViewTemplate, /isApplicationDocument \? '申请详情' : '费用明细'/)
  assert.match(detailViewTemplate, /展示本次申请的事实信息、职级规则测算和用户预估费用/)
  assert.match(detailViewTemplate, /<TravelRequestApplicationFacts/)
  assert.match(detailViewTemplate, /:fact-items="applicationDetailFactItems"/)
  assert.match(applicationFactsTemplate, /class="application-detail-facts"/)
  assert.match(applicationFactsTemplate, /v-for="item in factItems"/)
  assert.match(detailViewScript, /buildApplicationDetailFactItems/)
  assert.match(applicationFactsStyle, /\.application-detail-fact\.highlight strong/)
  assert.match(detailViewTemplate, /isApplicationDocument \? '申请类型' : '报销类型'/)
  assert.match(detailViewTemplate, /isApplicationDocument \? '预计金额' : '报销金额'/)
  assert.match(detailViewTemplate, /isApplicationDocument \? '退回申请' : '退回单据'/)
  assert.match(detailViewTemplate, /当前申请单已进入流程，详情页仅展示状态与申请信息。/)
})

test('draft or returned application detail edits allowed facts inline', () => {
  assert.doesNotMatch(detailViewTemplate, /修改申请/)
  assert.match(detailViewTemplate, /:can-edit-item="canEditApplicationDetailItem"/)
  assert.match(detailViewTemplate, /@open="openApplicationDetailEditor"/)
  assert.match(detailViewTemplate, /@save="saveApplicationDetailEdit"/)
  assert.match(applicationFactsTemplate, /canEditItem\(item\)/)
  assert.match(applicationFactsTemplate, /application-detail-edit-btn/)
  assert.match(applicationFactsTemplate, /emit\('open', item\)/)
  assert.match(applicationFactsTemplate, /emit\('save', item\)/)
  assert.doesNotMatch(detailViewScript, /handleModifyApplication/)
  assert.match(
    detailViewScript,
    /const canModifyApplication = computed\(\(\) => \([\s\S]*isApplicationDocument\.value[\s\S]*isEditableRequest\.value[\s\S]*isCurrentApplicant\.value[\s\S]*\)\)/
  )
  assert.match(detailViewScript, /function buildApplicationEditPreview\(\)/)
  assert.match(detailViewScript, /applicationDetailFactItems\.value[\s\S]*sourceText:\s*'修改申请'/)
  assert.match(detailViewScript, /applicationEditMode:\s*true/)
  assert.match(detailViewScript, /editableFields:\s*applicationEditEditableFields/)
  assert.match(detailViewScript, /fields:\s*\{[\s\S]*applicationType:[\s\S]*reason:[\s\S]*transportMode:/)
  assert.match(detailViewScript, /function buildApplicationEditDraftPayload\(\)/)
  assert.match(detailViewScript, /draft_type:\s*'expense_application'/)
  assert.match(detailViewScript, /claim_id:\s*claimId/)
  assert.match(detailViewScript, /application_edit_mode:\s*true/)
  assert.match(detailViewScript, /function canEditApplicationDetailItem\(item = \{\}\)/)
  assert.match(detailViewScript, /function openApplicationDetailEditor\(item = \{\}\)/)
  assert.match(detailViewScript, /async function saveApplicationDetailEdit\(item = \{\}\)/)
  assert.match(detailViewScript, /runAiApplicationPreviewAction\(\{[\s\S]*AI_APPLICATION_ACTION_SAVE_DRAFT/)
  assert.match(detailViewScript, /emit\('request-updated'/)
  assert.match(detailViewScript, /canModifyApplication,/)
  assert.match(detailViewScript, /canEditApplicationDetailItem,/)
  assert.match(detailViewScript, /openApplicationDetailEditor,/)
  assert.match(detailViewScript, /saveApplicationDetailEdit,/)
})

test('application detail does not show optional travel receipt reminders', () => {
  const request = {
    documentTypeCode: 'application',
    claimNo: 'APP-20260525-ABC123',
    typeCode: 'travel_application',
    detailVariant: 'travel'
  }

  assert.equal(isApplicationDocumentRequest(request), true)
  assert.deepEqual(
    buildTravelReceiptMaterialPrompts(request, [
      { id: 'allowance', itemType: 'travel_allowance', isSystemGenerated: true, invoiceId: '' }
    ]),
    []
  )
})

test('employee profile advice highlights prior return and material quality issues', () => {
  const items = buildEmployeeProfileAdviceItems({
    profiles: [
      {
        profile_type: 'process_quality',
        metrics: {
          return_count: 2,
          missing_attachment_count: 1,
          missing_business_context_count: 1,
          invoice_mismatch_count: 1
        }
      }
    ],
    review_suggestions: [
      { message: '申请人近期材料质量波动较高，建议重点核对附件、事由和票据一致性。' }
    ]
  })

  assert.ok(items.some((item) => item.includes('历史退单建议')))
  assert.ok(items.some((item) => item.includes('材料完整性建议')))
  assert.ok(items.some((item) => item.includes('票据一致性建议')))
})

test('draft submit validation uses expense detail date and amount when claim summary is stale', () => {
  const issues = buildDraftBlockingIssues(
    {
      profileName: '张三',
      typeLabel: '待补充',
      typeCode: 'office',
      reason: '待补充',
      location: '待补充',
      occurredDisplay: '待补充',
      amountValue: 0
    },
    [
      {
        id: 'item-1',
        itemDate: '2026-05-21',
        itemType: 'office',
        itemReason: '采购办公用品',
        itemLocation: '',
        itemAmount: 88,
        invoiceId: 'claim-1/item-1/office-note.png'
      }
    ]
  )

  assert.ok(!issues.some((issue) => issue.includes('发生时间未完善')))
  assert.ok(!issues.some((issue) => issue.includes('报销金额未完善')))
  assert.ok(!issues.some((issue) => issue.includes('报销类型未完善')))
  assert.ok(!issues.some((issue) => issue.includes('报销事由未完善')))
})

test('draft submit validation does not hard block uploaded receipt rows with OCR gaps', () => {
  const issues = buildDraftBlockingIssues(
    {
      profileName: '张三',
      typeLabel: '住宿费',
      typeCode: 'hotel',
      reason: '上海出差住宿',
      location: '上海',
      occurredDisplay: '2026-06-01',
      amountValue: 1086
    },
    [
      buildExpenseItemViewModel(
        {
          id: 'hotel-uploaded',
          itemType: 'hotel_ticket',
          itemReason: '',
          itemDate: '',
          itemAmount: 0,
          invoiceId: 'claim-1/hotel-uploaded/hotel-invoice.png'
        },
        0,
        { typeCode: 'hotel', detailVariant: 'travel' }
      )
    ]
  )

  assert.ok(!issues.some((issue) => issue.includes('缺少日期')))
  assert.ok(!issues.some((issue) => issue.includes('缺少说明')))
  assert.ok(!issues.some((issue) => issue.includes('缺少金额')))
  assert.ok(!issues.some((issue) => issue.includes('缺少票据标识')))
})

test('draft submit validation ignores trailing placeholder detail rows', () => {
  const issues = buildDraftBlockingIssues(
    {
      profileName: '张三',
      typeLabel: '差旅费',
      typeCode: 'travel',
      reason: '上海出差',
      location: '上海',
      occurredDisplay: '2026-02-20 至 2026-02-23',
      amountValue: 1086
    },
    [
      buildExpenseItemViewModel(
        {
          id: 'hotel-uploaded',
          itemType: 'hotel_ticket',
          itemReason: '上海喜来登酒店',
          itemLocation: '上海',
          itemDate: '2026-02-20',
          itemAmount: 1086,
          invoiceId: 'claim-1/hotel-uploaded/hotel-invoice.png'
        },
        0,
        { typeCode: 'travel', detailVariant: 'travel' }
      ),
      buildExpenseItemViewModel(
        {
          id: 'placeholder-6',
          itemType: 'hotel_ticket',
          itemDate: '2026-02-23',
          itemReason: '',
          itemLocation: '',
          itemAmount: 0,
          invoiceId: ''
        },
        1,
        { typeCode: 'travel', detailVariant: 'travel' }
      )
    ]
  )

  assert.ok(!issues.some((issue) => issue.includes('费用明细第 2 条缺少说明')))
  assert.ok(!issues.some((issue) => issue.includes('费用明细第 2 条缺少地点')))
  assert.ok(!issues.some((issue) => issue.includes('费用明细第 2 条缺少金额')))
  assert.ok(!issues.some((issue) => issue.includes('费用明细第 2 条缺少票据标识')))
})

test('draft submit validation does not require receipt fields for generated allowance rows', () => {
  const issues = buildExpenseDraftIssues({
    id: 'allowance',
    itemType: 'travel_allowance',
    itemDate: '2026-02-23',
    itemReason: '',
    itemLocation: '',
    itemAmount: 0,
    invoiceId: ''
  })

  assert.deepEqual(issues, [])
})

test('returned application submit validation does not require expense detail rows', () => {
  const issues = buildDraftBlockingIssues(
    {
      id: 'AP-202606060001-RETURNED',
      claimNo: 'AP-202606060001-RETURNED',
      documentTypeCode: 'application',
      approvalKey: 'rejected',
      profileName: '曹笑竹',
      typeLabel: '差旅费用申请',
      typeCode: 'travel_application',
      reason: '支撑国网仿生产环境部署',
      location: '上海',
      occurredDisplay: '2026-02-20 至 2026-02-23',
      amountValue: 1880
    },
    []
  )

  assert.deepEqual(issues, [])
  assert.equal(issues.includes('费用明细不能为空'), false)
})

test('transport ticket descriptions use route format and invalid format becomes risk advice', () => {
  const routeItem = buildExpenseItemViewModel(
    {
      id: 'route-item',
      itemType: 'train_ticket',
      itemReason: '广州南-上海虹桥',
      itemLocation: '上海',
      itemAmount: 354,
      invoiceId: 'train-ticket.png'
    },
    0,
    { claimId: 'claim-route', detailVariant: 'travel' }
  )
  const shipItem = buildExpenseItemViewModel(
    {
      id: 'ship-item',
      itemType: 'ship_ticket',
      itemReason: '上海港-舟山港',
      itemLocation: '舟山',
      itemAmount: 120,
      invoiceId: 'ship-ticket.png'
    },
    1,
    { claimId: 'claim-route', detailVariant: 'travel' }
  )

  assert.equal(routeItem.desc, '广州南-上海虹桥')
  assert.equal(routeItem.detail, '起始地-目的地')
  assert.equal(shipItem.name, '轮船票')
  assert.equal(shipItem.detail, '起始地-目的地')
  assert.match(detailViewScript, /const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set\(\['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'\]\)/)
  assert.match(detailViewScript, /const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set\(\['hotel_ticket'\]\)/)
  assert.match(detailViewScript, /const ROUTE_DESCRIPTION_PATTERN = \/\^\[A-Za-z0-9\\u4e00-\\u9fa5/)
  assert.match(detailViewScript, /return '起始地-目的地，例如：广州南-北京南'/)
  assert.match(detailViewScript, /return '起始地-目的地'/)
  assert.match(detailViewScript, /return '目的地酒店，例如：北京中心酒店'/)
  assert.match(detailViewScript, /return '目的地酒店'/)
  assert.match(detailViewScript, /isSyntheticLocationDisplay\(item\.detail, item\.itemType\)/)
  assert.match(
    detailViewScript,
    /isRouteDescriptionExpenseType\(item\.itemType\) && !isValidRouteDescription\(item\.itemReason\)[\s\S]*issues\.push\('行程说明格式错误'\)/
  )
  assert.match(
    detailViewScript,
    /isRouteDescriptionExpenseType\(expenseEditor\.itemType\)[\s\S]*!isValidRouteDescription\(expenseEditor\.itemReason\)[\s\S]*return '行程说明格式应为“起始地-目的地”，例如：广州南-北京南。'/
  )
  assert.match(
    detailViewScript,
    /fieldText === '行程说明格式错误'[\s\S]*return `\$\{labelPrefix\}的行程说明，格式应为“起始地-目的地”。`/
  )
})

test('transport ticket items no longer generate business location completion advice', () => {
  const locationRequiredBlock = detailViewScript.match(/const LOCATION_REQUIRED_EXPENSE_TYPES = new Set\(\[[\s\S]*?\]\)/)?.[0] || ''

  assert.match(locationRequiredBlock, /'travel'/)
  assert.match(locationRequiredBlock, /'meeting'/)
  assert.match(locationRequiredBlock, /'entertainment'/)
  assert.doesNotMatch(locationRequiredBlock, /'train_ticket'/)
  assert.doesNotMatch(locationRequiredBlock, /'flight_ticket'/)
  assert.doesNotMatch(locationRequiredBlock, /'ride_ticket'/)
  assert.match(
    detailViewScript,
    /const locationRequired = isLocationRequiredExpenseType\(item\.itemType\)[\s\S]*if \([^)]*locationRequired && isPlaceholderValue\(item\.itemLocation\)\) \{[\s\S]*issues\.push\('缺少地点'\)/
  )
  assert.doesNotMatch(detailViewScript, /完善第 1 条费用明细的业务地点/)
})

test('compliant attachment analysis does not create medium risk cards', () => {
  const riskCards = buildAttachmentRiskCards({
    expenseItems: [
      {
        id: 'item-001',
        invoiceId: 'mock/invoice-001.txt',
        itemReason: 'taxi',
        itemType: 'transport'
      }
    ],
    attachmentMetaByItemId: {
      'item-001': {
        analysis: {
          severity: 'success',
          label: 'compliant',
          headline: 'invoice fields match reimbursement item',
          summary: 'mock OCR fields are consistent with the reimbursement detail',
          points: ['amount and document type are consistent']
        }
      }
    }
  })

  assert.deepEqual(riskCards, [])
  assert.match(detailViewInsights, /success', 'ok', 'normal', 'none', 'compliant', 'approved'/)
  assert.match(detailViewScript, /tone: normalizeRiskTone\(analysis\.severity \|\| 'low'\)/)
})

test('return reason dialog is wired into approval and detail return actions', () => {
  assert.match(returnReasonDialog, /missing_attachment/)
  assert.match(returnReasonDialog, /invoice_mismatch/)
  assert.match(returnReasonDialog, /APPLICATION_RETURN_REASON_OPTIONS/)
  assert.match(returnReasonDialog, /application_info_incomplete/)
  assert.match(returnReasonDialog, /application_business_need_unclear/)
  assert.match(returnReasonDialog, /application_budget_basis_missing/)
  assert.match(returnReasonDialog, /application_policy_mismatch/)
  assert.match(returnReasonDialog, /application_attachment_needed/)
  assert.match(returnReasonDialog, /application_other/)
  assert.match(returnReasonDialog, /退单选项/)
  assert.match(returnReasonDialog, /selectionError/)
  assert.match(returnReasonDialog, /selectedApplicationCode/)
  assert.match(returnReasonDialog, /application \? 'radio' : 'checkbox'/)
  assert.match(returnReasonDialog, /selectedReasonCodes\.value\.length === 0/)
  assert.match(returnReasonDialog, /lastAutoReason/)
  assert.match(returnReasonDialog, /reason_codes/)
  assert.match(approvalCenterTemplate, /<TravelRequestDetailView/)
  assert.doesNotMatch(approvalCenterTemplate, /<ReturnReasonDialog/)
  assert.match(detailViewTemplate, /<TravelRequestReturnDialog/)
  assert.match(detailViewTemplate, /:application="isApplicationDocument"/)
  assert.doesNotMatch(approvalCenterScript, /returnExpenseClaim/)
  assert.match(detailViewScript, /returnExpenseClaim\(request\.value\.claimId, \{/)
  assert.match(detailViewScript, /request_id: returnRequestId\.value/)
  assert.match(detailViewScript, /expected_status: returnExpectedStatus\.value/)
  assert.doesNotMatch(approvalCenterScript, /审批中心退回/)
  assert.doesNotMatch(detailViewScript, /详情页退回/)
})
