// 【重要】必须使用 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 ? (