Add Tahesab bulk customer import by code range.

Connecting alone does not copy customers; settings now list a code range and import them into CRM persons with link status.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-05 09:33:12 +03:30
co-authored by Cursor
parent a88bd83c9b
commit 15a0afae5c
5 changed files with 390 additions and 22 deletions
+203 -17
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
@@ -30,10 +31,25 @@ const sync = reactive({
phone: '',
name: '',
moshtariCode: '' as string | number,
fromCode: '1',
toCode: '500',
});
const syncResults = ref<
Array<{ moshtariCode: number | null; name: string; tel: string }>
>([]);
const syncBusy = ref(false);
type SyncResult = {
moshtariCode: number | null;
name: string;
tel: string;
alreadyLinked?: boolean;
personId?: string | null;
personName?: string | null;
};
const syncResults = ref<SyncResult[]>([]);
function pendingImportCodes() {
return syncResults.value
.filter((r) => r.moshtariCode != null && !r.alreadyLinked)
.map((r) => r.moshtariCode as number);
}
function resetForm() {
editingId.value = null;
@@ -162,33 +178,55 @@ async function removeConn(id: string) {
}
}
async function searchTahesab() {
async function searchTahesab(mode: 'filter' | 'range' = 'filter') {
error.value = '';
ok.value = '';
syncResults.value = [];
if (!sync.connectionId) {
error.value = 'ابتدا اتصال را انتخاب کنید.';
return;
}
try {
syncBusy.value = true;
const body: Record<string, unknown> = {
connectionId: sync.connectionId,
};
if (sync.moshtariCode !== '' && sync.moshtariCode != null) {
if (mode === 'range') {
const fromCode = Number(sync.fromCode);
const toCode = Number(sync.toCode);
if (!Number.isFinite(fromCode) || !Number.isFinite(toCode)) {
error.value = 'بازهٔ کد را درست وارد کنید.';
return;
}
body.fromCode = fromCode;
body.toCode = toCode;
} else if (sync.moshtariCode !== '' && sync.moshtariCode != null) {
body.moshtariCode = Number(sync.moshtariCode);
} else if (sync.phone.trim()) {
body.phone = sync.phone.trim();
} else if (sync.name.trim()) {
body.name = sync.name.trim();
} else {
error.value = 'موبایل، نام یا کد مشتری را وارد کنید.';
error.value = 'موبایل، نام، کد مشتری یا بازهٔ کد را وارد کنید.';
return;
}
const { data } = await api.post<{
results: Array<{ moshtariCode: number | null; name: string; tel: string }>;
}>('/integrations/tahesab/sync/search', body);
const { data } = await api.post<{ results: SyncResult[] }>(
'/integrations/tahesab/sync/search',
body,
);
syncResults.value = data.results ?? [];
if (!syncResults.value.length) ok.value = 'نتیجه‌ای یافت نشد.';
if (!syncResults.value.length) {
ok.value = 'نتیجه‌ای یافت نشد.';
} else {
const linked = syncResults.value.filter((r) => r.alreadyLinked).length;
ok.value = `${syncResults.value.length} مشتری پیدا شد` + (linked ? ` (${linked} قبلاً لینک شده)` : '');
}
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'جستجو ناموفق')
: 'جستجو ناموفق';
} finally {
syncBusy.value = false;
}
}
@@ -196,15 +234,80 @@ async function importOne(moshtariCode: number) {
error.value = '';
ok.value = '';
try {
syncBusy.value = true;
const { data } = await api.post<{ personId: string }>(
'/integrations/tahesab/sync/import',
{ connectionId: sync.connectionId, moshtariCode },
);
ok.value = `وارد شد — شخص ${data.personId}`;
ok.value = `وارد شد — در مخاطبین با شناسه ${data.personId}`;
const row = syncResults.value.find((r) => r.moshtariCode === moshtariCode);
if (row) {
row.alreadyLinked = true;
row.personId = data.personId;
}
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'وارد کردن ناموفق')
: 'وارد کردن ناموفق';
} finally {
syncBusy.value = false;
}
}
async function importBulk(mode: 'results' | 'range') {
error.value = '';
ok.value = '';
if (!sync.connectionId) {
error.value = 'ابتدا اتصال را انتخاب کنید.';
return;
}
const body: Record<string, unknown> = { connectionId: sync.connectionId };
if (mode === 'results') {
const codes = pendingImportCodes();
if (!codes.length) {
error.value = 'مشتری جدیدی در نتایج برای وارد کردن نیست (همه لینک شده‌اند یا لیست خالی است).';
return;
}
if (
!confirm(
`${codes.length} مشتری جدید از ته‌حساب به‌عنوان شخص در منتو ساخته و لینک می‌شوند. ادامه؟`,
)
) {
return;
}
body.moshtariCodes = codes;
} else {
const fromCode = Number(sync.fromCode);
const toCode = Number(sync.toCode);
if (!Number.isFinite(fromCode) || !Number.isFinite(toCode)) {
error.value = 'بازهٔ کد را درست وارد کنید.';
return;
}
if (
!confirm(
`همهٔ مشتریان ته‌حساب با کد ${fromCode} تا ${toCode} وارد منتو می‌شوند (قبلاً لینک‌شده‌ها رد می‌شوند). ادامه؟`,
)
) {
return;
}
body.fromCode = fromCode;
body.toCode = toCode;
}
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');
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'وارد کردن دسته‌ای ناموفق')
: 'وارد کردن دسته‌ای ناموفق';
} finally {
syncBusy.value = false;
}
}
@@ -309,9 +412,10 @@ onMounted(load);
</form>
<section class="card sync">
<h2>جستجو / وارد کردن از ته‌حساب</h2>
<h2>وارد کردن مشتریان از ته‌حساب</h2>
<p class="muted">
مشتری را در یک اتصال جستجو کنید و به‌عنوان شخص جدید وارد CRM کنید یا به شخص موجود لینک دهید.
فقط «افزودن اتصال» مشتری‌ها را نمی‌آورد. باید با بازهٔ کد یا جستجو آن‌ها را از ته‌حساب
<strong>وارد</strong> کنید تا در «اشخاص و شرکت‌ها» دیده شوند. جزئیات مانده/اسناد بعد از لینک، در پروفایل شخص → تب دارایی است.
</p>
<div class="field">
<label>اتصال</label>
@@ -322,28 +426,82 @@ onMounted(load);
</option>
</select>
</div>
<h3 class="subhead">۱) وارد کردن دسته‌ای با بازهٔ کد مشتری</h3>
<p class="muted small">
معمولاً کدها از ۱ شروع می‌شوند. بازه را مطابق دیتابیس ته‌حساب بگذارید (حداکثر ۲۰۰۰ کد در هر بار).
</p>
<div class="sync-grid range">
<input v-model="sync.fromCode" type="number" min="1" dir="ltr" placeholder="از کد" />
<input v-model="sync.toCode" type="number" min="1" dir="ltr" placeholder="تا کد" />
</div>
<div class="sync-actions">
<button
type="button"
class="btn ghost"
:disabled="!sync.connectionId || syncBusy"
@click="searchTahesab('range')"
>
نمایش بازه
</button>
<button
type="button"
class="btn primary"
:disabled="!sync.connectionId || syncBusy"
@click="importBulk('range')"
>
وارد کردن کل بازه
</button>
</div>
<h3 class="subhead">۲) جستجوی تکی</h3>
<div class="sync-grid">
<input v-model="sync.phone" dir="ltr" placeholder="موبایل" />
<input v-model="sync.name" placeholder="نام" />
<input v-model="sync.moshtariCode" type="number" dir="ltr" placeholder="کد مشتری" />
</div>
<button type="button" class="btn ghost" :disabled="!sync.connectionId" @click="searchTahesab">
جستجو
</button>
<div class="sync-actions">
<button
type="button"
class="btn ghost"
:disabled="!sync.connectionId || syncBusy"
@click="searchTahesab('filter')"
>
جستجو
</button>
<button
type="button"
class="btn primary"
:disabled="!sync.connectionId || syncBusy || !pendingImportCodes().length"
@click="importBulk('results')"
>
وارد کردن نتایج جدید ({{ pendingImportCodes().length }})
</button>
</div>
<ul v-if="syncResults.length" class="sync-results">
<li v-for="r in syncResults" :key="String(r.moshtariCode)">
<span>
{{ r.moshtariCode }} — {{ r.name || 'بدون نام' }}
<span class="muted">{{ r.tel }}</span>
<span v-if="r.alreadyLinked" class="linked">لینک‌شده{{ r.personName ? ` · ${r.personName}` : '' }}</span>
</span>
<button
v-if="!r.alreadyLinked"
type="button"
class="btn"
:disabled="!r.moshtariCode"
:disabled="!r.moshtariCode || syncBusy"
@click="importOne(r.moshtariCode!)"
>
وارد کردن
</button>
<RouterLink
v-else-if="r.personId"
class="btn ghost"
:to="{ name: 'person-detail', params: { id: r.personId } }"
>
مشاهده شخص
</RouterLink>
</li>
</ul>
</section>
@@ -534,6 +692,34 @@ select {
gap: 0.5rem;
}
.sync-grid.range {
grid-template-columns: repeat(2, 1fr);
}
.sync-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.subhead {
margin: 0.35rem 0 0;
font-size: 0.95rem;
color: #0f766e;
}
.muted.small {
font-size: 0.85rem;
}
.linked {
display: inline-block;
margin-inline-start: 0.35rem;
font-size: 0.78rem;
font-weight: 700;
color: #047857;
}
.sync-results {
list-style: none;
margin: 0;