From 15a0afae5cb76e6e84d31ebff74fc6ff32c56d9a Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 5 Sep 2026 09:33:12 +0330 Subject: [PATCH] Add Tahesab bulk customer import by code range. Connecting alone does not copy customers; settings now list a code range and import them into CRM persons with link status. Co-authored-by: Cursor --- apps/api/src/integrations/tahesab/dto.ts | 39 ++++ .../tahesab/person-link.service.ts | 130 ++++++++++- .../tahesab/tahesab.controller.ts | 10 + apps/web/src/views/TahesabSettingsView.vue | 220 ++++++++++++++++-- docs/integrations/tahesab.md | 13 +- 5 files changed, 390 insertions(+), 22 deletions(-) diff --git a/apps/api/src/integrations/tahesab/dto.ts b/apps/api/src/integrations/tahesab/dto.ts index 0719a1e..1ddac86 100644 --- a/apps/api/src/integrations/tahesab/dto.ts +++ b/apps/api/src/integrations/tahesab/dto.ts @@ -1,4 +1,5 @@ import { + IsArray, IsBoolean, IsIn, IsInt, @@ -121,6 +122,7 @@ export class ImportFromTahesabDto { @IsString() connectionId!: string; + @Type(() => Number) @IsInt() @Min(1) moshtariCode!: number; @@ -131,6 +133,29 @@ export class ImportFromTahesabDto { personId?: string; } +export class ImportBulkFromTahesabDto { + @IsString() + connectionId!: string; + + @IsOptional() + @IsArray() + @Type(() => Number) + @IsInt({ each: true }) + moshtariCodes?: number[]; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + fromCode?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + toCode?: number; +} + export class SearchTahesabDto { @IsString() connectionId!: string; @@ -144,8 +169,22 @@ export class SearchTahesabDto { name?: string; @IsOptional() + @Type(() => Number) @IsInt() moshtariCode?: number; + + /** لیست بازه‌ای با DoListMoshtari-ByCode */ + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + fromCode?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + toCode?: number; } export class ListAsnadQueryDto { diff --git a/apps/api/src/integrations/tahesab/person-link.service.ts b/apps/api/src/integrations/tahesab/person-link.service.ts index 66c7460..3e766d7 100644 --- a/apps/api/src/integrations/tahesab/person-link.service.ts +++ b/apps/api/src/integrations/tahesab/person-link.service.ts @@ -9,6 +9,7 @@ import { PrismaService } from '../../prisma/prisma.service'; import { TahesabConnectionsService } from './connections.service'; import { CreateInTahesabDto, + ImportBulkFromTahesabDto, ImportFromTahesabDto, ManualLinkDto, MatchPersonDto, @@ -317,7 +318,15 @@ export class TahesabPersonLinkService { dto.connectionId, ); let raw: unknown = []; - if (dto.moshtariCode) { + 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); + } else if (dto.moshtariCode) { raw = await api.doListMoshtariByCode(dto.moshtariCode, dto.moshtariCode); } else if (dto.phone) { const m = normalizeIranMobile(dto.phone) ?? dto.phone; @@ -325,16 +334,49 @@ export class TahesabPersonLinkService { } else if (dto.name?.trim()) { raw = await api.doListMoshtariByName(dto.name.trim()); } else { - throw new BadRequestException('یکی از phone، name یا moshtariCode لازم است.'); + throw new BadRequestException( + 'یکی از موارد لازم است: بازه کد (از–تا)، کد مشتری، موبایل یا نام.', + ); } - const results = asMoshtariList(raw).map((row) => ({ + const mapped = asMoshtariList(raw).map((row) => ({ moshtariCode: pickCode(row), name: pickName(row), tel: pickTel(row), 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, @@ -435,4 +477,86 @@ export class TahesabPersonLinkService { return { personId, link: this.linkDto(link) }; } + + async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) { + let codes = (dto.moshtariCodes ?? []).filter((n) => Number.isFinite(n) && n >= 1); + + 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); + const listed = asMoshtariList(raw) + .map((row) => pickCode(row)) + .filter((c): c is number => c != null); + codes = [...new Set([...codes, ...listed])]; + } + + if (!codes.length) { + throw new BadRequestException( + 'کد مشتری یا بازهٔ کد برای وارد کردن مشخص نشده است.', + ); + } + if (codes.length > 500) { + throw new BadRequestException('حداکثر ۵۰۰ مشتری در هر بار وارد کنید.'); + } + + const items: Array<{ + moshtariCode: number; + ok: boolean; + personId?: string; + skipped?: boolean; + error?: string; + }> = []; + + for (const moshtariCode of codes) { + try { + const existing = await this.prisma.personAccountingLink.findFirst({ + where: { connectionId: dto.connectionId, moshtariCode }, + }); + if (existing) { + items.push({ + moshtariCode, + ok: true, + skipped: true, + personId: existing.personId, + }); + continue; + } + const r = await this.importFromTahesab(organizationId, { + connectionId: dto.connectionId, + moshtariCode, + }); + 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 }; + } } diff --git a/apps/api/src/integrations/tahesab/tahesab.controller.ts b/apps/api/src/integrations/tahesab/tahesab.controller.ts index 458c745..47a0065 100644 --- a/apps/api/src/integrations/tahesab/tahesab.controller.ts +++ b/apps/api/src/integrations/tahesab/tahesab.controller.ts @@ -20,6 +20,7 @@ import { TahesabDocumentsService } from './documents.service'; import { CreateInTahesabDto, CreateTahesabConnectionDto, + ImportBulkFromTahesabDto, ImportFromTahesabDto, ManualLinkDto, MatchPersonDto, @@ -200,4 +201,13 @@ export class TahesabController { ) { return this.links.importFromTahesab(user.organizationId, dto); } + + @Post('sync/import-bulk') + @Roles('owner', 'admin') + importBulk( + @CurrentUser() user: { organizationId: string }, + @Body() dto: ImportBulkFromTahesabDto, + ) { + return this.links.importBulk(user.organizationId, dto); + } } diff --git a/apps/web/src/views/TahesabSettingsView.vue b/apps/web/src/views/TahesabSettingsView.vue index fe3298a..e96554c 100644 --- a/apps/web/src/views/TahesabSettingsView.vue +++ b/apps/web/src/views/TahesabSettingsView.vue @@ -1,5 +1,6 @@