fix(contacts): treat Iranian mobile formats as one person
Tahesab import still replaces the name and keeps the previous name. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 {}
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
return { ...(value as Record<string, unknown>) };
|
||||
}
|
||||
|
||||
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<string, PersonRow[]>();
|
||||
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=<id>');
|
||||
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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user