diff --git a/apps/api/src/integrations/tahesab/person-link.service.ts b/apps/api/src/integrations/tahesab/person-link.service.ts index 38cc2dd..83049a3 100644 --- a/apps/api/src/integrations/tahesab/person-link.service.ts +++ b/apps/api/src/integrations/tahesab/person-link.service.ts @@ -465,6 +465,7 @@ export class TahesabPersonLinkService { async importFromTahesab( organizationId: string, dto: ImportFromTahesabDto, + prefetched?: { api: TahesabApi; row?: Record }, ) { const clash = await this.prisma.personAccountingLink.findFirst({ where: { @@ -479,16 +480,19 @@ export class TahesabPersonLinkService { ); } - const { api } = await this.connections.getApi( - organizationId, - dto.connectionId, - ); - const raw = await api.doListMoshtariByCode( - dto.moshtariCode, - dto.moshtariCode, - ); - const rows = asMoshtariList(raw); - const row = rows[0] ?? {}; + let row: Record; + 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, + ); + row = (asMoshtariList(raw)[0] ?? {}) as Record; + } const name = pickName(row) || `مشتری ته‌حساب ${dto.moshtariCode}`; const tel = pickTel(row); const parts = name.trim().split(/\s+/); @@ -668,24 +672,49 @@ export class TahesabPersonLinkService { 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 rowsByCode = new Map>(); + if (pending.length) { + bulkApi = (await this.connections.getApi(organizationId, dto.connectionId)).api; + const minCode = Math.min(...pending); + const maxCode = Math.max(...pending); + 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 existing = await this.prisma.personAccountingLink.findFirst({ - where: { connectionId: dto.connectionId, moshtariCode }, - }); - if (existing) { + const linkedPersonId = linkedByCode.get(moshtariCode); + if (linkedPersonId) { items.push({ moshtariCode, ok: true, skipped: true, - personId: existing.personId, + personId: linkedPersonId, }); continue; } - const r = await this.importFromTahesab(organizationId, { - connectionId: dto.connectionId, - moshtariCode, - }); + 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 = diff --git a/apps/web/src/views/TahesabSettingsView.vue b/apps/web/src/views/TahesabSettingsView.vue index 9b72c44..f34a242 100644 --- a/apps/web/src/views/TahesabSettingsView.vue +++ b/apps/web/src/views/TahesabSettingsView.vue @@ -10,8 +10,10 @@ import type { UpdateTahesabConnectionRequest, } from '@mento/shared'; import { brand } from '@mento/shared'; +import { toFaDigits } from '@/lib/jalali'; import { UiBadge, + UiBusyOverlay, UiButton, UiCard, UiField, @@ -48,6 +50,13 @@ const sync = reactive({ toCode: '', }); const syncBusy = ref(false); +/** تعداد مشتری در هر درخواست ورود دسته‌ای (برای جلوگیری از تایم‌اوت و نمایش درصد) */ +const IMPORT_CHUNK = 25; +const importOverlay = reactive<{ open: boolean; message: string; progress: number | null }>({ + open: false, + message: '', + progress: null, +}); type SyncResult = { moshtariCode: number | null; name: string; @@ -475,18 +484,72 @@ async function importBulk(mode: 'results' | 'range') { body.toCode = toCode; } } + syncBusy.value = true; + importOverlay.open = true; + importOverlay.progress = 0; + importOverlay.message = 'در حال آماده‌سازی فهرست مشتریان…'; + const totals = { imported: 0, skipped: 0, failed: 0 }; + let firstError = ''; try { - syncBusy.value = true; - const { data } = await api.post<{ - imported: number; - skipped: number; - failed: number; - }>('/integrations/tahesab/sync/import-bulk', body); - ok.value = `وارد شد: ${data.imported} · قبلاً بود: ${data.skipped} · ناموفق: ${data.failed}`; - await searchTahesab(mode === 'range' ? 'range' : 'filter'); + let codes: number[]; + if (Array.isArray(body.moshtariCodes)) { + codes = body.moshtariCodes as number[]; + } else { + const { data } = await api.post<{ results: SyncResult[] }>( + '/integrations/tahesab/sync/search', + body, + { timeout: 90_000 }, + ); + const found = data.results ?? []; + totals.skipped += found.filter((r) => r.alreadyLinked).length; + codes = found + .filter((r) => r.moshtariCode != null && !r.alreadyLinked) + .map((r) => r.moshtariCode as number); + } + + const total = codes.length; + for (let i = 0; i < total; i += IMPORT_CHUNK) { + const chunk = codes.slice(i, i + IMPORT_CHUNK); + importOverlay.message = `در حال وارد کردن مشتریان… ${toFaDigits(Math.min(i + chunk.length, total))} از ${toFaDigits(total)}`; + try { + const { data } = await api.post<{ + imported: number; + skipped: number; + failed: number; + items?: Array<{ moshtariCode: number; ok: boolean; personId?: string; error?: string }>; + }>( + '/integrations/tahesab/sync/import-bulk', + { connectionId: sync.connectionId, moshtariCodes: chunk }, + { timeout: 120_000 }, + ); + totals.imported += data.imported; + totals.skipped += data.skipped; + totals.failed += data.failed; + for (const item of data.items ?? []) { + if (!item.ok) { + if (!firstError && item.error) firstError = item.error; + continue; + } + const row = syncResults.value.find((r) => r.moshtariCode === item.moshtariCode); + if (row) { + row.alreadyLinked = true; + if (item.personId) row.personId = item.personId; + } + } + } catch (e) { + totals.failed += chunk.length; + if (!firstError) firstError = apiErr(e, 'خطا در ارسال'); + } + importOverlay.progress = total ? ((i + chunk.length) / total) * 100 : 100; + } + + ok.value = `وارد شد: ${toFaDigits(totals.imported)} · قبلاً بود: ${toFaDigits(totals.skipped)} · ناموفق: ${toFaDigits(totals.failed)}`; + if (firstError) error.value = `برخی موارد وارد نشدند: ${firstError}`; } catch (e) { error.value = apiErr(e, 'وارد کردن دسته‌ای ناموفق'); } finally { + importOverlay.open = false; + importOverlay.progress = null; syncBusy.value = false; } } @@ -980,6 +1043,12 @@ onMounted(load); + +