From 7f6d9a84d67dcace78ab54aa0a1e76ca565e4550 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 5 Nov 2026 10:51:36 +0330 Subject: [PATCH] fix(contacts): treat Iranian mobile formats as one person Tahesab import still replaces the name and keeps the previous name. Co-authored-by: Cursor --- .../migration.sql | 6 + apps/api/prisma/schema.prisma | 6 + .../activities/activities-excel.service.ts | 13 +- apps/api/src/contacts/contacts.module.ts | 3 +- apps/api/src/contacts/merge-same-mobile.ts | 346 ++++++++++++++++++ apps/api/src/contacts/normalize.ts | 33 ++ apps/api/src/contacts/people.service.ts | 25 +- .../src/contacts/same-mobile-merge.service.ts | 71 ++++ .../src/integrations/montify/sync.service.ts | 16 +- .../tahesab/person-link.service.ts | 18 +- .../src/integrations/voip/calls.service.ts | 3 +- .../integrations/woocommerce/sync.service.ts | 13 +- apps/api/src/lead-forms/lead-forms.service.ts | 24 +- apps/api/src/leads/leads.service.ts | 44 +-- apps/api/src/pipelines/deals-excel.service.ts | 13 +- apps/api/src/portal/portal.service.ts | 19 +- .../project-cards-excel.service.ts | 12 +- 17 files changed, 586 insertions(+), 79 deletions(-) create mode 100644 apps/api/prisma/migrations/20261105103000_data_fix_flag/migration.sql create mode 100644 apps/api/src/contacts/merge-same-mobile.ts create mode 100644 apps/api/src/contacts/same-mobile-merge.service.ts diff --git a/apps/api/prisma/migrations/20261105103000_data_fix_flag/migration.sql b/apps/api/prisma/migrations/20261105103000_data_fix_flag/migration.sql new file mode 100644 index 0000000..8caddf4 --- /dev/null +++ b/apps/api/prisma/migrations/20261105103000_data_fix_flag/migration.sql @@ -0,0 +1,6 @@ +CREATE TABLE "DataFix" ( + "key" TEXT NOT NULL, + "doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DataFix_pkey" PRIMARY KEY ("key") +); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 76bb1ef..5e5b710 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -3779,3 +3779,9 @@ model AttendanceEvent { @@index([organizationId, userId, at]) @@unique([organizationId, userId, kind, sessionStartedAt]) } + +/// علامت یک‌بار اجرای اصلاح داده +model DataFix { + key String @id + doneAt DateTime @default(now()) +} diff --git a/apps/api/src/activities/activities-excel.service.ts b/apps/api/src/activities/activities-excel.service.ts index 13e38d2..94aa74f 100644 --- a/apps/api/src/activities/activities-excel.service.ts +++ b/apps/api/src/activities/activities-excel.service.ts @@ -3,7 +3,7 @@ import type { ActivityType, ImportContactsResult } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import type { Actor } from '../common/record-scope'; import { assigneeWhere } from '../common/record-scope'; -import { normalizePhone } from '../contacts/normalize'; +import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize'; import { excelFieldsFromColumns, formatClock, @@ -357,7 +357,7 @@ export class ActivitiesExcelService { return id; }; const resolvePerson = async (row: Record) => { - const mobile = normalizePhone(row.personMobile); + const mobile = storedPhoneNumber(row.personMobile); const name = (row.personName ?? '').trim().toLowerCase(); const key = mobile ? `m:${mobile}` : name ? `n:${name}` : ''; if (!key) return null; @@ -478,10 +478,13 @@ export class ActivitiesExcelService { } private async findPersonId(organizationId: string, row: Record) { - const mobile = normalizePhone(row.personMobile); - if (mobile) { + const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile)); + if (mobileKeys.length) { const byMobile = await this.prisma.person.findFirst({ - where: { organizationId, mobile: { contains: mobile.replace(/^\+98|^98|^0/, '') } }, + where: { + organizationId, + OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }], + }, select: { id: true }, }); if (byMobile) return byMobile.id; diff --git a/apps/api/src/contacts/contacts.module.ts b/apps/api/src/contacts/contacts.module.ts index 0c21610..5fa3765 100644 --- a/apps/api/src/contacts/contacts.module.ts +++ b/apps/api/src/contacts/contacts.module.ts @@ -8,6 +8,7 @@ import { CompaniesService } from './companies.service'; import { PeopleController } from './people.controller'; import { PeopleService } from './people.service'; import { ContactsExcelService } from './contacts-excel.service'; +import { SameMobileMergeService } from './same-mobile-merge.service'; import { BehaviorModule } from '../behavior/behavior.module'; import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'; @@ -21,7 +22,7 @@ import { CustomerGroupsModule } from '../customer-groups/customer-groups.module' CustomerGroupsModule, ], controllers: [PeopleController, CompaniesController], - providers: [PeopleService, CompaniesService, ContactsExcelService], + providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService], exports: [PeopleService, CompaniesService], }) export class ContactsModule {} diff --git a/apps/api/src/contacts/merge-same-mobile.ts b/apps/api/src/contacts/merge-same-mobile.ts new file mode 100644 index 0000000..5de6701 --- /dev/null +++ b/apps/api/src/contacts/merge-same-mobile.ts @@ -0,0 +1,346 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { Prisma, PrismaClient } from '@prisma/client'; +import { canonicalIranMobile, storedPhoneNumber } from './normalize'; + +const personSelect = { + id: true, + createdAt: true, + firstName: true, + lastName: true, + gender: true, + showGenderInName: true, + previousFirstName: true, + previousLastName: true, + email: true, + phone: true, + mobile: true, + position: true, + website: true, + instagram: true, + birthDate: true, + referrer: true, + addresses: true, + notes: true, + tags: true, + companyId: true, + customFields: true, + importSource: true, + loyaltyPoints: true, + discLabel: true, + discReliable: true, + discAssessedAt: true, +} satisfies Prisma.PersonSelect; + +type PersonRow = Prisma.PersonGetPayload<{ select: typeof personSelect }>; + +function blank(value: string | null | undefined) { + return !value?.trim(); +} + +function asStrings(value: Prisma.JsonValue): string[] { + if (!Array.isArray(value)) return []; + return value.map((item) => String(item)).filter(Boolean); +} + +function asObject(value: Prisma.JsonValue): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return { ...(value as Record) }; +} + +function mergedFields(keeper: PersonRow, extras: PersonRow[], canon: string): Prisma.PersonUncheckedUpdateInput { + const notes = [keeper.notes?.trim() || '']; + let email = keeper.email; + let phone = storedPhoneNumber(keeper.phone); + let position = keeper.position; + let website = keeper.website; + let instagram = keeper.instagram; + let birthDate = keeper.birthDate; + let referrer = keeper.referrer; + let companyId = keeper.companyId; + let gender = keeper.gender; + let showGenderInName = keeper.showGenderInName; + let previousFirstName = keeper.previousFirstName; + let previousLastName = keeper.previousLastName; + let discLabel = keeper.discLabel; + let discReliable = keeper.discReliable; + let discAssessedAt = keeper.discAssessedAt; + let loyaltyPoints = keeper.loyaltyPoints; + const addresses = asStrings(keeper.addresses); + const tags = new Set(keeper.tags); + const customFields = asObject(keeper.customFields); + + for (const extra of extras) { + const name = `${extra.firstName} ${extra.lastName}`.trim(); + notes.push( + `ادغام شماره تکراری: ${name || 'بدون نام'} (${extra.mobile || 'بدون موبایل'})`, + ); + if (blank(email) && extra.email) email = extra.email; + if (blank(phone)) phone = storedPhoneNumber(extra.phone); + if (blank(position) && extra.position) position = extra.position; + if (blank(website) && extra.website) website = extra.website; + if (blank(instagram) && extra.instagram) instagram = extra.instagram; + if (!birthDate && extra.birthDate) birthDate = extra.birthDate; + if (blank(referrer) && extra.referrer) referrer = extra.referrer; + if (!companyId && extra.companyId) companyId = extra.companyId; + if (gender === 'none' && extra.gender !== 'none') { + gender = extra.gender; + showGenderInName = extra.showGenderInName; + } + if (blank(previousFirstName) && extra.previousFirstName) { + previousFirstName = extra.previousFirstName; + } + if (blank(previousLastName) && extra.previousLastName) { + previousLastName = extra.previousLastName; + } + if ( + keeper.importSource === 'tahesab' && + blank(previousFirstName) && + blank(previousLastName) + ) { + const extraName = `${extra.firstName} ${extra.lastName}`.trim(); + const keeperName = `${keeper.firstName} ${keeper.lastName}`.trim(); + if (extraName && extraName !== keeperName) { + previousFirstName = extra.firstName || null; + previousLastName = extra.lastName || null; + } + } + if (!discLabel && extra.discLabel) { + discLabel = extra.discLabel; + discReliable = extra.discReliable; + discAssessedAt = extra.discAssessedAt; + } + loyaltyPoints = Math.max(loyaltyPoints, extra.loyaltyPoints); + for (const address of asStrings(extra.addresses)) { + if (!addresses.includes(address)) addresses.push(address); + } + for (const tag of extra.tags) tags.add(tag); + for (const [key, value] of Object.entries(asObject(extra.customFields))) { + if (customFields[key] == null || customFields[key] === '') customFields[key] = value; + } + } + + return { + mobile: canon, + email, + phone, + position, + website, + instagram, + birthDate, + referrer, + companyId, + gender, + showGenderInName, + previousFirstName, + previousLastName, + discLabel, + discReliable, + discAssessedAt, + loyaltyPoints, + addresses, + tags: [...tags], + customFields: customFields as Prisma.InputJsonValue, + notes: notes.filter(Boolean).join('\n') || null, + }; +} + +async function personIdTables(prisma: PrismaClient | Prisma.TransactionClient) { + const rows = await prisma.$queryRaw<{ table_name: string }[]>` + SELECT table_name + FROM information_schema.columns + WHERE table_schema = 'public' AND column_name = 'personId' + `; + return rows + .map((row) => row.table_name) + .filter((name) => /^[A-Za-z0-9_]+$/.test(name) && name !== 'Person'); +} + +async function moveLinks( + tx: Prisma.TransactionClient, + tables: string[], + keeperId: string, + dupId: string, +) { + await tx.$executeRaw` + DELETE FROM "PersonGroupLink" AS d + USING "PersonGroupLink" AS k + WHERE d."personId" = ${dupId} + AND k."personId" = ${keeperId} + AND d."groupId" = k."groupId" + `; + await tx.$executeRaw` + DELETE FROM "PersonAccountingLink" AS d + USING "PersonAccountingLink" AS k + WHERE d."personId" = ${dupId} + AND k."personId" = ${keeperId} + AND d."connectionId" = k."connectionId" + `; + await tx.$executeRaw` + DELETE FROM "PartyOpeningBalance" + WHERE "personId" = ${dupId} + AND EXISTS ( + SELECT 1 FROM "PartyOpeningBalance" AS k WHERE k."personId" = ${keeperId} + ) + `; + + for (const table of tables) { + await tx.$executeRawUnsafe( + `UPDATE "${table}" SET "personId" = $1 WHERE "personId" = $2`, + keeperId, + dupId, + ); + } + + await tx.$executeRaw` + UPDATE "Lead" SET "convertedPersonId" = ${keeperId} WHERE "convertedPersonId" = ${dupId} + `; + await tx.$executeRaw` + UPDATE "MontifyLink" + SET "entityId" = ${keeperId} + WHERE "entityType" = 'customer' AND "entityId" = ${dupId} + `; + await tx.$executeRaw` + UPDATE "Notification" + SET "entityId" = ${keeperId} + WHERE "entityType" = 'person' AND "entityId" = ${dupId} + `; + await tx.$executeRaw` + UPDATE "GamificationTransaction" + SET "entityId" = ${keeperId} + WHERE "entityType" = 'person' AND "entityId" = ${dupId} + `; + await tx.$executeRaw` + UPDATE "FinancialAuditLog" + SET "entityId" = ${keeperId} + WHERE "entityType" = 'person' AND "entityId" = ${dupId} + `; +} + +export async function mergeSameMobilePeople( + prisma: PrismaClient, + organizationId: string, + apply: boolean, +) { + const people = await prisma.person.findMany({ + where: { organizationId }, + select: personSelect, + orderBy: { createdAt: 'asc' }, + }); + const groups = new Map(); + for (const person of people) { + const key = canonicalIranMobile(person.mobile); + if (!key) continue; + const list = groups.get(key) ?? []; + list.push(person); + groups.set(key, list); + } + + const duplicateGroups = [...groups.entries()].filter(([, list]) => list.length > 1); + const preview = duplicateGroups.map(([mobile, list]) => ({ + mobile, + people: list.map((person) => ({ + id: person.id, + stored: person.mobile, + name: `${person.firstName} ${person.lastName}`.trim(), + })), + })); + + if (!apply) { + const needsRewrite = people.filter((person) => { + const canon = canonicalIranMobile(person.mobile); + return Boolean(canon && person.mobile !== canon); + }).length; + return { merged: 0, rewritten: 0, needsRewrite, groups: preview }; + } + + const tables = await personIdTables(prisma); + let merged = 0; + let rewritten = 0; + + for (const [canon, list] of groups) { + const ordered = [...list].sort((a, b) => { + const aTahesab = a.importSource === 'tahesab' ? 0 : 1; + const bTahesab = b.importSource === 'tahesab' ? 0 : 1; + if (aTahesab !== bTahesab) return aTahesab - bTahesab; + return a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id); + }); + const [keeper, ...extras] = ordered; + if (!keeper) continue; + if (!extras.length) { + const phone = storedPhoneNumber(keeper.phone); + if (keeper.mobile !== canon || (phone && keeper.phone !== phone)) { + await prisma.person.update({ + where: { id: keeper.id }, + data: { + mobile: canon, + ...(phone && keeper.phone !== phone ? { phone } : {}), + }, + }); + rewritten += 1; + } + continue; + } + + await prisma.$transaction( + async (tx) => { + await tx.person.update({ + where: { id: keeper.id }, + data: mergedFields(keeper, extras, canon), + }); + for (const extra of extras) { + await moveLinks(tx, tables, keeper.id, extra.id); + await tx.person.delete({ where: { id: extra.id } }); + merged += 1; + } + }, + { timeout: 60_000 }, + ); + } + + return { merged, rewritten, needsRewrite: 0, groups: preview }; +} + +function loadEnv(file: string) { + const text = readFileSync(file, 'utf8'); + for (const line of text.split(/\r?\n/)) { + const match = line.match(/^([A-Za-z0-9_]+)=(.*)$/); + if (!match || process.env[match[1]]) continue; + let value = match[2].trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + process.env[match[1]] = value; + } +} + +const direct = process.argv[1]?.replace(/\\/g, '/').includes('merge-same-mobile'); +if (direct) { + loadEnv(resolve(__dirname, '../../.env')); + const orgId = process.argv.find((arg) => arg.startsWith('--org='))?.slice(6); + const apply = process.argv.includes('--apply'); + if (!orgId) { + console.error('org id required: --org='); + process.exit(1); + } + const prisma = new PrismaClient(); + mergeSameMobilePeople(prisma, orgId, apply) + .then((result) => { + console.log(JSON.stringify({ + apply, + merged: result.merged, + rewritten: result.rewritten, + needsRewrite: result.needsRewrite, + groupCount: result.groups.length, + groups: result.groups, + }, null, 2)); + }) + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => prisma.$disconnect()); +} diff --git a/apps/api/src/contacts/normalize.ts b/apps/api/src/contacts/normalize.ts index bbd4ded..a12db1b 100644 --- a/apps/api/src/contacts/normalize.ts +++ b/apps/api/src/contacts/normalize.ts @@ -27,3 +27,36 @@ export function normalizePhone(value?: string | null): string | null { const cleaned = latinDigits(v).replace(/[^\d+]/g, ''); return cleaned.length ? cleaned : null; } + +/** موبایل ایران به شکل 09xxxxxxxxx. 912… و 98912… و +98912… همان شماره‌اند. */ +export function canonicalIranMobile(value?: string | null): string | null { + const n = normalizePhone(value); + if (!n) return null; + let d = n.replace(/\D/g, ''); + if (!d) return null; + if (d.startsWith('0098') && d.length >= 14) d = d.slice(4); + else if (d.startsWith('98') && d.length >= 12) d = d.slice(2); + if (d.length === 10 && d.startsWith('9')) d = `0${d}`; + return /^09\d{9}$/.test(d) ? d : null; +} + +/** شکل‌هایی که ممکن است همین موبایل با آن‌ها ذخیره شده باشد. */ +export function iranMobileLookupKeys(value?: string | null): string[] { + const canon = canonicalIranMobile(value); + if (!canon) return []; + const core = canon.slice(1); + return [canon, core, `98${core}`, `+98${core}`, `0098${core}`]; +} + +/** برای تطبیق: اگر موبایل ایران است همه شکل‌ها، وگرنه همان متن نرمال‌شده. */ +export function phoneIdentityKeys(value?: string | null): string[] { + const keys = iranMobileLookupKeys(value); + if (keys.length) return keys; + const n = normalizePhone(value); + return n ? [n] : []; +} + +/** ذخیره: موبایل ایران همیشه 09… ؛ بقیه شماره‌ها فقط تمیز می‌شوند. */ +export function storedPhoneNumber(value?: string | null): string | null { + return canonicalIranMobile(value) ?? normalizePhone(value); +} diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index 3e5e748..9fce8b6 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -28,7 +28,12 @@ import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service'; import { CompaniesService } from './companies.service'; -import { emptyToNull, normalizeEmail, normalizePhone } from './normalize'; +import { + emptyToNull, + normalizeEmail, + phoneIdentityKeys, + storedPhoneNumber, +} from './normalize'; import { asAddresses, asCustomFields, @@ -399,7 +404,10 @@ export class PeopleService implements OnModuleInit { input: { mobile?: string | null; email?: string | null; birthDate: Date }, ) { const or: Prisma.PersonWhereInput[] = []; - if (input.mobile) or.push({ mobile: input.mobile }); + const mobileKeys = phoneIdentityKeys(input.mobile); + if (mobileKeys.length) { + or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }); + } if (input.email) or.push({ email: input.email }); if (!or.length) return; await this.prisma.person.updateMany({ @@ -441,8 +449,8 @@ export class PeopleService implements OnModuleInit { importSource: normalizePersonImportSource(dto.importSource), sourceRef: emptyToNull(dto.sourceRef), email: normalizeEmail(dto.email), - phone: normalizePhone(dto.phone), - mobile: normalizePhone(dto.mobile), + phone: storedPhoneNumber(dto.phone), + mobile: storedPhoneNumber(dto.mobile), position: emptyToNull(dto.position), website: emptyToNull(dto.website), instagram: emptyToNull(dto.instagram), @@ -553,8 +561,8 @@ export class PeopleService implements OnModuleInit { } if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef); if ('email' in dto) data.email = normalizeEmail(dto.email); - if ('phone' in dto) data.phone = normalizePhone(dto.phone); - if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile); + if ('phone' in dto) data.phone = storedPhoneNumber(dto.phone); + if ('mobile' in dto) data.mobile = storedPhoneNumber(dto.mobile); if ('position' in dto) data.position = emptyToNull(dto.position); if ('website' in dto) data.website = emptyToNull(dto.website); if ('instagram' in dto) data.instagram = emptyToNull(dto.instagram); @@ -641,10 +649,13 @@ export class PeopleService implements OnModuleInit { ) { const email = typeof data.email === 'string' ? data.email : null; const mobile = typeof data.mobile === 'string' ? data.mobile : null; + const mobileKeys = phoneIdentityKeys(mobile); const or: Prisma.PersonWhereInput[] = []; if (email) or.push({ email }); - if (mobile) or.push({ mobile }); + if (mobileKeys.length) { + or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }); + } if (!or.length) return; const clash = await this.prisma.person.findFirst({ diff --git a/apps/api/src/contacts/same-mobile-merge.service.ts b/apps/api/src/contacts/same-mobile-merge.service.ts new file mode 100644 index 0000000..f0bbc96 --- /dev/null +++ b/apps/api/src/contacts/same-mobile-merge.service.ts @@ -0,0 +1,71 @@ +import { Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { mergeSameMobilePeople } from './merge-same-mobile'; + +const FIX_KEY = 'person-mobile-canonical-v1'; + +@Injectable() +export class SameMobileMergeService implements OnApplicationBootstrap { + private readonly logger = new Logger(SameMobileMergeService.name); + + constructor(private readonly prisma: PrismaService) {} + + onApplicationBootstrap() { + if (process.env.NODE_ENV === 'test') return; + void this.run(); + } + + private async run() { + try { + const ready = await this.prisma.$queryRaw<{ ok: number }[]>` + SELECT 1 AS ok + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'Person' + AND column_name = 'gender' + `; + if (!ready.length) return; + + const table = await this.prisma.$queryRaw<{ ok: number }[]>` + SELECT 1 AS ok + FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'DataFix' + `; + if (!table.length) return; + + const done = await this.prisma.$queryRaw<{ key: string }[]>` + SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY} + `; + if (done.length) return; + + await this.prisma.$executeRaw`SELECT pg_advisory_lock(86051205)`; + try { + const again = await this.prisma.$queryRaw<{ key: string }[]>` + SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY} + `; + if (again.length) return; + + const orgs = await this.prisma.organization.findMany({ select: { id: true } }); + let merged = 0; + let rewritten = 0; + for (const org of orgs) { + const result = await mergeSameMobilePeople(this.prisma, org.id, true); + merged += result.merged; + rewritten += result.rewritten; + } + await this.prisma.$executeRaw` + INSERT INTO "DataFix" ("key") VALUES (${FIX_KEY}) + ON CONFLICT ("key") DO NOTHING + `; + this.logger.log(`شماره‌های تکراری یکی شد: ${merged} ادغام، ${rewritten} بازنویسی`); + } finally { + await this.prisma.$executeRaw`SELECT pg_advisory_unlock(86051205)`; + } + } catch (error) { + this.logger.error( + 'یکسان‌سازی موبایل مشتری‌ها انجام نشد', + error instanceof Error ? error.stack : String(error), + ); + } + } +} diff --git a/apps/api/src/integrations/montify/sync.service.ts b/apps/api/src/integrations/montify/sync.service.ts index 7788e99..381f88d 100644 --- a/apps/api/src/integrations/montify/sync.service.ts +++ b/apps/api/src/integrations/montify/sync.service.ts @@ -17,7 +17,11 @@ import type { } from '@mento/shared'; import { parseBirthDate } from '../../common/excel'; import { PrismaService } from '../../prisma/prisma.service'; -import { normalizeEmail, normalizePhone } from '../../contacts/normalize'; +import { + normalizeEmail, + phoneIdentityKeys, + storedPhoneNumber, +} from '../../contacts/normalize'; import { carryPreviousSource } from '../../contacts/person-source'; import { ownerWhere, type Actor } from '../../common/record-scope'; import { MontifyConnectionsService } from './connections.service'; @@ -100,13 +104,7 @@ function arr(v: unknown): unknown[] { } function mobileVariants(mobile: string): string[] { - const digits = mobile.replace(/\D/g, ''); - let core = digits; - if (core.startsWith('0098')) core = core.slice(4); - else if (core.startsWith('98') && core.length === 12) core = core.slice(2); - else if (core.startsWith('0')) core = core.slice(1); - if (!core) return [mobile]; - return [...new Set([mobile, core, `0${core}`, `98${core}`, `+98${core}`])]; + return phoneIdentityKeys(mobile); } function formatAddress(a: MontifyAddress): string | null { @@ -417,7 +415,7 @@ export class MontifySyncService { }> { const externalId = String(c.id); const { firstName, lastName } = splitName(c); - const mobile = normalizePhone(c.phone ?? null); + const mobile = storedPhoneNumber(c.phone ?? null); const email = normalizeEmail(c.email ?? null); const addressLines = addresses.map(formatAddress).filter((a): a is string => Boolean(a)); const profile: CustomerProfile = { diff --git a/apps/api/src/integrations/tahesab/person-link.service.ts b/apps/api/src/integrations/tahesab/person-link.service.ts index a7fafca..1f54e44 100644 --- a/apps/api/src/integrations/tahesab/person-link.service.ts +++ b/apps/api/src/integrations/tahesab/person-link.service.ts @@ -9,6 +9,7 @@ import { import { Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { carryPreviousSource } from '../../contacts/person-source'; +import { phoneIdentityKeys, storedPhoneNumber } from '../../contacts/normalize'; import { applyPersonGenderTitle } from '@mento/shared'; import { TahesabConnectionsService } from './connections.service'; import { @@ -577,7 +578,7 @@ export class TahesabPersonLinkService { const named = applyPersonGenderTitle({ firstName: name, lastName: '' }); const firstName = named.firstName; const lastName = named.lastName; - const mobile = tel || null; + const mobile = storedPhoneNumber(tel); const sourceRef = String(dto.moshtariCode); let personId = dto.personId; @@ -606,8 +607,8 @@ export class TahesabPersonLinkService { ...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}), ...(mobile ? { - mobile: existing.mobile || mobile, - phone: existing.phone || mobile, + mobile: storedPhoneNumber(existing.mobile) || mobile, + phone: storedPhoneNumber(existing.phone) || mobile, } : {}), }, @@ -616,15 +617,18 @@ export class TahesabPersonLinkService { // اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین. // شخصی که روی همین اتصال به کد دیگری لینک است کنار گذاشته می‌شود؛ // وگرنه upsert لینک، کد قبلی را جابه‌جا و آن مشتری را دوباره «واردنشده» می‌کند. - const byMobile = mobile - ? await this.prisma.person.findFirst({ + const mobileKeys = phoneIdentityKeys(mobile); + const matches = mobileKeys.length + ? await this.prisma.person.findMany({ where: { organizationId, - OR: [{ mobile }, { phone: mobile }], + OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }], accountingLinks: { none: { connectionId: dto.connectionId } }, }, + orderBy: { createdAt: 'asc' }, }) - : null; + : []; + const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null; if (byMobile) { const nameChanged = `${byMobile.firstName} ${byMobile.lastName}`.trim() !== diff --git a/apps/api/src/integrations/voip/calls.service.ts b/apps/api/src/integrations/voip/calls.service.ts index 869affc..e7cfb9a 100644 --- a/apps/api/src/integrations/voip/calls.service.ts +++ b/apps/api/src/integrations/voip/calls.service.ts @@ -11,7 +11,7 @@ import type { } from '@mento/shared'; import { PrismaService } from '../../prisma/prisma.service'; import { NotificationsService } from '../../notifications/notifications.service'; -import { normalizePhone } from '../../contacts/normalize'; +import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize'; import { VoipConnectionsService } from './connections.service'; function digitsOnly(value: string | null | undefined): string { @@ -35,6 +35,7 @@ function phoneVariants(raw: string | null | undefined): string[] { variants.add(`0${d}`); variants.add(`98${d}`); } + for (const key of iranMobileLookupKeys(raw)) variants.add(key); return [...variants]; } diff --git a/apps/api/src/integrations/woocommerce/sync.service.ts b/apps/api/src/integrations/woocommerce/sync.service.ts index 17d18ec..759475c 100644 --- a/apps/api/src/integrations/woocommerce/sync.service.ts +++ b/apps/api/src/integrations/woocommerce/sync.service.ts @@ -6,7 +6,11 @@ import type { WooCommerceSyncResultDto, } from '@mento/shared'; import { PrismaService } from '../../prisma/prisma.service'; -import { normalizeEmail, normalizePhone } from '../../contacts/normalize'; +import { + normalizeEmail, + phoneIdentityKeys, + storedPhoneNumber, +} from '../../contacts/normalize'; import { WooConnectionsService } from './connections.service'; import type { WooOrder } from './woo-client'; @@ -374,7 +378,8 @@ export class WooSyncService { ): Promise { const billing = order.billing ?? {}; const email = normalizeEmail(billing.email ?? null); - const mobile = normalizePhone(billing.phone ?? null); + const mobile = storedPhoneNumber(billing.phone ?? null); + const mobileKeys = phoneIdentityKeys(mobile); const firstName = (billing.first_name ?? '').trim() || 'مشتری'; const lastName = (billing.last_name ?? '').trim() || 'ووکامرس'; @@ -387,7 +392,9 @@ export class WooSyncService { organizationId, OR: [ ...(email ? [{ email }] : []), - ...(mobile ? [{ mobile }, { phone: mobile }] : []), + ...(mobileKeys.length + ? [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }] + : []), ], }, select: { id: true }, diff --git a/apps/api/src/lead-forms/lead-forms.service.ts b/apps/api/src/lead-forms/lead-forms.service.ts index 9dfb411..28f9fd6 100644 --- a/apps/api/src/lead-forms/lead-forms.service.ts +++ b/apps/api/src/lead-forms/lead-forms.service.ts @@ -21,9 +21,12 @@ import { StorageQuotaService } from '../billing/storage-quota.service'; import { AutomationsService } from '../automations/automations.service'; import { MessageAutomationsService } from '../message-automations/message-automations.service'; import { + canonicalIranMobile, emptyToNull, normalizeEmail, normalizePhone, + phoneIdentityKeys, + storedPhoneNumber, } from '../contacts/normalize'; import { LeadStagesService } from '../leads/lead-stages.service'; @@ -458,7 +461,10 @@ export class LeadFormsService { ? await this.prisma.person.findFirst({ where: { organizationId: form.organizationId, - OR: [{ mobile }, { phone: mobile }], + OR: [ + { mobile: { in: phoneIdentityKeys(mobile) } }, + { phone: { in: phoneIdentityKeys(mobile) } }, + ], }, select: { id: true, firstName: true, lastName: true }, }) @@ -641,13 +647,16 @@ export class LeadFormsService { ownerId: string, ): Promise { const email = normalizeEmail(mapped.email); - const mobile = normalizePhone(mapped.mobile); - const phone = normalizePhone(mapped.phone); + const mobile = storedPhoneNumber(mapped.mobile); + const phone = storedPhoneNumber(mapped.phone); const or: Prisma.PersonWhereInput[] = []; if (email) or.push({ email }); - if (mobile) or.push({ mobile }); + const mobileKeys = phoneIdentityKeys(mobile); + if (mobileKeys.length) { + or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }); + } const existing = or.length ? await this.prisma.person.findFirst({ where: { organizationId, OR: or }, @@ -1115,13 +1124,12 @@ function slugKey(label: string): string { } function mobileKey(raw: string | null | undefined): string | null { + const canon = canonicalIranMobile(raw); + if (canon) return canon; const n = normalizePhone(raw); if (!n) return null; const d = n.replace(/\D/g, ''); - if (!d) return null; - if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`; - if (d.length === 10 && d.startsWith('9')) return `0${d}`; - return d.startsWith('0') ? d : d; + return d || null; } function normalizeDuplicateMode(raw?: string | null): string { diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index 4203328..29dcfc0 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -38,7 +38,12 @@ import { collectAddresses, toJsonAddresses, } from '../common/custom-fields'; -import { emptyToNull, normalizePhone } from '../contacts/normalize'; +import { + canonicalIranMobile, + emptyToNull, + normalizePhone, + phoneIdentityKeys, +} from '../contacts/normalize'; import { LocalFileStorage } from '../storage/local-file.storage'; import { StorageQuotaService } from '../billing/storage-quota.service'; import { LeadStagesService } from './lead-stages.service'; @@ -55,13 +60,20 @@ const LOGO_EXT: Record = { }; function mobileKey(raw: string | null | undefined): string | null { + const canon = canonicalIranMobile(raw); + if (canon) return canon; const n = normalizePhone(raw); if (!n) return null; const d = n.replace(/\D/g, ''); - if (!d) return null; - if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`; - if (d.length === 10 && d.startsWith('9')) return `0${d}`; - return d.startsWith('0') ? d : d; + return d || null; +} + +function personWithMobile(organizationId: string, mobile: string): Prisma.PersonWhereInput { + const keys = phoneIdentityKeys(mobile); + return { + organizationId, + OR: [{ mobile: { in: keys } }, { phone: { in: keys } }], + }; } function leadAccessWhere(actor: Actor): Prisma.LeadWhereInput { @@ -323,10 +335,7 @@ export class LeadsService { }; } const person = await this.prisma.person.findFirst({ - where: { - organizationId: actor.organizationId, - OR: [{ mobile }, { phone: mobile }], - }, + where: personWithMobile(actor.organizationId, mobile), select: { id: true, firstName: true, lastName: true }, }); if (person) { @@ -966,10 +975,7 @@ export class LeadsService { } let person = await this.prisma.person.findFirst({ - where: { - organizationId: actor.organizationId, - OR: [{ mobile: lead.mobile }, { phone: lead.mobile }], - }, + where: personWithMobile(actor.organizationId, lead.mobile), }); let createdPerson = false; @@ -996,7 +1002,7 @@ export class LeadsService { lastName: named.lastName, gender: named.gender, showGenderInName: named.showGenderInName, - mobile: lead.mobile, + mobile: canonicalIranMobile(lead.mobile) ?? lead.mobile, phone: lead.phone, instagram: lead.instagram, birthDate: lead.birthDate, @@ -1222,10 +1228,7 @@ export class LeadsService { } const person = await this.prisma.person.findFirst({ - where: { - organizationId: input.organizationId, - OR: [{ mobile }, { phone: mobile }], - }, + where: personWithMobile(input.organizationId, mobile), select: { id: true }, }); if (person) { @@ -1304,10 +1307,7 @@ export class LeadsService { } const existingPerson = await this.prisma.person.findFirst({ - where: { - organizationId, - OR: [{ mobile }, { phone: mobile }], - }, + where: personWithMobile(organizationId, mobile), select: { id: true }, }); if (existingPerson) { diff --git a/apps/api/src/pipelines/deals-excel.service.ts b/apps/api/src/pipelines/deals-excel.service.ts index 7fd68b4..be79d50 100644 --- a/apps/api/src/pipelines/deals-excel.service.ts +++ b/apps/api/src/pipelines/deals-excel.service.ts @@ -14,7 +14,7 @@ import type { Actor } from '../common/record-scope'; import { dealAccessWhere, resolveOwnerId } from '../common/record-scope'; import { dealPriorWhere } from '../common/prior-archive'; import { asCustomFields, toJsonCustomFields } from '../common/custom-fields'; -import { normalizePhone } from '../contacts/normalize'; +import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize'; import { excelFieldsFromColumns, formatJalaliDate, @@ -708,9 +708,9 @@ export class DealsExcelService { ): Promise { const orgId = actor.organizationId; const code = cleanCode(row.personCode); - const mobile = normalizePhone(row.personMobile); - const mobileCore = mobile ? mobile.replace(/^\+98|^98|^0/, '') : ''; - const validMobile = mobileCore.length >= 8 ? mobile : null; + const mobile = storedPhoneNumber(row.personMobile); + const mobileKeys = phoneIdentityKeys(mobile); + const validMobile = (mobile ?? '').replace(/\D/g, '').length >= 8 ? mobile : null; const first = (row.personFirstName ?? '').trim(); const last = (row.personName ?? '').trim(); const fullName = [first, last].filter(Boolean).join(' '); @@ -731,7 +731,10 @@ export class DealsExcelService { } if (!found && validMobile) { found = await this.prisma.person.findFirst({ - where: { organizationId: orgId, mobile: { contains: mobileCore } }, + where: { + organizationId: orgId, + OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }], + }, select: { id: true }, }); } diff --git a/apps/api/src/portal/portal.service.ts b/apps/api/src/portal/portal.service.ts index ba7bea7..1554577 100644 --- a/apps/api/src/portal/portal.service.ts +++ b/apps/api/src/portal/portal.service.ts @@ -19,6 +19,7 @@ import type { } from '@mento/shared'; import { TICKET_SLA_HOURS } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; +import { canonicalIranMobile, phoneIdentityKeys } from '../contacts/normalize'; import { SmsGatewayService } from '../sms-billing/sms-gateway.service'; import type { PortalActor } from './portal-auth'; @@ -62,7 +63,7 @@ export class PortalService { mobileRaw: string, ): Promise { const org = await this.findOrg(slug); - const mobile = normalizeMobile(mobileRaw); + const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw); if (!mobile) { throw new BadRequestException('شماره موبایل معتبر نیست'); } @@ -123,7 +124,8 @@ export class PortalService { codeRaw: string, ): Promise { const org = await this.findOrg(slug); - const mobile = normalizeMobile(mobileRaw); + const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw); + const mobileKeys = phoneIdentityKeys(mobile); const code = codeRaw.trim(); if (!mobile || !code) { throw new BadRequestException('موبایل یا کد ناقص است'); @@ -148,10 +150,15 @@ export class PortalService { data: { consumedAt: new Date() }, }); - let person = await this.prisma.person.findFirst({ - where: { organizationId: org.id, mobile }, - orderBy: { createdAt: 'asc' }, - }); + let person = mobileKeys.length + ? await this.prisma.person.findFirst({ + where: { + organizationId: org.id, + OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }], + }, + orderBy: { createdAt: 'asc' }, + }) + : null; if (!person) { person = await this.prisma.person.create({ data: { diff --git a/apps/api/src/project-boards/project-cards-excel.service.ts b/apps/api/src/project-boards/project-cards-excel.service.ts index 75609df..b9d7c32 100644 --- a/apps/api/src/project-boards/project-cards-excel.service.ts +++ b/apps/api/src/project-boards/project-cards-excel.service.ts @@ -3,7 +3,7 @@ import type { ImportContactsResult } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import type { Actor } from '../common/record-scope'; import { projectCardAccessWhere } from '../common/record-scope'; -import { normalizePhone } from '../contacts/normalize'; +import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize'; import { excelFieldsFromColumns, formatJalaliDate, @@ -256,11 +256,13 @@ export class ProjectCardsExcelService { } private async findPersonId(organizationId: string, row: Record) { - const mobile = normalizePhone(row.personMobile); - if (mobile) { - const needle = mobile.replace(/^\+98|^98|^0/, ''); + const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile)); + if (mobileKeys.length) { const byMobile = await this.prisma.person.findFirst({ - where: { organizationId, mobile: { contains: needle } }, + where: { + organizationId, + OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }], + }, select: { id: true }, }); if (byMobile) return byMobile.id;