feat(tahesab): sales report snapshot with org-module gate

Sync linked asnad into CRM, filter by group/kind/metal, and require the tahesab superadmin module plus reports role access.
This commit is contained in:
2026-10-22 18:20:46 +03:30
parent 65cf4e9a66
commit ade43fea0f
11 changed files with 1490 additions and 3 deletions
@@ -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;
+49
View File
@@ -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
@@ -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<string>(['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<Record<string, unknown>> {
if (!raw) return [];
if (Array.isArray(raw)) return raw as Array<Record<string, unknown>>;
if (typeof raw === 'object') {
const obj = raw as Record<string, unknown>;
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<string, unknown>
>;
}
}
return Object.values(obj).filter(
(v) => v && typeof v === 'object' && !Array.isArray(v),
) as Array<Record<string, unknown>>;
}
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, unknown>): 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<string, unknown>;
const res = (job.result ?? {}) as Partial<SyncProgress>;
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<TahesabAsnadSyncJobDto> {
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<TahesabAsnadSyncJobDto> {
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<Prisma.AccountingAsnadLineWhereInput> {
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<TahesabAsnadReportPageDto> {
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<TahesabAsnadSummaryDto> {
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<string, { lineCount: number; maliSum: number; vaznSum: number }>();
const byMetal = new Map<string, { lineCount: number; maliSum: number; vaznSum: number; label: string }>();
const byGroup = new Map<string, { lineCount: number; maliSum: number; vaznSum: number; label: string }>();
const nos = new Set<string>();
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,
};
}
}
+16
View File
@@ -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;
@@ -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,
};
}
}
@@ -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 {}