433 lines
18 KiB
JavaScript
433 lines
18 KiB
JavaScript
#!/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),
|
||
}));
|
||
}
|
||
})();
|