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 { import {
IsArray,
IsBoolean, IsBoolean,
IsIn, IsIn,
IsInt, IsInt,
@@ -121,6 +122,7 @@ export class ImportFromTahesabDto {
@IsString() @IsString()
connectionId!: string; connectionId!: string;
@Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(1)
moshtariCode!: number; moshtariCode!: number;
@@ -131,6 +133,29 @@ export class ImportFromTahesabDto {
personId?: string; 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 { export class SearchTahesabDto {
@IsString() @IsString()
connectionId!: string; connectionId!: string;
@@ -144,8 +169,22 @@ export class SearchTahesabDto {
name?: string; name?: string;
@IsOptional() @IsOptional()
@Type(() => Number)
@IsInt() @IsInt()
moshtariCode?: number; moshtariCode?: number;
/** لیست بازه‌ای با DoListMoshtari-ByCode */
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
fromCode?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
toCode?: number;
} }
export class ListAsnadQueryDto { export class ListAsnadQueryDto {
@@ -9,6 +9,7 @@ import { PrismaService } from '../../prisma/prisma.service';
import { TahesabConnectionsService } from './connections.service'; import { TahesabConnectionsService } from './connections.service';
import { import {
CreateInTahesabDto, CreateInTahesabDto,
ImportBulkFromTahesabDto,
ImportFromTahesabDto, ImportFromTahesabDto,
ManualLinkDto, ManualLinkDto,
MatchPersonDto, MatchPersonDto,
@@ -317,7 +318,15 @@ export class TahesabPersonLinkService {
dto.connectionId, dto.connectionId,
); );
let raw: unknown = []; 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); raw = await api.doListMoshtariByCode(dto.moshtariCode, dto.moshtariCode);
} else if (dto.phone) { } else if (dto.phone) {
const m = normalizeIranMobile(dto.phone) ?? dto.phone; const m = normalizeIranMobile(dto.phone) ?? dto.phone;
@@ -325,16 +334,49 @@ export class TahesabPersonLinkService {
} else if (dto.name?.trim()) { } else if (dto.name?.trim()) {
raw = await api.doListMoshtariByName(dto.name.trim()); raw = await api.doListMoshtariByName(dto.name.trim());
} else { } 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), moshtariCode: pickCode(row),
name: pickName(row), name: pickName(row),
tel: pickTel(row), tel: pickTel(row),
raw: 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({ await this.prisma.accountingSyncJob.create({
data: { data: {
organizationId, organizationId,
@@ -435,4 +477,86 @@ export class TahesabPersonLinkService {
return { personId, link: this.linkDto(link) }; 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 { import {
CreateInTahesabDto, CreateInTahesabDto,
CreateTahesabConnectionDto, CreateTahesabConnectionDto,
ImportBulkFromTahesabDto,
ImportFromTahesabDto, ImportFromTahesabDto,
ManualLinkDto, ManualLinkDto,
MatchPersonDto, MatchPersonDto,
@@ -200,4 +201,13 @@ export class TahesabController {
) { ) {
return this.links.importFromTahesab(user.organizationId, dto); 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);
}
} }
+201 -15
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios'; import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { import type {
@@ -30,10 +31,25 @@ const sync = reactive({
phone: '', phone: '',
name: '', name: '',
moshtariCode: '' as string | number, moshtariCode: '' as string | number,
fromCode: '1',
toCode: '500',
}); });
const syncResults = ref< const syncBusy = ref(false);
Array<{ moshtariCode: number | null; name: string; tel: string }> 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() { function resetForm() {
editingId.value = null; editingId.value = null;
@@ -162,33 +178,55 @@ async function removeConn(id: string) {
} }
} }
async function searchTahesab() { async function searchTahesab(mode: 'filter' | 'range' = 'filter') {
error.value = ''; error.value = '';
ok.value = ''; ok.value = '';
syncResults.value = []; syncResults.value = [];
if (!sync.connectionId) {
error.value = 'ابتدا اتصال را انتخاب کنید.';
return;
}
try { try {
syncBusy.value = true;
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
connectionId: sync.connectionId, 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); body.moshtariCode = Number(sync.moshtariCode);
} else if (sync.phone.trim()) { } else if (sync.phone.trim()) {
body.phone = sync.phone.trim(); body.phone = sync.phone.trim();
} else if (sync.name.trim()) { } else if (sync.name.trim()) {
body.name = sync.name.trim(); body.name = sync.name.trim();
} else { } else {
error.value = 'موبایل، نام یا کد مشتری را وارد کنید.'; error.value = 'موبایل، نام، کد مشتری یا بازهٔ کد را وارد کنید.';
return; return;
} }
const { data } = await api.post<{ const { data } = await api.post<{ results: SyncResult[] }>(
results: Array<{ moshtariCode: number | null; name: string; tel: string }>; '/integrations/tahesab/sync/search',
}>('/integrations/tahesab/sync/search', body); body,
);
syncResults.value = data.results ?? []; 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) { } catch (e) {
error.value = axios.isAxiosError(e) error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'جستجو ناموفق') ? String((e.response?.data as { message?: string })?.message ?? 'جستجو ناموفق')
: 'جستجو ناموفق'; : 'جستجو ناموفق';
} finally {
syncBusy.value = false;
} }
} }
@@ -196,15 +234,80 @@ async function importOne(moshtariCode: number) {
error.value = ''; error.value = '';
ok.value = ''; ok.value = '';
try { try {
syncBusy.value = true;
const { data } = await api.post<{ personId: string }>( const { data } = await api.post<{ personId: string }>(
'/integrations/tahesab/sync/import', '/integrations/tahesab/sync/import',
{ connectionId: sync.connectionId, moshtariCode }, { 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) { } catch (e) {
error.value = axios.isAxiosError(e) error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'وارد کردن ناموفق') ? 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> </form>
<section class="card sync"> <section class="card sync">
<h2>جستجو / وارد کردن از ته‌حساب</h2> <h2>وارد کردن مشتریان از ته‌حساب</h2>
<p class="muted"> <p class="muted">
مشتری را در یک اتصال جستجو کنید و به‌عنوان شخص جدید وارد CRM کنید یا به شخص موجود لینک دهید. فقط «افزودن اتصال» مشتری‌ها را نمی‌آورد. باید با بازهٔ کد یا جستجو آن‌ها را از ته‌حساب
<strong>وارد</strong> کنید تا در «اشخاص و شرکت‌ها» دیده شوند. جزئیات مانده/اسناد بعد از لینک، در پروفایل شخص → تب دارایی است.
</p> </p>
<div class="field"> <div class="field">
<label>اتصال</label> <label>اتصال</label>
@@ -322,28 +426,82 @@ onMounted(load);
</option> </option>
</select> </select>
</div> </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"> <div class="sync-grid">
<input v-model="sync.phone" dir="ltr" placeholder="موبایل" /> <input v-model="sync.phone" dir="ltr" placeholder="موبایل" />
<input v-model="sync.name" placeholder="نام" /> <input v-model="sync.name" placeholder="نام" />
<input v-model="sync.moshtariCode" type="number" dir="ltr" placeholder="کد مشتری" /> <input v-model="sync.moshtariCode" type="number" dir="ltr" placeholder="کد مشتری" />
</div> </div>
<button type="button" class="btn ghost" :disabled="!sync.connectionId" @click="searchTahesab"> <div class="sync-actions">
<button
type="button"
class="btn ghost"
:disabled="!sync.connectionId || syncBusy"
@click="searchTahesab('filter')"
>
جستجو جستجو
</button> </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"> <ul v-if="syncResults.length" class="sync-results">
<li v-for="r in syncResults" :key="String(r.moshtariCode)"> <li v-for="r in syncResults" :key="String(r.moshtariCode)">
<span> <span>
{{ r.moshtariCode }} — {{ r.name || 'بدون نام' }} {{ r.moshtariCode }} — {{ r.name || 'بدون نام' }}
<span class="muted">{{ r.tel }}</span> <span class="muted">{{ r.tel }}</span>
<span v-if="r.alreadyLinked" class="linked">لینک‌شده{{ r.personName ? ` · ${r.personName}` : '' }}</span>
</span> </span>
<button <button
v-if="!r.alreadyLinked"
type="button" type="button"
class="btn" class="btn"
:disabled="!r.moshtariCode" :disabled="!r.moshtariCode || syncBusy"
@click="importOne(r.moshtariCode!)" @click="importOne(r.moshtariCode!)"
> >
وارد کردن وارد کردن
</button> </button>
<RouterLink
v-else-if="r.personId"
class="btn ghost"
:to="{ name: 'person-detail', params: { id: r.personId } }"
>
مشاهده شخص
</RouterLink>
</li> </li>
</ul> </ul>
</section> </section>
@@ -534,6 +692,34 @@ select {
gap: 0.5rem; 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 { .sync-results {
list-style: none; list-style: none;
margin: 0; margin: 0;
+11 -2
View File
@@ -29,7 +29,7 @@
- تطبیق: `POST .../people/:id/match` · ثبت: `POST .../create-in-tahesab` - تطبیق: `POST .../people/:id/match` · ثبت: `POST .../create-in-tahesab`
- دارایی: `GET .../people/:id/assets` · اسناد: `GET .../people/:id/asnad` - دارایی: `GET .../people/:id/assets` · اسناد: `GET .../people/:id/asnad`
- ثبت سند: `POST .../people/:id/sanad` - ثبت سند: `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` 2. `npx prisma migrate deploy` در `apps/api`
3. rebuild API/web و restart PM2 3. rebuild API/web و restart PM2
## وارد کردن مشتریان
افزودن اتصال به‌تنهایی مشتری را به CRM کپی نمی‌کند. از **تنظیمات → ته‌حساب → وارد کردن مشتریان**:
1. بازهٔ کد (مثلاً ۱ تا ۵۰۰) را بزنید و «وارد کردن کل بازه»، یا
2. با موبایل/نام/کد جستجو کنید و تکی/دسته‌ای وارد کنید.
مانده و اسناد بعد از لینک، در پروفایل شخص → تب **دارایی** دیده می‌شود.
## چک‌لیست تست دستی ## چک‌لیست تست دستی
- [ ] افزودن اتصال + تست health - [ ] افزودن اتصال + تست health
- [ ] افزودن اتصال دوم با DB دیگر؛ انتخاب پیش‌فرض - [ ] افزودن اتصال دوم با DB دیگر؛ انتخاب پیش‌فرض
- [ ] وارد کردن بازه کد مشتریان + دیدن در اشخاص
- [ ] تطبیق شخص با موبایل / لینک دستی / ثبت جدید - [ ] تطبیق شخص با موبایل / لینک دستی / ثبت جدید
- [ ] نمایش مانده و جزئیات در تب دارایی - [ ] نمایش مانده و جزئیات در تب دارایی
- [ ] لیست اسناد با بازه شمسی - [ ] لیست اسناد با بازه شمسی
- [ ] ثبت سند؛ retry با همان Factor_Code نباید سند تکراری بسازد - [ ] ثبت سند؛ retry با همان Factor_Code نباید سند تکراری بسازد
- [ ] وارد کردن مشتری از تنظیمات ته‌حساب
## خارج از فاز ۱ ## خارج از فاز ۱