594 lines
23 KiB
JavaScript
594 lines
23 KiB
JavaScript
const fs = require('node:fs');
|
||
|
||
function valueOf(source, paths) {
|
||
for (const path of paths) {
|
||
const value = path.split('.').reduce((acc, key) => (acc && acc[key] !== undefined ? acc[key] : undefined), source);
|
||
if (value !== undefined && value !== null && String(value).trim() !== '') return value;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function normalizeToken(value) {
|
||
return String(value || '').normalize('NFKC').replace(/[^A-Za-z0-9]/g, '').toUpperCase();
|
||
}
|
||
|
||
function normalizeName(value) {
|
||
return String(value || '').normalize('NFKC').replace(/\s+/g, '').trim();
|
||
}
|
||
|
||
function normalizePlanNo(value) {
|
||
return String(value || '').trim().toLowerCase();
|
||
}
|
||
|
||
function getProductType(planNo) {
|
||
const code = normalizePlanNo(planNo);
|
||
if (code.startsWith('cxwypro')) return 'CXWY_PRO';
|
||
if (code.startsWith('cxwy')) return 'CXWY';
|
||
if (code.startsWith('pqxf')) return 'PQXF';
|
||
if (code.startsWith('ltwy')) return 'LTWY';
|
||
if (code.startsWith('yb')) return 'YB';
|
||
return 'UNKNOWN';
|
||
}
|
||
|
||
function needCommercialPolicy(productType) {
|
||
return ['CXWY', 'CXWY_PRO', 'PQXF'].includes(productType);
|
||
}
|
||
|
||
function hasValue(value) {
|
||
if (Array.isArray(value)) return value.some(hasValue);
|
||
return value !== undefined && value !== null && String(value).trim() !== '';
|
||
}
|
||
|
||
function normalizeDateString(value) {
|
||
const text = String(value || '').normalize('NFKC').trim();
|
||
if (!text) return '';
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return text;
|
||
|
||
const direct = text.match(/(\d{4})[年./\-\s](\d{1,2})[月./\-\s](\d{1,2})日?/);
|
||
if (direct) {
|
||
return [
|
||
String(direct[1]).padStart(4, '0'),
|
||
String(direct[2]).padStart(2, '0'),
|
||
String(direct[3]).padStart(2, '0'),
|
||
].join('-');
|
||
}
|
||
|
||
const compact = text.match(/^(\d{4})(\d{2})(\d{2})$/);
|
||
if (compact) {
|
||
return `${compact[1]}-${compact[2]}-${compact[3]}`;
|
||
}
|
||
|
||
const timestamp = Number(text);
|
||
if (Number.isFinite(timestamp) && String(Math.trunc(timestamp)).length >= 10) {
|
||
const date = new Date(String(Math.trunc(timestamp)).length === 10 ? timestamp * 1000 : timestamp);
|
||
if (!Number.isNaN(date.getTime())) {
|
||
const year = String(date.getFullYear()).padStart(4, '0');
|
||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||
const day = String(date.getDate()).padStart(2, '0');
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
function normalizeUsageNature(value) {
|
||
const text = String(value || '').normalize('NFKC').replace(/\s+/g, '');
|
||
if (!text) return '';
|
||
if (/家庭自用/.test(text)) return '家庭自用汽车';
|
||
if (/非营运/.test(text)) return '非营运';
|
||
if (/非营业/.test(text)) return '非营业';
|
||
if (/营业/.test(text)) return '营业';
|
||
if (/营运/.test(text)) return '营运';
|
||
if (/预约出租/.test(text)) return '预约出租客运';
|
||
if (/网约/.test(text)) return '网约车';
|
||
if (/出租/.test(text)) return '出租';
|
||
if (/租赁/.test(text)) return '租赁';
|
||
if (/客运/.test(text)) return '客运';
|
||
if (/货运/.test(text)) return '货运';
|
||
return text;
|
||
}
|
||
|
||
function isOperatingUsageNature(value) {
|
||
const text = String(value || '').normalize('NFKC').replace(/\s+/g, '');
|
||
if (!text) return false;
|
||
if (/家庭自用/.test(text) || /非营运/.test(text) || /非营业/.test(text)) return false;
|
||
return /营运|出租|租赁|网约|预约出租|客运|货运|营业/.test(text);
|
||
}
|
||
|
||
function isFamilySelfUseUsageNature(value) {
|
||
const text = String(value || '').normalize('NFKC').replace(/\s+/g, '');
|
||
return /家庭自用|非营运|非营业/.test(text);
|
||
}
|
||
|
||
function mismatchCount(a, b) {
|
||
if (!a || !b || a.length !== b.length) return Number.POSITIVE_INFINITY;
|
||
let count = 0;
|
||
for (let i = 0; i < a.length; i += 1) {
|
||
if (a[i] !== b[i]) count += 1;
|
||
}
|
||
return count;
|
||
}
|
||
|
||
function isLikelyVinOcrError(expectedVin, actualVin) {
|
||
if (!expectedVin || !actualVin || expectedVin.length !== 17 || actualVin.length !== 17) {
|
||
return false;
|
||
}
|
||
const diff = mismatchCount(expectedVin, actualVin);
|
||
if (diff <= 1) return true;
|
||
if (diff === 2) {
|
||
const positions = [];
|
||
for (let i = 0; i < expectedVin.length; i += 1) {
|
||
if (expectedVin[i] !== actualVin[i]) positions.push(i);
|
||
}
|
||
return (
|
||
positions[1] === positions[0] + 1 &&
|
||
expectedVin[positions[0]] === actualVin[positions[1]] &&
|
||
expectedVin[positions[1]] === actualVin[positions[0]]
|
||
);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function isKnownAvatrTa60OcrSwap(expectedVin, actualVin) {
|
||
if (!expectedVin || !actualVin || expectedVin.length !== 17 || actualVin.length !== 17) {
|
||
return false;
|
||
}
|
||
if (!expectedVin.includes('TA60') || !actualVin.includes('TA06')) {
|
||
return false;
|
||
}
|
||
return actualVin.replace('TA06', 'TA60') === expectedVin;
|
||
}
|
||
|
||
function canCorrectByConfusion(expected, actual, groups) {
|
||
if (!expected || !actual || expected.length !== actual.length) {
|
||
return false;
|
||
}
|
||
let hasDiff = false;
|
||
for (let i = 0; i < expected.length; i += 1) {
|
||
if (expected[i] === actual[i]) continue;
|
||
hasDiff = true;
|
||
const inSameGroup = groups.some(group => group.includes(expected[i]) && group.includes(actual[i]));
|
||
if (!inSameGroup) return false;
|
||
}
|
||
return hasDiff;
|
||
}
|
||
|
||
function isPolicyNoOq0Confusion(expectedPolicyNo, actualPolicyNo) {
|
||
return canCorrectByConfusion(
|
||
normalizeToken(expectedPolicyNo),
|
||
normalizeToken(actualPolicyNo),
|
||
[['O', 'Q', '0']]
|
||
);
|
||
}
|
||
|
||
function isDateWithinRange(date, start, end) {
|
||
if (!date || !start || !end) return false;
|
||
return date >= start && date <= end;
|
||
}
|
||
|
||
function isRangeWithinRange(innerStart, innerEnd, outerStart, outerEnd) {
|
||
if (!innerStart && !innerEnd) return false;
|
||
if (!outerStart || !outerEnd) return false;
|
||
if (innerStart && innerStart < outerStart) return false;
|
||
if (innerEnd && innerEnd > outerEnd) return false;
|
||
return true;
|
||
}
|
||
|
||
function collectFirstValue(source, paths) {
|
||
for (const path of paths) {
|
||
const raw = valueOf(source, [path]);
|
||
const normalized = normalizeDateString(raw);
|
||
if (normalized) return normalized;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function getOrderPurchaseWindow(order) {
|
||
const singleDate = collectFirstValue({ order }, [
|
||
'order.purchaseTime',
|
||
'order.purchaseDate',
|
||
'order.buyTime',
|
||
'order.buyDate',
|
||
'order.commercialInsuranceDate',
|
||
'order.orderTime',
|
||
'order.createTime',
|
||
]);
|
||
|
||
const purchaseStartDate = collectFirstValue({ order }, [
|
||
'order.purchaseStartDate',
|
||
'order.purchaseStartTime',
|
||
'order.buyStartDate',
|
||
'order.buyStartTime',
|
||
'order.commercialInsuranceStartDate',
|
||
'order.commercialInsuranceStartTime',
|
||
'order.purchaseRange.startDate',
|
||
'order.purchaseRange.startTime',
|
||
]);
|
||
|
||
const purchaseEndDate = collectFirstValue({ order }, [
|
||
'order.purchaseEndDate',
|
||
'order.purchaseEndTime',
|
||
'order.buyEndDate',
|
||
'order.buyEndTime',
|
||
'order.commercialInsuranceEndDate',
|
||
'order.commercialInsuranceEndTime',
|
||
'order.purchaseRange.endDate',
|
||
'order.purchaseRange.endTime',
|
||
]);
|
||
|
||
if (purchaseStartDate || purchaseEndDate) {
|
||
return {
|
||
purchaseDate: singleDate,
|
||
purchaseStartDate: purchaseStartDate || singleDate,
|
||
purchaseEndDate: purchaseEndDate || singleDate,
|
||
purchaseLabel: purchaseStartDate || purchaseEndDate ? '购买时间范围' : '购买时间',
|
||
};
|
||
}
|
||
|
||
return {
|
||
purchaseDate: singleDate,
|
||
purchaseStartDate: singleDate,
|
||
purchaseEndDate: singleDate,
|
||
purchaseLabel: '购买时间',
|
||
};
|
||
}
|
||
|
||
function normalizeOcr(input) {
|
||
const ocr = input.commercialPolicyOcr || {};
|
||
const nestedPolicy = ocr.policy || {};
|
||
const nestedVehicle = ocr.vehicle || {};
|
||
const nestedIdentity = ocr.identity || {};
|
||
const nameCandidates = [];
|
||
|
||
if (Array.isArray(ocr.nameCandidates)) {
|
||
for (const item of ocr.nameCandidates) {
|
||
if (item && typeof item === 'object' && item.name) {
|
||
nameCandidates.push({ label: String(item.label || '姓名候选'), name: String(item.name) });
|
||
} else if (typeof item === 'string') {
|
||
nameCandidates.push({ label: '姓名候选', name: item });
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const [label, value] of [
|
||
['被保险人', nestedIdentity.insuredName],
|
||
['车主', nestedIdentity.ownerName],
|
||
['投保人', nestedIdentity.applicantName],
|
||
['姓名', ocr.name],
|
||
]) {
|
||
if (hasValue(value)) nameCandidates.push({ label, name: String(value) });
|
||
}
|
||
|
||
return {
|
||
policyNo: String(valueOf({ ocr, nestedPolicy }, ['ocr.policyNo', 'nestedPolicy.policyNo']) || ''),
|
||
vin: String(valueOf({ ocr, nestedVehicle }, ['ocr.vin', 'nestedVehicle.vin']) || ''),
|
||
nameCandidates,
|
||
certificateNo: String(valueOf({ ocr, nestedIdentity }, ['ocr.certificateNo', 'nestedIdentity.certificateNo']) || ''),
|
||
certificateNoMasked: Boolean(ocr.certificateNoMasked || nestedIdentity.certificateNoMasked),
|
||
usageNature: normalizeUsageNature(valueOf({ ocr, nestedPolicy, nestedVehicle }, [
|
||
'ocr.usageNature',
|
||
'ocr.useNature',
|
||
'ocr.vehicleUsageNature',
|
||
'ocr.usageNatureText',
|
||
'nestedPolicy.usageNature',
|
||
'nestedPolicy.useNature',
|
||
'nestedVehicle.usageNature',
|
||
'nestedVehicle.useNature',
|
||
])),
|
||
startDate: normalizeDateString(valueOf({ ocr, nestedPolicy }, [
|
||
'ocr.startDate',
|
||
'ocr.insuranceStartDate',
|
||
'ocr.policyStartDate',
|
||
'nestedPolicy.startDate',
|
||
'nestedPolicy.insuranceStartDate',
|
||
'nestedPolicy.policyStartDate',
|
||
])),
|
||
endDate: normalizeDateString(valueOf({ ocr, nestedPolicy }, [
|
||
'ocr.endDate',
|
||
'ocr.insuranceEndDate',
|
||
'ocr.policyEndDate',
|
||
'nestedPolicy.endDate',
|
||
'nestedPolicy.insuranceEndDate',
|
||
'nestedPolicy.policyEndDate',
|
||
])),
|
||
hasVehicleDamageCoverage: valueOf({ ocr, nestedPolicy }, ['ocr.hasVehicleDamageCoverage', 'nestedPolicy.hasVehicleDamageCoverage']),
|
||
confidence: typeof ocr.confidence === 'number' ? ocr.confidence : undefined,
|
||
warnings: Array.isArray(ocr.warnings) ? ocr.warnings : [],
|
||
};
|
||
}
|
||
|
||
function isCompleteIdCard(value) {
|
||
return /^\d{17}[\dX]$/i.test(String(value || '').trim());
|
||
}
|
||
|
||
function maskedIdMatches(masked, actual) {
|
||
const mask = String(masked || '').toUpperCase();
|
||
const id = String(actual || '').toUpperCase();
|
||
if (!mask || !id) return false;
|
||
if (isCompleteIdCard(mask)) return mask === id;
|
||
let idIndex = 0;
|
||
for (const char of mask) {
|
||
if (char === '*' || char === 'X') {
|
||
idIndex += 1;
|
||
continue;
|
||
}
|
||
while (idIndex < id.length && id[idIndex] !== char) idIndex += 1;
|
||
if (idIndex >= id.length) return false;
|
||
idIndex += 1;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function createAudit(input) {
|
||
const order = input.order || {};
|
||
const attachmentsProvided = input.attachments && typeof input.attachments === 'object';
|
||
const attachments = input.attachments || {};
|
||
const ocrProvided = input.commercialPolicyOcr && typeof input.commercialPolicyOcr === 'object';
|
||
const ocr = normalizeOcr(input);
|
||
const productType = getProductType(order.planNo);
|
||
const purchaseWindow = getOrderPurchaseWindow(order);
|
||
const policyStartDate = normalizeDateString(ocr.startDate);
|
||
const policyEndDate = normalizeDateString(ocr.endDate);
|
||
const normalizedUsageNature = normalizeUsageNature(ocr.usageNature);
|
||
const fieldChecks = [];
|
||
const reasons = [];
|
||
const riskFlags = [];
|
||
let reject = false;
|
||
let manual = false;
|
||
|
||
function addCheck(field, expected, actual, result, message, extra = {}) {
|
||
fieldChecks.push({
|
||
field,
|
||
expected: expected || '',
|
||
actual: actual || '',
|
||
result,
|
||
message,
|
||
...extra,
|
||
});
|
||
if (result === 'REJECT') reject = true;
|
||
if (result === 'REVIEW') manual = true;
|
||
}
|
||
|
||
function addReason(result, code, message) {
|
||
reasons.push({ code, message });
|
||
if (result === 'REJECT') reject = true;
|
||
if (result === 'REVIEW') manual = true;
|
||
}
|
||
|
||
for (const field of ['businessNo', 'orderNum', 'planNo', 'vin', 'ownerName']) {
|
||
if (!hasValue(order[field])) {
|
||
addCheck(field, '必填', '', 'REVIEW', `订单缺少${field},需要人工确认`);
|
||
}
|
||
}
|
||
|
||
if (hasValue(order.status) && order.status !== 'CONFIRMED') {
|
||
addCheck('status', 'CONFIRMED', order.status, 'REVIEW', '阿维塔子单状态不是CONFIRMED');
|
||
} else if (hasValue(order.status)) {
|
||
addCheck('status', 'CONFIRMED', order.status, 'PASS', '阿维塔子单状态正常');
|
||
}
|
||
|
||
if (hasValue(order.mainStatus) && order.mainStatus !== 'WAITEXAMINE') {
|
||
addCheck('mainStatus', 'WAITEXAMINE', order.mainStatus, 'REVIEW', '主订单状态不是WAITEXAMINE');
|
||
} else if (hasValue(order.mainStatus)) {
|
||
addCheck('mainStatus', 'WAITEXAMINE', order.mainStatus, 'PASS', '主订单状态正常');
|
||
}
|
||
|
||
if (needCommercialPolicy(productType)) {
|
||
if (attachmentsProvided) {
|
||
if (hasValue(attachments.commercialInsuranceUrl)) {
|
||
addCheck('commercialInsuranceUrl', '必传', '已传', 'PASS', '商业险保单附件已上传');
|
||
} else {
|
||
addCheck('commercialInsuranceUrl', '必传', '', 'REJECT', '产品要求商业险保单附件,但未上传');
|
||
}
|
||
} else {
|
||
addCheck('commercialInsuranceUrl', '必传', '', 'REVIEW', '未提供附件字段,无法确认商业险保单附件');
|
||
}
|
||
if (!ocrProvided) {
|
||
addReason('REVIEW', 'MISSING_POLICY_OCR', '缺少商业险/车损险保单OCR结构化结果');
|
||
}
|
||
}
|
||
|
||
if (productType === 'LTWY' && attachmentsProvided) {
|
||
for (const field of ['leftFrontDot', 'rightFrontDot', 'leftRearDot', 'rightRearDot']) {
|
||
addCheck(field, '必传', hasValue(attachments[field]) ? '已传' : '', hasValue(attachments[field]) ? 'PASS' : 'REJECT', `${field}轮胎DOT附件校验`);
|
||
}
|
||
}
|
||
|
||
if (productType === 'YB') {
|
||
addCheck('warrantyPeriod', '必填', order.warrantyPeriod || '', hasValue(order.warrantyPeriod) ? 'PASS' : 'REJECT', '整车延保产品需填写原厂整车质保期');
|
||
}
|
||
|
||
if (needCommercialPolicy(productType) || ocrProvided) {
|
||
if (!hasValue(ocr.policyNo)) {
|
||
addCheck('policyNo', 'OCR应识别', '', 'REVIEW', '未识别到商业险保单号', {
|
||
needsMultimodalReview: true,
|
||
multimodalReason: 'policyNo_missing',
|
||
});
|
||
} else if (hasValue(order.commercialInsuranceNo)) {
|
||
const expectedPolicyNo = normalizeToken(order.commercialInsuranceNo);
|
||
const actualPolicyNo = normalizeToken(ocr.policyNo);
|
||
const matched = expectedPolicyNo === actualPolicyNo;
|
||
if (matched) {
|
||
addCheck('policyNo', order.commercialInsuranceNo, ocr.policyNo, 'PASS', '保单号一致');
|
||
} else if (isPolicyNoOq0Confusion(expectedPolicyNo, actualPolicyNo)) {
|
||
addCheck('policyNo', order.commercialInsuranceNo, ocr.policyNo, 'PASS', '保单号存在O/Q/0常见OCR混淆,按订单商业险保单号校正后一致', {
|
||
correctedValue: order.commercialInsuranceNo,
|
||
correctionRule: 'POLICY_O_Q_0_CONFUSION',
|
||
});
|
||
riskFlags.push({ level: 'LOW', code: 'POLICY_O_Q_0_OCR_CORRECTED', message: '保单号O/Q/0 OCR误识别已按订单保单号校正' });
|
||
} else {
|
||
addCheck('policyNo', order.commercialInsuranceNo, ocr.policyNo, 'REVIEW', 'OCR保单号与订单商业险保单号不一致,需多模态复核保单原图', {
|
||
needsMultimodalReview: true,
|
||
multimodalReason: 'policyNo_mismatch',
|
||
});
|
||
riskFlags.push({ level: 'MEDIUM', code: 'POLICY_NO_MISMATCH_NEEDS_MULTIMODAL', message: 'OCR保单号与订单商业险保单号不一致,需多模态复核' });
|
||
}
|
||
} else {
|
||
addCheck('policyNo', '订单可为空', ocr.policyNo, 'PASS', 'OCR已识别保单号,可用于回填');
|
||
}
|
||
|
||
if (!hasValue(ocr.vin)) {
|
||
addCheck('vin', order.vin || '订单VIN', '', 'REVIEW', '未识别到保单VIN', {
|
||
needsMultimodalReview: true,
|
||
multimodalReason: 'vin_missing',
|
||
});
|
||
} else {
|
||
const expectedVin = normalizeToken(order.vin);
|
||
const actualVin = normalizeToken(ocr.vin);
|
||
if (expectedVin && actualVin && expectedVin !== actualVin) {
|
||
if (isKnownAvatrTa60OcrSwap(expectedVin, actualVin)) {
|
||
addCheck('vin', order.vin, ocr.vin, 'PASS', '阿维塔车型VIN存在TA60被OCR识别为TA06的已知误识别,按订单VIN校正后一致', {
|
||
correctedValue: order.vin,
|
||
correctionRule: 'AVATR_TA06_TO_TA60',
|
||
});
|
||
riskFlags.push({ level: 'LOW', code: 'VIN_TA60_TA06_OCR_CORRECTED', message: '阿维塔VIN TA60/TA06 OCR误识别已按订单VIN校正' });
|
||
} else if (isLikelyVinOcrError(expectedVin, actualVin)) {
|
||
addCheck('vin', order.vin, ocr.vin, 'REVIEW', '保单VIN与订单VIN存在轻微差异,疑似OCR识别错误,需多模态复核保单原图', {
|
||
needsMultimodalReview: true,
|
||
multimodalReason: 'vin_suspected_ocr_error',
|
||
});
|
||
riskFlags.push({ level: 'MEDIUM', code: 'VIN_OCR_SUSPECT_NEEDS_MULTIMODAL', message: '保单VIN与订单VIN存在轻微差异,需多模态复核' });
|
||
} else {
|
||
addCheck('vin', order.vin, ocr.vin, 'REJECT', '保单VIN与订单VIN不一致');
|
||
riskFlags.push({ level: 'HIGH', code: 'VIN_MISMATCH', message: '保单VIN与订单VIN不一致' });
|
||
}
|
||
} else {
|
||
addCheck('vin', order.vin, ocr.vin, 'PASS', 'VIN一致');
|
||
}
|
||
}
|
||
|
||
const ownerName = normalizeName(order.ownerName);
|
||
const matchedName = ocr.nameCandidates.find(item => normalizeName(item.name) === ownerName);
|
||
if (!ownerName) {
|
||
addCheck('ownerName', '订单车主姓名', '', 'REVIEW', '订单车主姓名缺失');
|
||
} else if (matchedName) {
|
||
addCheck('ownerName', order.ownerName, `${matchedName.label}:${matchedName.name}`, 'PASS', '保单姓名候选与订单车主一致');
|
||
} else if (ocr.nameCandidates.length > 0) {
|
||
addCheck('ownerName', order.ownerName, ocr.nameCandidates.map(item => `${item.label}:${item.name}`).join(', '), 'REVIEW', '保单姓名候选未匹配订单车主');
|
||
riskFlags.push({ level: 'MEDIUM', code: 'NAME_NOT_MATCHED', message: '保单姓名候选未匹配订单车主' });
|
||
} else {
|
||
addCheck('ownerName', order.ownerName, '', 'REVIEW', '未识别到被保险人/车主/投保人姓名');
|
||
}
|
||
|
||
if (hasValue(ocr.certificateNo) && hasValue(order.idCard)) {
|
||
if (ocr.certificateNoMasked) {
|
||
const matched = maskedIdMatches(ocr.certificateNo, order.idCard);
|
||
addCheck('idCard', order.idCard, ocr.certificateNo, matched ? 'PASS' : 'REVIEW', matched ? '脱敏证件号可见位匹配' : '脱敏证件号无法确认匹配');
|
||
} else if (isCompleteIdCard(ocr.certificateNo)) {
|
||
const matched = normalizeToken(ocr.certificateNo) === normalizeToken(order.idCard);
|
||
addCheck('idCard', order.idCard, ocr.certificateNo, matched ? 'PASS' : 'REJECT', matched ? '完整证件号一致' : '完整证件号与订单身份证号不一致');
|
||
}
|
||
} else {
|
||
addCheck('idCard', order.idCard || '可选', ocr.certificateNo || '', 'SKIP', '保单证件号不是必填项,缺失不影响核心审核');
|
||
}
|
||
|
||
if (!hasValue(ocr.usageNature)) {
|
||
addCheck('usageNature', '家庭自用汽车', '', 'REVIEW', '未识别到保单使用性质,需多模态复核保单原图', {
|
||
needsMultimodalReview: true,
|
||
multimodalReason: 'usageNature_missing',
|
||
});
|
||
} else if (isFamilySelfUseUsageNature(ocr.usageNature)) {
|
||
addCheck('usageNature', '家庭自用汽车/非营运/非营业', ocr.usageNature, 'PASS', '保单使用性质为家庭自用或非营运/非营业,符合承保要求');
|
||
} else if (isOperatingUsageNature(ocr.usageNature)) {
|
||
addCheck('usageNature', '家庭自用汽车', ocr.usageNature, 'REJECT', '保单使用性质为营运/营业类,不符合承保要求');
|
||
riskFlags.push({ level: 'HIGH', code: 'OPERATING_USAGE_NATURE', message: '保单使用性质不是家庭自用汽车' });
|
||
} else {
|
||
addCheck('usageNature', '家庭自用汽车', ocr.usageNature, 'REJECT', '保单使用性质不是家庭自用汽车,不符合承保要求');
|
||
riskFlags.push({ level: 'HIGH', code: 'NON_FAMILY_USAGE_NATURE', message: '保单使用性质不是家庭自用汽车' });
|
||
}
|
||
|
||
if (!policyStartDate || !policyEndDate) {
|
||
addCheck('insurancePeriod', '应识别保险起止日期', `${ocr.startDate || ''} - ${ocr.endDate || ''}`, 'REVIEW', '未完整识别保单保险期间,需多模态复核保单原图', {
|
||
needsMultimodalReview: true,
|
||
multimodalReason: 'insurancePeriod_missing',
|
||
});
|
||
} else if (!purchaseWindow.purchaseStartDate && !purchaseWindow.purchaseEndDate) {
|
||
addCheck('insurancePeriod', '购买时间应在保险期间内', `${policyStartDate} - ${policyEndDate}`, 'REVIEW', '订单缺少购买时间,无法自动校验保险期间');
|
||
} else if (purchaseWindow.purchaseStartDate === purchaseWindow.purchaseEndDate) {
|
||
const purchaseDate = purchaseWindow.purchaseStartDate;
|
||
const matched = isDateWithinRange(purchaseDate, policyStartDate, policyEndDate);
|
||
addCheck(
|
||
'insurancePeriod',
|
||
`应覆盖购买时间 ${purchaseDate}`,
|
||
`${policyStartDate} - ${policyEndDate}`,
|
||
matched ? 'PASS' : 'REJECT',
|
||
matched ? '购买时间在保单保险期间内' : '购买时间不在保单保险期间内'
|
||
);
|
||
if (!matched) {
|
||
riskFlags.push({ level: 'HIGH', code: 'PURCHASE_TIME_OUT_OF_POLICY_PERIOD', message: '购买时间不在保单保险期间内' });
|
||
}
|
||
} else {
|
||
const matched = isRangeWithinRange(purchaseWindow.purchaseStartDate, purchaseWindow.purchaseEndDate, policyStartDate, policyEndDate);
|
||
addCheck(
|
||
'insurancePeriod',
|
||
`应覆盖${purchaseWindow.purchaseLabel} ${purchaseWindow.purchaseStartDate || ''} - ${purchaseWindow.purchaseEndDate || ''}`,
|
||
`${policyStartDate} - ${policyEndDate}`,
|
||
matched ? 'PASS' : 'REJECT',
|
||
matched ? '购买时间范围在保单保险期间内' : '购买时间范围不在保单保险期间内'
|
||
);
|
||
if (!matched) {
|
||
riskFlags.push({ level: 'HIGH', code: 'PURCHASE_RANGE_OUT_OF_POLICY_PERIOD', message: '购买时间范围不在保单保险期间内' });
|
||
}
|
||
}
|
||
|
||
if (ocr.hasVehicleDamageCoverage === true) {
|
||
addCheck('vehicleDamageCoverage', '应包含车损险', '已识别', 'PASS', '识别到车损险险种证据');
|
||
} else {
|
||
addCheck('vehicleDamageCoverage', '应包含车损险', '未确认', 'REVIEW', '未识别到机动车损失保险/车辆损失保险/车损险证据');
|
||
}
|
||
}
|
||
|
||
const decision = reject ? 'REJECT' : manual ? 'MANUAL_REVIEW' : 'PASS';
|
||
const suggestedAction = decision === 'PASS'
|
||
? '提交人保投保'
|
||
: decision === 'REJECT'
|
||
? '驳回并要求重新核对或上传正确商业险/车损险保单'
|
||
: '转人工复核保单原件和OCR结果';
|
||
|
||
return {
|
||
success: true,
|
||
decision,
|
||
reasons,
|
||
fieldChecks,
|
||
riskFlags,
|
||
suggestedAction,
|
||
summary: {
|
||
productType,
|
||
policyNo: ocr.policyNo,
|
||
vin: ocr.vin,
|
||
nameCandidates: ocr.nameCandidates,
|
||
usageNature: ocr.usageNature,
|
||
normalizedUsageNature,
|
||
policyStartDate,
|
||
policyEndDate,
|
||
purchaseDate: purchaseWindow.purchaseDate,
|
||
purchaseStartDate: purchaseWindow.purchaseStartDate,
|
||
purchaseEndDate: purchaseWindow.purchaseEndDate,
|
||
ocrConfidence: ocr.confidence,
|
||
ocrWarnings: ocr.warnings,
|
||
},
|
||
};
|
||
}
|
||
|
||
async function main() {
|
||
try {
|
||
const stdin = fs.readFileSync(0, 'utf8');
|
||
const input = JSON.parse(stdin || '{}');
|
||
process.stdout.write(JSON.stringify(createAudit(input)));
|
||
} catch (error) {
|
||
process.stdout.write(JSON.stringify({ success: false, error: error.message }));
|
||
process.exitCode = 0;
|
||
}
|
||
}
|
||
|
||
if (require.main === module) {
|
||
main();
|
||
}
|
||
|
||
module.exports = {
|
||
createAudit,
|
||
normalizeOcr,
|
||
getProductType,
|
||
};
|