From adb63447ca660112f6976ffdeb875e374e322f46 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Tue, 20 Oct 2026 12:00:11 +0330 Subject: [PATCH] fix: stop Tahesab imports from stealing links between shared-mobile customers List all Tahesab customers in one paginated table with a page-size picker instead of 2000-code pages. --- .../tahesab/person-link.service.ts | 5 +- apps/web/src/views/TahesabSettingsView.vue | 272 +++++++----------- 2 files changed, 100 insertions(+), 177 deletions(-) diff --git a/apps/api/src/integrations/tahesab/person-link.service.ts b/apps/api/src/integrations/tahesab/person-link.service.ts index aae09d7..99ec921 100644 --- a/apps/api/src/integrations/tahesab/person-link.service.ts +++ b/apps/api/src/integrations/tahesab/person-link.service.ts @@ -548,12 +548,15 @@ export class TahesabPersonLinkService { }, }); } else { - // اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین + // اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین. + // شخصی که روی همین اتصال به کد دیگری لینک است کنار گذاشته می‌شود؛ + // وگرنه upsert لینک، کد قبلی را جابه‌جا و آن مشتری را دوباره «واردنشده» می‌کند. const byMobile = mobile ? await this.prisma.person.findFirst({ where: { organizationId, OR: [{ mobile }, { phone: mobile }], + accountingLinks: { none: { connectionId: dto.connectionId } }, }, }) : null; diff --git a/apps/web/src/views/TahesabSettingsView.vue b/apps/web/src/views/TahesabSettingsView.vue index 8b3f301..e9e07f7 100644 --- a/apps/web/src/views/TahesabSettingsView.vue +++ b/apps/web/src/views/TahesabSettingsView.vue @@ -66,25 +66,13 @@ type SyncResult = { personName?: string | null; }; const syncResults = ref([]); -/** حالت آخرین جستجو — برای پیجینیت بازهٔ کد */ -const syncListMode = ref<'none' | 'listAll' | 'range' | 'filter'>('none'); -const syncListPage = ref(1); -const syncListMeta = reactive({ - fromCode: 0, - toCode: 0, - hasMore: false, - pageSize: 2000, -}); -const SYNC_TABLE_PAGE_SIZE = 50; +/** هر درخواست listAll یک بازهٔ ۲۰۰۰ کدی است؛ بعد از دو بازهٔ خالی پشت‌سرهم، خواندن تمام می‌شود */ +const LIST_ALL_CODE_PAGE = 2000; +const LIST_ALL_EMPTY_STOP = 2; +const SYNC_TABLE_PAGE_SIZES = [10, 20, 50, 100]; +const syncTablePageSize = ref(50); const syncTablePage = ref(1); -const syncCodeTotalPages = computed(() => { - if (syncListMode.value !== 'listAll') return 1; - // اگر صفحه فعلی نتیجه دارد، صفحهٔ بعد هم قابل امتحان است - return syncListMeta.hasMore - ? syncListPage.value + 1 - : Math.max(1, syncListPage.value); -}); const syncOnlyNew = ref(false); const syncVisibleResults = computed(() => syncOnlyNew.value @@ -92,15 +80,18 @@ const syncVisibleResults = computed(() => : syncResults.value, ); const syncTableTotalPages = computed(() => - Math.max(1, Math.ceil(syncVisibleResults.value.length / SYNC_TABLE_PAGE_SIZE)), + Math.max(1, Math.ceil(syncVisibleResults.value.length / syncTablePageSize.value)), ); const syncPagedResults = computed(() => { - const start = (syncTablePage.value - 1) * SYNC_TABLE_PAGE_SIZE; - return syncVisibleResults.value.slice(start, start + SYNC_TABLE_PAGE_SIZE); + const start = (syncTablePage.value - 1) * syncTablePageSize.value; + return syncVisibleResults.value.slice(start, start + syncTablePageSize.value); }); -watch(syncOnlyNew, () => { +watch([syncOnlyNew, syncTablePageSize], () => { syncTablePage.value = 1; }); +watch(syncTableTotalPages, (total) => { + if (syncTablePage.value > total) syncTablePage.value = total; +}); const productSync = reactive({ connectionId: '', @@ -301,7 +292,43 @@ async function removeConn(id: string) { } } -async function searchTahesab(mode: 'range' | 'filter', codePage?: number) { +type SyncSearchResponse = { results: SyncResult[] }; + +async function fetchAllCustomers(): Promise { + const all: SyncResult[] = []; + let page = 1; + let emptyStreak = 0; + importOverlay.open = true; + importOverlay.progress = null; + try { + while (emptyStreak < LIST_ALL_EMPTY_STOP) { + importOverlay.message = `در حال خواندن مشتریان از ته‌حساب… ${toFaDigits(all.length)} مشتری`; + const { data } = await api.post( + '/integrations/tahesab/sync/search', + { + connectionId: sync.connectionId, + listAll: true, + page, + pageSize: LIST_ALL_CODE_PAGE, + }, + { timeout: 90_000 }, + ); + const rows = data.results ?? []; + if (rows.length) { + all.push(...rows); + emptyStreak = 0; + } else { + emptyStreak += 1; + } + page += 1; + } + } finally { + importOverlay.open = false; + } + return all; +} + +async function searchTahesab(mode: 'range' | 'filter') { error.value = ''; ok.value = ''; if (!sync.connectionId) { @@ -311,17 +338,12 @@ async function searchTahesab(mode: 'range' | 'filter', codePage?: number) { syncBusy.value = true; try { const body: Record = { connectionId: sync.connectionId }; + let listAll = false; if (mode === 'range') { const fromRaw = String(sync.fromCode ?? '').trim(); const toRaw = String(sync.toCode ?? '').trim(); if (!fromRaw && !toRaw) { - // همهٔ مشتریان — صفحه‌بندی بازهٔ کد (هر صفحه ۲۰۰۰ کد؛ بدون سقف ۵۰۰۰) - const page = Math.max(1, codePage ?? syncListPage.value); - body.listAll = true; - body.page = page; - body.pageSize = 2000; - syncListPage.value = page; - syncListMode.value = 'listAll'; + listAll = true; } else { const fromCode = fromRaw ? Number(fromRaw) : 1; const toCode = toRaw ? Number(toRaw) : fromCode + 1999; @@ -335,59 +357,38 @@ async function searchTahesab(mode: 'range' | 'filter', codePage?: number) { } body.fromCode = fromCode; body.toCode = toCode; - syncListMode.value = 'range'; - syncListMeta.hasMore = false; } } else if (sync.moshtariCode !== '' && sync.moshtariCode != null) { body.moshtariCode = Number(sync.moshtariCode); - syncListMode.value = 'filter'; - syncListMeta.hasMore = false; } else if (sync.phone.trim()) { body.phone = sync.phone.trim(); - syncListMode.value = 'filter'; - syncListMeta.hasMore = false; } else if (sync.name.trim()) { body.name = sync.name.trim(); - syncListMode.value = 'filter'; - syncListMeta.hasMore = false; } else { error.value = 'موبایل، نام، کد مشتری یا بازهٔ کد را وارد کنید.'; return; } - const { data } = await api.post<{ - results: SyncResult[]; - page?: number; - pageSize?: number; - fromCode?: number; - toCode?: number; - hasMore?: boolean; - note?: string; - }>( - '/integrations/tahesab/sync/search', - body, - { timeout: body.listAll ? 90_000 : 60_000 }, - ); - syncResults.value = data.results ?? []; - syncTablePage.value = 1; - if (data.page != null) { - syncListPage.value = data.page; - syncListMeta.fromCode = data.fromCode ?? 0; - syncListMeta.toCode = data.toCode ?? 0; - syncListMeta.hasMore = Boolean(data.hasMore); - syncListMeta.pageSize = data.pageSize ?? 2000; + + if (listAll) { + syncResults.value = await fetchAllCustomers(); + } else { + const { data } = await api.post( + '/integrations/tahesab/sync/search', + body, + { timeout: 60_000 }, + ); + syncResults.value = data.results ?? []; } + syncTablePage.value = 1; + if (!syncResults.value.length) { - ok.value = - data.note ?? - (syncListMode.value === 'listAll' && syncListPage.value > 1 - ? 'در این صفحه مشتری‌ای نیست. صفحه قبل را ببینید یا بازه دستی بزنید.' - : 'نتیجه‌ای یافت نشد.'); + ok.value = 'نتیجه‌ای یافت نشد.'; } else { const linked = syncResults.value.filter((r) => r.alreadyLinked).length; - const base = - `${syncResults.value.length} مشتری پیدا شد` + - (linked ? ` (${linked} قبلاً لینک شده)` : ''); - ok.value = data.note ? `${base} — ${data.note}` : base; + const pending = syncResults.value.length - linked; + ok.value = + `${toFaDigits(syncResults.value.length)} مشتری پیدا شد` + + ` · ${toFaDigits(linked)} قبلاً وارد شده · ${toFaDigits(pending)} واردنشده`; } } catch (e) { error.value = apiErr(e, 'جستجو ناموفق'); @@ -396,10 +397,6 @@ async function searchTahesab(mode: 'range' | 'filter', codePage?: number) { } } -function onSyncCodePageChange(page: number) { - void searchTahesab('range', page); -} - function onSyncTablePageChange(page: number) { syncTablePage.value = page; } @@ -426,72 +423,24 @@ async function importOne(moshtariCode: number) { } } -async function importBulk(mode: 'results' | 'range') { +async function importBulk() { error.value = ''; ok.value = ''; if (!sync.connectionId) { error.value = 'ابتدا اتصال را انتخاب کنید.'; return; } - const body: Record = { connectionId: sync.connectionId }; - if (mode === 'results') { - const codes = pendingImportCodes(); - if (!codes.length) { - error.value = 'مشتری جدیدی در نتایج برای وارد کردن نیست (همه لینک شده‌اند یا لیست خالی است).'; - return; - } - if ( - !confirm( - `${codes.length} مشتری جدید از ته‌حساب به‌عنوان شخص در ${brand.nameFa} ساخته و لینک می‌شوند. ادامه؟`, - ) - ) { - return; - } - body.moshtariCodes = codes; - } else { - const fromRaw = String(sync.fromCode ?? '').trim(); - const toRaw = String(sync.toCode ?? '').trim(); - if (!fromRaw && !toRaw) { - const page = syncListMode.value === 'listAll' ? syncListPage.value : 1; - const from = - syncListMode.value === 'listAll' && syncListMeta.fromCode - ? syncListMeta.fromCode - : (page - 1) * 2000 + 1; - const to = - syncListMode.value === 'listAll' && syncListMeta.toCode - ? syncListMeta.toCode - : from + 1999; - if ( - !confirm( - `مشتریان صفحهٔ ${page} (کد ${from} تا ${to}) از ته‌حساب به‌عنوان شخص وارد ${brand.nameFa} می‌شوند. برای صفحات بعدی، پیجینیت را بزنید و دوباره وارد کنید. ادامه؟`, - ) - ) { - return; - } - body.listAll = true; - body.page = page; - body.pageSize = 2000; - } else { - const fromCode = fromRaw ? Number(fromRaw) : 1; - const toCode = toRaw ? Number(toRaw) : fromCode + 1999; - if (Number.isNaN(fromCode) || Number.isNaN(toCode) || fromCode < 1 || toCode < fromCode) { - error.value = 'بازه نامعتبر است.'; - return; - } - if (toCode - fromCode > 2000) { - error.value = 'حداکثر طول بازه در هر درخواست ۲۰۰۰ کد است.'; - return; - } - if ( - !confirm( - `مشتریان با کد ${fromCode} تا ${toCode} از ته‌حساب وارد ${brand.nameFa} می‌شوند. ادامه؟`, - ) - ) { - return; - } - body.fromCode = fromCode; - body.toCode = toCode; - } + const codes = pendingImportCodes(); + if (!codes.length) { + error.value = 'مشتری جدیدی در نتایج برای وارد کردن نیست (همه وارد شده‌اند یا لیست خالی است).'; + return; + } + if ( + !confirm( + `${codes.length} مشتری جدید از ته‌حساب به‌عنوان شخص در ${brand.nameFa} ساخته و لینک می‌شوند. ادامه؟`, + ) + ) { + return; } syncBusy.value = true; importOverlay.open = true; @@ -500,22 +449,6 @@ async function importBulk(mode: 'results' | 'range') { const totals = { imported: 0, skipped: 0, failed: 0 }; let firstError = ''; try { - 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); @@ -682,7 +615,7 @@ onMounted(load); class="crm-alert crm-alert-warning" > اتصال شما به‌خاطر گواهی SSL خودامضا قطع است. روی همان کارت اتصال، - فعال‌سازی SSL خودامضا را بزنید؛ بعد دوباره «نمایش بازه» را امتحان کنید. + فعال‌سازی SSL خودامضا را بزنید؛ بعد دوباره «نمایش مشتریان» را امتحان کنید. @@ -823,45 +756,31 @@ onMounted(load);

۱) وارد کردن دسته‌ای با بازه کد مشتری

- بازهٔ دستی حداکثر ۲۰۰۰ کد است. اگر «از» و «تا» خالی باشد، مشتریان صفحه‌به‌صفحه (هر صفحه ۲۰۰۰ کد) خوانده می‌شوند — بدون سقف ۵۰۰۰. + اگر «از» و «تا» خالی باشد، همهٔ مشتریان ته‌حساب خوانده و در جدول زیر نمایش داده می‌شوند. بازهٔ دستی حداکثر ۲۰۰۰ کد است.

- - + +
- نمایش بازه / همه + نمایش مشتریان - وارد کردن این صفحه / بازه + وارد کردن همهٔ واردنشده‌ها ({{ toFaDigits(pendingImportCodes().length) }})
- -

- صفحه {{ syncListPage }} — کد {{ syncListMeta.fromCode }} تا {{ syncListMeta.toCode }} - · صفحه بعد برای کدهای بالاتر -

@@ -881,9 +800,9 @@ onMounted(load); - وارد کردن نتایج جدید ({{ pendingImportCodes().length }}) + وارد کردن نتایج جدید ({{ toFaDigits(pendingImportCodes().length) }})
@@ -899,7 +818,7 @@ onMounted(load); @@ -947,13 +866,15 @@ onMounted(load);
@@ -1255,7 +1176,6 @@ onMounted(load); flex-wrap: wrap; } -.sync-code-pagination, .sync-table-pagination { margin-top: var(--sp-3); }