diff --git a/apps/api/src/integrations/tahesab/dto.ts b/apps/api/src/integrations/tahesab/dto.ts index 62bb2ef..9481a75 100644 --- a/apps/api/src/integrations/tahesab/dto.ts +++ b/apps/api/src/integrations/tahesab/dto.ts @@ -7,6 +7,7 @@ import { IsOptional, IsString, IsUrl, + Max, MaxLength, Min, MinLength, @@ -155,10 +156,24 @@ export class ImportBulkFromTahesabDto { @Min(1) toCode?: number; - /** اگر true باشد همهٔ مشتریان با اسکن بازه‌ای خوانده می‌شوند */ + /** اگر true باشد یک صفحه از مشتریان (بازهٔ کد) خوانده می‌شود */ @IsOptional() @IsBoolean() listAll?: boolean; + + /** صفحهٔ بازهٔ کد هنگام listAll (پیش‌فرض ۱) */ + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(100) + @Max(2000) + pageSize?: number; } export class SearchTahesabDto { @@ -191,10 +206,28 @@ export class SearchTahesabDto { @Min(1) toCode?: number; - /** بازه خالی در UI → همهٔ مشتریان */ + /** بازه خالی در UI → مرور صفحه‌ای همهٔ مشتریان */ @IsOptional() @IsBoolean() listAll?: boolean; + + /** + * صفحهٔ بازهٔ کد برای listAll (۱ = کد ۱…۲۰۰۰، ۲ = ۲۰۰۱…۴۰۰۰، …). + * سقف ۵۰۰۰ دیگر اعمال نمی‌شود. + */ + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + /** اندازهٔ پنجرهٔ کد در هر صفحه (پیش‌فرض ۲۰۰۰، حداکثر ۲۰۰۰) */ + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(100) + @Max(2000) + pageSize?: number; } export class SearchTahesabProductsDto { diff --git a/apps/api/src/integrations/tahesab/person-link.service.ts b/apps/api/src/integrations/tahesab/person-link.service.ts index 07831f8..a27e06c 100644 --- a/apps/api/src/integrations/tahesab/person-link.service.ts +++ b/apps/api/src/integrations/tahesab/person-link.service.ts @@ -17,10 +17,9 @@ import { } from './dto'; import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods'; -const LIST_ALL_CHUNK = 2000; -/** سقف «همه» در یک درخواست ByCode — تکه‌تکه باعث ۵۰۲ پروکسی می‌شود */ -const LIST_ALL_MAX_CODE = 5_000; -const LIST_ALL_TIMEOUT_MS = 55_000; +/** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */ +const LIST_PAGE_SIZE = 2000; +const LIST_TIMEOUT_MS = 55_000; function asMoshtariList(raw: unknown): Array> { if (!raw) return []; @@ -318,25 +317,32 @@ export class TahesabPersonLinkService { } /** - * لیست همهٔ مشتریان در یک درخواست (بازه ۱…LIST_ALL_MAX_CODE). - * تکه‌تکه کردن پشت‌سرهم معمولاً از مهلت nginx بیشتر می‌شود و ۵۰۲ می‌دهد. + * یک صفحه از مشتریان بر اساس بازهٔ کد: + * صفحه ۱ → ۱…pageSize، صفحه ۲ → pageSize+1…۲×pageSize، … + * سقف ۵۰۰۰ حذف شده؛ با پیجینیت می‌توان همه را مرور کرد. */ - private async listAllMoshtari(api: TahesabApi) { - try { - return asMoshtariList( - await api.doListMoshtariByCode(1, LIST_ALL_MAX_CODE, { - timeoutMs: LIST_ALL_TIMEOUT_MS, - }), - ); - } catch (err) { - // اگر بازهٔ بزرگ سمت ته‌حساب رد شد، همان سقف دستی ۲۰۰۰ را یک‌بار امتحان کن - if (LIST_ALL_MAX_CODE <= LIST_ALL_CHUNK) throw err; - return asMoshtariList( - await api.doListMoshtariByCode(1, LIST_ALL_CHUNK, { - timeoutMs: LIST_ALL_TIMEOUT_MS, - }), - ); - } + 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) { @@ -345,8 +351,28 @@ export class TahesabPersonLinkService { dto.connectionId, ); let raw: unknown = []; + let listMeta: + | { + page: number; + pageSize: number; + fromCode: number; + toCode: number; + } + | undefined; + if (dto.listAll) { - raw = await this.listAllMoshtari(api); + 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('کد پایان باید بزرگ‌تر یا مساوی کد شروع باشد.'); @@ -354,7 +380,9 @@ export class TahesabPersonLinkService { if (dto.toCode - dto.fromCode > 2000) { throw new BadRequestException('بازهٔ کد حداکثر ۲۰۰۰ عدد است (مثلاً ۱ تا ۲۰۰۰). برای همه، بازه را خالی بگذارید.'); } - raw = await api.doListMoshtariByCode(dto.fromCode, dto.toCode); + 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) { @@ -414,11 +442,23 @@ export class TahesabPersonLinkService { kind: 'search', status: 'success', request: dto as unknown as Prisma.InputJsonValue, - result: { count: results.length } as Prisma.InputJsonValue, + result: { + count: results.length, + ...(listMeta ?? {}), + } as Prisma.InputJsonValue, }, }); - return { results }; + return { + results, + ...(listMeta + ? { + ...listMeta, + hasMore: results.length > 0, + note: `صفحه ${listMeta.page}: کد ${listMeta.fromCode} تا ${listMeta.toCode}. برای ادامه صفحه بعد را بزنید.`, + } + : {}), + }; } async importFromTahesab( @@ -516,10 +556,15 @@ export class TahesabPersonLinkService { organizationId, dto.connectionId, ); - const listed = (await this.listAllMoshtari(api)) + const listed = await this.listMoshtariPage( + api, + dto.page ?? 1, + dto.pageSize, + ); + const listedCodes = listed.rows .map((row) => pickCode(row)) .filter((c): c is number => c != null); - codes = [...new Set([...codes, ...listed])]; + codes = [...new Set([...codes, ...listedCodes])]; } else if (dto.fromCode != null && dto.toCode != null) { if (dto.toCode < dto.fromCode) { throw new BadRequestException('کد پایان باید بزرگ‌تر یا مساوی کد شروع باشد.'); @@ -545,10 +590,10 @@ export class TahesabPersonLinkService { 'کد مشتری یا بازهٔ کد برای وارد کردن مشخص نشده است.', ); } - const maxImport = dto.listAll ? 2000 : 500; + const maxImport = dto.listAll || (dto.fromCode != null && dto.toCode != null) ? 2000 : 500; if (codes.length > maxImport) { throw new BadRequestException( - `حداکثر ${maxImport} مشتری در هر بار وارد کنید (الان ${codes.length}). بازه را کوچک‌تر کنید.`, + `حداکثر ${maxImport} مشتری در هر بار وارد کنید (الان ${codes.length}). صفحه یا بازه را کوچک‌تر کنید.`, ); } diff --git a/apps/web/src/views/TahesabSettingsView.vue b/apps/web/src/views/TahesabSettingsView.vue index b952a53..cbbe6d1 100644 --- a/apps/web/src/views/TahesabSettingsView.vue +++ b/apps/web/src/views/TahesabSettingsView.vue @@ -1,5 +1,5 @@