#!/usr/bin/env node 'use strict'; const fs = require('fs'); const DEFAULT_CONFIG = { allowCompanyVehicle: false, maxPurchaseAgeDays: 365, plusCMaxVehicleAgeYears: 5, plusCKeywords: ['plusc', '代步权益'], manualReviewOnMissingCriticalFields: true, allowedVehicleTypeKeywords: ['乘用车', '小型轿车', '轿车', '客车', 'SUV', '新能源', '越野', '多用途乘用车'], rejectVehicleTypeKeywords: ['货车', '牵引', '挂车', '专项', '工程', '营运', '出租', '公交', '客运', '危险品'], }; const COMPANY_SUFFIX_RE = /(公司|有限|集团|厂|店|中心|企业|合作社|机关|单位|学校|医院|政府|委员会|事务所|营业部|分公司|4S店)/; function emit(stage, message, status = 'running', extra = {}) { process.stderr.write(JSON.stringify({ type: 'process_event', stage, message, status, timestamp: new Date().toISOString(), ...extra, }) + '\n'); } function readInput() { const raw = fs.readFileSync(0, 'utf8').trim(); return raw ? JSON.parse(raw) : {}; } function firstValue(obj, keys) { if (!obj || typeof obj !== 'object') return ''; for (const key of keys) { const value = obj[key]; if (value !== undefined && value !== null && String(value).trim() !== '' && String(value).trim() !== 'null') { return value; } } return ''; } function str(value) { if (value === undefined || value === null) return ''; return String(value).trim(); } function normName(value) { return str(value).replace(/\s+/g, '').replace(/[·..]/g, ''); } function normId(value) { return str(value).replace(/\s+/g, '').toUpperCase(); } function normVin(value) { return str(value).replace(/\s+/g, '').toUpperCase(); } function normLoose(value) { return str(value).replace(/\s+/g, '').toUpperCase(); } function fieldsOf(result) { if (!result || typeof result !== 'object') return {}; if (result.fields && typeof result.fields === 'object') return result.fields; return result; } function parseDate(value) { if (value === undefined || value === null || value === '') return null; if (typeof value === 'number') { const timestamp = value > 1000000000 && value < 100000000000 ? value * 1000 : value; const date = new Date(timestamp); return Number.isNaN(date.getTime()) ? null : date; } const text = str(value); if (!text || text === '长期') return null; if (/^\d+$/.test(text)) { const n = Number(text); const timestamp = n > 1000000000 && n < 100000000000 ? n * 1000 : n; const date = new Date(timestamp); if (!Number.isNaN(date.getTime()) && n > 1000000000) return date; } const normalized = text .replace(/[年月/.]/g, '-') .replace(/日/g, '') .replace(/T.*$/, '') .trim(); const compact = normalized.match(/^(\d{4})(\d{2})(\d{2})$/); const finalText = compact ? `${compact[1]}-${compact[2]}-${compact[3]}` : normalized; const dateOnly = finalText.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/); const date = dateOnly ? new Date(Number(dateOnly[1]), Number(dateOnly[2]) - 1, Number(dateOnly[3])) : new Date(finalText); return Number.isNaN(date.getTime()) ? null : date; } function formatDate(date) { if (!date) return ''; const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } function daysBetween(start, end) { return Math.floor((end.getTime() - start.getTime()) / 86400000); } function fullMonthsBetween(start, end) { let months = (end.getFullYear() - start.getFullYear()) * 12 + (end.getMonth() - start.getMonth()); if (end.getDate() < start.getDate()) months -= 1; return Math.max(0, months); } function addYearsClamped(date, years) { const targetYear = date.getFullYear() + years; const targetMonth = date.getMonth(); const day = Math.min(date.getDate(), new Date(targetYear, targetMonth + 1, 0).getDate()); return new Date(targetYear, targetMonth, day, 0, 0, 0, 0); } function isDateAfterLimitYears(start, end, years) { return end.getTime() > addYearsClamped(start, years).getTime(); } function normalizeKeywordText(value) { return str(value).normalize('NFKC').replace(/\s+/g, '').toLowerCase(); } function isPackageTypePlusC(value) { return normalizeKeywordText(value) === '3'; } function isPlusCOrder(order, cfg) { if (!order || typeof order !== 'object') return false; if (isPackageTypePlusC(order.packageType)) return true; const text = normalizeKeywordText([ order.productName, order.packageName, order.packageTypeName, order.goodsName, order.product, order.productTitle, order.productNo, ].filter(Boolean).join(' ')); const keywords = Array.isArray(cfg.plusCKeywords) && cfg.plusCKeywords.length ? cfg.plusCKeywords : DEFAULT_CONFIG.plusCKeywords; return keywords.some(keyword => text.includes(normalizeKeywordText(keyword))); } function resolveVehicleAgeStartDate(n) { const order = n.order || {}; const candidates = [ ['firstRegistrationDate', firstValue(order, ['firstRegistrationDate', 'registrationDate', 'registerDate', 'vehicleRegisterDate', 'licenseRegisterDate'])], ['orderPurchaseDate', firstValue(order, ['carPurchaseTime', 'purchaseTime', 'purchaseDate'])], ['invoiceIssueDate', firstValue(n.invoiceFields, ['issueDate', '开票日期', 'date'])], ]; for (const [source, value] of candidates) { const date = parseDate(value); if (date) return { source, date }; } return { source: '', date: null }; } function containsAny(text, keywords) { const value = str(text).toUpperCase(); return keywords.some(k => value.includes(String(k).toUpperCase())); } function isMissingCritical(status, reason) { return { id: '', name: '', status, reason, evidence: {}, }; } function rule(id, name, status, reason, evidence = {}) { return { id, name, status, reason, evidence }; } function compareRequired(left, right) { if (!left || !right) return 'MISSING'; return left === right ? 'MATCH' : 'MISMATCH'; } function compareLoose(left, right) { if (!left || !right) return 'MISSING'; const a = normLoose(left); const b = normLoose(right); if (a === b) return 'MATCH'; if (a.includes(b) || b.includes(a)) return 'PARTIAL'; return 'MISMATCH'; } function normalizeInputs(input) { const order = input.order || {}; const idFields = fieldsOf(input.idCardOcr); const invoiceFields = fieldsOf(input.vehicleInvoiceOcr); return { order, idFields, invoiceFields, orderName: normName(firstValue(order, ['cardName', 'ownerName', 'customerName', 'name', 'buyerName'])), orderIdNumber: normId(firstValue(order, ['cardNumber', 'idNumber', 'idCardNo', 'certificateNo', 'cardNo'])), orderVin: normVin(firstValue(order, ['carFrame', 'vin', 'frameNo', 'vehicleVin'])), orderEngineNo: normLoose(firstValue(order, ['engineNo', 'engineNumber'])), orderModel: str(firstValue(order, ['modelName', 'carModel', 'vehicleModel', 'productName'])), orderPurchaseDate: parseDate(firstValue(order, ['carPurchaseTime', 'purchaseTime', 'purchaseDate'])), orderCreateDate: parseDate(firstValue(order, ['createTime', 'createdAt', 'createDate'])) || new Date(), idName: normName(firstValue(idFields, ['name', '姓名'])), idNumber: normId(firstValue(idFields, ['idNumber', 'cardNumber', '公民身份号码', '身份证号'])), invoiceBuyerName: normName(firstValue(invoiceFields, ['buyerName', '购买方名称', '购买方'])), invoiceVin: normVin(firstValue(invoiceFields, ['vin', '车辆识别代号/车架号码', '车架号码', 'VIN'])), invoiceEngineNo: normLoose(firstValue(invoiceFields, ['engineNo', '发动机号码', '发动机号'])), invoiceIssueDate: parseDate(firstValue(invoiceFields, ['issueDate', '开票日期', 'date'])), invoiceVehicleType: str(firstValue(invoiceFields, ['vehicleType', '车辆类型'])), invoiceBrandModel: str(firstValue(invoiceFields, ['brandModel', '厂牌型号', '品牌型号'])), invoiceSellerName: str(firstValue(invoiceFields, ['sellerName', '销货单位名称', '销售方名称'])), }; } function auditPublicVehicle(n, cfg) { const carHost = n.order.carHost; const hostText = str(carHost); const companyNames = [n.invoiceBuyerName, n.orderName].filter(Boolean).filter(v => COMPANY_SUFFIX_RE.test(v)); const publicHost = carHost !== undefined && carHost !== null && hostText !== '' && !['0', '个人', 'PERSONAL', 'false'].includes(hostText); if (cfg.allowCompanyVehicle) { return rule('R1_PUBLIC_VEHICLE', '公户车检查', 'PASS', '配置允许公户车', { carHost, companyNames }); } if (publicHost || companyNames.length) { return rule('R1_PUBLIC_VEHICLE', '公户车检查', 'REJECT', '发现公户车或企业购买方特征', { carHost, companyNames }); } return rule('R1_PUBLIC_VEHICLE', '公户车检查', 'PASS', '未发现公户车特征', { carHost, buyerName: n.invoiceBuyerName }); } function auditBuyerName(n, cfg) { const cmp = compareRequired(n.orderName, n.invoiceBuyerName); const evidence = { orderName: n.orderName, invoiceBuyerName: n.invoiceBuyerName }; if (cmp === 'MATCH') return rule('R2_BUYER_NAME', '购买方名称比对', 'PASS', '订单客户姓名与发票购买方一致', evidence); if (cmp === 'MISSING') { return rule('R2_BUYER_NAME', '购买方名称比对', cfg.manualReviewOnMissingCriticalFields ? 'MANUAL_REVIEW' : 'REJECT', '订单客户姓名或发票购买方缺失', evidence); } return rule('R2_BUYER_NAME', '购买方名称比对', 'MANUAL_REVIEW', '订单客户姓名与发票购买方不一致,需人工核验是否代购或录入错误', evidence); } function auditPurchaseAge(n, cfg) { const purchaseDate = n.invoiceIssueDate || n.orderPurchaseDate; const refDate = n.orderCreateDate || new Date(); const evidence = { invoiceIssueDate: formatDate(n.invoiceIssueDate), orderPurchaseDate: formatDate(n.orderPurchaseDate), referenceDate: formatDate(refDate), maxPurchaseAgeDays: cfg.maxPurchaseAgeDays, }; if (isPlusCOrder(n.order, cfg)) { return rule('R3_PURCHASE_AGE', '购车时间超期', 'PASS', 'Plus C/代步权益产品按5年车龄限制执行,不适用新车购车时间阈值', evidence); } if (!purchaseDate) { return rule('R3_PURCHASE_AGE', '购车时间超期', cfg.manualReviewOnMissingCriticalFields ? 'MANUAL_REVIEW' : 'REJECT', '发票开票日期和订单购车时间均缺失', evidence); } const ageDays = daysBetween(purchaseDate, refDate); evidence.ageDays = ageDays; if (ageDays < -7) return rule('R3_PURCHASE_AGE', '购车时间超期', 'MANUAL_REVIEW', '购车日期晚于订单创建日期,需核验日期来源', evidence); if (ageDays > cfg.maxPurchaseAgeDays) return rule('R3_PURCHASE_AGE', '购车时间超期', 'MANUAL_REVIEW', '购车日期超过配置阈值', evidence); return rule('R3_PURCHASE_AGE', '购车时间超期', 'PASS', '购车日期在允许范围内', evidence); } function auditIdCard(n, cfg) { const nameCmp = compareRequired(n.orderName, n.idName); const idCmp = compareRequired(n.orderIdNumber, n.idNumber); const evidence = { orderName: n.orderName, ocrName: n.idName, orderIdNumber: n.orderIdNumber, ocrIdNumber: n.idNumber, }; if (nameCmp === 'MATCH' && idCmp === 'MATCH') return rule('R4_ID_CARD', '身份证比对', 'PASS', '订单姓名和身份证号与身份证OCR一致', evidence); if (nameCmp === 'MISSING' || idCmp === 'MISSING') { return rule('R4_ID_CARD', '身份证比对', cfg.manualReviewOnMissingCriticalFields ? 'MANUAL_REVIEW' : 'REJECT', '订单或身份证OCR关键字段缺失', evidence); } return rule('R4_ID_CARD', '身份证比对', 'REJECT', '订单姓名或身份证号与身份证OCR不一致', evidence); } function auditInvoice(n, cfg) { const vinCmp = compareRequired(n.orderVin, n.invoiceVin); const engineCmp = compareLoose(n.orderEngineNo, n.invoiceEngineNo); const evidence = { orderVin: n.orderVin, invoiceVin: n.invoiceVin, orderEngineNo: n.orderEngineNo, invoiceEngineNo: n.invoiceEngineNo, orderModel: n.orderModel, invoiceBrandModel: n.invoiceBrandModel, sellerName: n.invoiceSellerName, }; if (vinCmp === 'MISMATCH') return rule('R5_INVOICE', '发票比对', 'REJECT', '订单VIN/车架号与发票不一致', evidence); if (vinCmp === 'MISSING') return rule('R5_INVOICE', '发票比对', cfg.manualReviewOnMissingCriticalFields ? 'MANUAL_REVIEW' : 'REJECT', '订单VIN或发票VIN缺失', evidence); if (engineCmp === 'MISMATCH') return rule('R5_INVOICE', '发票比对', 'MANUAL_REVIEW', 'VIN一致,但发动机号不一致,需人工核验', evidence); return rule('R5_INVOICE', '发票比对', 'PASS', '订单VIN与发票一致,车型营销名/厂牌公告型号差异不单独作为冲突', evidence); } function auditVehicleType(n, cfg) { const text = `${n.invoiceVehicleType} ${n.invoiceBrandModel} ${n.orderModel}`; const evidence = { vehicleType: n.invoiceVehicleType, brandModel: n.invoiceBrandModel, orderModel: n.orderModel }; if (!str(text)) return rule('R6_VEHICLE_TYPE', '车辆类型判断', cfg.manualReviewOnMissingCriticalFields ? 'MANUAL_REVIEW' : 'REJECT', '车辆类型和车型字段缺失', evidence); if (containsAny(text, cfg.rejectVehicleTypeKeywords)) return rule('R6_VEHICLE_TYPE', '车辆类型判断', 'REJECT', '车辆类型命中非目标车型或营运特征', evidence); if (containsAny(text, cfg.allowedVehicleTypeKeywords)) return rule('R6_VEHICLE_TYPE', '车辆类型判断', 'PASS', '车辆类型符合新车投保审核范围', evidence); return rule('R6_VEHICLE_TYPE', '车辆类型判断', 'MANUAL_REVIEW', '车辆类型未命中明确允许范围,需人工判断', evidence); } function auditPlusCVehicleAge(n, cfg) { const plusC = isPlusCOrder(n.order, cfg); const refDate = n.orderCreateDate || new Date(); const start = resolveVehicleAgeStartDate(n); const maxYears = Number(cfg.plusCMaxVehicleAgeYears || DEFAULT_CONFIG.plusCMaxVehicleAgeYears); const effectiveMaxYears = Number.isFinite(maxYears) && maxYears > 0 ? maxYears : DEFAULT_CONFIG.plusCMaxVehicleAgeYears; const evidence = { productName: firstValue(n.order, ['productName', 'packageName', 'goodsName']), packageType: n.order.packageType, isPlusC: plusC, vehicleAgeStartSource: start.source, vehicleAgeStartDate: formatDate(start.date), referenceDate: formatDate(refDate), plusCMaxVehicleAgeYears: effectiveMaxYears, }; if (!plusC) { return rule('R7_PLUS_C_VEHICLE_AGE', 'PlusC车龄限制', 'PASS', '非Plus C/代步权益产品,不适用5年车龄限制', evidence); } if (!start.date) { return rule('R7_PLUS_C_VEHICLE_AGE', 'PlusC车龄限制', cfg.manualReviewOnMissingCriticalFields ? 'MANUAL_REVIEW' : 'REJECT', 'Plus C/代步权益产品缺少车龄起算日期,无法判断是否超过5年', evidence); } const ageDays = daysBetween(start.date, refDate); evidence.vehicleAgeDays = ageDays; if (ageDays < -7) { return rule('R7_PLUS_C_VEHICLE_AGE', 'PlusC车龄限制', 'MANUAL_REVIEW', '车龄起算日期晚于审核基准日期,需人工核验日期来源', evidence); } evidence.vehicleAgeMonths = fullMonthsBetween(start.date, refDate); evidence.vehicleAgeYears = Number((Math.max(0, ageDays) / 365.25).toFixed(3)); if (isDateAfterLimitYears(start.date, refDate, effectiveMaxYears)) { return rule('R7_PLUS_C_VEHICLE_AGE', 'PlusC车龄限制', 'REJECT', 'Plus C/代步权益产品不允许车龄超过5年的车辆购买', evidence); } return rule('R7_PLUS_C_VEHICLE_AGE', 'PlusC车龄限制', 'PASS', 'Plus C/代步权益产品车龄未超过5年', evidence); } function finalStatus(rules) { if (rules.some(r => r.status === 'REJECT')) return 'REJECT'; if (rules.some(r => r.status === 'MANUAL_REVIEW')) return 'MANUAL_REVIEW'; return 'PASS'; } function reasonFor(status, rules) { if (status === 'PASS') return `${rules.length}项规则均通过`; const failed = rules.filter(r => r.status !== 'PASS'); return failed.map(r => `${r.name}: ${r.reason}`).join(';'); } function scoreFor(rules) { const score = rules.reduce((current, item) => { if (item.status === 'REJECT') return current - 35; if (item.status === 'MANUAL_REVIEW') return current - 15; return current; }, 100); return Math.max(0, score); } function run(input) { if (!input.order) throw new Error('order is required'); if (!input.idCardOcr) throw new Error('idCardOcr is required'); if (!input.vehicleInvoiceOcr) throw new Error('vehicleInvoiceOcr is required'); const cfg = { ...DEFAULT_CONFIG, ...(input.config || {}) }; emit('normalize', '归一化订单、身份证OCR和发票OCR字段'); const normalized = normalizeInputs(input); emit('rules', '执行7条投保审核规则'); const rules = [ auditPublicVehicle(normalized, cfg), auditBuyerName(normalized, cfg), auditPurchaseAge(normalized, cfg), auditIdCard(normalized, cfg), auditInvoice(normalized, cfg), auditVehicleType(normalized, cfg), auditPlusCVehicleAge(normalized, cfg), ]; const status = finalStatus(rules); const score = scoreFor(rules); const reason = reasonFor(status, rules); emit('complete', `审核完成: ${status}`, 'completed', { auditStatus: status, percent: 100 }); return { success: true, status, score, reason, rules, summary: status === 'PASS' ? '审核通过' : status === 'REJECT' ? '审核驳回' : '转人工审核', normalized: { orderNo: firstValue(normalized.order, ['orderNo', 'orderNum']), orderName: normalized.orderName, orderIdNumber: normalized.orderIdNumber, orderVin: normalized.orderVin, idName: normalized.idName, idNumber: normalized.idNumber, invoiceBuyerName: normalized.invoiceBuyerName, invoiceVin: normalized.invoiceVin, invoiceIssueDate: formatDate(normalized.invoiceIssueDate), orderPurchaseDate: formatDate(normalized.orderPurchaseDate), vehicleType: normalized.invoiceVehicleType, brandModel: normalized.invoiceBrandModel, }, config: cfg, }; } (async () => { try { const input = readInput(); process.stdout.write(JSON.stringify(run(input))); } catch (err) { process.stdout.write(JSON.stringify({ success: false, error: err && err.message ? err.message : String(err), })); } })();