Files
tms-erp-web/src/utils/cert-audit.js
T
weicw-dshsa 6384e80758 🐛 司机档案住址与出生年月口径对齐小程序
- 住址只读态直接显示区划名称串,不再用级联反解(改名后会退化成灰字 placeholder)
- 身份证号联动改为失焦触发,且只在出生年月/性别为空时补,不覆盖手填值
- 认证审核页标签改为「住址」「详细地址」
- 详细地址 placeholder 补明采集范围,空住址提示改为「请选择省/市/区」
2026-10-22 00:17:55 +08:00

438 lines
16 KiB
JavaScript

/**
* 认证审核通用工具
*
* 认证对象(司机 / 车辆)每次提交产生一条不可变快照记录,「审核」针对的是**一条记录**。
* 记录分两侧:本次**待审快照**与该对象**当前生效快照**(最后一次审核通过的那条)。
* 待审内容在审核通过前不影响生效值,所以审核页必须逐字段对比两侧,而不是只看一条当前值。
*
* 本文件提供三件事:
* 1. 审核状态文案与标签(与后端 statusText 同一套词表);
* 2. 快照字段的展示元数据(中文名、控件类型、分组);
* 3. 两侧快照的字段级 diff 计算,含「从未认证通过、无对比基准」的处理。
*
* 两侧快照字段名完全一致(同一套结构),故可逐键比较。
*/
const EMPTY_TEXT = '—';
const TYPE_IMAGE = 'image';
const TYPE_DATE = 'date';
const TYPE_LONG_TERM = 'longTerm';
/** 审核状态取值 */
export const CERT_RECORD_STATUS = {
PENDING: 0,
APPROVED: 1,
REJECTED: 2,
VOIDED: 3,
};
/** 状态文案统一为:审核中 / 已通过 / 已驳回 / 已作废 */
const CERT_STATUS_TAG = {
0: { label: '审核中', type: 'warning' },
1: { label: '已通过', type: 'success' },
2: { label: '已驳回', type: 'danger' },
3: { label: '已作废', type: 'info' },
};
/** 变更类型 → 差异标签 */
export const CERT_CHANGE_TAG = {
added: { label: '新增', type: 'success' },
modified: { label: '已修改', type: 'warning' },
cleared: { label: '已清空', type: 'danger' },
};
/**
* 「最新提交状态」展示缓存 → 标签
*
* 缓存为空表示该对象从未提交过认证(即没有生效快照)。
* 注意它只是最近一次提交的状态,不等于「是否已认证」——后者由生效快照指针决定。
*/
export const certStatusTag = status => {
if (status === null || status === undefined || status === '') {
return { label: '未认证', type: 'info' };
}
return CERT_STATUS_TAG[status] || { label: EMPTY_TEXT, type: 'info' };
};
const pad = value => String(value).padStart(2, '0');
/** 后端若改用时间戳毫秒序列化日期,也能落成日期文案 */
const EPOCH_PATTERN = /^\d{13}$/;
const isEpochText = text => EPOCH_PATTERN.test(text);
const toDate = text => {
const date = new Date(Number(text));
return Number.isNaN(date.getTime()) ? null : date;
};
/** 提交时间等日期时间展示,兼容 `yyyy-MM-dd HH:mm:ss`、ISO 串与时间戳毫秒 */
export const formatCertDateTime = value => {
if (value === null || value === undefined || value === '') return EMPTY_TEXT;
const text = String(value).trim();
const matched = text.match(
/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})[T ](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?/
);
if (matched) {
return (
`${matched[1]}-${pad(matched[2])}-${pad(matched[3])} ` +
`${pad(matched[4])}:${pad(matched[5])}:${pad(matched[6] || 0)}`
);
}
const date = isEpochText(text) ? toDate(text) : null;
if (!date) return text;
return (
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
);
};
/**
* 快照字段元数据
*
* 字段名与后端提交 DTO 的 JSON 键一致(司机 PersonCertSubmitRequest / 车辆 VehicleCertSubmitRequest),
* 两侧快照共用同一套结构,故同时用于标签与比较规则。
*
* `longTermKey` 只挂在**被长期有效作废掉的那个字段**上,也就是各组的「有效期止」与强制报废日期:
* 勾了长期有效,它们显示「长期有效」、比较时按同一个值算(避免每次审核都报「已变更」)。
* 「有效期起」不挂:起期是一个独立的日期,长期有效并不会让它失效,
* 挂上去等于把它自己的值藏起来、还让审核员看不到起期的改动。
*/
const CERT_SNAPSHOT_GROUPS = {
driver: [
{
title: '身份信息',
fields: [
{ key: 'realName', label: '真实姓名' },
{ key: 'idCardNo', label: '身份证号' },
{ key: 'birthday', label: '出生年月', type: TYPE_DATE },
{ key: 'addressRegion', label: '住址' },
{ key: 'address', label: '详细地址' },
{ key: 'idCardFrontUrl', label: '身份证正面照', type: TYPE_IMAGE },
{ key: 'idCardBackUrl', label: '身份证反面照', type: TYPE_IMAGE },
{ key: 'idCardValidStart', label: '身份证有效期起', type: TYPE_DATE },
{
key: 'idCardValidEnd',
label: '身份证有效期止',
type: TYPE_DATE,
longTermKey: 'idCardLongTerm',
},
{ key: 'idCardLongTerm', label: '身份证长期有效', type: TYPE_LONG_TERM },
{ key: 'headPhoto', label: '大头照', type: TYPE_IMAGE },
],
},
{
title: '驾驶证信息',
fields: [
{ key: 'driverFileNo', label: '驾驶证档案编号' },
{ key: 'driverClass', label: '准驾车型' },
{ key: 'driverLicenseFrontUrl', label: '驾驶证主页照片', type: TYPE_IMAGE },
{ key: 'driverLicenseBackUrl', label: '驾驶证副页照片', type: TYPE_IMAGE },
{ key: 'driverValidStart', label: '驾驶证有效期起', type: TYPE_DATE },
{
key: 'driverValidEnd',
label: '驾驶证有效期止',
type: TYPE_DATE,
longTermKey: 'driverLongTerm',
},
{ key: 'driverLongTerm', label: '驾驶证长期有效', type: TYPE_LONG_TERM },
],
},
{
title: '从业资格证信息',
fields: [
{ key: 'qualificationType', label: '从业资格认证类型' },
{ key: 'qualificationNo', label: '从业资格证号' },
{
key: 'qualificationFront',
label: '从业资格证封面页',
type: TYPE_IMAGE,
legacyKeys: ['qualificationUrl'],
},
{ key: 'qualificationBack', label: '从业资格证内容页', type: TYPE_IMAGE },
{ key: 'qualificationValidStart', label: '从业资格证有效期起', type: TYPE_DATE },
{
key: 'qualificationValidEnd',
label: '从业资格证有效期止',
type: TYPE_DATE,
longTermKey: 'qualificationLongTerm',
},
{ key: 'qualificationLongTerm', label: '从业资格证长期有效', type: TYPE_LONG_TERM },
],
},
],
/*
* 车辆字段名以 VehicleCertSubmitRequest 为准,与 PersonCertSubmitRequest 不同。
* 键名对不上不报错——每个字段都算「没有值」,两组全显示「—」且 diff 不出变更。
*/
vehicle: [
{
title: '车辆基本信息',
fields: [
{ key: 'plateNo', label: '车牌号' },
{ key: 'vehicleType', label: '车辆类型' },
{ key: 'plateColor', label: '车牌颜色' },
{ key: 'ownerName', label: '所有人' },
{ key: 'vin', label: '车辆识别代号(车架号)' },
{ key: 'outerLength', label: '外廓长度(mm)' },
{ key: 'outerWidth', label: '外廓宽度(mm)' },
{ key: 'outerHeight', label: '外廓高度(mm)' },
{ key: 'approvedLoadKg', label: '核定载质量(KG)' },
{ key: 'tractionMassKg', label: '准牵引总质量(KG)' },
{ key: 'energyType', label: '能源类型' },
{ key: 'businessRelation', label: '业务关系' },
],
},
{
title: '行驶证信息',
fields: [
{ key: 'compulsoryScrapDate', label: '强制报废日期', type: TYPE_DATE, longTermKey: 'compulsoryScrapLongTerm' },
{ key: 'compulsoryScrapLongTerm', label: '强制报废长期有效', type: TYPE_LONG_TERM },
{ key: 'drivingLicenseNo', label: '行驶证档案编号' },
{ key: 'drivingLicenseImage', label: '行驶证主页正面', type: TYPE_IMAGE },
{ key: 'drivingLicenseMainBack', label: '行驶证主页反面', type: TYPE_IMAGE },
{ key: 'drivingLicenseViceFront', label: '行驶证副页正面', type: TYPE_IMAGE },
{ key: 'drivingLicenseViceBack', label: '行驶证副页反面', type: TYPE_IMAGE },
{ key: 'drivingLicenseStartDate', label: '行驶证有效期起', type: TYPE_DATE },
{
key: 'drivingLicenseEndDate',
label: '行驶证有效期止',
type: TYPE_DATE,
longTermKey: 'drivingLicenseLongTerm',
},
{ key: 'drivingLicenseLongTerm', label: '行驶证长期有效', type: TYPE_LONG_TERM },
],
},
{
title: '道路运输证信息',
fields: [
{ key: 'roadTransportCertNo', label: '道路运输证号' },
{ key: 'roadTransportCertImage', label: '道路运输证照片', type: TYPE_IMAGE },
{ key: 'roadTransportCertStartDate', label: '道路运输证有效期起', type: TYPE_DATE },
{
key: 'roadTransportCertEndDate',
label: '道路运输证有效期止',
type: TYPE_DATE,
longTermKey: 'roadTransportCertLongTerm',
},
{ key: 'roadTransportCertLongTerm', label: '道路运输证长期有效', type: TYPE_LONG_TERM },
],
},
{
title: '机动车登记本信息',
fields: [
{ key: 'registrationNo', label: '机动车登记编号' },
{ key: 'registrationImage', label: '机动车登记本照片', type: TYPE_IMAGE },
],
},
],
};
/** 提交元信息与主键不参与对比(两侧必相同,展示出来只是噪音) */
const IGNORED_KEYS = [
'id',
'bizType',
'bizId',
'bizKey',
'certificationStatus',
'snapshotJson',
'pendingSnapshotJson',
'effectiveSnapshotJson',
];
/**
* 解析快照 JSON
*
* 后端把 null 序列化为空串,故空串 / 'null' 都按「没有快照」处理,不算解析失败。
*/
const readSnapshot = json => {
if (json && typeof json === 'object') {
return { value: Object.keys(json).length ? json : null, invalid: false };
}
const text = String(json === null || json === undefined ? '' : json).trim();
if (!text || text === 'null') {
return { value: null, invalid: false };
}
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === 'object') {
return { value: parsed, invalid: false };
}
return { value: null, invalid: true };
} catch (error) {
return { value: null, invalid: true };
}
};
/** 解析快照 JSON,无快照或解析失败时返回 null */
export const parseSnapshot = json => readSnapshot(json).value;
const isEmptyValue = value => value === null || value === undefined || value === '';
const isLongTermOn = value => value === 1 || value === '1' || value === true;
/** 日期归一化到 yyyy-MM-dd,便于 `2026-01-01` 与 `2026-01-01T00:00:00` 判等 */
const normalizeDate = value => {
if (isEmptyValue(value)) return '';
const text = String(value).trim();
const matched = text.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
if (matched) return `${matched[1]}-${pad(matched[2])}-${pad(matched[3])}`;
const date = isEpochText(text) ? toDate(text) : null;
if (date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
return text;
};
/**
* 取字段值,兼容字段改名前的旧键
*
* 后端把某字段改名后,改名之前提交的记录里存的仍是旧键(例:从业资格证封面页
* 由 `qualificationUrl` 改为 `qualificationFront`)。只读新键会得到空,
* 审核页就把已填的封面页显示成「—」,误导审核员以为对方没传。
* 新旧键都存在时以新键为准。
*/
const readFieldValue = (snapshot, field) => {
if (!snapshot) return undefined;
const value = snapshot[field.key];
if (!isEmptyValue(value)) return value;
const legacyKeys = field.legacyKeys || [];
for (let i = 0; i < legacyKeys.length; i += 1) {
if (!isEmptyValue(snapshot[legacyKeys[i]])) return snapshot[legacyKeys[i]];
}
return value;
};
/** 比较用的归一化取值:展示相同即视为未变更 */
const canonicalOf = (snapshot, field) => {
const value = readFieldValue(snapshot, field);
if (field.type === TYPE_LONG_TERM) {
if (isEmptyValue(value)) return '';
return isLongTermOn(value) ? '1' : '0';
}
if (field.type === TYPE_DATE) {
if (field.longTermKey && isLongTermOn(snapshot && snapshot[field.longTermKey])) {
return '长期有效';
}
return normalizeDate(value);
}
return isEmptyValue(value) ? '' : String(value).trim();
};
/** 展示文案:日期型受「长期有效」影响,图片型返回 URL 由组件渲染缩略图 */
const textOf = (snapshot, field) => {
const value = readFieldValue(snapshot, field);
if (field.type === TYPE_IMAGE) {
return isEmptyValue(value) ? '' : String(value);
}
if (field.type === TYPE_LONG_TERM) {
if (isEmptyValue(value)) return EMPTY_TEXT;
return isLongTermOn(value) ? '是' : '否';
}
if (field.type === TYPE_DATE) {
if (field.longTermKey && isLongTermOn(snapshot && snapshot[field.longTermKey])) {
return '长期有效';
}
return normalizeDate(value) || EMPTY_TEXT;
}
return isEmptyValue(value) ? EMPTY_TEXT : String(value);
};
const buildRow = (field, pendingSnapshot, effectiveSnapshot, hasBaseline) => {
const type = field.type || 'text';
const target = { ...field, type };
const pendingCanonical = canonicalOf(pendingSnapshot, target);
const effectiveCanonical = canonicalOf(effectiveSnapshot, target);
let changeType = '';
if (hasBaseline && pendingCanonical !== effectiveCanonical) {
if (!effectiveCanonical) {
changeType = 'added';
} else if (!pendingCanonical) {
changeType = 'cleared';
} else {
changeType = 'modified';
}
}
return {
key: field.key,
label: field.label,
type,
effective: {
raw: readFieldValue(effectiveSnapshot, target),
text: textOf(effectiveSnapshot, target),
},
pending: {
raw: readFieldValue(pendingSnapshot, target),
text: textOf(pendingSnapshot, target),
},
changed: Boolean(changeType),
changeType,
};
};
const unionKeys = (pendingSnapshot, effectiveSnapshot) => {
const keys = [];
[pendingSnapshot, effectiveSnapshot].forEach(snapshot => {
Object.keys(snapshot || {}).forEach(key => {
if (!keys.includes(key)) keys.push(key);
});
});
return keys;
};
/**
* 构造「待审快照 vs 当前生效值」的字段级 diff
*
* @param {string} bizType 认证对象类型:driver / vehicle
* @param {string} pendingJson 本次待审快照 JSON
* @param {string} effectiveJson 该对象当前生效快照 JSON,从未认证通过时为空
* @returns {Object} { hasBaseline, groups, totalCount, changedCount, ... }
* hasBaseline 为 false 表示无对比基准(从未认证通过),此时不产出任何变更标记
*/
export const buildSnapshotDiff = (bizType, pendingJson, effectiveJson) => {
const pending = readSnapshot(pendingJson);
const effective = readSnapshot(effectiveJson);
const hasBaseline = effective.value !== null;
const groupsConfig = CERT_SNAPSHOT_GROUPS[bizType] || [];
const knownKeys = [];
const groups = groupsConfig.map(group => {
const rows = group.fields.map(field => {
// 旧键已由所属字段并入展示,不能再作为独立字段出现,否则同一张封面页会出现两行
knownKeys.push(field.key, ...(field.legacyKeys || []));
return buildRow(field, pending.value, effective.value, hasBaseline);
});
return {
title: group.title,
rows,
changedCount: rows.filter(row => row.changed).length,
};
});
// 元数据未登记的字段照样展示,避免后端新增字段时审核员看不到变化
const extraKeys = unionKeys(pending.value, effective.value).filter(
key => !knownKeys.includes(key) && !IGNORED_KEYS.includes(key)
);
if (extraKeys.length) {
const rows = extraKeys.map(key =>
buildRow({ key, label: key }, pending.value, effective.value, hasBaseline)
);
groups.push({
title: '其他字段',
rows,
changedCount: rows.filter(row => row.changed).length,
});
}
const rows = groups.reduce((all, group) => all.concat(group.rows), []);
return {
hasBaseline,
pendingInvalid: pending.invalid,
effectiveInvalid: effective.invalid,
groups,
totalCount: rows.length,
changedCount: rows.filter(row => row.changed).length,
};
};