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:
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user