From 79733e6c96332326f7da0423ad950fb37b60ed08 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Tue, 3 Nov 2026 10:09:40 +0330 Subject: [PATCH] feat(tahesab): sync filtered customers and pick Jalali periods Document refresh can follow the selected group or person instead of every linked customer, and the sales report picks a named month or season. Co-authored-by: Cursor --- .../tahesab/asnad-report.service.ts | 125 ++++++++- apps/api/src/integrations/tahesab/dto.ts | 12 + .../tahesab/tahesab.controller.ts | 14 + apps/web/src/views/TahesabSalesReportView.vue | 243 ++++++++++++++++-- packages/shared/src/index.ts | 4 + 5 files changed, 366 insertions(+), 32 deletions(-) diff --git a/apps/api/src/integrations/tahesab/asnad-report.service.ts b/apps/api/src/integrations/tahesab/asnad-report.service.ts index 6bb81bb..1bc66a6 100644 --- a/apps/api/src/integrations/tahesab/asnad-report.service.ts +++ b/apps/api/src/integrations/tahesab/asnad-report.service.ts @@ -38,6 +38,12 @@ import { const ASNAD_COUNT_LAST = 5000; const SYNC_CONCURRENCY = 3; + +function latinDigits(value: string): string { + return value + .replace(/[۰-۹]/g, (ch) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(ch))) + .replace(/[٠-٩]/g, (ch) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(ch))); +} const ASNAD_KINDS = new Set(['sale', 'return', 'purchase', 'other']); function normalizeShamsiYmd(input: string): string { @@ -184,6 +190,7 @@ export class TahesabAsnadReportService { connectionId: string | undefined, from: string, to: string, + scope?: { personId?: string; groupIds?: string[] }, ): Promise { const fromNorm = normalizeShamsiYmd(from); const toNorm = normalizeShamsiYmd(to); @@ -194,6 +201,22 @@ export class TahesabAsnadReportService { organizationId, connectionId, ); + const scopedPersonIds = await this.resolveSyncPersonIds(organizationId, scope); + if (scopedPersonIds && scopedPersonIds.length === 0) { + throw new BadRequestException( + 'برای گروه یا شخص انتخاب‌شده مشتری‌ای پیدا نشد.', + ); + } + if (scopedPersonIds) { + const linked = await this.prisma.personAccountingLink.count({ + where: { connectionId: resolvedId, personId: { in: scopedPersonIds } }, + }); + if (linked === 0) { + throw new BadRequestException( + 'مشتریان این فیلتر به ته‌حساب وصل نیستند. به‌روزرسانی همه را بزنید یا اول اتصال مشتری را انجام دهید.', + ); + } + } const job = await this.prisma.accountingSyncJob.create({ data: { @@ -201,7 +224,13 @@ export class TahesabAsnadReportService { connectionId: resolvedId, kind: 'asnad-sync', status: 'pending', - request: { from: fromNorm, to: toNorm, connectionId: resolvedId }, + request: { + from: fromNorm, + to: toNorm, + connectionId: resolvedId, + personId: scope?.personId ?? null, + groupIds: scope?.groupIds ?? [], + }, result: { connectionId: resolvedId, from: fromNorm, @@ -214,9 +243,14 @@ export class TahesabAsnadReportService { }, }); - void this.runSync(job.id, organizationId, resolvedId, fromNorm, toNorm).catch( - () => undefined, - ); + void this.runSync( + job.id, + organizationId, + resolvedId, + fromNorm, + toNorm, + scopedPersonIds, + ).catch(() => undefined); return this.mapJob(job); } @@ -238,6 +272,7 @@ export class TahesabAsnadReportService { connectionId: string, from: string, to: string, + personIds: string[] | null, ) { const progress: SyncProgress = { connectionId, @@ -257,7 +292,10 @@ export class TahesabAsnadReportService { const { api } = await this.connections.getApi(organizationId, connectionId); const links = await this.prisma.personAccountingLink.findMany({ - where: { connectionId }, + where: { + connectionId, + ...(personIds ? { personId: { in: personIds } } : {}), + }, select: { id: true, personId: true, @@ -276,6 +314,7 @@ export class TahesabAsnadReportService { organizationId, connectionId, tarikh: { gte: from, lte: to }, + ...(personIds ? { personId: { in: personIds } } : {}), }, }); @@ -638,6 +677,82 @@ export class TahesabAsnadReportService { }; } + /** null یعنی همهٔ مشتریان متصل. آرایه یعنی فقط همین اشخاص. */ + private async resolveSyncPersonIds( + organizationId: string, + scope?: { personId?: string; groupIds?: string[] }, + ): Promise { + const groupIds = (scope?.groupIds ?? []).map((id) => id.trim()).filter(Boolean); + const personId = scope?.personId?.trim() || undefined; + if (!personId && groupIds.length === 0) return null; + let ids: string[] | null = null; + if (groupIds.length) { + const expanded = await this.expandGroupIds(organizationId, groupIds); + const rows = await this.prisma.personGroupLink.findMany({ + where: { + groupId: { in: expanded }, + person: { organizationId }, + }, + select: { personId: true }, + distinct: ['personId'], + }); + ids = rows.map((row) => row.personId); + } + if (personId) ids = ids ? ids.filter((id) => id === personId) : [personId]; + return ids ?? []; + } + + async searchCustomers( + organizationId: string, + connectionId: string | undefined, + q: string, + ) { + const resolvedId = await this.connections.resolveConnectionId( + organizationId, + connectionId, + ); + const query = q.trim(); + const tokens = query.split(/\s+/).filter(Boolean); + const code = Number(latinDigits(query)); + const or: Prisma.PersonAccountingLinkWhereInput[] = []; + if (tokens.length) { + or.push({ + person: { + organizationId, + AND: tokens.map((token) => ({ + OR: [ + { firstName: { contains: token } }, + { lastName: { contains: token } }, + ], + })), + }, + }); + } + if (Number.isInteger(code) && code > 0 && latinDigits(query) === String(code)) { + or.push({ moshtariCode: code, person: { organizationId } }); + } + const rows = await this.prisma.personAccountingLink.findMany({ + where: { + connectionId: resolvedId, + ...(or.length ? { OR: or } : { person: { organizationId } }), + }, + take: 30, + orderBy: [{ person: { lastName: 'asc' } }, { person: { firstName: 'asc' } }], + select: { + personId: true, + moshtariCode: true, + person: { select: { firstName: true, lastName: true } }, + }, + }); + return { + items: rows.map((row) => ({ + personId: row.personId, + moshtariCode: row.moshtariCode, + name: `${row.person.firstName} ${row.person.lastName}`.trim(), + })), + }; + } + /** گروه اصلی، زیرگروه‌ها را هم داخل فیلتر می‌آورد. */ private async expandGroupIds(organizationId: string, ids: string[]): Promise { const rows = await this.prisma.customerGroup.findMany({ diff --git a/apps/api/src/integrations/tahesab/dto.ts b/apps/api/src/integrations/tahesab/dto.ts index 11aff5c..17214b7 100644 --- a/apps/api/src/integrations/tahesab/dto.ts +++ b/apps/api/src/integrations/tahesab/dto.ts @@ -447,6 +447,18 @@ export class SyncAsnadReportDto { @MinLength(8) @MaxLength(12) to!: string; + + /** اگر باشد، فقط سندهای همین شخص گرفته می‌شود */ + @IsOptional() + @IsString() + personId?: string; + + /** اگر باشد، فقط مشتریان این گروه‌ها (و زیرگروه‌ها) گرفته می‌شوند */ + @IsOptional() + @IsArray() + @IsString({ each: true }) + @ArrayMaxSize(200) + groupIds?: string[]; } export class UpdateTahesabInvoiceSettingsDto { diff --git a/apps/api/src/integrations/tahesab/tahesab.controller.ts b/apps/api/src/integrations/tahesab/tahesab.controller.ts index 4fab981..cca1fd0 100644 --- a/apps/api/src/integrations/tahesab/tahesab.controller.ts +++ b/apps/api/src/integrations/tahesab/tahesab.controller.ts @@ -309,6 +309,20 @@ export class TahesabController { dto.connectionId, dto.from, dto.to, + { personId: dto.personId, groupIds: dto.groupIds }, + ); + } + + @Get('reports/asnad/customers') + searchAsnadCustomers( + @CurrentUser() user: { organizationId: string }, + @Query('connectionId') connectionId?: string, + @Query('q') q?: string, + ) { + return this.asnadReport.searchCustomers( + user.organizationId, + connectionId, + q ?? '', ); } diff --git a/apps/web/src/views/TahesabSalesReportView.vue b/apps/web/src/views/TahesabSalesReportView.vue index 08aa928..7afe745 100644 --- a/apps/web/src/views/TahesabSalesReportView.vue +++ b/apps/web/src/views/TahesabSalesReportView.vue @@ -13,6 +13,7 @@ import { Form as AForm, FormItem as AFormItem, List as AList, + Modal as AModal, Row as ARow, Segmented as ASegmented, Select as ASelect, @@ -41,7 +42,6 @@ import { TAHESAB_ASNAD_KINDS, TAHESAB_JENS_FELEZ_LABELS, type CustomerGroupDto, - type PersonDto, type TahesabAsnadKind, type TahesabAsnadLineDto, type TahesabAsnadReportPageDto, @@ -54,6 +54,8 @@ import { api } from '@/lib/api'; import { dateToJalali, formatLocalDateInput, + jalaliMonthLength, + jalaliMonthName, jalaliToDate, parseLocalInput, toFaDigits, @@ -78,6 +80,20 @@ const personId = ref(); const personOptions = ref>([]); const kinds = ref([]); const period = ref<'custom' | 'week' | 'month' | 'season'>('month'); +const periodModalOpen = ref(false); +const periodModalKind = ref<'month' | 'season'>('month'); +const pickerYear = ref(dateToJalali(new Date()).jy); +const syncModalOpen = ref(false); +const SEASONS = [ + { key: 'spring', label: 'بهار', start: 1, end: 3 }, + { key: 'summer', label: 'تابستان', start: 4, end: 6 }, + { key: 'autumn', label: 'پاییز', start: 7, end: 9 }, + { key: 'winter', label: 'زمستان', start: 10, end: 12 }, +] as const; +const pickerYears = computed(() => { + const current = dateToJalali(new Date()).jy; + return Array.from({ length: 8 }, (_, index) => current - index); +}); const overview = ref(null); const auth = useAuthStore(); const canGroups = computed(() => canManageSettings(auth.user?.role)); @@ -203,6 +219,66 @@ function markCustomPeriod() { period.value = 'custom'; } +function openPeriodModal(kind: 'month' | 'season') { + const parsed = parseLocalInput(fromDate.value); + pickerYear.value = parsed ? dateToJalali(parsed).jy : dateToJalali(new Date()).jy; + periodModalKind.value = kind; + periodModalOpen.value = true; +} + +function onPeriodPick(value: string | number) { + const key = String(value) as 'week' | 'month' | 'season' | 'custom'; + if (key === 'month' || key === 'season') { + openPeriodModal(key); + return; + } + applyNamedPeriod(key, true); +} + +function onPeriodReclick(event: MouseEvent) { + const item = (event.target as HTMLElement | null)?.closest('.ant-segmented-item'); + const text = item?.querySelector('.ant-segmented-item-label')?.textContent?.replace(/\s/g, '') ?? ''; + if (text === 'ماه') openPeriodModal('month'); + if (text === 'فصل') openPeriodModal('season'); +} + +function applyJalaliSpan( + year: number, + startMonth: number, + endMonth: number, + key: 'month' | 'season', +) { + const now = new Date(); + const today = dateToJalali(now); + const start = jalaliToDate(year, startMonth, 1); + let end = jalaliToDate(year, endMonth, jalaliMonthLength(year, endMonth)); + if ( + today.jy === year && + today.jm >= startMonth && + today.jm <= endMonth && + end.getTime() > now.getTime() + ) { + end = now; + } + applyingPeriod = true; + period.value = key; + fromDate.value = formatLocalDateInput(start); + toDate.value = formatLocalDateInput(end); + periodModalOpen.value = false; + void nextTick(() => { + applyingPeriod = false; + }); + applyFilters(); +} + +function pickMonth(month: number) { + applyJalaliSpan(pickerYear.value, month, month, 'month'); +} + +function pickSeason(start: number, end: number) { + applyJalaliSpan(pickerYear.value, start, end, 'season'); +} + function groupOptionLabel(g: CustomerGroupDto) { return g.parentName ? `${g.parentName} / ${g.name}` : g.name; } @@ -375,25 +451,51 @@ async function loadConnections() { if (!connectionId.value && def) connectionId.value = def.id; } -async function searchPeople(q: string) { - if (!q || q.trim().length < 2) { +let personSearchTimer: ReturnType | null = null; +let customersEndpointMissing = false; + +async function loadTahesabCustomers(q: string) { + if (!connectionId.value) { personOptions.value = []; return; } try { - const { data } = await api.get<{ items: PersonDto[] }>('/people', { - params: { q: q.trim(), limit: 20 }, + const { data } = await api.get<{ + items: Array<{ personId: string; name: string; moshtariCode: number }>; + }>('/integrations/tahesab/reports/asnad/customers', { + params: { connectionId: connectionId.value, q: q.trim() }, }); const items = Array.isArray(data?.items) ? data.items : []; - personOptions.value = items.map((p) => ({ - value: p.id, - label: [p.firstName, p.lastName].filter(Boolean).join(' ') || p.id, + const next = items.map((item) => ({ + value: item.personId, + label: `${item.name || 'بدون نام'} (${toFaDigits(String(item.moshtariCode))})`, })); - } catch { - personOptions.value = []; + const selected = personOptions.value.find((item) => item.value === personId.value); + if (selected && !next.some((item) => item.value === selected.value)) next.unshift(selected); + personOptions.value = next; + } catch (e: unknown) { + const status = axios.isAxiosError(e) ? e.response?.status : undefined; + if (status === 404 && !customersEndpointMissing) { + customersEndpointMissing = true; + message.warning('جستجوی مشتری ته‌حساب بعد از به‌روزرسانی سرور فعال می‌شود.'); + } + personOptions.value = personId.value + ? personOptions.value.filter((item) => item.value === personId.value) + : []; } } +function searchTahesabCustomers(q: string) { + if (personSearchTimer) clearTimeout(personSearchTimer); + personSearchTimer = setTimeout(() => { + void loadTahesabCustomers(q); + }, 250); +} + +function onPersonDropdown(open: boolean) { + if (open) void loadTahesabCustomers(''); +} + function apiErrMessage(e: unknown, fallback: string): string { if (!axios.isAxiosError(e)) return fallback; const raw = e.response?.data as { message?: string | string[] } | undefined; @@ -493,7 +595,20 @@ async function pollJob(jobId: string) { }, 1500); } -async function startSync() { +const hasSyncScope = computed( + () => Boolean(personId.value) || groupIds.value.length > 0, +); + +function openSyncChoice() { + if (!filterParams.value) { + error.value = 'ابتدا بازه تاریخ را مشخص کنید، بعد به‌روزرسانی را بزنید.'; + message.warning(error.value); + return; + } + syncModalOpen.value = true; +} + +async function startSync(scoped: boolean) { const params = filterParams.value; if (!params) { error.value = 'ابتدا بازه تاریخ را مشخص کنید، بعد به‌روزرسانی را بزنید.'; @@ -502,7 +617,10 @@ async function startSync() { } syncing.value = true; error.value = ''; - syncHint.value = 'شروع همگام‌سازی…'; + syncHint.value = scoped + ? 'شروع به‌روزرسانی مشتریان همین فیلتر…' + : 'شروع به‌روزرسانی همه مشتریان متصل…'; + syncModalOpen.value = false; try { const { data } = await api.post( '/integrations/tahesab/reports/asnad/sync', @@ -510,6 +628,8 @@ async function startSync() { connectionId: params.connectionId, from: params.from, to: params.to, + ...(scoped && personId.value ? { personId: personId.value } : {}), + ...(scoped && groupIds.value.length ? { groupIds: [...groupIds.value] } : {}), }, { timeout: 60_000 }, ); @@ -549,7 +669,10 @@ watch([connectionId, groupIds, kinds, jensFelez, personId], () => { page.value = 1; }); -onUnmounted(() => stopPoll()); +onUnmounted(() => { + stopPoll(); + if (personSearchTimer) clearTimeout(personSearchTimer); +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 82826c4..c4cd35c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4199,6 +4199,10 @@ export interface TahesabAsnadSyncRequest { from: string; /** شمسی YYYY-MM-DD */ to: string; + /** محدود کردن همگام‌سازی به یک شخص */ + personId?: string; + /** محدود کردن همگام‌سازی به گروه‌ها و زیرگروه‌ها */ + groupIds?: string[]; } export interface TahesabAsnadSyncJobDto {