2026-05-20 21:39:12 +08:00

433 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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),
}));
}
})();