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 ? ( 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('业务类型', ( setDraft((p) => ({ ...p, company: v || '' }))} style={{ width: '100%' }} - options={INSURANCE_MGMT_COMPANIES.map((c) => ({ label: c, value: c }))} + placeholder={isPolicyRecogn ? '识别未完全命中时请手动选择' : undefined} + options={companySelectOptions} /> - ))} + )) : null} {renderFilterField(fieldLabel('保单号', 'policyNo'), ( setDraft((p) => ({ ...p, policyNo: e.target.value }))} /> ))} {!recognConfirmMode ? renderFilterField('批单号', ( setDraft((p) => ({ ...p, endorsementNo: e.target.value }))} placeholder="停保/复驶/退保批单号" /> )) : null} - {renderFilterField(fieldLabel('付款时间', 'payTime'), ( - setDraft((p) => ({ ...p, payTime: e.target.value }))} placeholder="如 2026-06-01 17:42:10" /> - ))} + {!isEndorsementRecogn ? renderFilterField( + isPolicyRecogn ? fieldLabel('收费确认时间', 'payTime') : fieldLabel('付款时间', 'payTime'), + ( + 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('签单日期', ( setDraft((p) => ({ ...p, signDate: ds || '' }))} /> )) : null} - {renderFilterField(fieldLabel('生效日期', 'startDate'), ( - setDraft((p) => ({ ...p, startDate: ds || '' }))} - /> - ))} - {renderFilterField(fieldLabel('到期日期', 'endDate'), ( - setDraft((p) => ({ ...p, endDate: ds || '' }))} - /> - ))} + {isSuspendRecogn ? ( + <> + {renderFilterField(fieldLabel('中止时间', 'suspendTime'), ( + setDraft((p) => ({ + ...p, + suspendTime: ds || '', + startDate: ds || p.startDate, + }))} + /> + ))} + {renderFilterField('恢复时间', ( + setDraft((p) => ({ + ...p, + resumeTime: ds || '', + reinstateDate: ds || p.reinstateDate, + }))} + /> + ))} + {renderFilterField(fieldLabel('新到期日期', 'newEndDate'), ( + setDraft((p) => ({ + ...p, + newEndDate: ds || '', + endDate: ds || p.endDate, + }))} + /> + ))} + + ) : isResumeRecogn ? ( + <> + {renderFilterField(fieldLabel('恢复时间', 'resumeTime'), ( + setDraft((p) => ({ + ...p, + resumeTime: ds || '', + reinstateDate: ds || p.reinstateDate, + startDate: ds || p.startDate, + }))} + /> + ))} + {renderFilterField(fieldLabel('新到期日期', 'newEndDate'), ( + setDraft((p) => ({ + ...p, + newEndDate: ds || '', + endDate: ds || p.endDate, + }))} + /> + ))} + + ) : isCancelRecogn ? ( + <> + {renderFilterField(fieldLabel('退保时间', 'cancelTime'), ( + setDraft((p) => ({ + ...p, + cancelTime: ds || '', + startDate: ds || p.startDate, + endDate: ds || p.endDate, + }))} + /> + ))} + {renderFilterField(fieldLabel('退保金额', 'premium'), ( + setDraft((p) => ({ ...p, premium: sanitizePremiumInput(e.target.value) }))} + onBlur={(e) => setDraft((p) => ({ ...p, premium: normalizeRecognPremiumAmount(e.target.value) }))} + placeholder="0.00" + /> + ))} + + ) : ( + <> + {renderFilterField(fieldLabel('生效日期', 'startDate'), ( + setDraft((p) => ({ ...p, startDate: ds || '' }))} + /> + ))} + {renderFilterField(fieldLabel('到期日期', 'endDate'), ( + setDraft((p) => ({ ...p, endDate: ds || '' }))} + /> + ))} + + )} {!recognConfirmMode && (draft.bizType === 'suspend' || draft.bizType === 'resume') ? renderFilterField('复驶日期', ( setDraft((p) => ({ ...p, reinstateDate: ds || '' }))} /> )) : null} - {renderFilterField( + {!isEndorsementRecogn ? renderFilterField( recognConfirmMode || policyEntryMode ? fieldLabel('保险费合计', 'premium') : (draft.bizType === 'cancel' ? '退费金额(元)' : '保险费合计'), ( - setDraft((p) => ({ ...p, premium: e.target.value }))} placeholder="元" /> + setDraft((p) => ({ ...p, premium: sanitizePremiumInput(e.target.value) }))} + onBlur={(e) => setDraft((p) => ({ ...p, premium: normalizeRecognPremiumAmount(e.target.value) }))} + placeholder="0.00" + /> ) - )} - {!recognConfirmMode ? renderFilterField('投保人', ( + ) : null} + {!recognConfirmMode && !isPolicyRecogn ? renderFilterField('投保人', ( setDraft((p) => ({ ...p, applicant: e.target.value }))} /> )) : null} - {!recognConfirmMode ? renderFilterField('被保险人', ( + {!recognConfirmMode && !isPolicyRecogn ? renderFilterField('被保险人', ( setDraft((p) => ({ ...p, insured: e.target.value }))} /> )) : null} + {!isEndorsementRecogn && !isPolicyRecogn ? (
{!recognConfirmMode ? (
保单项目/责任限额
@@ -4636,6 +5410,19 @@ const Component = function () {
)}
+ ) : 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、邮件与线下批单中,台账不全、续保临期易漏、停保退保无留痕、采购比价难追溯、交车合规无法实时校验。 + 本模块需在同一页面承载两条相互独立的业务线,技术实现上须避免强耦合与自动双向同步。 +

-

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

-

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

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

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

-

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

- - -

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

-

4.1 新保 / 续保录入

- -

4.2 停保 / 复驶 / 退保

- - -

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

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

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

- - -

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

- - -

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

+

二、模块边界与外部依赖

- - + + + + + + + + + + + + + + + + + + + + + + +
业务动作须满足的条件依赖模块交互方式产品要求
车辆管理读取车辆档案;输出保险状态交车合规判定口径须与车辆管理「保险状态」字段一致,异常车辆禁止交车
审批中心发起采购审批;回写采购状态本页只读展示状态与当前审批人,不提供审批办理能力
OCR 服务保单/批单识别识别结果须人工确认后落库;不同业务类型识别字段不同(见第六节)
+

+ 关键约束:比价单引用车辆档案与台账到期日作只读参考;审批通过后运营人员须在保单管理侧另行录入正式保单。 +

+ +

三、核心数据实体(需持久化)

+ + +

四、保单管理 · 功能需求

+

4.1 列表与筛选

+ +

4.2 车辆保险档案(管理弹窗)

+ +

4.3 录入通道

+ + +

五、险种状态计算规则(须服务端统一实现)

+

单险种展示状态(列表副标签),按优先级自上而下命中即停止:

+
    +
  1. 已退保 → 已退保
  2. +
  3. 无保单号 → 未购买
  4. +
  5. 有保单号 + 停保标记 → 已停保
  6. +
  7. 有保单号无到期日 → 未购买
  8. +
  9. 到期日 ≤ 今天 → 已到期
  10. +
  11. 到期日 ≤ 今天+30 天 → 临期
  12. +
  13. 其余 → 正常
  14. +
+

车辆级保险状态(交车联动)

+ + +

六、OCR / 批单识别 · 字段与落库规则

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
业务类型识别/确认字段确认后写入匹配规则
保单录入车牌、车主、投保人、被保险人、保司、保单号、收费确认时间、生效/到期日、保费合计对应险种台账;清空停保/退保标记车牌/VIN 匹配车辆
停保保单号、中止时间、恢复时间、新到期日期到期日写新到期日期;标记已停保按保单号全库匹配
复驶保单号、恢复时间、新到期日期清除停保/退保标记;到期日更新;状态恢复为正常按保单号全库匹配
退保保单号、退保时间、退保金额标记已退保;清空到期日按保单号全库匹配
+ +

七、停保 / 复驶 / 退保 · 业务规则

+ + + + + + + + + + + + + +
当前记录类型允许操作
新保 / 续保(正常)停保、退保
已停保复驶、退保
已退保仅复驶
历史归档只读,不可操作
+ + +

八、比价单 · 功能需求

+

8.1 比价单管理列表

+ +

8.2 编辑器 · 购买记录

+ +

8.3 保存与提交校验

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

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

+

九、采购状态机(购买记录行级)

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

+ 当前审批人由审批流回写,允许为空;本页不提供审批/撤回/驳回操作。 +

-

十、预警与一键生成规则

-

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

- -

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

- -

10.3 一键生成比价单

- - -

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

+

十、预警与一键生成

-

十二、两条业务线的关系

- +

十一、验收标准(研发自测 + 产品验收)

+
    +
  1. 五类险种表格在保单号右侧正确展示保险状态;复驶后显示「正常」。
  2. +
  3. 停保/复驶/退保办理后,操作历史可预览与下载对应附件。
  4. +
  5. 停保/复驶/退保 OCR 仅展示约定字段,确认后按保单号匹配台账并正确更新到期日与状态。
  6. +
  7. 保单 OCR 确认页不展示险种明细表;手工编辑路径仍保留明细能力。
  8. +
  9. 交强+商业均有效时车辆保险状态正常/临期,与车辆管理交车规则一致。
  10. +
  11. 比价单保存/提交校验按第八节执行;审批中/通过行不可重复勾选提交。
  12. +
  13. 比价单管理看板「全部/临期/超期」点击后列表筛选结果正确,可与创建时间/车牌叠加。
  14. +
  15. 审批通过后比价单数据不自动写入保单台账;须通过保单管理独立录入。
  16. +
), }, @@ -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, }) ) : (