Paginate Tahesab customer list by code windows past the 5000 cap.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-04 11:09:40 +03:30
co-authored by Cursor
parent 60c16053a4
commit 236f73de93
3 changed files with 233 additions and 45 deletions
+35 -2
View File
@@ -7,6 +7,7 @@ import {
IsOptional, IsOptional,
IsString, IsString,
IsUrl, IsUrl,
Max,
MaxLength, MaxLength,
Min, Min,
MinLength, MinLength,
@@ -155,10 +156,24 @@ export class ImportBulkFromTahesabDto {
@Min(1) @Min(1)
toCode?: number; toCode?: number;
/** اگر true باشد همهٔ مشتریان با اسکن بازه‌ای خوانده می‌شوند */ /** اگر true باشد یک صفحه از مشتریان (بازهٔ کد) خوانده می‌شود */
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
listAll?: boolean; listAll?: boolean;
/** صفحهٔ بازهٔ کد هنگام listAll (پیش‌فرض ۱) */
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(100)
@Max(2000)
pageSize?: number;
} }
export class SearchTahesabDto { export class SearchTahesabDto {
@@ -191,10 +206,28 @@ export class SearchTahesabDto {
@Min(1) @Min(1)
toCode?: number; toCode?: number;
/** بازه خالی در UI → همهٔ مشتریان */ /** بازه خالی در UI → مرور صفحه‌ای همهٔ مشتریان */
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
listAll?: boolean; listAll?: boolean;
/**
* صفحهٔ بازهٔ کد برای listAll (۱ = کد ۱…۲۰۰۰، ۲ = ۲۰۰۱…۴۰۰۰، …).
* سقف ۵۰۰۰ دیگر اعمال نمی‌شود.
*/
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
/** اندازهٔ پنجرهٔ کد در هر صفحه (پیش‌فرض ۲۰۰۰، حداکثر ۲۰۰۰) */
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(100)
@Max(2000)
pageSize?: number;
} }
export class SearchTahesabProductsDto { export class SearchTahesabProductsDto {
@@ -17,10 +17,9 @@ import {
} from './dto'; } from './dto';
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods'; import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
const LIST_ALL_CHUNK = 2000; /** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */
/** سقف «همه» در یک درخواست ByCode — تکه‌تکه باعث ۵۰۲ پروکسی می‌شود */ const LIST_PAGE_SIZE = 2000;
const LIST_ALL_MAX_CODE = 5_000; const LIST_TIMEOUT_MS = 55_000;
const LIST_ALL_TIMEOUT_MS = 55_000;
function asMoshtariList(raw: unknown): Array<Record<string, unknown>> { function asMoshtariList(raw: unknown): Array<Record<string, unknown>> {
if (!raw) return []; if (!raw) return [];
@@ -318,25 +317,32 @@ export class TahesabPersonLinkService {
} }
/** /**
* لیست همهٔ مشتریان در یک درخواست (بازه ۱…LIST_ALL_MAX_CODE). * یک صفحه از مشتریان بر اساس بازهٔ کد:
* تکه‌تکه کردن پشت‌سرهم معمولاً از مهلت nginx بیشتر می‌شود و ۵۰۲ می‌دهد. * صفحه ۱ → ۱…pageSize، صفحه ۲ → pageSize+1…۲×pageSize، …
* سقف ۵۰۰۰ حذف شده؛ با پیجینیت می‌توان همه را مرور کرد.
*/ */
private async listAllMoshtari(api: TahesabApi) { private async listMoshtariPage(
try { api: TahesabApi,
return asMoshtariList( page: number,
await api.doListMoshtariByCode(1, LIST_ALL_MAX_CODE, { pageSize?: number,
timeoutMs: LIST_ALL_TIMEOUT_MS, ) {
}), const size = Math.min(
); LIST_PAGE_SIZE,
} catch (err) { Math.max(100, Math.floor(pageSize ?? LIST_PAGE_SIZE) || LIST_PAGE_SIZE),
// اگر بازهٔ بزرگ سمت ته‌حساب رد شد، همان سقف دستی ۲۰۰۰ را یک‌بار امتحان کن );
if (LIST_ALL_MAX_CODE <= LIST_ALL_CHUNK) throw err; const p = Math.max(1, Math.floor(page) || 1);
return asMoshtariList( const fromCode = (p - 1) * size + 1;
await api.doListMoshtariByCode(1, LIST_ALL_CHUNK, { const toCode = fromCode + size - 1;
timeoutMs: LIST_ALL_TIMEOUT_MS, 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) { async search(organizationId: string, dto: SearchTahesabDto) {
@@ -345,8 +351,28 @@ export class TahesabPersonLinkService {
dto.connectionId, dto.connectionId,
); );
let raw: unknown = []; let raw: unknown = [];
let listMeta:
| {
page: number;
pageSize: number;
fromCode: number;
toCode: number;
}
| undefined;
if (dto.listAll) { 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) { } else if (dto.fromCode != null && dto.toCode != null) {
if (dto.toCode < dto.fromCode) { if (dto.toCode < dto.fromCode) {
throw new BadRequestException('کد پایان باید بزرگ‌تر یا مساوی کد شروع باشد.'); throw new BadRequestException('کد پایان باید بزرگ‌تر یا مساوی کد شروع باشد.');
@@ -354,7 +380,9 @@ export class TahesabPersonLinkService {
if (dto.toCode - dto.fromCode > 2000) { if (dto.toCode - dto.fromCode > 2000) {
throw new BadRequestException('بازهٔ کد حداکثر ۲۰۰۰ عدد است (مثلاً ۱ تا ۲۰۰۰). برای همه، بازه را خالی بگذارید.'); 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) { } else if (dto.moshtariCode) {
raw = await api.doListMoshtariByCode(dto.moshtariCode, dto.moshtariCode); raw = await api.doListMoshtariByCode(dto.moshtariCode, dto.moshtariCode);
} else if (dto.phone) { } else if (dto.phone) {
@@ -414,11 +442,23 @@ export class TahesabPersonLinkService {
kind: 'search', kind: 'search',
status: 'success', status: 'success',
request: dto as unknown as Prisma.InputJsonValue, 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( async importFromTahesab(
@@ -516,10 +556,15 @@ export class TahesabPersonLinkService {
organizationId, organizationId,
dto.connectionId, 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)) .map((row) => pickCode(row))
.filter((c): c is number => c != null); .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) { } else if (dto.fromCode != null && dto.toCode != null) {
if (dto.toCode < dto.fromCode) { if (dto.toCode < dto.fromCode) {
throw new BadRequestException('کد پایان باید بزرگ‌تر یا مساوی کد شروع باشد.'); 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) { if (codes.length > maxImport) {
throw new BadRequestException( throw new BadRequestException(
`حداکثر ${maxImport} مشتری در هر بار وارد کنید (الان ${codes.length}). بازه را کوچک‌تر کنید.`, `حداکثر ${maxImport} مشتری در هر بار وارد کنید (الان ${codes.length}). صفحه یا بازه را کوچک‌تر کنید.`,
); );
} }
+123 -13
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'; import { computed, onMounted, reactive, ref } from 'vue';
import { RouterLink } from 'vue-router'; import { RouterLink } from 'vue-router';
import axios from 'axios'; import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
@@ -16,6 +16,7 @@ import {
UiField, UiField,
UiInput, UiInput,
UiPageHeader, UiPageHeader,
UiPagination,
UiSelect, UiSelect,
} from '@/components/ui'; } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
@@ -55,6 +56,32 @@ type SyncResult = {
personName?: string | null; personName?: string | null;
}; };
const syncResults = ref<SyncResult[]>([]); const syncResults = ref<SyncResult[]>([]);
/** حالت آخرین جستجو — برای پیجینیت بازهٔ کد */
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;
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 syncTableTotalPages = computed(() =>
Math.max(1, Math.ceil(syncResults.value.length / SYNC_TABLE_PAGE_SIZE)),
);
const syncPagedResults = computed(() => {
const start = (syncTablePage.value - 1) * SYNC_TABLE_PAGE_SIZE;
return syncResults.value.slice(start, start + SYNC_TABLE_PAGE_SIZE);
});
const productSync = reactive({ const productSync = reactive({
connectionId: '', connectionId: '',
@@ -255,7 +282,7 @@ async function removeConn(id: string) {
} }
} }
async function searchTahesab(mode: 'range' | 'filter') { async function searchTahesab(mode: 'range' | 'filter', codePage?: number) {
error.value = ''; error.value = '';
ok.value = ''; ok.value = '';
if (!sync.connectionId) { if (!sync.connectionId) {
@@ -269,8 +296,13 @@ async function searchTahesab(mode: 'range' | 'filter') {
const fromRaw = String(sync.fromCode ?? '').trim(); const fromRaw = String(sync.fromCode ?? '').trim();
const toRaw = String(sync.toCode ?? '').trim(); const toRaw = String(sync.toCode ?? '').trim();
if (!fromRaw && !toRaw) { if (!fromRaw && !toRaw) {
// API برای «همه» فلگ listAll دارد؛ ارسال ۱…۵۰۰۰ با سقف ۲۰۰۰ کد رد می‌شود // همهٔ مشتریان — صفحه‌بندی بازهٔ کد (هر صفحه ۲۰۰۰ کد؛ بدون سقف ۵۰۰۰)
const page = Math.max(1, codePage ?? syncListPage.value);
body.listAll = true; body.listAll = true;
body.page = page;
body.pageSize = 2000;
syncListPage.value = page;
syncListMode.value = 'listAll';
} else { } else {
const fromCode = fromRaw ? Number(fromRaw) : 1; const fromCode = fromRaw ? Number(fromRaw) : 1;
const toCode = toRaw ? Number(toRaw) : fromCode + 1999; const toCode = toRaw ? Number(toRaw) : fromCode + 1999;
@@ -284,28 +316,59 @@ async function searchTahesab(mode: 'range' | 'filter') {
} }
body.fromCode = fromCode; body.fromCode = fromCode;
body.toCode = toCode; body.toCode = toCode;
syncListMode.value = 'range';
syncListMeta.hasMore = false;
} }
} else if (sync.moshtariCode !== '' && sync.moshtariCode != null) { } else if (sync.moshtariCode !== '' && sync.moshtariCode != null) {
body.moshtariCode = Number(sync.moshtariCode); body.moshtariCode = Number(sync.moshtariCode);
syncListMode.value = 'filter';
syncListMeta.hasMore = false;
} else if (sync.phone.trim()) { } else if (sync.phone.trim()) {
body.phone = sync.phone.trim(); body.phone = sync.phone.trim();
syncListMode.value = 'filter';
syncListMeta.hasMore = false;
} else if (sync.name.trim()) { } else if (sync.name.trim()) {
body.name = sync.name.trim(); body.name = sync.name.trim();
syncListMode.value = 'filter';
syncListMeta.hasMore = false;
} else { } else {
error.value = 'موبایل، نام، کد مشتری یا بازهٔ کد را وارد کنید.'; error.value = 'موبایل، نام، کد مشتری یا بازهٔ کد را وارد کنید.';
return; return;
} }
const { data } = await api.post<{ results: SyncResult[] }>( const { data } = await api.post<{
results: SyncResult[];
page?: number;
pageSize?: number;
fromCode?: number;
toCode?: number;
hasMore?: boolean;
note?: string;
}>(
'/integrations/tahesab/sync/search', '/integrations/tahesab/sync/search',
body, body,
{ timeout: body.listAll ? 180_000 : 60_000 }, { timeout: body.listAll ? 90_000 : 60_000 },
); );
syncResults.value = data.results ?? []; 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 (!syncResults.value.length) { if (!syncResults.value.length) {
ok.value = 'نتیجه‌ای یافت نشد.'; ok.value =
data.note ??
(syncListMode.value === 'listAll' && syncListPage.value > 1
? 'در این صفحه مشتری‌ای نیست. صفحه قبل را ببینید یا بازه دستی بزنید.'
: 'نتیجه‌ای یافت نشد.');
} else { } else {
const linked = syncResults.value.filter((r) => r.alreadyLinked).length; const linked = syncResults.value.filter((r) => r.alreadyLinked).length;
ok.value = `${syncResults.value.length} مشتری پیدا شد` + (linked ? ` (${linked} قبلاً لینک شده)` : ''); const base =
`${syncResults.value.length} مشتری پیدا شد` +
(linked ? ` (${linked} قبلاً لینک شده)` : '');
ok.value = data.note ? `${base} — ${data.note}` : base;
} }
} catch (e) { } catch (e) {
error.value = apiErr(e, 'جستجو ناموفق'); error.value = apiErr(e, 'جستجو ناموفق');
@@ -314,6 +377,14 @@ async function searchTahesab(mode: 'range' | 'filter') {
} }
} }
function onSyncCodePageChange(page: number) {
void searchTahesab('range', page);
}
function onSyncTablePageChange(page: number) {
syncTablePage.value = page;
}
async function importOne(moshtariCode: number) { async function importOne(moshtariCode: number) {
error.value = ''; error.value = '';
ok.value = ''; ok.value = '';
@@ -362,14 +433,25 @@ async function importBulk(mode: 'results' | 'range') {
const fromRaw = String(sync.fromCode ?? '').trim(); const fromRaw = String(sync.fromCode ?? '').trim();
const toRaw = String(sync.toCode ?? '').trim(); const toRaw = String(sync.toCode ?? '').trim();
if (!fromRaw && !toRaw) { 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 ( if (
!confirm( !confirm(
`کل مشتریان قابل‌خواندن از ته‌حساب به‌عنوان شخص وارد ${brand.nameFa} می‌شوند. ادامه؟`, `مشتریان صفحهٔ ${page} (کد ${from} تا ${to}) از ته‌حساب به‌عنوان شخص وارد ${brand.nameFa} می‌شوند. برای صفحات بعدی، پیجینیت را بزنید و دوباره وارد کنید. ادامه؟`,
) )
) { ) {
return; return;
} }
body.listAll = true; body.listAll = true;
body.page = page;
body.pageSize = 2000;
} else { } else {
const fromCode = fromRaw ? Number(fromRaw) : 1; const fromCode = fromRaw ? Number(fromRaw) : 1;
const toCode = toRaw ? Number(toRaw) : fromCode + 1999; const toCode = toRaw ? Number(toRaw) : fromCode + 1999;
@@ -668,7 +750,7 @@ onMounted(load);
<div class="sync-sub-section"> <div class="sync-sub-section">
<h4 class="sync-subhead">۱) وارد کردن دسته‌ای با بازه کد مشتری</h4> <h4 class="sync-subhead">۱) وارد کردن دسته‌ای با بازه کد مشتری</h4>
<p class="sync-sub-desc"> <p class="sync-sub-desc">
بازهٔ دستی حداکثر ۲۰۰۰ کد است. اگر «از» و «تا» خالی باشد، همهٔ مشتریان خوانده می‌شوند. بازهٔ دستی حداکثر ۲۰۰۰ کد است. اگر «از» و «تا» خالی باشد، مشتریان صفحه‌به‌صفحه (هر صفحه ۲۰۰۰ کد) خوانده می‌شوند — بدون سقف ۵۰۰۰.
</p> </p>
<div class="crm-form-grid-2"> <div class="crm-form-grid-2">
<UiInput v-model="sync.fromCode" type="number" min="1" dir="ltr" placeholder="از کد (مثلاً ۱)" /> <UiInput v-model="sync.fromCode" type="number" min="1" dir="ltr" placeholder="از کد (مثلاً ۱)" />
@@ -678,17 +760,31 @@ onMounted(load);
<UiButton <UiButton
variant="secondary" variant="secondary"
:disabled="!sync.connectionId || syncBusy" :disabled="!sync.connectionId || syncBusy"
@click="searchTahesab('range')" @click="searchTahesab('range', 1)"
> >
نمایش بازه نمایش بازه / همه
</UiButton> </UiButton>
<UiButton <UiButton
:disabled="!sync.connectionId || syncBusy" :disabled="!sync.connectionId || syncBusy"
@click="importBulk('range')" @click="importBulk('range')"
> >
وارد کردن کل بازه وارد کردن این صفحه / بازه
</UiButton> </UiButton>
</div> </div>
<UiPagination
v-if="syncListMode === 'listAll' && (syncResults.length || syncListPage > 1)"
:page="syncListPage"
:total-pages="syncCodeTotalPages"
class="sync-code-pagination"
@update:page="onSyncCodePageChange"
/>
<p
v-if="syncListMode === 'listAll' && syncListMeta.fromCode"
class="sync-sub-desc"
>
صفحه {{ syncListPage }} — کد {{ syncListMeta.fromCode }} تا {{ syncListMeta.toCode }}
<span v-if="syncListMeta.hasMore"> · صفحه بعد برای کدهای بالاتر</span>
</p>
</div> </div>
<div class="sync-sub-section"> <div class="sync-sub-section">
@@ -729,7 +825,7 @@ onMounted(load);
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="r in syncResults" :key="String(r.moshtariCode)"> <tr v-for="r in syncPagedResults" :key="String(r.moshtariCode)">
<td class="font-mono">{{ r.moshtariCode }}</td> <td class="font-mono">{{ r.moshtariCode }}</td>
<td class="font-bold">{{ r.name || 'بدون نام' }}</td> <td class="font-bold">{{ r.name || 'بدون نام' }}</td>
<td class="font-mono" dir="ltr">{{ r.tel }}</td> <td class="font-mono" dir="ltr">{{ r.tel }}</td>
@@ -761,6 +857,15 @@ onMounted(load);
</tbody> </tbody>
</table> </table>
</div> </div>
<UiPagination
v-if="syncTableTotalPages > 1"
:page="syncTablePage"
:total-pages="syncTableTotalPages"
:total-items="syncResults.length"
:page-size="SYNC_TABLE_PAGE_SIZE"
class="sync-table-pagination"
@update:page="onSyncTablePageChange"
/>
</div> </div>
</div> </div>
</UiCard> </UiCard>
@@ -1046,6 +1151,11 @@ onMounted(load);
flex-wrap: wrap; flex-wrap: wrap;
} }
.sync-code-pagination,
.sync-table-pagination {
margin-top: var(--sp-3);
}
.sync-results-box { .sync-results-box {
display: flex; display: flex;
flex-direction: column; flex-direction: column;