// 【重要】必须使用 const Component 作为组件变量名
// 业务管理 - 保险采购
// 模块:① 比价单(选车报价 → 保存 → 按最晚付费日临期/超期提醒 → 勾选提交采购工作流)
// ② 保单管理(一车一档台账,OCR/导入/逐条录入,与比价单不关联)
// 与车辆管理「保险状态」联动:交强险 + 商业险均存在且在有效期内为正常,否则异常(禁止交车)
const { useState, useMemo, useCallback } = 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,
Steps,
Upload,
Progress,
Tabs,
Timeline,
} = 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_v1';
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 INSURANCE_WARN_DAYS = 30;
/** 比价单:最晚付费日期 ≤ 该天数视为临期 */
const LATEST_PAY_WARN_DAYS = 3;
const INSURANCE_LABEL_TO_KEY = {
交强险: 'compulsory',
商业险: 'commercial',
超赔险: 'excess',
货物险: 'cargo',
驾意险: 'driverAccident',
};
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: '',
};
/** 保单项目/责任限额:表单为字符串数组,导入/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 serializeCoverageItems = (items) => parseCoverageItemsInput(items).join(';');
const getCoverageItemsFormRows = (items) => {
const list = Array.isArray(items) ? items : parseCoverageItemsInput(items);
return list.length ? list : [''];
};
/** 基于用户提供的真实保单/批单样本(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: '死亡伤残赔偿限额180000元;医疗费用赔偿限额18000元;财产损失赔偿限额2000元',
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: '机动车损失险、第三者责任险、车上人员责任险',
},
},
{
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: parseCoverageItemsInput(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 (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');
};
const POLICY_IMPORT_TEMPLATE_HEADERS = [
'车牌号',
'VIN码',
'业务类型',
'险种',
'保险公司',
'保单号',
'批单号',
'付款时间',
'生效日期',
'到期日期',
'复驶日期',
'保费(元)',
'保单项目',
'投保人',
'被保险人',
'签单日期',
];
const POLICY_IMPORT_TEMPLATE_SAMPLE_ROW = [
'沪BDB9161',
'LC0DF4CD8S0303140',
'保单录入',
'交强险',
'中国太平洋财产保险股份有限公司',
'ASHZ001CTP26B187065J',
'DZQA26480000279515',
'2026-06-01 17:42:10',
'2026-06-05',
'2027-06-04',
'',
'1243.00',
'死亡伤残180000元;医疗18000元;财产2000元',
'上海羚牛氢运物联网科技有限公司',
'上海羚牛氢运物联网科技有限公司',
'2026-05-27',
];
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/, '');
const map = {
车牌号: 'plateNo',
车牌: 'plateNo',
VIN码: 'vin',
VIN: 'vin',
业务类型: 'bizTypeLabel',
险种: 'insuranceType',
保险类型: 'insuranceType',
保险公司: 'company',
保单号: 'policyNo',
批单号: 'endorsementNo',
付款时间: 'payTime',
生效日期: 'startDate',
起保日期: 'startDate',
到期日期: 'endDate',
复驶日期: 'reinstateDate',
'保费(元)': 'premium',
保费: 'premium',
保单项目: 'coverageItems',
投保人: 'applicant',
被保险人: 'insured',
签单日期: 'signDate',
};
return map[h] || null;
};
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 pick = (cells, key, fallbackIdx) => {
if (hasHeader && colIndex[key] != null) return (cells[colIndex[key]] || '').trim();
return (cells[fallbackIdx] || '').trim();
};
return dataLines.map((line) => {
const cells = parseCsvLine(line);
if (!cells.some((c) => c)) return null;
const bizLabel = pick(cells, 'bizTypeLabel', 2);
const bizMap = { 保单录入: 'policy', 停保: 'suspend', 停租: 'suspend', 复驶: 'resume', 退保: 'cancel' };
return {
plateNo: pick(cells, 'plateNo', 0),
vin: pick(cells, 'vin', 1),
bizType: bizMap[bizLabel] || 'policy',
insuranceType: pick(cells, 'insuranceType', 3),
company: pick(cells, 'company', 4),
policyNo: pick(cells, 'policyNo', 5),
endorsementNo: pick(cells, 'endorsementNo', 6),
payTime: pick(cells, 'payTime', 7),
startDate: pick(cells, 'startDate', 8),
endDate: pick(cells, 'endDate', 9),
reinstateDate: pick(cells, 'reinstateDate', 10),
premium: pick(cells, 'premium', 11),
coverageItems: pick(cells, 'coverageItems', 12),
applicant: pick(cells, 'applicant', 13),
insured: pick(cells, 'insured', 14),
signDate: pick(cells, 'signDate', 15),
};
}).filter(Boolean);
};
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
? '险种填写有误'
: '请填写保单号或到期日期',
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)}`;
}
return buildRecognResultFromDetail(
{ id: `ocr-r-${file.uid}`, fileUid: file.uid, fileName: file.name, fileType: file.type || '' },
detail,
allInsurance,
mode
);
})
);
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: parseCoverageItemsInput(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' },
};
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) => r.procurementStatus === 'submitted' || r.procurementStatus === 'completed').length;
const completedCount = rows.filter((r) => r.procurementStatus === 'completed').length;
return { submittedProcurementCount, completedCount };
};
const normalizeCompareRows = (rows) => (rows || []).map((row) => ({
...row,
procurementStatus: row.procurementStatus || 'none',
}));
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: '',
});
const createEmptyCompareRow = () => ({
id: createCompareRowId(),
plateNo: '',
vin: '',
customer: '',
ownerCompany: '',
brand: '',
model: '',
bodyColor: '',
regDate: '',
inspectExpire: '',
insureMode: '续保',
insuranceType: '交强险',
jqValidUntil: '',
syValidUntil: '',
latestPayDate: '',
quotes: [],
confirmedQuoteId: '',
procurementStatus: 'none',
procurementSubmittedAt: '',
});
const buildCompareRowFromVehicle = (v, insuranceData) => ({
id: createCompareRowId(),
...buildVehicleComparePatch(v, insuranceData),
latestPayDate: '',
quotes: [],
confirmedQuoteId: '',
procurementStatus: 'none',
procurementSubmittedAt: '',
});
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: '自营' },
];
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_PURCHASE_TYPE_LEGEND = ['new', 'renew', 'rentStop', 'resume', 'cancel'];
const POLICY_LIKE_EVENT_TYPES = new Set(['purchase', 'renew', 'procurement', 'recognize']);
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 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: parseCoverageItemsInput(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 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;
const purchaseTime = item.startDate || item.updateTime || item.endDate;
records.push(createInsuranceHistoryRecord({
id: `ih-${ledgerKey}-${typeKey}-ledger-current`,
typeKey,
typeLabel,
eventType: 'purchase',
time: purchaseTime,
payTime: item.payTime || '',
policyNo: item.policyNo,
company: item.company,
premium: item.premium,
startDate: item.startDate,
endDate: item.endDate,
policyDetail: buildPolicyDetailFromLedgerItem(vehicle, typeLabel, item, 'policy'),
source: 'ledger',
sourceLabel: '台账当前保单',
fileName: `${item.policyNo}_${typeLabel}.pdf`,
}));
if (item.startDate) {
const prevStart = subtractInsuranceYears(item.startDate, 1);
const prevEnd = item.endDate ? subtractInsuranceYears(item.endDate, 1) : '';
if (prevStart) {
records.push(createInsuranceHistoryRecord({
id: `ih-${ledgerKey}-${typeKey}-history-${prevStart}`,
typeKey,
typeLabel,
eventType: 'renew',
time: prevStart,
policyNo: `${String(item.policyNo).slice(0, 12)}-H1`,
company: item.company,
premium: item.premium,
startDate: prevStart,
endDate: prevEnd,
source: 'history',
sourceLabel: '历史续保',
fileName: `${item.policyNo}_续保_${typeLabel}.pdf`,
}));
}
}
if (item.policyTag === 'suspended') {
records.push(createInsuranceHistoryRecord({
id: `ih-${ledgerKey}-${typeKey}-ledger-suspend`,
typeKey,
typeLabel,
eventType: 'suspend',
time: item.updateTime || item.endDate || purchaseTime,
policyNo: item.policyNo,
company: item.company,
premium: item.premium,
startDate: item.startDate,
endDate: item.endDate,
policyTag: 'suspended',
reinstateDate: item.reinstateDate,
policyDetail: buildPolicyDetailFromLedgerItem(vehicle, typeLabel, item, 'suspend'),
source: 'ledger',
sourceLabel: '停租',
fileName: `${item.policyNo}_停租_${typeLabel}.pdf`,
}));
}
if (item.policyTag === 'cancelled') {
records.push(createInsuranceHistoryRecord({
id: `ih-${ledgerKey}-${typeKey}-ledger-cancel`,
typeKey,
typeLabel,
eventType: 'cancel',
time: item.updateTime || item.endDate || purchaseTime,
policyNo: item.policyNo,
company: item.company,
premium: item.premium,
startDate: item.startDate,
endDate: item.endDate,
policyTag: 'cancelled',
policyDetail: buildPolicyDetailFromLedgerItem(vehicle, typeLabel, item, 'cancel'),
source: 'ledger',
sourceLabel: '退保',
fileName: `${item.policyNo}_退保_${typeLabel}.pdf`,
}));
}
});
(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 = row.procurementStatus === 'completed' ? '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 }) => { byType[key] = []; });
records.forEach((r) => {
if (byType[r.typeKey]) byType[r.typeKey].push(r);
});
Object.keys(byType).forEach((k) => {
byType[k].sort((a, b) => String(b.time).localeCompare(String(a.time)));
});
return { timeline, 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: '' },
},
};
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 derivePolicyRecognTaskStatus = (results) => {
const list = 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) => {
const list = results || [];
return {
fileCount: list.length,
matchedCount: list.filter((r) => r.matched).length,
confirmedCount: list.filter((r) => r.confirmed).length,
};
};
const buildPolicyRecognTaskRecord = ({
id,
entry,
mode,
insuranceType,
results,
createdAt,
creator,
status,
completedAt,
phase,
}) => {
const stats = summarizePolicyRecognTask(results);
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: phase || 'results',
...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 files1 = [
{ uid: 'demo-f1', name: '粤BDG9701_交强险.pdf', status: 'done' },
{ uid: 'demo-f2', name: '粤AGR9766_超赔险.pdf', status: 'done' },
];
const results1 = buildMockOcrResults(files1, 'policy', '交强险', insMap);
results1.forEach((r) => { r.confirmed = true; });
const files2 = [
{ uid: 'demo-f3', name: '沪A03561F_商业险.jpg', status: 'done' },
{ uid: 'demo-f4', name: '粤B88888_交强险.pdf', status: 'done' },
];
const results2 = buildMockOcrResults(files2, 'policy', '商业险', insMap);
if (results2[0]) results2[0].confirmed = true;
return [
buildPolicyRecognTaskRecord({
id: 'TASK-83892906',
entry: 'ocr',
mode: 'policy',
insuranceType: '交强险',
results: results1,
createdAt: '2026-05-28 15:20:10',
completedAt: '2026-05-28 15:32:00',
phase: 'results',
}),
buildPolicyRecognTaskRecord({
id: 'TASK-84120155',
entry: 'import',
mode: 'policy',
insuranceType: '商业险',
results: results2,
createdAt: '2026-05-30 09:15:00',
phase: 'results',
}),
];
};
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 = extra.procurementStatus || 'none';
row.procurementSubmittedAt = extra.procurementSubmittedAt || '';
return row;
};
const createMockCompareSheets = () => {
const insMap = buildMockInsuranceMap();
const sheet1Rows = normalizeCompareRows([
createMockCompareRowWithQuote(MOCK_VEHICLES[0], insMap, '交强险', '950.00', { latestPayDate: '2026-06-03', procurementStatus: 'completed' }),
createMockCompareRowWithQuote(MOCK_VEHICLES[0], insMap, '商业险', '12800.50', { latestPayDate: '2026-06-04', procurementStatus: 'submitted' }),
createMockCompareRowWithQuote(MOCK_VEHICLES[1], insMap, '交强险', '950.00', { latestPayDate: '2026-05-28' }),
]);
const sheet2Rows = normalizeCompareRows([
createMockCompareRowWithQuote(MOCK_VEHICLES[2], insMap, '交强险', '880.00', { latestPayDate: '2026-06-02', procurementStatus: 'completed' }),
createMockCompareRowWithQuote(MOCK_VEHICLES[2], insMap, '商业险', '9850.00', { latestPayDate: '2026-06-02', procurementStatus: 'completed' }),
createMockCompareRowWithQuote(MOCK_VEHICLES[7], insMap, '交强险', '950.00', { latestPayDate: '2026-06-01', procurementStatus: 'submitted' }),
createMockCompareRowWithQuote(MOCK_VEHICLES[7], insMap, '超赔险', '2600.00', { latestPayDate: '2026-07-01', procurementStatus: 'submitted' }),
]);
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: '苏粤车辆续保比价',
rows: sheet2Rows,
}),
normalizeCompareSheet({
id: 'cs-mock-20260510',
createdAt: '2026-05-10 16:40:00',
createdBy: '王专员',
periodLabel: '2026年5月',
remark: '',
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 DEFAULT_COMPARE_MGMT_FILTERS = { plateNo: '', createdRange: null };
const DEFAULT_POLICY_RECOGN_TASK_FILTERS = { taskId: '', entry: '全部', status: '全部', createdRange: null };
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()))];
};
const ICONS = {
vehicle: ,
success: ,
warning: ,
shield: ,
policy: ,
};
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-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-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-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-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-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-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 { margin-top: 8px; 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-task { padding: 10px 14px; border-radius: 10px; background: #f8fafc; border: 1px solid #e2e8f0; margin-bottom: 14px; font-size: 12px; color: #64748b; }
.lc-policy-recogn-task strong { color: #0f172a; }
.lc-policy-recogn-progress { margin: 16px 0; }
.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-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: 70vh; object-fit: contain; }
.lc-policy-recogn-preview iframe { width: 100%; height: 70vh; border: none; border-radius: 8px; }
.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-legend { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; padding: 10px 12px; border-radius: 10px; background: #f8fafc; border: 1px solid #e2e8f0; }
.lc-vehicle-ins-mgmt-legend-hint { font-size: 12px; color: #64748b; margin-right: 8px; align-self: center; }
.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 { padding: 4px 8px 12px; max-height: 440px; overflow-y: auto; }
.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; }
.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-editor { display: flex; flex-direction: column; gap: 8px; width: 100%; }
.lc-coverage-items-row { display: flex; align-items: center; gap: 8px; }
.lc-coverage-items-row .ant-input { flex: 1; min-width: 0; }
.lc-coverage-items-index { flex-shrink: 0; width: 22px; font-size: 12px; font-weight: 700; color: #94a3b8; text-align: right; font-variant-numeric: tabular-nums; }
.lc-coverage-items-add { margin-top: 2px; 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]);
});
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: '全部',
};
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 [compareSheetPeriod, setCompareSheetPeriod] = useState('');
const [compareAttachmentFileList, setCompareAttachmentFileList] = useState([]);
const [procurementFlowOpen, setProcurementFlowOpen] = useState(false);
const [procurementFlowStep, setProcurementFlowStep] = useState(0);
const [procurementFlowLoading, setProcurementFlowLoading] = useState(false);
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 [policyRecognProgress, setPolicyRecognProgress] = useState(0);
const [policyRecognTaskId, setPolicyRecognTaskId] = useState('');
const [policyRecognResults, setPolicyRecognResults] = useState([]);
const [policyRecognViewOnly, setPolicyRecognViewOnly] = useState(false);
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 [policyAddDraft, setPolicyAddDraft] = useState(() => ({ ...EMPTY_POLICY_DETAIL }));
const [policyRecognEditOpen, setPolicyRecognEditOpen] = useState(false);
const [policyRecognEditId, setPolicyRecognEditId] = useState('');
const [policyRecognEditDraft, setPolicyRecognEditDraft] = useState(() => ({ ...EMPTY_POLICY_DETAIL }));
const [insuranceHistoryEdits, setInsuranceHistoryEdits] = useState(() => loadInsuranceHistoryEditsFromStorage());
const [vehicleInsHistoryEditOpen, setVehicleInsHistoryEditOpen] = useState(false);
const [vehicleInsHistoryEditRecord, setVehicleInsHistoryEditRecord] = useState(null);
const [vehicleInsHistoryEditDraft, setVehicleInsHistoryEditDraft] = useState(() => ({ ...EMPTY_POLICY_DETAIL }));
const compareSheetSummary = useMemo(
() => calcCompareSheetConfirmedTotal(compareRows),
[compareRows]
);
const selectedProcurementSummary = useMemo(() => {
const selected = compareRows.filter((r) => selectedCompareKeys.includes(r.id));
return calcCompareSheetConfirmedTotal(selected);
}, [compareRows, selectedCompareKeys]);
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 || '');
setCompareSheetPeriod(sheet.periodLabel || '');
setCompareAttachmentFileList(attachmentsToUploadFileList(sheet.attachments));
} else {
setEditingCompareSheetId(null);
setCompareRows([createEmptyCompareRow()]);
setCompareRemark('');
setCompareSheetPeriod(moment ? moment(ANCHOR_TODAY).format('YYYY年M月') : '2026年6月');
setCompareAttachmentFileList([]);
}
setSelectedCompareKeys([]);
setQuoteDraft(createEmptyQuoteDraft());
setQuoteEditRowId(null);
setCompareModalOpen(true);
};
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: compareSheetPeriod,
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 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 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;
}
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 handleSubmitProcurement = () => {
if (!selectedCompareKeys.length) {
message.warning('请勾选需要提交采购的购买记录');
return;
}
const selectedRows = compareRows.filter((r) => selectedCompareKeys.includes(r.id));
const invalid = selectedRows.find((r) => !r.confirmedQuoteId || !r.latestPayDate);
if (invalid) {
message.warning('勾选记录须已选定确认报价并填写最晚付费日期');
return;
}
const alreadySubmitted = selectedRows.find((r) => r.procurementStatus === 'submitted' || r.procurementStatus === 'completed');
if (alreadySubmitted) {
message.warning('勾选记录中包含已提交采购项,请重新选择');
return;
}
if (!editingCompareSheetId) {
Modal.confirm({
title: '保存并提交采购',
content: '提交采购前将先保存当前比价单,是否继续?',
okText: '继续',
cancelText: '取消',
centered: true,
onOk: () => {
const savedId = handleSubmitCompareSheet({ closeModal: false });
if (savedId) {
setProcurementFlowStep(0);
setProcurementFlowLoading(false);
setProcurementFlowOpen(true);
}
},
});
return;
}
setProcurementFlowStep(0);
setProcurementFlowLoading(false);
setProcurementFlowOpen(true);
};
const runProcurementWorkflow = () => {
setProcurementFlowLoading(true);
setProcurementFlowStep(1);
window.setTimeout(() => setProcurementFlowStep(2), 650);
window.setTimeout(() => {
const submittedAt = formatCompareSheetNow();
const rowsSnapshot = compareRows.map((r) => (
selectedCompareKeys.includes(r.id)
? { ...r, procurementStatus: 'submitted', procurementSubmittedAt: submittedAt }
: r
));
const payload = buildSheetPayloadFromEditor(rowsSnapshot);
const nextSheets = compareSheets.map((s) => (s.id === payload.id ? payload : s));
saveCompareSheets(nextSheets);
setCompareRows(rowsSnapshot);
setProcurementFlowStep(3);
setProcurementFlowLoading(false);
message.success(`采购申请已发起(原型),已提交 ${selectedCompareKeys.length} 条,工作流单号 WF-INS-${Date.now().toString().slice(-6)}`);
setSelectedCompareKeys([]);
}, 1300);
};
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 savePolicyRecognTaskSnapshot = useCallback((snapshot) => {
const {
taskId,
entry,
mode,
insuranceType,
results,
phase,
completedAt,
} = snapshot;
if (!taskId || !results?.length) return;
setPolicyRecognTasks((prev) => {
const existing = prev.find((t) => t.id === taskId);
const record = buildPolicyRecognTaskRecord({
id: taskId,
entry: entry ?? existing?.entry ?? 'ocr',
mode: mode ?? existing?.mode ?? 'policy',
insuranceType: insuranceType ?? existing?.insuranceType ?? '',
results,
createdAt: existing?.createdAt,
creator: existing?.creator,
completedAt: completedAt ?? existing?.completedAt ?? '',
phase: phase ?? existing?.phase ?? 'results',
});
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: [], byType: {}, ledgerKey: '' };
const built = buildVehicleInsuranceHistory(
vehicleInsMgmtVehicle,
allInsurance,
compareSheets,
policyRecognTasks
);
return applyHistoryEditsToVehicleHistory(built, insuranceHistoryEdits);
}, [vehicleInsMgmtVehicle, allInsurance, compareSheets, policyRecognTasks, insuranceHistoryEdits]);
const openVehicleInsuranceMgmt = (vehicle) => {
setVehicleInsMgmtVehicle(vehicle);
setVehicleInsMgmtActiveTab('timeline');
setVehicleInsMgmtHighlightId('');
setVehicleInsMgmtOpen(true);
};
const jumpToVehicleInsuranceRecord = (typeKey, recordId) => {
setVehicleInsMgmtActiveTab(typeKey);
setVehicleInsMgmtHighlightId(recordId);
window.setTimeout(() => setVehicleInsMgmtHighlightId(''), 3200);
};
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 vehicleInsMgmtTabCounts = useMemo(() => {
const counts = { timeline: vehicleInsuranceHistory.timeline.length };
VEHICLE_INSURANCE_MGMT_TABS.filter((t) => t.key !== 'timeline').forEach((tab) => {
counts[tab.key] = (vehicleInsuranceHistory.byType[tab.key] || []).length;
});
return counts;
}, [vehicleInsuranceHistory]);
const vehicleInsuranceHistoryColumns = [
{
title: '业务时间',
dataIndex: 'purchaseTime',
width: 104,
render: (val) => {val || '—'},
},
{
title: '类型',
dataIndex: 'purchaseType',
width: 76,
render: (val) => renderPurchaseTypeChip(val),
},
{
title: '保单号',
dataIndex: 'policyNo',
width: 140,
ellipsis: true,
},
{
title: '保险公司',
dataIndex: 'company',
width: 168,
ellipsis: true,
render: (val) => val || '—',
},
{
title: '付款时间',
dataIndex: 'payTime',
width: 108,
ellipsis: true,
render: (val) => val || '—',
},
{
title: '生效日',
dataIndex: 'startDate',
width: 96,
render: (val) => val || '—',
},
{
title: '到期日',
dataIndex: 'endDate',
width: 96,
render: (val) => val || '—',
},
{
title: '金额',
dataIndex: 'premium',
width: 88,
align: 'right',
render: (val, record) => (
val ? (
{record.purchaseType === 'cancel' ? '-' : ''}¥{val}
) : '—'
),
},
{
title: '操作',
key: 'action',
width: 148,
fixed: 'right',
render: (_, record) => (
),
},
];
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 ? { pageSize: 8, showSizeChanger: false, size: 'small' } : false}
scroll={{ x: 1020 }}
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 taskKey = (appliedPolicyRecognTasksFilters.taskId || '').trim().toUpperCase();
const entry = appliedPolicyRecognTasksFilters.entry;
const status = appliedPolicyRecognTasksFilters.status;
const range = appliedPolicyRecognTasksFilters.createdRange;
return [...policyRecognTasks]
.filter((task) => {
if (taskKey && !(task.id || '').toUpperCase().includes(taskKey)) return false;
if (entry && entry !== '全部' && task.entryLabel !== entry) return false;
if (status && status !== '全部') {
const meta = POLICY_RECOGN_STATUS_META[task.status];
if ((meta?.label || task.status) !== status) 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') => {
setPolicyRecognEntry(entry);
setPolicyRecognMode(entry === 'import' ? 'policy' : initialMode);
setPolicyRecognInsuranceType('交强险');
setPolicyRecognPhase('upload');
setPolicyRecognFiles([]);
setPolicyRecognProgress(0);
setPolicyRecognTaskId('');
setPolicyRecognResults([]);
setPolicyRecognViewOnly(false);
setPolicyPreview(null);
setPolicyRecognOpen(true);
};
const openPolicyRecognTaskRecord = (task) => {
if (!task?.id || !task.results?.length) {
message.warning('任务记录无效');
return;
}
setPolicyRecognEntry(task.entry || 'ocr');
setPolicyRecognMode(task.mode || 'policy');
setPolicyRecognInsuranceType(task.insuranceType || '交强险');
setPolicyRecognTaskId(task.id);
setPolicyRecognResults(task.results.map((r) => ({ ...r })));
setPolicyRecognFiles([]);
setPolicyRecognProgress(100);
setPolicyRecognViewOnly(task.status === 'completed');
const phase = task.status === 'completed'
? 'results'
: (task.phase === 'recognized' ? 'recognized' : 'results');
setPolicyRecognPhase(phase);
setPolicyPreview(null);
setPolicyRecognOpen(true);
setPolicyRecognTasksOpen(false);
};
const closePolicyRecogn = () => {
if (policyRecognTaskId && policyRecognResults.length) {
const status = derivePolicyRecognTaskStatus(policyRecognResults);
savePolicyRecognTaskSnapshot({
taskId: policyRecognTaskId,
entry: policyRecognEntry,
mode: policyRecognMode,
insuranceType: policyRecognInsuranceType,
results: policyRecognResults,
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 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);
setPolicyRecognProgress(0);
let progress = 0;
const progTimer = window.setInterval(() => {
progress = Math.min(92, progress + 14);
setPolicyRecognProgress(progress);
}, 220);
try {
const text = await readPolicyImportFileAsText(file);
window.clearInterval(progTimer);
if (!String(text).trim()) {
setPolicyRecognPhase('upload');
return;
}
const rows = parsePolicyImportFileText(text);
if (!rows.length) {
message.error('未解析到有效数据,请按模板填写车牌/VIN、险种、保单号等字段');
setPolicyRecognPhase('upload');
return;
}
const results = buildImportResultsFromRows(rows, allInsurance);
setPolicyRecognProgress(100);
setPolicyRecognResults(results);
setPolicyRecognPhase('recognized');
savePolicyRecognTaskSnapshot({
taskId,
entry: 'import',
mode: 'policy',
insuranceType: '',
results,
phase: 'recognized',
});
const matchedN = results.filter((r) => r.matched).length;
message.success(`已解析 ${results.length} 条,${matchedN} 条已匹配台账,请点击「处理」核对`);
} catch {
window.clearInterval(progTimer);
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;
setPolicyRecognPhase('recognizing');
setPolicyRecognTaskId(taskId);
setPolicyRecognProgress(0);
let progress = 0;
const timer = window.setInterval(() => {
progress += 10 + Math.floor(Math.random() * 8);
if (progress >= 100) {
window.clearInterval(timer);
setPolicyRecognProgress(100);
const results = buildMockOcrResults(
filesSnap,
modeSnap,
insuranceSnap,
allInsurance
);
setPolicyRecognResults(results);
setPolicyRecognPhase('recognized');
savePolicyRecognTaskSnapshot({
taskId,
entry: entrySnap,
mode: modeSnap,
insuranceType: insuranceSnap,
results,
phase: 'recognized',
});
message.success('识别完成,已写入任务记录,请点击「处理」查看结果');
} else {
setPolicyRecognProgress(Math.min(99, progress));
}
}, 380);
};
const openPolicyRecognResults = () => {
if (!policyRecognResults.length) {
message.warning('暂无识别结果');
return;
}
setPolicyRecognPhase('results');
if (policyRecognTaskId) {
savePolicyRecognTaskSnapshot({
taskId: policyRecognTaskId,
entry: policyRecognEntry,
mode: policyRecognMode,
insuranceType: policyRecognInsuranceType,
results: policyRecognResults,
phase: 'results',
});
}
};
const handlePreviewPolicyResult = (result) => {
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: policyRecognFiles.length
? 'PDF 预览(原型):正式环境将内嵌预览识别原件'
: '任务记录未保存原件,正式环境可从附件库查看',
});
};
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 renderPolicyDetailForm = (draft, setDraft, options = {}) => {
const { showBizType = true } = options;
const coverageRows = getCoverageItemsFormRows(draft.coverageItems);
const updateCoverageRow = (idx, value) => {
const next = [...coverageRows];
next[idx] = value;
setDraft((p) => ({ ...p, coverageItems: next }));
};
const addCoverageRow = () => {
setDraft((p) => ({
...p,
coverageItems: [...getCoverageItemsFormRows(p.coverageItems), ''],
}));
};
const removeCoverageRow = (idx) => {
const next = coverageRows.filter((_, i) => i !== idx);
setDraft((p) => ({ ...p, coverageItems: next.length ? next : [''] }));
};
return (
车辆与险种
{renderFilterField('车牌号', (
setDraft((p) => ({ ...p, plateNo: e.target.value }))}
placeholder="与 VIN 至少填一项"
/>
))}
{renderFilterField('VIN码', (
setDraft((p) => ({ ...p, vin: e.target.value }))} />
))}
{showBizType ? renderFilterField('业务类型', (
);
};
const openPolicyRecognResultEdit = (result) => {
setPolicyRecognEditId(result.id);
setPolicyRecognEditDraft(recognResultToPolicyDetail(result));
setPolicyRecognEditOpen(true);
};
const savePolicyRecognResultEdit = () => {
const result = policyRecognResults.find((r) => r.id === policyRecognEditId);
if (!result) return;
const merged = mergeRecognResultWithDetail(result, policyRecognEditDraft);
setPolicyRecognResults((prev) => prev.map((r) => (r.id === policyRecognEditId ? merged : r)));
setPolicyRecognEditOpen(false);
message.success('已更新识别结果,请确认后写入台账');
};
const confirmPolicyRecognResult = (resultId) => {
const result = policyRecognResults.find((r) => r.id === resultId);
if (!result) return;
if (!result.matched) {
message.warning('该条未匹配台账,无法确认');
return;
}
if (result.confirmed) {
message.info('该条已确认');
return;
}
const mode = result.recognMode || policyRecognMode;
updateAllInsurance(applyPolicyOcrResultToLedger(result, mode));
const nextResults = policyRecognResults.map((r) => (
r.id === resultId ? { ...r, confirmed: true } : r
));
setPolicyRecognResults(nextResults);
if (derivePolicyRecognTaskStatus(nextResults) === 'completed') {
setPolicyRecognViewOnly(true);
}
if (policyRecognTaskId) {
savePolicyRecognTaskSnapshot({
taskId: policyRecognTaskId,
entry: policyRecognEntry,
mode: policyRecognMode,
insuranceType: policyRecognInsuranceType,
results: nextResults,
phase: 'results',
completedAt: derivePolicyRecognTaskStatus(nextResults) === 'completed' ? formatCompareSheetNow() : undefined,
});
}
message.success(`已更新 ${result.displayPlate || result.ocrVin} 的到期时间`);
};
const confirmAllPolicyRecognResults = () => {
const pending = policyRecognResults.filter((r) => r.matched && !r.confirmed);
if (!pending.length) {
message.info('没有可批量确认的记录');
return;
}
let nextInsurance = { ...allInsurance };
pending.forEach((result) => {
const mode = result.recognMode || policyRecognMode;
nextInsurance = applyPolicyOcrResultToLedger(result, mode)(nextInsurance);
});
updateAllInsurance(() => nextInsurance);
const nextResults = policyRecognResults.map((r) => (
r.matched ? { ...r, confirmed: true } : r
));
setPolicyRecognResults(nextResults);
const allDone = derivePolicyRecognTaskStatus(nextResults) === 'completed';
if (allDone) setPolicyRecognViewOnly(true);
if (policyRecognTaskId) {
savePolicyRecognTaskSnapshot({
taskId: policyRecognTaskId,
entry: policyRecognEntry,
mode: policyRecognMode,
insuranceType: policyRecognInsuranceType,
results: nextResults,
phase: 'results',
completedAt: allDone ? formatCompareSheetNow() : undefined,
});
}
message.success(`已批量确认 ${pending.length} 条,台账到期时间已更新`);
};
const policyRecognResultColumns = useMemo(() => {
const canEdit = !policyRecognViewOnly;
return [
{
title: '文件',
dataIndex: 'fileName',
width: 140,
ellipsis: true,
},
{
title: '类型',
key: 'biz',
width: 72,
render: (_, r) => (
{r.ocrBizTypeLabel || '保单录入'}
),
},
{
title: '车牌号',
key: 'plate',
width: 96,
render: (_, r) => r.displayPlate || r.ocrPlateNo || r.ocrVin || '—',
},
{
title: '保单号',
dataIndex: 'ocrPolicyNo',
width: 130,
ellipsis: true,
},
{
title: '险种',
dataIndex: 'insuranceTypeLabel',
width: 72,
},
{
title: '付款时间',
dataIndex: 'ocrPayTime',
width: 108,
ellipsis: true,
render: (v) => v || '—',
},
{
title: '生效日',
dataIndex: 'ocrStartDate',
width: 96,
render: (v) => v || '—',
},
{
title: '到期日',
dataIndex: 'ocrEndDate',
width: 96,
},
{
title: '金额',
dataIndex: 'ocrPremium',
width: 80,
align: 'right',
render: (v) => (v ? `¥${v}` : '—'),
},
{
title: '匹配',
key: 'matched',
width: 88,
render: (_, r) => (
{r.matched ? '已匹配' : '未匹配'}
),
},
{
title: '状态',
key: 'confirmed',
width: 80,
render: (_, r) => (
r.confirmed ? 已确认 : 待确认
),
},
{
title: '操作',
key: 'action',
width: 168,
fixed: 'right',
render: (_, r) => (
{canEdit ? (
<>
>
) : null}
),
},
];
}, [policyRecognViewOnly, policyRecognResults, policyRecognFiles, allInsurance]);
const handlePolicyAddSubmit = () => {
const detail = normalizePolicyDetail(policyAddDraft);
const ledgerKey = resolvePolicyVehicleKey(detail.plateNo || detail.vin);
if (!ledgerKey) {
message.warning('请填写台账中存在的车牌或 VIN');
return;
}
const typeKey = INSURANCE_LABEL_TO_KEY[detail.insuranceType];
if (!typeKey || !detail.endDate) {
message.warning('请填写险种与到期日期');
return;
}
const mode = bizTypeToRecognMode(detail.bizType);
updateAllInsurance((prev) => {
const record = ensureInsuranceRecordShape(prev[ledgerKey] || createEmptyInsuranceRecord());
const item = applyPolicyDetailToInsuranceItem(
{ ...record[typeKey] },
{ ...detail, policyNo: detail.policyNo || `MAN-${Date.now().toString().slice(-6)}` },
mode
);
item.updateTime = formatCompareSheetNow();
item.updateUser = PROTO_COMPARE_CREATOR;
return { ...prev, [ledgerKey]: { ...record, [typeKey]: item } };
});
message.success('保单已录入台账');
setPolicyAddOpen(false);
setPolicyAddDraft({ ...EMPTY_POLICY_DETAIL });
};
const renderPolicyStatusTag = (item) => {
if (!item?.policyTag) return null;
if (item.policyTag === 'suspended') {
return (
已停保
);
}
if (item.policyTag === 'cancelled') {
return (
已退保
);
}
return null;
};
const renderDateCell = (rowId, field, value) => (
updateCompareRow(rowId, { [field]: ds || '' })}
placeholder="选择日期"
style={{ width: '100%' }}
/>
);
const renderQuoteAddPopover = (row) => (
{
setQuoteEditRowId(open ? row.id : null);
if (!open) setQuoteDraft(createEmptyQuoteDraft());
}}
content={(
添加报价
{row.insuranceType || '交强险'}
setQuoteDraft((d) => ({ ...d, company: val }))}
options={INSURANCE_MGMT_COMPANIES.map((c) => ({ label: c, value: c }))}
filterOption={(input, option) => (option?.label || '').toLowerCase().includes(input.toLowerCase())}
style={{ width: '100%' }}
/>
setQuoteDraft((d) => ({ ...d, premium: sanitizePremiumInput(e.target.value) }))}
onBlur={() => {
if (quoteDraft.premium && isValidPremium(quoteDraft.premium)) {
setQuoteDraft((d) => ({ ...d, premium: formatPremiumDisplay(d.premium) }));
}
}}
/>
)}
>
);
const renderQuoteCell = (row) => {
const quotes = row.quotes || [];
return (
{quotes.length > 0 ? (
updateCompareRow(row.id, { confirmedQuoteId: e.target.value })}
className="lc-compare-quote-inline-list"
>
{quotes.map((q) => {
const selected = row.confirmedQuoteId === q.id;
return (
);
})}
) : (
暂无报价
)}
{renderQuoteAddPopover(row)}
);
};
const compareColumns = [
{
title: '车牌号',
dataIndex: 'plateNo',
width: 118,
fixed: 'left',
onHeaderCell: () => ({ className: 'lc-compare-th-key' }),
render: (val, row) => (
(option?.label || '').toLowerCase().includes(input.toLowerCase())}
onChange={(v) => handleComparePlateChange(row.id, v)}
/>
),
},
{
title: '车辆识别代码',
dataIndex: 'vin',
width: 168,
fixed: 'left',
onHeaderCell: () => ({ className: 'lc-compare-th-key' }),
render: (val, row) => (
(option?.label || '').toLowerCase().includes(input.toLowerCase())}
onChange={(v) => handleCompareVinChange(row.id, v)}
/>
),
},
{
title: '客户',
dataIndex: 'customer',
width: 128,
onHeaderCell: () => ({ className: 'lc-compare-th-auto' }),
render: (val, row) => renderReadonlyField(val, isCompareRowVehicleLinked(row)),
},
{
title: '归属公司',
dataIndex: 'ownerCompany',
width: 148,
onHeaderCell: () => ({ className: 'lc-compare-th-auto' }),
render: (val, row) => renderReadonlyField(val, isCompareRowVehicleLinked(row)),
},
{
title: '品牌',
dataIndex: 'brand',
width: 80,
onHeaderCell: () => ({ className: 'lc-compare-th-auto' }),
render: (val, row) => renderReadonlyField(val, isCompareRowVehicleLinked(row)),
},
{
title: '型号',
dataIndex: 'model',
width: 120,
onHeaderCell: () => ({ className: 'lc-compare-th-auto' }),
render: (val, row) => renderReadonlyField(val, isCompareRowVehicleLinked(row)),
},
{
title: '车身颜色',
dataIndex: 'bodyColor',
width: 80,
onHeaderCell: () => ({ className: 'lc-compare-th-auto' }),
render: (val, row) => renderReadonlyField(val, isCompareRowVehicleLinked(row)),
},
{
title: '行驶证注册日期',
dataIndex: 'regDate',
width: 118,
onHeaderCell: () => ({ className: 'lc-compare-th-auto' }),
render: (val, row) => renderReadonlyDate(val, isCompareRowVehicleLinked(row)),
},
{
title: '年检有效期',
dataIndex: 'inspectExpire',
width: 108,
onHeaderCell: () => ({ className: 'lc-compare-th-auto' }),
render: (val, row) => renderReadonlyDate(val, isCompareRowVehicleLinked(row)),
},
{
title: '投保方式',
dataIndex: 'insureMode',
width: 96,
onHeaderCell: () => ({ className: 'lc-compare-th-edit' }),
render: (val, row) => (
updateCompareRow(row.id, { insureMode: v })}
options={[{ label: '新保', value: '新保' }, { label: '续保', value: '续保' }]}
/>
),
},
{
title: '保险类型',
dataIndex: 'insuranceType',
width: 100,
onHeaderCell: () => ({ className: 'lc-compare-th-edit' }),
render: (val, row) => (
updateCompareRow(row.id, {
insuranceType: v,
quotes: [],
confirmedQuoteId: '',
})}
options={QUOTE_INSURANCE_TYPES.map((t) => ({ label: t, value: t }))}
/>
),
},
{
title: '交强险有效期',
dataIndex: 'jqValidUntil',
width: 108,
onHeaderCell: () => ({ className: 'lc-compare-th-auto' }),
render: (val, row) => renderReadonlyDate(val, isCompareRowVehicleLinked(row)),
},
{
title: '商业险有效期',
dataIndex: 'syValidUntil',
width: 108,
onHeaderCell: () => ({ className: 'lc-compare-th-auto' }),
render: (val, row) => renderReadonlyDate(val, isCompareRowVehicleLinked(row)),
},
{
title: '最晚付费日期',
dataIndex: 'latestPayDate',
width: 128,
onHeaderCell: () => ({ className: 'lc-compare-th-edit' }),
render: (val, row) => {
const paySt = getLatestPayDateStatus(val);
return (
{renderDateCell(row.id, 'latestPayDate', val)}
{val ? (
{paySt.type === 'overdue' ? `超期${Math.abs(paySt.diffDays)}天` : paySt.type === 'warning' ? `临期${paySt.diffDays}天` : `剩余${paySt.diffDays}天`}
) : null}
);
},
},
{
title: '采购状态',
dataIndex: 'procurementStatus',
width: 92,
onHeaderCell: () => ({ className: 'lc-compare-th-auto' }),
render: (st) => {
if (st === 'completed') return 已完结;
if (st === 'submitted') return 已提交;
return 未提交;
},
},
{
title: '报价情况',
key: 'quotes',
width: 220,
onHeaderCell: () => ({ className: 'lc-compare-th-edit' }),
render: (_, row) => renderQuoteCell(row),
},
{
title: '操作',
key: 'action',
width: 96,
fixed: 'right',
render: (_, row) => (
{
setCopyPopoverRowId(open ? row.id : null);
if (!open) setCopyCountDraft(1);
}}
content={(
复制条数
setCopyCountDraft(v || 1)} style={{ width: '100%' }} />
)}
>
),
},
];
const getInsuranceItemStatus = (ledgerKey, typeKey) => {
const item = allInsurance[ledgerKey]?.[typeKey];
if (!item || !item.endDate || !item.policyNo) {
return { type: 'unuploaded', text: '未购买', diffDays: null };
}
const today = new Date(ANCHOR_TODAY);
today.setHours(0, 0, 0, 0);
const expDate = new Date(item.endDate);
expDate.setHours(0, 0, 0, 0);
const diffDays = Math.ceil((expDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays <= 0) {
return { type: 'expired', text: `已到期 (逾期 ${Math.abs(diffDays)} 天)`, diffDays };
}
if (diffDays <= INSURANCE_WARN_DAYS) {
return { type: 'warning', text: `临期 (${diffDays} 天后)`, diffDays };
}
return { type: 'success', text: '正常', diffDays };
};
/** 与车辆管理「保险状态」一致:交强险 + 商业险均存在且在有效期内(临期仍算有效) */
const getVehicleInsuranceStatus = (ledgerKey) => {
const jq = getInsuranceItemStatus(ledgerKey, 'compulsory');
const sy = getInsuranceItemStatus(ledgerKey, 'commercial');
const isValid = (st) => st.type === 'success' || st.type === 'warning';
const isNormal = isValid(jq) && isValid(sy);
if (isNormal) {
const hasWarning = jq.type === 'warning' || sy.type === 'warning';
return {
label: hasWarning ? '临期' : '正常',
color: hasWarning ? 'warning' : 'success',
abnormal: false,
tip: hasWarning
? '交强险或商业险临期,仍在有效期内,可交车但需尽快续保'
: '交强险、商业险均在有效期内',
};
}
const reasons = [];
if (jq.type === 'unuploaded') reasons.push('交强险未购买');
else if (jq.type === 'expired') reasons.push('交强险已到期');
if (sy.type === 'unuploaded') reasons.push('商业险未购买');
else if (sy.type === 'expired') reasons.push('商业险已到期');
return {
label: '异常',
color: 'error',
abnormal: true,
tip: `${reasons.join(';')}。保险状态异常的车辆禁止交车`,
};
};
const isCoreInsuranceNormal = (ledgerKey) => !getVehicleInsuranceStatus(ledgerKey).abnormal;
const isAnyInsuranceWarning = (ledgerKey) => (
INSURANCE_TYPE_ITEMS.some((item) => getInsuranceItemStatus(ledgerKey, item.key).type === 'warning')
);
const isCoreInsuranceExpired = (ledgerKey) => (
CORE_INSURANCE_KEYS.some((key) => getInsuranceItemStatus(ledgerKey, key).type === 'expired')
);
const isCoreInsuranceMissing = (ledgerKey) => (
CORE_INSURANCE_KEYS.some((key) => getInsuranceItemStatus(ledgerKey, key).type === 'unuploaded')
);
const matchKpiFilter = (ledgerKey, filterKey) => {
if (filterKey === 'total') return true;
if (filterKey === 'normal') return isCoreInsuranceNormal(ledgerKey);
if (filterKey === 'warning') return isAnyInsuranceWarning(ledgerKey);
if (filterKey === 'expired') return isCoreInsuranceExpired(ledgerKey);
if (filterKey === 'unuploaded') return isCoreInsuranceMissing(ledgerKey);
return true;
};
const stats = useMemo(() => {
let normal = 0;
let warning = 0;
let expired = 0;
let unuploaded = 0;
MOCK_VEHICLES.forEach((v) => {
const ledgerKey = getVehicleLedgerKey(v);
if (isCoreInsuranceNormal(ledgerKey)) normal += 1;
if (isAnyInsuranceWarning(ledgerKey)) warning += 1;
if (isCoreInsuranceExpired(ledgerKey)) expired += 1;
if (isCoreInsuranceMissing(ledgerKey)) unuploaded += 1;
});
return { total: MOCK_VEHICLES.length, normal, warning, expired, unuploaded };
}, [allInsurance]);
const brandOptions = useMemo(
() => [...new Set(MOCK_VEHICLES.map((v) => v.brand))].map((b) => ({ label: b, value: b })),
[]
);
const modelOptions = useMemo(
() => [...new Set(MOCK_VEHICLES.map((v) => v.model))].map((m) => ({ label: m, value: m })),
[]
);
const appliedMultiPlates = useMemo(() => parseMultiPlates(appliedFilters.plateNos), [appliedFilters.plateNos]);
const filterVehiclesByFilters = (vehicles, f, kpi) => {
const plateKey = (f.plateNo || '').trim().toLowerCase();
const multiPlates = parseMultiPlates(f.plateNos);
const vinKey = (f.vin || '').trim().toLowerCase();
const brandKey = (f.brand || '').trim().toLowerCase();
const modelKey = (f.model || '').trim().toLowerCase();
return vehicles.filter((v) => {
const ledgerKey = getVehicleLedgerKey(v);
const plateText = (v.plateNo || '').trim();
if (multiPlates.length) {
if (!plateText) return false;
if (!multiPlates.includes(plateText.toUpperCase())) return false;
} else if (plateKey) {
if (!plateText) {
if (!NO_PLATE_LABEL.toLowerCase().includes(plateKey) && !plateKey.includes('暂无')) return false;
} else if (!plateText.toLowerCase().includes(plateKey)) return false;
}
if (vinKey && !v.vin.toLowerCase().includes(vinKey)) return false;
if (brandKey && !v.brand.toLowerCase().includes(brandKey)) return false;
if (modelKey && !v.model.toLowerCase().includes(modelKey)) return false;
if (f.operateStatus !== '全部' && v.status !== f.operateStatus) return false;
if (f.insuranceStatus === '正常' && getVehicleInsuranceStatus(ledgerKey).abnormal) return false;
if (f.insuranceStatus === '异常' && !getVehicleInsuranceStatus(ledgerKey).abnormal) return false;
if (!matchKpiFilter(ledgerKey, kpi)) return false;
return true;
});
};
const filteredVehicles = useMemo(
() => sortVehiclesRetiredLast(filterVehiclesByFilters(MOCK_VEHICLES, appliedFilters, kpiFilter)),
[appliedFilters, allInsurance, kpiFilter]
);
const handleListFilterQuery = () => {
const plates = parseMultiPlates(multiPlateDraft);
const next = {
...listFilters,
plateNos: multiPlateDraft.trim(),
plateNo: plates.length ? '' : listFilters.plateNo,
};
setListFilters(next);
setAppliedFilters(next);
setMultiPlateOpen(false);
const hitCount = filterVehiclesByFilters(MOCK_VEHICLES, next, kpiFilter).length;
if (plates.length) {
message.success(`已按 ${plates.length} 个车牌筛选,命中 ${hitCount} 条记录`);
} else {
message.success(`查询完成,命中 ${hitCount} 条记录`);
}
};
const handleListFilterReset = () => {
setListFilters({ ...DEFAULT_LIST_FILTERS });
setAppliedFilters({ ...DEFAULT_LIST_FILTERS });
setMultiPlateDraft('');
setKpiFilter('total');
message.info('筛选条件已重置');
};
const handleMultiPlateOpenChange = (open) => {
setMultiPlateOpen(open);
if (open) setMultiPlateDraft(listFilters.plateNos || '');
};
const renderFilterField = (label, control) => (
);
const isRetiredVehicle = (record) => record?.status === '退出运营';
const renderInsuranceDateCell = (record, typeKey) => {
const ledgerKey = getVehicleLedgerKey(record);
const item = allInsurance[ledgerKey]?.[typeKey];
const status = getInsuranceItemStatus(ledgerKey, typeKey);
const dateVal = item?.endDate;
const muted = isRetiredVehicle(record);
const policyTagEl = renderPolicyStatusTag(item);
return (
{dateVal || '—'}
{!muted ? (
{policyTagEl || (
{getInsuranceRemainShortText(status)}
)}
/>
)}
) : null}
);
};
const listColumns = [
{
title: '车牌号',
dataIndex: 'plateNo',
key: 'plateNo',
width: 148,
onHeaderCell: listColumnHeaderCell,
align: 'left',
render: (plate, record) => {
const muted = isRetiredVehicle(record);
const noPlate = !hasVehiclePlate(record);
const displayPlate = formatVehiclePlateDisplay(plate);
const sub = [record.brand, record.model].filter(Boolean).join(' · ');
return (
{displayPlate}
{sub ? (
{sub}
) : null}
);
},
},
{
title: 'VIN码',
dataIndex: 'vin',
key: 'vin',
width: 112,
onHeaderCell: listColumnHeaderCell,
render: (vin, record) => (
{vin}
),
},
{
title: '运营状态',
dataIndex: 'status',
key: 'status',
width: 80,
onHeaderCell: listColumnHeaderCell,
render: (status) => (
{status}
),
},
{
title: '保险状态',
key: 'insuranceStatus',
width: 80,
onHeaderCell: listColumnHeaderCell,
render: (record) => {
const st = getVehicleInsuranceStatus(getVehicleLedgerKey(record));
return (
{st.label}
);
},
},
{
title: '交强险到期时间',
key: 'compulsory',
width: 118,
onHeaderCell: listColumnHeaderCell,
render: (record) => renderInsuranceDateCell(record, 'compulsory'),
},
{
title: '商业险到期时间',
key: 'commercial',
width: 118,
onHeaderCell: listColumnHeaderCell,
render: (record) => renderInsuranceDateCell(record, 'commercial'),
},
{
title: '超赔险到期时间',
key: 'excess',
width: 118,
onHeaderCell: listColumnHeaderCell,
render: (record) => renderInsuranceDateCell(record, 'excess'),
},
{
title: '货物险到期时间',
key: 'cargo',
width: 118,
onHeaderCell: listColumnHeaderCell,
render: (record) => renderInsuranceDateCell(record, 'cargo'),
},
{
title: '驾意险到期时间',
key: 'driverAccident',
width: 118,
onHeaderCell: listColumnHeaderCell,
render: (record) => renderInsuranceDateCell(record, 'driverAccident'),
},
{
title: '操作',
key: 'action',
width: 64,
onHeaderCell: listColumnHeaderCell,
render: (record) => (
),
},
];
return (
保险采购
保单管理(一车一档)· 比价单独立管理,互不关联
{renderFilterField('车牌号', (
0}
value={listFilters.plateNo}
onChange={(e) => setListFilters((prev) => ({ ...prev, plateNo: e.target.value }))}
onPressEnter={handleListFilterQuery}
style={{ borderRadius: 8 }}
/>
))}
{renderFilterField('多车牌', (
每行一个车牌,或同一行内用逗号分隔
setMultiPlateDraft(e.target.value)}
placeholder={'沪A03561F\n粤B58888F'}
style={{ borderRadius: 8 }}
/>
)}
>
))}
{renderFilterField('VIN码', (
setListFilters((prev) => ({ ...prev, vin: e.target.value }))}
onPressEnter={handleListFilterQuery}
style={{ borderRadius: 8 }}
/>
))}
{renderFilterField('品牌', (
setListFilters((prev) => ({ ...prev, brand: val || '' }))}
options={brandOptions}
filterOption={(input, option) => (option?.label || '').toLowerCase().includes(input.toLowerCase())}
style={{ width: '100%' }}
/>
))}
{renderFilterField('型号', (
setListFilters((prev) => ({ ...prev, model: val || '' }))}
options={modelOptions}
filterOption={(input, option) => (option?.label || '').toLowerCase().includes(input.toLowerCase())}
style={{ width: '100%' }}
/>
))}
{renderFilterField('运营状态', (
setListFilters((prev) => ({ ...prev, operateStatus: val }))}
style={{ width: '100%' }}
>
全部
租赁
自营
库存
退出运营
))}
{renderFilterField('保险状态', (
setListFilters((prev) => ({ ...prev, insuranceStatus: val }))}
style={{ width: '100%' }}
>
全部
正常/临期
异常
))}
{[
{ key: 'total', type: 'total', title: '台账车辆总数', desc: '纳入保险采购台账管理的车辆(一车一档)', val: stats.total, icon: ICONS.vehicle },
{ key: 'normal', type: 'normal', title: '保险状态正常', desc: '交强险、商业险均已购买且在有效期内,与车辆管理「保险状态=正常」一致', val: stats.normal, icon: ICONS.success },
{ key: 'warning', type: 'warning', title: '险种临期预警', desc: `任一类险种止期 ≤ ${INSURANCE_WARN_DAYS} 天(含交强险、商业险、超赔险、货物险、驾意险)`, val: stats.warning, icon: ICONS.warning },
{ key: 'expired', type: 'expired', title: '核心险种逾期', desc: '交强险或商业险已到期,车辆管理保险状态为异常,禁止交车', val: stats.expired, icon: ICONS.warning },
{ key: 'unuploaded', type: 'unuploaded', title: '核心险种待购', desc: '交强险或商业险未录入/未购买,车辆管理保险状态为异常,禁止交车', val: stats.unuploaded, icon: ICONS.shield },
].map((card) => (
setKpiFilter(card.key)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setKpiFilter(card.key);
}
}}
>
e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{card.icon}
))}
保单录入
getVehicleLedgerKey(record)}
rowClassName={(record) => (record.status === '退出运营' ? 'lc-row-retired' : '')}
pagination={false}
scroll={{ x: 1040 }}
locale={{
emptyText: (
),
}}
/>
setVehicleInsMgmtOpen(false)}>
关闭
)}
onCancel={() => setVehicleInsMgmtOpen(false)}
>
{vehicleInsMgmtVehicle ? (() => {
const profile = getVehicleProfile(vehicleInsMgmtVehicle);
const ledgerKey = getVehicleLedgerKey(vehicleInsMgmtVehicle);
const insStatus = getVehicleInsuranceStatus(ledgerKey);
const statusPillClass = insStatus.color === 'success' || insStatus.color === 'warning'
? `lc-vehicle-ins-mgmt-status-pill--${insStatus.color}`
: 'lc-vehicle-ins-mgmt-status-pill--error';
return (
车辆保险档案
{formatVehiclePlateDisplay(vehicleInsMgmtVehicle.plateNo)}
{vehicleInsMgmtVehicle.brand} {vehicleInsMgmtVehicle.model}
保险状态 · {insStatus.label}
VIN码
{vehicleInsMgmtVehicle.vin || '—'}
运营状态
{vehicleInsMgmtVehicle.status || '—'}
客户
{profile.customer || '—'}
产权方
{profile.ownerCompany || '—'}
注册日期
{profile.regDate || '—'}
年审到期
{profile.inspectExpire || '—'}
记录类型
{POLICY_PURCHASE_TYPE_LEGEND.map((key) => renderPurchaseTypeChip(key))}
新保:该险种首次购买;续保:此前已购后再投保
{vehicleInsuranceHistory.timeline.length ? (
{vehicleInsuranceHistory.timeline.map((item) => {
const meta = POLICY_PURCHASE_TYPE_META[item.purchaseType] || {};
return (
{item.time || '—'}
)}
>
jumpToVehicleInsuranceRecord(item.typeKey, item.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') jumpToVehicleInsuranceRecord(item.typeKey, item.id);
}}
>
{renderPurchaseTypeChip(item.purchaseType)}
{item.typeLabel}
{item.policyNo ? (
{item.policyNo}
) : null}
{item.summary}
{item.sourceLabel ? `${item.sourceLabel} · ` : ''}
点击查看 {item.typeLabel} 明细 →
);
})}
) : (
暂无全周期记录
可通过批量识别、Excel 导入或比价单采购产生记录
)}
{VEHICLE_INSURANCE_MGMT_TABS.filter((t) => t.key !== 'timeline').map((tab) => (
{renderVehicleInsuranceTypeTab(tab.key)}
))}
);
})() : null}
setPrdOpen(false)}
>
1. 面包屑:业务管理 → 保险采购
2. 保险状态规则(交车拦截):
- 核验交强险、商业险是否已购买且在有效期内
- 两项均满足(含临期未到期)→ 车辆管理「保险状态 = 正常/临期」
- 任一项缺失或已到期 → 「保险状态 = 异常」,禁止交车
3. 管理险种:交强险、商业险、超赔险、货物险、驾意险
4. 列表字段:车牌号(下附品牌型号)、VIN、运营状态、保险状态、各险种到期时间;操作「管理」打开全周期保险记录(时间轴 + 分险种 Tab,支持预览/下载)
5. 筛选:车牌号、多车牌、VIN、品牌、型号、运营状态、保险状态;KPI 看板点击可联动筛选列表
6. 交互参照:页面布局、KPI 看板、状态图示与证照管理模块保持一致
7. 比价单:按月管理批次;选车(可无车牌仅 VIN)→ 报价 → 保存;最晚付费日临期/超期看板提醒;勾选记录提交采购工作流(原型);列表统计已提交采购与流程完结数量
8. 保单管理:新增/识别/导入统一维护保单号、批单号、付款时间、生效/到期、保费、保单项目等;批量导入模板与新增字段一致;识别任务可回看
setCompareMgmtOpen(false)}
>
{renderFilterField('创建时间', (
setCompareMgmtFilters((prev) => ({ ...prev, createdRange: range }))}
placeholder={['开始日期', '结束日期']}
allowClear
/>
))}
{renderFilterField('车牌号', (
setCompareMgmtFilters((prev) => ({ ...prev, plateNo: e.target.value }))}
onPressEnter={handleCompareMgmtQuery}
style={{ borderRadius: 8 }}
/>
))}
最晚付费临期
距最晚付费日 ≤ {LATEST_PAY_WARN_DAYS} 天
{compareMgmtPayAlerts.warning}
{compareMgmtPayAlerts.overdue}
共 {filteredCompareSheets.length} 条比价单
`共 ${t} 条` }}
scroll={{ x: 1040 }}
locale={{ emptyText: '暂无比价单,请点击「新建比价单」' }}
columns={[
{
title: '创建日期',
dataIndex: 'createdAt',
key: 'createdAt',
width: 168,
render: (val) => {val || '—'},
},
{
title: '创建人',
dataIndex: 'createdBy',
key: 'createdBy',
width: 96,
render: (val) => val || '—',
},
{
title: '采购周期',
dataIndex: 'periodLabel',
key: 'periodLabel',
width: 108,
render: (val) => val || '—',
},
{
title: '总车辆',
dataIndex: 'totalVehicles',
key: 'totalVehicles',
width: 88,
align: 'center',
render: (val) => {val ?? 0},
},
{
title: '保险数量',
dataIndex: 'insuranceCount',
key: 'insuranceCount',
width: 96,
align: 'center',
render: (val) => {val ?? 0},
},
{
title: '附件',
key: 'attachments',
width: 72,
align: 'center',
render: (_, record) => {
const n = record.attachments?.length || 0;
return n > 0 ? (
a.name).join('、')}>
{n}
) : (
—
);
},
},
{
title: '已提交采购数量',
dataIndex: 'submittedProcurementCount',
key: 'submittedProcurementCount',
width: 128,
align: 'center',
render: (val) => (
0 ? 'processing' : 'default'} style={{ margin: 0, fontWeight: 600 }}>
{val ?? 0}
),
},
{
title: '流程完结数量',
dataIndex: 'completedCount',
key: 'completedCount',
width: 120,
align: 'center',
render: (val) => (
0 ? 'success' : 'default'} style={{ margin: 0, fontWeight: 600 }}>
{val ?? 0}
),
},
{
title: '操作',
key: 'action',
width: 120,
fixed: 'right',
render: (_, record) => (
),
},
]}
/>
{
setCompareModalOpen(false);
setEditingCompareSheetId(null);
}}
footer={(
勾选购买记录后可提交采购申请;须已确认报价并填写最晚付费日期
)}
>
{selectedCompareKeys.length ? (
) : null}
共 {compareRows.length} 条购买记录
({
disabled: record.procurementStatus === 'completed',
}),
}}
locale={{ emptyText: '暂无购买记录,请点击「新增一行」' }}
/>
附件
不限制文件格式与上传数量;保存比价单时一并存储附件信息(原型仅存元数据)
false}
onChange={handleCompareAttachmentChange}
itemRender={(originNode, file) => (
{originNode}
)}
>
整单确认金额
{compareSheetSummary.total.toFixed(2)}
元
全单已确认 {compareSheetSummary.count} 项
勾选提交采购金额
{selectedProcurementSummary.total.toFixed(2)}
元
已勾选 {selectedCompareKeys.length} 条 · 可提交 {selectedProcurementSummary.count} 项确认报价
)}
onCancel={() => !procurementFlowLoading && setProcurementFlowOpen(false)}
>
{procurementFlowStep >= 3 ? (
) : (
)}
setPolicyRecognTasksOpen(false)}
>
{renderFilterField('创建时间', (
setPolicyRecognTasksFilters((prev) => ({ ...prev, createdRange: range }))}
placeholder={['开始日期', '结束日期']}
allowClear
/>
))}
{renderFilterField('任务编号', (
setPolicyRecognTasksFilters((prev) => ({ ...prev, taskId: e.target.value }))}
onPressEnter={handlePolicyRecognTasksQuery}
style={{ borderRadius: 8 }}
/>
))}
{renderFilterField('业务类型', (
setPolicyRecognTasksFilters((prev) => ({ ...prev, entry: v }))}
style={{ width: '100%' }}
options={[
{ label: '全部', value: '全部' },
{ label: '保单批量识别', value: '保单批量识别' },
{ label: '批量导入', value: '批量导入' },
]}
/>
))}
{renderFilterField('状态', (
setPolicyRecognTasksFilters((prev) => ({ ...prev, status: v }))}
style={{ width: '100%' }}
options={[
{ label: '全部', value: '全部' },
{ label: '待确认', value: '待确认' },
{ label: '部分确认', value: '部分确认' },
{ label: '已完成', value: '已完成' },
]}
/>
))}
共 {filteredPolicyRecognTasks.length} 条识别任务
`共 ${t} 条` }}
scroll={{ x: 960 }}
locale={{ emptyText: '暂无识别任务,请发起「保单批量识别」或「批量导入」' }}
columns={[
{
title: '创建时间',
dataIndex: 'createdAt',
width: 168,
render: (val) => {val || '—'},
},
{
title: '任务编号',
dataIndex: 'id',
width: 148,
render: (val) => {val},
},
{
title: '业务类型',
dataIndex: 'entryLabel',
width: 120,
},
{
title: '业务模式',
dataIndex: 'modeLabel',
width: 96,
render: (val, record) => (
{val || '—'}
{record.insuranceType ? (
{record.insuranceType}
) : null}
),
},
{
title: '文件数',
dataIndex: 'fileCount',
width: 72,
align: 'center',
},
{
title: '确认进度',
key: 'progress',
width: 100,
align: 'center',
render: (_, record) => (
{record.confirmedCount}/{record.matchedCount || record.fileCount}
),
},
{
title: '状态',
dataIndex: 'status',
width: 96,
render: (status) => {
const meta = POLICY_RECOGN_STATUS_META[status] || { label: status, color: 'default' };
return {meta.label};
},
},
{
title: '操作',
key: 'action',
width: 88,
fixed: 'right',
render: (_, record) => (
),
},
]}
/>
{policyRecognTaskId ? (
任务编号:{policyRecognTaskId}
{policyRecognPhase === 'recognizing' ? (policyRecognEntry === 'import' ? ' · 导入中…' : ' · 识别中…') : null}
{policyRecognPhase === 'recognized' ? ' · 识别完成' : null}
{policyRecognPhase === 'results' ? ' · 核对确认' : null}
) : null}
{policyRecognPhase === 'upload' ? (
<>
{policyRecognEntry === 'ocr' ? (
<>
业务类型
setPolicyRecognMode(e.target.value)}
style={{ display: 'flex', flexDirection: 'column', gap: 8 }}
>
{POLICY_OCR_MODES.map((m) => (
{m.label}
{m.desc}
))}
{policyRecognMode === 'policy' ? (
{renderFilterField('保险类型', (
({ label: t, value: t }))}
/>
))}
) : null}
false}
fileList={policyRecognFiles}
onChange={handlePolicyRecognUploadChange}
disabled={policyRecognPhase !== 'upload'}
>
拖拽或点击上传 PDF / 图片
不限制上传数量;上传完成后可开始识别
>
) : (
<>
第一步:下载 Excel 导入模板
模板含车牌、VIN、业务类型、险种、保单号、批单号、付款/生效/到期时间、保费、保单项目等;与新增页字段一致。
false}
fileList={policyRecognFiles}
onChange={handlePolicyImportUploadChange}
disabled={policyRecognPhase !== 'upload'}
>
拖拽或点击上传 Excel / CSV
单次上传一个文件;.xlsx 原型请另存为 CSV UTF-8
>
)}
{policyRecognEntry === 'ocr' && policyRecognFiles.length ? (
{policyRecognFiles.map((f) => (
{f.name}
{f.status === 'uploading' ? (
上传中
) : (
已上传
)}
))}
) : null}
>
) : null}
{policyRecognPhase === 'recognizing' ? (
<>
>
) : null}
{policyRecognPhase === 'recognized' ? (
<>
>
) : null}
{policyRecognPhase === 'results' ? (
<>
{policyRecognViewOnly ? (
) : null}
{!policyRecognViewOnly ? (
) : null}
{!policyRecognViewOnly ? (
) : null}
>
) : null}
{ if (policyPreview?.url) URL.revokeObjectURL(policyPreview.url); setPolicyPreview(null); }}>关闭}
onCancel={() => { if (policyPreview?.url) URL.revokeObjectURL(policyPreview.url); setPolicyPreview(null); }}
>
{policyPreview?.isImage && policyPreview.url ? (

) : (
PDF
{policyPreview?.fileName}
{policyPreview?.hint}
)}
setPolicyRecognEditOpen(false)}
onOk={savePolicyRecognResultEdit}
okText="保存"
cancelText="取消"
>
{renderPolicyDetailForm(policyRecognEditDraft, setPolicyRecognEditDraft)}
{
setVehicleInsHistoryEditOpen(false);
setVehicleInsHistoryEditRecord(null);
}}
onOk={saveVehicleInsHistoryEdit}
okText="保存"
cancelText="取消"
>
{renderPolicyDetailForm(vehicleInsHistoryEditDraft, setVehicleInsHistoryEditDraft)}
setPolicyAddOpen(false)}
onOk={handlePolicyAddSubmit}
okText="保存"
cancelText="取消"
>
{renderPolicyDetailForm(policyAddDraft, setPolicyAddDraft)}
);
};
export default Component;