433 lines
18 KiB
JavaScript
Raw Normal View History

2026-05-20 21:39:12 +08:00
#!/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),
}));
}
})();