diff --git a/apps/api/prisma/migrations/20261022180000_accounting_asnad_line/migration.sql b/apps/api/prisma/migrations/20261022180000_accounting_asnad_line/migration.sql new file mode 100644 index 0000000..344f2c8 --- /dev/null +++ b/apps/api/prisma/migrations/20261022180000_accounting_asnad_line/migration.sql @@ -0,0 +1,62 @@ +-- CreateTable +CREATE TABLE "AccountingAsnadLine" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "connectionId" TEXT NOT NULL, + "personId" TEXT, + "linkId" TEXT, + "moshtariCode" INTEGER NOT NULL, + "lineKey" TEXT NOT NULL, + "tahesabId" INTEGER, + "factorCode" TEXT, + "shFactor" INTEGER, + "radif" INTEGER, + "tarikh" TEXT NOT NULL, + "noRaw" TEXT NOT NULL DEFAULT '', + "kind" TEXT NOT NULL DEFAULT 'other', + "jensFelez" INTEGER, + "noKar" TEXT, + "vazn" DOUBLE PRECISION, + "tabdilVazn" DOUBLE PRECISION, + "mali" DOUBLE PRECISION, + "count" DOUBLE PRECISION, + "ayar" DOUBLE PRECISION, + "mazaneh" DOUBLE PRECISION, + "fi" DOUBLE PRECISION, + "sharh1" TEXT, + "syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AccountingAsnadLine_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AccountingAsnadLine_connectionId_lineKey_key" ON "AccountingAsnadLine"("connectionId", "lineKey"); + +-- CreateIndex +CREATE INDEX "AccountingAsnadLine_organizationId_connectionId_tarikh_idx" ON "AccountingAsnadLine"("organizationId", "connectionId", "tarikh"); + +-- CreateIndex +CREATE INDEX "AccountingAsnadLine_organizationId_kind_idx" ON "AccountingAsnadLine"("organizationId", "kind"); + +-- CreateIndex +CREATE INDEX "AccountingAsnadLine_personId_idx" ON "AccountingAsnadLine"("personId"); + +-- CreateIndex +CREATE INDEX "AccountingAsnadLine_linkId_idx" ON "AccountingAsnadLine"("linkId"); + +-- CreateIndex +CREATE INDEX "AccountingAsnadLine_moshtariCode_idx" ON "AccountingAsnadLine"("moshtariCode"); + +-- AddForeignKey +ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "AccountingConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_linkId_fkey" FOREIGN KEY ("linkId") REFERENCES "PersonAccountingLink"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 7afcd02..4471b18 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -196,6 +196,7 @@ model Organization { noteAiSuggestions NoteAiSuggestion[] accountingConnections AccountingConnection[] accountingSyncJobs AccountingSyncJob[] + accountingAsnadLines AccountingAsnadLine[] warehouses Warehouse[] stockMovements StockMovement[] partyOpenings PartyOpeningBalance[] @@ -614,6 +615,7 @@ model Person { memoryItems MemoryItem[] accountingLinks PersonAccountingLink[] accountingDocuments AccountingDocumentLog[] + asnadLines AccountingAsnadLine[] openingBalances PartyOpeningBalance[] cheques Cheque[] trackingLinks TrackingLink[] @@ -1747,6 +1749,7 @@ model AccountingConnection { productLinks ProductAccountingLink[] documentLogs AccountingDocumentLog[] balanceCaches AccountingBalanceCache[] + asnadLines AccountingAsnadLine[] @@unique([organizationId, baseUrl, dbName]) @@index([organizationId, enabled]) @@ -1766,6 +1769,7 @@ model PersonAccountingLink { linkedAt DateTime @default(now()) lastBalanceSyncedAt DateTime? balanceCache AccountingBalanceCache? + asnadLines AccountingAsnadLine[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1844,6 +1848,51 @@ model AccountingBalanceCache { @@index([connectionId]) } +/// اسنپ‌شات ردیف‌های DoListAsnad برای گزارش فروش گروهی +model AccountingAsnadLine { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + connectionId String + connection AccountingConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) + personId String? + person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) + linkId String? + link PersonAccountingLink? @relation(fields: [linkId], references: [id], onDelete: SetNull) + moshtariCode Int + /// کلید پایدار برای upsert: ID ته‌حساب یا f:factor|r:radif|t:tarikh|n:no + lineKey String + tahesabId Int? + factorCode String? + shFactor Int? + radif Int? + /// شمسی YYYY-MM-DD + tarikh String + noRaw String @default("") + /// sale | return | purchase | other + kind String @default("other") + jensFelez Int? + noKar String? + vazn Float? + tabdilVazn Float? + mali Float? + count Float? + ayar Float? + mazaneh Float? + fi Float? + sharh1 String? + syncedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([connectionId, lineKey]) + @@index([organizationId, connectionId, tarikh]) + @@index([organizationId, kind]) + @@index([personId]) + @@index([linkId]) + @@index([moshtariCode]) +} + model AccountingSyncJob { id String @id @default(cuid()) organizationId String diff --git a/apps/api/src/integrations/tahesab/asnad-report.service.ts b/apps/api/src/integrations/tahesab/asnad-report.service.ts new file mode 100644 index 0000000..fd6ece4 --- /dev/null +++ b/apps/api/src/integrations/tahesab/asnad-report.service.ts @@ -0,0 +1,617 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import type { + CustomerGroupSummaryDto, + TahesabAsnadKind, + TahesabAsnadLineDto, + TahesabAsnadReportPageDto, + TahesabAsnadSummaryDto, + TahesabAsnadSyncJobDto, +} from '@mento/shared'; +import { + TAHESAB_ASNAD_KIND_LABELS, + TAHESAB_JENS_FELEZ_LABELS, +} from '@mento/shared'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TahesabConnectionsService } from './connections.service'; + +const ASNAD_COUNT_LAST = 200; +const SYNC_CONCURRENCY = 3; +const ASNAD_KINDS = new Set(['sale', 'return', 'purchase', 'other']); + +function normalizeShamsiYmd(input: string): string { + const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(input.trim()); + if (!m) { + throw new BadRequestException( + 'تاریخ از/تا باید شمسی با قالب YYYY-MM-DD باشد.', + ); + } + return `${m[1]}-${m[2]!.padStart(2, '0')}-${m[3]!.padStart(2, '0')}`; +} + +function asAsnadList(raw: unknown): Array> { + if (!raw) return []; + if (Array.isArray(raw)) return raw as Array>; + if (typeof raw === 'object') { + const obj = raw as Record; + for (const key of ['DoListAsnad', 'Asnad', 'asnad', 'Items', 'items', 'Data']) { + const nested = obj[key]; + if (Array.isArray(nested)) { + return nested.filter((v) => v && typeof v === 'object') as Array< + Record + >; + } + } + return Object.values(obj).filter( + (v) => v && typeof v === 'object' && !Array.isArray(v), + ) as Array>; + } + return []; +} + +/** طبقه‌بندی تقریبی — قابل اصلاح بعد از فیدبک کارفرما */ +export function classifyAsnadNo(noRaw: unknown): TahesabAsnadKind { + const s = String(noRaw ?? '') + .replace(/\s+/g, ' ') + .trim(); + if (/مرجوع|برگشت/.test(s)) return 'return'; + if (/فروش/.test(s)) return 'sale'; + if (/خرید/.test(s)) return 'purchase'; + return 'other'; +} + +function pickNum(v: unknown): number | null { + if (v == null || v === '' || v === 'null') return null; + const n = typeof v === 'number' ? v : Number(String(v).replace(/,/g, '')); + return Number.isFinite(n) ? n : null; +} + +function pickInt(v: unknown): number | null { + const n = pickNum(v); + return n == null ? null : Math.trunc(n); +} + +function pickStr(v: unknown): string | null { + if (v == null) return null; + const s = String(v).trim(); + if (!s || s === 'null') return null; + return s; +} + +/** تاریخ ته‌حساب اغلب 1405/05/04 است → YYYY-MM-DD */ +function normalizeTarikh(raw: unknown, fallbackFrom: string, fallbackTo: string): string { + const s = pickStr(raw); + if (!s) return fallbackFrom; + const slash = /^(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})/.exec(s); + if (slash) { + return `${slash[1]}-${slash[2]!.padStart(2, '0')}-${slash[3]!.padStart(2, '0')}`; + } + return fallbackFrom <= fallbackTo ? fallbackFrom : fallbackTo; +} + +function buildLineKey(row: Record): string { + const id = pickInt(row.ID ?? row.Id ?? row.id); + if (id != null) return `id:${id}`; + const factor = pickStr(row.Factor_Code) ?? ''; + const radif = pickInt(row.Radif) ?? 0; + const tarikh = pickStr(row.Tarikh) ?? ''; + const no = pickStr(row.NO) ?? ''; + const sh = pickInt(row.Sh_Factor ?? row.Sh_factor) ?? 0; + return `f:${factor}|r:${radif}|t:${tarikh}|n:${no}|s:${sh}`; +} + +function personDisplayName(p: { + firstName: string | null; + lastName: string | null; +} | null): string | null { + if (!p) return null; + const name = [p.firstName, p.lastName].filter(Boolean).join(' ').trim(); + return name || null; +} + +type SyncProgress = { + connectionId: string; + from: string; + to: string; + processed: number; + total: number; + linesUpserted: number; + truncatedPersonIds: string[]; +}; + +@Injectable() +export class TahesabAsnadReportService { + constructor( + private readonly prisma: PrismaService, + private readonly connections: TahesabConnectionsService, + ) {} + + private mapJob( + job: { + id: string; + status: string; + connectionId: string | null; + request: unknown; + result: unknown; + errorMessage: string | null; + createdAt: Date; + updatedAt: Date; + }, + ): TahesabAsnadSyncJobDto { + const req = (job.request ?? {}) as Record; + const res = (job.result ?? {}) as Partial; + return { + id: job.id, + status: job.status, + connectionId: job.connectionId, + from: String(req.from ?? res.from ?? ''), + to: String(req.to ?? res.to ?? ''), + processed: Number(res.processed ?? 0), + total: Number(res.total ?? 0), + linesUpserted: Number(res.linesUpserted ?? 0), + truncatedPersonIds: Array.isArray(res.truncatedPersonIds) + ? res.truncatedPersonIds.map(String) + : [], + errorMessage: job.errorMessage, + createdAt: job.createdAt.toISOString(), + updatedAt: job.updatedAt.toISOString(), + }; + } + + async startSync( + organizationId: string, + connectionId: string | undefined, + from: string, + to: string, + ): Promise { + const fromNorm = normalizeShamsiYmd(from); + const toNorm = normalizeShamsiYmd(to); + if (fromNorm > toNorm) { + throw new BadRequestException('تاریخ «از» نباید بعد از «تا» باشد.'); + } + const resolvedId = await this.connections.resolveConnectionId( + organizationId, + connectionId, + ); + + const job = await this.prisma.accountingSyncJob.create({ + data: { + organizationId, + connectionId: resolvedId, + kind: 'asnad-sync', + status: 'pending', + request: { from: fromNorm, to: toNorm, connectionId: resolvedId }, + result: { + connectionId: resolvedId, + from: fromNorm, + to: toNorm, + processed: 0, + total: 0, + linesUpserted: 0, + truncatedPersonIds: [], + }, + }, + }); + + void this.runSync(job.id, organizationId, resolvedId, fromNorm, toNorm).catch( + () => undefined, + ); + + return this.mapJob(job); + } + + async getSyncJob( + organizationId: string, + jobId: string, + ): Promise { + const job = await this.prisma.accountingSyncJob.findFirst({ + where: { id: jobId, organizationId, kind: 'asnad-sync' }, + }); + if (!job) throw new NotFoundException('کار همگام‌سازی یافت نشد.'); + return this.mapJob(job); + } + + private async runSync( + jobId: string, + organizationId: string, + connectionId: string, + from: string, + to: string, + ) { + const progress: SyncProgress = { + connectionId, + from, + to, + processed: 0, + total: 0, + linesUpserted: 0, + truncatedPersonIds: [], + }; + + try { + await this.prisma.accountingSyncJob.update({ + where: { id: jobId }, + data: { status: 'running', result: progress }, + }); + + const { api } = await this.connections.getApi(organizationId, connectionId); + const links = await this.prisma.personAccountingLink.findMany({ + where: { connectionId }, + select: { + id: true, + personId: true, + moshtariCode: true, + }, + orderBy: { moshtariCode: 'asc' }, + }); + progress.total = links.length; + await this.prisma.accountingSyncJob.update({ + where: { id: jobId }, + data: { result: { ...progress } }, + }); + + await this.prisma.accountingAsnadLine.deleteMany({ + where: { + organizationId, + connectionId, + tarikh: { gte: from, lte: to }, + }, + }); + + let cursor = 0; + const workers = Array.from( + { length: Math.min(SYNC_CONCURRENCY, Math.max(links.length, 1)) }, + async () => { + while (true) { + const i = cursor; + cursor += 1; + if (i >= links.length) return; + const link = links[i]!; + try { + const raw = await api.doListAsnad( + ASNAD_COUNT_LAST, + link.moshtariCode, + from, + to, + ); + const items = asAsnadList(raw); + if (items.length >= ASNAD_COUNT_LAST) { + progress.truncatedPersonIds.push(link.personId); + } + const rows: Prisma.AccountingAsnadLineCreateManyInput[] = []; + for (const item of items) { + const tarikh = normalizeTarikh(item.Tarikh, from, to); + if (tarikh < from || tarikh > to) continue; + rows.push({ + organizationId, + connectionId, + personId: link.personId, + linkId: link.id, + moshtariCode: link.moshtariCode, + lineKey: buildLineKey(item), + tahesabId: pickInt(item.ID ?? item.Id ?? item.id), + factorCode: pickStr(item.Factor_Code), + shFactor: pickInt(item.Sh_Factor ?? item.Sh_factor), + radif: pickInt(item.Radif), + tarikh, + noRaw: pickStr(item.NO) ?? '', + kind: classifyAsnadNo(item.NO), + jensFelez: pickInt(item.Jens_Felez), + noKar: pickStr(item.No_Kar), + vazn: pickNum(item.Vazn), + tabdilVazn: pickNum(item.TabdilVazn), + mali: pickNum(item.Mali), + count: pickNum(item.Count), + ayar: pickNum(item.Ayar), + mazaneh: pickNum(item.Mazaneh), + fi: pickNum(item.Fi), + sharh1: pickStr(item.Sharh1), + syncedAt: new Date(), + }); + } + if (rows.length) { + // createMany skipDuplicates در صورت برخورد نادر lineKey + const result = await this.prisma.accountingAsnadLine.createMany({ + data: rows, + skipDuplicates: true, + }); + progress.linesUpserted += result.count; + } + } catch { + // یک مشتری خراب کل sync را متوقف نکند + } + progress.processed += 1; + if (progress.processed % 5 === 0 || progress.processed === progress.total) { + await this.prisma.accountingSyncJob.update({ + where: { id: jobId }, + data: { result: { ...progress } }, + }); + } + } + }, + ); + await Promise.all(workers); + + await this.prisma.accountingSyncJob.update({ + where: { id: jobId }, + data: { status: 'done', result: { ...progress } }, + }); + } catch (err) { + const message = + err instanceof Error ? err.message : 'همگام‌سازی اسناد ناموفق بود.'; + await this.prisma.accountingSyncJob.update({ + where: { id: jobId }, + data: { + status: 'failed', + errorMessage: message, + result: { ...progress }, + }, + }); + } + } + + private async buildWhere(params: { + organizationId: string; + connectionId?: string; + from: string; + to: string; + groupIds?: string[]; + personId?: string; + kinds?: string[]; + jensFelez?: number; + }): Promise { + const from = normalizeShamsiYmd(params.from); + const to = normalizeShamsiYmd(params.to); + const connectionId = await this.connections.resolveConnectionId( + params.organizationId, + params.connectionId, + ); + + const kinds = (params.kinds ?? []).filter((k) => ASNAD_KINDS.has(k)); + const where: Prisma.AccountingAsnadLineWhereInput = { + organizationId: params.organizationId, + connectionId, + tarikh: { gte: from, lte: to }, + }; + if (params.personId) where.personId = params.personId; + if (kinds.length) where.kind = { in: kinds }; + if (params.jensFelez != null && Number.isFinite(params.jensFelez)) { + where.jensFelez = params.jensFelez; + } + if (params.groupIds?.length) { + where.personId = { + in: ( + await this.prisma.personGroupLink.findMany({ + where: { + groupId: { in: params.groupIds }, + person: { organizationId: params.organizationId }, + }, + select: { personId: true }, + distinct: ['personId'], + }) + ).map((r) => r.personId), + }; + if (params.personId) { + // تقاطع با شخص مشخص + const allowed = where.personId as { in: string[] }; + if (!allowed.in.includes(params.personId)) { + where.personId = '__none__'; + } else { + where.personId = params.personId; + } + } + } + return where; + } + + async query( + organizationId: string, + opts: { + connectionId?: string; + from: string; + to: string; + groupIds?: string[]; + personId?: string; + kinds?: string[]; + jensFelez?: number; + page?: number; + pageSize?: number; + }, + ): Promise { + const page = Math.max(1, opts.page ?? 1); + const pageSize = Math.min(200, Math.max(1, opts.pageSize ?? 50)); + const where = await this.buildWhere({ organizationId, ...opts }); + + const [total, rows] = await Promise.all([ + this.prisma.accountingAsnadLine.count({ where }), + this.prisma.accountingAsnadLine.findMany({ + where, + orderBy: [{ tarikh: 'desc' }, { shFactor: 'desc' }, { radif: 'asc' }], + skip: (page - 1) * pageSize, + take: pageSize, + include: { + person: { + select: { + firstName: true, + lastName: true, + groupLinks: { + include: { + group: { select: { id: true, name: true, color: true } }, + }, + }, + }, + }, + }, + }), + ]); + + const items: TahesabAsnadLineDto[] = rows.map((r) => { + const groups: CustomerGroupSummaryDto[] = (r.person?.groupLinks ?? []).map( + (gl) => ({ + id: gl.group.id, + name: gl.group.name, + color: gl.group.color, + source: gl.source === 'tahesab' ? 'tahesab' : 'manual', + }), + ); + return { + id: r.id, + connectionId: r.connectionId, + personId: r.personId, + personName: personDisplayName(r.person), + moshtariCode: r.moshtariCode, + tarikh: r.tarikh, + noRaw: r.noRaw, + kind: r.kind, + jensFelez: r.jensFelez, + noKar: r.noKar, + vazn: r.vazn, + tabdilVazn: r.tabdilVazn, + mali: r.mali, + count: r.count, + shFactor: r.shFactor, + factorCode: r.factorCode, + groups, + syncedAt: r.syncedAt.toISOString(), + }; + }); + + return { items, total, page, pageSize }; + } + + async summary( + organizationId: string, + opts: { + connectionId?: string; + from: string; + to: string; + groupIds?: string[]; + personId?: string; + kinds?: string[]; + jensFelez?: number; + }, + ): Promise { + const where = await this.buildWhere({ organizationId, ...opts }); + + const rows = await this.prisma.accountingAsnadLine.findMany({ + where, + select: { + kind: true, + jensFelez: true, + mali: true, + vazn: true, + noRaw: true, + syncedAt: true, + personId: true, + person: { + select: { + groupLinks: { + include: { + group: { select: { id: true, name: true } }, + }, + }, + }, + }, + }, + }); + + let maliSum = 0; + let vaznSum = 0; + let lastSyncedAt: Date | null = null; + const byKind = new Map(); + const byMetal = new Map(); + const byGroup = new Map(); + const nos = new Set(); + + for (const r of rows) { + const mali = r.mali ?? 0; + const vazn = r.vazn ?? 0; + maliSum += mali; + vaznSum += vazn; + if (!lastSyncedAt || r.syncedAt > lastSyncedAt) lastSyncedAt = r.syncedAt; + if (r.noRaw) nos.add(r.noRaw); + + const k = r.kind || 'other'; + const kb = byKind.get(k) ?? { lineCount: 0, maliSum: 0, vaznSum: 0 }; + kb.lineCount += 1; + kb.maliSum += mali; + kb.vaznSum += vazn; + byKind.set(k, kb); + + const metalKey = r.jensFelez == null ? 'none' : String(r.jensFelez); + const metalLabel = + r.jensFelez == null + ? 'نامشخص' + : TAHESAB_JENS_FELEZ_LABELS[r.jensFelez] ?? `فلز ${r.jensFelez}`; + const mb = byMetal.get(metalKey) ?? { + lineCount: 0, + maliSum: 0, + vaznSum: 0, + label: metalLabel, + }; + mb.lineCount += 1; + mb.maliSum += mali; + mb.vaznSum += vazn; + byMetal.set(metalKey, mb); + + const gls = r.person?.groupLinks ?? []; + if (!gls.length) { + const gb = byGroup.get('__none__') ?? { + lineCount: 0, + maliSum: 0, + vaznSum: 0, + label: 'بدون گروه', + }; + gb.lineCount += 1; + gb.maliSum += mali; + gb.vaznSum += vazn; + byGroup.set('__none__', gb); + } else { + for (const gl of gls) { + const gb = byGroup.get(gl.group.id) ?? { + lineCount: 0, + maliSum: 0, + vaznSum: 0, + label: gl.group.name, + }; + gb.lineCount += 1; + gb.maliSum += mali; + gb.vaznSum += vazn; + byGroup.set(gl.group.id, gb); + } + } + } + + return { + totalLines: rows.length, + maliSum, + vaznSum, + byKind: [...byKind.entries()].map(([key, v]) => ({ + key, + label: + TAHESAB_ASNAD_KIND_LABELS[key as TahesabAsnadKind] ?? key, + ...v, + })), + byMetal: [...byMetal.entries()].map(([key, v]) => ({ + key, + label: v.label, + lineCount: v.lineCount, + maliSum: v.maliSum, + vaznSum: v.vaznSum, + })), + byGroup: [...byGroup.entries()].map(([key, v]) => ({ + key, + label: v.label, + lineCount: v.lineCount, + maliSum: v.maliSum, + vaznSum: v.vaznSum, + })), + distinctNos: [...nos].sort((a, b) => a.localeCompare(b, 'fa')), + lastSyncedAt: lastSyncedAt?.toISOString() ?? null, + }; + } +} diff --git a/apps/api/src/integrations/tahesab/dto.ts b/apps/api/src/integrations/tahesab/dto.ts index 63e8206..2c4d857 100644 --- a/apps/api/src/integrations/tahesab/dto.ts +++ b/apps/api/src/integrations/tahesab/dto.ts @@ -433,6 +433,22 @@ export class PushSanadDto { factorCode?: string; } +export class SyncAsnadReportDto { + @IsOptional() + @IsString() + connectionId?: string; + + @IsString() + @MinLength(8) + @MaxLength(12) + from!: string; + + @IsString() + @MinLength(8) + @MaxLength(12) + to!: string; +} + // silence unused import warnings for ValidateNested/Type if unused void ValidateNested; void Type; diff --git a/apps/api/src/integrations/tahesab/tahesab.controller.ts b/apps/api/src/integrations/tahesab/tahesab.controller.ts index 4d76861..d46b25f 100644 --- a/apps/api/src/integrations/tahesab/tahesab.controller.ts +++ b/apps/api/src/integrations/tahesab/tahesab.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -13,11 +14,14 @@ import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; import { CurrentUser } from '../../auth/current-user.decorator'; import { Roles } from '../../auth/roles.decorator'; import { RolesGuard } from '../../auth/roles.guard'; +import { OrgModuleGuard } from '../../billing/org-module.guard'; +import { RequireOrgModule } from '../../billing/org-module.decorator'; import { TahesabConnectionsService } from './connections.service'; import { TahesabPersonLinkService } from './person-link.service'; import { TahesabProductLinkService } from './product-link.service'; import { TahesabBalanceService } from './balance.service'; import { TahesabDocumentsService } from './documents.service'; +import { TahesabAsnadReportService } from './asnad-report.service'; import { CreateInTahesabDto, CreateTahesabConnectionDto, @@ -31,12 +35,14 @@ import { PushSanadDto, SearchTahesabDto, SearchTahesabProductsDto, + SyncAsnadReportDto, SyncGroupsPageDto, UpdateTahesabConnectionDto, } from './dto'; @Controller('integrations/tahesab') -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard) +@RequireOrgModule('tahesab') export class TahesabController { constructor( private readonly connections: TahesabConnectionsService, @@ -44,6 +50,7 @@ export class TahesabController { private readonly products: TahesabProductLinkService, private readonly balance: TahesabBalanceService, private readonly documents: TahesabDocumentsService, + private readonly asnadReport: TahesabAsnadReportService, ) {} // —— Connections —— @@ -271,4 +278,107 @@ export class TahesabController { ) { return this.products.importBulk(user.organizationId, dto); } + + // —— گزارش فروش اسناد (اسنپ‌شات DoListAsnad) —— + @Post('reports/asnad/sync') + @Roles('owner', 'admin', 'sales') + syncAsnadReport( + @CurrentUser() user: { organizationId: string }, + @Body() dto: SyncAsnadReportDto, + ) { + return this.asnadReport.startSync( + user.organizationId, + dto.connectionId, + dto.from, + dto.to, + ); + } + + @Get('reports/asnad/sync/:jobId') + syncAsnadReportStatus( + @CurrentUser() user: { organizationId: string }, + @Param('jobId') jobId: string, + ) { + return this.asnadReport.getSyncJob(user.organizationId, jobId); + } + + @Get('reports/asnad/summary') + asnadReportSummary( + @CurrentUser() user: { organizationId: string }, + @Query('connectionId') connectionId?: string, + @Query('from') from?: string, + @Query('to') to?: string, + @Query('groupIds') groupIds?: string | string[], + @Query('personId') personId?: string, + @Query('kinds') kinds?: string | string[], + @Query('jensFelez') jensFelez?: string, + ) { + if (!from || !to) { + throw new BadRequestException('پارامتر from و to الزامی است.'); + } + return this.asnadReport.summary(user.organizationId, { + connectionId, + from, + to, + ...this.parseAsnadFilters(groupIds, personId, kinds, jensFelez), + }); + } + + @Get('reports/asnad') + listAsnadReport( + @CurrentUser() user: { organizationId: string }, + @Query('connectionId') connectionId?: string, + @Query('from') from?: string, + @Query('to') to?: string, + @Query('groupIds') groupIds?: string | string[], + @Query('personId') personId?: string, + @Query('kinds') kinds?: string | string[], + @Query('jensFelez') jensFelez?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + if (!from || !to) { + throw new BadRequestException('پارامتر from و to الزامی است.'); + } + return this.asnadReport.query(user.organizationId, { + connectionId, + from, + to, + ...this.parseAsnadFilters(groupIds, personId, kinds, jensFelez), + page: page ? Number(page) : undefined, + pageSize: pageSize ? Number(pageSize) : undefined, + }); + } + + private parseAsnadFilters( + groupIds?: string | string[], + personId?: string, + kinds?: string | string[], + jensFelez?: string, + ) { + const groups = Array.isArray(groupIds) + ? groupIds + : groupIds + ? groupIds + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : undefined; + const kindList = Array.isArray(kinds) + ? kinds + : kinds + ? kinds + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : undefined; + const jf = + jensFelez != null && jensFelez !== '' ? Number(jensFelez) : undefined; + return { + groupIds: groups, + personId, + kinds: kindList, + jensFelez: jf != null && Number.isFinite(jf) ? jf : undefined, + }; + } } diff --git a/apps/api/src/integrations/tahesab/tahesab.module.ts b/apps/api/src/integrations/tahesab/tahesab.module.ts index 54c714a..31963dc 100644 --- a/apps/api/src/integrations/tahesab/tahesab.module.ts +++ b/apps/api/src/integrations/tahesab/tahesab.module.ts @@ -10,6 +10,7 @@ import { import { TahesabProductLinkService } from './product-link.service'; import { TahesabBalanceService } from './balance.service'; import { TahesabDocumentsService } from './documents.service'; +import { TahesabAsnadReportService } from './asnad-report.service'; import { TahesabController } from './tahesab.controller'; import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module'; @@ -25,6 +26,7 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu TahesabProductLinkService, TahesabBalanceService, TahesabDocumentsService, + TahesabAsnadReportService, ], exports: [ TahesabCryptoService, @@ -33,6 +35,7 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu TahesabProductLinkService, TahesabBalanceService, TahesabDocumentsService, + TahesabAsnadReportService, ], }) export class TahesabModule {} diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index e8f22d5..87fdbf3 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -78,7 +78,9 @@ function pathIn(prefixes: string[]) { const dashboardActive = computed(() => route.path === '/'); const loyaltyActive = computed(() => pathIn(['/loyalty'])); -const reportsActive = computed(() => pathIn(['/reports'])); +const reportsActive = computed(() => + pathIn(['/reports', '/reports/tahesab-sales']), +); const clubActive = computed(() => pathIn(['/club'])); const leadsActive = computed(() => pathIn(['/leads', '/settings/lead-stages'])); const customersActive = computed( diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 8479516..cb05ae8 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -88,6 +88,11 @@ export const router = createRouter({ name: 'reports', component: () => import('@/views/ReportsView.vue'), }, + { + path: 'reports/tahesab-sales', + name: 'tahesab-sales-report', + component: () => import('@/views/TahesabSalesReportView.vue'), + }, { path: 'behavior/disc', name: 'disc-report', @@ -637,6 +642,15 @@ router.beforeEach((to) => { const modules = auth.user?.enabledModules; if (modules && !modules.includes('treasury')) return { name: 'home' }; } + if ( + to.name === 'tahesab-sales-report' || + to.name === 'tahesab-settings' || + String(to.path).startsWith('/settings/tahesab') || + String(to.path).startsWith('/reports/tahesab') + ) { + const modules = auth.user?.enabledModules; + if (modules && !modules.includes('tahesab')) return { name: 'home' }; + } if (!canOpenPath(auth.user?.role, auth.user?.permissions, String(to.path))) { return { name: 'home' }; } diff --git a/apps/web/src/views/ReportsView.vue b/apps/web/src/views/ReportsView.vue index c6ee6a9..f020009 100644 --- a/apps/web/src/views/ReportsView.vue +++ b/apps/web/src/views/ReportsView.vue @@ -1139,6 +1139,14 @@ const activityMax = computed(() => lead="نمای جامع عملکرد فروش، مشتریان، فعالیت‌ها و تیم" >