Files
ONE-OS/web端/业务管理/保险采购.jsx

8699 lines
394 KiB
JavaScript
Raw Blame History

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