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,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 {}