// 【重要】必须使用 const Component 作为组件变量名 // 业务管理 - 保险采购 // 模块:① 比价单(选车报价 → 保存 → 按最晚付费日临期/超期提醒 → 勾选提交采购审批) // ② 保单管理(一车一档台账,OCR/导入/逐条录入,与比价单不关联) // 与车辆管理「保险状态」联动:交强险 + 商业险均存在且在有效期内为正常,否则异常(禁止交车) const { useState, useMemo, useCallback, useRef } = React; const moment = window.moment || window.dayjs; const antd = window.antd; const { Input, Select, Button, Card, Table, Badge, Tooltip, Modal, Tag, message, Popover, Alert, Checkbox, DatePicker, InputNumber, Space, Radio, Upload, Progress, Dropdown, Tabs, } = antd; const ANCHOR_TODAY = '2026-06-01'; const IPC_STORAGE_KEY = 'oneos_ipc_insurance_v1'; const IPC_COMPARE_SHEETS_KEY = 'oneos_ipc_compare_sheets_v1'; const IPC_POLICY_RECOGN_TASKS_KEY = 'oneos_ipc_policy_recogn_tasks_v2'; const IPC_INSURANCE_HISTORY_EDITS_KEY = 'oneos_ipc_insurance_history_edits_v1'; const IPC_EDIT_PLATE_KEY = 'oneos_ipc_edit_plate'; const NO_PLATE_LABEL = '暂无车牌'; const PROTO_COMPARE_CREATOR = '张明辉'; const INSURANCE_TYPE_ITEMS = [ { key: 'compulsory', label: '交强', fullLabel: '交强险' }, { key: 'commercial', label: '商业', fullLabel: '商业险' }, { key: 'excess', label: '超赔', fullLabel: '超赔险' }, { key: 'cargo', label: '货物', fullLabel: '货物险' }, { key: 'driverAccident', label: '驾意', fullLabel: '驾意险' }, ]; const CORE_INSURANCE_KEYS = ['compulsory', 'commercial']; /** 比价单采购流程中视为「已占用比价单」的状态(审批中、审批通过;不含撤回、审批驳回) */ const ACTIVE_COMPARE_PROCUREMENT_STATUSES = ['submitted', 'approved']; const normalizeCompareProcurementStatus = (status) => ( status === 'completed' ? 'approved' : (status || 'none') ); const isCompareProcurementSelectionDisabled = (status) => { const st = normalizeCompareProcurementStatus(status); return st === 'submitted' || st === 'approved'; }; /** 采购状态展示(撤回、审批驳回、审批通过由工作流回写,本页只读展示) */ const COMPARE_PROCUREMENT_STATUS_META = { none: { label: '未提交', color: 'default' }, submitted: { label: '审批中', color: 'processing' }, approved: { label: '审批通过', color: 'success' }, withdrawn: { label: '撤回', color: 'default' }, rejected: { label: '审批驳回', color: 'error' }, }; const renderCompareProcurementStatusTag = (status) => { const st = normalizeCompareProcurementStatus(status); const meta = COMPARE_PROCUREMENT_STATUS_META[st] || COMPARE_PROCUREMENT_STATUS_META.none; return {meta.label}; }; /** KPI 临期/逾期弹窗:比价采购状态标签(与列表采购状态文案略有差异) */ const ALERT_COMPARE_PROCUREMENT_STATUS_META = { submitted: { label: '审批中', color: 'processing' }, approved: { label: '审批完成', color: 'success' }, withdrawn: { label: '撤回', color: 'default' }, rejected: { label: '驳回', color: 'error' }, }; const COMPARE_PROCUREMENT_STATUS_PRIORITY = { submitted: 4, approved: 3, rejected: 2, withdrawn: 1, }; const renderAlertCompareProcurementTag = (status) => { const st = normalizeCompareProcurementStatus(status); const meta = ALERT_COMPARE_PROCUREMENT_STATUS_META[st]; if (!meta) return null; return {meta.label}; }; const buildCompareProcurementStatusByVehicleType = (compareSheets) => { const map = new Map(); (compareSheets || []).forEach((sheet) => { (sheet.rows || []).forEach((row) => { const key = buildCompareSubmissionKey(row, row.insuranceType); if (!key) return; const st = normalizeCompareProcurementStatus(row.procurementStatus); if (st === 'none') return; const prev = map.get(key); const prevP = prev ? (COMPARE_PROCUREMENT_STATUS_PRIORITY[prev] ?? 0) : 0; const nextP = COMPARE_PROCUREMENT_STATUS_PRIORITY[st] ?? 0; if (nextP >= prevP) map.set(key, st); }); }); return map; }; const getCompareProcurementStatusForVehicleType = (vehicle, insuranceTypeLabel, statusMap) => { const key = buildCompareSubmissionKey(vehicle, insuranceTypeLabel); if (!key || !statusMap) return null; return statusMap.get(key) || null; }; /** 工作流当前审批人样例(正式环境由审批流接口回写,非必填) */ const MOCK_WORKFLOW_CURRENT_APPROVERS = ['李专员', '王专员', '张明辉', '陈高伟', '赵六']; const pickMockWorkflowCurrentApprover = (rowId) => { const hash = String(rowId || '').split('').reduce((sum, ch) => sum + ch.charCodeAt(0), 0); return MOCK_WORKFLOW_CURRENT_APPROVERS[hash % MOCK_WORKFLOW_CURRENT_APPROVERS.length]; }; const INSURANCE_WARN_DAYS = 30; /** 比价单:最晚付费日期 ≤ 该天数视为临期 */ const LATEST_PAY_WARN_DAYS = 3; const INSURANCE_LABEL_TO_KEY = { 交强险: 'compulsory', 商业险: 'commercial', 超赔险: 'excess', 货物险: 'cargo', 驾意险: 'driverAccident', }; const INSURANCE_KEY_TO_LABEL = { compulsory: '交强险', commercial: '商业险', excess: '超赔险', cargo: '货物险', driverAccident: '驾意险', }; const EXPIRING_WARN_TYPE_KEYS = INSURANCE_TYPE_ITEMS.map((item) => item.key); const getVehicleInsuranceEndDate = (ledgerKey, typeKey, allInsurance) => { const item = allInsurance[ledgerKey]?.[typeKey]; if (!item?.endDate || !item?.policyNo) return ''; return item.endDate; }; const compareInsuranceEndDate = (dateA, dateB, order) => { const av = dateA || ''; const bv = dateB || ''; if (!av && !bv) return 0; if (!av) return 1; if (!bv) return -1; const cmp = String(av).localeCompare(String(bv)); return order === 'ascend' ? cmp : -cmp; }; const POLICY_OCR_MODES = [ { key: 'policy', label: '保单录入', desc: '选择险种后上传附件,自动识别保单要素并匹配台账' }, { key: 'suspend', label: '停保', desc: '上传停保/停驶批单,识别保单号与车牌后停保' }, { key: 'resume', label: '复驶', desc: '上传复驶批单,识别后更新生效日期与到期日期' }, { key: 'cancel', label: '退保', desc: '上传退保批单,识别保单号与退费金额' }, ]; const POLICY_BIZ_TYPE_OPTIONS = [ { value: 'policy', label: '保单录入' }, { value: 'suspend', label: '停租' }, { value: 'resume', label: '复驶' }, { value: 'cancel', label: '退保' }, ]; const EMPTY_POLICY_DETAIL = { plateNo: '', vin: '', insuranceType: '交强险', bizType: 'policy', company: '', policyNo: '', endorsementNo: '', payTime: '', startDate: '', endDate: '', reinstateDate: '', premium: '', coverageItems: [], applicant: '', insured: '', signDate: '', attachments: [], }; const EMPTY_COVERAGE_ITEM = { coverageName: '', coverageAmount: '', deductible: '', itemPremium: '', }; /** 保单项目/责任限额:表单为结构化列表;导入/OCR/台账可为分号拼接文本 */ const parseCoverageItemsInput = (raw) => { if (Array.isArray(raw)) { return raw.map((s) => String(s ?? '').trim()).filter(Boolean); } const text = String(raw ?? '').trim(); if (!text) return []; const segments = text.split(/[;;\n]+/).map((s) => s.trim()).filter(Boolean); const expanded = []; segments.forEach((part) => { const sub = part.split(/、/).map((s) => s.trim()).filter(Boolean); if (sub.length > 1 && part.length <= 120) { expanded.push(...sub); } else { expanded.push(part); } }); return expanded; }; const normalizeCoverageItem = (raw) => { if (raw && typeof raw === 'object' && !Array.isArray(raw)) { return { coverageName: String(raw.coverageName ?? raw.name ?? '').trim(), coverageAmount: String(raw.coverageAmount ?? '').trim(), deductible: String(raw.deductible ?? '').trim(), itemPremium: String(raw.itemPremium ?? raw.premium ?? '').trim(), }; } const text = String(raw ?? '').trim(); return text ? { ...EMPTY_COVERAGE_ITEM, coverageName: text } : { ...EMPTY_COVERAGE_ITEM }; }; const normalizeCoverageItems = (raw) => { if (!raw) return []; if (Array.isArray(raw)) { return raw .map(normalizeCoverageItem) .filter((item) => item.coverageName || item.coverageAmount || item.deductible || item.itemPremium); } return parseCoverageItemsInput(raw).map((name) => ({ ...EMPTY_COVERAGE_ITEM, coverageName: name })); }; const serializeCoverageItems = (items) => ( normalizeCoverageItems(items).map((row) => { const parts = [ row.coverageName, row.coverageAmount && `保额${row.coverageAmount}`, row.deductible && `免额${row.deductible}`, row.itemPremium && `保费${row.itemPremium}元`, ].filter(Boolean); return parts.join(' '); }).join(';') ); const getCoverageItemsFormRows = (items) => { const list = normalizeCoverageItems(items); return list.length ? list.map((item) => ({ ...EMPTY_COVERAGE_ITEM, ...item })) : [{ ...EMPTY_COVERAGE_ITEM }]; }; const buildSampleCoverageItemsForRecognEdit = (insuranceType, premiumTotal) => { const total = (premiumTotal || '').trim(); const samples = { 交强险: [ { coverageName: '死亡伤残赔偿', coverageAmount: '180000元', deductible: '—', itemPremium: '850.00' }, { coverageName: '医疗费用赔偿', coverageAmount: '18000元', deductible: '—', itemPremium: '283.00' }, { coverageName: '财产损失赔偿', coverageAmount: '2000元', deductible: '—', itemPremium: '110.00' }, ], 商业险: [ { coverageName: '机动车损失险', coverageAmount: '350000元', deductible: '绝对免赔额500元', itemPremium: '5200.00' }, { coverageName: '第三者责任险', coverageAmount: '2000000元', deductible: '—', itemPremium: '6100.00' }, { coverageName: '车上人员责任险(司机)', coverageAmount: '20000元', deductible: '—', itemPremium: '850.00' }, { coverageName: '车上人员责任险(乘客)', coverageAmount: '20000元/座', deductible: '—', itemPremium: '650.50' }, ], 超赔险: [ { coverageName: '超赔责任险', coverageAmount: '10000000元', deductible: '—', itemPremium: '1200.00' }, { coverageName: '附加超额第三者责任', coverageAmount: '5000000元', deductible: '—', itemPremium: '300.00' }, ], 货物险: [ { coverageName: '公路货物运输定额保险', coverageAmount: '500000元', deductible: '每次事故免赔1000元', itemPremium: '1800.00' }, { coverageName: '集装箱货物及其箱体', coverageAmount: '200000元', deductible: '—', itemPremium: '420.00' }, ], 驾意险: [ { coverageName: '驾乘意外身故伤残', coverageAmount: '500000元/座', deductible: '—', itemPremium: '220.00' }, { coverageName: '驾乘意外医疗', coverageAmount: '50000元/座', deductible: '免赔额100元', itemPremium: '160.00' }, ], }; const rows = (samples[insuranceType] || samples.交强险).map((row) => ({ ...EMPTY_COVERAGE_ITEM, ...row })); if (total && rows.length) { const sum = rows.reduce((acc, row) => acc + (parseFloat(row.itemPremium) || 0), 0); const target = parseFloat(total); if (!Number.isNaN(target) && sum > 0 && Math.abs(sum - target) > 0.01) { const last = rows[rows.length - 1]; const adjust = (target - sum + (parseFloat(last.itemPremium) || 0)).toFixed(2); last.itemPremium = adjust; } } return rows; }; const enrichPolicyDetailCoverageForEdit = (detail) => { const items = normalizeCoverageItems(detail.coverageItems); const needsSample = !items.length || items.every((i) => !i.coverageAmount && !i.deductible && !i.itemPremium); if (needsSample) { return { ...detail, coverageItems: buildSampleCoverageItemsForRecognEdit(detail.insuranceType, detail.premium), }; } return { ...detail, coverageItems: items }; }; /** 基于用户提供的真实保单/批单样本(PDF 解析 + 文件名) */ const REFERENCE_POLICY_OCR_MOCKS = [ { test: (n) => /沪BDB9161.*交强险/i.test(n), detail: { plateNo: '沪BDB9161', vin: 'LC0DF4CD8S0303140', insuranceType: '交强险', bizType: 'policy', policyNo: 'ASHZ001CTP26B187065J', endorsementNo: 'DZQA26480000279515', company: '中国太平洋财产保险股份有限公司', payTime: '2026-06-01 17:42:10', signDate: '2026-05-27', startDate: '2026-06-05', endDate: '2027-06-04', premium: '1243.00', coverageItems: [ { coverageName: '死亡伤残赔偿', coverageAmount: '180000元', deductible: '—', itemPremium: '850.00' }, { coverageName: '医疗费用赔偿', coverageAmount: '18000元', deductible: '—', itemPremium: '283.00' }, { coverageName: '财产损失赔偿', coverageAmount: '2000元', deductible: '—', itemPremium: '110.00' }, ], applicant: '上海羚牛氢运物联网科技有限公司', insured: '上海羚牛氢运物联网科技有限公司', }, }, { test: (n) => /粤AGP9827.*商业险/i.test(n), detail: { plateNo: '粤AGP9827', insuranceType: '商业险', bizType: 'policy', policyNo: '2050AA330400260000GV', company: '紫金财产保险股份有限公司', payTime: '2026-05-28 10:00:00', startDate: '2026-06-06', endDate: '2027-05-27', premium: '12800.00', coverageItems: [ { coverageName: '机动车损失险', coverageAmount: '350000元', deductible: '绝对免赔额500元', itemPremium: '5200.00' }, { coverageName: '第三者责任险', coverageAmount: '2000000元', deductible: '—', itemPremium: '6100.00' }, { coverageName: '车上人员责任险', coverageAmount: '20000元/座', deductible: '—', itemPremium: '1500.00' }, ], }, }, { test: (n) => /粤AGP3071.*驾意/i.test(n), detail: { plateNo: '粤AGP3071', insuranceType: '驾意险', bizType: 'policy', policyNo: 'JY2026AGP3071001', company: '中国平安财产保险股份有限公司', startDate: '2026-06-06', endDate: '2027-05-27', premium: '380.00', coverageItems: '驾乘意外险,每座身故伤残/医疗限额', }, }, { test: (n) => /粤AGR9766.*超赔/i.test(n), detail: { plateNo: '粤AGR9766', vin: 'LB9A32A21R0LS1478', insuranceType: '超赔险', bizType: 'policy', policyNo: '6260828000909X006408', company: '国任财产保险股份有限公司广州市番禺支公司', payTime: '2026-04-16 14:37:37', signDate: '2026-04-16', startDate: '2026-04-17', endDate: '2027-04-16', premium: '1500.00', coverageItems: '公路货物运输定额保险;累计赔偿限额10001000元;主险货物保险金额1000元', applicant: '羚牛氢能科技(广东)有限公司', insured: '羚牛氢能科技(广东)有限公司', }, }, { test: (n) => /货物险|20208A330400240001QX/i.test(n), detail: { plateNo: '浙F05178F', insuranceType: '货物险', bizType: 'policy', policyNo: '20208A330400240001QX', company: '紫金财产保险股份有限公司', payTime: '2024-10-17 15:58:05', startDate: '2024-10-18', endDate: '2025-10-17', premium: '1500.00', coverageItems: '公路货物运输定额保险 CNY500000;集装箱货物及其箱体', applicant: '嘉兴羚牛汽车服务有限公司', insured: '嘉兴羚牛汽车服务有限公司', }, }, { test: (n) => /粤A03423F.*停驶/i.test(n), detail: { plateNo: '粤A03423F', insuranceType: '商业险', bizType: 'suspend', policyNo: '2050AA3304002600002EM', endorsementNo: '3050AA3304002600002EM01', company: '紫金财产保险股份有限公司', payTime: '2026-04-16 16:13:53', startDate: '2026-04-17', endDate: '2026-04-17', reinstateDate: '2027-03-31', coverageItems: '停驶批单:保险车辆停驶,停驶期间保险责任中止', }, }, { test: (n) => /粤A06290F.*复驶/i.test(n), detail: { plateNo: '粤A06290F', insuranceType: '商业险', bizType: 'resume', policyNo: '2050AA33040026000226', endorsementNo: '3050AA3304002600022602', company: '紫金财产保险股份有限公司', payTime: '2026-04-30 14:25:43', startDate: '2026-05-06', endDate: '2027-03-27', reinstateDate: '2027-03-16', coverageItems: '复驶批单:停驶车辆恢复行驶,保险责任自复驶日起恢复', }, }, { test: (n) => /浙F03220F.*复驶|BSHZ001S2024B005477B/i.test(n), detail: { plateNo: '浙F03220F', insuranceType: '商业险', bizType: 'resume', policyNo: 'BSHZ001S2024B005477B', endorsementNo: 'BSHZ001S2024B005477E', company: '中国太平洋财产保险股份有限公司', startDate: '2026-05-01', endDate: '2027-04-30', coverageItems: '复驶批单', }, }, { test: (n) => /沪A06192F.*停保|BSHZ001S2024B005054V/i.test(n), detail: { plateNo: '沪A06192F', insuranceType: '商业险', bizType: 'suspend', policyNo: 'BSHZ001S2024B005054V', endorsementNo: 'BSHZ001S2024B005054E', company: '中国太平洋财产保险股份有限公司', startDate: '2025-12-05', endDate: '2026-03-05', reinstateDate: '2026-06-01', coverageItems: '停保批单', }, }, { test: (n) => /粤A03331F.*退保/i.test(n), detail: { plateNo: '粤A03331F', insuranceType: '商业险', bizType: 'cancel', policyNo: '2050AA330400260000GV', endorsementNo: '3050AA330400260000GV02', company: '紫金财产保险股份有限公司', payTime: '2026-05-27 17:51:20', startDate: '2026-05-28', endDate: '2026-05-28', premium: '7853.27', coverageItems: '商业险退保批单,退还保费', }, }, { test: (n) => /粤AGR0772.*停保/i.test(n), detail: { plateNo: '粤AGR0772', insuranceType: '商业险', bizType: 'suspend', policyNo: 'PAIC-SY-AGR0772-2025', company: '中国平安财产保险股份有限公司', startDate: '2025-12-05', endDate: '2026-03-05', reinstateDate: '2026-06-01', coverageItems: '商业险停保', }, }, ]; const normalizePolicyDetail = (raw = {}) => ({ ...EMPTY_POLICY_DETAIL, ...raw, plateNo: (raw.plateNo || '').trim(), vin: (raw.vin || '').trim(), insuranceType: raw.insuranceType || '交强险', bizType: raw.bizType || 'policy', coverageItems: normalizeCoverageItems(raw.coverageItems), }); const inferPolicyDetailFromFileName = (fileName) => { const name = fileName || ''; const plateMatch = name.match(/[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼][A-Z][A-Z0-9]{4,6}[A-Z0-9挂学警港澳]?/i); const plateNo = plateMatch ? plateMatch[0].toUpperCase() : ''; let insuranceType = '交强险'; if (/商业险|商业/.test(name)) insuranceType = '商业险'; else if (/超赔/.test(name)) insuranceType = '超赔险'; else if (/驾意/.test(name)) insuranceType = '驾意险'; else if (/货物/.test(name)) insuranceType = '货物险'; else if (/交强/.test(name)) insuranceType = '交强险'; let bizType = 'policy'; if (/停驶|停保/.test(name)) bizType = 'suspend'; else if (/复驶/.test(name)) bizType = 'resume'; else if (/退保/.test(name)) bizType = 'cancel'; const policyNoMatch = name.match(/BSHZ\d+[A-Z0-9]+|ASHZ\d+[A-Z0-9]+|202\d{2}A\d+QX|2050AA\d+[A-Z0-9]+|6260\d+X\d+/i); const rangeMatch = name.match(/(\d{4})[.\-](\d{1,2})[.\-](\d{1,2})/); let startDate = ''; let endDate = ''; if (rangeMatch) { const y = rangeMatch[1]; const m = String(rangeMatch[2]).padStart(2, '0'); const d = String(rangeMatch[3]).padStart(2, '0'); startDate = `${y}-${m}-${d}`; const parts = name.match(/(\d{4})[.\-](\d{1,2})[.\-](\d{1,2}).*?(\d{4})[.\-](\d{1,2})[.\-](\d{1,2})/); if (parts) { endDate = `${parts[4]}-${String(parts[5]).padStart(2, '0')}-${String(parts[6]).padStart(2, '0')}`; } } return normalizePolicyDetail({ plateNo, insuranceType, bizType, policyNo: policyNoMatch ? policyNoMatch[0] : '', startDate, endDate, }); }; const resolvePolicyDetailFromFileName = (fileName) => { const ref = REFERENCE_POLICY_OCR_MOCKS.find((m) => m.test(fileName)); if (ref) return normalizePolicyDetail(ref.detail); return inferPolicyDetailFromFileName(fileName); }; const bizTypeToRecognMode = (bizType) => ( bizType === 'suspend' ? 'suspend' : bizType === 'resume' ? 'resume' : bizType === 'cancel' ? 'cancel' : 'policy' ); const applyPolicyDetailToInsuranceItem = (item, detail, mode) => { const d = normalizePolicyDetail(detail); const next = { ...item }; next.company = d.company || next.company; next.policyNo = d.policyNo || next.policyNo; next.endorsementNo = d.endorsementNo || next.endorsementNo || ''; next.startDate = d.startDate || next.startDate; next.endDate = d.endDate || next.endDate; next.premium = d.premium || next.premium; next.payTime = d.payTime || next.payTime || ''; next.signDate = d.signDate || next.signDate || ''; next.coverageItems = serializeCoverageItems(d.coverageItems) || next.coverageItems || ''; next.applicant = d.applicant || next.applicant || ''; next.insured = d.insured || next.insured || ''; if (Array.isArray(d.attachments)) { next.attachments = d.attachments; } if (mode === 'policy') { next.policyTag = ''; next.reinstateDate = ''; } else if (mode === 'suspend') { next.policyTag = 'suspended'; next.reinstateDate = d.reinstateDate || next.reinstateDate || '2026-09-01'; } else if (mode === 'resume') { next.policyTag = ''; next.reinstateDate = ''; } else if (mode === 'cancel') { next.policyTag = 'cancelled'; next.reinstateDate = ''; } return next; }; const createPolicyRecognTaskId = () => `TASK-${Date.now().toString().slice(-8)}`; const isPolicyRecognImageOrPdf = (file) => { const name = (file?.name || '').toLowerCase(); const type = (file?.type || '').toLowerCase(); return type.includes('pdf') || type.startsWith('image/') || /\.(pdf|png|jpe?g|webp|bmp|gif|tiff?)$/i.test(name); }; const isPolicyImportExcelFile = (file) => { const name = (file?.name || '').toLowerCase(); const type = (file?.type || '').toLowerCase(); return /\.(csv|xlsx|xls)$/i.test(name) || type.includes('csv') || type.includes('spreadsheet') || type.includes('excel'); }; /** 新增/续保录入必填项(车牌与 VIN 至少填一项;承保险种明细非必填) */ const POLICY_ENTRY_REQUIRED_FIELDS = [ { key: 'insuranceType', label: '险种' }, { key: 'company', label: '保险公司' }, { key: 'policyNo', label: '保单号' }, { key: 'startDate', label: '生效日期' }, { key: 'endDate', label: '到期日期' }, { key: 'premium', label: '保险费合计' }, ]; const POLICY_ENTRY_FORM_REQUIRED_KEYS = [ 'plateNo', ...POLICY_ENTRY_REQUIRED_FIELDS.map((item) => item.key), ]; const validatePolicyEntryDetail = (detail, options = {}) => { const d = normalizePolicyDetail(detail); const { silent = false, rowIndex } = options; const rowPrefix = rowIndex != null ? `第 ${rowIndex} 行:` : ''; if (!d.plateNo && !d.vin) { if (!silent) message.warning(`${rowPrefix}车牌号与 VIN 至少填一项`); return { ok: false, label: '车牌号或 VIN' }; } const missing = POLICY_ENTRY_REQUIRED_FIELDS.find(({ key }) => !String(d[key] || '').trim()); if (missing) { if (!silent) message.warning(`${rowPrefix}请填写${missing.label}`); return { ok: false, label: missing.label }; } return { ok: true }; }; /** 批量导入模板列:与「新增保单」表单一致,仅用于新增/续保;带 * 为必填 */ const POLICY_IMPORT_TEMPLATE_COLUMNS = [ { header: '车牌号', key: 'plateNo', required: true, sample: '沪BDB9161' }, { header: 'VIN码', key: 'vin', required: false, sample: 'LC0DF4CD8S0303140' }, { header: '险种', key: 'insuranceType', required: true, sample: '交强险' }, { header: '保险公司', key: 'company', required: true, sample: '中国太平洋财产保险股份有限公司' }, { header: '保单号', key: 'policyNo', required: true, sample: 'ASHZ001CTP26B187065J' }, { header: '批单号', key: 'endorsementNo', required: false, sample: '' }, { header: '付款时间', key: 'payTime', required: false, sample: '2026-06-01 17:42:10' }, { header: '签单日期', key: 'signDate', required: false, sample: '2026-05-27' }, { header: '生效日期', key: 'startDate', required: true, sample: '2026-06-05' }, { header: '到期日期', key: 'endDate', required: true, sample: '2027-06-04' }, { header: '保险费合计', key: 'premium', required: true, sample: '1243.00' }, { header: '投保人', key: 'applicant', required: false, sample: '上海羚牛氢运物联网科技有限公司' }, { header: '被保险人', key: 'insured', required: false, sample: '上海羚牛氢运物联网科技有限公司' }, { header: '承保险种', key: 'coverageName', required: false, sample: '机动车第三者责任险' }, { header: '保险金额', key: 'coverageAmount', required: false, sample: '2000000元' }, { header: '保险金额/责任免额', key: 'coverageDeductible', required: false, sample: '绝对免赔额500元' }, { header: '保险费', key: 'coveragePremium', required: false, sample: '1280.00' }, ]; const POLICY_IMPORT_TEMPLATE_HEADERS = POLICY_IMPORT_TEMPLATE_COLUMNS.map((col) => ( col.required ? `${col.header}*` : col.header )); const POLICY_IMPORT_TEMPLATE_SAMPLE_ROW = POLICY_IMPORT_TEMPLATE_COLUMNS.map((col) => col.sample); const POLICY_IMPORT_HEADER_ALIASES = { 车牌: 'plateNo', VIN: 'vin', 车辆识别代码: 'vin', 保险类型: 'insuranceType', 生效日: 'startDate', 生效时间: 'startDate', 起保日期: 'startDate', 到期日: 'endDate', 到期时间: 'endDate', '保费(元)': 'premium', 保费: 'premium', 保单项目: 'coverageItems', 责任免额: 'coverageDeductible', }; const finalizePolicyImportRow = (row) => { const coverageName = (row.coverageName || '').trim(); const coverageAmount = (row.coverageAmount || '').trim(); const coverageDeductible = (row.coverageDeductible || '').trim(); const coveragePremium = (row.coveragePremium || '').trim(); let coverageItems = row.coverageItems; if (coverageName || coverageAmount || coverageDeductible || coveragePremium) { coverageItems = [{ coverageName, coverageAmount, deductible: coverageDeductible, itemPremium: coveragePremium, }]; } const { coverageName: _coverageName, coverageAmount: _coverageAmount, coverageDeductible: _coverageDeductible, coveragePremium: _coveragePremium, ...rest } = row; return normalizePolicyDetail({ ...rest, coverageItems }); }; const downloadPolicyImportTemplate = () => { const escapeCsvCell = (val) => { const s = String(val ?? ''); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }; const csv = `\uFEFF${[ POLICY_IMPORT_TEMPLATE_HEADERS.map(escapeCsvCell).join(','), POLICY_IMPORT_TEMPLATE_SAMPLE_ROW.map(escapeCsvCell).join(','), ].join('\n')}`; const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = '保单新增续保导入模板.csv'; a.click(); URL.revokeObjectURL(url); }; const parseCsvLine = (line) => { const result = []; let cur = ''; let inQuote = false; for (let i = 0; i < line.length; i += 1) { const c = line[i]; if (c === '"') { inQuote = !inQuote; continue; } if (c === ',' && !inQuote) { result.push(cur.trim()); cur = ''; continue; } cur += c; } result.push(cur.trim()); return result; }; const normalizeImportHeaderKey = (header) => { const h = String(header || '').trim().replace(/^\uFEFF/, '').replace(/\*+$/, ''); const fromColumns = POLICY_IMPORT_TEMPLATE_COLUMNS.find((col) => col.header === h)?.key; if (fromColumns) return fromColumns; if (h === '业务类型') return 'bizTypeLabel'; return POLICY_IMPORT_HEADER_ALIASES[h] || null; }; const POLICY_IMPORT_SKIP_BIZ_LABELS = new Set(['停保', '停租', '复驶', '退保']); const parsePolicyImportFileText = (text) => { const lines = String(text || '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean); if (!lines.length) return []; const headerCells = parseCsvLine(lines[0]); const colIndex = {}; headerCells.forEach((cell, idx) => { const key = normalizeImportHeaderKey(cell); if (key) colIndex[key] = idx; }); const hasHeader = Object.keys(colIndex).length >= 3; const dataLines = hasHeader ? lines.slice(1) : lines; const fallbackIndexByKey = Object.fromEntries( POLICY_IMPORT_TEMPLATE_COLUMNS.map((col, idx) => [col.key, idx]) ); const pick = (cells, key) => { if (hasHeader && colIndex[key] != null) return (cells[colIndex[key]] || '').trim(); const idx = fallbackIndexByKey[key]; return idx != null ? (cells[idx] || '').trim() : ''; }; return dataLines.map((line) => { const cells = parseCsvLine(line); if (!cells.some((c) => c)) return null; const bizLabel = pick(cells, 'bizTypeLabel'); if (bizLabel && POLICY_IMPORT_SKIP_BIZ_LABELS.has(bizLabel)) return null; const row = { bizType: 'policy' }; POLICY_IMPORT_TEMPLATE_COLUMNS.forEach((col) => { row[col.key] = pick(cells, col.key); }); if (!row.coverageItems) { row.coverageItems = pick(cells, 'coverageItems'); } return finalizePolicyImportRow(row); }).filter(Boolean); }; const validatePolicyImportRows = (rows) => { for (let i = 0; i < (rows || []).length; i += 1) { const result = validatePolicyEntryDetail(rows[i], { silent: true, rowIndex: i + 1 }); if (!result.ok) { message.error(`第 ${i + 1} 行缺少必填项:${result.label}`); return false; } } return true; }; const resolveImportRowLedgerKey = (row) => { const plate = (row?.plateNo || '').trim(); const vin = (row?.vin || '').trim(); if (plate) { const v = findVehicleByPlate(plate); if (v) return getVehicleLedgerKey(v); } if (vin) { const v = findVehicleByVin(vin); if (v) return getVehicleLedgerKey(v); } return null; }; const isImportRowLedgerMatched = (ledgerKey) => ( !!ledgerKey && MOCK_VEHICLES.some((v) => getVehicleLedgerKey(v) === ledgerKey) ); const buildRecognResultFromDetail = (fileMeta, detail, allInsurance, forcedMode) => { const d = normalizePolicyDetail(detail); const mode = forcedMode || bizTypeToRecognMode(d.bizType); const plate = d.plateNo; const vin = d.vin; const vehicle = (plate && findVehicleByPlate(plate)) || (vin && findVehicleByVin(vin)) || { plateNo: plate, vin }; const ledgerKey = resolveImportRowLedgerKey({ plateNo: plate, vin }) || (plate ? getVehicleLedgerKey(vehicle) : '') || (vin ? getVehicleLedgerKey(vehicle) : ''); let typeKey = INSURANCE_LABEL_TO_KEY[d.insuranceType]; const record = ledgerKey ? allInsurance[ledgerKey] : null; let existing = typeKey && record ? record[typeKey] : null; if (mode !== 'policy' && d.policyNo && ledgerKey) { const policyMatch = findPolicyMatchInLedger(allInsurance, ledgerKey, d.policyNo); if (policyMatch) { typeKey = policyMatch.typeKey; existing = policyMatch.item; } } const typeLabel = INSURANCE_TYPE_ITEMS.find((it) => it.key === typeKey)?.fullLabel || d.insuranceType || '—'; const ocrPolicyNo = d.policyNo || existing?.policyNo || ''; let ocrEndDate = d.endDate || existing?.endDate || ''; let reinstateDate = d.reinstateDate || ''; if (!ocrEndDate) { if (mode === 'suspend' || mode === 'cancel') ocrEndDate = ANCHOR_TODAY; else if (mode === 'resume') ocrEndDate = '2027-06-30'; else ocrEndDate = '2027-12-31'; } const matched = isImportRowLedgerMatched(ledgerKey) && !!typeKey && !!(ocrPolicyNo || ocrEndDate); const bizLabel = POLICY_BIZ_TYPE_OPTIONS.find((o) => o.value === d.bizType)?.label || '保单录入'; return { id: fileMeta.id || `ocr-r-${Date.now()}`, fileUid: fileMeta.fileUid || fileMeta.uid || `f-${Date.now()}`, fileName: fileMeta.fileName || fileMeta.name || '导入记录', fileType: fileMeta.fileType || '', policyDetail: d, ocrPlateNo: (vehicle.plateNo || plate || '').trim(), ocrVin: vehicle.vin || vin || '', displayPlate: formatVehiclePlateDisplay(vehicle.plateNo || plate), ocrPolicyNo, ocrEndDate, ocrStartDate: d.startDate || '', ocrPremium: d.premium || '', ocrPayTime: d.payTime || '', ocrEndorsementNo: d.endorsementNo || '', ocrCoverageItems: serializeCoverageItems(d.coverageItems), ocrBizType: d.bizType, ocrBizTypeLabel: bizLabel, ocrCompany: d.company || existing?.company || INSURANCE_MGMT_COMPANIES[0], reinstateDate, ledgerKey: ledgerKey || '', typeKey: typeKey || '', insuranceTypeLabel: typeLabel, matched, matchTip: matched ? '已与台账车辆、险种匹配,可核对后确认' : !ledgerKey ? '未匹配到台账车辆,请检查车牌或 VIN' : !typeKey ? '险种填写有误' : '请填写保单号或到期日期', recognSuccess: true, confirmed: false, recognMode: mode, }; }; const buildImportResultsFromRows = (rows, allInsurance) => ( (rows || []).map((row, idx) => buildRecognResultFromDetail( { id: `import-r-${idx}-${Date.now()}`, fileUid: `import-row-${idx}`, fileName: `导入_${row.plateNo || row.vin || `第${idx + 1}行`}.csv`, fileType: 'text/csv' }, normalizePolicyDetail(row), allInsurance )) ); const readPolicyImportFileAsText = (file) => new Promise((resolve, reject) => { const name = (file?.name || '').toLowerCase(); if (/\.(xlsx|xls)$/i.test(name)) { message.warning('当前原型请使用 CSV 模板(在 Excel 中打开模板后另存为 CSV UTF-8 再上传)'); resolve(''); return; } const reader = new FileReader(); reader.onload = (e) => resolve(String(e?.target?.result || '')); reader.onerror = () => reject(new Error('read failed')); reader.readAsText(file, 'UTF-8'); }); const findPolicyMatchInLedger = (allInsurance, ledgerKey, policyNo) => { const record = allInsurance?.[ledgerKey]; if (!record || !policyNo) return null; for (let i = 0; i < INSURANCE_TYPE_ITEMS.length; i += 1) { const item = INSURANCE_TYPE_ITEMS[i]; if (record[item.key]?.policyNo === policyNo) { return { typeKey: item.key, item: record[item.key], label: item.fullLabel }; } } return null; }; const buildMockOcrResults = (files, mode, insuranceTypeLabel, allInsurance) => ( (files || []).filter((f) => f.status === 'done').map((file, idx) => { const fromFile = resolvePolicyDetailFromFileName(file.name); const detail = normalizePolicyDetail({ ...fromFile, insuranceType: mode === 'policy' ? (insuranceTypeLabel || fromFile.insuranceType) : fromFile.insuranceType, bizType: mode !== 'policy' ? mode : (fromFile.bizType || 'policy'), }); if (!detail.plateNo && !detail.vin) { const vehicle = MOCK_VEHICLES[idx % MOCK_VEHICLES.length] || {}; detail.plateNo = vehicle.plateNo || ''; detail.vin = vehicle.vin || ''; } if (!detail.policyNo) { detail.policyNo = `PDZA${String(20260000 + idx)}`; } if (mode === 'policy') { detail.coverageItems = buildSampleCoverageItemsForRecognEdit( detail.insuranceType, detail.premium || (detail.insuranceType === '交强险' ? '1243.00' : '12800.00') ); if (!detail.premium) { const sum = detail.coverageItems.reduce((acc, row) => acc + (parseFloat(row.itemPremium) || 0), 0); detail.premium = sum > 0 ? sum.toFixed(2) : ''; } } const result = buildRecognResultFromDetail( { id: `ocr-r-${file.uid}`, fileUid: file.uid, fileName: file.name, fileType: file.type || '' }, detail, allInsurance, mode ); if (files.length >= 2 && idx === files.length - 1) { return { ...result, recognSuccess: false, matched: false, matchTip: 'OCR 识别失败,请检查文件清晰度或重新上传', }; } return result; }) ); const recognResultToPolicyDetail = (result) => normalizePolicyDetail({ plateNo: result.ocrPlateNo, vin: result.ocrVin, insuranceType: result.insuranceTypeLabel, bizType: result.ocrBizType || result.policyDetail?.bizType || 'policy', company: result.ocrCompany, policyNo: result.ocrPolicyNo, endorsementNo: result.ocrEndorsementNo, payTime: result.ocrPayTime, startDate: result.ocrStartDate, endDate: result.ocrEndDate, reinstateDate: result.reinstateDate, premium: result.ocrPremium, coverageItems: normalizeCoverageItems(result.policyDetail?.coverageItems ?? result.ocrCoverageItems), ...(result.policyDetail || {}), }); const applyPolicyOcrResultToLedger = (result, mode) => { const { ledgerKey, typeKey } = result; if (!ledgerKey || !typeKey) return false; const effectiveMode = result.recognMode || mode; const detail = result.policyDetail ? normalizePolicyDetail(result.policyDetail) : recognResultToPolicyDetail(result); const nowStr = formatCompareSheetNow(); return (prev) => { const record = ensureInsuranceRecordShape(prev[ledgerKey] || createEmptyInsuranceRecord()); const item = applyPolicyDetailToInsuranceItem({ ...record[typeKey] }, detail, effectiveMode); item.updateTime = nowStr; item.updateUser = PROTO_COMPARE_CREATOR; return { ...prev, [ledgerKey]: { ...record, [typeKey]: item } }; }; }; /** 保险公司管理模块 — 保险公司名称枚举(原型 mock) */ const INSURANCE_MGMT_COMPANIES = [ '中国人民财产保险股份有限公司', '中国平安财产保险股份有限公司', '中国太平洋财产保险股份有限公司', '中国人寿财产保险股份有限公司', '阳光财产保险股份有限公司', '中华联合财产保险股份有限公司', '太平财产保险有限公司', '大地财产保险股份有限公司', '上海某某保险公司', ]; const QUOTE_INSURANCE_TYPES = ['交强险', '商业险', '超赔险', '货物险', '驾意险']; const sanitizePremiumInput = (raw) => { let s = String(raw || '').replace(/[^\d.]/g, ''); const dotIdx = s.indexOf('.'); if (dotIdx >= 0) { s = s.slice(0, dotIdx + 1) + s.slice(dotIdx + 1).replace(/\./g, '').slice(0, 2); } return s; }; const isValidPremium = (s) => { const v = (s || '').trim(); if (!v) return false; if (!/^\d+(\.\d{1,2})?$/.test(v)) return false; return parseFloat(v) > 0; }; const formatPremiumDisplay = (s) => { if (!isValidPremium(s)) return s || ''; return parseFloat(s).toFixed(2); }; const createEmptyQuoteDraft = () => ({ company: undefined, premium: '' }); const shortInsuranceCompanyName = (name) => ( (name || '').replace(/股份有限公司/g, '').replace(/有限公司/g, '').trim() ); /** 比价单:汇总各行已确认报价金额 */ const calcCompareSheetConfirmedTotal = (rows) => { let total = 0; let count = 0; (rows || []).forEach((row) => { if (!row.confirmedQuoteId) return; const quote = (row.quotes || []).find((q) => q.id === row.confirmedQuoteId); if (!quote?.premium) return; const amount = parseFloat(quote.premium); if (!Number.isNaN(amount) && amount > 0) { total += amount; count += 1; } }); return { total, count }; }; const VEHICLE_PROFILES = { '沪A03561F': { customer: '上海迅杰物流有限公司', ownerCompany: '浙江羚牛氢能科技有限公司', color: '白色', regDate: '2024-06-05', inspectExpire: '2026-06-30' }, '粤B58888F': { customer: '深圳冷链运输有限公司', ownerCompany: '羚牛运营(广东)', color: '蓝色', regDate: '2024-07-20', inspectExpire: '2026-07-20' }, '苏E33333': { customer: '苏州港务集团', ownerCompany: '浙江羚牛氢能科技有限公司', color: '红色', regDate: '2024-05-16', inspectExpire: '2026-05-15' }, '京A12345': { customer: '—', ownerCompany: '某某科技有限公司', color: '灰色', regDate: '2020-10-01', inspectExpire: '2024-10-01' }, '浙A88888': { customer: '—', ownerCompany: '浙江羚牛氢能科技有限公司', color: '绿色', regDate: '2025-01-01', inspectExpire: '2027-12-31' }, '沪D66666': { customer: '客户C', ownerCompany: '羚牛运营(上海)', color: '白色', regDate: '2021-06-15', inspectExpire: '2025-01-31' }, '粤A12345': { customer: '客户A', ownerCompany: '羚牛运营(广东)', color: '白色', regDate: '2023-07-01', inspectExpire: '2026-02-28' }, '苏A55678': { customer: '—', ownerCompany: '羚牛运营(嘉兴)', color: '黄色', regDate: '2025-05-01', inspectExpire: '2026-04-30' }, LZYTBACR2M9999001: { customer: '嘉兴某某物流有限公司', ownerCompany: '浙江羚牛氢能科技有限公司', color: '白色', regDate: '2025-11-01', inspectExpire: '2026-10-31' }, '浙F08888F': { customer: '嘉兴港务物流有限公司', ownerCompany: '羚牛运营(嘉兴)', color: '白色', regDate: '2024-08-10', inspectExpire: '2026-08-10' }, '浙F07777F': { customer: '平湖冷链运输有限公司', ownerCompany: '羚牛运营(嘉兴)', color: '蓝色', regDate: '2024-09-15', inspectExpire: '2026-09-15' }, '粤AGP9001': { customer: '广州氢能示范运营公司', ownerCompany: '羚牛运营(广东)', color: '银色', regDate: '2025-02-01', inspectExpire: '2026-07-15' }, '粤AGP9002': { customer: '深圳城配物流有限公司', ownerCompany: '羚牛运营(广东)', color: '白色', regDate: '2024-11-20', inspectExpire: '2026-06-20' }, '沪A09999F': { customer: '上海综合物流有限公司', ownerCompany: '羚牛运营(上海)', color: '绿色', regDate: '2025-04-01', inspectExpire: '2026-10-01' }, }; const createCompareRowId = () => `cr-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const createCompareSheetId = () => `cs-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const createCompareAttachmentId = () => `att-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const createQuoteId = () => `qt-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const formatFileSize = (bytes) => { if (bytes == null || Number.isNaN(bytes)) return ''; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; }; const attachmentsToUploadFileList = (attachments) => (attachments || []).map((a) => ({ uid: a.uid || a.id || createCompareAttachmentId(), name: a.name, size: a.size, type: a.type, status: 'done', uploadedAt: a.uploadedAt, })); const uploadFileListToAttachments = (fileList) => (fileList || []).map((f) => ({ id: f.uid || createCompareAttachmentId(), uid: f.uid, name: f.name, size: f.size, type: f.type || '', uploadedAt: f.uploadedAt || formatCompareSheetNow(), })); const formatCompareSheetNow = () => { if (moment) return moment(ANCHOR_TODAY).hour(10).minute(30).second(0).format('YYYY-MM-DD HH:mm:ss'); return `${ANCHOR_TODAY} 10:30:00`; }; const calcCompareSheetStats = (rows) => { const vehicleKeys = new Set(); (rows || []).forEach((row) => { const plate = (row.plateNo || '').trim(); const vin = (row.vin || '').trim(); if (plate) vehicleKeys.add(`plate:${plate.toUpperCase()}`); else if (vin) vehicleKeys.add(`vin:${vin.toUpperCase()}`); }); return { totalVehicles: vehicleKeys.size, insuranceCount: (rows || []).length, }; }; const countCompareRowsWithConfirmedQuote = (rows) => ( (rows || []).filter((r) => r.confirmedQuoteId).length ); const getLatestPayDateDiffDays = (dateStr) => { if (!dateStr || !moment) return null; const today = moment(ANCHOR_TODAY).startOf('day'); const pay = moment(dateStr).startOf('day'); if (!pay.isValid()) return null; return pay.diff(today, 'days'); }; const getLatestPayDateStatus = (dateStr) => { const diff = getLatestPayDateDiffDays(dateStr); if (diff === null) return { type: 'none', text: '未填写最晚付费日期' }; if (diff < 0) return { type: 'overdue', text: `最晚付费已超期 ${Math.abs(diff)} 天`, diffDays: diff }; if (diff <= LATEST_PAY_WARN_DAYS) return { type: 'warning', text: `最晚付费临期,剩余 ${diff} 天`, diffDays: diff }; return { type: 'normal', text: `距离最晚付费 ${diff} 天`, diffDays: diff }; }; const calcCompareSheetPayAlerts = (sheet) => { let warning = 0; let overdue = 0; (sheet?.rows || []).forEach((row) => { const st = getLatestPayDateStatus(row.latestPayDate); if (st.type === 'warning') warning += 1; if (st.type === 'overdue') overdue += 1; }); return { warning, overdue }; }; const syncCompareSheetProcurementCounts = (sheet) => { const rows = sheet?.rows || []; const submittedProcurementCount = rows.filter((r) => { const st = normalizeCompareProcurementStatus(r.procurementStatus); return st === 'submitted' || st === 'approved'; }).length; const approvedCount = rows.filter((r) => ( normalizeCompareProcurementStatus(r.procurementStatus) === 'approved' )).length; return { submittedProcurementCount, approvedCount, completedCount: approvedCount }; }; const normalizeCompareRows = (rows) => (rows || []).map((row) => ({ ...row, procurementStatus: normalizeCompareProcurementStatus(row.procurementStatus), procurementCurrentApprover: row.procurementCurrentApprover || '', })); const normalizeCompareSheet = (sheet) => { const rows = normalizeCompareRows(sheet.rows); const attachments = Array.isArray(sheet.attachments) ? sheet.attachments : []; return { ...sheet, rows, attachments, ...calcCompareSheetStats(rows), ...syncCompareSheetProcurementCounts({ rows }), }; }; const createEmptyInsuranceItem = () => ({ company: '', policyNo: '', endorsementNo: '', startDate: '', endDate: '', premium: '', payTime: '', signDate: '', coverageItems: '', applicant: '', insured: '', updateTime: '', updateUser: '', policyTag: '', reinstateDate: '', suspendTime: '', resumeTime: '', cancelTime: '', refundPremium: '', attachments: [], operationLogs: [], archivedPolicies: [], }); const INSURANCE_OPERATION_TYPE_LABEL = { add: '新增', suspend: '停保', resume: '复驶', cancel: '退保', }; const EMPTY_POLICY_BIZ_FORM = { suspendTime: '', resumeTime: '', newEndDate: '', cancelTime: '', refundPremium: '', }; const appendInsuranceOperationLog = (logs, payload) => [ { id: `iop-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, time: formatCompareSheetNow(), operator: payload.operator || PROTO_COMPARE_CREATOR, type: payload.type, remark: payload.remark || '', }, ...(logs || []), ]; const buildOperationChangeRemark = (changes) => ( (changes || []) .filter((c) => c.before !== c.after) .map((c) => `${c.label}:${c.before || '—'} → ${c.after || '—'}`) .join(';') ); const deriveLedgerMgmtPurchaseType = (item) => { if (item?.policyTag === 'cancelled') return 'cancel'; if (item?.policyTag === 'suspended') return 'rentStop'; return 'new'; }; const createLedgerMgmtHistoryRecord = (vehicle, ledgerKey, typeKey, typeLabel, item, options = {}) => { const purchaseTime = item.startDate || item.updateTime || item.endDate || item.suspendTime || ''; const derivedEventType = item.policyTag === 'cancelled' ? 'cancel' : item.policyTag === 'suspended' ? 'suspend' : 'purchase'; const record = createInsuranceHistoryRecord({ id: options.id || `ih-${ledgerKey}-${typeKey}-ledger-current`, typeKey, typeLabel, eventType: options.eventType || derivedEventType, purchaseType: options.purchaseType || deriveLedgerMgmtPurchaseType(item), time: purchaseTime, payTime: item.payTime || '', policyNo: item.policyNo, company: item.company, premium: item.premium, startDate: item.startDate, endDate: item.endDate, policyTag: item.policyTag || '', reinstateDate: item.reinstateDate || item.resumeTime || '', policyDetail: buildPolicyDetailFromLedgerItem( vehicle, typeLabel, item, item.policyTag === 'suspended' ? 'suspend' : item.policyTag === 'cancelled' ? 'cancel' : 'policy' ), source: 'ledger', sourceLabel: options.sourceLabel || '台账当前保单', fileName: item.attachments?.[0]?.name || `${item.policyNo}_${typeLabel}.pdf`, }); return { ...record, purchaseTime, operationLogs: item.operationLogs || [], isArchived: !!options.isArchived, isLedgerCurrent: !options.isArchived, attachments: item.attachments || [], summary: getInsuranceEventSummary(record), }; }; const createEmptyCompareRow = () => ({ id: createCompareRowId(), plateNo: '', vin: '', customer: '', ownerCompany: '', brand: '', model: '', bodyColor: '', regDate: '', inspectExpire: '', insureMode: '续保', insuranceType: '交强险', jqValidUntil: '', syValidUntil: '', latestPayDate: '', quotes: [], confirmedQuoteId: '', procurementStatus: 'none', procurementSubmittedAt: '', procurementCurrentApprover: '', }); const buildCompareRowFromVehicle = (v, insuranceData) => ({ id: createCompareRowId(), ...buildVehicleComparePatch(v, insuranceData), latestPayDate: '', quotes: [], confirmedQuoteId: '', procurementStatus: 'none', procurementSubmittedAt: '', procurementCurrentApprover: '', }); const cloneCompareRow = (row) => ({ ...JSON.parse(JSON.stringify(row)), id: createCompareRowId(), quotes: (row.quotes || []).map((q) => ({ ...q, id: createQuoteId() })), confirmedQuoteId: '', }); const MOCK_VEHICLES = [ { plateNo: '沪A03561F', brand: '宇通', model: '49吨牵引车头', vin: 'LMRKH9AC0R1004086', status: '自营' }, { plateNo: '粤B58888F', brand: '福田', model: '奥铃4.5吨冷藏车', vin: 'LGHXCAE28M6789012', status: '租赁' }, { plateNo: '苏E33333', brand: '陕汽', model: '德龙X3000混动牵引车', vin: 'LSXCH9AE8M1094857', status: '自营' }, { plateNo: '京A12345', brand: '东风', model: 'DFH1180厢式货车', vin: 'LKLG7C4E4NA774759', status: '退出运营' }, { plateNo: '浙A88888', brand: '宇通', model: '氢燃料电池大巴', vin: 'LMRKH9AE2P9876543', status: '库存' }, { plateNo: '沪D66666', brand: '比亚迪', model: 'T5轻卡', vin: 'LSVAU2BR3NS567890', status: '租赁' }, { plateNo: '粤A12345', brand: '比亚迪', model: '汉EV', vin: 'LGWEF4A59NS123456', status: '租赁' }, { plateNo: '苏A55678', brand: '福田', model: '欧马可4.2米', vin: 'LVBV3JBB8NY123456', status: '库存' }, { plateNo: '', brand: '东风', model: '氢燃料电池牵引车(待上牌)', vin: 'LZYTBACR2M9999001', status: '库存' }, { plateNo: '沪BDB9161', brand: '腾势', model: 'QCJ6520MBEV1纯电动', vin: 'LC0DF4CD8S0303140', status: '自营' }, { plateNo: '粤AGR9766', brand: '帕力安', model: '燃料电池翼开启厢式车', vin: 'LB9A32A21R0LS1478', status: '自营' }, { plateNo: '粤AGP9827', brand: '比亚迪', model: '轻卡', vin: 'LGXAGP98270000001', status: '租赁' }, { plateNo: '粤AGP3071', brand: '福田', model: '欧马可', vin: 'LGXAGP30710000001', status: '租赁' }, { plateNo: '粤A03423F', brand: '宇通', model: '49吨牵引', vin: 'LZYTBACR2A03423F01', status: '自营' }, { plateNo: '粤A06290F', brand: '陕汽', model: '牵引车', vin: 'LZYTBACR2A06290F01', status: '自营' }, { plateNo: '粤A03331F', brand: '东风', model: '厢式货车', vin: 'LZYTBACR2A03331F01', status: '自营' }, { plateNo: '浙F03220F', brand: '福田', model: '冷藏车', vin: 'LZYTBACR2F03220F01', status: '租赁' }, { plateNo: '沪A06192F', brand: '比亚迪', model: 'T5', vin: 'LZYTBACR2A06192F01', status: '自营' }, { plateNo: '浙F05178F', brand: '福田', model: '牵引车', vin: 'LZYTBACR2F05178F01', status: '自营' }, /* 样例:多险种同时临期/到期 */ { plateNo: '浙F08888F', brand: '宇通', model: '49吨氢能牵引车', vin: 'LMRKH9AC0R1004991', status: '自营' }, { plateNo: '浙F07777F', brand: '福田', model: '4.5吨氢能冷藏车', vin: 'LGHXCAE28M6784992', status: '租赁' }, { plateNo: '粤AGP9001', brand: '帕力安', model: '燃料电池厢式车', vin: 'LB9A32A21R0LS4993', status: '自营' }, { plateNo: '粤AGP9002', brand: '陕汽', model: '德龙氢能牵引车', vin: 'LSXCH9AE8M1094994', status: '租赁' }, { plateNo: '沪A09999F', brand: '比亚迪', model: 'T5氢能轻卡', vin: 'LSVAU2BR3NS5674995', status: '自营' }, ]; const hasVehiclePlate = (vehicle) => !!(vehicle?.plateNo || '').trim(); const getVehicleLedgerKey = (vehicleOrKey) => { if (!vehicleOrKey) return ''; if (typeof vehicleOrKey === 'object') { const plate = (vehicleOrKey.plateNo || '').trim(); if (plate) return plate; return (vehicleOrKey.vin || '').trim(); } return String(vehicleOrKey).trim(); }; const formatVehiclePlateDisplay = (plateNo) => { const p = (plateNo || '').trim(); return p || NO_PLATE_LABEL; }; const isCompareRowVehicleLinked = (row) => !!(row?.plateNo || '').trim() || !!(row?.vin || '').trim(); const getVehicleProfile = (vehicle) => { if (!vehicle) return {}; const plate = (vehicle.plateNo || '').trim(); if (plate && VEHICLE_PROFILES[plate]) return VEHICLE_PROFILES[plate]; const vin = (vehicle.vin || '').trim(); return VEHICLE_PROFILES[vin] || {}; }; const getInitialInsuranceSeed = (vehicle) => { const plate = (vehicle.plateNo || '').trim(); if (plate && INITIAL_INSURANCE_DATA[plate]) return INITIAL_INSURANCE_DATA[plate]; const vin = (vehicle.vin || '').trim(); return INITIAL_INSURANCE_DATA[vin] || null; }; const findVehicleByPlate = (plate) => { const key = (plate || '').trim().toUpperCase(); if (!key) return null; return MOCK_VEHICLES.find((v) => (v.plateNo || '').trim().toUpperCase() === key) || null; }; const findVehicleByVin = (vin) => { const key = (vin || '').trim().toUpperCase(); return MOCK_VEHICLES.find((v) => v.vin.toUpperCase() === key) || null; }; const PLATE_SELECT_OPTIONS = MOCK_VEHICLES .filter((v) => hasVehiclePlate(v)) .map((v) => ({ label: v.plateNo, value: v.plateNo })); const VIN_SELECT_OPTIONS = MOCK_VEHICLES.map((v) => ({ label: v.vin, value: v.vin })); const buildVehicleComparePatch = (vehicle, insuranceData) => { if (!vehicle) return {}; const profile = getVehicleProfile(vehicle); const ins = insuranceData[getVehicleLedgerKey(vehicle)] || {}; const jqEnd = ins.compulsory?.endDate || ''; const syEnd = ins.commercial?.endDate || ''; return { plateNo: vehicle.plateNo, vin: vehicle.vin, customer: profile.customer || '', ownerCompany: profile.ownerCompany || '', brand: vehicle.brand, model: vehicle.model, bodyColor: profile.color || '', regDate: profile.regDate || '', inspectExpire: profile.inspectExpire || '', insureMode: jqEnd || syEnd ? '续保' : '新保', insuranceType: '交强险', jqValidUntil: jqEnd, syValidUntil: syEnd, }; }; const clearVehicleComparePatch = () => ({ plateNo: '', vin: '', customer: '', ownerCompany: '', brand: '', model: '', bodyColor: '', regDate: '', inspectExpire: '', insureMode: '续保', insuranceType: '交强险', jqValidUntil: '', syValidUntil: '', }); const createEmptyInsuranceRecord = () => ({ compulsory: createEmptyInsuranceItem(), commercial: createEmptyInsuranceItem(), excess: createEmptyInsuranceItem(), cargo: createEmptyInsuranceItem(), driverAccident: createEmptyInsuranceItem(), }); const ensureInsuranceRecordShape = (record) => { const base = createEmptyInsuranceRecord(); const next = { ...base }; INSURANCE_TYPE_ITEMS.forEach(({ key }) => { next[key] = { ...base[key], ...(record?.[key] || {}) }; }); return next; }; const VEHICLE_INSURANCE_MGMT_TABS = [ { key: 'timeline', label: '保险采购全周期记录' }, { key: 'compulsory', label: '交强险' }, { key: 'commercial', label: '商业险' }, { key: 'excess', label: '超赔险' }, { key: 'driverAccident', label: '驾意险' }, { key: 'cargo', label: '货物险' }, ]; /** 管理页记录类型:新保 / 续保 / 停租 / 复驶 / 退保 */ const POLICY_PURCHASE_TYPE_META = { new: { label: '新保', color: 'success', timelineColor: 'green', chipClass: 'lc-purchase-type--new' }, renew: { label: '续保', color: 'processing', timelineColor: 'blue', chipClass: 'lc-purchase-type--renew' }, rentStop: { label: '停租', color: 'warning', timelineColor: 'orange', chipClass: 'lc-purchase-type--rent-stop' }, resume: { label: '复驶', color: 'cyan', timelineColor: 'cyan', chipClass: 'lc-purchase-type--resume' }, cancel: { label: '退保', color: 'default', timelineColor: 'gray', chipClass: 'lc-purchase-type--cancel' }, }; const POLICY_LIKE_EVENT_TYPES = new Set(['purchase', 'renew', 'procurement', 'recognize']); const isPolicyLikeInsuranceRecord = (record) => ( POLICY_LIKE_EVENT_TYPES.has(record?.eventType) || record?.purchaseType === 'new' || record?.purchaseType === 'renew' ); const eventTypeToDefaultPurchaseType = (eventType) => { if (eventType === 'suspend') return 'rentStop'; if (eventType === 'resume') return 'resume'; if (eventType === 'cancel') return 'cancel'; if (eventType === 'renew') return 'renew'; return 'new'; }; const assignPurchaseTypesToRecords = (records) => { const counters = {}; const chronological = [...records].sort((a, b) => String(a.time).localeCompare(String(b.time))); chronological.forEach((rec) => { const typeKey = rec.typeKey || '_'; if (!counters[typeKey]) counters[typeKey] = 0; let purchaseType = eventTypeToDefaultPurchaseType(rec.eventType); if (POLICY_LIKE_EVENT_TYPES.has(rec.eventType)) { purchaseType = counters[typeKey] > 0 ? 'renew' : 'new'; counters[typeKey] += 1; } rec.purchaseType = purchaseType; }); return records; }; const subtractInsuranceYears = (dateStr, years) => { if (!moment || !dateStr) return ''; const d = moment(dateStr, 'YYYY-MM-DD', true); if (!d.isValid()) return ''; return d.subtract(years, 'year').format('YYYY-MM-DD'); }; const vehicleMatchesCompareRow = (vehicle, row) => { if (!vehicle || !row) return false; const ledgerKey = getVehicleLedgerKey(vehicle); const rowKey = getVehicleLedgerKey({ plateNo: row.plateNo, vin: row.vin }); return !!ledgerKey && ledgerKey === rowKey; }; const isActiveCompareProcurementStatus = (status) => ( ACTIVE_COMPARE_PROCUREMENT_STATUSES.includes(status) ); const buildCompareSubmissionKey = (vehicleOrRow, insuranceTypeLabel) => { const key = getVehicleLedgerKey(vehicleOrRow); const type = insuranceTypeLabel || '交强险'; if (!key) return ''; return `${key}::${type}`; }; const buildActiveCompareSubmissionSet = (compareSheets) => { const set = new Set(); (compareSheets || []).forEach((sheet) => { (sheet.rows || []).forEach((row) => { if (!isActiveCompareProcurementStatus(row.procurementStatus)) return; const submissionKey = buildCompareSubmissionKey(row, row.insuranceType); if (submissionKey) set.add(submissionKey); }); }); return set; }; const isVehicleTypeSubmittedToCompare = (vehicle, insuranceTypeLabel, submissionSet) => ( submissionSet.has(buildCompareSubmissionKey(vehicle, insuranceTypeLabel)) ); const createInsuranceHistoryRecord = (payload) => ({ id: payload.id || `ih-${payload.typeKey}-${payload.eventType}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, typeKey: payload.typeKey, typeLabel: payload.typeLabel, eventType: payload.eventType, purchaseType: payload.purchaseType || eventTypeToDefaultPurchaseType(payload.eventType), time: payload.time || '', payTime: payload.payTime || '', purchaseTime: payload.time || '', policyNo: payload.policyNo || '', company: payload.company || '', premium: payload.premium || '', startDate: payload.startDate || '', endDate: payload.endDate || '', source: payload.source || 'ledger', sourceLabel: payload.sourceLabel || '', policyTag: payload.policyTag || '', reinstateDate: payload.reinstateDate || '', policyDetail: payload.policyDetail || null, fileName: payload.fileName || (payload.policyNo ? `${payload.policyNo}_${payload.typeLabel}.pdf` : '保单附件.pdf'), }); const purchaseTypeToBizType = (purchaseType, eventType) => { if (purchaseType === 'rentStop' || eventType === 'suspend') return 'suspend'; if (purchaseType === 'resume' || eventType === 'resume') return 'resume'; if (purchaseType === 'cancel' || eventType === 'cancel') return 'cancel'; return 'policy'; }; const historyRecordToPolicyDetail = (record, vehicle) => { if (record?.policyDetail) { return normalizePolicyDetail({ ...record.policyDetail, plateNo: vehicle?.plateNo || record.policyDetail.plateNo, vin: vehicle?.vin || record.policyDetail.vin, }); } return normalizePolicyDetail({ plateNo: vehicle?.plateNo || '', vin: vehicle?.vin || '', insuranceType: record?.typeLabel || '交强险', bizType: purchaseTypeToBizType(record?.purchaseType, record?.eventType), company: record?.company || '', policyNo: record?.policyNo || '', payTime: record?.payTime || '', startDate: record?.startDate || '', endDate: record?.endDate || '', reinstateDate: record?.reinstateDate || '', premium: record?.premium || '', coverageItems: normalizeCoverageItems(record?.policyDetail?.coverageItems), applicant: '', insured: '', signDate: '', }); }; const applyPolicyDetailToHistoryRecord = (record, detail) => { const d = normalizePolicyDetail(detail); const time = d.startDate || record.time; const typeLabel = d.insuranceType || record.typeLabel; const next = { ...record, policyDetail: d, typeLabel, policyNo: d.policyNo || record.policyNo, company: d.company, payTime: d.payTime, startDate: d.startDate, endDate: d.endDate, premium: d.premium, reinstateDate: d.reinstateDate, time, purchaseTime: time, fileName: d.policyNo ? `${d.policyNo}_${typeLabel}.pdf` : record.fileName, }; next.summary = getInsuranceEventSummary(next); return next; }; const applyHistoryEditsToVehicleHistory = (history, edits) => { if (!history || !edits || typeof edits !== 'object') return history; const patch = (r) => { const detail = edits[r.id]; if (!detail) return r; return applyPolicyDetailToHistoryRecord(r, detail); }; const byType = {}; Object.keys(history.byType || {}).forEach((k) => { byType[k] = (history.byType[k] || []).map(patch); }); return { ...history, timeline: (history.timeline || []).map(patch), byType, }; }; const buildPolicyDetailFromLedgerItem = (vehicle, typeLabel, item, bizType = 'policy') => normalizePolicyDetail({ plateNo: vehicle?.plateNo || '', vin: vehicle?.vin || '', insuranceType: typeLabel, bizType, company: item?.company || '', policyNo: item?.policyNo || '', endorsementNo: item?.endorsementNo || '', payTime: item?.payTime || '', signDate: item?.signDate || '', startDate: item?.startDate || '', endDate: item?.endDate || '', reinstateDate: item?.reinstateDate || '', premium: item?.premium || '', coverageItems: parseCoverageItemsInput(item?.coverageItems), applicant: item?.applicant || '', insured: item?.insured || '', }); const getInsuranceEventSummary = (record) => { const premiumText = record.premium ? `,金额 ¥${record.premium}` : ''; const typeLabel = POLICY_PURCHASE_TYPE_META[record.purchaseType]?.label || '记录'; const period = record.startDate && record.endDate ? `,期间 ${record.startDate} 至 ${record.endDate}` : (record.endDate ? `,到期日期 ${record.endDate}` : ''); switch (record.purchaseType) { case 'new': return `${typeLabel} ${record.typeLabel},保单号 ${record.policyNo || '—'}${period}${premiumText}`; case 'renew': return `${typeLabel} ${record.typeLabel},保单号 ${record.policyNo || '—'}${period}${premiumText}`; case 'rentStop': return `${typeLabel} · ${record.typeLabel},保单号 ${record.policyNo || '—'}${record.reinstateDate ? `,预计复驶 ${record.reinstateDate}` : ''}`; case 'resume': return `${typeLabel} · ${record.typeLabel},保单号 ${record.policyNo || '—'}${period}`; case 'cancel': return `${typeLabel} · ${record.typeLabel},保单号 ${record.policyNo || '—'}${premiumText}`; default: return `${record.typeLabel} · ${record.policyNo || '—'}`; } }; const isTimelineBizRecord = (item) => ( ['rentStop', 'resume', 'cancel'].includes(item?.purchaseType) || ['suspend', 'resume', 'cancel'].includes(item?.eventType) ); const buildOperationLogTimelineEntries = (insRecord) => { const entries = []; const seen = new Set(); INSURANCE_TYPE_ITEMS.forEach(({ key: typeKey, fullLabel: typeLabel }) => { const pushLogs = (item) => { if (!item) return; (item.operationLogs || []).forEach((log) => { if (!log?.id || seen.has(log.id)) return; seen.add(log.id); const purchaseType = log.type === 'suspend' ? 'rentStop' : log.type === 'resume' ? 'resume' : log.type === 'cancel' ? 'cancel' : 'new'; const record = createInsuranceHistoryRecord({ id: `tl-op-${log.id}`, typeKey, typeLabel, eventType: log.type === 'add' ? 'purchase' : log.type, purchaseType, time: log.time, policyNo: item.policyNo || '', company: item.company || '', source: 'operation', sourceLabel: '操作记录', }); entries.push({ ...record, summary: log.remark || `${INSURANCE_OPERATION_TYPE_LABEL[log.type] || log.type} · ${typeLabel}`, operator: log.operator, fromOperationLog: true, }); }); }; const item = insRecord[typeKey]; pushLogs(item); (item?.archivedPolicies || []).forEach(pushLogs); }); return entries; }; const splitVehicleInsuranceTimeline = (timeline, insRecord) => { const opEntries = buildOperationLogTimelineEntries(insRecord); const seenIds = new Set(); const all = []; [...(timeline || []), ...opEntries].forEach((item) => { if (!item?.id || seenIds.has(item.id)) return; seenIds.add(item.id); all.push({ ...item, summary: item.summary || getInsuranceEventSummary(item), }); }); const timelinePolicy = []; const timelineBiz = []; all.forEach((item) => { if (isTimelineBizRecord(item)) timelineBiz.push(item); else timelinePolicy.push(item); }); const sorter = (a, b) => String(b.time || '').localeCompare(String(a.time || '')); timelinePolicy.sort(sorter); timelineBiz.sort(sorter); return { timelinePolicy, timelineBiz }; }; const buildVehicleInsuranceHistory = (vehicle, allInsurance, compareSheets, policyRecognTasks) => { const ledgerKey = getVehicleLedgerKey(vehicle); const record = ensureInsuranceRecordShape(allInsurance[ledgerKey] || createEmptyInsuranceRecord()); const records = []; INSURANCE_TYPE_ITEMS.forEach(({ key: typeKey, fullLabel: typeLabel }) => { const item = record[typeKey]; if (!item?.policyNo) return; records.push(createLedgerMgmtHistoryRecord(vehicle, ledgerKey, typeKey, typeLabel, item)); }); (compareSheets || []).forEach((sheet) => { (sheet.rows || []).forEach((row) => { if (!vehicleMatchesCompareRow(vehicle, row)) return; const typeKey = INSURANCE_LABEL_TO_KEY[row.insuranceType] || 'compulsory'; const typeLabel = row.insuranceType || INSURANCE_TYPE_ITEMS.find((it) => it.key === typeKey)?.fullLabel || '—'; const confirmed = (row.quotes || []).find((q) => q.id === row.confirmedQuoteId); if (!confirmed) return; const eventType = normalizeCompareProcurementStatus(row.procurementStatus) === 'approved' ? 'procurement' : 'procurement'; records.push(createInsuranceHistoryRecord({ id: `ih-compare-${sheet.id}-${row.id}`, typeKey, typeLabel, eventType, time: row.procurementSubmittedAt || sheet.createdAt, policyNo: confirmed.policyNo || `CG-${String(row.id).slice(-8)}`, company: confirmed.company, premium: confirmed.premium, source: 'compare', sourceLabel: sheet.periodLabel ? `比价单 · ${sheet.periodLabel}` : '比价单采购', fileName: `${sheet.id || 'sheet'}_${typeLabel}_采购单.pdf`, })); }); }); (policyRecognTasks || []).forEach((task) => { (task.results || []).forEach((r) => { if (!r.confirmed || r.ledgerKey !== ledgerKey) return; const typeKey = r.typeKey || INSURANCE_LABEL_TO_KEY[r.insuranceTypeLabel]; if (!typeKey) return; let eventType = 'recognize'; if (task.mode === 'suspend') eventType = 'suspend'; else if (task.mode === 'cancel') eventType = 'cancel'; else if (task.mode === 'resume') eventType = 'resume'; records.push(createInsuranceHistoryRecord({ id: `ih-recognize-${task.id}-${r.id}`, typeKey, typeLabel: r.insuranceTypeLabel || INSURANCE_TYPE_ITEMS.find((it) => it.key === typeKey)?.fullLabel, eventType, time: task.completedAt || task.createdAt, policyNo: r.ocrPolicyNo, company: r.ocrCompany, premium: r.ocrPremium || '', payTime: r.ocrPayTime || '', startDate: r.ocrStartDate || '', endDate: r.ocrEndDate, reinstateDate: r.reinstateDate, policyTag: eventType === 'suspend' ? 'suspended' : eventType === 'cancel' ? 'cancelled' : '', policyDetail: r.policyDetail ? normalizePolicyDetail(r.policyDetail) : recognResultToPolicyDetail(r), source: 'recognize', sourceLabel: task.entryLabel || '批量识别', recognizeTaskId: task.id, recognizeResultId: r.id, fileName: r.fileName || `${r.ocrPolicyNo}_${r.insuranceTypeLabel}.pdf`, })); }); }); assignPurchaseTypesToRecords(records); records.sort((a, b) => String(b.time).localeCompare(String(a.time))); const timeline = records.map((r) => ({ ...r, summary: getInsuranceEventSummary(r), })); const byType = {}; INSURANCE_TYPE_ITEMS.forEach(({ key: typeKey, fullLabel: typeLabel }) => { const item = record[typeKey]; const typeRows = []; if (item?.policyNo) { typeRows.push(createLedgerMgmtHistoryRecord(vehicle, ledgerKey, typeKey, typeLabel, item)); } (item?.archivedPolicies || []).forEach((archived, archivedIndex) => { typeRows.push(createLedgerMgmtHistoryRecord(vehicle, ledgerKey, typeKey, typeLabel, archived, { id: `ih-${ledgerKey}-${typeKey}-archived-${archivedIndex}`, isArchived: true, sourceLabel: '历史保单', purchaseType: archived.policyTag === 'cancelled' ? 'cancel' : 'renew', })); }); byType[typeKey] = typeRows; }); const { timelinePolicy, timelineBiz } = splitVehicleInsuranceTimeline(timeline, record); return { timeline, timelinePolicy, timelineBiz, byType, ledgerKey }; }; const INITIAL_INSURANCE_DATA = { '沪A03561F': { compulsory: { company: '中国人民财产保险', policyNo: 'PDZA202533048200000123', startDate: '2025-01-01', endDate: '2026-12-31', premium: '950.00', updateTime: '2025-01-05 10:00', updateUser: '张明辉' }, commercial: { company: '中国人民财产保险', policyNo: 'PDAA202533048200000456', startDate: '2025-01-01', endDate: '2026-12-31', premium: '12800.50', updateTime: '2025-01-05 10:00', updateUser: '张明辉' }, excess: { company: '中国平安财产保险', policyNo: 'PAIC-CP-2025-8899', startDate: '2025-07-01', endDate: '2026-06-30', premium: '3200.00', updateTime: '2025-06-28 14:20', updateUser: '李专员' }, cargo: { company: '中国太平洋财产保险', policyNo: 'CPIC-HW-2025-1122', startDate: '2025-03-15', endDate: '2026-03-14', premium: '1800.00', updateTime: '2025-03-10 09:15', updateUser: '李专员' }, driverAccident: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' }, }, '粤B58888F': { compulsory: { company: '阳光财产保险', policyNo: 'YGCI-JQ-2025-3301', startDate: '2025-09-01', endDate: '2026-08-31', premium: '950.00', updateTime: '2025-08-28 11:00', updateUser: '王专员' }, commercial: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' }, excess: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' }, cargo: { company: '中国人寿财产保险', policyNo: 'GPIC-HW-2025-7788', startDate: '2025-04-01', endDate: '2026-03-31', premium: '1600.00', updateTime: '2025-03-28 16:40', updateUser: '王专员' }, driverAccident: { company: '中华联合财产保险', policyNo: 'CIC-JY-2025-001', startDate: '2025-05-01', endDate: '2026-04-30', premium: '560.00', updateTime: '2025-04-28 10:00', updateUser: '王专员' }, }, '苏E33333': { compulsory: { company: '中国太平洋财产保险', policyNo: 'CPIC-JQ-2024-7788', startDate: '2024-06-01', endDate: '2025-05-31', premium: '880.00', updateTime: '2024-05-28 09:00', updateUser: '陈高伟' }, commercial: { company: '中国人寿财产保险', policyNo: 'GPIC-SY-2025-1122', startDate: '2025-07-01', endDate: '2026-06-30', premium: '9850.00', updateTime: '2025-06-25 15:30', updateUser: '陈高伟' }, excess: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' }, cargo: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' }, driverAccident: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' }, }, '京A12345': { compulsory: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' }, commercial: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' }, excess: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' }, cargo: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' }, driverAccident: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' }, }, '浙A88888': { compulsory: { company: '中国人民财产保险', policyNo: 'PDZA202533048200000789', startDate: '2025-07-01', endDate: '2026-06-30', premium: '950.00', updateTime: '2025-06-28 10:00', updateUser: '张小凡' }, commercial: { company: '中国人民财产保险', policyNo: 'PDAA202533048200000790', startDate: '2025-01-01', endDate: '2027-12-31', premium: '15600.00', updateTime: '2025-01-05 10:00', updateUser: '张小凡' }, excess: { company: '中国平安财产保险', policyNo: 'PAIC-CP-2025-9900', startDate: '2025-01-01', endDate: '2026-12-31', premium: '2800.00', updateTime: '2025-01-03 11:00', updateUser: '张小凡' }, cargo: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' }, driverAccident: { company: '阳光财产保险', policyNo: 'YGCI-JY-2025-2200', startDate: '2025-09-01', endDate: '2026-08-31', premium: '480.00', updateTime: '2025-08-30 09:00', updateUser: '张小凡' }, }, '沪D66666': { compulsory: { company: '中国人民财产保险', policyNo: 'PDZA202533048200000321', startDate: '2025-02-01', endDate: '2026-01-31', premium: '950.00', updateTime: '2025-01-28 14:00', updateUser: '赵六' }, commercial: { company: '中国人民财产保险', policyNo: 'PDAA202533048200000322', startDate: '2025-02-01', endDate: '2026-01-31', premium: '11200.00', updateTime: '2025-01-28 14:00', updateUser: '赵六' }, excess: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' }, cargo: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' }, driverAccident: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' }, }, '粤A12345': { compulsory: { company: '中国平安财产保险', policyNo: 'PAIC-JQ-2025-4455', startDate: '2025-03-01', endDate: '2026-02-28', premium: '950.00', updateTime: '2025-02-25 10:00', updateUser: '张三' }, commercial: { company: '中国平安财产保险', policyNo: 'PAIC-SY-2025-4456', startDate: '2025-03-01', endDate: '2026-02-28', premium: '10500.00', updateTime: '2025-02-25 10:00', updateUser: '张三' }, excess: { company: '中国太平洋财产保险', policyNo: 'CPIC-CP-2025-5566', startDate: '2025-03-01', endDate: '2026-02-28', premium: '2400.00', updateTime: '2025-02-25 10:00', updateUser: '张三' }, cargo: { company: '中华联合财产保险', policyNo: 'CIC-HW-2025-7788', startDate: '2025-03-01', endDate: '2026-02-28', premium: '1500.00', updateTime: '2025-02-25 10:00', updateUser: '张三' }, driverAccident: { company: '阳光财产保险', policyNo: 'YGCI-JY-2025-9900', startDate: '2025-03-01', endDate: '2026-02-28', premium: '520.00', updateTime: '2025-02-25 10:00', updateUser: '张三' }, }, '苏A55678': { compulsory: { company: '中国人寿财产保险', policyNo: 'GPIC-JQ-2025-0011', startDate: '2025-05-01', endDate: '2026-04-30', premium: '880.00', updateTime: '2025-04-28 09:00', updateUser: '孙七' }, commercial: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' }, excess: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' }, cargo: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' }, driverAccident: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' }, }, LZYTBACR2M9999001: { compulsory: { company: '中国人民财产保险', policyNo: 'PDZA202533048200000901', startDate: '2025-10-01', endDate: '2026-09-30', premium: '950.00', updateTime: '2025-09-28 10:00', updateUser: '李专员' }, commercial: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' }, excess: { company: '中国平安财产保险', policyNo: 'PAIC-CP-2025-7701', startDate: '2025-10-01', endDate: '2026-09-30', premium: '2600.00', updateTime: '2025-09-28 10:00', updateUser: '李专员' }, cargo: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' }, driverAccident: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' }, }, /* 样例1:五类险种均为临期(基准日 2026-06-01 起 30 天内) */ '浙F08888F': { compulsory: { company: '中国人民财产保险', policyNo: 'PDZA-DEMO-08888-JQ', startDate: '2025-06-08', endDate: '2026-06-08', premium: '950.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, commercial: { company: '中国人民财产保险', policyNo: 'PDAA-DEMO-08888-SY', startDate: '2025-06-12', endDate: '2026-06-12', premium: '11800.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, excess: { company: '中国平安财产保险', policyNo: 'PAIC-DEMO-08888-CP', startDate: '2025-06-16', endDate: '2026-06-16', premium: '2800.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, cargo: { company: '中国太平洋财产保险', policyNo: 'CPIC-DEMO-08888-HW', startDate: '2025-06-20', endDate: '2026-06-20', premium: '1500.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, driverAccident: { company: '阳光财产保险', policyNo: 'YGCI-DEMO-08888-JY', startDate: '2025-06-25', endDate: '2026-06-25', premium: '520.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, }, /* 样例2:临期 + 到期混合 */ '浙F07777F': { compulsory: { company: '中国人寿财产保险', policyNo: 'GPIC-DEMO-07777-JQ', startDate: '2025-05-20', endDate: '2026-05-20', premium: '880.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, commercial: { company: '中国平安财产保险', policyNo: 'PAIC-DEMO-07777-SY', startDate: '2025-06-10', endDate: '2026-06-10', premium: '10200.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, excess: { company: '中华联合财产保险', policyNo: 'CIC-DEMO-07777-CP', startDate: '2025-05-31', endDate: '2026-05-31', premium: '2400.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, cargo: { company: '中国太平洋财产保险', policyNo: 'CPIC-DEMO-07777-HW', startDate: '2025-06-28', endDate: '2026-06-28', premium: '1600.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, driverAccident: { company: '阳光财产保险', policyNo: 'YGCI-DEMO-07777-JY', startDate: '2025-05-15', endDate: '2026-05-15', premium: '480.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, }, /* 样例3:四类临期 + 一类到期 */ '粤AGP9001': { compulsory: { company: '中国人民财产保险', policyNo: 'PDZA-DEMO-9001-JQ', startDate: '2025-06-05', endDate: '2026-06-05', premium: '950.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, commercial: { company: '中国人民财产保险', policyNo: 'PDAA-DEMO-9001-SY', startDate: '2025-06-30', endDate: '2026-06-30', premium: '13200.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, excess: { company: '中国平安财产保险', policyNo: 'PAIC-DEMO-9001-CP', startDate: '2025-06-18', endDate: '2026-06-18', premium: '3000.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, cargo: { company: '中华联合财产保险', policyNo: 'CIC-DEMO-9001-HW', startDate: '2025-05-28', endDate: '2026-05-28', premium: '1400.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, driverAccident: { company: '阳光财产保险', policyNo: 'YGCI-DEMO-9001-JY', startDate: '2025-06-22', endDate: '2026-06-22', premium: '560.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, }, /* 样例4:三类临期 + 两类到期(交强/商业同日落临期) */ '粤AGP9002': { compulsory: { company: '中国人寿财产保险', policyNo: 'GPIC-DEMO-9002-JQ', startDate: '2025-06-01', endDate: '2026-06-01', premium: '900.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, commercial: { company: '中国平安财产保险', policyNo: 'PAIC-DEMO-9002-SY', startDate: '2025-06-15', endDate: '2026-06-15', premium: '10800.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, excess: { company: '中国太平洋财产保险', policyNo: 'CPIC-DEMO-9002-CP', startDate: '2025-06-15', endDate: '2026-06-15', premium: '2600.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, cargo: { company: '中华联合财产保险', policyNo: 'CIC-DEMO-9002-HW', startDate: '2025-05-10', endDate: '2026-05-10', premium: '1200.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, driverAccident: { company: '阳光财产保险', policyNo: 'YGCI-DEMO-9002-JY', startDate: '2025-06-30', endDate: '2026-06-30', premium: '500.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, }, /* 样例5:五类险种均为临期(均落在 30 天临界内) */ '沪A09999F': { compulsory: { company: '中国人民财产保险', policyNo: 'PDZA-DEMO-09999-JQ', startDate: '2025-07-01', endDate: '2026-07-01', premium: '950.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, commercial: { company: '中国人民财产保险', policyNo: 'PDAA-DEMO-09999-SY', startDate: '2025-07-01', endDate: '2026-07-01', premium: '12500.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, excess: { company: '中国平安财产保险', policyNo: 'PAIC-DEMO-09999-CP', startDate: '2025-07-01', endDate: '2026-07-01', premium: '2900.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, cargo: { company: '中国太平洋财产保险', policyNo: 'CPIC-DEMO-09999-HW', startDate: '2025-07-01', endDate: '2026-07-01', premium: '1550.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, driverAccident: { company: '阳光财产保险', policyNo: 'YGCI-DEMO-09999-JY', startDate: '2025-07-01', endDate: '2026-07-01', premium: '530.00', updateTime: '2026-05-28 10:00', updateUser: '样例数据' }, }, }; const loadInsuranceFromStorage = () => { try { const raw = localStorage.getItem(IPC_STORAGE_KEY); if (!raw) return null; const parsed = JSON.parse(raw); return parsed && typeof parsed === 'object' ? parsed : null; } catch { return null; } }; const persistInsuranceToStorage = (data) => { try { localStorage.setItem(IPC_STORAGE_KEY, JSON.stringify(data)); } catch { /* ignore */ } }; const loadCompareSheetsFromStorage = () => { try { const raw = localStorage.getItem(IPC_COMPARE_SHEETS_KEY); if (!raw) return null; const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : null; } catch { return null; } }; const persistCompareSheetsToStorage = (sheets) => { try { localStorage.setItem(IPC_COMPARE_SHEETS_KEY, JSON.stringify(sheets)); } catch { /* ignore */ } }; const POLICY_RECOGN_ENTRY_LABEL = { ocr: '保单批量识别', import: '批量导入' }; const POLICY_RECOGN_MODE_LABEL = Object.fromEntries(POLICY_OCR_MODES.map((m) => [m.key, m.label])); const POLICY_RECOGN_STATUS_META = { pending_confirm: { label: '待确认', color: 'warning' }, partial: { label: '部分确认', color: 'processing' }, completed: { label: '已完成', color: 'success' }, }; const validatePolicyRecognDetailForConfirm = (detail) => validatePolicyEntryDetail(detail).ok; const getPolicyRecognSuccessResults = (results) => ( (results || []).filter((r) => r.recognSuccess !== false) ); const derivePolicyRecognTaskStatus = (results) => { const list = getPolicyRecognSuccessResults(results); const matched = list.filter((r) => r.matched); const confirmedMatched = matched.filter((r) => r.confirmed); if (matched.length > 0 && confirmedMatched.length >= matched.length) return 'completed'; if (list.some((r) => r.confirmed)) return 'partial'; return 'pending_confirm'; }; const summarizePolicyRecognTask = (results, extras = {}) => { const list = results || []; const successList = getPolicyRecognSuccessResults(list); const failList = list.filter((r) => r.recognSuccess === false); const total = extras.totalFileCount ?? list.length; const done = extras.recognDoneCount ?? (extras.phase === 'recognizing' ? 0 : total); return { fileCount: list.length, totalFileCount: total, recognDoneCount: done, recognSuccessCount: successList.length, recognFailCount: failList.length, matchedCount: successList.filter((r) => r.matched).length, confirmedCount: successList.filter((r) => r.confirmed).length, }; }; const isPolicyRecognTaskRecognizing = (task) => ( task?.phase === 'recognizing' || ((task?.totalFileCount || 0) > 0 && (task?.recognDoneCount || 0) < task.totalFileCount) ); const buildPolicyRecognTaskRecord = ({ id, entry, mode, insuranceType, results, createdAt, creator, status, completedAt, phase, totalFileCount, recognDoneCount, }) => { const taskPhase = phase || 'results'; const stats = summarizePolicyRecognTask(results, { totalFileCount, recognDoneCount, phase: taskPhase, }); return { id, createdAt: createdAt || formatCompareSheetNow(), completedAt: completedAt || '', entry, entryLabel: POLICY_RECOGN_ENTRY_LABEL[entry] || entry, mode, modeLabel: POLICY_RECOGN_MODE_LABEL[mode] || mode, insuranceType: insuranceType || '', creator: creator || PROTO_COMPARE_CREATOR, status: status || derivePolicyRecognTaskStatus(results), phase: taskPhase, ...stats, results: (results || []).map((r) => ({ ...r })), fileNames: (results || []).map((r) => r.fileName).filter(Boolean), }; }; const loadPolicyRecognTasksFromStorage = () => { try { const raw = localStorage.getItem(IPC_POLICY_RECOGN_TASKS_KEY); if (!raw) return null; const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : null; } catch { return null; } }; const persistPolicyRecognTasksToStorage = (tasks) => { try { localStorage.setItem(IPC_POLICY_RECOGN_TASKS_KEY, JSON.stringify(tasks)); } catch { /* ignore */ } }; const loadInsuranceHistoryEditsFromStorage = () => { try { const raw = localStorage.getItem(IPC_INSURANCE_HISTORY_EDITS_KEY); if (!raw) return {}; const parsed = JSON.parse(raw); return parsed && typeof parsed === 'object' ? parsed : {}; } catch { return {}; } }; const persistInsuranceHistoryEditsToStorage = (edits) => { try { localStorage.setItem(IPC_INSURANCE_HISTORY_EDITS_KEY, JSON.stringify(edits)); } catch { /* ignore */ } }; const createMockPolicyRecognTasks = () => { const insMap = buildMockInsuranceMap(); const filesCompleted1 = [ { uid: 'demo-c1', name: '粤BDG9701_交强险.pdf', status: 'done' }, { uid: 'demo-c2', name: '粤AGR9766_商业险.pdf', status: 'done' }, { uid: 'demo-c3', name: '沪A03561F_交强险.pdf', status: 'done' }, ]; const resultsCompleted1 = buildMockOcrResults(filesCompleted1, 'policy', '交强险', insMap); if (resultsCompleted1[0]) resultsCompleted1[0].confirmed = true; const filesCompleted2 = [ { uid: 'demo-c4', name: '粤B88888_复驶批单.pdf', status: 'done' }, { uid: 'demo-c5', name: '京ADH1653_复驶批单.pdf', status: 'done' }, { uid: 'demo-c6', name: '粤BDG9701_复驶批单.pdf', status: 'done' }, { uid: 'demo-c7', name: '模糊扫描件_复驶.pdf', status: 'done' }, ]; const resultsCompleted2 = buildMockOcrResults(filesCompleted2, 'resume', '', insMap); resultsCompleted2.forEach((r) => { if (r.recognSuccess !== false) r.confirmed = true; }); return [ buildPolicyRecognTaskRecord({ id: 'TASK-83892906', entry: 'ocr', mode: 'policy', insuranceType: '交强险', results: resultsCompleted1, createdAt: '2026-05-28 15:20:10', completedAt: '2026-05-28 15:32:00', phase: 'results', totalFileCount: filesCompleted1.length, recognDoneCount: filesCompleted1.length, }), buildPolicyRecognTaskRecord({ id: 'TASK-84120155', entry: 'ocr', mode: 'resume', insuranceType: '', results: resultsCompleted2, createdAt: '2026-05-30 09:15:00', completedAt: '2026-05-30 09:18:40', phase: 'results', totalFileCount: filesCompleted2.length, recognDoneCount: filesCompleted2.length, }), buildPolicyRecognTaskRecord({ id: 'TASK-84210588', entry: 'ocr', mode: 'policy', insuranceType: '商业险', results: [], createdAt: '2026-06-01 10:08:22', phase: 'recognizing', totalFileCount: 5, recognDoneCount: 2, }), ]; }; const buildMockInsuranceMap = () => { const map = {}; MOCK_VEHICLES.forEach((v) => { const key = getVehicleLedgerKey(v); const seed = getInitialInsuranceSeed(v); map[key] = seed ? JSON.parse(JSON.stringify(seed)) : createEmptyInsuranceRecord(); }); return map; }; const createMockCompareRowWithQuote = (vehicle, insMap, insuranceType, premium, extra = {}) => { const row = buildCompareRowFromVehicle(vehicle, insMap); const quoteId = createQuoteId(); row.insuranceType = insuranceType; row.quotes = [{ id: quoteId, company: INSURANCE_MGMT_COMPANIES[0], premium }]; row.confirmedQuoteId = quoteId; row.latestPayDate = extra.latestPayDate || '2026-06-15'; row.procurementStatus = normalizeCompareProcurementStatus(extra.procurementStatus || 'none'); row.procurementSubmittedAt = extra.procurementSubmittedAt || ''; row.procurementCurrentApprover = extra.procurementCurrentApprover || ''; return row; }; const createMockCompareSheets = () => { const insMap = buildMockInsuranceMap(); const sheet1Rows = normalizeCompareRows([ createMockCompareRowWithQuote(MOCK_VEHICLES[0], insMap, '交强险', '950.00', { latestPayDate: '2026-06-03', procurementStatus: 'approved' }), createMockCompareRowWithQuote(MOCK_VEHICLES[0], insMap, '商业险', '12800.50', { latestPayDate: '2026-06-04', procurementStatus: 'submitted', procurementCurrentApprover: MOCK_WORKFLOW_CURRENT_APPROVERS[0], procurementSubmittedAt: '2026-05-29 10:00:00', }), createMockCompareRowWithQuote(MOCK_VEHICLES[1], insMap, '交强险', '950.00', { latestPayDate: '2026-05-28', procurementStatus: 'submitted', procurementSubmittedAt: '2026-05-31 09:15:00', procurementCurrentApprover: '', }), ]); const sheet2Rows = normalizeCompareRows([ createMockCompareRowWithQuote(MOCK_VEHICLES[2], insMap, '交强险', '880.00', { latestPayDate: '2026-06-02', procurementStatus: 'approved' }), createMockCompareRowWithQuote(MOCK_VEHICLES[2], insMap, '商业险', '9850.00', { latestPayDate: '2026-06-02', procurementStatus: 'approved' }), createMockCompareRowWithQuote(MOCK_VEHICLES[7], insMap, '交强险', '950.00', { latestPayDate: '2026-06-01', procurementStatus: 'submitted', procurementCurrentApprover: MOCK_WORKFLOW_CURRENT_APPROVERS[1], procurementSubmittedAt: '2026-05-30 14:00:00', }), createMockCompareRowWithQuote(MOCK_VEHICLES[7], insMap, '超赔险', '2600.00', { latestPayDate: '2026-07-01', procurementStatus: 'submitted', procurementCurrentApprover: MOCK_WORKFLOW_CURRENT_APPROVERS[2], procurementSubmittedAt: '2026-05-30 14:00:00', }), createMockCompareRowWithQuote(MOCK_VEHICLES[3], insMap, '交强险', '950.00', { latestPayDate: '2026-06-10', procurementStatus: 'submitted', procurementCurrentApprover: MOCK_WORKFLOW_CURRENT_APPROVERS[3], procurementSubmittedAt: '2026-05-27 16:40:00', }), createMockCompareRowWithQuote(MOCK_VEHICLES[1], insMap, '商业险', '11200.00', { latestPayDate: '2026-05-20', procurementStatus: 'withdrawn', procurementSubmittedAt: '2026-05-18 11:20:00', }), createMockCompareRowWithQuote(MOCK_VEHICLES[1], insMap, '货物险', '1600.00', { latestPayDate: '2026-05-22', procurementStatus: 'rejected', procurementSubmittedAt: '2026-05-19 09:30:00', }), ]); const sheet3Rows = normalizeCompareRows([ createMockCompareRowWithQuote(MOCK_VEHICLES[4], insMap, '商业险', '15600.00', { latestPayDate: '2026-05-20' }), ]); return [ normalizeCompareSheet({ id: 'cs-mock-20260528', createdAt: '2026-05-28 14:20:00', createdBy: '张明辉', periodLabel: '2026年5-6月', remark: '华东区二季度集中采购', attachments: [ { id: 'att-demo-1', uid: 'att-demo-1', name: '6月比价询价单.pdf', size: 245760, type: 'application/pdf', uploadedAt: '2026-05-28 14:18:00' }, { id: 'att-demo-2', uid: 'att-demo-2', name: '保险公司报价截图.zip', size: 1048576, type: 'application/zip', uploadedAt: '2026-05-28 14:19:00' }, ], rows: sheet1Rows, }), normalizeCompareSheet({ id: 'cs-mock-20260520', createdAt: '2026-05-20 09:15:00', createdBy: '李专员', periodLabel: '2026年5月', remark: '苏粤车辆续保比价', attachments: [ { id: 'att-demo-3', uid: 'att-demo-3', name: '5月比价汇总表.xlsx', size: 186240, type: 'application/vnd.ms-excel', uploadedAt: '2026-05-20 09:10:00' }, ], rows: sheet2Rows, }), normalizeCompareSheet({ id: 'cs-mock-20260510', createdAt: '2026-05-10 16:40:00', createdBy: '王专员', periodLabel: '2026年5月', remark: '浙A88888 商业险新保询价', attachments: [ { id: 'att-demo-4', uid: 'att-demo-4', name: '询价邮件截图.png', size: 98304, type: 'image/png', uploadedAt: '2026-05-10 16:35:00' }, ], rows: sheet3Rows, }), ]; }; const compareSheetMatchesPlateFilter = (sheet, plateKey) => { if (!plateKey) return true; const key = plateKey.trim().toLowerCase(); return (sheet.rows || []).some((row) => { const plate = (row.plateNo || '').trim().toLowerCase(); const vin = (row.vin || '').trim().toLowerCase(); if (plate && plate.includes(key)) return true; if (!plate && (NO_PLATE_LABEL.toLowerCase().includes(key) || key.includes('暂无'))) return true; if (vin && vin.includes(key)) return true; return false; }); }; const compareSheetMatchesCreatedRange = (createdAt, range) => { if (!range || !range[0] || !range[1]) return true; if (!createdAt || !moment) return true; const day = moment(String(createdAt).slice(0, 10), 'YYYY-MM-DD', true); if (!day.isValid()) return true; const start = range[0].clone().startOf('day'); const end = range[1].clone().endOf('day'); return day.isSameOrAfter(start) && day.isSameOrBefore(end); }; const insuranceEndDateMatchesRange = (endDate, range) => { if (!range?.[0] || !range?.[1]) return true; if (!endDate || !moment) return false; const day = moment(endDate, 'YYYY-MM-DD', true); if (!day.isValid()) return false; const start = range[0].clone().startOf('day'); const end = range[1].clone().endOf('day'); return day.isSameOrAfter(start) && day.isSameOrBefore(end); }; const vehicleMatchesListInsuranceTypeFilter = (ledgerKey, insuranceTypeLabel, endDateRange, insuranceData) => { if (!insuranceTypeLabel) return true; const typeKey = INSURANCE_LABEL_TO_KEY[insuranceTypeLabel]; if (!typeKey) return true; const item = insuranceData?.[ledgerKey]?.[typeKey]; const endDate = item?.endDate; if (!endDate && !item?.policyNo) return false; if (endDateRange?.[0] && endDateRange?.[1]) { return insuranceEndDateMatchesRange(endDate, endDateRange); } return true; }; const DEFAULT_COMPARE_MGMT_FILTERS = { plateNo: '', createdRange: null }; const DEFAULT_COMPARE_EDITOR_FILTERS = { vehicles: '', latestPayWithin3Days: false, insuranceType: '' }; const DEFAULT_POLICY_RECOGN_TASK_FILTERS = { mode: '全部', createdRange: null }; const compareRowMatchesVehicleFilter = (row, vehicleText) => { const tokens = parseMultiPlates(vehicleText); if (!tokens.length) return true; const plate = (row.plateNo || '').trim().toUpperCase(); const vin = (row.vin || '').trim().toUpperCase(); return tokens.some((token) => { if (plate && (plate === token || plate.includes(token))) return true; if (vin && (vin === token || vin.includes(token))) return true; return false; }); }; const isCompareRowLatestPayWithinDays = (row, days = LATEST_PAY_WARN_DAYS) => { const diff = getLatestPayDateDiffDays(row.latestPayDate); if (diff === null) return false; return diff <= days; }; const filterCompareEditorRows = (rows, filters) => { const type = filters.insuranceType || ''; return (rows || []).filter((row) => { if (!compareRowMatchesVehicleFilter(row, filters.vehicles)) return false; if (filters.latestPayWithin3Days && !isCompareRowLatestPayWithinDays(row)) return false; if (type && (row.insuranceType || '交强险') !== type) return false; return true; }); }; const countCompareRowsByInsuranceType = (rows) => { const counts = {}; QUOTE_INSURANCE_TYPES.forEach((t) => { counts[t] = 0; }); (rows || []).forEach((row) => { const t = row.insuranceType || '交强险'; if (counts[t] !== undefined) counts[t] += 1; }); return counts; }; const tableTitleMultiline = (...lines) => (
{lines.map((line, idx) => ( {line} ))}
); const listColumnHeaderCell = () => ({ className: 'lc-th-wrap' }); const mapInsuranceStatusToBadge = (type) => { if (type === 'success') return 'success'; if (type === 'warning') return 'warning'; if (type === 'expired') return 'error'; return 'default'; }; /** 到期日期列:剩余 / 过期天数文案 */ const getInsuranceRemainShortText = (status) => { const { type, diffDays } = status || {}; if (type === 'unuploaded') return '未购买'; if (type === 'expired') return diffDays != null ? `过期${Math.abs(diffDays)}天` : '已过期'; if (diffDays != null) return `剩余${diffDays}天`; return '—'; }; const sortVehiclesRetiredLast = (vehicles) => { const active = []; const retired = []; vehicles.forEach((v) => { if (v.status === '退出运营') retired.push(v); else active.push(v); }); return [...active, ...retired]; }; const parseMultiPlates = (text) => { const raw = (text || '').trim(); if (!raw) return []; const lines = raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); const expanded = lines.flatMap((line) => { if (/[,,、;;]/.test(line)) { return line.split(/[,,、;;]+/).map((s) => s.trim()).filter(Boolean); } return [line]; }); return [...new Set(expanded.map((s) => s.toUpperCase()))]; }; /** 批量新增比价行:每行一条车牌或 VIN,不去重 */ const parseBatchVehicleLines = (text) => ( (text || '').trim().split(/\r?\n/).map((line) => line.trim()).filter(Boolean) ); const findVehicleByPlateOrVin = (token) => { const key = (token || '').trim(); if (!key) return null; return findVehicleByPlate(key) || findVehicleByVin(key); }; const ICONS = { vehicle: , success: , warning: , shield: , policy: , more: , }; const PAGE_STYLE = ` .lc-edit-page { font-family: system-ui, -apple-system, sans-serif; color: #1e293b; } .lc-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; } .lc-filter-card.ant-card { border-radius: 16px !important; border: 1px solid #e2e8f0 !important; box-shadow: 0 4px 20px -4px rgba(15, 23, 42, 0.03) !important; margin-bottom: 16px; } .lc-filter-card > .ant-card-head { border-bottom: 1px solid #f1f5f9 !important; min-height: auto; padding: 12px 20px !important; } .lc-filter-card > .ant-card-head .ant-card-head-title { font-size: 15px !important; font-weight: 700 !important; color: #0f172a !important; padding: 0 !important; } .lc-filter-card > .ant-card-body { padding: 16px 20px 20px !important; } .lc-filter-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px 24px; } @media (max-width: 1100px) { .lc-filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 720px) { .lc-filter-grid { grid-template-columns: 1fr; } } .lc-filter-field { display: flex; align-items: center; gap: 12px; min-width: 0; } .lc-filter-field-label { flex: 0 0 72px; text-align: right; font-size: 13px; font-weight: 500; color: #475569; line-height: 1.4; white-space: nowrap; } .lc-filter-field-control { flex: 1; min-width: 0; } .lc-filter-field-control .ant-input, .lc-filter-field-control .ant-select { width: 100%; } .lc-multi-plate-pop { width: 320px; padding: 4px 2px; } .lc-multi-plate-pop-hint { font-size: 12px; color: #64748b; margin-bottom: 8px; line-height: 1.5; } .lc-multi-plate-pop-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; } .lc-multi-plate-trigger { cursor: pointer; } .lc-multi-plate-trigger .ant-input { cursor: pointer; } .lc-filter-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; padding-top: 16px; border-top: 1px solid #f1f5f9; } .lc-alert-stats-row { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; } @media (max-width: 1200px) { .lc-alert-stats-row { grid-template-columns: repeat(3, minmax(0, 1fr)); } } @media (max-width: 768px) { .lc-alert-stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } } .lc-alert-card { display: flex; align-items: flex-start; gap: 12px; padding: 14px 30px 14px 16px; border-radius: 12px; border: 1px solid #e2e8f0; background: #fff; position: relative; overflow: hidden; min-width: 0; } .lc-alert-card-main { flex: 1; min-width: 0; } .lc-alert-card-icon { flex-shrink: 0; width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; } .lc-alert-card-val { font-size: 26px; font-weight: 800; line-height: 1.1; color: #0f172a; font-variant-numeric: tabular-nums; } .lc-alert-card-title { font-size: 13px; font-weight: 600; color: #334155; margin-top: 2px; } .lc-alert-card-tip-anchor { position: absolute; top: 8px; right: 8px; z-index: 2; line-height: 0; } .lc-alert-card-tip { width: 18px; height: 18px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: #94a3b8; background: rgba(255,255,255,.92); border: 1px solid #e2e8f0; cursor: help; line-height: 0; } .lc-alert-card-tip:hover { color: #64748b; border-color: #cbd5e1; background: #fff; } .lc-alert-card--total { background: linear-gradient(135deg, #f8fafc 0%, #fff 100%); } .lc-alert-card--total .lc-alert-card-icon { background: #e2e8f0; color: #475569; } .lc-alert-card--normal { background: linear-gradient(135deg, #ecfdf5 0%, #fff 55%); border-color: #bbf7d0; } .lc-alert-card--normal .lc-alert-card-icon { background: #d1fae5; color: #059669; } .lc-alert-card--normal .lc-alert-card-val { color: #047857; } .lc-alert-card--warning { background: linear-gradient(135deg, #fff7ed 0%, #fff 55%); border-color: #fed7aa; } .lc-alert-card--warning .lc-alert-card-icon { background: #ffedd5; color: #ea580c; } .lc-alert-card--warning .lc-alert-card-val { color: #c2410c; } .lc-alert-card--expired { background: linear-gradient(135deg, #fef2f2 0%, #fff 55%); border-color: #fecaca; } .lc-alert-card--expired .lc-alert-card-icon { background: #fee2e2; color: #dc2626; } .lc-alert-card--expired .lc-alert-card-val { color: #b91c1c; } .lc-alert-card--unuploaded { background: linear-gradient(135deg, #f8fafc 0%, #fff 55%); } .lc-alert-card--unuploaded .lc-alert-card-icon { background: #f1f5f9; color: #64748b; } .lc-alert-card--unuploaded .lc-alert-card-val { color: #64748b; } .lc-alert-card-clickable { cursor: pointer; transition: box-shadow .2s ease, border-color .2s ease; } .lc-alert-card-clickable:hover { box-shadow: 0 4px 14px rgba(15, 23, 42, 0.08); } .lc-alert-card-active { box-shadow: 0 0 0 2px rgba(22, 93, 255, 0.2) !important; border-color: #165dff !important; } .lc-table-section { margin-bottom: 0; } .lc-table-toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px 16px; margin-bottom: 8px; min-height: 32px; } .lc-table-card { background: #fff; border-radius: 16px; border: 1px solid #e2e8f0; box-shadow: 0 4px 20px -4px rgba(15, 23, 42, 0.03); overflow: hidden; } .lc-table-card .ant-table-thead > tr > th { background: #f8fafc !important; color: #475569 !important; font-weight: 700 !important; font-size: 13px !important; border-bottom: 1px solid #e2e8f0 !important; padding: 12px 16px !important; vertical-align: middle; } .lc-table-card .ant-table-thead > tr > th.lc-th-wrap { padding: 10px 8px !important; text-align: center; vertical-align: middle; white-space: nowrap; } .lc-table-th-multiline { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; line-height: 1.3; white-space: normal; } .lc-table-th-line { display: block; font-size: 12px; font-weight: 700; color: #475569; } .lc-list-expire-cell { display: flex; flex-direction: column; gap: 4px; min-width: 0; } .lc-list-expire-date { font-size: 12px; font-weight: 600; line-height: 1.35; } .lc-list-expire-meta { line-height: 1.2; } .lc-list-status-badge-wrap { display: inline-flex; max-width: 100%; } .lc-list-status-badge-wrap .ant-badge { display: inline-flex; align-items: center; max-width: 100%; } .lc-list-status-badge-text { font-size: 11px; font-weight: 600; white-space: nowrap; } .lc-list-table .ant-table-wrapper, .lc-list-table .ant-table { width: 100% !important; } .lc-list-table .ant-table-content table { table-layout: fixed; width: 100% !important; } .lc-cell-ellipsis { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .lc-table-card .ant-table-tbody > tr:not(.ant-table-measure-row) > td { padding: 10px 8px !important; border-bottom: 1px solid #f1f5f9 !important; } .lc-table-card .ant-table-tbody > tr:not(.ant-table-measure-row):hover > td { background: #f8fafc !important; } .lc-table-card .ant-table-tbody > tr.lc-row-retired:not(.ant-table-measure-row) > td { background: #f8fafc !important; color: #94a3b8; } .lc-list-plate-cell { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .lc-list-plate-sub { display: block; font-size: 11px; font-weight: 500; line-height: 1.35; } .lc-list-plate-empty { color: #94a3b8 !important; font-style: italic; font-weight: 500 !important; } .lc-table-toolbar-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-left: auto; } .lc-compare-modal .ant-modal-content { border-radius: 16px; overflow: hidden; } .lc-compare-modal .ant-modal-header { background: linear-gradient(135deg, #f8fafc 0%, #fff 100%); border-bottom: 1px solid #e2e8f0; padding: 16px 20px; } .lc-compare-modal .ant-modal-title { font-size: 16px; font-weight: 700; color: #0f172a; } .lc-compare-modal .ant-modal-body { padding: 16px 20px 12px; max-height: calc(100vh - 180px); overflow: auto; } .lc-compare-modal .ant-modal-footer { border-top: 1px solid #f1f5f9; padding: 12px 20px 16px; } .lc-compare-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-wrap: wrap; gap: 8px; } .lc-compare-table-wrap { border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden; background: #fff; box-shadow: 0 4px 16px -4px rgba(15, 23, 42, 0.06); } .lc-compare-table .ant-table-thead > tr > th { background: #f1f5f9 !important; color: #334155 !important; font-size: 12px !important; font-weight: 700 !important; padding: 10px 8px !important; white-space: nowrap; border-bottom: 1px solid #e2e8f0 !important; } .lc-compare-table .ant-table-thead > tr > th.lc-compare-th-key { background: #ecfdf5 !important; color: #065f46 !important; } .lc-compare-table .ant-table-thead > tr > th.lc-compare-th-auto { background: #f8fafc !important; } .lc-compare-table .ant-table-thead > tr > th.lc-compare-th-edit { background: #fffbeb !important; color: #92400e !important; } .lc-compare-th-batch-trigger { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; user-select: none; white-space: nowrap; } .lc-compare-th-batch-trigger:hover { color: #059669 !important; } .lc-compare-th-batch-tag { font-size: 10px; font-weight: 700; color: #059669; background: #ecfdf5; border: 1px solid #bbf7d0; padding: 0 4px; border-radius: 4px; line-height: 16px; } .lc-compare-table .ant-table-thead > tr > th.ant-table-cell-fix-right.lc-compare-th-edit { background: #fffbeb !important; z-index: 3; } .lc-compare-table .ant-table-thead > tr > th.ant-table-cell-fix-right.lc-compare-th-auto { background: #f8fafc !important; z-index: 3; } .lc-compare-table .ant-table-tbody > tr > td.ant-table-cell-fix-right { z-index: 2; } .lc-compare-table .ant-table-tbody > tr > td { padding: 8px 6px !important; vertical-align: middle !important; font-size: 12px; border-bottom: 1px solid #f1f5f9 !important; } .lc-compare-table .ant-table-tbody > tr:nth-child(even) > td { background: #fafbfc; } .lc-compare-table .ant-table-tbody > tr:hover > td { background: #f0fdf4 !important; } .lc-compare-table .ant-table-tbody > tr.ant-table-row-selected > td { background: #ecfdf5 !important; } .lc-compare-table .ant-table-thead > tr > th.ant-table-cell-fix-left, .lc-compare-table .ant-table-thead > tr > th.ant-table-cell-fix-right { background: #f1f5f9 !important; } .lc-compare-table .ant-table-thead > tr > th.ant-table-cell-fix-left.lc-compare-th-key { background: #ecfdf5 !important; } .lc-compare-table .ant-table-tbody > tr > td.ant-table-cell-fix-left, .lc-compare-table .ant-table-tbody > tr > td.ant-table-cell-fix-right { background: #fff !important; } .lc-compare-table .ant-table-tbody > tr:nth-child(even) > td.ant-table-cell-fix-left, .lc-compare-table .ant-table-tbody > tr:nth-child(even) > td.ant-table-cell-fix-right { background: #fafbfc !important; } .lc-compare-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-left, .lc-compare-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-right { background: #f0fdf4 !important; } .lc-compare-table .ant-table-tbody > tr.ant-table-row-selected > td.ant-table-cell-fix-left, .lc-compare-table .ant-table-tbody > tr.ant-table-row-selected > td.ant-table-cell-fix-right { background: #ecfdf5 !important; } .lc-compare-table .ant-table-cell-fix-left-last::after, .lc-compare-table .ant-table-cell-fix-right-first::after { box-shadow: inset 10px 0 8px -8px rgba(15, 23, 42, 0.12); } .lc-compare-cell-select { width: 100%; min-width: 100px; } .lc-compare-cell-select .ant-select-selector { border-radius: 6px !important; font-size: 12px !important; min-height: 28px !important; } .lc-compare-cell-input { width: 100%; min-width: 88px; border-radius: 6px; } .lc-compare-cell-input.ant-input-sm, .lc-compare-cell-input.ant-picker-small { font-size: 12px; } .lc-compare-readonly { display: block; padding: 4px 8px; min-height: 28px; line-height: 20px; border-radius: 6px; background: #f8fafc; border: 1px solid #f1f5f9; color: #334155; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .lc-compare-readonly--wrap { white-space: normal; word-break: break-all; line-height: 1.4; } .lc-compare-readonly.is-empty { color: #94a3b8; } .lc-compare-readonly.is-linked { background: #f0fdf4; border-color: #bbf7d0; color: #065f46; font-weight: 500; } .lc-compare-action-cell { display: flex; align-items: center; gap: 2px; flex-wrap: wrap; } .lc-compare-action-cell .ant-btn-link { font-size: 12px; font-weight: 600; padding: 0 4px; height: auto; } .lc-compare-quote-btn { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 6px; background: #ecfdf5; border: 1px solid #a7f3d0; color: #059669 !important; font-weight: 600 !important; font-size: 12px !important; } .lc-compare-quote-btn:hover { background: #d1fae5 !important; } .lc-quote-popover-overlay .ant-popover-inner { padding: 0 !important; border-radius: 14px !important; overflow: hidden; box-shadow: 0 16px 48px -12px rgba(15, 23, 42, 0.22) !important; border: 1px solid #e2e8f0; } .lc-quote-popover-overlay .ant-popover-arrow { display: none; } .lc-quote-card { width: 400px; max-width: 92vw; } .lc-quote-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 14px 16px; background: linear-gradient(135deg, #ecfdf5 0%, #f8fafc 55%, #fff 100%); border-bottom: 1px solid #e2e8f0; } .lc-quote-card-title { font-size: 15px; font-weight: 700; color: #0f172a; } .lc-quote-card-plate { font-size: 11px; font-weight: 600; color: #059669; background: #d1fae5; border: 1px solid #a7f3d0; padding: 2px 8px; border-radius: 999px; white-space: nowrap; } .lc-quote-card-body { padding: 14px 16px 16px; max-height: 420px; overflow-y: auto; } .lc-quote-card-body::-webkit-scrollbar { width: 4px; } .lc-quote-card-body::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 2px; } .lc-quote-list-label { font-size: 11px; font-weight: 700; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 8px; } .lc-quote-empty { text-align: center; padding: 20px 12px; border-radius: 10px; background: #f8fafc; border: 1px dashed #e2e8f0; color: #94a3b8; font-size: 12px; margin-bottom: 14px; } .lc-quote-item-card { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; margin-bottom: 8px; border-radius: 10px; border: 1px solid #e2e8f0; background: #fff; transition: border-color .2s, box-shadow .2s; } .lc-quote-item-card:hover { border-color: #cbd5e1; box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04); } .lc-quote-item-card.is-selected { border-color: #10b981; background: linear-gradient(135deg, #f0fdf4 0%, #fff 100%); box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.15); } .lc-quote-item-card .ant-radio { margin-top: 2px; } .lc-quote-item-main { flex: 1; min-width: 0; } .lc-quote-item-company { font-size: 13px; font-weight: 600; color: #0f172a; line-height: 1.4; margin-bottom: 4px; } .lc-quote-item-row { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; font-size: 12px; color: #64748b; } .lc-quote-type-tag { display: inline-flex; align-items: center; padding: 1px 7px; border-radius: 4px; background: #eff6ff; border: 1px solid #bfdbfe; color: #1d4ed8; font-size: 11px; font-weight: 600; } .lc-quote-price { font-size: 14px; font-weight: 700; color: #059669; font-variant-numeric: tabular-nums; } .lc-quote-price-unit { font-size: 11px; font-weight: 500; color: #64748b; margin-left: 2px; } .lc-quote-item-del { flex-shrink: 0; padding: 0 4px !important; height: auto !important; font-size: 12px !important; } .lc-quote-form-wrap { margin-top: 14px; padding-top: 14px; border-top: 1px solid #f1f5f9; } .lc-quote-form-title { font-size: 13px; font-weight: 700; color: #0f172a; margin-bottom: 12px; display: flex; align-items: center; gap: 6px; } .lc-quote-form-title::before { content: ''; width: 3px; height: 14px; border-radius: 2px; background: #10b981; } .lc-quote-form-field { margin-bottom: 10px; } .lc-quote-form-label { display: block; font-size: 12px; font-weight: 600; color: #475569; margin-bottom: 5px; } .lc-quote-form-label-required::after { content: ' *'; color: #ef4444; } .lc-quote-form-actions { display: flex; justify-content: flex-end; margin-top: 4px; } .lc-quote-form-actions .ant-btn-primary { border-radius: 8px; font-weight: 600; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border: none; box-shadow: 0 2px 8px rgba(16, 185, 129, 0.25); } .lc-compare-quote-cell { display: flex; flex-direction: column; gap: 6px; min-width: 0; } .lc-compare-quote-inline-list { display: flex; flex-direction: column; gap: 4px; width: 100%; } .lc-compare-quote-inline-item { display: flex; align-items: center; gap: 4px; padding: 4px 6px; border-radius: 6px; border: 1px solid #e2e8f0; background: #fff; cursor: pointer; transition: border-color .15s, background .15s; margin: 0 !important; } .lc-compare-quote-inline-item:hover { border-color: #cbd5e1; background: #f8fafc; } .lc-compare-quote-inline-item.is-selected { border-color: #10b981; background: #f0fdf4; box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.12); } .lc-compare-quote-inline-item .ant-radio { margin-right: 0; top: 0; } .lc-compare-quote-inline-company { flex: 1; min-width: 0; font-size: 11px; font-weight: 600; color: #334155; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .lc-compare-quote-inline-price { flex-shrink: 0; font-size: 12px; font-weight: 700; color: #059669; font-variant-numeric: tabular-nums; } .lc-compare-quote-inline-del { flex-shrink: 0; padding: 0 2px !important; height: 18px !important; min-width: 18px !important; font-size: 14px !important; line-height: 1 !important; color: #94a3b8 !important; } .lc-compare-quote-inline-del:hover { color: #ef4444 !important; } .lc-compare-quote-add { padding: 0 !important; height: auto !important; font-size: 12px !important; font-weight: 600 !important; color: #059669 !important; align-self: flex-start; } .lc-compare-quote-empty-hint { font-size: 11px; color: #94a3b8; line-height: 1.4; } .lc-quote-card-type-badge { font-size: 11px; font-weight: 600; color: #1d4ed8; background: #eff6ff; border: 1px solid #bfdbfe; padding: 2px 8px; border-radius: 999px; white-space: nowrap; } .lc-compare-footer { margin-top: 14px; padding-top: 14px; border-top: 1px solid #e2e8f0; display: flex; flex-direction: column; gap: 12px; } .lc-compare-remark-field { display: flex; flex-direction: column; gap: 6px; } .lc-compare-remark-label { font-size: 13px; font-weight: 600; color: #334155; } .lc-compare-field-label-required::after { content: '*'; color: #ef4444; margin-left: 2px; } .lc-compare-total-row { display: flex; gap: 12px; align-items: stretch; } @media (max-width: 720px) { .lc-compare-total-row { flex-direction: column; } } .lc-compare-total-bar { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px 16px; padding: 12px 16px; border-radius: 10px; background: linear-gradient(135deg, #ecfdf5 0%, #f8fafc 100%); border: 1px solid #bbf7d0; } .lc-compare-total-row .lc-compare-total-bar { flex: 1; min-width: 0; flex-direction: column; align-items: flex-start; gap: 6px; } .lc-compare-total-label { font-size: 13px; font-weight: 600; color: #334155; } .lc-compare-total-amount { font-size: 22px; font-weight: 800; color: #059669; font-variant-numeric: tabular-nums; line-height: 1.2; } .lc-compare-total-unit { font-size: 13px; font-weight: 600; color: #059669; margin-left: 2px; } .lc-compare-total-hint { font-size: 12px; color: #64748b; margin-left: auto; } .lc-compare-total-row .lc-compare-total-hint { margin-left: 0; } .lc-copy-pop { width: 200px; } .lc-copy-pop-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; } .lc-compare-mgmt-modal .ant-modal-body { padding: 16px 20px 20px; } .lc-compare-mgmt-filter { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 24px; margin-bottom: 14px; padding-bottom: 14px; border-bottom: 1px solid #f1f5f9; } @media (max-width: 720px) { .lc-compare-mgmt-filter { grid-template-columns: 1fr; } } .lc-compare-mgmt-toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; } .lc-compare-mgmt-table .ant-table-thead > tr > th { background: #f8fafc !important; font-weight: 700 !important; font-size: 13px !important; } .lc-compare-mgmt-table .ant-table-tbody > tr > td { font-size: 13px; } .lc-compare-mgmt-count { font-variant-numeric: tabular-nums; font-weight: 700; color: #0f172a; } .lc-expiring-warn-modal .ant-modal-body { padding: 16px 20px 20px; } .lc-expiring-warn-filter { margin-bottom: 14px; padding: 12px 14px; border-radius: 10px; background: #f8fafc; border: 1px solid #e2e8f0; } .lc-expiring-warn-filter-label { font-size: 12px; font-weight: 700; color: #64748b; margin-bottom: 8px; } .lc-expiring-warn-filter .ant-checkbox-group { display: flex; flex-wrap: wrap; gap: 8px 16px; } .lc-expiring-warn-toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; } .lc-expiring-warn-table .ant-table-thead > tr > th { background: #f8fafc !important; font-weight: 700 !important; font-size: 13px !important; cursor: pointer; } .lc-expiring-warn-table .ant-table-tbody > tr > td { font-size: 13px; } .lc-compare-pay-alert-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 14px; } .lc-compare-pay-alert { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 14px; border-radius: 10px; border: 1px solid #e2e8f0; background: #fff; } .lc-compare-pay-alert--warning { border-color: #fed7aa; background: linear-gradient(135deg, #fff7ed 0%, #fff 80%); } .lc-compare-pay-alert--overdue { border-color: #fecaca; background: linear-gradient(135deg, #fef2f2 0%, #fff 80%); } .lc-compare-pay-alert-val { font-size: 22px; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1; } .lc-compare-pay-alert--warning .lc-compare-pay-alert-val { color: #c2410c; } .lc-compare-pay-alert--overdue .lc-compare-pay-alert-val { color: #b91c1c; } .lc-compare-editor-filter { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px 20px; margin-bottom: 12px; padding: 12px 14px; border-radius: 10px; background: #f8fafc; border: 1px solid #e2e8f0; align-items: end; } @media (max-width: 900px) { .lc-compare-editor-filter { grid-template-columns: 1fr; } } .lc-compare-editor-filter-check { display: flex; align-items: center; min-height: 32px; padding: 4px 0; } .lc-compare-type-stats-row { margin-bottom: 12px; grid-template-columns: repeat(6, minmax(0, 1fr)); } @media (max-width: 1200px) { .lc-compare-type-stats-row { grid-template-columns: repeat(3, minmax(0, 1fr)); } } @media (max-width: 768px) { .lc-compare-type-stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } } .lc-compare-type-card { display: flex; flex-direction: column; gap: 4px; padding: 10px 14px; border-radius: 10px; border: 1px solid #e2e8f0; background: #fff; cursor: pointer; transition: box-shadow .2s ease, border-color .2s ease; min-width: 0; } .lc-compare-type-card:hover { box-shadow: 0 4px 14px rgba(15, 23, 42, 0.08); } .lc-compare-type-card.is-active { box-shadow: 0 0 0 2px rgba(22, 93, 255, 0.2); border-color: #165dff; background: linear-gradient(135deg, #eff6ff 0%, #fff 80%); } .lc-compare-type-card-val { font-size: 22px; font-weight: 800; color: #0f172a; font-variant-numeric: tabular-nums; line-height: 1.1; } .lc-compare-type-card-title { font-size: 12px; font-weight: 600; color: #64748b; } .lc-compare-editor-meta { display: flex; flex-wrap: wrap; gap: 12px 20px; margin-bottom: 12px; padding: 10px 14px; border-radius: 10px; background: #f8fafc; border: 1px solid #e2e8f0; } .lc-compare-editor-meta-field { display: flex; align-items: center; gap: 8px; min-width: 200px; flex: 1; } .lc-compare-editor-meta-label { font-size: 12px; font-weight: 600; color: #64748b; white-space: nowrap; } .lc-compare-procurement-hint { font-size: 12px; color: #64748b; } .lc-compare-total-bar--procurement { border-color: #bfdbfe; background: linear-gradient(135deg, #eff6ff 0%, #f8fafc 100%); } .lc-compare-total-bar--procurement .lc-compare-total-amount { color: #1d4ed8; } .lc-module-tabs.ant-tabs > .ant-tabs-nav { margin-bottom: 12px; } .lc-module-tabs .ant-tabs-tab { font-weight: 600; font-size: 14px; } .lc-policy-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } .lc-policy-ocr-upload { margin: 12px 0; } .lc-policy-import-template-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 12px 14px; margin-bottom: 12px; border-radius: 10px; background: linear-gradient(135deg, #ecfdf5 0%, #f8fafc 100%); border: 1px solid #a7f3d0; } .lc-policy-import-template-bar-text { font-size: 12px; color: #047857; line-height: 1.55; flex: 1; min-width: 200px; } .lc-policy-import-excel-upload { margin: 0; } .lc-policy-recogn-modal .ant-modal-body { padding: 16px 20px 20px; max-height: calc(100vh - 160px); overflow-y: auto; } .lc-policy-recogn-file-list { margin-top: 10px; max-height: 140px; overflow-y: auto; } .lc-policy-recogn-file-item { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 0; border-bottom: 1px solid #f1f5f9; font-size: 12px; } .lc-policy-recogn-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; flex-wrap: wrap; } .lc-policy-recogn-tasks-filter { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px 16px; margin-bottom: 12px; } @media (max-width: 900px) { .lc-policy-recogn-tasks-filter { grid-template-columns: repeat(2, minmax(0, 1fr)); } } .lc-policy-recogn-tasks-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; flex-wrap: wrap; gap: 8px; } .lc-policy-recogn-task-progress { min-width: 108px; } .lc-policy-recogn-task-progress .ant-progress { margin-bottom: 2px; line-height: 1; } .lc-policy-recogn-task-progress-text { display: block; font-size: 11px; color: #64748b; font-variant-numeric: tabular-nums; text-align: center; } .lc-policy-recogn-task-id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: #334155; } .lc-policy-recogn-preview { width: 100%; min-height: 360px; border: 1px solid #e2e8f0; border-radius: 8px; background: #f8fafc; display: flex; align-items: center; justify-content: center; } .lc-policy-recogn-preview img { max-width: 100%; max-height: 100%; object-fit: contain; } .lc-policy-recogn-preview iframe { width: 100%; height: 100%; border: none; border-radius: 8px; } .lc-policy-recogn-confirm-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; min-height: 480px; margin-top: 12px; } @media (max-width: 1100px) { .lc-policy-recogn-confirm-split { grid-template-columns: 1fr; } } .lc-policy-recogn-confirm-preview { border: 1px solid #e2e8f0; border-radius: 12px; background: #f8fafc; display: flex; flex-direction: column; min-height: 480px; overflow: hidden; } .lc-policy-recogn-confirm-preview-head { padding: 10px 14px; border-bottom: 1px solid #e2e8f0; font-size: 12px; font-weight: 600; color: #64748b; background: #fff; } .lc-policy-recogn-confirm-preview-body { flex: 1; display: flex; align-items: center; justify-content: center; padding: 12px; min-height: 0; } .lc-policy-recogn-confirm-preview-body .lc-policy-recogn-preview { min-height: 420px; height: 100%; border: none; background: transparent; } .lc-policy-recogn-confirm-form { border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; padding: 14px 16px; max-height: 520px; overflow-y: auto; } .lc-policy-recogn-confirm-form-title { font-size: 13px; font-weight: 700; color: #0f172a; margin-bottom: 12px; } .lc-policy-recogn-picker { margin-bottom: 4px; } .lc-policy-recogn-picker .ant-table-tbody > tr { cursor: pointer; } .lc-policy-recogn-picker .ant-table-tbody > tr.lc-policy-recogn-picker-row--active > td { background: #eff6ff !important; } .lc-policy-recogn-picker .ant-table-tbody > tr.lc-policy-recogn-picker-row--confirmed > td { opacity: 0.72; } .lc-policy-field-label-required::after { content: '*'; color: #ef4444; margin-left: 2px; } .lc-vehicle-ins-mgmt-modal .ant-modal-content { border-radius: 16px; overflow: hidden; box-shadow: 0 24px 64px -16px rgba(15, 23, 42, 0.28); } .lc-vehicle-ins-mgmt-modal .ant-modal-header { display: none; } .lc-vehicle-ins-mgmt-modal .ant-modal-body { padding: 0; max-height: calc(100vh - 96px); overflow: hidden; display: flex; flex-direction: column; } .lc-vehicle-ins-mgmt-modal .ant-modal-footer { border-top: 1px solid #e2e8f0; padding: 12px 20px; background: #f8fafc; } .lc-vehicle-ins-mgmt-shell { display: flex; flex-direction: column; min-height: 0; flex: 1; } .lc-vehicle-ins-mgmt-hero { padding: 20px 24px 18px; background: linear-gradient(135deg, #ecfdf5 0%, #f8fafc 42%, #fff 100%); border-bottom: 1px solid #e2e8f0; } .lc-vehicle-ins-mgmt-hero-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; } .lc-vehicle-ins-mgmt-hero-title { font-size: 11px; font-weight: 700; color: #059669; letter-spacing: 0.06em; text-transform: uppercase; margin-bottom: 6px; } .lc-vehicle-ins-mgmt-plate { font-size: 26px; font-weight: 800; color: #0f172a; letter-spacing: 0.02em; line-height: 1.2; } .lc-vehicle-ins-mgmt-subtitle { font-size: 13px; color: #64748b; margin-top: 6px; } .lc-vehicle-ins-mgmt-status-pill { display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 999px; font-size: 13px; font-weight: 700; border: 1px solid transparent; } .lc-vehicle-ins-mgmt-status-pill--success { background: #ecfdf5; border-color: #a7f3d0; color: #047857; } .lc-vehicle-ins-mgmt-status-pill--warning { background: #fffbeb; border-color: #fde68a; color: #b45309; } .lc-vehicle-ins-mgmt-status-pill--error { background: #fef2f2; border-color: #fecaca; color: #b91c1c; } .lc-vehicle-ins-mgmt-meta-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px 14px; } @media (max-width: 768px) { .lc-vehicle-ins-mgmt-meta-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } .lc-vehicle-ins-mgmt-meta-card { padding: 10px 12px; border-radius: 10px; background: rgba(255,255,255,.85); border: 1px solid #e2e8f0; min-width: 0; } .lc-vehicle-ins-mgmt-meta-label { font-size: 11px; color: #94a3b8; font-weight: 600; margin-bottom: 4px; } .lc-vehicle-ins-mgmt-meta-val { font-size: 13px; font-weight: 600; color: #0f172a; word-break: break-all; line-height: 1.35; } .lc-vehicle-ins-mgmt-body { padding: 16px 20px 20px; overflow-y: auto; flex: 1; min-height: 0; } .lc-vehicle-ins-mgmt-filter { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; padding: 12px 14px; border-radius: 10px; background: #f8fafc; border: 1px solid #e2e8f0; } .lc-vehicle-ins-mgmt-filter .lc-filter-field { flex: 1; min-width: 0; margin: 0; } .lc-vehicle-ins-mgmt-filter .lc-filter-field-label { flex: 0 0 64px; } .lc-vehicle-ins-policy-more-btn { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 6px; color: #64748b; cursor: pointer; transition: background 0.15s, color 0.15s; } .lc-vehicle-ins-policy-more-btn:hover { background: #f1f5f9; color: #334155; } .lc-policy-biz-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 16px; padding: 14px 16px; margin-bottom: 16px; border-radius: 12px; background: #f8fafc; border: 1px solid #e2e8f0; } .lc-policy-biz-summary-item-label { font-size: 11px; color: #94a3b8; font-weight: 600; margin-bottom: 4px; } .lc-policy-biz-summary-item-val { font-size: 13px; color: #0f172a; font-weight: 600; word-break: break-all; } .lc-policy-biz-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; } .lc-policy-biz-form-full { grid-column: 1 / -1; } @media (max-width: 640px) { .lc-policy-biz-summary, .lc-policy-biz-form { grid-template-columns: 1fr; } } .lc-purchase-type-chip { display: inline-flex; align-items: center; padding: 2px 10px; border-radius: 999px; font-size: 11px; font-weight: 700; border: 1px solid transparent; line-height: 1.5; } .lc-purchase-type--new { background: #ecfdf5; border-color: #a7f3d0; color: #047857; } .lc-purchase-type--renew { background: #eff6ff; border-color: #bfdbfe; color: #1d4ed8; } .lc-purchase-type--rent-stop { background: #fff7ed; border-color: #fed7aa; color: #c2410c; } .lc-purchase-type--resume { background: #ecfeff; border-color: #a5f3fc; color: #0e7490; } .lc-purchase-type--cancel { background: #f1f5f9; border-color: #cbd5e1; color: #475569; } .lc-vehicle-ins-mgmt-tabs .ant-tabs-nav { margin-bottom: 0 !important; padding: 0 4px; } .lc-vehicle-ins-mgmt-tabs .ant-tabs-tab { font-weight: 600; font-size: 13px; padding: 10px 14px !important; transition: color 0.15s; } .lc-vehicle-ins-mgmt-tabs .ant-tabs-tab-active .ant-tabs-tab-btn { color: #059669 !important; } .lc-vehicle-ins-mgmt-tabs .ant-tabs-ink-bar { background: #10b981 !important; height: 3px !important; border-radius: 3px 3px 0 0; } .lc-vehicle-ins-mgmt-tabs .ant-tabs-content-holder { padding-top: 14px; } .lc-ins-tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; padding: 0 5px; margin-left: 6px; border-radius: 999px; background: #e2e8f0; color: #475569; font-size: 11px; font-weight: 700; } .lc-vehicle-ins-mgmt-tabs .ant-tabs-tab-active .lc-ins-tab-badge { background: #d1fae5; color: #047857; } .lc-vehicle-ins-timeline-center { max-height: 480px; overflow-y: auto; padding: 4px 8px 12px; } .lc-vehicle-ins-timeline-center-head { display: grid; grid-template-columns: minmax(0, 1fr) 28px minmax(0, 1fr); gap: 12px; align-items: center; margin-bottom: 12px; padding: 0 4px; } .lc-vehicle-ins-timeline-center-head-side { font-size: 13px; font-weight: 700; color: #334155; } .lc-vehicle-ins-timeline-center-head-side--left { text-align: right; } .lc-vehicle-ins-timeline-center-head-side--right { text-align: left; } .lc-vehicle-ins-timeline-center-head-axis { width: 2px; height: 18px; margin: 0 auto; border-radius: 2px; background: linear-gradient(180deg, #10b981, #94a3b8); } .lc-vehicle-ins-timeline-center-body { position: relative; } .lc-vehicle-ins-timeline-center-row { display: grid; grid-template-columns: minmax(0, 1fr) 28px minmax(0, 1fr); gap: 12px; align-items: stretch; min-height: 72px; } .lc-vehicle-ins-timeline-center-col { display: flex; min-width: 0; } .lc-vehicle-ins-timeline-center-col--left { justify-content: flex-end; } .lc-vehicle-ins-timeline-center-col--right { justify-content: flex-start; } .lc-vehicle-ins-timeline-center-axis { position: relative; display: flex; justify-content: center; padding-top: 18px; } .lc-vehicle-ins-timeline-center-axis::before { content: ''; position: absolute; top: 0; bottom: 0; left: 50%; width: 2px; margin-left: -1px; background: #e2e8f0; } .lc-vehicle-ins-timeline-center-row:first-child .lc-vehicle-ins-timeline-center-axis::before { top: 22px; } .lc-vehicle-ins-timeline-center-row:last-child .lc-vehicle-ins-timeline-center-axis::before { bottom: auto; height: 22px; } .lc-vehicle-ins-timeline-center-dot { position: relative; z-index: 1; width: 12px; height: 12px; border-radius: 50%; border: 2px solid #fff; box-shadow: 0 0 0 2px currentColor; flex-shrink: 0; } .lc-vehicle-ins-timeline-center-dot--policy { color: #10b981; background: #10b981; } .lc-vehicle-ins-timeline-center-dot--biz { color: #f59e0b; background: #f59e0b; } .lc-vehicle-ins-timeline-item--left { text-align: right; } .lc-vehicle-ins-timeline-item--left .lc-vehicle-ins-timeline-title { justify-content: flex-end; } .lc-vehicle-ins-timeline-item--left .lc-vehicle-ins-timeline-meta { text-align: right; } @media (max-width: 720px) { .lc-vehicle-ins-timeline-center-head { grid-template-columns: 1fr; gap: 4px; text-align: center !important; } .lc-vehicle-ins-timeline-center-head-axis { display: none; } .lc-vehicle-ins-timeline-center-row { grid-template-columns: 1fr; gap: 8px; min-height: auto; padding-left: 20px; border-left: 2px solid #e2e8f0; margin-left: 8px; } .lc-vehicle-ins-timeline-center-axis { display: none; } .lc-vehicle-ins-timeline-center-col--left { justify-content: flex-start; } .lc-vehicle-ins-timeline-item--left { text-align: left; } .lc-vehicle-ins-timeline-item--left .lc-vehicle-ins-timeline-title { justify-content: flex-start; } .lc-vehicle-ins-timeline-item--left .lc-vehicle-ins-timeline-meta { text-align: left; } } .lc-vehicle-ins-timeline-center-col .lc-vehicle-ins-timeline-item { width: 100%; max-width: 360px; margin: 0 0 14px; } .lc-vehicle-ins-timeline-item { cursor: pointer; padding: 12px 14px; margin: 0 0 10px; border-radius: 12px; border: 1px solid #e2e8f0; background: #fff; transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s; } .lc-vehicle-ins-timeline-item:hover { border-color: #a7f3d0; box-shadow: 0 4px 14px -6px rgba(16, 185, 129, 0.35); transform: translateY(-1px); } .lc-vehicle-ins-timeline-item:focus-visible { outline: 2px solid #10b981; outline-offset: 2px; } .lc-vehicle-ins-timeline-title { font-size: 13px; font-weight: 700; color: #0f172a; margin-bottom: 6px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .lc-vehicle-ins-timeline-desc { font-size: 12px; color: #475569; line-height: 1.55; } .lc-vehicle-ins-timeline-meta { font-size: 11px; color: #94a3b8; margin-top: 6px; } .lc-vehicle-ins-mgmt-table-card { border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden; background: #fff; } .lc-vehicle-ins-mgmt-table-card .ant-table-thead > tr > th { background: #f8fafc !important; font-weight: 700 !important; font-size: 12px !important; color: #475569 !important; white-space: nowrap; } .lc-vehicle-ins-mgmt-table .ant-table-content table { table-layout: fixed; width: 100% !important; } .lc-vehicle-ins-mgmt-table .ant-table-tbody > tr > td { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; padding-top: 8px !important; padding-bottom: 8px !important; } .lc-vehicle-ins-mgmt-table .ant-table-tbody > tr > td .ant-table-cell-content { overflow: hidden; text-overflow: ellipsis; } .lc-vehicle-ins-mgmt-table .lc-vehicle-ins-mgmt-actions { display: inline-flex; align-items: center; flex-wrap: nowrap; white-space: nowrap; gap: 2px; max-width: 100%; } .lc-vehicle-ins-mgmt-table .lc-vehicle-ins-mgmt-actions .ant-btn-link { flex-shrink: 0; white-space: nowrap; } .lc-vehicle-ins-mgmt-table .lc-vehicle-ins-mgmt-cell-ellipsis { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; } .lc-vehicle-ins-mgmt-empty { padding: 48px 24px; text-align: center; border-radius: 12px; border: 1px dashed #cbd5e1; background: #f8fafc; } .lc-ins-history-row--active > td { background: #ecfdf5 !important; } .lc-ins-history-row--active > td:first-child { box-shadow: inset 3px 0 0 #10b981; } .lc-policy-detail-form { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; } @media (max-width: 720px) { .lc-policy-detail-form { grid-template-columns: 1fr; } } .lc-policy-detail-form-full { grid-column: 1 / -1; } .lc-policy-detail-section-title { font-size: 13px; font-weight: 700; color: #334155; margin: 4px 0 8px; grid-column: 1 / -1; } .lc-coverage-items-table-section { grid-column: 1 / -1; } .lc-coverage-items-table-section--confirm { margin-top: 4px; } .lc-coverage-items-table-wrap { border-radius: 10px; border: 1px solid #e2e8f0; overflow: hidden; background: #fff; } .lc-coverage-items-table .ant-table-thead > tr > th { background: #f8fafc !important; font-size: 12px !important; font-weight: 700 !important; padding: 8px 10px !important; } .lc-coverage-items-table .ant-table-tbody > tr > td { padding: 6px 8px !important; vertical-align: middle !important; } .lc-coverage-items-add { margin-top: 8px; border-radius: 8px !important; font-weight: 600; color: #059669 !important; border-color: #a7f3d0 !important; background: #f0fdf4 !important; } .lc-coverage-items-add:hover { border-color: #6ee7b7 !important; color: #047857 !important; } .lc-list-policy-tag { margin-top: 4px; } .lc-compare-attach-field { display: flex; flex-direction: column; gap: 8px; } .lc-compare-attach-label { font-size: 13px; font-weight: 600; color: #334155; } .lc-compare-attach-hint { font-size: 12px; color: #94a3b8; line-height: 1.5; } .lc-compare-attach-upload .ant-upload-list { max-height: 160px; overflow-y: auto; margin-top: 8px; } .lc-compare-attach-upload .ant-upload-list-item { border-radius: 8px; } .lc-compare-attach-upload .ant-upload-select { display: block; } `; const goInsuranceEditPage = (ledgerKey, master, vehicle) => { const label = vehicle ? (hasVehiclePlate(vehicle) ? vehicle.plateNo : `${NO_PLATE_LABEL}(${vehicle.vin})`) : ledgerKey; try { sessionStorage.setItem(IPC_EDIT_PLATE_KEY, ledgerKey); persistInsuranceToStorage(master); } catch { /* ignore */ } if (typeof window.__axhubNavigate === 'function') { window.__axhubNavigate('保险采购-编辑'); message.success(`已进入 [${label}] 保险维护`); return; } message.info(`已带入 [${label}] 车辆信息,请打开「保险采购-编辑」页面继续维护`); }; const Component = function () { const [allInsurance, setAllInsurance] = useState(() => { const stored = loadInsuranceFromStorage(); if (stored) { const normalized = {}; Object.keys(stored).forEach((k) => { normalized[k] = ensureInsuranceRecordShape(stored[k]); }); MOCK_VEHICLES.forEach((v) => { const key = getVehicleLedgerKey(v); if (normalized[key]) return; const seed = getInitialInsuranceSeed(v); if (seed) { normalized[key] = ensureInsuranceRecordShape(JSON.parse(JSON.stringify(seed))); } }); return normalized; } const merged = {}; MOCK_VEHICLES.forEach((v) => { const key = getVehicleLedgerKey(v); const seed = getInitialInsuranceSeed(v); merged[key] = ensureInsuranceRecordShape( seed ? JSON.parse(JSON.stringify(seed)) : createEmptyInsuranceRecord() ); }); return merged; }); const updateAllInsurance = useCallback((updater) => { setAllInsurance((prev) => { const next = typeof updater === 'function' ? updater(prev) : updater; persistInsuranceToStorage(next); return next; }); }, []); const DEFAULT_LIST_FILTERS = { plateNo: '', plateNos: '', vin: '', brand: '', model: '', operateStatus: '全部', insuranceStatus: '全部', insuranceType: '', endDateRange: null, }; const [listFilters, setListFilters] = useState(() => ({ ...DEFAULT_LIST_FILTERS })); const [appliedFilters, setAppliedFilters] = useState(() => ({ ...DEFAULT_LIST_FILTERS })); const [multiPlateOpen, setMultiPlateOpen] = useState(false); const [multiPlateDraft, setMultiPlateDraft] = useState(''); const [kpiFilter, setKpiFilter] = useState('total'); const [prdOpen, setPrdOpen] = useState(false); const [compareMgmtOpen, setCompareMgmtOpen] = useState(false); const [compareSheets, setCompareSheets] = useState(() => { const stored = loadCompareSheetsFromStorage(); const list = stored && stored.length ? stored : createMockCompareSheets(); return list.map(normalizeCompareSheet); }); const [compareMgmtFilters, setCompareMgmtFilters] = useState(() => ({ ...DEFAULT_COMPARE_MGMT_FILTERS })); const [appliedCompareMgmtFilters, setAppliedCompareMgmtFilters] = useState(() => ({ ...DEFAULT_COMPARE_MGMT_FILTERS })); const [compareModalOpen, setCompareModalOpen] = useState(false); const [editingCompareSheetId, setEditingCompareSheetId] = useState(null); const [compareRows, setCompareRows] = useState([]); const [selectedCompareKeys, setSelectedCompareKeys] = useState([]); const [copyPopoverRowId, setCopyPopoverRowId] = useState(null); const [copyCountDraft, setCopyCountDraft] = useState(1); const [quoteDraft, setQuoteDraft] = useState(createEmptyQuoteDraft); const [quoteEditRowId, setQuoteEditRowId] = useState(null); const [compareRemark, setCompareRemark] = useState(''); const [compareEditorFilters, setCompareEditorFilters] = useState(() => ({ ...DEFAULT_COMPARE_EDITOR_FILTERS })); const [compareAttachmentFileList, setCompareAttachmentFileList] = useState([]); const [policyRecognOpen, setPolicyRecognOpen] = useState(false); const [policyRecognEntry, setPolicyRecognEntry] = useState('ocr'); const [policyRecognMode, setPolicyRecognMode] = useState('policy'); const [policyRecognInsuranceType, setPolicyRecognInsuranceType] = useState('交强险'); const [policyRecognPhase, setPolicyRecognPhase] = useState('upload'); const [policyRecognFiles, setPolicyRecognFiles] = useState([]); const [policyRecognTaskId, setPolicyRecognTaskId] = useState(''); const policyRecognTimerRef = useRef(null); const policyRecognProgressTimerRef = useRef(null); const [policyRecognResults, setPolicyRecognResults] = useState([]); const [policyRecognViewOnly, setPolicyRecognViewOnly] = useState(false); const [policyRecognActiveResultId, setPolicyRecognActiveResultId] = useState(''); const [policyRecognConfirmDraft, setPolicyRecognConfirmDraft] = useState(() => ({ ...EMPTY_POLICY_DETAIL })); const [policyPreview, setPolicyPreview] = useState(null); const [policyRecognTasks, setPolicyRecognTasks] = useState(() => { const stored = loadPolicyRecognTasksFromStorage(); return stored && stored.length ? stored : createMockPolicyRecognTasks(); }); const [policyRecognTasksOpen, setPolicyRecognTasksOpen] = useState(false); const [policyRecognTasksFilters, setPolicyRecognTasksFilters] = useState(() => ({ ...DEFAULT_POLICY_RECOGN_TASK_FILTERS })); const [appliedPolicyRecognTasksFilters, setAppliedPolicyRecognTasksFilters] = useState(() => ({ ...DEFAULT_POLICY_RECOGN_TASK_FILTERS })); const [policyAddOpen, setPolicyAddOpen] = useState(false); const [vehicleInsMgmtOpen, setVehicleInsMgmtOpen] = useState(false); const [vehicleInsMgmtVehicle, setVehicleInsMgmtVehicle] = useState(null); const [vehicleInsMgmtActiveTab, setVehicleInsMgmtActiveTab] = useState('timeline'); const [vehicleInsMgmtHighlightId, setVehicleInsMgmtHighlightId] = useState(''); const [vehicleInsMgmtPolicyNoFilter, setVehicleInsMgmtPolicyNoFilter] = useState(''); const [vehicleInsMgmtTabPage, setVehicleInsMgmtTabPage] = useState({}); const [policyBizModalOpen, setPolicyBizModalOpen] = useState(false); const [policyBizModalMode, setPolicyBizModalMode] = useState('suspend'); const [policyBizModalRecord, setPolicyBizModalRecord] = useState(null); const [policyBizForm, setPolicyBizForm] = useState(() => ({ ...EMPTY_POLICY_BIZ_FORM })); const [policyBizAttachmentFileList, setPolicyBizAttachmentFileList] = useState([]); const [policyOpHistoryOpen, setPolicyOpHistoryOpen] = useState(false); const [policyOpHistoryRecord, setPolicyOpHistoryRecord] = useState(null); const [policyAddDraft, setPolicyAddDraft] = useState(() => ({ ...EMPTY_POLICY_DETAIL })); const [policyAddAttachmentFileList, setPolicyAddAttachmentFileList] = useState([]); const [insuranceHistoryEdits, setInsuranceHistoryEdits] = useState(() => loadInsuranceHistoryEditsFromStorage()); const [vehicleInsHistoryEditOpen, setVehicleInsHistoryEditOpen] = useState(false); const [vehicleInsHistoryEditRecord, setVehicleInsHistoryEditRecord] = useState(null); const [vehicleInsHistoryEditDraft, setVehicleInsHistoryEditDraft] = useState(() => ({ ...EMPTY_POLICY_DETAIL })); const [insuranceAlertOpen, setInsuranceAlertOpen] = useState(false); const [insuranceAlertMode, setInsuranceAlertMode] = useState('expiring'); const [insuranceAlertTypeFilter, setInsuranceAlertTypeFilter] = useState(() => [...EXPIRING_WARN_TYPE_KEYS]); const [insuranceAlertSort, setInsuranceAlertSort] = useState({ key: 'commercial', order: 'descend' }); const [batchCompareTypesOpen, setBatchCompareTypesOpen] = useState(false); const [batchCompareTypesDraft, setBatchCompareTypesDraft] = useState(() => [...QUOTE_INSURANCE_TYPES]); const [compareVehicleFilterOpen, setCompareVehicleFilterOpen] = useState(false); const [compareVehicleFilterDraft, setCompareVehicleFilterDraft] = useState(''); const [compareBatchAddOpen, setCompareBatchAddOpen] = useState(false); const [compareBatchAddDraft, setCompareBatchAddDraft] = useState(''); const compareSheetSummary = useMemo( () => calcCompareSheetConfirmedTotal(compareRows), [compareRows] ); const selectedProcurementSummary = useMemo(() => { const selected = compareRows.filter((r) => selectedCompareKeys.includes(r.id)); return calcCompareSheetConfirmedTotal(selected); }, [compareRows, selectedCompareKeys]); const compareEditorTypeCounts = useMemo( () => countCompareRowsByInsuranceType(compareRows), [compareRows] ); const displayCompareRows = useMemo( () => filterCompareEditorRows(compareRows, compareEditorFilters), [compareRows, compareEditorFilters] ); const appliedCompareVehicles = useMemo( () => parseMultiPlates(compareEditorFilters.vehicles), [compareEditorFilters.vehicles] ); const compareVehicleTriggerText = appliedCompareVehicles.length ? `已选 ${appliedCompareVehicles.length} 辆车` : ''; const isCompareEditorFiltered = useMemo(() => { const f = compareEditorFilters; return !!(f.vehicles || '').trim() || f.latestPayWithin3Days || f.insuranceType; }, [compareEditorFilters]); const compareMgmtPayAlerts = useMemo(() => { let warning = 0; let overdue = 0; compareSheets.forEach((sheet) => { const alert = calcCompareSheetPayAlerts(sheet); warning += alert.warning; overdue += alert.overdue; }); return { warning, overdue }; }, [compareSheets]); const saveCompareSheets = useCallback((nextSheets) => { setCompareSheets(nextSheets); persistCompareSheetsToStorage(nextSheets); }, []); const filteredCompareSheets = useMemo(() => { const plateKey = (appliedCompareMgmtFilters.plateNo || '').trim(); const range = appliedCompareMgmtFilters.createdRange; return compareSheets .filter((sheet) => compareSheetMatchesCreatedRange(sheet.createdAt, range)) .filter((sheet) => compareSheetMatchesPlateFilter(sheet, plateKey)) .sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || ''))); }, [compareSheets, appliedCompareMgmtFilters]); const updateCompareRow = useCallback((rowId, patch) => { setCompareRows((prev) => prev.map((r) => (r.id === rowId ? { ...r, ...patch } : r))); }, []); const fillCompareRowFromVehicle = useCallback((rowId, vehicle) => { if (!vehicle) return; updateCompareRow(rowId, buildVehicleComparePatch(vehicle, allInsurance)); }, [allInsurance, updateCompareRow]); const fillCompareRowFromPlate = useCallback((rowId, plateNo) => { const vehicle = findVehicleByPlate(plateNo); if (!vehicle) return; fillCompareRowFromVehicle(rowId, vehicle); }, [fillCompareRowFromVehicle]); const handleComparePlateChange = useCallback((rowId, plateNo) => { if (!plateNo) { updateCompareRow(rowId, clearVehicleComparePatch()); return; } fillCompareRowFromPlate(rowId, plateNo); }, [fillCompareRowFromPlate, updateCompareRow]); const handleCompareVinChange = useCallback((rowId, vin) => { if (!vin) { updateCompareRow(rowId, clearVehicleComparePatch()); return; } const vehicle = findVehicleByVin(vin); if (!vehicle) { message.warning('未找到该 VIN 对应车辆'); return; } fillCompareRowFromVehicle(rowId, vehicle); }, [fillCompareRowFromVehicle, updateCompareRow]); const renderReadonlyField = (val, linked) => ( {val || '—'} ); const renderReadonlyDate = (val, linked) => ( {val || '—'} ); const openCompareMgmtModal = () => { setCompareMgmtFilters({ ...DEFAULT_COMPARE_MGMT_FILTERS }); setAppliedCompareMgmtFilters({ ...DEFAULT_COMPARE_MGMT_FILTERS }); setCompareMgmtOpen(true); }; const openCompareEditor = (sheet) => { if (sheet) { setEditingCompareSheetId(sheet.id); setCompareRows(normalizeCompareRows(JSON.parse(JSON.stringify(sheet.rows || [])))); setCompareRemark(sheet.remark || ''); setCompareAttachmentFileList(attachmentsToUploadFileList(sheet.attachments)); } else { setEditingCompareSheetId(null); setCompareRows([createEmptyCompareRow()]); setCompareRemark(''); setCompareAttachmentFileList([]); } setCompareEditorFilters({ ...DEFAULT_COMPARE_EDITOR_FILTERS }); setCompareVehicleFilterOpen(false); setCompareVehicleFilterDraft(''); setCompareBatchAddOpen(false); setCompareBatchAddDraft(''); setSelectedCompareKeys([]); setQuoteDraft(createEmptyQuoteDraft()); setQuoteEditRowId(null); setCompareModalOpen(true); }; const handleCompareVehicleFilterOpenChange = (open) => { setCompareVehicleFilterOpen(open); if (open) setCompareVehicleFilterDraft(compareEditorFilters.vehicles || ''); }; const handleCompareVehicleFilterClear = () => { setCompareVehicleFilterDraft(''); setCompareEditorFilters((prev) => ({ ...prev, vehicles: '' })); setCompareVehicleFilterOpen(false); }; const handleCompareVehicleFilterApply = () => { const trimmed = compareVehicleFilterDraft.trim(); setCompareEditorFilters((prev) => ({ ...prev, vehicles: trimmed })); setCompareVehicleFilterOpen(false); const tokens = parseMultiPlates(trimmed); if (tokens.length) { const hitCount = filterCompareEditorRows(compareRows, { ...compareEditorFilters, vehicles: trimmed, }).length; message.success(`已按 ${tokens.length} 辆车筛选,命中 ${hitCount} 条购买记录`); } }; const buildSheetPayloadFromEditor = (rowsSnapshot) => normalizeCompareSheet({ id: editingCompareSheetId || createCompareSheetId(), createdAt: editingCompareSheetId ? (compareSheets.find((s) => s.id === editingCompareSheetId)?.createdAt || formatCompareSheetNow()) : formatCompareSheetNow(), createdBy: editingCompareSheetId ? (compareSheets.find((s) => s.id === editingCompareSheetId)?.createdBy || PROTO_COMPARE_CREATOR) : PROTO_COMPARE_CREATOR, periodLabel: editingCompareSheetId ? (compareSheets.find((s) => s.id === editingCompareSheetId)?.periodLabel || '') : '', remark: compareRemark, attachments: uploadFileListToAttachments(compareAttachmentFileList), rows: rowsSnapshot, }); const handleCompareAttachmentChange = ({ fileList }) => { setCompareAttachmentFileList( fileList.map((f) => ({ ...f, status: 'done', uploadedAt: f.uploadedAt || formatCompareSheetNow(), })) ); }; const handleCompareMgmtQuery = () => { setAppliedCompareMgmtFilters({ ...compareMgmtFilters }); message.success('查询完成'); }; const handleCompareMgmtReset = () => { setCompareMgmtFilters({ ...DEFAULT_COMPARE_MGMT_FILTERS }); setAppliedCompareMgmtFilters({ ...DEFAULT_COMPARE_MGMT_FILTERS }); message.info('筛选条件已重置'); }; const handleDeleteCompareSheet = (sheet) => { Modal.confirm({ title: '删除比价单', content: `确定删除 ${sheet.createdAt || ''} 由 ${sheet.createdBy || '—'} 创建的比价单吗?删除后不可恢复。`, okText: '删除', okType: 'danger', cancelText: '取消', centered: true, onOk: () => { const next = compareSheets.filter((s) => s.id !== sheet.id); saveCompareSheets(next); message.success('比价单已删除'); }, }); }; const handleAddCompareRow = () => { setCompareRows((prev) => [...prev, createEmptyCompareRow()]); }; const handleOpenBatchAddCompareRows = () => { setCompareBatchAddDraft(''); setCompareBatchAddOpen(true); }; const handleConfirmBatchAddCompareRows = () => { const lines = parseBatchVehicleLines(compareBatchAddDraft); if (!lines.length) { message.warning('请输入至少一条车牌号或车辆识别代码'); return; } const notFound = []; const newRows = lines.map((token) => { const vehicle = findVehicleByPlateOrVin(token); if (!vehicle) { notFound.push(token); return null; } const row = buildCompareRowFromVehicle(vehicle, allInsurance); if (compareEditorFilters.insuranceType) { row.insuranceType = compareEditorFilters.insuranceType; } return row; }).filter(Boolean); if (!newRows.length) { message.warning( notFound.length ? `未找到匹配车辆:${notFound.slice(0, 5).join('、')}${notFound.length > 5 ? ` 等 ${notFound.length} 条` : ''}` : '没有可新增的记录' ); return; } setCompareRows((prev) => [...prev, ...newRows]); setCompareBatchAddOpen(false); setCompareBatchAddDraft(''); const skipHint = notFound.length ? `,${notFound.length} 条未匹配已跳过` : ''; message.success(`已批量新增 ${newRows.length} 条购买记录${skipHint}`); }; const handleBatchSetCompareInsuranceType = (insuranceType) => { const targetIdSet = new Set( selectedCompareKeys.length ? selectedCompareKeys : compareRows.map((r) => r.id) ); if (!targetIdSet.size) { message.warning('没有可设置的购买记录'); return; } let updated = 0; let skippedProcurement = 0; setCompareRows((prev) => prev.map((r) => { if (!targetIdSet.has(r.id)) return r; if (isCompareProcurementSelectionDisabled(r.procurementStatus)) { skippedProcurement += 1; return r; } if (r.insuranceType === insuranceType) return r; updated += 1; return { ...r, insuranceType, quotes: [], confirmedQuoteId: '', }; })); if (!updated) { message.info( skippedProcurement ? '所选记录均为审批中/审批通过,或险种已是目标值' : '没有需要变更的记录' ); return; } const scopeLabel = selectedCompareKeys.length ? '所选' : '全部'; const skipHint = skippedProcurement ? `,${skippedProcurement} 条审批中/通过记录已跳过` : ''; message.success(`已将${scopeLabel} ${updated} 条记录设为${insuranceType}${skipHint}`); }; const handleDeleteCompareRow = (rowId) => { setCompareRows((prev) => prev.filter((r) => r.id !== rowId)); setSelectedCompareKeys((prev) => prev.filter((k) => k !== rowId)); }; const handleCopyCompareRow = (row, count) => { const n = Math.max(1, Math.min(50, Number(count) || 1)); const clones = Array.from({ length: n }, () => cloneCompareRow(row)); setCompareRows((prev) => { const idx = prev.findIndex((r) => r.id === row.id); if (idx < 0) return [...prev, ...clones]; const next = [...prev]; next.splice(idx + 1, 0, ...clones); return next; }); setCopyPopoverRowId(null); setCopyCountDraft(1); message.success(`已复制 ${n} 条记录`); }; const handleAddQuote = (rowId, rowInsuranceType) => { if (!quoteDraft.company) { message.warning('请选择保险公司'); return; } if (!isValidPremium(quoteDraft.premium)) { message.warning('请输入大于 0 的报价,最多两位小数'); return; } const premium = formatPremiumDisplay(quoteDraft.premium); const newQuote = { id: createQuoteId(), company: quoteDraft.company, premium, }; let added = false; setCompareRows((prev) => prev.map((r) => { if (r.id !== rowId) return r; const exists = (r.quotes || []).some((q) => q.company === quoteDraft.company); if (exists) { message.warning('该保险公司报价已存在'); return r; } added = true; return { ...r, quotes: [...(r.quotes || []), newQuote] }; })); if (added) { setQuoteDraft(createEmptyQuoteDraft()); message.success(`已添加${rowInsuranceType || ''}报价`); } }; const handleRemoveQuote = (rowId, quoteId) => { setCompareRows((prev) => prev.map((r) => { if (r.id !== rowId) return r; const quotes = (r.quotes || []).filter((q) => q.id !== quoteId); const confirmedQuoteId = r.confirmedQuoteId === quoteId ? '' : r.confirmedQuoteId; return { ...r, quotes, confirmedQuoteId }; })); }; const validateCompareSheetRequiredMeta = () => { if (!(compareRemark || '').trim()) { message.warning('请填写备注'); return false; } const hasAttachment = (compareAttachmentFileList || []).some((f) => f.status !== 'removed'); if (!hasAttachment) { message.warning('请上传附件'); return false; } return true; }; const handleSubmitCompareSheet = (options = {}) => { const { closeModal = true } = options; if (!compareRows.length) { message.warning('请至少添加一条购买记录'); return null; } const missingVehicle = compareRows.find((r) => !(r.plateNo || '').trim() && !(r.vin || '').trim()); if (missingVehicle) { message.warning('存在未选择车辆的记录,请填写车牌或 VIN'); return null; } if (!validateCompareSheetRequiredMeta()) return null; const rowsSnapshot = normalizeCompareRows(JSON.parse(JSON.stringify(compareRows))); const payload = buildSheetPayloadFromEditor(rowsSnapshot); let nextSheets; let savedId = payload.id; if (editingCompareSheetId) { nextSheets = compareSheets.map((s) => (s.id === editingCompareSheetId ? payload : s)); message.success(`比价单已保存,共 ${rowsSnapshot.length} 条购买记录`); } else { nextSheets = [payload, ...compareSheets]; message.success(`比价单已创建,共 ${rowsSnapshot.length} 条购买记录`); savedId = payload.id; } saveCompareSheets(nextSheets); setEditingCompareSheetId(savedId); if (closeModal) { setCompareModalOpen(false); } return savedId; }; const submitProcurementApplication = (keys = selectedCompareKeys) => { if (!keys.length) return; const submittedAt = formatCompareSheetNow(); const rowsSnapshot = compareRows.map((r) => ( keys.includes(r.id) ? { ...r, procurementStatus: 'submitted', procurementSubmittedAt: submittedAt, procurementCurrentApprover: pickMockWorkflowCurrentApprover(r.id), } : r )); const payload = buildSheetPayloadFromEditor(rowsSnapshot); const nextSheets = compareSheets.map((s) => (s.id === payload.id ? payload : s)); saveCompareSheets(nextSheets); setCompareRows(rowsSnapshot); setSelectedCompareKeys([]); message.success('比价单审批流程提交成功'); }; const handleSubmitProcurement = () => { if (!selectedCompareKeys.length) { message.warning('请勾选需要提交采购的购买记录'); return; } const selectedRows = compareRows.filter((r) => selectedCompareKeys.includes(r.id)); const noQuotes = selectedRows.find((r) => !(r.quotes || []).length); if (noQuotes) { message.warning('勾选记录须新增保险报价(报价情况为必填)'); return; } const noConfirmed = selectedRows.find((r) => !r.confirmedQuoteId); if (noConfirmed) { message.warning('勾选记录须将报价设为最终比价结果后方可提交'); return; } const noPayDate = selectedRows.find((r) => !r.latestPayDate); if (noPayDate) { message.warning('勾选记录须填写最晚付费日期'); return; } const alreadySubmitted = selectedRows.find((r) => isCompareProcurementSelectionDisabled(r.procurementStatus)); if (alreadySubmitted) { message.warning('勾选记录中包含审批中或审批通过项,请重新选择'); return; } if (!validateCompareSheetRequiredMeta()) return; if (!editingCompareSheetId) { Modal.confirm({ title: '保存并提交采购', content: '提交采购前将先保存当前比价单,是否继续?', okText: '继续', cancelText: '取消', centered: true, onOk: () => { const savedId = handleSubmitCompareSheet({ closeModal: false }); if (savedId) submitProcurementApplication(); }, }); return; } submitProcurementApplication(); }; const resolvePolicyVehicleKey = (plateOrVin) => { const key = (plateOrVin || '').trim(); if (!key) return null; const byPlate = findVehicleByPlate(key); if (byPlate) return getVehicleLedgerKey(byPlate); const byVin = findVehicleByVin(key); if (byVin) return getVehicleLedgerKey(byVin); return null; }; const upsertPolicyRecognTask = useCallback((snapshot) => { const { taskId, entry, mode, insuranceType, results, phase, completedAt, totalFileCount, recognDoneCount, } = snapshot; if (!taskId) return; setPolicyRecognTasks((prev) => { const existing = prev.find((t) => t.id === taskId); let mergedResults = results; if (mergedResults !== undefined && existing?.results?.length) { const existingFailed = existing.results.filter((r) => r.recognSuccess === false); const newHasFailed = mergedResults.some((r) => r.recognSuccess === false); if (existingFailed.length && !newHasFailed) { mergedResults = [...mergedResults, ...existingFailed]; } } const finalResults = mergedResults !== undefined ? mergedResults : (existing?.results || []); const record = buildPolicyRecognTaskRecord({ id: taskId, entry: entry ?? existing?.entry ?? 'ocr', mode: mode ?? existing?.mode ?? 'policy', insuranceType: insuranceType ?? existing?.insuranceType ?? '', results: finalResults, createdAt: existing?.createdAt, creator: existing?.creator, completedAt: completedAt ?? existing?.completedAt ?? '', phase: phase ?? existing?.phase ?? 'results', totalFileCount: totalFileCount ?? existing?.totalFileCount, recognDoneCount: recognDoneCount ?? existing?.recognDoneCount, }); const idx = prev.findIndex((t) => t.id === taskId); const next = idx >= 0 ? prev.map((t, i) => (i === idx ? record : t)) : [record, ...prev]; persistPolicyRecognTasksToStorage(next); return next; }); }, []); const vehicleInsuranceHistory = useMemo(() => { if (!vehicleInsMgmtVehicle) { return { timeline: [], timelinePolicy: [], timelineBiz: [], byType: {}, ledgerKey: '' }; } const built = buildVehicleInsuranceHistory( vehicleInsMgmtVehicle, allInsurance, compareSheets, policyRecognTasks ); const patched = applyHistoryEditsToVehicleHistory(built, insuranceHistoryEdits); const insRecord = ensureInsuranceRecordShape(allInsurance[patched.ledgerKey] || createEmptyInsuranceRecord()); const { timelinePolicy, timelineBiz } = splitVehicleInsuranceTimeline(patched.timeline, insRecord); return { ...patched, timelinePolicy, timelineBiz }; }, [vehicleInsMgmtVehicle, allInsurance, compareSheets, policyRecognTasks, insuranceHistoryEdits]); const openVehicleInsuranceMgmt = (vehicle) => { setVehicleInsMgmtVehicle(vehicle); setVehicleInsMgmtActiveTab('timeline'); setVehicleInsMgmtHighlightId(''); setVehicleInsMgmtPolicyNoFilter(''); setVehicleInsMgmtTabPage({}); setVehicleInsMgmtOpen(true); }; const openPolicyBizModal = (record, mode) => { if (!vehicleInsMgmtVehicle || !record?.typeKey) return; const ledgerKey = getVehicleLedgerKey(vehicleInsMgmtVehicle); const item = allInsurance[ledgerKey]?.[record.typeKey] || {}; setPolicyBizModalRecord(record); setPolicyBizModalMode(mode); setPolicyBizForm({ suspendTime: item.suspendTime || ANCHOR_TODAY, resumeTime: item.resumeTime || item.reinstateDate || '', newEndDate: item.endDate || '', cancelTime: item.cancelTime || ANCHOR_TODAY, refundPremium: item.refundPremium || item.premium || '', }); setPolicyBizAttachmentFileList(attachmentsToUploadFileList(item.attachments || [])); setPolicyBizModalOpen(true); }; const openPolicyOpHistoryModal = (record) => { if (!vehicleInsMgmtVehicle || !record) return; const ledgerKey = getVehicleLedgerKey(vehicleInsMgmtVehicle); const ledgerItem = allInsurance[ledgerKey]?.[record.typeKey]; let logs = record.operationLogs || []; if (record.isArchived && ledgerItem?.archivedPolicies?.length) { const archivedIndex = Number(String(record.id || '').split('-archived-')[1]); if (!Number.isNaN(archivedIndex)) { logs = ledgerItem.archivedPolicies[archivedIndex]?.operationLogs || logs; } } else if (record.isLedgerCurrent && ledgerItem) { logs = ledgerItem.operationLogs || logs; } setPolicyOpHistoryRecord({ ...record, operationLogs: logs }); setPolicyOpHistoryOpen(true); }; const handlePolicyBizAttachmentChange = ({ fileList }) => { const incoming = fileList.filter((f) => f.status !== 'removed'); const valid = incoming.filter((f) => isPolicyRecognImageOrPdf(f)); if (valid.length < incoming.length) { message.warning('已忽略不支持格式,附件仅支持 PDF / 图片'); } setPolicyBizAttachmentFileList( valid.map((f) => ({ ...f, status: 'done', uploadedAt: f.uploadedAt || formatCompareSheetNow(), })) ); }; const submitPolicyBizModal = () => { if (!vehicleInsMgmtVehicle || !policyBizModalRecord?.typeKey) return; const ledgerKey = getVehicleLedgerKey(vehicleInsMgmtVehicle); const typeKey = policyBizModalRecord.typeKey; const mode = policyBizModalMode; const attachments = uploadFileListToAttachments(policyBizAttachmentFileList); if (mode === 'suspend' && (!policyBizForm.suspendTime || !policyBizForm.newEndDate)) { message.warning('请填写中止时间与「新到期日期」'); return; } if (mode === 'resume' && (!policyBizForm.resumeTime || !policyBizForm.newEndDate)) { message.warning('请填写恢复时间与「新到期日期」'); return; } if (mode === 'cancel' && !policyBizForm.cancelTime) { message.warning('请填写退保时间'); return; } updateAllInsurance((prev) => { const rec = ensureInsuranceRecordShape(prev[ledgerKey] || createEmptyInsuranceRecord()); const before = { ...rec[typeKey] }; const nextItem = { ...before }; const changes = []; const beforeStatus = before.policyTag === 'cancelled' ? '已退保' : before.policyTag === 'suspended' ? '已停保' : '正常'; if (mode === 'suspend') { changes.push( { label: '保单状态', before: beforeStatus, after: '已停保' }, { label: '到期日期', before: before.endDate, after: policyBizForm.newEndDate }, { label: '中止时间', before: before.suspendTime, after: policyBizForm.suspendTime }, { label: '恢复时间', before: before.resumeTime || before.reinstateDate, after: policyBizForm.resumeTime }, ); nextItem.policyTag = 'suspended'; nextItem.endDate = policyBizForm.newEndDate; nextItem.suspendTime = policyBizForm.suspendTime; nextItem.resumeTime = policyBizForm.resumeTime; nextItem.reinstateDate = policyBizForm.resumeTime; if (attachments.length) nextItem.attachments = attachments; } else if (mode === 'resume') { const resumeBeforeStatus = before.policyTag === 'cancelled' ? '已退保' : before.policyTag === 'suspended' ? '已停保' : '正常'; changes.push( { label: '保单状态', before: resumeBeforeStatus, after: '正常' }, { label: '到期日期', before: before.endDate, after: policyBizForm.newEndDate }, { label: '恢复时间', before: before.resumeTime || before.reinstateDate, after: policyBizForm.resumeTime }, ); if (before.policyTag === 'cancelled') { changes.push({ label: '退保时间', before: before.cancelTime, after: '' }); nextItem.cancelTime = ''; } nextItem.policyTag = ''; nextItem.endDate = policyBizForm.newEndDate; nextItem.resumeTime = policyBizForm.resumeTime; nextItem.reinstateDate = policyBizForm.resumeTime; if (attachments.length) nextItem.attachments = attachments; } else if (mode === 'cancel') { changes.push( { label: '保单状态', before: beforeStatus, after: '已退保' }, { label: '到期日期', before: before.endDate, after: '' }, { label: '退保时间', before: before.cancelTime, after: policyBizForm.cancelTime }, { label: '退还保费', before: before.refundPremium, after: policyBizForm.refundPremium }, ); nextItem.policyTag = 'cancelled'; nextItem.endDate = ''; nextItem.cancelTime = policyBizForm.cancelTime; nextItem.refundPremium = policyBizForm.refundPremium; } nextItem.updateTime = formatCompareSheetNow(); nextItem.updateUser = PROTO_COMPARE_CREATOR; nextItem.operationLogs = appendInsuranceOperationLog(before.operationLogs, { type: mode, remark: buildOperationChangeRemark(changes), }); return { ...prev, [ledgerKey]: { ...rec, [typeKey]: nextItem } }; }); const okText = mode === 'suspend' ? '停保已提交' : mode === 'resume' ? '复驶已提交' : '退保已提交'; message.success(okText); setPolicyBizModalOpen(false); setPolicyBizModalRecord(null); setPolicyBizForm({ ...EMPTY_POLICY_BIZ_FORM }); setPolicyBizAttachmentFileList([]); }; const getPolicyMoreMenuItems = (record) => { const items = []; if (record?.isLedgerCurrent && !record?.isArchived) { const pt = record.purchaseType; if (pt === 'cancel') { items.push({ key: 'resume', label: '复驶' }); } else if (pt === 'new' || pt === 'renew') { items.push({ key: 'suspend', label: '停保' }); items.push({ key: 'cancel', label: '退保' }); } else if (pt === 'rentStop') { items.push({ key: 'resume', label: '复驶' }); items.push({ key: 'cancel', label: '退保' }); } } items.push({ key: 'history', label: '操作历史' }); return items; }; const handlePolicyMoreMenuClick = (record, key) => { if (key === 'history') { openPolicyOpHistoryModal(record); return; } openPolicyBizModal(record, key); }; const jumpToVehicleInsuranceRecord = (typeKey, recordId, rowsSource) => { const rows = rowsSource || vehicleInsuranceHistory.byType[typeKey] || []; const idx = rows.findIndex((r) => r.id === recordId); if (idx >= 0 && rows.length > 8) { const pageSize = 8; setVehicleInsMgmtTabPage((prev) => ({ ...prev, [typeKey]: Math.floor(idx / pageSize) + 1, })); } setVehicleInsMgmtActiveTab(typeKey); setVehicleInsMgmtHighlightId(recordId); window.setTimeout(() => setVehicleInsMgmtHighlightId(''), 3200); }; const handleVehicleInsMgmtPolicyNoSearch = () => { const key = (vehicleInsMgmtPolicyNoFilter || '').trim().toUpperCase(); if (!key) { message.warning('请输入保单号'); return; } const matched = vehicleInsuranceHistory.timeline.filter((record) => ( String(record.policyNo || '').toUpperCase().includes(key) )); if (!matched.length) { message.warning('未找到匹配的保单记录'); return; } if (matched.length > 1) { message.info(`找到 ${matched.length} 条匹配记录,已定位至第一条`); } const target = matched[0]; jumpToVehicleInsuranceRecord(target.typeKey, target.id); }; const handleInsuranceRecordPreview = (record) => { Modal.info({ title: `预览 · ${record.fileName}`, width: 520, centered: true, content: (
险种:{record.typeLabel}
保单号:{record.policyNo || '—'}
保险公司:{record.company || '—'}
正式环境将内嵌 PDF / 图片预览;原型仅展示附件名称。
), okText: '关闭', }); }; const handleInsuranceRecordDownload = (record) => { message.success(`已开始下载:${record.fileName || '保单附件'}(原型)`); }; const syncVehicleInsHistoryEditToLedger = (record, detail) => { if (!vehicleInsMgmtVehicle || record.source !== 'ledger') return; const ledgerKey = getVehicleLedgerKey(vehicleInsMgmtVehicle); if (!ledgerKey) return; const ledgerEvents = new Set(['purchase', 'suspend', 'cancel']); if (!ledgerEvents.has(record.eventType)) return; const mode = bizTypeToRecognMode(detail.bizType); updateAllInsurance((prev) => { const rec = ensureInsuranceRecordShape(prev[ledgerKey] || createEmptyInsuranceRecord()); const item = rec[record.typeKey]; if (!item?.policyNo) return prev; if (record.eventType === 'purchase' && item.policyNo !== record.policyNo) return prev; const nextItem = applyPolicyDetailToInsuranceItem({ ...item }, detail, mode); nextItem.updateTime = formatCompareSheetNow(); nextItem.updateUser = PROTO_COMPARE_CREATOR; return { ...prev, [ledgerKey]: { ...rec, [record.typeKey]: nextItem } }; }); }; const syncVehicleInsHistoryEditToRecognTask = (record, detail) => { if (record.source !== 'recognize' || !record.recognizeTaskId || !record.recognizeResultId) return; setPolicyRecognTasks((prev) => { const next = prev.map((task) => { if (task.id !== record.recognizeTaskId) return task; const results = (task.results || []).map((r) => ( r.id === record.recognizeResultId ? mergeRecognResultWithDetail(r, detail) : r )); return { ...task, results }; }); persistPolicyRecognTasksToStorage(next); return next; }); }; const openVehicleInsHistoryEdit = (record) => { if (!vehicleInsMgmtVehicle) return; setVehicleInsHistoryEditRecord(record); setVehicleInsHistoryEditDraft(historyRecordToPolicyDetail(record, vehicleInsMgmtVehicle)); setVehicleInsHistoryEditOpen(true); }; const saveVehicleInsHistoryEdit = () => { if (!vehicleInsHistoryEditRecord) return; const detail = normalizePolicyDetail(vehicleInsHistoryEditDraft); if (!detail.policyNo && !detail.endDate) { message.warning('请至少填写保单号或到期日期'); return; } const record = vehicleInsHistoryEditRecord; setInsuranceHistoryEdits((prev) => { const next = { ...prev, [record.id]: detail }; persistInsuranceHistoryEditsToStorage(next); return next; }); syncVehicleInsHistoryEditToLedger(record, detail); syncVehicleInsHistoryEditToRecognTask(record, detail); setVehicleInsHistoryEditOpen(false); setVehicleInsHistoryEditRecord(null); message.success('已保存保单要素'); }; const renderPurchaseTypeChip = (purchaseType) => { const meta = POLICY_PURCHASE_TYPE_META[purchaseType] || { label: purchaseType, chipClass: '' }; return ( {meta.label} ); }; const renderVehicleInsuranceTimelineCard = (item, side) => (
jumpToVehicleInsuranceRecord(item.typeKey, item.id)} onKeyDown={(e) => { if (e.key === 'Enter') jumpToVehicleInsuranceRecord(item.typeKey, item.id); }} >
{item.time || '—'}
{renderPurchaseTypeChip(item.purchaseType)} {item.typeLabel} {item.policyNo ? ( {item.policyNo} ) : null}
{item.summary}
{item.sourceLabel ? `${item.sourceLabel} · ` : ''} 点击查看 {item.typeLabel} 明细 →
); const renderVehicleInsuranceCenterTimeline = () => { const policyItems = vehicleInsuranceHistory.timelinePolicy || []; const bizItems = vehicleInsuranceHistory.timelineBiz || []; const merged = [ ...policyItems.map((item) => ({ ...item, timelineSide: 'policy' })), ...bizItems.map((item) => ({ ...item, timelineSide: 'biz' })), ].sort((a, b) => String(b.time || '').localeCompare(String(a.time || ''))); return (
保单新增 / 续保
停保 / 复驶 / 退保
{merged.map((item) => (
{item.timelineSide === 'policy' ? renderVehicleInsuranceTimelineCard(item, 'left') : null}
{item.timelineSide === 'biz' ? renderVehicleInsuranceTimelineCard(item, 'right') : null}
))}
); }; const vehicleInsMgmtTabCounts = useMemo(() => { const counts = { timeline: (vehicleInsuranceHistory.timelinePolicy?.length || 0) + (vehicleInsuranceHistory.timelineBiz?.length || 0), }; VEHICLE_INSURANCE_MGMT_TABS.filter((t) => t.key !== 'timeline').forEach((tab) => { counts[tab.key] = (vehicleInsuranceHistory.byType[tab.key] || []).length; }); return counts; }, [vehicleInsuranceHistory]); const renderMgmtTableEllipsis = (val) => { const text = val || '—'; return ( {text} ); }; const vehicleInsuranceHistoryColumns = [ { title: '保险导入时间', dataIndex: 'purchaseTime', width: 148, ellipsis: true, render: (val) => renderMgmtTableEllipsis(val), }, { title: '类型', dataIndex: 'purchaseType', width: 72, render: (val) => renderPurchaseTypeChip(val), }, { title: '保单号', dataIndex: 'policyNo', width: 148, ellipsis: true, render: (val) => renderMgmtTableEllipsis(val), }, { title: '保险公司', dataIndex: 'company', width: 160, ellipsis: true, render: (val) => renderMgmtTableEllipsis(val), }, { title: '付款时间', dataIndex: 'payTime', width: 148, ellipsis: true, render: (val) => renderMgmtTableEllipsis(val), }, { title: '生效日期', dataIndex: 'startDate', width: 100, ellipsis: true, render: (val) => renderMgmtTableEllipsis(val), }, { title: '到期日期', dataIndex: 'endDate', width: 100, ellipsis: true, render: (val) => renderMgmtTableEllipsis(val), }, { title: '金额', dataIndex: 'premium', width: 96, align: 'right', ellipsis: true, render: (val, record) => { const text = val ? `${record.purchaseType === 'cancel' ? '-' : ''}¥${val}` : '—'; return ( {text} ); }, }, { title: '操作', key: 'action', width: 188, fixed: 'right', render: (_, record) => (
{ domEvent.stopPropagation(); handlePolicyMoreMenuClick(record, key); }, }} > e.stopPropagation()} > {ICONS.more}
), }, ]; const renderVehicleInsuranceTypeTab = (typeKey) => { const rows = vehicleInsuranceHistory.byType[typeKey] || []; const tabLabel = VEHICLE_INSURANCE_MGMT_TABS.find((t) => t.key === typeKey)?.label || ''; if (!rows.length) { return (
{tabLabel}暂无记录
该险种首次购买为「新保」,此前已购后再投保记为「续保」
); } return (
8 ? { current: vehicleInsMgmtTabPage[typeKey] || 1, pageSize: 8, showSizeChanger: false, size: 'small', onChange: (page) => setVehicleInsMgmtTabPage((prev) => ({ ...prev, [typeKey]: page })), } : false} scroll={{ x: 1160 }} rowClassName={(record) => (record.id === vehicleInsMgmtHighlightId ? 'lc-ins-history-row--active' : '')} /> ); }; const renderVehicleInsMgmtTabLabel = (tab) => { const count = vehicleInsMgmtTabCounts[tab.key] || 0; return ( {tab.label} {count > 0 ? {count} : null} ); }; const filteredPolicyRecognTasks = useMemo(() => { const mode = appliedPolicyRecognTasksFilters.mode; const range = appliedPolicyRecognTasksFilters.createdRange; return [...policyRecognTasks] .filter((task) => { if (mode && mode !== '全部' && task.modeLabel !== mode) return false; return compareSheetMatchesCreatedRange(task.createdAt, range); }) .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))); }, [policyRecognTasks, appliedPolicyRecognTasksFilters]); const openPolicyRecognTasksModal = () => { setPolicyRecognTasksFilters({ ...DEFAULT_POLICY_RECOGN_TASK_FILTERS }); setAppliedPolicyRecognTasksFilters({ ...DEFAULT_POLICY_RECOGN_TASK_FILTERS }); setPolicyRecognTasksOpen(true); }; const handlePolicyRecognTasksQuery = () => { setAppliedPolicyRecognTasksFilters({ ...policyRecognTasksFilters }); message.success('已刷新任务列表'); }; const handlePolicyRecognTasksReset = () => { setPolicyRecognTasksFilters({ ...DEFAULT_POLICY_RECOGN_TASK_FILTERS }); setAppliedPolicyRecognTasksFilters({ ...DEFAULT_POLICY_RECOGN_TASK_FILTERS }); }; const openPolicyRecogn = (entry, initialMode = 'policy') => { const canResumeOcr = entry === 'ocr' && policyRecognEntry === 'ocr' && policyRecognTaskId && (policyRecognPhase === 'recognizing' || (policyRecognPhase === 'results' && policyRecognResults.length)); if (canResumeOcr) { if (policyRecognPhase === 'results' && policyRecognResults.length && !policyRecognActiveResultId) { const preferred = policyRecognResults.find((r) => r.matched && !r.confirmed) || policyRecognResults.find((r) => !r.confirmed) || policyRecognResults[0]; if (preferred) { setPolicyRecognActiveResultId(preferred.id); setPolicyRecognConfirmDraft(enrichPolicyDetailCoverageForEdit(recognResultToPolicyDetail(preferred))); showPolicyRecognResultPreview(preferred); } } setPolicyRecognOpen(true); return; } if (policyRecognTimerRef.current) { window.clearTimeout(policyRecognTimerRef.current); policyRecognTimerRef.current = null; } if (policyRecognProgressTimerRef.current) { window.clearInterval(policyRecognProgressTimerRef.current); policyRecognProgressTimerRef.current = null; } setPolicyRecognEntry(entry); setPolicyRecognMode(entry === 'import' ? 'policy' : initialMode); setPolicyRecognInsuranceType('交强险'); setPolicyRecognPhase('upload'); setPolicyRecognFiles([]); setPolicyRecognTaskId(''); setPolicyRecognResults([]); setPolicyRecognViewOnly(false); setPolicyRecognActiveResultId(''); setPolicyRecognConfirmDraft({ ...EMPTY_POLICY_DETAIL }); setPolicyPreview(null); setPolicyRecognOpen(true); }; const openPolicyRecognTaskRecord = (task) => { if (!task?.id) { message.warning('任务记录无效'); return; } if (isPolicyRecognTaskRecognizing(task)) { message.info('请等待识别完成后操作'); return; } const successResults = getPolicyRecognSuccessResults(task.results).map((r) => ({ ...r })); if (!successResults.length) { message.warning('暂无识别成功的结果可确认'); return; } setPolicyRecognEntry(task.entry || 'ocr'); setPolicyRecognMode(task.mode || 'policy'); setPolicyRecognInsuranceType(task.insuranceType || '交强险'); setPolicyRecognTaskId(task.id); setPolicyRecognResults(successResults); setPolicyRecognFiles([]); setPolicyRecognViewOnly(task.status === 'completed'); setPolicyRecognPhase('results'); setPolicyPreview(null); setPolicyRecognOpen(true); setPolicyRecognTasksOpen(false); const preferred = successResults.find((r) => r.matched && !r.confirmed) || successResults.find((r) => !r.confirmed) || successResults[0]; if (preferred) { setPolicyRecognActiveResultId(preferred.id); setPolicyRecognConfirmDraft(enrichPolicyDetailCoverageForEdit(recognResultToPolicyDetail(preferred))); showPolicyRecognResultPreview(preferred); } }; const closePolicyRecogn = () => { const syncedResults = policyRecognPhase === 'results' ? persistActiveRecognDraft() : policyRecognResults; if (syncedResults !== policyRecognResults) { setPolicyRecognResults(syncedResults); } if (policyRecognTaskId && syncedResults.length) { const status = derivePolicyRecognTaskStatus(syncedResults); upsertPolicyRecognTask({ taskId: policyRecognTaskId, entry: policyRecognEntry, mode: policyRecognMode, insuranceType: policyRecognInsuranceType, results: syncedResults, phase: policyRecognPhase, completedAt: status === 'completed' ? formatCompareSheetNow() : undefined, }); } if (policyPreview?.url) URL.revokeObjectURL(policyPreview.url); setPolicyPreview(null); setPolicyRecognOpen(false); }; const policyRecognAllUploaded = policyRecognFiles.length > 0 && policyRecognFiles.every((f) => f.status === 'done'); const handlePolicyRecognUploadChange = ({ fileList }) => { const incoming = fileList.filter((f) => f.status !== 'removed'); const valid = incoming.filter((f) => isPolicyRecognImageOrPdf(f)); if (valid.length < incoming.length) { message.warning('已忽略不支持格式,仅支持 PDF / 图片'); } const next = valid.map((f) => { if (f.status === 'done') return f; return { ...f, status: 'uploading', percent: f.percent || 0 }; }); setPolicyRecognFiles(next); next.forEach((f, i) => { if (f.status === 'uploading') { window.setTimeout(() => { setPolicyRecognFiles((prev) => prev.map((p) => ( p.uid === f.uid ? { ...p, status: 'done', percent: 100 } : p ))); }, 500 + i * 280); } }); }; const handlePolicyImportUploadChange = ({ fileList }) => { const incoming = fileList.filter((f) => f.status !== 'removed').slice(-1); const valid = incoming.filter((f) => isPolicyImportExcelFile(f)); if (incoming.length && !valid.length) { message.warning('请上传 Excel 模板文件(.csv、.xlsx、.xls)'); return; } const next = valid.map((f) => ( f.status === 'done' ? f : { ...f, status: 'done', percent: 100 } )); setPolicyRecognFiles(next); }; const mergeRecognResultWithDetail = (result, detail) => { const rebuilt = buildRecognResultFromDetail( { id: result.id, fileUid: result.fileUid, fileName: result.fileName, fileType: result.fileType, }, detail, allInsurance, bizTypeToRecognMode(detail.bizType) ); return { ...result, ...rebuilt, id: result.id, confirmed: result.confirmed }; }; const showPolicyRecognResultPreview = (result) => { if (!result) return; const file = policyRecognFiles.find((f) => f.uid === result.fileUid); if (policyPreview?.url) URL.revokeObjectURL(policyPreview.url); if (file?.originFileObj && (file.type || '').startsWith('image/')) { const url = URL.createObjectURL(file.originFileObj); setPolicyPreview({ url, fileName: result.fileName, isImage: true }); return; } setPolicyPreview({ url: null, fileName: result.fileName, isImage: false, hint: policyRecognEntry === 'import' ? '导入记录无原件预览,请核对右侧识别字段' : (policyRecognFiles.length ? 'PDF 预览(原型):正式环境将内嵌预览识别原件' : '任务记录未保存原件,正式环境可从附件库查看'), }); }; const persistActiveRecognDraft = (resultsList = policyRecognResults) => { if (!policyRecognActiveResultId) return resultsList; const result = resultsList.find((r) => r.id === policyRecognActiveResultId); if (!result) return resultsList; const merged = mergeRecognResultWithDetail(result, policyRecognConfirmDraft); return resultsList.map((r) => (r.id === policyRecognActiveResultId ? merged : r)); }; const selectPolicyRecognResult = (resultId, resultsList = policyRecognResults) => { const nextResults = policyRecognActiveResultId && policyRecognActiveResultId !== resultId ? persistActiveRecognDraft(resultsList) : resultsList; if (nextResults !== resultsList) { setPolicyRecognResults(nextResults); } const result = nextResults.find((r) => r.id === resultId); if (!result) return nextResults; setPolicyRecognActiveResultId(resultId); setPolicyRecognConfirmDraft(enrichPolicyDetailCoverageForEdit(recognResultToPolicyDetail(result))); showPolicyRecognResultPreview(result); return nextResults; }; const enterPolicyRecognConfirmPhase = (results, snapshot = {}) => { const displayResults = getPolicyRecognSuccessResults(results); if (!displayResults.length) return; setPolicyRecognResults(displayResults); setPolicyRecognPhase('results'); const preferred = displayResults.find((r) => r.matched && !r.confirmed) || displayResults.find((r) => !r.confirmed) || displayResults[0]; if (preferred) { setPolicyRecognActiveResultId(preferred.id); setPolicyRecognConfirmDraft(enrichPolicyDetailCoverageForEdit(recognResultToPolicyDetail(preferred))); showPolicyRecognResultPreview(preferred); } if (snapshot.taskId) { upsertPolicyRecognTask({ taskId: snapshot.taskId, entry: snapshot.entry ?? policyRecognEntry, mode: snapshot.mode ?? policyRecognMode, insuranceType: snapshot.insuranceType ?? policyRecognInsuranceType, results, phase: 'results', totalFileCount: snapshot.totalFileCount, recognDoneCount: snapshot.recognDoneCount ?? snapshot.totalFileCount, }); } }; const handleRecognConfirmPlateChange = (plateNo) => { const vehicle = plateNo ? findVehicleByPlate(plateNo) : null; setPolicyRecognConfirmDraft((prev) => ({ ...prev, plateNo: plateNo || '', vin: vehicle?.vin || (plateNo ? prev.vin : ''), })); }; const startPolicyExcelImportTask = async () => { const fileItem = policyRecognFiles.find((f) => f.status === 'done'); const file = fileItem?.originFileObj; if (!file) { message.warning('请先上传 Excel 导入文件'); return; } const taskId = createPolicyRecognTaskId(); setPolicyRecognPhase('recognizing'); setPolicyRecognTaskId(taskId); try { const text = await readPolicyImportFileAsText(file); if (!String(text).trim()) { setPolicyRecognPhase('upload'); return; } const rows = parsePolicyImportFileText(text); if (!rows.length) { message.error('未解析到有效数据,请按模板填写带 * 的必填项'); setPolicyRecognPhase('upload'); return; } if (!validatePolicyImportRows(rows)) { setPolicyRecognPhase('upload'); return; } const results = buildImportResultsFromRows(rows, allInsurance); enterPolicyRecognConfirmPhase(results, { taskId, entry: 'import', mode: 'policy', insuranceType: '', }); const matchedN = results.filter((r) => r.matched).length; message.success(`已解析 ${results.length} 条,${matchedN} 条已匹配台账,请核对识别内容后确认`); } catch { message.error('导入文件读取失败,请重试'); setPolicyRecognPhase('upload'); } }; const startPolicyRecognTask = () => { if (!policyRecognAllUploaded) { message.warning(policyRecognEntry === 'import' ? '请先上传 Excel 文件' : '请等待全部文件上传完成'); return; } if (policyRecognEntry === 'import') { startPolicyExcelImportTask(); return; } if (policyRecognMode === 'policy' && !policyRecognInsuranceType) { message.warning('请选择保险类型'); return; } const taskId = createPolicyRecognTaskId(); const entrySnap = policyRecognEntry; const modeSnap = policyRecognMode; const insuranceSnap = policyRecognInsuranceType; const filesSnap = policyRecognFiles; const fileCount = filesSnap.filter((f) => f.status === 'done').length; if (policyRecognTimerRef.current) { window.clearTimeout(policyRecognTimerRef.current); policyRecognTimerRef.current = null; } if (policyRecognProgressTimerRef.current) { window.clearInterval(policyRecognProgressTimerRef.current); policyRecognProgressTimerRef.current = null; } setPolicyRecognPhase('recognizing'); setPolicyRecognTaskId(taskId); upsertPolicyRecognTask({ taskId, entry: entrySnap, mode: modeSnap, insuranceType: insuranceSnap, results: [], phase: 'recognizing', totalFileCount: fileCount, recognDoneCount: 0, }); setPolicyRecognOpen(false); message.info('正在识别,请稍后点击「保单批量识别」确认识别结果'); policyRecognProgressTimerRef.current = window.setInterval(() => { setPolicyRecognTasks((prev) => { const task = prev.find((t) => t.id === taskId); if (!task || !isPolicyRecognTaskRecognizing(task)) { if (policyRecognProgressTimerRef.current) { window.clearInterval(policyRecognProgressTimerRef.current); policyRecognProgressTimerRef.current = null; } return prev; } const nextDone = Math.min(task.totalFileCount, (task.recognDoneCount || 0) + 1); if (nextDone >= task.totalFileCount) { if (policyRecognProgressTimerRef.current) { window.clearInterval(policyRecognProgressTimerRef.current); policyRecognProgressTimerRef.current = null; } return prev; } const next = prev.map((t) => ( t.id === taskId ? { ...t, recognDoneCount: nextDone } : t )); persistPolicyRecognTasksToStorage(next); return next; }); }, 480); policyRecognTimerRef.current = window.setTimeout(() => { policyRecognTimerRef.current = null; if (policyRecognProgressTimerRef.current) { window.clearInterval(policyRecognProgressTimerRef.current); policyRecognProgressTimerRef.current = null; } const results = buildMockOcrResults( filesSnap, modeSnap, insuranceSnap, allInsurance ); enterPolicyRecognConfirmPhase(results, { taskId, entry: entrySnap, mode: modeSnap, insuranceType: insuranceSnap, totalFileCount: fileCount, recognDoneCount: fileCount, }); }, 2400); }; const openPolicyRecognResults = () => { if (!policyRecognResults.length) { message.warning('暂无识别结果'); return; } enterPolicyRecognConfirmPhase(policyRecognResults, { taskId: policyRecognTaskId }); }; const renderPolicyDetailForm = (draft, setDraft, options = {}) => { const { showBizType = true, recognConfirmMode = false, policyEntryMode = false, onPlateChange, } = options; const requiredKeys = recognConfirmMode || policyEntryMode ? POLICY_ENTRY_FORM_REQUIRED_KEYS : []; const fieldLabel = (text, key) => ( requiredKeys.includes(key) ? {text} : text ); const coverageRows = getCoverageItemsFormRows(draft.coverageItems); const updateCoverageField = (idx, field, value) => { const next = coverageRows.map((row, i) => (i === idx ? { ...row, [field]: value } : row)); setDraft((p) => ({ ...p, coverageItems: next })); }; const addCoverageRow = () => { setDraft((p) => ({ ...p, coverageItems: [...getCoverageItemsFormRows(p.coverageItems), { ...EMPTY_COVERAGE_ITEM }], })); }; const removeCoverageRow = (idx) => { const next = coverageRows.filter((_, i) => i !== idx); setDraft((p) => ({ ...p, coverageItems: next.length ? next : [{ ...EMPTY_COVERAGE_ITEM }] })); }; return (
{!recognConfirmMode ?
车辆与险种
: null} {renderFilterField(fieldLabel('车牌号', 'plateNo'), ( recognConfirmMode ? ( setDraft((p) => ({ ...p, plateNo: e.target.value }))} placeholder="与 VIN 至少填一项" /> ) ))} {renderFilterField('车辆识别代码', ( setDraft((p) => ({ ...p, vin: e.target.value }))} style={recognConfirmMode ? { background: '#f8fafc', color: '#475569' } : undefined} /> ))} {showBizType ? renderFilterField('业务类型', ( setDraft((p) => ({ ...p, insuranceType: v }))} style={{ width: '100%' }} options={QUOTE_INSURANCE_TYPES.map((t) => ({ label: t, value: t }))} /> )) : null} {!recognConfirmMode ?
保单要素
: null} {renderFilterField(fieldLabel('保险公司', 'company'), ( setDraft((p) => ({ ...p, policyNo: e.target.value }))} /> ))} {!recognConfirmMode ? renderFilterField('批单号', ( setDraft((p) => ({ ...p, endorsementNo: e.target.value }))} placeholder="停保/复驶/退保批单号" /> )) : null} {renderFilterField(fieldLabel('付款时间', 'payTime'), ( setDraft((p) => ({ ...p, payTime: e.target.value }))} placeholder="如 2026-06-01 17:42:10" /> ))} {!recognConfirmMode ? renderFilterField('签单日期', ( setDraft((p) => ({ ...p, signDate: ds || '' }))} /> )) : null} {renderFilterField(fieldLabel('生效日期', 'startDate'), ( setDraft((p) => ({ ...p, startDate: ds || '' }))} /> ))} {renderFilterField(fieldLabel('到期日期', 'endDate'), ( setDraft((p) => ({ ...p, endDate: ds || '' }))} /> ))} {!recognConfirmMode && (draft.bizType === 'suspend' || draft.bizType === 'resume') ? renderFilterField('复驶日期', ( setDraft((p) => ({ ...p, reinstateDate: ds || '' }))} /> )) : null} {renderFilterField( recognConfirmMode || policyEntryMode ? fieldLabel('保险费合计', 'premium') : (draft.bizType === 'cancel' ? '退费金额(元)' : '保险费合计'), ( setDraft((p) => ({ ...p, premium: e.target.value }))} placeholder="元" /> ) )} {!recognConfirmMode ? renderFilterField('投保人', ( setDraft((p) => ({ ...p, applicant: e.target.value }))} /> )) : null} {!recognConfirmMode ? renderFilterField('被保险人', ( setDraft((p) => ({ ...p, insured: e.target.value }))} /> )) : null}
{!recognConfirmMode ? (
保单项目/责任限额
) : null}
`cov-row-${idx}`} dataSource={coverageRows} scroll={{ x: recognConfirmMode ? 600 : 720 }} locale={{ emptyText: '暂无承保险种数据' }} columns={[ { title: '承保险种', dataIndex: 'coverageName', width: recognConfirmMode ? 140 : 160, render: (val, _row, idx) => ( updateCoverageField(idx, 'coverageName', e.target.value)} placeholder="如:机动车损失险" /> ), }, { title: '保险金额', dataIndex: 'coverageAmount', width: recognConfirmMode ? 120 : 140, render: (val, _row, idx) => ( updateCoverageField(idx, 'coverageAmount', e.target.value)} placeholder="如:2000000元" /> ), }, { title: '保险金额/责任免额', dataIndex: 'deductible', width: recognConfirmMode ? 140 : 160, render: (val, _row, idx) => ( updateCoverageField(idx, 'deductible', e.target.value)} placeholder="如:绝对免赔额500元" /> ), }, { title: '保险费', dataIndex: 'itemPremium', width: recognConfirmMode ? 96 : 110, render: (val, _row, idx) => ( updateCoverageField(idx, 'itemPremium', e.target.value)} placeholder="0.00" /> ), }, { title: '操作', key: 'action', width: 64, fixed: 'right', render: (_val, _row, idx) => ( ), }, ]} /> {!recognConfirmMode ? (
按承保险种分行维护保额、免赔额与分项保险费;上方「保险费合计」为整单合计金额
) : (
识别结果已自动反写,可按需修改;上方「保险费合计」为整单合计金额
)} ); }; const confirmPolicyRecognResult = (resultId, detailOverride) => { const baseResults = persistActiveRecognDraft(); const result = baseResults.find((r) => r.id === resultId); if (!result) return; const detail = detailOverride || (resultId === policyRecognActiveResultId ? policyRecognConfirmDraft : recognResultToPolicyDetail(result)); if (!validatePolicyRecognDetailForConfirm(detail)) return; const merged = mergeRecognResultWithDetail(result, detail); if (!merged.matched) { message.warning('该条未匹配台账,请检查车牌号是否正确'); return; } if (merged.confirmed) { message.info('该条已确认'); return; } const mode = merged.recognMode || policyRecognMode; updateAllInsurance(applyPolicyOcrResultToLedger(merged, mode)); const nextResults = baseResults.map((r) => ( r.id === resultId ? { ...merged, confirmed: true } : r )); setPolicyRecognResults(nextResults); if (derivePolicyRecognTaskStatus(nextResults) === 'completed') { setPolicyRecognViewOnly(true); } if (policyRecognTaskId) { upsertPolicyRecognTask({ taskId: policyRecognTaskId, entry: policyRecognEntry, mode: policyRecognMode, insuranceType: policyRecognInsuranceType, results: nextResults, phase: 'results', completedAt: derivePolicyRecognTaskStatus(nextResults) === 'completed' ? formatCompareSheetNow() : undefined, }); } message.success(`已确认 ${merged.displayPlate || merged.ocrVin},台账已更新`); }; const confirmCurrentPolicyRecognResult = () => { if (!policyRecognActiveResultId) { message.warning('请先选择要确认的识别记录'); return; } confirmPolicyRecognResult(policyRecognActiveResultId, policyRecognConfirmDraft); }; const confirmAllPolicyRecognResults = () => { const synced = persistActiveRecognDraft(); setPolicyRecognResults(synced); const pending = synced.filter((r) => r.matched && !r.confirmed); if (!pending.length) { message.info('没有可批量确认的记录'); return; } let nextInsurance = { ...allInsurance }; const invalid = pending.find((result) => { const detail = result.id === policyRecognActiveResultId ? policyRecognConfirmDraft : recognResultToPolicyDetail(result); return !validatePolicyEntryDetail(detail, { silent: true }).ok; }); if (invalid) { message.warning('批量确认前请确保每条记录必填项均已填写,可先逐条确认'); return; } pending.forEach((result) => { const detail = result.id === policyRecognActiveResultId ? policyRecognConfirmDraft : recognResultToPolicyDetail(result); const merged = mergeRecognResultWithDetail(result, detail); const mode = merged.recognMode || policyRecognMode; nextInsurance = applyPolicyOcrResultToLedger(merged, mode)(nextInsurance); }); updateAllInsurance(() => nextInsurance); const nextResults = policyRecognResults.map((r) => ( r.matched ? { ...r, confirmed: true } : r )); setPolicyRecognResults(nextResults); const allDone = derivePolicyRecognTaskStatus(nextResults) === 'completed'; if (allDone) setPolicyRecognViewOnly(true); if (policyRecognTaskId) { upsertPolicyRecognTask({ taskId: policyRecognTaskId, entry: policyRecognEntry, mode: policyRecognMode, insuranceType: policyRecognInsuranceType, results: nextResults, phase: 'results', completedAt: allDone ? formatCompareSheetNow() : undefined, }); } message.success(`已批量确认 ${pending.length} 条,台账到期日期已更新`); }; const policyRecognPickerColumns = useMemo(() => ([ { title: '文件/记录', dataIndex: 'fileName', width: 160, ellipsis: true, }, { title: '车牌号', key: 'plate', width: 100, render: (_, r) => r.displayPlate || r.ocrPlateNo || '—', }, { title: '险种', dataIndex: 'insuranceTypeLabel', width: 72, }, { title: '匹配', key: 'matched', width: 80, render: (_, r) => ( {r.matched ? '已匹配' : '未匹配'} ), }, { title: '状态', key: 'confirmed', width: 80, render: (_, r) => ( r.confirmed ? 已确认 : 待确认 ), }, ]), []); const handlePolicyAddAttachmentChange = ({ fileList }) => { const incoming = fileList.filter((f) => f.status !== 'removed'); const valid = incoming.filter((f) => isPolicyRecognImageOrPdf(f)); if (valid.length < incoming.length) { message.warning('已忽略不支持格式,保单附件仅支持 PDF / 图片'); } setPolicyAddAttachmentFileList( valid.map((f) => ({ ...f, status: 'done', uploadedAt: f.uploadedAt || formatCompareSheetNow(), })) ); }; const handlePolicyAddSubmit = () => { const attachments = uploadFileListToAttachments(policyAddAttachmentFileList); const detail = normalizePolicyDetail({ ...policyAddDraft, bizType: 'policy', attachments, }); if (!validatePolicyEntryDetail(detail).ok) return; const ledgerKey = resolvePolicyVehicleKey(detail.plateNo || detail.vin); if (!ledgerKey) { message.warning('请填写台账中存在的车牌或 VIN'); return; } const typeKey = INSURANCE_LABEL_TO_KEY[detail.insuranceType]; if (!typeKey) { message.warning('险种填写有误'); return; } updateAllInsurance((prev) => { const record = ensureInsuranceRecordShape(prev[ledgerKey] || createEmptyInsuranceRecord()); const existing = record[typeKey] || createEmptyInsuranceItem(); let baseItem = { ...existing }; let logs = existing.operationLogs || []; if (existing.policyTag === 'cancelled' && existing.policyNo) { baseItem = { ...createEmptyInsuranceItem(), archivedPolicies: [...(existing.archivedPolicies || []), { ...existing }], }; logs = []; } const item = applyPolicyDetailToInsuranceItem( baseItem, { ...detail, policyNo: detail.policyNo || `MAN-${Date.now().toString().slice(-6)}` }, 'policy' ); item.updateTime = formatCompareSheetNow(); item.updateUser = PROTO_COMPARE_CREATOR; item.operationLogs = appendInsuranceOperationLog(logs, { type: 'add', remark: buildOperationChangeRemark([ { label: '保单号', before: existing.policyNo, after: item.policyNo }, { label: '保险公司', before: existing.company, after: item.company }, { label: '到期日期', before: existing.endDate, after: item.endDate }, ]), }); return { ...prev, [ledgerKey]: { ...record, [typeKey]: item } }; }); message.success('保单已录入台账'); setPolicyAddOpen(false); setPolicyAddDraft({ ...EMPTY_POLICY_DETAIL }); setPolicyAddAttachmentFileList([]); }; const renderPolicyStatusTag = (item) => { if (!item?.policyTag) return null; if (item.policyTag === 'suspended') { return ( 已停保 ); } if (item.policyTag === 'cancelled') { return ( 已退保 ); } return null; }; const renderDateCell = (rowId, field, value) => ( updateCompareRow(rowId, { [field]: ds || '' })} placeholder="选择日期" style={{ width: '100%' }} /> ); const renderQuoteAddPopover = (row) => ( { setQuoteEditRowId(open ? row.id : null); if (!open) setQuoteDraft(createEmptyQuoteDraft()); }} content={(
添加报价 {row.insuranceType || '交强险'}
setQuoteDraft((d) => ({ ...d, premium: sanitizePremiumInput(e.target.value) }))} onBlur={() => { if (quoteDraft.premium && isValidPremium(quoteDraft.premium)) { setQuoteDraft((d) => ({ ...d, premium: formatPremiumDisplay(d.premium) })); } }} />
)} >
); const renderQuoteCell = (row) => { const quotes = row.quotes || []; return (
{quotes.length > 0 ? ( updateCompareRow(row.id, { confirmedQuoteId: e.target.value })} className="lc-compare-quote-inline-list" > {quotes.map((q) => { const selected = row.confirmedQuoteId === q.id; return ( ); })} ) : ( 暂无报价(必填) )} {renderQuoteAddPopover(row)}
); }; const compareColumns = [ { title: '车牌号', dataIndex: 'plateNo', width: 118, fixed: 'left', onHeaderCell: () => ({ className: 'lc-compare-th-key' }), render: (val, row) => ( (option?.label || '').toLowerCase().includes(input.toLowerCase())} onChange={(v) => handleCompareVinChange(row.id, v)} /> ), }, { title: '客户', dataIndex: 'customer', width: 128, onHeaderCell: () => ({ className: 'lc-compare-th-auto' }), render: (val, row) => renderReadonlyField(val, isCompareRowVehicleLinked(row)), }, { title: '归属公司', dataIndex: 'ownerCompany', width: 148, onHeaderCell: () => ({ className: 'lc-compare-th-auto' }), render: (val, row) => renderReadonlyField(val, isCompareRowVehicleLinked(row)), }, { title: '品牌', dataIndex: 'brand', width: 80, onHeaderCell: () => ({ className: 'lc-compare-th-auto' }), render: (val, row) => renderReadonlyField(val, isCompareRowVehicleLinked(row)), }, { title: '型号', dataIndex: 'model', width: 120, onHeaderCell: () => ({ className: 'lc-compare-th-auto' }), render: (val, row) => renderReadonlyField(val, isCompareRowVehicleLinked(row)), }, { title: '车身颜色', dataIndex: 'bodyColor', width: 80, onHeaderCell: () => ({ className: 'lc-compare-th-auto' }), render: (val, row) => renderReadonlyField(val, isCompareRowVehicleLinked(row)), }, { title: '行驶证注册日期', dataIndex: 'regDate', width: 118, onHeaderCell: () => ({ className: 'lc-compare-th-auto' }), render: (val, row) => renderReadonlyDate(val, isCompareRowVehicleLinked(row)), }, { title: '年检有效期', dataIndex: 'inspectExpire', width: 108, onHeaderCell: () => ({ className: 'lc-compare-th-auto' }), render: (val, row) => renderReadonlyDate(val, isCompareRowVehicleLinked(row)), }, { title: '投保方式', dataIndex: 'insureMode', width: 96, onHeaderCell: () => ({ className: 'lc-compare-th-edit' }), render: (val, row) => ( updateCompareRow(row.id, { insuranceType: v, quotes: [], confirmedQuoteId: '', })} options={QUOTE_INSURANCE_TYPES.map((t) => ({ label: t, value: t }))} /> ), }, { title: '交强险到期日期', dataIndex: 'jqValidUntil', width: 108, onHeaderCell: () => ({ className: 'lc-compare-th-auto' }), render: (val, row) => renderReadonlyDate(val, isCompareRowVehicleLinked(row)), }, { title: '商业险到期日期', dataIndex: 'syValidUntil', width: 108, onHeaderCell: () => ({ className: 'lc-compare-th-auto' }), render: (val, row) => renderReadonlyDate(val, isCompareRowVehicleLinked(row)), }, { title: '最晚付费日期', dataIndex: 'latestPayDate', width: 128, onHeaderCell: () => ({ className: 'lc-compare-th-edit' }), render: (val, row) => { const paySt = getLatestPayDateStatus(val); return (
{renderDateCell(row.id, 'latestPayDate', val)} {val ? (
{paySt.type === 'overdue' ? `超期${Math.abs(paySt.diffDays)}天` : paySt.type === 'warning' ? `临期${paySt.diffDays}天` : `剩余${paySt.diffDays}天`}
) : null}
); }, }, { title: '采购状态', dataIndex: 'procurementStatus', width: 92, fixed: 'right', onHeaderCell: () => ({ className: 'lc-compare-th-auto' }), render: (st) => renderCompareProcurementStatusTag(st), }, { title: '当前审批人', dataIndex: 'procurementCurrentApprover', width: 96, fixed: 'right', onHeaderCell: () => ({ className: 'lc-compare-th-auto' }), render: (val, row) => { if (normalizeCompareProcurementStatus(row.procurementStatus) !== 'submitted') { return ; } if (!val) { return ( ); } return {val}; }, }, { title: ( 报价情况 * ), key: 'quotes', width: 240, fixed: 'right', onHeaderCell: () => ({ className: 'lc-compare-th-edit' }), render: (_, row) => renderQuoteCell(row), }, { title: '操作', key: 'action', width: 96, fixed: 'right', onHeaderCell: () => ({ className: 'lc-compare-th-edit' }), render: (_, row) => (
{ setCopyPopoverRowId(open ? row.id : null); if (!open) setCopyCountDraft(1); }} content={(
复制条数
setCopyCountDraft(v || 1)} style={{ width: '100%' }} />
)} >
), }, ]; const getInsuranceItemStatus = (ledgerKey, typeKey) => { const item = allInsurance[ledgerKey]?.[typeKey]; if (item?.policyTag === 'cancelled') { return { type: 'unuploaded', text: '已退保', diffDays: null }; } if (!item || !item.policyNo) { return { type: 'unuploaded', text: '未购买', diffDays: null }; } if (!item.endDate) { if (item.policyTag === 'suspended') { return { type: 'warning', text: '已停保', diffDays: null }; } return { type: 'unuploaded', text: '未购买', diffDays: null }; } const today = new Date(ANCHOR_TODAY); today.setHours(0, 0, 0, 0); const expDate = new Date(item.endDate); expDate.setHours(0, 0, 0, 0); const diffDays = Math.ceil((expDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); if (diffDays <= 0) { return { type: 'expired', text: `已到期 (逾期 ${Math.abs(diffDays)} 天)`, diffDays }; } if (diffDays <= INSURANCE_WARN_DAYS) { return { type: 'warning', text: `临期 (${diffDays} 天后)`, diffDays }; } return { type: 'success', text: '正常', diffDays }; }; /** 与车辆管理「保险状态」一致:交强险 + 商业险均存在且在有效期内(临期仍算有效) */ const getVehicleInsuranceStatus = (ledgerKey) => { const jq = getInsuranceItemStatus(ledgerKey, 'compulsory'); const sy = getInsuranceItemStatus(ledgerKey, 'commercial'); const isValid = (st) => st.type === 'success' || st.type === 'warning'; const isNormal = isValid(jq) && isValid(sy); if (isNormal) { const hasWarning = jq.type === 'warning' || sy.type === 'warning'; return { label: hasWarning ? '临期' : '正常', color: hasWarning ? 'warning' : 'success', abnormal: false, tip: hasWarning ? '交强险或商业险临期,仍在有效期内,可交车但需尽快续保' : '交强险、商业险均在有效期内', }; } const reasons = []; if (jq.type === 'unuploaded') reasons.push('交强险未购买'); else if (jq.type === 'expired') reasons.push('交强险已到期'); if (sy.type === 'unuploaded') reasons.push('商业险未购买'); else if (sy.type === 'expired') reasons.push('商业险已到期'); return { label: '异常', color: 'error', abnormal: true, tip: `${reasons.join(';')}。保险状态异常的车辆禁止交车`, }; }; const isCoreInsuranceNormal = (ledgerKey) => !getVehicleInsuranceStatus(ledgerKey).abnormal; const isAnyInsuranceWarning = (ledgerKey) => ( INSURANCE_TYPE_ITEMS.some((item) => getInsuranceItemStatus(ledgerKey, item.key).type === 'warning') ); const isCoreInsuranceExpired = (ledgerKey) => ( CORE_INSURANCE_KEYS.some((key) => getInsuranceItemStatus(ledgerKey, key).type === 'expired') ); const isCoreInsuranceMissing = (ledgerKey) => ( CORE_INSURANCE_KEYS.some((key) => getInsuranceItemStatus(ledgerKey, key).type === 'unuploaded') ); const matchKpiFilter = (ledgerKey, filterKey) => { if (filterKey === 'total') return true; if (filterKey === 'normal') return isCoreInsuranceNormal(ledgerKey); if (filterKey === 'warning') return isAnyInsuranceWarning(ledgerKey); if (filterKey === 'expired') return isCoreInsuranceExpired(ledgerKey); if (filterKey === 'unuploaded') return isCoreInsuranceMissing(ledgerKey); return true; }; const stats = useMemo(() => { let normal = 0; let warning = 0; let expired = 0; let unuploaded = 0; MOCK_VEHICLES.forEach((v) => { const ledgerKey = getVehicleLedgerKey(v); if (isCoreInsuranceNormal(ledgerKey)) normal += 1; if (isAnyInsuranceWarning(ledgerKey)) warning += 1; if (isCoreInsuranceExpired(ledgerKey)) expired += 1; if (isCoreInsuranceMissing(ledgerKey)) unuploaded += 1; }); return { total: MOCK_VEHICLES.length, normal, warning, expired, unuploaded }; }, [allInsurance]); const activeCompareSubmissionSet = useMemo( () => buildActiveCompareSubmissionSet(compareSheets), [compareSheets] ); const compareProcurementStatusByVehicleType = useMemo( () => buildCompareProcurementStatusByVehicleType(compareSheets), [compareSheets] ); const openInsuranceAlertModal = (mode) => { setInsuranceAlertMode(mode); setInsuranceAlertTypeFilter( mode === 'coreExpired' ? [...CORE_INSURANCE_KEYS] : [...EXPIRING_WARN_TYPE_KEYS] ); setInsuranceAlertSort({ key: 'commercial', order: 'descend' }); setInsuranceAlertOpen(true); }; const insuranceAlertBaseList = useMemo(() => { const selectedKeys = insuranceAlertTypeFilter || []; if (!selectedKeys.length) return []; return MOCK_VEHICLES.filter((vehicle) => { const ledgerKey = getVehicleLedgerKey(vehicle); if (insuranceAlertMode === 'coreExpired') { if (!isCoreInsuranceExpired(ledgerKey)) return false; return selectedKeys.some((typeKey) => getInsuranceItemStatus(ledgerKey, typeKey).type === 'expired'); } return selectedKeys.some((typeKey) => { const st = getInsuranceItemStatus(ledgerKey, typeKey).type; return st === 'warning' || st === 'expired'; }); }); }, [allInsurance, insuranceAlertTypeFilter, insuranceAlertMode]); const insuranceAlertSortedList = useMemo(() => { const list = [...insuranceAlertBaseList]; const sortKey = insuranceAlertSort.key || 'commercial'; const sortOrder = insuranceAlertSort.order || 'descend'; list.sort((a, b) => { const av = getVehicleInsuranceEndDate(getVehicleLedgerKey(a), sortKey, allInsurance); const bv = getVehicleInsuranceEndDate(getVehicleLedgerKey(b), sortKey, allInsurance); return compareInsuranceEndDate(av, bv, sortOrder); }); return list; }, [insuranceAlertBaseList, insuranceAlertSort, allInsurance]); const handleInsuranceAlertTableChange = (_pagination, _filters, sorter) => { const nextSorter = Array.isArray(sorter) ? sorter[0] : sorter; if (!nextSorter || !nextSorter.columnKey) return; setInsuranceAlertSort({ key: nextSorter.columnKey, order: nextSorter.order || 'descend', }); }; const renderInsuranceAlertDateCell = (record, typeKey) => { const ledgerKey = getVehicleLedgerKey(record); const dateVal = getVehicleInsuranceEndDate(ledgerKey, typeKey, allInsurance); const status = getInsuranceItemStatus(ledgerKey, typeKey); const isWarn = status.type === 'warning'; const isExpired = status.type === 'expired'; const typeLabel = INSURANCE_KEY_TO_LABEL[typeKey]; const procurementSt = getCompareProcurementStatusForVehicleType( record, typeLabel, compareProcurementStatusByVehicleType ); return (
{dateVal || '—'} {procurementSt && (isWarn || isExpired) ? (
{renderAlertCompareProcurementTag(procurementSt)}
) : null}
); }; const insuranceAlertColumns = useMemo(() => { const sortOrderFor = (typeKey) => (insuranceAlertSort.key === typeKey ? insuranceAlertSort.order : null); const dateCol = (typeKey, title) => ({ title, key: typeKey, dataIndex: typeKey, width: 136, sortOrder: sortOrderFor(typeKey), sorter: () => 0, sortDirections: ['descend', 'ascend'], showSorterTooltip: false, render: (_val, record) => renderInsuranceAlertDateCell(record, typeKey), }); return [ { title: '车牌号', key: 'plateNo', width: 108, fixed: 'left', render: (_val, record) => ( {formatVehiclePlateDisplay(record.plateNo)} ), }, dateCol('compulsory', '交强险到期日期'), dateCol('commercial', '商业险到期日期'), dateCol('excess', '超赔险到期日期'), dateCol('cargo', '货物险到期日期'), dateCol('driverAccident', '驾意险到期日期'), ]; }, [allInsurance, insuranceAlertSort, compareProcurementStatusByVehicleType]); const openBatchCompareTypesModal = () => { if (!insuranceAlertSortedList.length) { message.warning( insuranceAlertMode === 'coreExpired' ? '当前筛选条件下暂无核心险种逾期记录' : '当前筛选条件下暂无临期记录' ); return; } const defaultLabels = insuranceAlertTypeFilter.map((key) => INSURANCE_KEY_TO_LABEL[key]).filter(Boolean); const fallback = insuranceAlertMode === 'coreExpired' ? CORE_INSURANCE_KEYS.map((k) => INSURANCE_KEY_TO_LABEL[k]) : [...QUOTE_INSURANCE_TYPES]; setBatchCompareTypesDraft(defaultLabels.length ? defaultLabels : fallback); setBatchCompareTypesOpen(true); }; const handleConfirmBatchCompareSheets = () => { const allowedTypes = insuranceAlertMode === 'coreExpired' ? CORE_INSURANCE_KEYS.map((k) => INSURANCE_KEY_TO_LABEL[k]) : QUOTE_INSURANCE_TYPES; const selectedLabels = (batchCompareTypesDraft || []).filter((label) => allowedTypes.includes(label)); if (!selectedLabels.length) { message.warning('请至少选择一种保险类型'); return; } const targetStatus = insuranceAlertMode === 'coreExpired' ? 'expired' : 'warning'; const generatedRows = []; let skippedSubmitted = 0; selectedLabels.forEach((typeLabel) => { const typeKey = INSURANCE_LABEL_TO_KEY[typeLabel]; insuranceAlertSortedList .filter((vehicle) => getInsuranceItemStatus(getVehicleLedgerKey(vehicle), typeKey).type === targetStatus) .forEach((vehicle) => { if (isVehicleTypeSubmittedToCompare(vehicle, typeLabel, activeCompareSubmissionSet)) { skippedSubmitted += 1; return; } const row = buildCompareRowFromVehicle(vehicle, allInsurance); row.insuranceType = typeLabel; generatedRows.push(row); }); }); if (!generatedRows.length) { message.warning( skippedSubmitted ? '所选险种均已提交比价单或无可生成记录,未生成购买记录' : (insuranceAlertMode === 'coreExpired' ? '所选险种在当前列表中无逾期车辆,未生成购买记录' : '所选险种在当前列表中无临期车辆,未生成购买记录') ); return; } setEditingCompareSheetId(null); setCompareRows(normalizeCompareRows(generatedRows)); setCompareRemark(`临期预警一键生成 · ${selectedLabels.join('、')}`); setCompareEditorFilters({ ...DEFAULT_COMPARE_EDITOR_FILTERS }); setCompareVehicleFilterOpen(false); setCompareVehicleFilterDraft(''); setCompareAttachmentFileList([]); setSelectedCompareKeys([]); setQuoteDraft(createEmptyQuoteDraft()); setQuoteEditRowId(null); setBatchCompareTypesOpen(false); setInsuranceAlertOpen(false); setCompareModalOpen(true); const skipHint = skippedSubmitted ? `,已跳过 ${skippedSubmitted} 条已提交比价单记录` : ''; message.success(`已带入 ${generatedRows.length} 条购买记录至新建比价单${skipHint},请继续维护报价后保存`); }; const brandOptions = useMemo( () => [...new Set(MOCK_VEHICLES.map((v) => v.brand))].map((b) => ({ label: b, value: b })), [] ); const modelOptions = useMemo( () => [...new Set(MOCK_VEHICLES.map((v) => v.model))].map((m) => ({ label: m, value: m })), [] ); const appliedMultiPlates = useMemo(() => parseMultiPlates(appliedFilters.plateNos), [appliedFilters.plateNos]); const filterVehiclesByFilters = (vehicles, f, kpi) => { const plateKey = (f.plateNo || '').trim().toLowerCase(); const multiPlates = parseMultiPlates(f.plateNos); const vinKey = (f.vin || '').trim().toLowerCase(); const brandKey = (f.brand || '').trim().toLowerCase(); const modelKey = (f.model || '').trim().toLowerCase(); return vehicles.filter((v) => { const ledgerKey = getVehicleLedgerKey(v); const plateText = (v.plateNo || '').trim(); if (multiPlates.length) { if (!plateText) return false; if (!multiPlates.includes(plateText.toUpperCase())) return false; } else if (plateKey) { if (!plateText) { if (!NO_PLATE_LABEL.toLowerCase().includes(plateKey) && !plateKey.includes('暂无')) return false; } else if (!plateText.toLowerCase().includes(plateKey)) return false; } if (vinKey && !v.vin.toLowerCase().includes(vinKey)) return false; if (brandKey && !v.brand.toLowerCase().includes(brandKey)) return false; if (modelKey && !v.model.toLowerCase().includes(modelKey)) return false; if (f.operateStatus !== '全部' && v.status !== f.operateStatus) return false; if (f.insuranceStatus === '正常' && getVehicleInsuranceStatus(ledgerKey).abnormal) return false; if (f.insuranceStatus === '异常' && !getVehicleInsuranceStatus(ledgerKey).abnormal) return false; if (!vehicleMatchesListInsuranceTypeFilter(ledgerKey, f.insuranceType, f.endDateRange, allInsurance)) return false; if (!matchKpiFilter(ledgerKey, kpi)) return false; return true; }); }; const filteredVehicles = useMemo( () => sortVehiclesRetiredLast(filterVehiclesByFilters(MOCK_VEHICLES, appliedFilters, kpiFilter)), [appliedFilters, allInsurance, kpiFilter] ); const handleListFilterQuery = () => { const hasEndStart = listFilters.endDateRange?.[0]; const hasEndEnd = listFilters.endDateRange?.[1]; if ((hasEndStart || hasEndEnd) && !listFilters.insuranceType) { message.warning('请先选择保险类型,再按到期时间筛选'); return; } if ((hasEndStart && !hasEndEnd) || (!hasEndStart && hasEndEnd)) { message.warning('请完整选择到期时间的开始与结束日期'); return; } const plates = parseMultiPlates(multiPlateDraft); const next = { ...listFilters, plateNos: multiPlateDraft.trim(), plateNo: plates.length ? '' : listFilters.plateNo, }; setListFilters(next); setAppliedFilters(next); setMultiPlateOpen(false); const hitCount = filterVehiclesByFilters(MOCK_VEHICLES, next, kpiFilter).length; if (plates.length) { message.success(`已按 ${plates.length} 个车牌筛选,命中 ${hitCount} 条记录`); } else { message.success(`查询完成,命中 ${hitCount} 条记录`); } }; const handleListFilterReset = () => { setListFilters({ ...DEFAULT_LIST_FILTERS }); setAppliedFilters({ ...DEFAULT_LIST_FILTERS }); setMultiPlateDraft(''); setKpiFilter('total'); message.info('筛选条件已重置'); }; const handleMultiPlateOpenChange = (open) => { setMultiPlateOpen(open); if (open) setMultiPlateDraft(listFilters.plateNos || ''); }; const renderFilterField = (label, control) => (
{label}
{control}
); const isRetiredVehicle = (record) => record?.status === '退出运营'; const renderInsuranceDateCell = (record, typeKey) => { const ledgerKey = getVehicleLedgerKey(record); const item = allInsurance[ledgerKey]?.[typeKey]; const status = getInsuranceItemStatus(ledgerKey, typeKey); const dateVal = item?.endDate; const muted = isRetiredVehicle(record); const policyTagEl = renderPolicyStatusTag(item); return (
{dateVal || '—'}
{!muted ? (
{policyTagEl || ( {getInsuranceRemainShortText(status)} )} /> )}
) : null}
); }; const listColumns = [ { title: '车牌号', dataIndex: 'plateNo', key: 'plateNo', width: 148, onHeaderCell: listColumnHeaderCell, align: 'left', render: (plate, record) => { const muted = isRetiredVehicle(record); const noPlate = !hasVehiclePlate(record); const displayPlate = formatVehiclePlateDisplay(plate); const sub = [record.brand, record.model].filter(Boolean).join(' · '); return (
{displayPlate} {sub ? ( {sub} ) : null}
); }, }, { title: 'VIN码', dataIndex: 'vin', key: 'vin', width: 112, onHeaderCell: listColumnHeaderCell, render: (vin, record) => ( {vin} ), }, { title: '运营状态', dataIndex: 'status', key: 'status', width: 80, onHeaderCell: listColumnHeaderCell, render: (status) => ( {status} ), }, { title: '保险状态', key: 'insuranceStatus', width: 80, onHeaderCell: listColumnHeaderCell, render: (record) => { const st = getVehicleInsuranceStatus(getVehicleLedgerKey(record)); return ( {st.label} ); }, }, { title: '交强险到期日期', key: 'compulsory', width: 118, onHeaderCell: listColumnHeaderCell, render: (record) => renderInsuranceDateCell(record, 'compulsory'), }, { title: '商业险到期日期', key: 'commercial', width: 118, onHeaderCell: listColumnHeaderCell, render: (record) => renderInsuranceDateCell(record, 'commercial'), }, { title: '超赔险到期日期', key: 'excess', width: 118, onHeaderCell: listColumnHeaderCell, render: (record) => renderInsuranceDateCell(record, 'excess'), }, { title: '货物险到期日期', key: 'cargo', width: 118, onHeaderCell: listColumnHeaderCell, render: (record) => renderInsuranceDateCell(record, 'cargo'), }, { title: '驾意险到期日期', key: 'driverAccident', width: 118, onHeaderCell: listColumnHeaderCell, render: (record) => renderInsuranceDateCell(record, 'driverAccident'), }, { title: '操作', key: 'action', width: 64, onHeaderCell: listColumnHeaderCell, render: (record) => ( ), }, ]; return (
保险采购
保单管理(一车一档)· 比价单独立管理,互不关联
{renderFilterField('车牌号', ( 0} value={listFilters.plateNo} onChange={(e) => setListFilters((prev) => ({ ...prev, plateNo: e.target.value }))} onPressEnter={handleListFilterQuery} style={{ borderRadius: 8 }} /> ))} {renderFilterField('多车牌', (
每行一个车牌,或同一行内用逗号分隔
setMultiPlateDraft(e.target.value)} placeholder={'沪A03561F\n粤B58888F'} style={{ borderRadius: 8 }} />
)} > ))} {renderFilterField('VIN码', ( setListFilters((prev) => ({ ...prev, vin: e.target.value }))} onPressEnter={handleListFilterQuery} style={{ borderRadius: 8 }} /> ))} {renderFilterField('品牌', ( setListFilters((prev) => ({ ...prev, model: val || '' }))} options={modelOptions} filterOption={(input, option) => (option?.label || '').toLowerCase().includes(input.toLowerCase())} style={{ width: '100%' }} /> ))} {renderFilterField('运营状态', ( ))} {renderFilterField('保险状态', ( ))} {renderFilterField('保险类型', (
getVehicleLedgerKey(record)} rowClassName={(record) => (record.status === '退出运营' ? 'lc-row-retired' : '')} pagination={false} scroll={{ x: 1040 }} locale={{ emptyText: (
暂无符合检索条件的保险台账车辆
), }} /> setVehicleInsMgmtOpen(false)}> 关闭 )} onCancel={() => setVehicleInsMgmtOpen(false)} > {vehicleInsMgmtVehicle ? (() => { const profile = getVehicleProfile(vehicleInsMgmtVehicle); const ledgerKey = getVehicleLedgerKey(vehicleInsMgmtVehicle); const insStatus = getVehicleInsuranceStatus(ledgerKey); const statusPillClass = insStatus.color === 'success' || insStatus.color === 'warning' ? `lc-vehicle-ins-mgmt-status-pill--${insStatus.color}` : 'lc-vehicle-ins-mgmt-status-pill--error'; return (
车辆保险档案
{formatVehiclePlateDisplay(vehicleInsMgmtVehicle.plateNo)}
{vehicleInsMgmtVehicle.brand} {vehicleInsMgmtVehicle.model}
保险状态 · {insStatus.label}
VIN码
{vehicleInsMgmtVehicle.vin || '—'}
运营状态
{vehicleInsMgmtVehicle.status || '—'}
客户
{profile.customer || '—'}
产权方
{profile.ownerCompany || '—'}
注册日期
{profile.regDate || '—'}
年审到期
{profile.inspectExpire || '—'}
{renderFilterField('保单号', ( setVehicleInsMgmtPolicyNoFilter(e.target.value)} onPressEnter={handleVehicleInsMgmtPolicyNoSearch} style={{ borderRadius: 8 }} /> ))}
{ setVehicleInsMgmtActiveTab(key); setVehicleInsMgmtHighlightId(''); }} type="card" size="small" > {!vehicleInsuranceHistory.timelinePolicy?.length && !vehicleInsuranceHistory.timelineBiz?.length ? (
暂无全周期记录
可通过新增、续保或停保 / 复驶 / 退保操作产生记录
) : ( renderVehicleInsuranceCenterTimeline() )}
{VEHICLE_INSURANCE_MGMT_TABS.filter((t) => t.key !== 'timeline').map((tab) => ( {renderVehicleInsuranceTypeTab(tab.key)} ))}
); })() : null}
{ setPolicyBizModalOpen(false); setPolicyBizModalRecord(null); setPolicyBizForm({ ...EMPTY_POLICY_BIZ_FORM }); setPolicyBizAttachmentFileList([]); }} onOk={submitPolicyBizModal} > {policyBizModalRecord && vehicleInsMgmtVehicle ? ( <>
车牌号
{formatVehiclePlateDisplay(vehicleInsMgmtVehicle.plateNo)}
保单号
{policyBizModalRecord.policyNo || '—'}
保险公司
{policyBizModalRecord.company || '—'}
生效日期
{policyBizModalRecord.startDate || '—'}
到期日期
{policyBizModalRecord.endDate || '—'}
{policyBizModalMode === 'suspend' ? ( <> {renderFilterField('中止时间', ( setPolicyBizForm((p) => ({ ...p, suspendTime: ds || '' }))} /> ))} {renderFilterField('恢复时间', ( setPolicyBizForm((p) => ({ ...p, resumeTime: ds || '' }))} /> ))} {renderFilterField('新到期日期', ( setPolicyBizForm((p) => ({ ...p, newEndDate: ds || '' }))} /> ))}
停保单附件 支持 PDF、图片,可上传多份 false} onChange={handlePolicyBizAttachmentChange} >
) : null} {policyBizModalMode === 'resume' ? ( <> {renderFilterField('恢复时间', ( setPolicyBizForm((p) => ({ ...p, resumeTime: ds || '' }))} /> ))} {renderFilterField('新到期日期', ( setPolicyBizForm((p) => ({ ...p, newEndDate: ds || '' }))} /> ))}
复驶单附件 支持 PDF、图片,可上传多份 false} onChange={handlePolicyBizAttachmentChange} >
) : null} {policyBizModalMode === 'cancel' ? ( <> {renderFilterField('退保时间', ( setPolicyBizForm((p) => ({ ...p, cancelTime: ds || '' }))} /> ))} {renderFilterField('退还保费', ( setPolicyBizForm((p) => ({ ...p, refundPremium: e.target.value }))} placeholder="元" /> ))} ) : null}
) : null}
{ setPolicyOpHistoryOpen(false); setPolicyOpHistoryRecord(null); }} > 关闭 )} onCancel={() => { setPolicyOpHistoryOpen(false); setPolicyOpHistoryRecord(null); }} >
{val || '—'}, }, { title: '操作人', dataIndex: 'operator', width: 96, ellipsis: true, }, { title: '操作类型', dataIndex: 'type', width: 88, render: (val) => INSURANCE_OPERATION_TYPE_LABEL[val] || val || '—', }, { title: '备注', dataIndex: 'remark', ellipsis: true, render: (val) => ( {val || '—'} ), }, ]} /> setPrdOpen(false)} >

一、业务对象说明

  • 车辆档案:以车牌或 VIN 唯一标识一辆车;允许「暂无车牌、仅有 VIN」的库存车。
  • 保单台账:每辆车下分五类险种(交强、商业、超赔、货物、驾意)分别建档,记录保单号、保司、生效/到期日、保费等。
  • 比价单:一次保险采购批次,含创建信息、备注、附件,以及多条「购买记录」。
  • 购买记录:比价单内的一行,表示「某辆车 + 某种险种」的待购事项,含报价、最晚付费日、采购状态等。

二、保单管理 · 险种状态判定

针对每一类险种,按以下优先级判断当前状态(自上而下命中即停止):

  1. 已办理退保 → 视为「已退保」,等同未有效保障
  2. 无保单号 → 「未购买」
  3. 有保单号但无到期日,且处于停保状态 → 「已停保」(预警)
  4. 有保单号但无到期日 → 「未购买」
  5. 到期日 ≤ 今天 → 「已到期」
  6. 到期日在未来 30 天内 → 「临期」(仍在有效期内)
  7. 其余 → 「正常」

三、保单管理 · 交车联动规则

车辆整体保险状态仅由交强险 + 商业险决定,与车辆管理模块保持一致:

  • 交强、商业均为「正常」或「临期」→ 车辆保险状态为「正常」或「临期」,允许交车(临期仍有效,但需尽快续保)
  • 交强或商业任一为「未购买」或「已到期」→ 车辆保险状态为「异常」,禁止交车
  • 超赔、货物、驾意险只影响首页「险种临期预警」统计,不参与交车判定

四、保单管理 · 录入与变更流转

4.1 新保 / 续保录入

  • 渠道:逐条新增、批量识别(上传保单附件)、批量导入 Excel(仅新保/续保)
  • 必填:车牌或 VIN(至少一项)、险种、保险公司、保单号、生效日期、到期日期、保险费合计
  • 选填:承保险种明细、保险金额、免赔额、分项保费等
  • 导入时自动跳过停保、停租、复驶、退保类业务行

4.2 停保 / 复驶 / 退保

  • 在车辆「管理」弹窗中,针对当前有效记录办理(历史归档记录不可操作)
  • 新保、续保记录 → 可停保、可退保
  • 停租记录 → 可复驶、可退保
  • 退保记录 → 仅可复驶
  • 全周期时间轴:左侧展示新保/续保,右侧展示停保/复驶/退保,便于追溯

五、比价单 · 完整业务流转

  1. 创建比价单:手动新建、编辑历史批次,或由临期/逾期预警一键生成购买记录
  2. 维护购买记录:选择车辆与险种,系统自动带出客户、品牌、交强/商业到期日等;用户确认投保方式(新保/续保)
  3. 录入报价:每行可录多家保险公司报价,须选定一条为「最终比价结果」
  4. 填写最晚付费日:为每行指定付款截止日期,用于临期/超期提醒
  5. 保存比价单:填写备注、上传附件(均必填),持久化整单数据
  6. 勾选提交采购:选择需采购的行发起审批;提交后该行进入「审批中」
  7. 审批办理:在审批中心通过、驳回或撤回;结果回写至购买记录的采购状态
  8. 后续处理:驳回或撤回后可重新勾选提交;审批通过后该行不可再次提交

六、比价单 · 购买记录规则

  • 每行须关联一辆车(车牌或 VIN 至少填一项)
  • 选车后,客户、品牌、车型、年检有效期、交强/商业到期日等自动带出,不可手改
  • 用户可修改:投保方式、保险类型、最晚付费日期
  • 若台账中该车已有交强或商业到期日,默认投保方式为「续保」,否则为「新保」
  • 修改保险类型时,该行已有报价全部清空,需重新报价
  • 删除已设为「最终比价结果」的报价时,最终比价结果一并取消

七、比价单 · 报价业务规则

  • 「报价情况」为提交采购前的必填项:每行至少录入一条报价(保险公司 + 保险费金额)
  • 同一行可有多家报价,但提交前必须且只能确定一条「最终比价结果」
  • 金额汇总仅统计已确定最终比价结果的行;未确定的行不计入合计
  • 底部左侧「当前保单总金额」= 本单全部已确认报价之和;右侧「已选保单总金额」= 当前勾选且已确认报价之和

八、比价单 · 保存与提交的业务校验

业务动作 须满足的条件
保存比价单
  • 至少有一条购买记录
  • 每条记录均已选择车辆(车牌或 VIN)
  • 整单备注已填写
  • 整单已上传至少 1 个附件
提交采购申请
针对勾选的行
  • 至少勾选一条购买记录
  • 勾选行均已录入报价,且已设为最终比价结果
  • 勾选行均已填写最晚付费日期
  • 勾选行采购状态为「未提交」「撤回」或「审批驳回」(审批中、审批通过不可再提交)
  • 整单备注、附件要求同「保存比价单」
  • 若比价单尚未保存,系统先提示保存再提交

九、比价单 · 采购审批状态流转

状态 含义 可否再次勾选提交 产生方式
未提交尚未发起采购审批可以新建购买记录默认状态
审批中已提交,流程进行中不可以本页点击「提交采购申请」
审批通过审批流程已完结且通过不可以审批中心末节点通过后回写
撤回流程被申请人或审批人撤回可以审批中心办理后回写
审批驳回审批未通过可以审批中心办理后回写

十、预警与一键生成规则

10.1 台账险种预警(首页 KPI)

10.2 比价单最晚付费预警(比价单管理看板)

10.3 一键生成比价单

十一、比价单列表统计口径

十二、两条业务线的关系

), }, { key: 'manual', label: '操作手册', children: (

提交前自查(勾选行须全部满足)

  • 已设最终比价结果
  • 已填最晚付费日期
  • 采购状态为「未提交 / 撤回 / 审批驳回」(审批中、审批通过不可选)
  • 比价单已填备注并上传附件

金额栏

  • 左:当前保单总金额 — 全单已确认报价合计
  • 右:已选保单总金额 — 勾选待提交行合计

常见情况

  • 一键生成后须补报价、附件,再保存提交。
  • 撤回/驳回后:重新勾选该行 → 再次提交采购申请。
  • 比价单管理列表可筛创建时间/车牌,查看临期/超期看板。
), }, ]} /> setCompareMgmtOpen(false)} >
{renderFilterField('创建时间', ( setCompareMgmtFilters((prev) => ({ ...prev, createdRange: range }))} placeholder={['开始日期', '结束日期']} allowClear /> ))} {renderFilterField('车牌号', ( setCompareMgmtFilters((prev) => ({ ...prev, plateNo: e.target.value }))} onPressEnter={handleCompareMgmtQuery} style={{ borderRadius: 8 }} /> ))}
最晚付费临期
距最晚付费日 ≤ {LATEST_PAY_WARN_DAYS} 天
{compareMgmtPayAlerts.warning}
最晚付费超期
已超过最晚付费日
{compareMgmtPayAlerts.overdue}
{filteredCompareSheets.length} 条比价单
`共 ${t} 条` }} scroll={{ x: 1040 }} locale={{ emptyText: '暂无比价单,请点击「新建比价单」' }} columns={[ { title: '创建日期', dataIndex: 'createdAt', key: 'createdAt', width: 168, render: (val) => {val || '—'}, }, { title: '创建人', dataIndex: 'createdBy', key: 'createdBy', width: 96, render: (val) => val || '—', }, { title: '总车辆', dataIndex: 'totalVehicles', key: 'totalVehicles', width: 88, align: 'center', render: (val) => {val ?? 0}, }, { title: '保险数量', dataIndex: 'insuranceCount', key: 'insuranceCount', width: 96, align: 'center', render: (val) => {val ?? 0}, }, { title: '附件', key: 'attachments', width: 72, align: 'center', render: (_, record) => { const n = record.attachments?.length || 0; return n > 0 ? ( a.name).join('、')}> {n} ) : ( ); }, }, { title: '已提交采购数量', dataIndex: 'submittedProcurementCount', key: 'submittedProcurementCount', width: 128, align: 'center', render: (val) => ( 0 ? 'processing' : 'default'} style={{ margin: 0, fontWeight: 600 }}> {val ?? 0} ), }, { title: '审批通过数量', dataIndex: 'approvedCount', key: 'approvedCount', width: 120, align: 'center', render: (val) => ( 0 ? 'success' : 'default'} style={{ margin: 0, fontWeight: 600 }}> {val ?? 0} ), }, { title: '操作', key: 'action', width: 120, fixed: 'right', render: (_, record) => ( ), }, ]} /> { setCompareModalOpen(false); setEditingCompareSheetId(null); }} footer={(
勾选购买记录后可提交采购申请;须新增报价、设为最终比价结果并填写最晚付费日期
)} >
{renderFilterField('车辆', (
支持多辆车车牌号、车辆识别代码,每行一条;可从 Excel 等批量复制粘贴,点击「收起」后列表展示全部命中记录。
setCompareVehicleFilterDraft(e.target.value)} placeholder={'沪A03561F\n粤B58888F\nLB9A32A21R0LS1478'} autoSize={{ minRows: 5, maxRows: 10 }} style={{ borderRadius: 8, fontFamily: 'monospace', fontSize: 13 }} />
)} > setCompareVehicleFilterOpen(true)} onClear={(e) => { e.stopPropagation(); handleCompareVehicleFilterClear(); }} style={{ borderRadius: 8 }} suffix={( )} /> ))}
setCompareEditorFilters((prev) => ({ ...prev, latestPayWithin3Days: e.target.checked }))} > 仅显示最晚付费日期 {LATEST_PAY_WARN_DAYS} 天内的记录
setCompareEditorFilters((prev) => ({ ...prev, insuranceType: '' }))} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setCompareEditorFilters((prev) => ({ ...prev, insuranceType: '' })); } }} > {compareRows.length} 全部
{QUOTE_INSURANCE_TYPES.map((typeLabel) => (
setCompareEditorFilters((prev) => ({ ...prev, insuranceType: typeLabel, }))} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setCompareEditorFilters((prev) => ({ ...prev, insuranceType: typeLabel })); } }} > {compareEditorTypeCounts[typeLabel] ?? 0} {typeLabel}
))}
{selectedCompareKeys.length ? ( ) : null} {isCompareEditorFiltered ? ( <>筛选显示 {displayCompareRows.length} / {compareRows.length} 条购买记录 ) : ( <>共 {compareRows.length} 条购买记录 )}
({ disabled: isCompareProcurementSelectionDisabled(record.procurementStatus), }), }} locale={{ emptyText: '暂无购买记录,请点击「新增一行」或「批量新增」' }} />
setCompareRemark(e.target.value)} placeholder="请填写比价说明、采购要求等" maxLength={500} showCount style={{ borderRadius: 8 }} />
附件 必填,不限制文件格式与上传数量;保存比价单时一并存储附件信息(原型仅存元数据) false} onChange={handleCompareAttachmentChange} itemRender={(originNode, file) => ( {originNode} )} >
当前保单总金额 {compareSheetSummary.total.toFixed(2)} 全单已确认 {compareSheetSummary.count} 项
已选保单总金额 {selectedProcurementSummary.total.toFixed(2)} 已勾选 {selectedCompareKeys.length} 条 · 可提交 {selectedProcurementSummary.count} 项确认报价
{ setCompareBatchAddOpen(false); setCompareBatchAddDraft(''); }} onOk={handleConfirmBatchAddCompareRows} >
每行输入一个车牌号或车辆识别代码(VIN),确认后按行数生成购买记录并自动带出车辆信息。 {compareEditorFilters.insuranceType ? ( 当前已选险种筛选「{compareEditorFilters.insuranceType}」,新增记录将默认使用该险种。 ) : null}
setCompareBatchAddDraft(e.target.value)} placeholder={'沪A03561F\n粤B58888F\nLMRKH9AC0R1004086'} style={{ borderRadius: 8, fontFamily: 'ui-monospace, monospace', fontSize: 13 }} />
已输入 {parseBatchVehicleLines(compareBatchAddDraft).length} 条
setPolicyRecognTasksOpen(false)} >
{renderFilterField('创建时间', ( setPolicyRecognTasksFilters((prev) => ({ ...prev, createdRange: range }))} placeholder={['开始日期', '结束日期']} allowClear /> ))} {renderFilterField('业务类型', (
`共 ${t} 条` }} scroll={{ x: 1080 }} locale={{ emptyText: '暂无识别任务,请发起「保单批量识别」' }} columns={[ { title: '创建时间', dataIndex: 'createdAt', width: 168, render: (val) => {val || '—'}, }, { title: '操作人', dataIndex: 'creator', width: 96, ellipsis: true, }, { title: '业务类型', dataIndex: 'modeLabel', width: 96, render: (val) => val || '—', }, { title: '识别成功数', dataIndex: 'recognSuccessCount', width: 96, align: 'center', render: (val) => ( {val ?? 0} ), }, { title: '识别失败数', dataIndex: 'recognFailCount', width: 96, align: 'center', render: (val) => ( 0 ? '#dc2626' : '#64748b', fontWeight: 600 }}> {val ?? 0} ), }, { title: '识别进度', key: 'recognProgress', width: 132, render: (_, record) => { const total = record.totalFileCount || record.fileCount || 0; const done = record.recognDoneCount ?? (isPolicyRecognTaskRecognizing(record) ? 0 : total); const recognizing = isPolicyRecognTaskRecognizing(record); const percent = total > 0 ? Math.round((done / total) * 100) : 0; return (
{total > 0 ? `${done}/${total}` : '—'}
); }, }, { title: '确认进度', key: 'confirmProgress', width: 96, align: 'center', render: (_, record) => ( {record.confirmedCount}/{record.recognSuccessCount || 0} ), }, { title: '操作', key: 'action', width: 120, fixed: 'right', render: (_, record) => { const recognizing = isPolicyRecognTaskRecognizing(record); const noSuccess = !(record.recognSuccessCount > 0); const disabled = recognizing || noSuccess; const btn = ( ); if (recognizing) { return ( {btn} ); } return btn; }, }, ]} /> {policyRecognPhase === 'upload' ? ( <> {policyRecognEntry === 'ocr' ? ( <>
业务类型
setPolicyRecognMode(e.target.value)} style={{ display: 'flex', flexDirection: 'column', gap: 8 }} > {POLICY_OCR_MODES.map((m) => ( {m.label} {m.desc} ))}
{policyRecognMode === 'policy' ? (
{renderFilterField('保险类型', (
5 ? { pageSize: 5, showSizeChanger: false } : false} scroll={{ x: 560 }} columns={policyRecognPickerColumns} onRow={(record) => ({ onClick: () => selectPolicyRecognResult(record.id), className: [ record.id === policyRecognActiveResultId ? 'lc-policy-recogn-picker-row--active' : '', record.confirmed ? 'lc-policy-recogn-picker-row--confirmed' : '', ].filter(Boolean).join(' '), })} />
保单预览 · {policyPreview?.fileName || '—'}
{policyPreview?.isImage && policyPreview.url ? ( {policyPreview.fileName} ) : (
PDF
{policyPreview?.fileName || '暂无预览'}
{policyPreview?.hint}
)}
确认识别内容
{policyRecognActiveResultId ? ( renderPolicyDetailForm(policyRecognConfirmDraft, setPolicyRecognConfirmDraft, { showBizType: false, recognConfirmMode: true, onPlateChange: handleRecognConfirmPlateChange, }) ) : (
请从上方列表选择一条识别记录
)}
{!policyRecognViewOnly ? ( ) : null} {!policyRecognViewOnly ? ( ) : null} {!policyRecognViewOnly ? ( ) : null}
) : null} { setVehicleInsHistoryEditOpen(false); setVehicleInsHistoryEditRecord(null); }} onOk={saveVehicleInsHistoryEdit} okText="保存" cancelText="取消" > {renderPolicyDetailForm(vehicleInsHistoryEditDraft, setVehicleInsHistoryEditDraft)} { setPolicyAddOpen(false); setPolicyAddAttachmentFileList([]); }} onOk={handlePolicyAddSubmit} okText="保存" cancelText="取消" > {renderPolicyDetailForm(policyAddDraft, setPolicyAddDraft, { showBizType: false, policyEntryMode: true })}
保单附件 支持 PDF、图片格式,可上传多份;保存时一并存入保单档案(原型仅存元数据) false} onChange={handlePolicyAddAttachmentChange} itemRender={(originNode, file) => ( {originNode} )} >
默认按商业险到期日期降序;点击表头可切换各险种升序/降序 )} onCancel={() => setInsuranceAlertOpen(false)} >
{insuranceAlertMode === 'coreExpired' ? '逾期险种筛选(交强险、商业险)' : '临期险种筛选(默认全选 5 类)'}
CORE_INSURANCE_KEYS.includes(item.key)) : INSURANCE_TYPE_ITEMS ).map((item) => ({ label: item.fullLabel, value: item.key }))} value={insuranceAlertTypeFilter} onChange={(vals) => setInsuranceAlertTypeFilter(vals)} />
{insuranceAlertMode === 'coreExpired' ? ( <> 共 {insuranceAlertSortedList.length} 辆核心险种逾期 (交强险或商业险已到期,禁止交车) ) : ( <> 共 {insuranceAlertSortedList.length} 辆存在临期记录 (含临期 ≤ {INSURANCE_WARN_DAYS} 天及已到期) )}
getVehicleLedgerKey(record)} size="small" bordered pagination={{ pageSize: 10, showSizeChanger: true, pageSizeOptions: ['10', '20', '50'] }} scroll={{ x: 940 }} dataSource={insuranceAlertSortedList} columns={insuranceAlertColumns} onChange={handleInsuranceAlertTableChange} locale={{ emptyText: insuranceAlertMode === 'coreExpired' ? '当前筛选条件下暂无核心险种逾期记录' : '当前筛选条件下暂无临期记录', }} /> setBatchCompareTypesOpen(false)} onOk={handleConfirmBatchCompareSheets} >
选择要生成的保险类型
INSURANCE_KEY_TO_LABEL[k]) : QUOTE_INSURANCE_TYPES ).map((t) => ({ label: t, value: t }))} value={batchCompareTypesDraft} onChange={(vals) => setBatchCompareTypesDraft(vals)} />
); }; export default Component;