Files
CRM_Montzar/apps/api/src/integrations/tahesab/person-link.service.ts
T
MahdiandCursor 1ef7f91771 fix(contacts): keep one customer when Tahesab repeats a mobile
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>
2026-11-05 13:34:41 +03:30

1278 lines
43 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,
};
}
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 results = 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,
};
});
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;
let preserveExistingLink = false;
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;
preserveExistingLink = linkedCode != null && linkedCode !== dto.moshtariCode;
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: preserveExistingLink ? String(linkedCode) : 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;
}
}
if (preserveExistingLink) {
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
const existing = await this.prisma.personAccountingLink.findFirst({
where: { personId, connectionId: dto.connectionId },
include: {
connection: { select: { label: true, dbName: true, isDefault: true } },
},
});
if (!existing) {
throw new NotFoundException('لینک ته‌حساب این مشتری یافت نشد.');
}
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: existing.id, keptLink: true } as Prisma.InputJsonValue,
},
});
}
return { personId, link: this.linkDto(existing) };
}
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 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 {
await this.importFromTahesab(
organizationId,
{ connectionId, moshtariCode: code },
{ api, row },
{ skipJob: true },
);
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} به‌روز شد، ${failed} خطا`;
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId,
kind: 'customer-sync',
status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success',
result: { imported, updated, failed, pages: page - 1 } as Prisma.InputJsonValue,
},
});
this.logger.log(`همگام‌سازی مشتریان ته‌حساب ${connectionId}: ${summary}`);
return {
imported,
updated,
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,
);
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();
}
}