Files
CRM_Montzar/apps/api/src/integrations/tahesab/person-link.service.ts
T
Mahdi 29d99cbded feat: customer groups for people and companies with tahesab group sync and bulk register
Multiple groups per contact, legacy group text migrated, excel import/export, contacts filter and column, richer create-in-tahesab, match-first bulk export
2026-10-21 17:16:31 +03:30

1067 lines
35 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 } from '../../contacts/person-source';
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';
/** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/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> },
) {
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 parts = name.trim().split(/\s+/);
const firstName = parts[0] || name;
const lastName = parts.slice(1).join(' ') || '—';
const mobile = tel || null;
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,
...(nameChanged
? {
previousFirstName: prevFirst || null,
previousLastName: prevLast || null,
}
: {}),
...carryPreviousSource(existing, 'tahesab'),
importSource: 'tahesab',
sourceRef,
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...(mobile
? {
mobile: existing.mobile || mobile,
phone: existing.phone || mobile,
}
: {}),
},
});
} else {
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین.
// شخصی که روی همین اتصال به کد دیگری لینک است کنار گذاشته می‌شود؛
// وگرنه upsert لینک، کد قبلی را جابه‌جا و آن مشتری را دوباره «واردنشده» می‌کند.
const byMobile = mobile
? await this.prisma.person.findFirst({
where: {
organizationId,
OR: [{ mobile }, { phone: mobile }],
accountingLinks: { none: { connectionId: dto.connectionId } },
},
})
: null;
if (byMobile) {
const nameChanged =
`${byMobile.firstName} ${byMobile.lastName}`.trim() !==
`${firstName} ${lastName}`.trim();
await this.prisma.person.update({
where: { id: byMobile.id },
data: {
firstName,
lastName,
...(nameChanged
? {
previousFirstName: byMobile.firstName || null,
previousLastName: byMobile.lastName || null,
}
: {}),
...carryPreviousSource(byMobile, 'tahesab'),
importSource: 'tahesab',
sourceRef,
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
},
});
personId = byMobile.id;
} else {
const created = await this.prisma.person.create({
data: {
organizationId,
firstName,
lastName,
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);
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) };
}
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();
}
}