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
+39
View File
@@ -1,4 +1,5 @@
import {
IsArray,
IsBoolean,
IsIn,
IsInt,
@@ -121,6 +122,7 @@ export class ImportFromTahesabDto {
@IsString()
connectionId!: string;
@Type(() => Number)
@IsInt()
@Min(1)
moshtariCode!: number;
@@ -131,6 +133,29 @@ export class ImportFromTahesabDto {
personId?: string;
}
export class ImportBulkFromTahesabDto {
@IsString()
connectionId!: string;
@IsOptional()
@IsArray()
@Type(() => Number)
@IsInt({ each: true })
moshtariCodes?: number[];
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
fromCode?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
toCode?: number;
}
export class SearchTahesabDto {
@IsString()
connectionId!: string;
@@ -144,8 +169,22 @@ export class SearchTahesabDto {
name?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
moshtariCode?: number;
/** لیست بازه‌ای با DoListMoshtari-ByCode */
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
fromCode?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
toCode?: number;
}
export class ListAsnadQueryDto {
@@ -9,6 +9,7 @@ import { PrismaService } from '../../prisma/prisma.service';
import { TahesabConnectionsService } from './connections.service';
import {
CreateInTahesabDto,
ImportBulkFromTahesabDto,
ImportFromTahesabDto,
ManualLinkDto,
MatchPersonDto,
@@ -317,7 +318,15 @@ export class TahesabPersonLinkService {
dto.connectionId,
);
let raw: unknown = [];
if (dto.moshtariCode) {
if (dto.fromCode != null && dto.toCode != null) {
if (dto.toCode < dto.fromCode) {
throw new BadRequestException('کد پایان باید بزرگ‌تر یا مساوی کد شروع باشد.');
}
if (dto.toCode - dto.fromCode > 2000) {
throw new BadRequestException('بازهٔ کد حداکثر ۲۰۰۰ عدد است (مثلاً ۱ تا ۲۰۰۰).');
}
raw = await api.doListMoshtariByCode(dto.fromCode, dto.toCode);
} else if (dto.moshtariCode) {
raw = await api.doListMoshtariByCode(dto.moshtariCode, dto.moshtariCode);
} else if (dto.phone) {
const m = normalizeIranMobile(dto.phone) ?? dto.phone;
@@ -325,16 +334,49 @@ export class TahesabPersonLinkService {
} else if (dto.name?.trim()) {
raw = await api.doListMoshtariByName(dto.name.trim());
} else {
throw new BadRequestException('یکی از phone، name یا moshtariCode لازم است.');
throw new BadRequestException(
'یکی از موارد لازم است: بازه کد (از–تا)، کد مشتری، موبایل یا نام.',
);
}
const results = asMoshtariList(raw).map((row) => ({
const mapped = asMoshtariList(raw).map((row) => ({
moshtariCode: pickCode(row),
name: pickName(row),
tel: pickTel(row),
raw: row,
}));
const codes = mapped
.map((r) => r.moshtariCode)
.filter((c): c is number => c != null);
const existing =
codes.length === 0
? []
: await this.prisma.personAccountingLink.findMany({
where: {
connectionId: dto.connectionId,
moshtariCode: { in: codes },
},
select: {
moshtariCode: true,
personId: true,
person: { select: { firstName: true, lastName: true } },
},
});
const byCode = new Map(existing.map((e) => [e.moshtariCode, e]));
const results = mapped.map((row) => {
const link = row.moshtariCode != null ? byCode.get(row.moshtariCode) : undefined;
return {
...row,
alreadyLinked: Boolean(link),
personId: link?.personId ?? null,
personName: link
? `${link.person.firstName} ${link.person.lastName}`.trim()
: null,
};
});
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
@@ -435,4 +477,86 @@ export class TahesabPersonLinkService {
return { personId, link: this.linkDto(link) };
}
async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) {
let codes = (dto.moshtariCodes ?? []).filter((n) => Number.isFinite(n) && n >= 1);
if (dto.fromCode != null && dto.toCode != null) {
if (dto.toCode < dto.fromCode) {
throw new BadRequestException('کد پایان باید بزرگ‌تر یا مساوی کد شروع باشد.');
}
if (dto.toCode - dto.fromCode > 2000) {
throw new BadRequestException('بازهٔ کد حداکثر ۲۰۰۰ عدد است.');
}
const { api } = await this.connections.getApi(
organizationId,
dto.connectionId,
);
const raw = await api.doListMoshtariByCode(dto.fromCode, dto.toCode);
const listed = asMoshtariList(raw)
.map((row) => pickCode(row))
.filter((c): c is number => c != null);
codes = [...new Set([...codes, ...listed])];
}
if (!codes.length) {
throw new BadRequestException(
'کد مشتری یا بازهٔ کد برای وارد کردن مشخص نشده است.',
);
}
if (codes.length > 500) {
throw new BadRequestException('حداکثر ۵۰۰ مشتری در هر بار وارد کنید.');
}
const items: Array<{
moshtariCode: number;
ok: boolean;
personId?: string;
skipped?: boolean;
error?: string;
}> = [];
for (const moshtariCode of codes) {
try {
const existing = await this.prisma.personAccountingLink.findFirst({
where: { connectionId: dto.connectionId, moshtariCode },
});
if (existing) {
items.push({
moshtariCode,
ok: true,
skipped: true,
personId: existing.personId,
});
continue;
}
const r = await this.importFromTahesab(organizationId, {
connectionId: dto.connectionId,
moshtariCode,
});
items.push({ moshtariCode, ok: true, personId: r.personId });
} catch (e) {
const message =
e instanceof Error ? e.message : 'وارد کردن ناموفق بود';
items.push({ moshtariCode, ok: false, error: message });
}
}
const imported = items.filter((i) => i.ok && !i.skipped).length;
const skipped = items.filter((i) => i.skipped).length;
const failed = items.filter((i) => !i.ok).length;
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId: dto.connectionId,
kind: 'import',
status: failed && !imported ? 'error' : 'success',
request: dto as unknown as Prisma.InputJsonValue,
result: { imported, skipped, failed, count: items.length } as Prisma.InputJsonValue,
},
});
return { imported, skipped, failed, items };
}
}
@@ -20,6 +20,7 @@ import { TahesabDocumentsService } from './documents.service';
import {
CreateInTahesabDto,
CreateTahesabConnectionDto,
ImportBulkFromTahesabDto,
ImportFromTahesabDto,
ManualLinkDto,
MatchPersonDto,
@@ -200,4 +201,13 @@ export class TahesabController {
) {
return this.links.importFromTahesab(user.organizationId, dto);
}
@Post('sync/import-bulk')
@Roles('owner', 'admin')
importBulk(
@CurrentUser() user: { organizationId: string },
@Body() dto: ImportBulkFromTahesabDto,
) {
return this.links.importBulk(user.organizationId, dto);
}
}
+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;
+11 -2
View File
@@ -29,7 +29,7 @@
- تطبیق: `POST .../people/:id/match` · ثبت: `POST .../create-in-tahesab`
- دارایی: `GET .../people/:id/assets` · اسناد: `GET .../people/:id/asnad`
- ثبت سند: `POST .../people/:id/sanad`
- sync دوطرفه: `POST /sync/search` · `POST /sync/import`
- sync دوطرفه: `POST /sync/search` · `POST /sync/import` · `POST /sync/import-bulk` (بازه کد یا لیست کدها)
## تطبیق مشتری
@@ -56,15 +56,24 @@
2. `npx prisma migrate deploy` در `apps/api`
3. rebuild API/web و restart PM2
## وارد کردن مشتریان
افزودن اتصال به‌تنهایی مشتری را به CRM کپی نمی‌کند. از **تنظیمات → ته‌حساب → وارد کردن مشتریان**:
1. بازهٔ کد (مثلاً ۱ تا ۵۰۰) را بزنید و «وارد کردن کل بازه»، یا
2. با موبایل/نام/کد جستجو کنید و تکی/دسته‌ای وارد کنید.
مانده و اسناد بعد از لینک، در پروفایل شخص → تب **دارایی** دیده می‌شود.
## چک‌لیست تست دستی
- [ ] افزودن اتصال + تست health
- [ ] افزودن اتصال دوم با DB دیگر؛ انتخاب پیش‌فرض
- [ ] وارد کردن بازه کد مشتریان + دیدن در اشخاص
- [ ] تطبیق شخص با موبایل / لینک دستی / ثبت جدید
- [ ] نمایش مانده و جزئیات در تب دارایی
- [ ] لیست اسناد با بازه شمسی
- [ ] ثبت سند؛ retry با همان Factor_Code نباید سند تکراری بسازد
- [ ] وارد کردن مشتری از تنظیمات ته‌حساب
## خارج از فاز ۱