diff --git a/web端/业务管理/保险采购.jsx b/web端/业务管理/保险采购.jsx
index 793a8f1..dc34610 100644
--- a/web端/业务管理/保险采购.jsx
+++ b/web端/业务管理/保险采购.jsx
@@ -169,8 +169,8 @@ const compareInsuranceEndDate = (dateA, dateB, order) => {
const POLICY_OCR_MODES = [
{ key: 'policy', label: '保单录入', desc: '选择险种后上传附件,自动识别保单要素并匹配台账' },
{ key: 'suspend', label: '停保', desc: '上传停保/停驶批单,识别保单号与车牌后停保' },
- { key: 'resume', label: '复驶', desc: '上传复驶批单,识别后更新生效日期与到期日期' },
- { key: 'cancel', label: '退保', desc: '上传退保批单,识别保单号与退费金额' },
+ { key: 'resume', label: '复驶', desc: '上传复驶批单,识别保单号、恢复时间与新到期日期' },
+ { key: 'cancel', label: '退保', desc: '上传退保批单,识别保单号、退保时间与退保金额' },
];
const POLICY_BIZ_TYPE_OPTIONS = [
@@ -196,7 +196,12 @@ const EMPTY_POLICY_DETAIL = {
coverageItems: [],
applicant: '',
insured: '',
+ vehicleOwner: '',
signDate: '',
+ suspendTime: '',
+ resumeTime: '',
+ newEndDate: '',
+ cancelTime: '',
attachments: [],
};
@@ -308,15 +313,23 @@ const buildSampleCoverageItemsForRecognEdit = (insuranceType, premiumTotal) => {
};
const enrichPolicyDetailCoverageForEdit = (detail) => {
- const items = normalizeCoverageItems(detail.coverageItems);
+ 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 {
- ...detail,
- coverageItems: buildSampleCoverageItemsForRecognEdit(detail.insuranceType, detail.premium),
+ ...normalized,
+ coverageItems: buildSampleCoverageItemsForRecognEdit(normalized.insuranceType, normalized.premium),
};
}
- return { ...detail, coverageItems: items };
+ return { ...normalized, coverageItems: items };
+};
+
+/** 保单 OCR 识别不反写承保险种明细(保额/免赔/分项保费) */
+const stripPolicyRecognCoverageFields = (detail) => {
+ const d = normalizePolicyDetail(detail);
+ return { ...d, coverageItems: [] };
};
/** 基于用户提供的真实保单/批单样本(PDF 解析 + 文件名) */
@@ -335,6 +348,7 @@ const REFERENCE_POLICY_OCR_MOCKS = [
{ coverageName: '财产损失赔偿', coverageAmount: '2000元', deductible: '—', itemPremium: '110.00' },
],
applicant: '上海羚牛氢运物联网科技有限公司', insured: '上海羚牛氢运物联网科技有限公司',
+ vehicleOwner: '上海羚牛氢运物联网科技有限公司',
},
},
{
@@ -342,7 +356,8 @@ const REFERENCE_POLICY_OCR_MOCKS = [
detail: {
plateNo: '粤AGP9827', insuranceType: '商业险', bizType: 'policy',
policyNo: '2050AA330400260000GV', company: '紫金财产保险股份有限公司',
- payTime: '2026-05-28 10:00:00', startDate: '2026-06-06', endDate: '2027-05-27', premium: '12800.00',
+ 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' },
@@ -368,6 +383,7 @@ const REFERENCE_POLICY_OCR_MOCKS = [
premium: '1500.00',
coverageItems: '公路货物运输定额保险;累计赔偿限额10001000元;主险货物保险金额1000元',
applicant: '羚牛氢能科技(广东)有限公司', insured: '羚牛氢能科技(广东)有限公司',
+ vehicleOwner: '羚牛氢能科技(广东)有限公司',
},
},
{
@@ -378,6 +394,7 @@ const REFERENCE_POLICY_OCR_MOCKS = [
payTime: '2024-10-17 15:58:05', startDate: '2024-10-18', endDate: '2025-10-17', premium: '1500.00',
coverageItems: '公路货物运输定额保险 CNY500000;集装箱货物及其箱体',
applicant: '嘉兴羚牛汽车服务有限公司', insured: '嘉兴羚牛汽车服务有限公司',
+ vehicleOwner: '嘉兴羚牛汽车服务有限公司',
},
},
{
@@ -386,7 +403,8 @@ const REFERENCE_POLICY_OCR_MOCKS = [
plateNo: '粤A03423F', insuranceType: '商业险', bizType: 'suspend',
policyNo: '2050AA3304002600002EM', endorsementNo: '3050AA3304002600002EM01',
company: '紫金财产保险股份有限公司', payTime: '2026-04-16 16:13:53',
- startDate: '2026-04-17', endDate: '2026-04-17', reinstateDate: '2027-03-31',
+ 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: '停驶批单:保险车辆停驶,停驶期间保险责任中止',
},
},
@@ -396,7 +414,8 @@ const REFERENCE_POLICY_OCR_MOCKS = [
plateNo: '粤A06290F', insuranceType: '商业险', bizType: 'resume',
policyNo: '2050AA33040026000226', endorsementNo: '3050AA3304002600022602',
company: '紫金财产保险股份有限公司', payTime: '2026-04-30 14:25:43',
- startDate: '2026-05-06', endDate: '2027-03-27', reinstateDate: '2027-03-16',
+ resumeTime: '2026-05-06', newEndDate: '2027-03-27',
+ startDate: '2026-05-06', endDate: '2027-03-27', reinstateDate: '2026-05-06',
coverageItems: '复驶批单:停驶车辆恢复行驶,保险责任自复驶日起恢复',
},
},
@@ -405,7 +424,9 @@ const REFERENCE_POLICY_OCR_MOCKS = [
detail: {
plateNo: '浙F03220F', insuranceType: '商业险', bizType: 'resume',
policyNo: 'BSHZ001S2024B005477B', endorsementNo: 'BSHZ001S2024B005477E',
- company: '中国太平洋财产保险股份有限公司', startDate: '2026-05-01', endDate: '2027-04-30',
+ company: '中国太平洋财产保险股份有限公司',
+ resumeTime: '2026-05-01', newEndDate: '2027-04-30',
+ startDate: '2026-05-01', endDate: '2027-04-30',
coverageItems: '复驶批单',
},
},
@@ -415,7 +436,7 @@ const REFERENCE_POLICY_OCR_MOCKS = [
plateNo: '沪A06192F', insuranceType: '商业险', bizType: 'suspend',
policyNo: 'BSHZ001S2024B005054V', endorsementNo: 'BSHZ001S2024B005054E',
company: '中国太平洋财产保险股份有限公司', startDate: '2025-12-05', endDate: '2026-03-05',
- reinstateDate: '2026-06-01',
+ reinstateDate: '2026-06-01', suspendTime: '2025-12-05', resumeTime: '2026-06-01', newEndDate: '2026-03-05',
coverageItems: '停保批单',
},
},
@@ -425,7 +446,7 @@ const REFERENCE_POLICY_OCR_MOCKS = [
plateNo: '粤A03331F', insuranceType: '商业险', bizType: 'cancel',
policyNo: '2050AA330400260000GV', endorsementNo: '3050AA330400260000GV02',
company: '紫金财产保险股份有限公司', payTime: '2026-05-27 17:51:20',
- startDate: '2026-05-28', endDate: '2026-05-28', premium: '7853.27',
+ cancelTime: '2026-05-28', startDate: '2026-05-28', endDate: '2026-05-28', premium: '7853.27',
coverageItems: '商业险退保批单,退还保费',
},
},
@@ -435,6 +456,7 @@ const REFERENCE_POLICY_OCR_MOCKS = [
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: '商业险停保',
},
},
@@ -450,6 +472,85 @@ const normalizePolicyDetail = (raw = {}) => ({
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 (
+
+
中止时间:{suspendTime || '—'}
+
恢复时间:{resumeTime || '—'}
+
新到期日期:{newEndDate || '—'}
+
+ );
+};
+
const inferPolicyDetailFromFileName = (fileName) => {
const name = fileName || '';
const plateMatch = name.match(/[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼][A-Z][A-Z0-9]{4,6}[A-Z0-9挂学警港澳]?/i);
@@ -490,7 +591,12 @@ const inferPolicyDetailFromFileName = (fileName) => {
const resolvePolicyDetailFromFileName = (fileName) => {
const ref = REFERENCE_POLICY_OCR_MOCKS.find((m) => m.test(fileName));
- if (ref) return normalizePolicyDetail(ref.detail);
+ 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);
};
@@ -498,6 +604,20 @@ 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 };
@@ -512,6 +632,7 @@ const applyPolicyDetailToInsuranceItem = (item, detail, mode) => {
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;
}
@@ -519,14 +640,27 @@ const applyPolicyDetailToInsuranceItem = (item, detail, mode) => {
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.reinstateDate = d.reinstateDate || next.reinstateDate || '2026-09-01';
+ 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.reinstateDate = '';
+ 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;
};
@@ -759,37 +893,65 @@ const isImportRowLedgerMatched = (ledgerKey) => (
);
const buildRecognResultFromDetail = (fileMeta, detail, allInsurance, forcedMode) => {
- const d = normalizePolicyDetail(detail);
- const mode = forcedMode || bizTypeToRecognMode(d.bizType);
+ 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 };
- const ledgerKey = resolveImportRowLedgerKey({ 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 && ledgerKey) {
- const policyMatch = findPolicyMatchInLedger(allInsurance, ledgerKey, d.policyNo);
+ 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 = d.endDate || existing?.endDate || '';
- let reinstateDate = d.reinstateDate || '';
+ 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 = isImportRowLedgerMatched(ledgerKey) && !!typeKey && !!(ocrPolicyNo || ocrEndDate);
+ 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()}`,
@@ -797,6 +959,7 @@ const buildRecognResultFromDetail = (fileMeta, detail, allInsurance, forcedMode)
fileType: fileMeta.fileType || '',
policyDetail: d,
ocrPlateNo: (vehicle.plateNo || plate || '').trim(),
+ ocrRecognizedPlate,
ocrVin: vehicle.vin || vin || '',
displayPlate: formatVehiclePlateDisplay(vehicle.plateNo || plate),
ocrPolicyNo,
@@ -805,22 +968,25 @@ const buildRecognResultFromDetail = (fileMeta, detail, allInsurance, forcedMode)
ocrPremium: d.premium || '',
ocrPayTime: d.payTime || '',
ocrEndorsementNo: d.endorsementNo || '',
- ocrCoverageItems: serializeCoverageItems(d.coverageItems),
+ ocrCoverageItems: mode === 'policy' ? '' : serializeCoverageItems(d.coverageItems),
ocrBizType: d.bizType,
ocrBizTypeLabel: bizLabel,
- ocrCompany: d.company || existing?.company || INSURANCE_MGMT_COMPANIES[0],
+ 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: matched
- ? '已与台账车辆、险种匹配,可核对后确认'
- : !ledgerKey
- ? '未匹配到台账车辆,请检查车牌或 VIN'
- : !typeKey
- ? '险种填写有误'
- : '请填写保单号或到期日期',
+ matchTip,
recognSuccess: true,
confirmed: false,
recognMode: mode,
@@ -860,6 +1026,17 @@ const findPolicyMatchInLedger = (allInsurance, ledgerKey, policyNo) => {
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);
@@ -877,15 +1054,20 @@ const buildMockOcrResults = (files, mode, insuranceTypeLabel, allInsurance) => (
detail.policyNo = `PDZA${String(20260000 + idx)}`;
}
if (mode === 'policy') {
- detail.coverageItems = buildSampleCoverageItemsForRecognEdit(
- detail.insuranceType,
- detail.premium || (detail.insuranceType === '交强险' ? '1243.00' : '12800.00')
- );
+ Object.assign(detail, stripPolicyRecognCoverageFields(detail));
if (!detail.premium) {
- const sum = detail.coverageItems.reduce((acc, row) => acc + (parseFloat(row.itemPremium) || 0), 0);
- detail.premium = sum > 0 ? sum.toFixed(2) : '';
+ 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,
@@ -904,28 +1086,88 @@ const buildMockOcrResults = (files, mode, insuranceTypeLabel, allInsurance) => (
})
);
-const recognResultToPolicyDetail = (result) => normalizePolicyDetail({
- plateNo: result.ocrPlateNo,
- vin: result.ocrVin,
- insuranceType: result.insuranceTypeLabel,
- bizType: result.ocrBizType || result.policyDetail?.bizType || 'policy',
- company: result.ocrCompany,
- policyNo: result.ocrPolicyNo,
- endorsementNo: result.ocrEndorsementNo,
- payTime: result.ocrPayTime,
- startDate: result.ocrStartDate,
- endDate: result.ocrEndDate,
- reinstateDate: result.reinstateDate,
- premium: result.ocrPremium,
- coverageItems: normalizeCoverageItems(result.policyDetail?.coverageItems ?? result.ocrCoverageItems),
- ...(result.policyDetail || {}),
-});
+const 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 detail = result.policyDetail ? normalizePolicyDetail(result.policyDetail) : recognResultToPolicyDetail(result);
+ 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());
@@ -946,6 +1188,8 @@ const INSURANCE_MGMT_COMPANIES = [
'中华联合财产保险股份有限公司',
'太平财产保险有限公司',
'大地财产保险股份有限公司',
+ '紫金财产保险股份有限公司',
+ '国任财产保险股份有限公司广州市番禺支公司',
'上海某某保险公司',
];
@@ -1169,10 +1413,28 @@ const appendInsuranceOperationLog = (logs, payload) => [
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)
@@ -1208,6 +1470,8 @@ const createLedgerMgmtHistoryRecord = (vehicle, ledgerKey, typeKey, typeLabel, i
endDate: item.endDate,
policyTag: item.policyTag || '',
reinstateDate: item.reinstateDate || item.resumeTime || '',
+ suspendTime: item.suspendTime || '',
+ resumeTime: item.resumeTime || item.reinstateDate || '',
policyDetail: buildPolicyDetailFromLedgerItem(
vehicle,
typeLabel,
@@ -1348,6 +1612,120 @@ const PLATE_SELECT_OPTIONS = MOCK_VEHICLES
.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);
@@ -1417,7 +1795,7 @@ const VEHICLE_INSURANCE_MGMT_TABS = [
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' },
+ 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' },
};
@@ -1513,6 +1891,8 @@ const createInsuranceHistoryRecord = (payload) => ({
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'),
});
@@ -1552,8 +1932,10 @@ const historyRecordToPolicyDetail = (record, vehicle) => {
};
const applyPolicyDetailToHistoryRecord = (record, detail) => {
- const d = normalizePolicyDetail(detail);
- const time = d.startDate || record.time;
+ 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,
@@ -1563,11 +1945,13 @@ const applyPolicyDetailToHistoryRecord = (record, detail) => {
company: d.company,
payTime: d.payTime,
startDate: d.startDate,
- endDate: d.endDate,
+ endDate: d.bizType === 'suspend' ? (d.newEndDate || d.endDate) : d.endDate,
premium: d.premium,
reinstateDate: d.reinstateDate,
- time,
- purchaseTime: time,
+ 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);
@@ -1605,6 +1989,9 @@ const buildPolicyDetailFromLedgerItem = (vehicle, typeLabel, item, bizType = 'po
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 || '',
@@ -1622,8 +2009,16 @@ const getInsuranceEventSummary = (record) => {
return `${typeLabel} ${record.typeLabel},保单号 ${record.policyNo || '—'}${period}${premiumText}`;
case 'renew':
return `${typeLabel} ${record.typeLabel},保单号 ${record.policyNo || '—'}${period}${premiumText}`;
- case 'rentStop':
- return `${typeLabel} · ${record.typeLabel},保单号 ${record.policyNo || '—'}${record.reinstateDate ? `,预计复驶 ${record.reinstateDate}` : ''}`;
+ case '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':
@@ -1849,7 +2244,30 @@ const INITIAL_INSURANCE_DATA = {
},
'粤A12345': {
compulsory: { company: '中国平安财产保险', policyNo: 'PAIC-JQ-2025-4455', startDate: '2025-03-01', endDate: '2026-02-28', premium: '950.00', updateTime: '2025-02-25 10:00', updateUser: '张三' },
- commercial: { company: '中国平安财产保险', policyNo: 'PAIC-SY-2025-4456', startDate: '2025-03-01', endDate: '2026-02-28', premium: '10500.00', updateTime: '2025-02-25 10:00', updateUser: '张三' },
+ 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: '张三' },
@@ -1956,7 +2374,62 @@ const POLICY_RECOGN_STATUS_META = {
completed: { label: '已完成', color: 'success' },
};
-const validatePolicyRecognDetailForConfirm = (detail) => validatePolicyEntryDetail(detail).ok;
+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)
@@ -2091,6 +2564,12 @@ const createMockPolicyRecognTasks = () => {
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',
@@ -2116,6 +2595,18 @@ const createMockPolicyRecognTasks = () => {
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',
@@ -2289,7 +2780,15 @@ const vehicleMatchesListInsuranceTypeFilter = (ledgerKey, insuranceTypeLabel, en
return true;
};
-const DEFAULT_COMPARE_MGMT_FILTERS = { plateNo: '', createdRange: null };
+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 };
@@ -2589,11 +3088,15 @@ const PAGE_STYLE = `
.lc-expiring-warn-toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; }
.lc-expiring-warn-table .ant-table-thead > tr > th { background: #f8fafc !important; font-weight: 700 !important; font-size: 13px !important; cursor: pointer; }
.lc-expiring-warn-table .ant-table-tbody > tr > td { font-size: 13px; }
-.lc-compare-pay-alert-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 14px; }
-.lc-compare-pay-alert { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 14px; border-radius: 10px; border: 1px solid #e2e8f0; background: #fff; }
+.lc-compare-pay-alert-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; }
@@ -2732,7 +3235,9 @@ const PAGE_STYLE = `
.lc-vehicle-ins-mgmt-table-card { border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden; background: #fff; }
.lc-vehicle-ins-mgmt-table-card .ant-table-thead > tr > th { background: #f8fafc !important; font-weight: 700 !important; font-size: 12px !important; color: #475569 !important; white-space: nowrap; }
.lc-vehicle-ins-mgmt-table .ant-table-content table { table-layout: fixed; width: 100% !important; }
-.lc-vehicle-ins-mgmt-table .ant-table-tbody > tr > td { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; padding-top: 8px !important; padding-bottom: 8px !important; }
+.lc-vehicle-ins-mgmt-table .ant-table-tbody > tr.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; }
@@ -2741,6 +3246,7 @@ const PAGE_STYLE = `
.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; }
@@ -2937,30 +3443,44 @@ const Component = function () {
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 warning = 0;
- let overdue = 0;
- compareSheets.forEach((sheet) => {
+ let warningSheets = 0;
+ let overdueSheets = 0;
+ compareMgmtBaseFilteredSheets.forEach((sheet) => {
const alert = calcCompareSheetPayAlerts(sheet);
- warning += alert.warning;
- overdue += alert.overdue;
+ if (alert.warning > 0) warningSheets += 1;
+ if (alert.overdue > 0) overdueSheets += 1;
});
- return { warning, overdue };
- }, [compareSheets]);
+ return {
+ total: compareMgmtBaseFilteredSheets.length,
+ warning: warningSheets,
+ overdue: overdueSheets,
+ };
+ }, [compareMgmtBaseFilteredSheets]);
const saveCompareSheets = useCallback((nextSheets) => {
setCompareSheets(nextSheets);
persistCompareSheetsToStorage(nextSheets);
}, []);
- const filteredCompareSheets = useMemo(() => {
- const plateKey = (appliedCompareMgmtFilters.plateNo || '').trim();
- const range = appliedCompareMgmtFilters.createdRange;
- return compareSheets
- .filter((sheet) => compareSheetMatchesCreatedRange(sheet.createdAt, range))
- .filter((sheet) => compareSheetMatchesPlateFilter(sheet, plateKey))
- .sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
- }, [compareSheets, appliedCompareMgmtFilters]);
+ const 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)));
@@ -3579,12 +4099,14 @@ const Component = function () {
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 } };
});
@@ -3679,6 +4201,53 @@ const Component = function () {
message.success(`已开始下载:${record.fileName || '保单附件'}(原型)`);
};
+ const handleOperationLogAttachmentPreview = (attachment) => {
+ if (!attachment?.name) {
+ message.info('该操作未上传附件');
+ return;
+ }
+ Modal.info({
+ title: `预览 · ${attachment.name}`,
+ width: 520,
+ centered: true,
+ content: (
+
+
文件名:{attachment.name}
+ {attachment.size ? (
+
大小:{formatAttachmentSize(attachment.size)}
+ ) : null}
+
正式环境将内嵌 PDF / 图片预览;原型仅展示附件名称。
+
+ ),
+ okText: '关闭',
+ });
+ };
+
+ const handleOperationLogAttachmentDownload = (attachment) => {
+ if (!attachment?.name) {
+ message.info('该操作未上传附件');
+ return;
+ }
+ message.success(`已开始下载:${attachment.name}(原型)`);
+ };
+
+ const renderInsurancePolicyStatusTag = (record) => {
+ const meta = getInsurancePolicyStatusMeta(record);
+ const tag = (
+
+ {meta.label}
+
+ );
+ if (meta.label === '已停保') {
+ return (
+
+ {tag}
+
+ );
+ }
+ return tag;
+ };
+
const syncVehicleInsHistoryEditToLedger = (record, detail) => {
if (!vehicleInsMgmtVehicle || record.source !== 'ledger') return;
const ledgerKey = getVehicleLedgerKey(vehicleInsMgmtVehicle);
@@ -3742,11 +4311,19 @@ const Component = function () {
message.success('已保存保单要素');
};
- const renderPurchaseTypeChip = (purchaseType) => {
+ const renderPurchaseTypeChip = (purchaseType, record) => {
const meta = POLICY_PURCHASE_TYPE_META[purchaseType] || { label: purchaseType, chipClass: '' };
- return (
+ const chip = (
{meta.label}
);
+ if (purchaseType === 'rentStop' && record) {
+ return (
+
+ {chip}
+
+ );
+ }
+ return chip;
};
const renderVehicleInsuranceTimelineCard = (item, side) => (
@@ -3763,7 +4340,7 @@ const Component = function () {
{item.time || '—'}
- {renderPurchaseTypeChip(item.purchaseType)}
+ {renderPurchaseTypeChip(item.purchaseType, item)}
{item.typeLabel}
{item.policyNo ? (
{item.policyNo}
@@ -3850,7 +4427,7 @@ const Component = function () {
title: '类型',
dataIndex: 'purchaseType',
width: 72,
- render: (val) => renderPurchaseTypeChip(val),
+ render: (val, record) => renderPurchaseTypeChip(val, record),
},
{
title: '保单号',
@@ -3859,6 +4436,12 @@ const Component = function () {
ellipsis: true,
render: (val) => renderMgmtTableEllipsis(val),
},
+ {
+ title: '保险状态',
+ key: 'policyStatus',
+ width: 88,
+ render: (_, record) => renderInsurancePolicyStatusTag(record),
+ },
{
title: '保险公司',
dataIndex: 'company',
@@ -3978,7 +4561,7 @@ const Component = function () {
size: 'small',
onChange: (page) => setVehicleInsMgmtTabPage((prev) => ({ ...prev, [typeKey]: page })),
} : false}
- scroll={{ x: 1160 }}
+ scroll={{ x: 1248 }}
rowClassName={(record) => (record.id === vehicleInsMgmtHighlightId ? 'lc-ins-history-row--active' : '')}
/>
@@ -4035,7 +4618,7 @@ const Component = function () {
|| policyRecognResults[0];
if (preferred) {
setPolicyRecognActiveResultId(preferred.id);
- setPolicyRecognConfirmDraft(enrichPolicyDetailCoverageForEdit(recognResultToPolicyDetail(preferred)));
+ setPolicyRecognConfirmDraft(buildPolicyRecognConfirmDraft(preferred, policyRecognMode));
showPolicyRecognResultPreview(preferred);
}
}
@@ -4094,7 +4677,7 @@ const Component = function () {
|| successResults[0];
if (preferred) {
setPolicyRecognActiveResultId(preferred.id);
- setPolicyRecognConfirmDraft(enrichPolicyDetailCoverageForEdit(recognResultToPolicyDetail(preferred)));
+ setPolicyRecognConfirmDraft(buildPolicyRecognConfirmDraft(preferred, task.mode || 'policy'));
showPolicyRecognResultPreview(preferred);
}
};
@@ -4162,6 +4745,15 @@ const Component = function () {
};
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,
@@ -4169,9 +4761,9 @@ const Component = function () {
fileName: result.fileName,
fileType: result.fileType,
},
- detail,
+ normalizedDetail,
allInsurance,
- bizTypeToRecognMode(detail.bizType)
+ mode
);
return { ...result, ...rebuilt, id: result.id, confirmed: result.confirmed };
};
@@ -4215,7 +4807,7 @@ const Component = function () {
const result = nextResults.find((r) => r.id === resultId);
if (!result) return nextResults;
setPolicyRecognActiveResultId(resultId);
- setPolicyRecognConfirmDraft(enrichPolicyDetailCoverageForEdit(recognResultToPolicyDetail(result)));
+ setPolicyRecognConfirmDraft(buildPolicyRecognConfirmDraft(result, policyRecognMode));
showPolicyRecognResultPreview(result);
return nextResults;
};
@@ -4223,6 +4815,9 @@ const Component = function () {
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)
@@ -4230,7 +4825,7 @@ const Component = function () {
|| displayResults[0];
if (preferred) {
setPolicyRecognActiveResultId(preferred.id);
- setPolicyRecognConfirmDraft(enrichPolicyDetailCoverageForEdit(recognResultToPolicyDetail(preferred)));
+ setPolicyRecognConfirmDraft(buildPolicyRecognConfirmDraft(preferred, snapshot.mode ?? policyRecognMode));
showPolicyRecognResultPreview(preferred);
}
if (snapshot.taskId) {
@@ -4249,10 +4844,21 @@ const Component = function () {
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,
}));
};
@@ -4399,11 +5005,36 @@ const Component = function () {
showBizType = true,
recognConfirmMode = false,
policyEntryMode = false,
+ bizRecognMode,
onPlateChange,
+ recognResult,
} = options;
- const requiredKeys = recognConfirmMode || policyEntryMode
- ? POLICY_ENTRY_FORM_REQUIRED_KEYS
- : [];
+ 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) ? {text} : text
);
@@ -4423,9 +5054,28 @@ const Component = function () {
setDraft((p) => ({ ...p, coverageItems: next.length ? next : [{ ...EMPTY_COVERAGE_ITEM }] }));
};
return (
-
- {!recognConfirmMode ?
车辆与险种
: null}
- {renderFilterField(fieldLabel('车牌号', 'plateNo'), (
+
+ {isPolicyRecogn && draft._ocrRecognizedPlate && !draft._plateLedgerMatched ? (
+
+ ) : null}
+ {isPolicyRecogn ? (
+
车辆与主体
+ ) : (!recognConfirmMode ?
车辆与险种
: null)}
+ {isPolicyRecogn ? renderFilterField('车主', (
+
setDraft((p) => ({ ...p, vehicleOwner: e.target.value }))}
+ placeholder="行驶证车主名称"
+ />
+ )) : null}
+ {!isEndorsementRecogn ? renderFilterField(fieldLabel('车牌号', 'plateNo'), (
recognConfirmMode ? (
)
- ))}
- {renderFilterField('车辆识别代码', (
+ )) : null}
+ {!isEndorsementRecogn ? renderFilterField('车辆识别代码', (
setDraft((p) => ({ ...p, vin: e.target.value }))}
style={recognConfirmMode ? { background: '#f8fafc', color: '#475569' } : undefined}
/>
- ))}
+ )) : null}
+ {isPolicyRecogn ? (
+ <>
+ {renderFilterField('投保人', (
+
setDraft((p) => ({ ...p, applicant: e.target.value }))}
+ placeholder="投保人名称"
+ />
+ ))}
+ {renderFilterField('被保险人', (
+
setDraft((p) => ({ ...p, insured: e.target.value }))}
+ placeholder="被保险人名称"
+ />
+ ))}
+ >
+ ) : null}
{showBizType ? renderFilterField('业务类型', (
)) : null}
{!recognConfirmMode ?
保单要素
: null}
- {renderFilterField(fieldLabel('保险公司', 'company'), (
+ {isPolicyRecogn ?
保单要素
: null}
+ {isEndorsementRecogn ?
批单要素
: null}
+ {!isEndorsementRecogn ? renderFilterField(fieldLabel('保险公司', 'company'), (
+ ) : isSuspendRecogn ? (
+
+ 停保批单核对保单号、中止时间、恢复时间与新到期日期;台账匹配依据保单号,「新到期日期」确认后将写入该保单到期日期
+
+ ) : isResumeRecogn ? (
+
+ 复驶批单核对保单号、恢复时间与新到期日期;台账匹配依据保单号,「新到期日期」确认后将写入该保单到期日期
+
+ ) : isCancelRecogn ? (
+
+ 退保批单核对保单号、退保时间与退保金额;台账匹配依据保单号,确认后将标记该保单为已退保
+
+ ) : null}
);
};
@@ -4644,18 +5431,28 @@ const Component = function () {
const baseResults = persistActiveRecognDraft();
const result = baseResults.find((r) => r.id === resultId);
if (!result) return;
- const detail = detailOverride || (resultId === policyRecognActiveResultId ? policyRecognConfirmDraft : recognResultToPolicyDetail(result));
- if (!validatePolicyRecognDetailForConfirm(detail)) return;
+ const 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) {
- message.warning('该条未匹配台账,请检查车牌号是否正确');
+ const warnMode = resolvePolicyRecognEffectiveMode(policyRecognMode, merged);
+ message.warning(
+ warnMode === 'suspend' || warnMode === 'resume' || warnMode === 'cancel'
+ ? '该条未匹配台账,请检查保单号是否正确'
+ : '该条未匹配台账,请检查车牌号是否正确'
+ );
return;
}
if (merged.confirmed) {
message.info('该条已确认');
return;
}
- const mode = merged.recognMode || policyRecognMode;
+ const mode = resolvePolicyRecognEffectiveMode(policyRecognMode, merged);
updateAllInsurance(applyPolicyOcrResultToLedger(merged, mode));
const nextResults = baseResults.map((r) => (
r.id === resultId ? { ...merged, confirmed: true } : r
@@ -4698,8 +5495,9 @@ const Component = function () {
const invalid = pending.find((result) => {
const detail = result.id === policyRecognActiveResultId
? policyRecognConfirmDraft
- : recognResultToPolicyDetail(result);
- return !validatePolicyEntryDetail(detail, { silent: true }).ok;
+ : buildPolicyRecognConfirmDraft(result, policyRecognMode);
+ const mode = resolvePolicyRecognEffectiveMode(policyRecognMode, result);
+ return !validatePolicyRecognDetailForConfirm(detail, { mode, silent: true, recognResult: result });
});
if (invalid) {
message.warning('批量确认前请确保每条记录必填项均已填写,可先逐条确认');
@@ -4708,9 +5506,9 @@ const Component = function () {
pending.forEach((result) => {
const detail = result.id === policyRecognActiveResultId
? policyRecognConfirmDraft
- : recognResultToPolicyDetail(result);
+ : buildPolicyRecognConfirmDraft(result, policyRecognMode);
const merged = mergeRecognResultWithDetail(result, detail);
- const mode = merged.recognMode || policyRecognMode;
+ const mode = resolvePolicyRecognEffectiveMode(policyRecognMode, merged);
nextInsurance = applyPolicyOcrResultToLedger(merged, mode)(nextInsurance);
});
updateAllInsurance(() => nextInsurance);
@@ -4734,6 +5532,16 @@ const Component = function () {
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: '文件/记录',
@@ -4844,7 +5652,7 @@ const Component = function () {
if (!item?.policyTag) return null;
if (item.policyTag === 'suspended') {
return (
-
+
已停保
);
@@ -6322,6 +7130,22 @@ const Component = function () {
placeholder="元"
/>
))}
+
+ 退保单附件
+ 支持 PDF、图片,可上传多份
+ false}
+ onChange={handlePolicyBizAttachmentChange}
+ >
+
+
+
>
) : null}
@@ -6332,7 +7156,7 @@ const Component = function () {
{
@@ -6354,7 +7178,7 @@ const Component = function () {
pagination={false}
locale={{ emptyText: '暂无操作记录' }}
dataSource={policyOpHistoryRecord?.operationLogs || []}
- scroll={{ x: 760, y: 360 }}
+ scroll={{ x: 920, y: 360 }}
columns={[
{
title: '操作时间',
@@ -6377,6 +7201,7 @@ const Component = function () {
{
title: '备注',
dataIndex: 'remark',
+ width: 220,
ellipsis: true,
render: (val) => (
@@ -6384,13 +7209,52 @@ const Component = function () {
),
},
+ {
+ title: '附件',
+ key: 'attachments',
+ width: 148,
+ render: (_, log) => {
+ const bizTypes = ['suspend', 'resume', 'cancel'];
+ if (!bizTypes.includes(log.type)) return —;
+ const attachments = log.attachments || [];
+ if (!attachments.length) {
+ return 无附件;
+ }
+ return (
+
+ {attachments.map((att) => (
+
+
+
+
+
+
+ ))}
+
+ );
+ },
+ },
]}
/>
- 一、业务对象说明
+ 一、需求背景与目标
+
+ 车队保险采购分散在 Excel、邮件与线下批单中,台账不全、续保临期易漏、停保退保无留痕、采购比价难追溯、交车合规无法实时校验。
+ 本模块需在同一页面承载两条相互独立的业务线,技术实现上须避免强耦合与自动双向同步。
+
- - 车辆档案:以车牌或 VIN 唯一标识一辆车;允许「暂无车牌、仅有 VIN」的库存车。
- - 保单台账:每辆车下分五类险种(交强、商业、超赔、货物、驾意)分别建档,记录保单号、保司、生效/到期日、保费等。
- - 比价单:一次保险采购批次,含创建信息、备注、附件,以及多条「购买记录」。
- - 购买记录:比价单内的一行,表示「某辆车 + 某种险种」的待购事项,含报价、最晚付费日、采购状态等。
+ - 保单管理:建立一车一档台账,支撑 OCR/导入/手工录入及停保/复驶/退保变更,并与车辆管理交车合规联动。
+ - 比价单:支撑采购前多方报价、批次管理、最晚付费预警与采购审批发起;审批结果回写购买记录,不自动写入保单台账。
- 二、保单管理 · 险种状态判定
- 针对每一类险种,按以下优先级判断当前状态(自上而下命中即停止):
-
- - 已办理退保 → 视为「已退保」,等同未有效保障
- - 无保单号 → 「未购买」
- - 有保单号但无到期日,且处于停保状态 → 「已停保」(预警)
- - 有保单号但无到期日 → 「未购买」
- - 到期日 ≤ 今天 → 「已到期」
- - 到期日在未来 30 天内 → 「临期」(仍在有效期内)
- - 其余 → 「正常」
-
-
- 三、保单管理 · 交车联动规则
- 车辆整体保险状态仅由交强险 + 商业险决定,与车辆管理模块保持一致:
-
- - 交强、商业均为「正常」或「临期」→ 车辆保险状态为「正常」或「临期」,允许交车(临期仍有效,但需尽快续保)
- - 交强或商业任一为「未购买」或「已到期」→ 车辆保险状态为「异常」,禁止交车
- - 超赔、货物、驾意险只影响首页「险种临期预警」统计,不参与交车判定
-
-
- 四、保单管理 · 录入与变更流转
- 4.1 新保 / 续保录入
-
- - 渠道:逐条新增、批量识别(上传保单附件)、批量导入 Excel(仅新保/续保)
- - 必填:车牌或 VIN(至少一项)、险种、保险公司、保单号、生效日期、到期日期、保险费合计
- - 选填:承保险种明细、保险金额、免赔额、分项保费等
- - 导入时自动跳过停保、停租、复驶、退保类业务行
-
- 4.2 停保 / 复驶 / 退保
-
- - 在车辆「管理」弹窗中,针对当前有效记录办理(历史归档记录不可操作)
- - 新保、续保记录 → 可停保、可退保
- - 停租记录 → 可复驶、可退保
- - 退保记录 → 仅可复驶
- - 全周期时间轴:左侧展示新保/续保,右侧展示停保/复驶/退保,便于追溯
-
-
- 五、比价单 · 完整业务流转
-
- - 创建比价单:手动新建、编辑历史批次,或由临期/逾期预警一键生成购买记录
- - 维护购买记录:选择车辆与险种,系统自动带出客户、品牌、交强/商业到期日等;用户确认投保方式(新保/续保)
- - 录入报价:每行可录多家保险公司报价,须选定一条为「最终比价结果」
- - 填写最晚付费日:为每行指定付款截止日期,用于临期/超期提醒
- - 保存比价单:填写备注、上传附件(均必填),持久化整单数据
- - 勾选提交采购:选择需采购的行发起审批;提交后该行进入「审批中」
- - 审批办理:在审批中心通过、驳回或撤回;结果回写至购买记录的采购状态
- - 后续处理:驳回或撤回后可重新勾选提交;审批通过后该行不可再次提交
-
-
- 六、比价单 · 购买记录规则
-
- - 每行须关联一辆车(车牌或 VIN 至少填一项)
- - 选车后,客户、品牌、车型、年检有效期、交强/商业到期日等自动带出,不可手改
- - 用户可修改:投保方式、保险类型、最晚付费日期
- - 若台账中该车已有交强或商业到期日,默认投保方式为「续保」,否则为「新保」
- - 修改保险类型时,该行已有报价全部清空,需重新报价
- - 删除已设为「最终比价结果」的报价时,最终比价结果一并取消
-
-
- 七、比价单 · 报价业务规则
-
- - 「报价情况」为提交采购前的必填项:每行至少录入一条报价(保险公司 + 保险费金额)
- - 同一行可有多家报价,但提交前必须且只能确定一条「最终比价结果」
- - 金额汇总仅统计已确定最终比价结果的行;未确定的行不计入合计
- - 底部左侧「当前保单总金额」= 本单全部已确认报价之和;右侧「已选保单总金额」= 当前勾选且已确认报价之和
-
-
- 八、比价单 · 保存与提交的业务校验
+ 二、模块边界与外部依赖
- | 业务动作 |
- 须满足的条件 |
+ 依赖模块 |
+ 交互方式 |
+ 产品要求 |
+
+
+
+
+ | 车辆管理 |
+ 读取车辆档案;输出保险状态 |
+ 交车合规判定口径须与车辆管理「保险状态」字段一致,异常车辆禁止交车 |
+
+
+ | 审批中心 |
+ 发起采购审批;回写采购状态 |
+ 本页只读展示状态与当前审批人,不提供审批办理能力 |
+
+
+ | OCR 服务 |
+ 保单/批单识别 |
+ 识别结果须人工确认后落库;不同业务类型识别字段不同(见第六节) |
+
+
+
+
+ 关键约束:比价单引用车辆档案与台账到期日作只读参考;审批通过后运营人员须在保单管理侧另行录入正式保单。
+
+
+ 三、核心数据实体(需持久化)
+
+ - 车辆保险台账(一车一档):以车牌或 VIN 为键;下设五类险种(交强、商业、超赔、货物、驾意),每险种独立存储保单要素、
policyTag、操作日志、附件、历史归档保单。
+ - 险种记录关键字段:保单号、保险公司、生效/到期日、保费、保险状态(正常/已停保/已退保)、停保中止/恢复时间、退保时间与退还保费、附件列表、
operationLogs[]。
+ - 比价单:批次头(创建时间、创建人、备注、附件)+ 购买记录行数组。
+ - 购买记录行:车辆标识、险种、投保方式、报价列表、最终比价结果 ID、最晚付费日、采购状态、提交时间、当前审批人。
+ - 识别任务:任务级 mode(保单/停保/复驶/退保/导入)+ 多条待确认结果;确认后写入台账并标记任务状态。
+
+
+ 四、保单管理 · 功能需求
+ 4.1 列表与筛选
+
+ - 支持车牌、多车牌、VIN、品牌、型号、运营状态、保险状态(正常/异常)、险种、到期时间范围筛选。
+ - 首页 KPI:台账车辆总数、保险状态正常、险种临期预警(30 天)、核心险种逾期、核心险种待购;点击 KPI 可筛选列表或打开预警弹窗。
+ - 列表展示五类险种到期日;到期日单元格副文案展示临期/逾期天数或停保/退保标签。
+
+ 4.2 车辆保险档案(管理弹窗)
+
+ - Tab1「全周期记录」:时间轴分左右——左侧新保/续保,右侧停保/复驶/退保。
+ - Tab2~Tab6 按险种展示历史表;列顺序须包含:导入时间、类型、保单号、保险状态(保单号右侧)、保险公司、付款/生效/到期、金额、操作。
+ - 保险状态枚举:正常(复驶后亦算正常)、已停保、已退保;已停保悬停展示中止时间、恢复时间、新到期日期。
+ - 当前有效记录支持停保/复驶/退保;历史归档记录只读,不可办理业务变更。
+
+ 4.3 录入通道
+
+ - 逐条新增、保单批量识别(OCR)、Excel 批量导入(仅新保/续保)。
+ - 导入须自动跳过停保/复驶/退保类业务行。
+ - 保单 OCR 确认页不展示承保险种明细、保险金额、免赔额、分项保费及操作列;上述字段仅在手工新增/台账编辑保留。
+
+
+ 五、险种状态计算规则(须服务端统一实现)
+ 单险种展示状态(列表副标签),按优先级自上而下命中即停止:
+
+ - 已退保 → 已退保
+ - 无保单号 → 未购买
+ - 有保单号 + 停保标记 → 已停保
+ - 有保单号无到期日 → 未购买
+ - 到期日 ≤ 今天 → 已到期
+ - 到期日 ≤ 今天+30 天 → 临期
+ - 其余 → 正常
+
+ 车辆级保险状态(交车联动)
+
+ - 仅由交强险 + 商业险决定;二者均为正常或临期 → 车辆正常/临期,允许交车。
+ - 任一为未购买或已到期 → 车辆异常,禁止交车;已退保视同无有效保障。
+ - 超赔/货物/驾意仅参与「险种临期预警」统计,不参与交车判定。
+
+
+ 六、OCR / 批单识别 · 字段与落库规则
+
+
+
+ | 业务类型 |
+ 识别/确认字段 |
+ 确认后写入 |
+ 匹配规则 |
+
+
+
+
+ | 保单录入 |
+ 车牌、车主、投保人、被保险人、保司、保单号、收费确认时间、生效/到期日、保费合计 |
+ 对应险种台账;清空停保/退保标记 |
+ 车牌/VIN 匹配车辆 |
+
+
+ | 停保 |
+ 保单号、中止时间、恢复时间、新到期日期 |
+ 到期日写新到期日期;标记已停保 |
+ 按保单号全库匹配 |
+
+
+ | 复驶 |
+ 保单号、恢复时间、新到期日期 |
+ 清除停保/退保标记;到期日更新;状态恢复为正常 |
+ 按保单号全库匹配 |
+
+
+ | 退保 |
+ 保单号、退保时间、退保金额 |
+ 标记已退保;清空到期日 |
+ 按保单号全库匹配 |
+
+
+
+
+ 七、停保 / 复驶 / 退保 · 业务规则
+
+
+
+ | 当前记录类型 |
+ 允许操作 |
+
+
+
+ | 新保 / 续保(正常) | 停保、退保 |
+ | 已停保 | 复驶、退保 |
+ | 已退保 | 仅复驶 |
+ | 历史归档 | 只读,不可操作 |
+
+
+
+ - 每次办理须写入
operationLogs:操作时间、操作人、类型、变更备注;停保/复驶/退保须支持上传附件。
+ - 操作历史弹窗:停保/复驶/退保类日志须展示附件预览与下载入口;无附件时展示「无附件」。
+ - 新保录入时若当前险种为已退保且有保单号,须将旧记录归档至
archivedPolicies 后写入新保单。
+
+
+ 八、比价单 · 功能需求
+ 8.1 比价单管理列表
+
+ - 支持按创建时间、车牌筛选。
+ - 看板三项须可点击筛选下方列表:全部比价单、最晚付费临期(批次内含距最晚付费日 ≤ 3 天的购买记录)、最晚付费超期(批次内含已过期购买记录)。数字统计为比价单批次数量,可与时间/车牌筛选叠加。
+ - 列表字段:创建日期、创建人、总车辆(去重)、保险数量(行数)、附件数、已提交采购数量、审批通过数量。
+
+ 8.2 编辑器 · 购买记录
+
+ - 每行关联车辆(车牌或 VIN 至少一项);选车后客户、品牌、车型、年检/交强/商业到期日只读带出。
+ - 可编辑:投保方式、保险类型、最晚付费日期。有交强或商业到期日默认续保,否则新保。
+ - 修改保险类型须清空该行全部报价与最终比价结果。
+ - 每行须录入至少一条报价(保司+金额),提交前须确定唯一「最终比价结果」。
+ - 最晚付费日展示标签:剩余天数 / 临期(≤3天)/ 超期。
+
+ 8.3 保存与提交校验
+
+
+
+ | 动作 |
+ 校验条件 |
| 保存比价单 |
-
- - 至少有一条购买记录
- - 每条记录均已选择车辆(车牌或 VIN)
- - 整单备注已填写
- - 整单已上传至少 1 个附件
-
+ ≥1 条购买记录;每行已选车辆;整单备注非空;整单 ≥1 个附件
|
- 提交采购申请 针对勾选的行 |
+ 提交采购申请 |
-
- - 至少勾选一条购买记录
- - 勾选行均已录入报价,且已设为最终比价结果
- - 勾选行均已填写最晚付费日期
- - 勾选行采购状态为「未提交」「撤回」或「审批驳回」(审批中、审批通过不可再提交)
- - 整单备注、附件要求同「保存比价单」
- - 若比价单尚未保存,系统先提示保存再提交
-
+ ≥1 条勾选行;勾选行均有最终比价结果与最晚付费日;采购状态为未提交/撤回/审批驳回;满足保存条件;未保存须先提示保存
|
- 九、比价单 · 采购审批状态流转
+ 九、采购状态机(购买记录行级)
| 状态 |
- 含义 |
- 可否再次勾选提交 |
- 产生方式 |
+ 可否再次提交 |
+ 写入方 |
+ UI 约束 |
- | 未提交 | 尚未发起采购审批 | 可以 | 新建购买记录默认状态 |
- | 审批中 | 已提交,流程进行中 | 不可以 | 本页点击「提交采购申请」 |
- | 审批通过 | 审批流程已完结且通过 | 不可以 | 审批中心末节点通过后回写 |
- | 撤回 | 流程被申请人或审批人撤回 | 可以 | 审批中心办理后回写 |
- | 审批驳回 | 审批未通过 | 可以 | 审批中心办理后回写 |
+ | 未提交 | 可以 | 默认 | — |
+ | 审批中 | 不可以 | 本页提交 | 多选框禁用 |
+ | 审批通过 | 不可以 | 审批中心回写 | 多选框禁用 |
+ | 撤回 / 审批驳回 | 可以 | 审批中心回写 | — |
-
- - 本页仅展示采购状态与「当前审批人」,不提供审批、撤回、驳回操作(在审批中心办理)
- - 「当前审批人」由审批流自动回写,允许暂时为空,展示为「—」
- - 审批中、审批通过状态的行,列表多选框禁用,防止重复提交
-
+
+ 当前审批人由审批流回写,允许为空;本页不提供审批/撤回/驳回操作。
+
- 十、预警与一键生成规则
- 10.1 台账险种预警(首页 KPI)
-
- - 险种临期预警:任一类险种到期日在未来 30 天内(含五类险种)
- - 核心险种逾期:交强险或商业险已过期
- - 预警列表中,若某车某险种已有比价采购记录,展示「审批中 / 驳回 / 撤回 / 审批完成」标签;一键生成时自动跳过审批中、审批完成记录
-
- 10.2 比价单最晚付费预警(比价单管理看板)
-
- - 临期:距最晚付费日 ≤ 3 天(含当天)
- - 超期:最晚付费日已过
- - 购买记录列表中对最晚付费日展示对应标签(剩余天数 / 临期 / 超期)
-
- 10.3 一键生成比价单
-
- - 从临期或逾期预警中勾选险种,批量带入对应车辆与险种作为购买记录
- - 自动填写备注说明来源,但附件须用户手动补传后方可保存
- - 带入后仍需逐行完成报价、确定最终比价结果、填写最晚付费日,再走保存与提交流程
-
-
- 十一、比价单列表统计口径
+ 十、预警与一键生成
- - 总车辆:本单购买记录中,按车牌或 VIN 去重后的车辆数
- - 保险数量:购买记录总行数(一车多险种计为多行)
- - 已提交采购数量:采购状态为「审批中」或「审批通过」的行数
- - 审批通过数量:采购状态为「审批通过」的行数
+ - 台账 KPI:险种临期 = 任一类险种 30 天内到期;核心逾期 = 交强或商业已过期;临期弹窗展示比价采购状态标签。
+ - 一键生成比价单:从临期/逾期预警勾选险种带入购买记录;自动填备注;附件须用户补传;跳过已有审批中/审批通过的同车同险种记录。
+ - 比价看板:临期阈值 3 天;点击看板卡片即时筛选列表,无需二次点查询。
- 十二、两条业务线的关系
-
- - 比价单引用车辆档案与台账到期日作为只读参考,但不自动回写台账
- - 审批通过后,运营人员须在「保单管理」中另行录入或导入正式保单
- - 同一辆车同一险种,若已在比价流程中(审批中或已通过),临期预警不再重复生成该条购买记录
-
+ 十一、验收标准(研发自测 + 产品验收)
+
+ - 五类险种表格在保单号右侧正确展示保险状态;复驶后显示「正常」。
+ - 停保/复驶/退保办理后,操作历史可预览与下载对应附件。
+ - 停保/复驶/退保 OCR 仅展示约定字段,确认后按保单号匹配台账并正确更新到期日与状态。
+ - 保单 OCR 确认页不展示险种明细表;手工编辑路径仍保留明细能力。
+ - 交强+商业均有效时车辆保险状态正常/临期,与车辆管理交车规则一致。
+ - 比价单保存/提交校验按第八节执行;审批中/通过行不可重复勾选提交。
+ - 比价单管理看板「全部/临期/超期」点击后列表筛选结果正确,可与创建时间/车牌叠加。
+ - 审批通过后比价单数据不自动写入保单台账;须通过保单管理独立录入。
+
),
},
@@ -6677,17 +7598,57 @@ const Component = function () {
-
+
handleCompareMgmtPayAlertFilter('all')}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ handleCompareMgmtPayAlertFilter('all');
+ }
+ }}
+ >
+
+
全部比价单
+
显示符合筛选条件的全部批次
+
+
{compareMgmtPayAlerts.total}
+
+
handleCompareMgmtPayAlertFilter('warning')}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ handleCompareMgmtPayAlertFilter('warning');
+ }
+ }}
+ >
最晚付费临期
-
距最晚付费日 ≤ {LATEST_PAY_WARN_DAYS} 天
+
含临期购买记录的比价单(≤ {LATEST_PAY_WARN_DAYS} 天)
{compareMgmtPayAlerts.warning}
-
+
handleCompareMgmtPayAlertFilter('overdue')}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ handleCompareMgmtPayAlertFilter('overdue');
+ }
+ }}
+ >
最晚付费超期
-
已超过最晚付费日
+
含超期购买记录的比价单
{compareMgmtPayAlerts.overdue}
@@ -7271,7 +8232,9 @@ const Component = function () {
className="lc-policy-recogn-modal"
title={
policyRecognPhase === 'results'
- ? (policyRecognEntry === 'import' ? '批量导入 · 确认' : '保单批量识别 · 确认')
+ ? (policyRecognEntry === 'import'
+ ? '批量导入 · 确认'
+ : `保单批量识别 · 确认${activePolicyRecognMode !== 'policy' ? `(${POLICY_RECOGN_MODE_LABEL[activePolicyRecognMode] || activePolicyRecognMode})` : ''}`)
: (policyRecognEntry === 'import' ? '批量导入' : '保单批量识别')
}
open={policyRecognOpen}
@@ -7449,8 +8412,12 @@ const Component = function () {
)}
@@ -7490,12 +8457,19 @@ const Component = function () {
-
确认识别内容
+
+ {activePolicyRecognMode === 'suspend' ? '确认识别内容(停保)'
+ : activePolicyRecognMode === 'resume' ? '确认识别内容(复驶)'
+ : activePolicyRecognMode === 'cancel' ? '确认识别内容(退保)'
+ : '确认识别内容'}
+
{policyRecognActiveResultId ? (
renderPolicyDetailForm(policyRecognConfirmDraft, setPolicyRecognConfirmDraft, {
showBizType: false,
recognConfirmMode: true,
+ bizRecognMode: activePolicyRecognMode,
onPlateChange: handleRecognConfirmPlateChange,
+ recognResult: policyRecognActiveResult,
})
) : (