Files
CRM_Montzar/apps/api/src/integrations/tahesab/person-link.service.ts
T
MahdiandCursor 4764562bd4 fix(tahesab): stop treating shared phone numbers as new customers
A second Tahesab code with the same phone stays linked to the existing person, so the import list no longer counts it as unimported and sync no longer overwrites that person's name.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 14:42:46 +03:30

1371 lines
45 KiB
TypeScript

import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
type OnModuleInit,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { carryPreviousSource, namesDiffer } from '../../contacts/person-source';
import {
canonicalIranMobile,
phoneIdentityKeys,
storedPhoneNumber,
} from '../../contacts/normalize';
import { applyPersonGenderTitle } from '@mento/shared';
import { TahesabConnectionsService } from './connections.service';
import {
CreateInTahesabDto,
ExportPeopleToTahesabDto,
ImportBulkFromTahesabDto,
ImportFromTahesabDto,
ManualLinkDto,
MatchPersonDto,
SearchTahesabDto,
SyncGroupsPageDto,
} from './dto';
import { formatJalaliDate, parseBirthDate } from '../../common/excel';
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
/** موبایل ته‌حساب را به شکل 0912… ذخیره می‌کند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */
function canonicalMobileUpdate(
incoming: string | null,
current: { mobile: string | null; phone: string | null },
): { mobile?: string; phone?: string } {
const mobile =
canonicalIranMobile(incoming) ??
canonicalIranMobile(current.mobile) ??
canonicalIranMobile(current.phone);
if (!mobile) return {};
const phone = canonicalIranMobile(current.phone);
return phone ? { mobile, phone } : { mobile };
}
/** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */
const LIST_PAGE_SIZE = 2000;
const LIST_TIMEOUT_MS = 55_000;
function asMoshtariList(raw: unknown): Array<Record<string, unknown>> {
if (!raw) return [];
if (Array.isArray(raw)) return raw as Array<Record<string, unknown>>;
if (typeof raw === 'object') {
const obj = raw as Record<string, unknown>;
// پاسخ گاهی دیکشنری با کلید عددی است
const vals = Object.values(obj);
if (vals.every((v) => v && typeof v === 'object')) {
return vals as Array<Record<string, unknown>>;
}
}
return [];
}
function pickCode(row: Record<string, unknown>): number | null {
const c =
row.Moshtari_Code ??
row.Code ??
row.MCode ??
row.code ??
row.moshtari_code;
const n = Number(c);
return Number.isFinite(n) ? n : null;
}
function pickName(row: Record<string, unknown>): string {
return String(
row.Moshtari_Name ?? row.Name ?? row.name ?? row.FullName ?? '',
);
}
function pickTel(row: Record<string, unknown>): string {
return String(row.Tel ?? row.Phone ?? row.Mobile ?? row.tel ?? '');
}
function pickBirthDate(row: Record<string, unknown>): Date | null {
const raw =
row.BDate ??
row.bDate ??
row.Tarikh_Tavalod ??
row.tarikh_tavalod ??
row.BirthDate;
if (raw == null) return null;
return parseBirthDate(String(raw));
}
function firstAddress(raw: unknown): string | undefined {
if (!Array.isArray(raw)) return undefined;
const first = raw.find((a) => typeof a === 'string' && a.trim());
return typeof first === 'string' ? first.trim() : undefined;
}
/** گروه مشتری در ته‌حساب (فیلد نام در پاسخ ته‌حساب «GoroupName» غلط‌املایی دارد) */
function pickGroup(row: Record<string, unknown>): { gid: string | null; name: string } | null {
const rawGid = row.GID ?? row.Gid ?? row.GroupID ?? row.GroupId;
const rawName = row.GoroupName ?? row.GroupName ?? row.Group_Name ?? row.Goroup;
if (rawGid == null && rawName == null) return null;
const gid = rawGid == null ? null : String(rawGid).trim() || null;
const name = rawName == null ? '' : String(rawName).trim();
if (!gid && !name) return null;
return { gid, name };
}
@Injectable()
export class TahesabPersonLinkService {
private readonly logger = new Logger(TahesabPersonLinkService.name);
constructor(
private readonly prisma: PrismaService,
private readonly connections: TahesabConnectionsService,
private readonly groups: CustomerGroupsService,
) {}
/**
* گروه ته‌حساب روی شخص: فقط گروهِ آمده از ته‌حساب عوض می‌شود و گروه‌های دستی می‌مانند.
* @returns آیا گروه شخص عوض شد
*/
private async applyTahesabGroup(
organizationId: string,
connectionId: string,
personId: string,
row: Record<string, unknown> | undefined,
): Promise<boolean> {
const group = row ? pickGroup(row) : null;
if (!group) return false;
try {
const groupId = await this.groups.ensureTahesabGroup(
organizationId,
connectionId,
group.gid,
group.name,
);
return await this.groups.setPersonTahesabGroup(personId, groupId);
} catch (err) {
this.logger.warn(
`گروه ته‌حساب برای شخص ${personId} ثبت نشد: ${err instanceof Error ? err.message : err}`,
);
return false;
}
}
private async getPerson(organizationId: string, personId: string) {
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId },
});
if (!person) throw new NotFoundException('شخص یافت نشد.');
return person;
}
private linkDto(link: {
id: string;
personId: string;
connectionId: string;
moshtariCode: number;
matchMethod: string;
linkedAt: Date;
lastBalanceSyncedAt: Date | null;
connection?: { label: string; dbName: string; isDefault: boolean };
}) {
return {
id: link.id,
personId: link.personId,
connectionId: link.connectionId,
moshtariCode: link.moshtariCode,
matchMethod: link.matchMethod,
linkedAt: link.linkedAt.toISOString(),
lastBalanceSyncedAt: link.lastBalanceSyncedAt?.toISOString() ?? null,
connectionLabel: link.connection?.label,
dbName: link.connection?.dbName,
isDefaultConnection: link.connection?.isDefault,
};
}
async listLinks(organizationId: string, personId: string) {
await this.getPerson(organizationId, personId);
const links = await this.prisma.personAccountingLink.findMany({
where: {
personId,
connection: { organizationId, provider: 'tahesab' },
},
include: {
connection: { select: { label: true, dbName: true, isDefault: true } },
},
orderBy: { linkedAt: 'desc' },
});
return links.map((l) => this.linkDto(l));
}
async manualLink(
organizationId: string,
personId: string,
dto: ManualLinkDto,
) {
await this.getPerson(organizationId, personId);
await this.connections.getOrThrow(organizationId, dto.connectionId);
const clash = await this.prisma.personAccountingLink.findFirst({
where: {
connectionId: dto.connectionId,
moshtariCode: dto.moshtariCode,
personId: { not: personId },
},
});
if (clash) {
throw new ConflictException(
'این کد مشتری ته‌حساب قبلاً به شخص دیگری لینک شده است.',
);
}
const link = await this.prisma.personAccountingLink.upsert({
where: {
personId_connectionId: {
personId,
connectionId: dto.connectionId,
},
},
create: {
personId,
connectionId: dto.connectionId,
moshtariCode: dto.moshtariCode,
matchMethod: 'manual',
},
update: {
moshtariCode: dto.moshtariCode,
matchMethod: 'manual',
linkedAt: new Date(),
},
include: {
connection: { select: { label: true, dbName: true, isDefault: true } },
},
});
return this.linkDto(link);
}
async unlink(
organizationId: string,
personId: string,
connectionId: string,
) {
await this.getPerson(organizationId, personId);
const link = await this.prisma.personAccountingLink.findFirst({
where: {
personId,
connectionId,
connection: { organizationId },
},
});
if (!link) throw new NotFoundException('لینک یافت نشد.');
await this.prisma.personAccountingLink.delete({ where: { id: link.id } });
return { ok: true };
}
async match(
organizationId: string,
personId: string,
dto: MatchPersonDto,
) {
const person = await this.getPerson(organizationId, personId);
const { api } = await this.connections.getApi(
organizationId,
dto.connectionId,
);
const candidates: Array<{
moshtariCode: number;
name: string;
tel: string;
matchBy: 'mobile' | 'name';
raw: Record<string, unknown>;
}> = [];
const mobile = normalizeIranMobile(person.mobile ?? person.phone);
if (mobile) {
const raw = await api.doListMoshtariByPhone(mobile);
for (const row of asMoshtariList(raw)) {
const code = pickCode(row);
if (code == null) continue;
candidates.push({
moshtariCode: code,
name: pickName(row),
tel: pickTel(row),
matchBy: 'mobile',
raw: row,
});
}
}
if (candidates.length === 0) {
const name = `${person.firstName} ${person.lastName}`.trim();
if (name) {
const raw = await api.doListMoshtariByName(name);
for (const row of asMoshtariList(raw)) {
const code = pickCode(row);
if (code == null) continue;
candidates.push({
moshtariCode: code,
name: pickName(row),
tel: pickTel(row),
matchBy: 'name',
raw: row,
});
}
}
}
// unique by code
const uniq = new Map<number, (typeof candidates)[0]>();
for (const c of candidates) uniq.set(c.moshtariCode, c);
const list = [...uniq.values()];
if (dto.autoLinkIfUnique && list.length === 1) {
const only = list[0]!;
const link = await this.manualLink(organizationId, personId, {
connectionId: dto.connectionId,
moshtariCode: only.moshtariCode,
});
// override match method
const updated = await this.prisma.personAccountingLink.update({
where: { id: link.id },
data: { matchMethod: only.matchBy },
include: {
connection: {
select: { label: true, dbName: true, isDefault: true },
},
},
});
return {
status: 'linked' as const,
candidates: list,
link: this.linkDto(updated),
};
}
if (list.length === 0) {
return { status: 'none' as const, candidates: list, link: null };
}
if (list.length === 1) {
return { status: 'unique' as const, candidates: list, link: null };
}
return { status: 'ambiguous' as const, candidates: list, link: null };
}
async createInTahesab(
organizationId: string,
personId: string,
dto: CreateInTahesabDto,
) {
const person = await this.getPerson(organizationId, personId);
const existing = await this.prisma.personAccountingLink.findFirst({
where: { personId, connectionId: dto.connectionId },
});
if (existing) {
throw new ConflictException(
'این شخص از قبل به این اتصال لینک شده است.',
);
}
const { api } = await this.connections.getApi(
organizationId,
dto.connectionId,
);
const tel =
normalizeIranMobile(person.mobile ?? person.phone) ??
person.mobile ??
person.phone ??
'';
const extra = (person.customFields ?? {}) as Record<string, unknown>;
const nationalId = typeof extra.nationalId === 'string' ? extra.nationalId.trim() : '';
const groupName =
dto.groupName?.trim() || (await this.groups.firstGroupName(personId)) || 'CRM';
const args = SanadBuilders.newMoshtari({
name: `${person.firstName} ${person.lastName}`.trim(),
groupName,
tel,
address: firstAddress(person.addresses),
codeMeli: nationalId || undefined,
tarikhTavalod: person.birthDate ? formatJalaliDate(person.birthDate) : undefined,
jensFelez: dto.jensFelez ?? 0,
});
const res = await api.doNewMoshtari(args);
const code = Number(res?.OK);
if (!Number.isFinite(code) || code <= 0) {
throw new BadRequestException(
'ته‌حساب کد مشتری معتبر برنگرداند. پاسخ: ' + JSON.stringify(res),
);
}
const link = await this.prisma.personAccountingLink.create({
data: {
personId,
connectionId: dto.connectionId,
moshtariCode: code,
matchMethod: 'created',
},
include: {
connection: { select: { label: true, dbName: true, isDefault: true } },
},
});
return this.linkDto(link);
}
/**
* یک صفحه از مشتریان بر اساس بازهٔ کد:
* صفحه ۱ → ۱…pageSize، صفحه ۲ → pageSize+1…۲×pageSize، …
* سقف ۵۰۰۰ حذف شده؛ با پیجینیت می‌توان همه را مرور کرد.
*/
private async listMoshtariPage(
api: TahesabApi,
page: number,
pageSize?: number,
) {
const size = Math.min(
LIST_PAGE_SIZE,
Math.max(100, Math.floor(pageSize ?? LIST_PAGE_SIZE) || LIST_PAGE_SIZE),
);
const p = Math.max(1, Math.floor(page) || 1);
const fromCode = (p - 1) * size + 1;
const toCode = fromCode + size - 1;
const raw = await api.doListMoshtariByCode(fromCode, toCode, {
timeoutMs: LIST_TIMEOUT_MS,
});
return {
rows: asMoshtariList(raw),
fromCode,
toCode,
page: p,
pageSize: size,
};
}
/**
* کدهایی که لینک ندارند ولی شماره‌شان مال مشتریِ از قبل لینک‌شدهٔ همین اتصال است.
* یک شخص فقط یک کد ته‌حساب دارد؛ این کدها مشتری تازه نیستند.
*/
private async duplicateOwners(
organizationId: string,
connectionId: string,
rows: Array<{ moshtariCode: number | null; tel: string }>,
) {
const pending = rows.filter(
(row): row is { moshtariCode: number; tel: string } => row.moshtariCode != null,
);
const keys = [
...new Set(pending.flatMap((row) => phoneIdentityKeys(storedPhoneNumber(row.tel)))),
];
const out = new Map<
number,
{ moshtariCode: number; personId: string; personName: string }
>();
if (!keys.length) return out;
const people = await this.prisma.person.findMany({
where: {
organizationId,
OR: [{ mobile: { in: keys } }, { phone: { in: keys } }],
accountingLinks: { some: { connectionId } },
},
select: {
id: true,
firstName: true,
lastName: true,
mobile: true,
phone: true,
accountingLinks: {
where: { connectionId },
select: { moshtariCode: true },
take: 1,
},
},
});
const byKey = new Map<
string,
{ moshtariCode: number; personId: string; personName: string }
>();
for (const person of people) {
const link = person.accountingLinks[0];
if (!link) continue;
const info = {
moshtariCode: link.moshtariCode,
personId: person.id,
personName: `${person.firstName} ${person.lastName}`.trim(),
};
for (const key of [
...phoneIdentityKeys(person.mobile),
...phoneIdentityKeys(person.phone),
]) {
if (!byKey.has(key)) byKey.set(key, info);
}
}
for (const row of pending) {
for (const key of phoneIdentityKeys(storedPhoneNumber(row.tel))) {
const hit = byKey.get(key);
if (hit && hit.moshtariCode !== row.moshtariCode) {
out.set(row.moshtariCode, hit);
break;
}
}
}
return out;
}
async search(organizationId: string, dto: SearchTahesabDto) {
const { api } = await this.connections.getApi(
organizationId,
dto.connectionId,
);
let raw: unknown = [];
let listMeta:
| {
page: number;
pageSize: number;
fromCode: number;
toCode: number;
}
| undefined;
if (dto.listAll) {
const listed = await this.listMoshtariPage(
api,
dto.page ?? 1,
dto.pageSize,
);
raw = listed.rows;
listMeta = {
page: listed.page,
pageSize: listed.pageSize,
fromCode: listed.fromCode,
toCode: listed.toCode,
};
} else if (dto.fromCode != null && dto.toCode != null) {
if (dto.toCode < dto.fromCode) {
throw new BadRequestException('کد پایان باید بزرگ‌تر یا مساوی کد شروع باشد.');
}
if (dto.toCode - dto.fromCode > 2000) {
throw new BadRequestException('بازهٔ کد حداکثر ۲۰۰۰ عدد است (مثلاً ۱ تا ۲۰۰۰). برای همه، بازه را خالی بگذارید.');
}
raw = await api.doListMoshtariByCode(dto.fromCode, dto.toCode, {
timeoutMs: LIST_TIMEOUT_MS,
});
} else if (dto.moshtariCode) {
raw = await api.doListMoshtariByCode(dto.moshtariCode, dto.moshtariCode);
} else if (dto.phone) {
const m = normalizeIranMobile(dto.phone) ?? dto.phone;
raw = await api.doListMoshtariByPhone(m);
} else if (dto.name?.trim()) {
raw = await api.doListMoshtariByName(dto.name.trim());
} else {
throw new BadRequestException(
'یکی از موارد لازم است: بازه کد (از–تا)، کد مشتری، موبایل یا نام. برای همهٔ مشتریان بازه را خالی بگذارید.',
);
}
const mapped = asMoshtariList(raw).map((row) => ({
moshtariCode: pickCode(row),
name: pickName(row),
tel: pickTel(row),
// raw فقط برای جستجوی محدود؛ در listAll حجم پاسخ را باد می‌کند و پروکسی را می‌ترکاند
...(dto.listAll ? {} : { raw: row }),
}));
const codes = mapped
.map((r) => r.moshtariCode)
.filter((c): c is number => c != null);
const existing =
codes.length === 0
? []
: await this.prisma.personAccountingLink.findMany({
where: {
connectionId: dto.connectionId,
moshtariCode: { in: codes },
},
select: {
moshtariCode: true,
personId: true,
person: { select: { firstName: true, lastName: true } },
},
});
const byCode = new Map(existing.map((e) => [e.moshtariCode, e]));
const linked = mapped.map((row) => {
const link = row.moshtariCode != null ? byCode.get(row.moshtariCode) : undefined;
return {
...row,
alreadyLinked: Boolean(link),
personId: link?.personId ?? null,
personName: link
? `${link.person.firstName} ${link.person.lastName}`.trim()
: null,
};
});
const duplicates = await this.duplicateOwners(
organizationId,
dto.connectionId,
linked.filter((row) => !row.alreadyLinked),
);
const results = linked.map((row) => {
if (row.alreadyLinked || row.moshtariCode == null) return row;
const dup = duplicates.get(row.moshtariCode);
if (!dup) return row;
return {
...row,
duplicateOfCode: dup.moshtariCode,
duplicatePersonId: dup.personId,
duplicatePersonName: dup.personName,
};
});
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId: dto.connectionId,
kind: 'search',
status: 'success',
request: dto as unknown as Prisma.InputJsonValue,
result: {
count: results.length,
...(listMeta ?? {}),
} as Prisma.InputJsonValue,
},
});
return {
results,
...(listMeta
? {
...listMeta,
hasMore: results.length > 0,
note: `صفحه ${listMeta.page}: کد ${listMeta.fromCode} تا ${listMeta.toCode}. برای ادامه صفحه بعد را بزنید.`,
}
: {}),
};
}
async importFromTahesab(
organizationId: string,
dto: ImportFromTahesabDto,
prefetched?: { api: TahesabApi; row?: Record<string, unknown> },
options?: { skipJob?: boolean },
) {
const clash = await this.prisma.personAccountingLink.findFirst({
where: {
connectionId: dto.connectionId,
moshtariCode: dto.moshtariCode,
},
include: { person: true },
});
if (clash && (!dto.personId || clash.personId !== dto.personId)) {
throw new ConflictException(
`این کد ته‌حساب قبلاً به «${clash.person.firstName} ${clash.person.lastName}» لینک شده است.`,
);
}
let row: Record<string, unknown>;
if (prefetched?.row) {
row = prefetched.row;
} else {
const api =
prefetched?.api ??
(await this.connections.getApi(organizationId, dto.connectionId)).api;
const raw = await api.doListMoshtariByCode(
dto.moshtariCode,
dto.moshtariCode,
);
const found = asMoshtariList(raw)[0];
if (!found) {
throw new NotFoundException(
`مشتری با کد ${dto.moshtariCode} در ته‌حساب یافت نشد.`,
);
}
row = found as Record<string, unknown>;
}
const name = pickName(row) || `مشتری ته‌حساب ${dto.moshtariCode}`;
const tel = pickTel(row);
const named = applyPersonGenderTitle({ firstName: name, lastName: '' });
const firstName = named.firstName;
const lastName = named.lastName;
const mobile = storedPhoneNumber(tel);
const sourceRef = String(dto.moshtariCode);
let personId = dto.personId;
if (personId) {
const existing = await this.getPerson(organizationId, personId);
const prevFirst = existing.firstName;
const prevLast = existing.lastName;
const nameChanged =
`${prevFirst} ${prevLast}`.trim() !== `${firstName} ${lastName}`.trim();
await this.prisma.person.update({
where: { id: personId },
data: {
firstName,
lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
...(nameChanged
? {
previousFirstName: prevFirst || null,
previousLastName: prevLast || null,
}
: {}),
...carryPreviousSource(existing, 'tahesab'),
importSource: 'tahesab',
sourceRef,
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...canonicalMobileUpdate(mobile, existing),
},
});
} else {
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین.
// اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، نام و لینک دست نمی‌خورند
// تا کد قبلی جابه‌جا نشود و این کد هم مشتری تکراری نسازد.
const mobileKeys = phoneIdentityKeys(mobile);
const matches = mobileKeys.length
? await this.prisma.person.findMany({
where: {
organizationId,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
include: {
accountingLinks: {
where: { connectionId: dto.connectionId },
select: { moshtariCode: true },
},
},
orderBy: { createdAt: 'asc' },
})
: [];
const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null;
if (byMobile) {
const linkedCode = byMobile.accountingLinks[0]?.moshtariCode;
if (linkedCode != null && linkedCode !== dto.moshtariCode) {
const existing = await this.prisma.personAccountingLink.findFirst({
where: { personId: byMobile.id, connectionId: dto.connectionId },
include: {
connection: { select: { label: true, dbName: true, isDefault: true } },
},
});
if (!existing) {
throw new NotFoundException('لینک ته‌حساب این مشتری یافت نشد.');
}
return {
personId: byMobile.id,
link: this.linkDto(existing),
duplicateOf: linkedCode,
};
}
const nameChanged =
`${byMobile.firstName} ${byMobile.lastName}`.trim() !==
`${firstName} ${lastName}`.trim();
await this.prisma.person.update({
where: { id: byMobile.id },
data: {
firstName,
lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
...(nameChanged
? {
previousFirstName: byMobile.firstName || null,
previousLastName: byMobile.lastName || null,
}
: {}),
...carryPreviousSource(byMobile, 'tahesab'),
importSource: 'tahesab',
sourceRef,
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...canonicalMobileUpdate(mobile, byMobile),
},
});
personId = byMobile.id;
} else {
const created = await this.prisma.person.create({
data: {
organizationId,
firstName,
lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
mobile,
phone: mobile,
notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`,
importSource: 'tahesab',
sourceRef,
birthDate: pickBirthDate(row),
},
});
personId = created.id;
}
}
const link = await this.prisma.personAccountingLink.upsert({
where: {
personId_connectionId: {
personId,
connectionId: dto.connectionId,
},
},
create: {
personId,
connectionId: dto.connectionId,
moshtariCode: dto.moshtariCode,
matchMethod: 'manual',
},
update: {
moshtariCode: dto.moshtariCode,
matchMethod: 'manual',
linkedAt: new Date(),
},
include: {
connection: { select: { label: true, dbName: true, isDefault: true } },
},
});
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
if (!options?.skipJob) {
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId: dto.connectionId,
kind: 'import',
status: 'success',
request: dto as unknown as Prisma.InputJsonValue,
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
},
});
}
return { personId, link: this.linkDto(link) };
}
/** نام، موبایل و گروه مشتریِ از قبل لینک‌شده را با ردیف ته‌حساب تازه می‌کند. */
private async refreshLinkedCustomer(
organizationId: string,
connectionId: string,
personId: string,
moshtariCode: number,
row: Record<string, unknown>,
) {
const existing = await this.getPerson(organizationId, personId);
const picked = pickName(row).trim();
const named = picked
? applyPersonGenderTitle({ firstName: picked, lastName: '' })
: null;
const nameChanged =
named != null &&
namesDiffer(
existing.firstName,
existing.lastName,
named.firstName,
named.lastName,
);
await this.prisma.person.update({
where: { id: personId },
data: {
...(named
? {
firstName: named.firstName,
lastName: named.lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
}
: {}),
...(nameChanged
? {
previousFirstName: existing.firstName || null,
previousLastName: existing.lastName || null,
}
: {}),
...carryPreviousSource(existing, 'tahesab'),
importSource: 'tahesab',
sourceRef: String(moshtariCode),
...(!existing.birthDate && pickBirthDate(row)
? { birthDate: pickBirthDate(row) }
: {}),
...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing),
},
});
await this.applyTahesabGroup(organizationId, connectionId, personId, row);
}
/**
* همهٔ مشتریان ته‌حساب را صفحه به صفحه می‌خواند.
* کد تازه وارد می‌شود؛ کد لینک‌شده نام و شماره و گروهش تازه می‌شود.
*/
async syncAllCustomers(organizationId: string, connectionId: string) {
const { api } = await this.connections.getApi(organizationId, connectionId);
let page = 1;
let emptyStreak = 0;
let imported = 0;
let updated = 0;
let duplicate = 0;
let failed = 0;
const errors: string[] = [];
const maxPages = 300;
while (emptyStreak < 2 && page <= maxPages) {
let listed: Awaited<ReturnType<typeof this.listMoshtariPage>>;
try {
listed = await this.listMoshtariPage(api, page, LIST_PAGE_SIZE);
} catch (err) {
const message = err instanceof Error ? err.message : 'خواندن ته‌حساب ناموفق بود';
errors.push(message);
break;
}
if (!listed.rows.length) {
emptyStreak += 1;
page += 1;
continue;
}
emptyStreak = 0;
const rowsByCode = new Map<number, Record<string, unknown>>();
for (const row of listed.rows) {
const code = pickCode(row);
if (code != null && code >= 1) rowsByCode.set(code, row);
}
const links = rowsByCode.size
? await this.prisma.personAccountingLink.findMany({
where: {
connectionId,
moshtariCode: { in: [...rowsByCode.keys()] },
},
select: { moshtariCode: true, personId: true },
})
: [];
const linkedByCode = new Map(links.map((link) => [link.moshtariCode, link.personId]));
for (const [code, row] of rowsByCode) {
try {
const personId = linkedByCode.get(code);
if (personId) {
await this.refreshLinkedCustomer(
organizationId,
connectionId,
personId,
code,
row,
);
updated += 1;
} else {
const created = await this.importFromTahesab(
organizationId,
{ connectionId, moshtariCode: code },
{ api, row },
{ skipJob: true },
);
if (created.duplicateOf) duplicate += 1;
else imported += 1;
}
} catch (err) {
failed += 1;
if (errors.length < 5) {
errors.push(err instanceof Error ? err.message : 'خطا');
}
}
}
page += 1;
}
if (page > maxPages && emptyStreak < 2) {
errors.push('فهرست مشتریان از سقف خواندن خودکار بیشتر بود.');
}
const summary = [
`${imported} وارد شد`,
`${updated} به‌روز شد`,
...(duplicate ? [`${duplicate} شماره تکراری`] : []),
`${failed} خطا`,
].join('، ');
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId,
kind: 'customer-sync',
status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success',
result: { imported, updated, duplicate, failed, pages: page - 1 } as Prisma.InputJsonValue,
},
});
this.logger.log(`همگام‌سازی مشتریان ته‌حساب ${connectionId}: ${summary}`);
return {
imported,
updated,
duplicate,
failed,
summary,
error: errors.length ? errors.join('؛ ') : null,
};
}
async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) {
let codes = (dto.moshtariCodes ?? []).filter((n) => Number.isFinite(n) && n >= 1);
const rowsByCode = new Map<number, Record<string, unknown>>();
if (dto.listAll) {
const { api } = await this.connections.getApi(
organizationId,
dto.connectionId,
);
const listed = await this.listMoshtariPage(
api,
dto.page ?? 1,
dto.pageSize,
);
for (const row of listed.rows) {
const code = pickCode(row);
if (code != null) rowsByCode.set(code, row);
}
codes = [...new Set([...codes, ...rowsByCode.keys()])];
} else if (dto.fromCode != null && dto.toCode != null) {
if (dto.toCode < dto.fromCode) {
throw new BadRequestException('کد پایان باید بزرگ‌تر یا مساوی کد شروع باشد.');
}
if (dto.toCode - dto.fromCode > 2000) {
throw new BadRequestException(
'بازهٔ کد حداکثر ۲۰۰۰ عدد است. برای همه، بازه را خالی بگذارید.',
);
}
const { api } = await this.connections.getApi(
organizationId,
dto.connectionId,
);
const raw = await api.doListMoshtariByCode(dto.fromCode, dto.toCode);
for (const row of asMoshtariList(raw)) {
const code = pickCode(row);
if (code != null) rowsByCode.set(code, row);
}
codes = [...new Set([...codes, ...rowsByCode.keys()])];
}
if (!codes.length) {
throw new BadRequestException(
'کد مشتری یا بازهٔ کد برای وارد کردن مشخص نشده است.',
);
}
const maxImport = dto.listAll || (dto.fromCode != null && dto.toCode != null) ? 2000 : 500;
if (codes.length > maxImport) {
throw new BadRequestException(
`حداکثر ${maxImport} مشتری در هر بار وارد کنید (الان ${codes.length}). صفحه یا بازه را کوچک‌تر کنید.`,
);
}
const items: Array<{
moshtariCode: number;
ok: boolean;
personId?: string;
skipped?: boolean;
error?: string;
}> = [];
const existingLinks = await this.prisma.personAccountingLink.findMany({
where: { connectionId: dto.connectionId, moshtariCode: { in: codes } },
select: { moshtariCode: true, personId: true },
});
const linkedByCode = new Map(existingLinks.map((l) => [l.moshtariCode, l.personId]));
const pending = codes.filter((c) => !linkedByCode.has(c));
let bulkApi: TahesabApi | undefined;
const missingRows = pending.filter((c) => !rowsByCode.has(c));
if (pending.length) {
bulkApi = (await this.connections.getApi(organizationId, dto.connectionId)).api;
}
if (bulkApi && missingRows.length) {
const minCode = Math.min(...missingRows);
const maxCode = Math.max(...missingRows);
if (maxCode - minCode <= 2000) {
try {
const raw = await bulkApi.doListMoshtariByCode(minCode, maxCode);
for (const row of asMoshtariList(raw)) {
const code = pickCode(row);
if (code != null) rowsByCode.set(code, row);
}
} catch {
/* fallback: per-code fetch inside importFromTahesab */
}
}
}
for (const moshtariCode of codes) {
try {
const linkedPersonId = linkedByCode.get(moshtariCode);
if (linkedPersonId) {
const birthDate = pickBirthDate(rowsByCode.get(moshtariCode) ?? {});
if (birthDate) {
await this.prisma.person.updateMany({
where: { id: linkedPersonId, birthDate: null },
data: { birthDate },
});
}
await this.applyTahesabGroup(
organizationId,
dto.connectionId,
linkedPersonId,
rowsByCode.get(moshtariCode),
);
items.push({
moshtariCode,
ok: true,
skipped: true,
personId: linkedPersonId,
});
continue;
}
const r = await this.importFromTahesab(
organizationId,
{ connectionId: dto.connectionId, moshtariCode },
bulkApi ? { api: bulkApi, row: rowsByCode.get(moshtariCode) } : undefined,
);
if (r.duplicateOf) {
items.push({
moshtariCode,
ok: true,
skipped: true,
personId: r.personId,
error: `شماره تکراری است و قبلاً با کد ${r.duplicateOf} لینک شده`,
});
continue;
}
items.push({ moshtariCode, ok: true, personId: r.personId });
} catch (e) {
const message =
e instanceof Error ? e.message : 'وارد کردن ناموفق بود';
items.push({ moshtariCode, ok: false, error: message });
}
}
const imported = items.filter((i) => i.ok && !i.skipped).length;
const skipped = items.filter((i) => i.skipped).length;
const failed = items.filter((i) => !i.ok).length;
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId: dto.connectionId,
kind: 'import',
status: failed && !imported ? 'error' : 'success',
request: dto as unknown as Prisma.InputJsonValue,
result: { imported, skipped, failed, count: items.length } as Prisma.InputJsonValue,
},
});
return { imported, skipped, failed, items };
}
/** یک صفحه (بازهٔ کد) از ته‌حساب: گروه مشتری‌های لینک‌شده به‌روز می‌شود */
async syncGroupsPage(organizationId: string, dto: SyncGroupsPageDto) {
const { api } = await this.connections.getApi(organizationId, dto.connectionId);
const listed = await this.listMoshtariPage(api, dto.page ?? 1, dto.pageSize);
const rowsByCode = new Map<number, Record<string, unknown>>();
for (const row of listed.rows) {
const code = pickCode(row);
if (code != null) rowsByCode.set(code, row);
}
const links = rowsByCode.size
? await this.prisma.personAccountingLink.findMany({
where: { connectionId: dto.connectionId, moshtariCode: { in: [...rowsByCode.keys()] } },
select: { moshtariCode: true, personId: true },
})
: [];
let updated = 0;
for (const link of links) {
const changed = await this.applyTahesabGroup(
organizationId,
dto.connectionId,
link.personId,
rowsByCode.get(link.moshtariCode),
);
if (changed) updated += 1;
}
return {
page: listed.page,
fromCode: listed.fromCode,
toCode: listed.toCode,
rows: listed.rows.length,
linked: links.length,
updated,
};
}
/** اشخاصی که روی این اتصال به ته‌حساب لینک نیستند */
async unlinkedPeople(organizationId: string, connectionId: string) {
await this.connections.getOrThrow(organizationId, connectionId);
const rows = await this.prisma.person.findMany({
where: { organizationId, accountingLinks: { none: { connectionId } } },
select: { id: true },
orderBy: { createdAt: 'asc' },
});
return { ids: rows.map((r) => r.id), total: rows.length };
}
/**
* ثبت گروهی در ته‌حساب: برای هر نفر اول تطبیق (موبایل، بعد نام)؛
* تطبیق یکتا → لینک، چند نتیجه → رد می‌شود تا دستی بررسی شود، بدون نتیجه → ساخت مشتری.
*/
async exportPeople(organizationId: string, dto: ExportPeopleToTahesabDto) {
await this.connections.getOrThrow(organizationId, dto.connectionId);
const ids = [...new Set(dto.personIds)];
const people = await this.prisma.person.findMany({
where: { organizationId, id: { in: ids } },
select: {
id: true,
firstName: true,
lastName: true,
accountingLinks: {
where: { connectionId: dto.connectionId },
select: { moshtariCode: true },
},
},
});
const byId = new Map(people.map((p) => [p.id, p]));
const items: Array<{
personId: string;
name: string;
status: 'matched' | 'created' | 'ambiguous' | 'skipped' | 'error';
moshtariCode?: number;
error?: string;
}> = [];
for (const personId of ids) {
const person = byId.get(personId);
if (!person) {
items.push({ personId, name: '', status: 'error', error: 'شخص یافت نشد.' });
continue;
}
const name = `${person.firstName} ${person.lastName}`.trim();
const existing = person.accountingLinks[0];
if (existing) {
items.push({ personId, name, status: 'skipped', moshtariCode: existing.moshtariCode });
continue;
}
try {
const matched = await this.match(organizationId, personId, {
connectionId: dto.connectionId,
autoLinkIfUnique: true,
});
if (matched.status === 'linked' && matched.link) {
items.push({
personId,
name,
status: 'matched',
moshtariCode: matched.link.moshtariCode,
});
} else if (matched.status === 'none') {
const link = await this.createInTahesab(organizationId, personId, {
connectionId: dto.connectionId,
});
items.push({ personId, name, status: 'created', moshtariCode: link.moshtariCode });
} else {
items.push({ personId, name, status: 'ambiguous' });
}
} catch (e) {
items.push({
personId,
name,
status: 'error',
error: e instanceof Error ? e.message : 'ثبت ناموفق بود',
});
}
}
const count = (s: (typeof items)[number]['status']) =>
items.filter((i) => i.status === s).length;
const summary = {
matched: count('matched'),
created: count('created'),
ambiguous: count('ambiguous'),
skipped: count('skipped'),
failed: count('error'),
};
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId: dto.connectionId,
kind: 'export',
status: summary.failed && !summary.matched && !summary.created ? 'error' : 'success',
request: { count: ids.length } as Prisma.InputJsonValue,
result: summary as Prisma.InputJsonValue,
},
});
return { ...summary, items };
}
/** مشتری‌های قبلاً لینک‌شده که تاریخ تولد ندارند را از فهرست ته‌حساب پر می‌کند. */
async fillMissingBirthDates() {
const connections = await this.prisma.accountingConnection.findMany({
where: { enabled: true },
select: { id: true, organizationId: true },
});
let filled = 0;
for (const connection of connections) {
try {
const links = await this.prisma.personAccountingLink.findMany({
where: { connectionId: connection.id, person: { birthDate: null } },
select: { personId: true, moshtariCode: true },
});
if (!links.length) continue;
const recent = await this.prisma.accountingSyncJob.findFirst({
where: {
connectionId: connection.id,
kind: 'birthdate',
createdAt: { gte: new Date(Date.now() - 12 * 60 * 60 * 1000) },
},
select: { id: true },
});
if (recent) continue;
await this.prisma.accountingSyncJob.create({
data: {
organizationId: connection.organizationId,
connectionId: connection.id,
kind: 'birthdate',
status: 'success',
},
});
const { api } = await this.connections.getApi(connection.organizationId, connection.id);
const codes = [...new Set(links.map((link) => link.moshtariCode))].sort((a, b) => a - b);
const personByCode = new Map(links.map((link) => [link.moshtariCode, link.personId]));
let index = 0;
while (index < codes.length) {
const from = codes[index]!;
let end = index;
while (
end + 1 < codes.length &&
codes[end + 1]! - from < 2000 &&
end - index < 400
) {
end += 1;
}
const to = codes[end]!;
const raw = await api.doListMoshtariByCode(from, to);
for (const row of asMoshtariList(raw)) {
const code = pickCode(row);
const personId = code == null ? undefined : personByCode.get(code);
const birthDate = pickBirthDate(row);
if (!personId || !birthDate) continue;
const updated = await this.prisma.person.updateMany({
where: { id: personId, birthDate: null },
data: { birthDate },
});
filled += updated.count;
}
index = end + 1;
}
} catch (err) {
this.logger.warn(
`تاریخ تولد ته‌حساب برای اتصال ${connection.id} خوانده نشد: ${
err instanceof Error ? err.message : err
}`,
);
}
}
if (filled) this.logger.log(`تاریخ تولد ${filled} مشتری از ته‌حساب پر شد`);
}
}
@Injectable()
export class TahesabBirthDateBackfill implements OnModuleInit {
constructor(private readonly links: TahesabPersonLinkService) {}
onModuleInit() {
void this.links.fillMissingBirthDates();
}
}