feat(customer): 添加客户报备重复检查功能

- 新增 DUP_CHECK_LIMIT 和 DUP_CHECK_MAX_PAGES 常量配置
- 实现三要素重复检查逻辑,支持地址、楼栋号、单元号、房号、姓名、电话等字段组合匹配
- 添加 normalizeText 函数处理文本标准化
- 实现 combinationsOf3 函数生成三个元素的组合
- 添加 checkDuplicateBeforeSubmit 函数执行重复检查
- 在提交前验证重复报备并显示提示信息
- 移除用户页面中的 IsDealer 组件
- 更新用户组件删除实名认证和收货地址相关功能
This commit is contained in:
2026-02-02 13:05:19 +08:00
parent 9294c7b049
commit 316aab2616
3 changed files with 150 additions and 36 deletions

View File

@@ -26,6 +26,8 @@ const AddShopDealerApply = () => {
const [existingApply, setExistingApply] = useState<ShopDealerApply | null>(null)
const [referee, setReferee] = useState<ShopDealerUser>()
const PROTECTION_DAYS = 15;
const DUP_CHECK_LIMIT = 200;
const DUP_CHECK_MAX_PAGES = 50;
// 房号信息:用 dealerCode 存储唯一键dealerName 存储展示文案
const buildHouseKey = (community: string, buildingNo: string, unitNo: string | undefined, roomNo: string) => {
@@ -159,6 +161,107 @@ 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 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),
};
};
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 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;
}
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;
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 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 (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;
};
const submitSucceed = async (values: any) => {
try {
@@ -272,6 +375,20 @@ 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();

View File

@@ -2,11 +2,11 @@ import {Cell} from '@nutui/nutui-react-taro'
import navTo from "@/utils/common";
import Taro from '@tarojs/taro'
import {View, Text} from '@tarojs/components'
import {ArrowRight, ShieldCheck, LogisticsError, Location, Tips, Ask} from '@nutui/icons-react-taro'
import {ArrowRight, LogisticsError, Tips, Ask} from '@nutui/icons-react-taro'
import {useUser} from '@/hooks/useUser'
const UserCell = () => {
const {logoutUser, isCertified} = useUser();
const {logoutUser} = useUser();
const onLogout = () => {
Taro.showModal({
@@ -50,37 +50,37 @@ const UserCell = () => {
navTo('/user/wallet/index', true)
}}
/>
<Cell
className="nutui-cell-clickable"
title={
<View style={{display: 'inline-flex', alignItems: 'center'}}>
<Location size={16}/>
<Text className={'pl-3 text-sm'}></Text>
</View>
}
align="center"
extra={<ArrowRight color="#cccccc" size={18}/>}
onClick={() => {
navTo('/user/address/index', true)
}}
/>
<Cell
className="nutui-cell-clickable"
title={
<View style={{display: 'inline-flex', alignItems: 'center'}}>
<ShieldCheck size={16} color={isCertified() ? '#52c41a' : '#666'}/>
<Text className={'pl-3 text-sm'}></Text>
{isCertified() && (
<Text className={'pl-2 text-xs text-green-500'}></Text>
)}
</View>
}
align="center"
extra={<ArrowRight color="#cccccc" size={18}/>}
onClick={() => {
navTo('/user/userVerify/index', true)
}}
/>
{/*<Cell*/}
{/* className="nutui-cell-clickable"*/}
{/* title={*/}
{/* <View style={{display: 'inline-flex', alignItems: 'center'}}>*/}
{/* <Location size={16}/>*/}
{/* <Text className={'pl-3 text-sm'}>收货地址</Text>*/}
{/* </View>*/}
{/* }*/}
{/* align="center"*/}
{/* extra={<ArrowRight color="#cccccc" size={18}/>}*/}
{/* onClick={() => {*/}
{/* navTo('/user/address/index', true)*/}
{/* }}*/}
{/*/>*/}
{/*<Cell*/}
{/* className="nutui-cell-clickable"*/}
{/* title={*/}
{/* <View style={{display: 'inline-flex', alignItems: 'center'}}>*/}
{/* <ShieldCheck size={16} color={isCertified() ? '#52c41a' : '#666'}/>*/}
{/* <Text className={'pl-3 text-sm'}>实名认证</Text>*/}
{/* {isCertified() && (*/}
{/* <Text className={'pl-2 text-xs text-green-500'}>已认证</Text>*/}
{/* )}*/}
{/* </View>*/}
{/* }*/}
{/* align="center"*/}
{/* extra={<ArrowRight color="#cccccc" size={18}/>}*/}
{/* onClick={() => {*/}
{/* navTo('/user/userVerify/index', true)*/}
{/* }}*/}
{/*/>*/}
<Cell
className="nutui-cell-clickable"
title={

View File

@@ -8,7 +8,6 @@ import {useUser} from "@/hooks/useUser";
import {NavBar} from '@nutui/nutui-react-taro';
import {Home} from '@nutui/icons-react-taro'
import './user.scss'
import IsDealer from "./components/IsDealer";
function User() {
const [statusBarHeight, setStatusBarHeight] = useState<number>()
@@ -34,7 +33,6 @@ function User() {
}}>
<UserCard/>
<UserOrder/>
<IsDealer/>
<UserCell/>
<UserFooter/>
</div>
@@ -70,7 +68,6 @@ function User() {
}}>
<UserCard/>
{/*<UserOrder/>*/}
<IsDealer/>
<UserCell/>
<UserFooter/>
</div>