A Tahesab import now updates the person who already has that mobile instead of creating another, and the boot merge runs once more so existing pairs collapse. Co-authored-by: Cursor <cursoragent@cursor.com>
368 lines
11 KiB
TypeScript
368 lines
11 KiB
TypeScript
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,
|
|
sourceRef: true,
|
|
previousImportSource: true,
|
|
previousSourceRef: 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 previousImportSource = keeper.previousImportSource;
|
|
let previousSourceRef = keeper.previousSourceRef;
|
|
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 (
|
|
blank(previousImportSource) &&
|
|
extra.importSource &&
|
|
extra.importSource !== keeper.importSource
|
|
) {
|
|
previousImportSource = extra.importSource;
|
|
previousSourceRef = extra.sourceRef;
|
|
}
|
|
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,
|
|
previousImportSource,
|
|
previousSourceRef,
|
|
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) ?? canonicalIranMobile(person.phone);
|
|
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) ?? canonicalIranMobile(person.phone);
|
|
return Boolean(canon && (person.mobile !== canon || canonicalIranMobile(person.phone)));
|
|
}).length;
|
|
return { merged: 0, rewritten: 0, needsRewrite, failed: 0, groups: preview };
|
|
}
|
|
|
|
const tables = await personIdTables(prisma);
|
|
let merged = 0;
|
|
let rewritten = 0;
|
|
let failed = 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 phoneCanon = canonicalIranMobile(keeper.phone);
|
|
const data: Prisma.PersonUpdateInput = {};
|
|
if (keeper.mobile !== canon) data.mobile = canon;
|
|
if (phoneCanon && keeper.phone !== phoneCanon) data.phone = phoneCanon;
|
|
if (Object.keys(data).length) {
|
|
await prisma.person.update({ where: { id: keeper.id }, data });
|
|
rewritten += 1;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
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 },
|
|
);
|
|
} catch (error) {
|
|
failed += 1;
|
|
console.error(
|
|
`merge mobile ${canon} failed`,
|
|
error instanceof Error ? error.message : error,
|
|
);
|
|
}
|
|
}
|
|
|
|
return { merged, rewritten, needsRewrite: 0, failed, 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());
|
|
}
|