Import Tahesab customers in batches with progress overlay and prefetched rows

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-05 21:08:38 +03:30
co-authored by Cursor
parent 46dcf296d3
commit f88786ef61
2 changed files with 125 additions and 27 deletions
@@ -465,6 +465,7 @@ export class TahesabPersonLinkService {
async importFromTahesab(
organizationId: string,
dto: ImportFromTahesabDto,
prefetched?: { api: TahesabApi; row?: Record<string, unknown> },
) {
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<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,
);
row = (asMoshtariList(raw)[0] ?? {}) as Record<string, unknown>;
}
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<number, Record<string, unknown>>();
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 =
+77 -8
View File
@@ -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);
</div>
</div>
</UiCard>
<UiBusyOverlay
:open="importOverlay.open"
:message="importOverlay.message"
:progress="importOverlay.progress"
/>
</section>
</template>