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.
This commit is contained in:
2026-10-20 12:00:11 +03:30
parent c1b38e1737
commit adb63447ca
2 changed files with 100 additions and 177 deletions
@@ -548,12 +548,15 @@ export class TahesabPersonLinkService {
}, },
}); });
} else { } else {
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین // اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین.
// شخصی که روی همین اتصال به کد دیگری لینک است کنار گذاشته می‌شود؛
// وگرنه upsert لینک، کد قبلی را جابه‌جا و آن مشتری را دوباره «واردنشده» می‌کند.
const byMobile = mobile const byMobile = mobile
? await this.prisma.person.findFirst({ ? await this.prisma.person.findFirst({
where: { where: {
organizationId, organizationId,
OR: [{ mobile }, { phone: mobile }], OR: [{ mobile }, { phone: mobile }],
accountingLinks: { none: { connectionId: dto.connectionId } },
}, },
}) })
: null; : null;
+96 -176
View File
@@ -66,25 +66,13 @@ type SyncResult = {
personName?: string | null; personName?: string | null;
}; };
const syncResults = ref<SyncResult[]>([]); const syncResults = ref<SyncResult[]>([]);
/** حالت آخرین جستجو — برای پیجینیت بازهٔ کد */ /** هر درخواست listAll یک بازهٔ ۲۰۰۰ کدی است؛ بعد از دو بازهٔ خالی پشت‌سرهم، خواندن تمام می‌شود */
const syncListMode = ref<'none' | 'listAll' | 'range' | 'filter'>('none'); const LIST_ALL_CODE_PAGE = 2000;
const syncListPage = ref(1); const LIST_ALL_EMPTY_STOP = 2;
const syncListMeta = reactive({ const SYNC_TABLE_PAGE_SIZES = [10, 20, 50, 100];
fromCode: 0, const syncTablePageSize = ref(50);
toCode: 0,
hasMore: false,
pageSize: 2000,
});
const SYNC_TABLE_PAGE_SIZE = 50;
const syncTablePage = ref(1); 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 syncOnlyNew = ref(false);
const syncVisibleResults = computed(() => const syncVisibleResults = computed(() =>
syncOnlyNew.value syncOnlyNew.value
@@ -92,15 +80,18 @@ const syncVisibleResults = computed(() =>
: syncResults.value, : syncResults.value,
); );
const syncTableTotalPages = computed(() => 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 syncPagedResults = computed(() => {
const start = (syncTablePage.value - 1) * SYNC_TABLE_PAGE_SIZE; const start = (syncTablePage.value - 1) * syncTablePageSize.value;
return syncVisibleResults.value.slice(start, start + SYNC_TABLE_PAGE_SIZE); return syncVisibleResults.value.slice(start, start + syncTablePageSize.value);
}); });
watch(syncOnlyNew, () => { watch([syncOnlyNew, syncTablePageSize], () => {
syncTablePage.value = 1; syncTablePage.value = 1;
}); });
watch(syncTableTotalPages, (total) => {
if (syncTablePage.value > total) syncTablePage.value = total;
});
const productSync = reactive({ const productSync = reactive({
connectionId: '', 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<SyncResult[]> {
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<SyncSearchResponse>(
'/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 = ''; error.value = '';
ok.value = ''; ok.value = '';
if (!sync.connectionId) { if (!sync.connectionId) {
@@ -311,17 +338,12 @@ async function searchTahesab(mode: 'range' | 'filter', codePage?: number) {
syncBusy.value = true; syncBusy.value = true;
try { try {
const body: Record<string, unknown> = { connectionId: sync.connectionId }; const body: Record<string, unknown> = { connectionId: sync.connectionId };
let listAll = false;
if (mode === 'range') { if (mode === '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) {
// همهٔ مشتریان — صفحه‌بندی بازهٔ کد (هر صفحه ۲۰۰۰ کد؛ بدون سقف ۵۰۰۰) listAll = true;
const page = Math.max(1, codePage ?? syncListPage.value);
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;
@@ -335,59 +357,38 @@ async function searchTahesab(mode: 'range' | 'filter', codePage?: number) {
} }
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[]; if (listAll) {
page?: number; syncResults.value = await fetchAllCustomers();
pageSize?: number; } else {
fromCode?: number; const { data } = await api.post<SyncSearchResponse>(
toCode?: number; '/integrations/tahesab/sync/search',
hasMore?: boolean; body,
note?: string; { timeout: 60_000 },
}>( );
'/integrations/tahesab/sync/search', syncResults.value = data.results ?? [];
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;
} }
syncTablePage.value = 1;
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;
const base = const pending = syncResults.value.length - linked;
`${syncResults.value.length} مشتری پیدا شد` + ok.value =
(linked ? ` (${linked} قبلاً لینک شده)` : ''); `${toFaDigits(syncResults.value.length)} مشتری پیدا شد` +
ok.value = data.note ? `${base} — ${data.note}` : base; ` · ${toFaDigits(linked)} قبلاً وارد شده · ${toFaDigits(pending)} واردنشده`;
} }
} catch (e) { } catch (e) {
error.value = apiErr(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) { function onSyncTablePageChange(page: number) {
syncTablePage.value = page; syncTablePage.value = page;
} }
@@ -426,72 +423,24 @@ async function importOne(moshtariCode: number) {
} }
} }
async function importBulk(mode: 'results' | 'range') { async function importBulk() {
error.value = ''; error.value = '';
ok.value = ''; ok.value = '';
if (!sync.connectionId) { if (!sync.connectionId) {
error.value = 'ابتدا اتصال را انتخاب کنید.'; error.value = 'ابتدا اتصال را انتخاب کنید.';
return; return;
} }
const body: Record<string, unknown> = { connectionId: sync.connectionId }; const codes = pendingImportCodes();
if (mode === 'results') { if (!codes.length) {
const codes = pendingImportCodes(); error.value = 'مشتری جدیدی در نتایج برای وارد کردن نیست (همه وارد شده‌اند یا لیست خالی است).';
if (!codes.length) { return;
error.value = 'مشتری جدیدی در نتایج برای وارد کردن نیست (همه لینک شده‌اند یا لیست خالی است).'; }
return; if (
} !confirm(
if ( `${codes.length} مشتری جدید از ته‌حساب به‌عنوان شخص در ${brand.nameFa} ساخته و لینک می‌شوند. ادامه؟`,
!confirm( )
`${codes.length} مشتری جدید از ته‌حساب به‌عنوان شخص در ${brand.nameFa} ساخته و لینک می‌شوند. ادامه؟`, ) {
) return;
) {
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;
}
} }
syncBusy.value = true; syncBusy.value = true;
importOverlay.open = true; importOverlay.open = true;
@@ -500,22 +449,6 @@ async function importBulk(mode: 'results' | 'range') {
const totals = { imported: 0, skipped: 0, failed: 0 }; const totals = { imported: 0, skipped: 0, failed: 0 };
let firstError = ''; let firstError = '';
try { 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; const total = codes.length;
for (let i = 0; i < total; i += IMPORT_CHUNK) { for (let i = 0; i < total; i += IMPORT_CHUNK) {
const chunk = codes.slice(i, i + IMPORT_CHUNK); const chunk = codes.slice(i, i + IMPORT_CHUNK);
@@ -682,7 +615,7 @@ onMounted(load);
class="crm-alert crm-alert-warning" class="crm-alert crm-alert-warning"
> >
اتصال شما به‌خاطر گواهی SSL خودامضا قطع است. روی همان کارت اتصال، اتصال شما به‌خاطر گواهی SSL خودامضا قطع است. روی همان کارت اتصال،
<strong>فعال‌سازی SSL خودامضا</strong> را بزنید؛ بعد دوباره «نمایش بازه» را امتحان کنید. <strong>فعال‌سازی SSL خودامضا</strong> را بزنید؛ بعد دوباره «نمایش مشتریان» را امتحان کنید.
</div> </div>
<UiCard v-if="!loading && items.length" title="اتصالات تعریف‌شده" class="section-card"> <UiCard v-if="!loading && items.length" title="اتصالات تعریف‌شده" class="section-card">
@@ -823,45 +756,31 @@ 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="از کد (اختیاری)" />
<UiInput v-model="sync.toCode" type="number" min="1" dir="ltr" placeholder="تا کد (مثلاً ۵۰۰)" /> <UiInput v-model="sync.toCode" type="number" min="1" dir="ltr" placeholder="تا کد (اختیاری)" />
</div> </div>
<div class="sync-buttons"> <div class="sync-buttons">
<UiButton <UiButton
variant="secondary" variant="secondary"
:disabled="!sync.connectionId || syncBusy" :disabled="!sync.connectionId || syncBusy"
@click="searchTahesab('range', 1)" @click="searchTahesab('range')"
> >
نمایش بازه / همه نمایش مشتریان
</UiButton> </UiButton>
<UiButton <UiButton
:disabled="!sync.connectionId || syncBusy" :disabled="!sync.connectionId || syncBusy || !pendingImportCodes().length"
@click="importBulk('range')" @click="importBulk"
> >
وارد کردن این صفحه / بازه وارد کردن همهٔ واردنشده‌ها ({{ toFaDigits(pendingImportCodes().length) }})
</UiButton> </UiButton>
</div> </div>
<label class="custom-check"> <label class="custom-check">
<input v-model="syncOnlyNew" type="checkbox" /> <input v-model="syncOnlyNew" type="checkbox" />
<span>فقط مشتریانی که وارد نشده‌اند</span> <span>فقط مشتریانی که وارد نشده‌اند</span>
</label> </label>
<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">
@@ -881,9 +800,9 @@ onMounted(load);
</UiButton> </UiButton>
<UiButton <UiButton
:disabled="!sync.connectionId || syncBusy || !pendingImportCodes().length" :disabled="!sync.connectionId || syncBusy || !pendingImportCodes().length"
@click="importBulk('results')" @click="importBulk"
> >
وارد کردن نتایج جدید ({{ pendingImportCodes().length }}) وارد کردن نتایج جدید ({{ toFaDigits(pendingImportCodes().length) }})
</UiButton> </UiButton>
</div> </div>
</div> </div>
@@ -899,7 +818,7 @@ onMounted(load);
<table <table
v-table-cols="'tahesab.customers'" v-table-cols="'tahesab.customers'"
class="crm-table" class="crm-table"
:style="{ '--row-start': (syncTablePage - 1) * SYNC_TABLE_PAGE_SIZE }" :style="{ '--row-start': (syncTablePage - 1) * syncTablePageSize }"
> >
<thead> <thead>
<tr> <tr>
@@ -947,13 +866,15 @@ onMounted(load);
</table> </table>
</div> </div>
<UiPagination <UiPagination
v-if="syncTableTotalPages > 1" v-if="syncVisibleResults.length > SYNC_TABLE_PAGE_SIZES[0]"
:page="syncTablePage" :page="syncTablePage"
:total-pages="syncTableTotalPages" :total-pages="syncTableTotalPages"
:total-items="syncVisibleResults.length" :total-items="syncVisibleResults.length"
:page-size="SYNC_TABLE_PAGE_SIZE" :page-size="syncTablePageSize"
:page-size-options="SYNC_TABLE_PAGE_SIZES"
class="sync-table-pagination" class="sync-table-pagination"
@update:page="onSyncTablePageChange" @update:page="onSyncTablePageChange"
@update:page-size="syncTablePageSize = $event"
/> />
</div> </div>
</div> </div>
@@ -1255,7 +1176,6 @@ onMounted(load);
flex-wrap: wrap; flex-wrap: wrap;
} }
.sync-code-pagination,
.sync-table-pagination { .sync-table-pagination {
margin-top: var(--sp-3); margin-top: var(--sp-3);
} }