refactor(customer): 优化客户报备重复性检查逻辑

- 移除原有的三要素重复校验机制,改用房屋编码精确匹配
- 新增中文数字解析功能,支持大小写中文数字转换为阿拉伯数字
- 实现全角字符转半角处理,统一输入格式标准化
- 添加房屋编码规范化逻辑,去除常见后缀和装饰词
- 重构重复报备检查流程,使用标准化房屋编码进行精确匹配
- 保留关键词搜索兼容历史数据,确保查询准确性
- 移除冗余的组合算法和多条件查询逻辑
This commit is contained in:
2026-02-03 12:20:38 +08:00
parent 316aab2616
commit 217f3556fc

View File

@@ -161,103 +161,124 @@ const AddShopDealerApply = () => {
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
type DupKey = 'address' | 'buildingNo' | 'unitNo' | 'roomNo' | 'realName' | 'mobile';
const DUP_LABELS: Record<DupKey, string> = {
address: '小区名称',
buildingNo: '楼栋号',
unitNo: '单元号',
roomNo: '房号',
realName: '姓名',
mobile: '电话',
};
const normalizeText = (v: any) => (v ?? '').toString().trim();
const getApplyDupFields = (apply: ShopDealerApply): Record<DupKey, string> => {
const toHalfWidth = (input: string) =>
(input || '').replace(/[\uFF01-\uFF5E]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xFEE0)).replace(/\u3000/g, ' ');
const parseChineseNumber = (s: string): number | null => {
const str = (s || '').trim();
if (!str) return null;
// 仅处理纯中文数字(含大小写)+ 单位
if (!/^[零〇一二三四五六七八九十百千万两兩俩壹贰叁肆伍陆柒捌玖拾佰仟萬]+$/.test(str)) return null;
const digitMap: Record<string, number> = {
: 0, : 0,
: 1, : 1,
: 2, : 2, : 2, : 2, : 2,
: 3, : 3,
: 4, : 4,
: 5, : 5,
: 6, : 6,
: 7, : 7,
: 8, : 8,
: 9, : 9,
};
const unitMap: Record<string, number> = {: 10, : 10, : 100, : 100, : 1000, : 1000, : 10000, : 10000};
let total = 0;
let section = 0;
let number = 0;
for (const ch of str) {
if (digitMap[ch] !== undefined) {
number = digitMap[ch];
continue;
}
const unit = unitMap[ch];
if (!unit) continue;
if (unit === 10000) {
section = (section + number) * unit;
total += section;
section = 0;
} else {
// “十/百/千”前省略“一”的情况:十=10、十二=12
const n = number === 0 ? 1 : number;
section += n * unit;
}
number = 0;
}
const result = total + section + number;
return Number.isFinite(result) ? result : null;
};
const normalizeCommunity = (community: string) => {
const s = toHalfWidth(normalizeText(community));
return s.replace(/\s+/g, '').toUpperCase();
};
const normalizeHouseNoPart = (raw: string, kind: 'building' | 'unit' | 'room') => {
let s = toHalfWidth(normalizeText(raw)).toUpperCase();
s = s.replace(/\s+/g, '');
// 去掉常见后缀/装饰词
if (kind === 'building') s = s.replace(/(号楼|栋|幢|楼)$/g, '');
if (kind === 'unit') s = s.replace(/(单元)$/g, '');
if (kind === 'room') s = s.replace(/(室|房|号)$/g, '');
// 只保留数字与字母,统一分隔符差异(如 12-01 / 12#01
s = s.replace(/[^0-9A-Z零一二三四五六七八九十百千万两兩俩壹贰叁肆伍陆柒捌玖拾佰仟萬]/g, '');
// 纯中文数字 => 阿拉伯数字(支持大小写)
const cn = parseChineseNumber(s);
if (cn !== null) return String(cn);
// 数字段去前导 0如 03A => 3A1201 不变)
s = s.replace(/\d+/g, (m) => String(parseInt(m, 10)));
return s;
};
const buildHouseKeyNormalized = (community: string, buildingNo: string, unitNo: string | undefined, roomNo: string) => {
const c = normalizeCommunity(community);
const b = normalizeHouseNoPart(buildingNo, 'building');
const u = normalizeHouseNoPart(unitNo || '', 'unit');
const r = normalizeHouseNoPart(roomNo, 'room');
return [c, b, u, r].join('|');
};
const getNormalizedHouseKeyFromApply = (apply: ShopDealerApply) => {
const parsed = parseHouseKey(apply.dealerCode);
return {
address: normalizeText(parsed.community || apply.address),
buildingNo: normalizeText(parsed.buildingNo),
unitNo: normalizeText(parsed.unitNo),
roomNo: normalizeText(parsed.roomNo),
realName: normalizeText(apply.realName),
mobile: normalizeText(apply.mobile),
};
return buildHouseKeyNormalized(
parsed.community || apply.address || '',
parsed.buildingNo || '',
parsed.unitNo || '',
parsed.roomNo || ''
);
};
const getNewDupFields = (values: any): Record<DupKey, string> => ({
address: normalizeText(values.address),
buildingNo: normalizeText(values.buildingNo),
unitNo: normalizeText(values.unitNo),
roomNo: normalizeText(values.roomNo),
realName: normalizeText(values.realName),
mobile: normalizeText(values.mobile),
});
const combinationsOf3 = <T,>(arr: T[]): [T, T, T][] => {
const res: [T, T, T][] = [];
for (let i = 0; i < arr.length; i++) {
for (let j = i + 1; j < arr.length; j++) {
for (let k = j + 1; k < arr.length; k++) {
res.push([arr[i], arr[j], arr[k]]);
}
}
}
return res;
const findExistingApplyByHouse = async (params: {houseKeyNormalized: string; houseKeyRaw: string; communityKeyword: string}) => {
const tryByDealerCode = async (dealerCode: string) => {
const res = await pageShopDealerApply({dealerCode, type: 4});
return res?.list?.[0] as ShopDealerApply | undefined;
};
const findMatchedTriad = (a: Record<DupKey, string>, b: Record<DupKey, string>) => {
const availableKeys = (Object.keys(a) as DupKey[]).filter((k) => a[k] !== '');
if (availableKeys.length < 3) return null;
const triads = combinationsOf3(availableKeys);
for (const triad of triads) {
if (triad.every((k) => a[k] === b[k] && b[k] !== '')) return triad;
const keys = Array.from(new Set([params.houseKeyNormalized, params.houseKeyRaw].filter(Boolean)));
for (const k of keys) {
const hit = await tryByDealerCode(k);
if (hit) return hit;
}
return null;
};
const checkDuplicateBeforeSubmit = async (values: any, opts?: { skipDealerCode?: string }) => {
const inputFields = getNewDupFields(values);
const nonEmptyCount = Object.values(inputFields).filter((v) => v !== '').length;
if (nonEmptyCount < 3) return null;
// 兼容历史数据:用关键词拉取附近数据,再用“规范化后的 houseKey”对比
const keyword = normalizeText(params.communityKeyword);
if (!keyword) return null;
const seen = new Set<number>();
const scanPages = async (params: any) => {
for (let page = 1; page <= DUP_CHECK_MAX_PAGES; page++) {
const res = await pageShopDealerApply({...params, page, limit: DUP_CHECK_LIMIT});
const res = await pageShopDealerApply({type: 4, keywords: keyword, page, limit: DUP_CHECK_LIMIT});
const list = res?.list || [];
for (const item of list) {
if (opts?.skipDealerCode && item.dealerCode === opts.skipDealerCode) continue;
if (item.applyId && seen.has(item.applyId)) continue;
if (item.applyId) seen.add(item.applyId);
const triad = findMatchedTriad(inputFields, getApplyDupFields(item));
if (triad) return {item, triad};
if (getNormalizedHouseKeyFromApply(item) === params.houseKeyNormalized) return item;
}
if (list.length < DUP_CHECK_LIMIT) break;
}
return null;
};
// 优先按手机号(精确)查询,数据量更小
if (inputFields.mobile) {
const hit = await scanPages({type: 4, mobile: inputFields.mobile});
if (hit) return hit;
}
// 再按小区关键字查询,覆盖房号相关组合
if (inputFields.address) {
const hit = await scanPages({type: 4, keywords: inputFields.address});
if (hit) return hit;
}
// 最后按姓名关键字兜底(用于覆盖不包含“小区/电话”的组合)
if (inputFields.realName) {
const hit = await scanPages({type: 4, keywords: inputFields.realName});
if (hit) return hit;
}
return null;
};
@@ -314,16 +335,20 @@ const AddShopDealerApply = () => {
}
}
const houseKey = buildHouseKey(values.address, values.buildingNo, values.unitNo, values.roomNo);
const houseKeyRaw = buildHouseKey(values.address, values.buildingNo, values.unitNo, values.roomNo);
const houseKeyNormalized = buildHouseKeyNormalized(values.address, values.buildingNo, values.unitNo, values.roomNo);
const houseKey = houseKeyNormalized || houseKeyRaw;
const houseDisplay = buildHouseDisplay(values.address, values.buildingNo, values.unitNo, values.roomNo);
// 检查房号是否已报备
const res = await pageShopDealerApply({dealerCode: houseKey, type: 4});
if (res && res.count > 0) {
const existingCustomer = res.list[0];
// 新增报备:提交前检查房号是否已报备(按 小区+楼栋+单元+房号 判断,且做规范化)
if (!isEditMode) {
const existingCustomer = await findExistingApplyByHouse({
houseKeyNormalized,
houseKeyRaw,
communityKeyword: values.address
});
if (existingCustomer) {
// 已签约/已取消:直接提示已报备
if (existingCustomer.applyStatus && existingCustomer.applyStatus !== 10) {
Taro.showToast({
@@ -375,21 +400,6 @@ const AddShopDealerApply = () => {
}
}
// 新增报备:提交前做“三要素”重复校验(小区/楼栋/单元/房号/姓名/电话 任一三要素重复提示已报备)
if (!isEditMode) {
const dup = await checkDuplicateBeforeSubmit(values, {skipDealerCode: houseKey});
if (dup) {
const triadLabels = dup.triad.map((k: DupKey) => DUP_LABELS[k]).join('、');
const existingDisplay = dup.item.dealerName || dup.item.address || '地址未知';
Taro.showToast({
title: `疑似重复报备:${triadLabels}一致(${existingDisplay}),已报备`,
icon: 'none',
duration: 3000
});
return false;
}
}
// 计算过期时间
const expirationTime = isEditMode ? existingApply?.expirationTime : calculateExpirationTime();