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[] noteAiSuggestions NoteAiSuggestion[]
accountingConnections AccountingConnection[] accountingConnections AccountingConnection[]
accountingSyncJobs AccountingSyncJob[] accountingSyncJobs AccountingSyncJob[]
accountingAsnadLines AccountingAsnadLine[]
warehouses Warehouse[] warehouses Warehouse[]
stockMovements StockMovement[] stockMovements StockMovement[]
partyOpenings PartyOpeningBalance[] partyOpenings PartyOpeningBalance[]
@@ -614,6 +615,7 @@ model Person {
memoryItems MemoryItem[] memoryItems MemoryItem[]
accountingLinks PersonAccountingLink[] accountingLinks PersonAccountingLink[]
accountingDocuments AccountingDocumentLog[] accountingDocuments AccountingDocumentLog[]
asnadLines AccountingAsnadLine[]
openingBalances PartyOpeningBalance[] openingBalances PartyOpeningBalance[]
cheques Cheque[] cheques Cheque[]
trackingLinks TrackingLink[] trackingLinks TrackingLink[]
@@ -1747,6 +1749,7 @@ model AccountingConnection {
productLinks ProductAccountingLink[] productLinks ProductAccountingLink[]
documentLogs AccountingDocumentLog[] documentLogs AccountingDocumentLog[]
balanceCaches AccountingBalanceCache[] balanceCaches AccountingBalanceCache[]
asnadLines AccountingAsnadLine[]
@@unique([organizationId, baseUrl, dbName]) @@unique([organizationId, baseUrl, dbName])
@@index([organizationId, enabled]) @@index([organizationId, enabled])
@@ -1766,6 +1769,7 @@ model PersonAccountingLink {
linkedAt DateTime @default(now()) linkedAt DateTime @default(now())
lastBalanceSyncedAt DateTime? lastBalanceSyncedAt DateTime?
balanceCache AccountingBalanceCache? balanceCache AccountingBalanceCache?
asnadLines AccountingAsnadLine[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -1844,6 +1848,51 @@ model AccountingBalanceCache {
@@index([connectionId]) @@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 { model AccountingSyncJob {
id String @id @default(cuid()) id String @id @default(cuid())
organizationId String 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; 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 // silence unused import warnings for ValidateNested/Type if unused
void ValidateNested; void ValidateNested;
void Type; void Type;
@@ -1,4 +1,5 @@
import { import {
BadRequestException,
Body, Body,
Controller, Controller,
Delete, Delete,
@@ -13,11 +14,14 @@ import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { CurrentUser } from '../../auth/current-user.decorator'; import { CurrentUser } from '../../auth/current-user.decorator';
import { Roles } from '../../auth/roles.decorator'; import { Roles } from '../../auth/roles.decorator';
import { RolesGuard } from '../../auth/roles.guard'; 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 { TahesabConnectionsService } from './connections.service';
import { TahesabPersonLinkService } from './person-link.service'; import { TahesabPersonLinkService } from './person-link.service';
import { TahesabProductLinkService } from './product-link.service'; import { TahesabProductLinkService } from './product-link.service';
import { TahesabBalanceService } from './balance.service'; import { TahesabBalanceService } from './balance.service';
import { TahesabDocumentsService } from './documents.service'; import { TahesabDocumentsService } from './documents.service';
import { TahesabAsnadReportService } from './asnad-report.service';
import { import {
CreateInTahesabDto, CreateInTahesabDto,
CreateTahesabConnectionDto, CreateTahesabConnectionDto,
@@ -31,12 +35,14 @@ import {
PushSanadDto, PushSanadDto,
SearchTahesabDto, SearchTahesabDto,
SearchTahesabProductsDto, SearchTahesabProductsDto,
SyncAsnadReportDto,
SyncGroupsPageDto, SyncGroupsPageDto,
UpdateTahesabConnectionDto, UpdateTahesabConnectionDto,
} from './dto'; } from './dto';
@Controller('integrations/tahesab') @Controller('integrations/tahesab')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@RequireOrgModule('tahesab')
export class TahesabController { export class TahesabController {
constructor( constructor(
private readonly connections: TahesabConnectionsService, private readonly connections: TahesabConnectionsService,
@@ -44,6 +50,7 @@ export class TahesabController {
private readonly products: TahesabProductLinkService, private readonly products: TahesabProductLinkService,
private readonly balance: TahesabBalanceService, private readonly balance: TahesabBalanceService,
private readonly documents: TahesabDocumentsService, private readonly documents: TahesabDocumentsService,
private readonly asnadReport: TahesabAsnadReportService,
) {} ) {}
// —— Connections —— // —— Connections ——
@@ -271,4 +278,107 @@ export class TahesabController {
) { ) {
return this.products.importBulk(user.organizationId, dto); 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 { TahesabProductLinkService } from './product-link.service';
import { TahesabBalanceService } from './balance.service'; import { TahesabBalanceService } from './balance.service';
import { TahesabDocumentsService } from './documents.service'; import { TahesabDocumentsService } from './documents.service';
import { TahesabAsnadReportService } from './asnad-report.service';
import { TahesabController } from './tahesab.controller'; import { TahesabController } from './tahesab.controller';
import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module'; import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module';
@@ -25,6 +26,7 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu
TahesabProductLinkService, TahesabProductLinkService,
TahesabBalanceService, TahesabBalanceService,
TahesabDocumentsService, TahesabDocumentsService,
TahesabAsnadReportService,
], ],
exports: [ exports: [
TahesabCryptoService, TahesabCryptoService,
@@ -33,6 +35,7 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu
TahesabProductLinkService, TahesabProductLinkService,
TahesabBalanceService, TahesabBalanceService,
TahesabDocumentsService, TahesabDocumentsService,
TahesabAsnadReportService,
], ],
}) })
export class TahesabModule {} export class TahesabModule {}
+3 -1
View File
@@ -78,7 +78,9 @@ function pathIn(prefixes: string[]) {
const dashboardActive = computed(() => route.path === '/'); const dashboardActive = computed(() => route.path === '/');
const loyaltyActive = computed(() => pathIn(['/loyalty'])); const loyaltyActive = computed(() => pathIn(['/loyalty']));
const reportsActive = computed(() => pathIn(['/reports'])); const reportsActive = computed(() =>
pathIn(['/reports', '/reports/tahesab-sales']),
);
const clubActive = computed(() => pathIn(['/club'])); const clubActive = computed(() => pathIn(['/club']));
const leadsActive = computed(() => pathIn(['/leads', '/settings/lead-stages'])); const leadsActive = computed(() => pathIn(['/leads', '/settings/lead-stages']));
const customersActive = computed( const customersActive = computed(
+14
View File
@@ -88,6 +88,11 @@ export const router = createRouter({
name: 'reports', name: 'reports',
component: () => import('@/views/ReportsView.vue'), component: () => import('@/views/ReportsView.vue'),
}, },
{
path: 'reports/tahesab-sales',
name: 'tahesab-sales-report',
component: () => import('@/views/TahesabSalesReportView.vue'),
},
{ {
path: 'behavior/disc', path: 'behavior/disc',
name: 'disc-report', name: 'disc-report',
@@ -637,6 +642,15 @@ router.beforeEach((to) => {
const modules = auth.user?.enabledModules; const modules = auth.user?.enabledModules;
if (modules && !modules.includes('treasury')) return { name: 'home' }; 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))) { if (!canOpenPath(auth.user?.role, auth.user?.permissions, String(to.path))) {
return { name: 'home' }; return { name: 'home' };
} }
+8
View File
@@ -1139,6 +1139,14 @@ const activityMax = computed(() =>
lead="نمای جامع عملکرد فروش، مشتریان، فعالیت‌ها و تیم" lead="نمای جامع عملکرد فروش، مشتریان، فعالیت‌ها و تیم"
> >
<template #actions> <template #actions>
<UiButton
v-if="auth.user?.enabledModules?.includes('tahesab')"
variant="secondary"
@click="router.push({ name: 'tahesab-sales-report' })"
>
<UiIcon name="chart" :size="16" />
فروش ته‌حساب
</UiButton>
<UiButton variant="secondary" @click="openSaveModal"> <UiButton variant="secondary" @click="openSaveModal">
<UiIcon name="layers" :size="16" /> <UiIcon name="layers" :size="16" />
ذخیره نما ذخیره نما
@@ -0,0 +1,502 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import type { TableColumnsType, TablePaginationConfig } from 'ant-design-vue';
import { ReloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
import {
TAHESAB_ASNAD_KIND_LABELS,
TAHESAB_ASNAD_KINDS,
TAHESAB_JENS_FELEZ_LABELS,
type CustomerGroupDto,
type PersonDto,
type TahesabAsnadKind,
type TahesabAsnadLineDto,
type TahesabAsnadReportPageDto,
type TahesabAsnadSummaryDto,
type TahesabAsnadSyncJobDto,
type TahesabConnectionDto,
} from '@mento/shared';
import { api } from '@/lib/api';
import {
dateToJalali,
formatLocalDateInput,
parseLocalInput,
toFaDigits,
} from '@/lib/jalali';
import { formatMoney } from '@/lib/money';
import { useCustomerGroups } from '@/lib/customer-groups';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
const router = useRouter();
const { groups: customerGroups, load: loadCustomerGroups } = useCustomerGroups();
const connections = ref<TahesabConnectionDto[]>([]);
const connectionId = ref<string | undefined>();
const fromDate = ref('');
const toDate = ref('');
const groupIds = ref<string[]>([]);
const personId = ref<string | undefined>();
const personOptions = ref<Array<{ value: string; label: string }>>([]);
const kinds = ref<TahesabAsnadKind[]>(['sale', 'return']);
const jensFelez = ref<number | undefined>();
const loading = ref(false);
const syncing = ref(false);
const error = ref('');
const syncHint = ref('');
const page = ref(1);
const pageSize = ref(50);
const report = ref<TahesabAsnadReportPageDto | null>(null);
const summary = ref<TahesabAsnadSummaryDto | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
const kindOptions = TAHESAB_ASNAD_KINDS.map((k) => ({
value: k,
label: TAHESAB_ASNAD_KIND_LABELS[k],
}));
const metalOptions = [
{ value: 0, label: TAHESAB_JENS_FELEZ_LABELS[0] },
{ value: 1, label: TAHESAB_JENS_FELEZ_LABELS[1] },
{ value: 2, label: TAHESAB_JENS_FELEZ_LABELS[2] },
];
function toShamsiYmd(localIso: string): string | null {
const d = parseLocalInput(localIso);
if (!d) return null;
const { jy, jm, jd } = dateToJalali(d);
return `${jy}-${String(jm).padStart(2, '0')}-${String(jd).padStart(2, '0')}`;
}
function defaultRange() {
const now = new Date();
const from = new Date(now);
from.setDate(from.getDate() - 30);
fromDate.value = formatLocalDateInput(from);
toDate.value = formatLocalDateInput(now);
}
function kindLabel(k: string) {
return TAHESAB_ASNAD_KIND_LABELS[k as TahesabAsnadKind] ?? k;
}
function metalLabel(v: number | null) {
if (v == null) return '—';
return TAHESAB_JENS_FELEZ_LABELS[v] ?? String(v);
}
function formatNum(n: number | null | undefined) {
if (n == null) return '—';
return toFaDigits(new Intl.NumberFormat('fa-IR', { maximumFractionDigits: 3 }).format(n));
}
function formatMali(n: number | null | undefined) {
if (n == null) return '—';
return formatMoney(n, 'IRR');
}
const columns: TableColumnsType<TahesabAsnadLineDto> = [
{ title: 'تاریخ', dataIndex: 'tarikh', key: 'tarikh', width: 110 },
{
title: 'شخص',
key: 'person',
ellipsis: true,
customRender: ({ record }) =>
record.personName
? `${record.personName} (${toFaDigits(String(record.moshtariCode))})`
: toFaDigits(String(record.moshtariCode)),
},
{
title: 'گروه',
key: 'groups',
ellipsis: true,
customRender: ({ record }) =>
record.groups?.map((g) => g.name).join('، ') || '—',
},
{ title: 'نوع سند (NO)', dataIndex: 'noRaw', key: 'noRaw', ellipsis: true },
{
title: 'طبقه‌بندی',
key: 'kind',
width: 100,
customRender: ({ record }) => kindLabel(record.kind),
},
{
title: 'فلز',
key: 'jensFelez',
width: 90,
customRender: ({ record }) => metalLabel(record.jensFelez),
},
{
title: 'وزن',
key: 'vazn',
width: 90,
customRender: ({ record }) => formatNum(record.vazn),
},
{
title: 'مبلغ',
key: 'mali',
width: 140,
customRender: ({ record }) => formatMali(record.mali),
},
{
title: 'ش. فاکتور',
key: 'shFactor',
width: 100,
customRender: ({ record }) =>
record.shFactor != null ? toFaDigits(String(record.shFactor)) : '—',
},
];
const filterParams = computed(() => {
const from = toShamsiYmd(fromDate.value);
const to = toShamsiYmd(toDate.value);
if (!from || !to) return null;
return {
connectionId: connectionId.value,
from,
to,
groupIds: groupIds.value.length ? groupIds.value.join(',') : undefined,
personId: personId.value,
kinds: kinds.value.length ? kinds.value.join(',') : undefined,
jensFelez: jensFelez.value,
};
});
async function loadConnections() {
const { data } = await api.get<TahesabConnectionDto[]>(
'/integrations/tahesab/connections/public',
);
connections.value = data.filter((c) => c.enabled);
const def =
connections.value.find((c) => c.isDefault) ?? connections.value[0];
if (!connectionId.value && def) connectionId.value = def.id;
}
async function searchPeople(q: string) {
if (!q || q.trim().length < 2) {
personOptions.value = [];
return;
}
try {
const { data } = await api.get<{ items: PersonDto[] }>('/people', {
params: { q: q.trim(), limit: 20 },
});
const items = Array.isArray(data?.items) ? data.items : [];
personOptions.value = items.map((p) => ({
value: p.id,
label: [p.firstName, p.lastName].filter(Boolean).join(' ') || p.id,
}));
} catch {
personOptions.value = [];
}
}
async function loadReport() {
const params = filterParams.value;
if (!params) {
error.value = 'تاریخ از و تا را انتخاب کنید.';
return;
}
loading.value = true;
error.value = '';
try {
const [pageRes, sumRes] = await Promise.all([
api.get<TahesabAsnadReportPageDto>('/integrations/tahesab/reports/asnad', {
params: { ...params, page: page.value, pageSize: pageSize.value },
}),
api.get<TahesabAsnadSummaryDto>(
'/integrations/tahesab/reports/asnad/summary',
{ params },
),
]);
report.value = pageRes.data;
summary.value = sumRes.data;
} catch (e: unknown) {
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? 'بارگذاری گزارش ناموفق بود.';
error.value = String(msg);
report.value = null;
summary.value = null;
} finally {
loading.value = false;
}
}
function stopPoll() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
async function pollJob(jobId: string) {
stopPoll();
pollTimer = setInterval(async () => {
try {
const { data } = await api.get<TahesabAsnadSyncJobDto>(
`/integrations/tahesab/reports/asnad/sync/${jobId}`,
);
syncHint.value = `همگام‌سازی: ${toFaDigits(String(data.processed))} از ${toFaDigits(String(data.total))} · ${toFaDigits(String(data.linesUpserted))} ردیف`;
if (data.status === 'done' || data.status === 'failed') {
stopPoll();
syncing.value = false;
if (data.status === 'failed') {
error.value = data.errorMessage || 'همگام‌سازی ناموفق بود.';
} else {
if (data.truncatedPersonIds?.length) {
syncHint.value += ` · هشدار: برای ${toFaDigits(String(data.truncatedPersonIds.length))} مشتری ممکن است اسناد ناقص باشد (سقف ۲۰۰).`;
} else {
syncHint.value = `همگام‌سازی تمام شد · ${toFaDigits(String(data.linesUpserted))} ردیف`;
}
await loadReport();
}
}
} catch {
stopPoll();
syncing.value = false;
error.value = 'وضعیت همگام‌سازی دریافت نشد.';
}
}, 1500);
}
async function startSync() {
const params = filterParams.value;
if (!params) {
error.value = 'تاریخ از و تا را انتخاب کنید.';
return;
}
syncing.value = true;
error.value = '';
syncHint.value = 'شروع همگام‌سازی…';
try {
const { data } = await api.post<TahesabAsnadSyncJobDto>(
'/integrations/tahesab/reports/asnad/sync',
{
connectionId: params.connectionId,
from: params.from,
to: params.to,
},
{ timeout: 60_000 },
);
await pollJob(data.id);
} catch (e: unknown) {
syncing.value = false;
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? 'شروع همگام‌سازی ناموفق بود.';
error.value = String(msg);
}
}
function onTableChange(pag: TablePaginationConfig) {
page.value = pag.current ?? 1;
pageSize.value = pag.pageSize ?? 50;
void loadReport();
}
watch([connectionId, groupIds, kinds, jensFelez, personId], () => {
page.value = 1;
});
onMounted(async () => {
defaultRange();
try {
await Promise.all([loadConnections(), loadCustomerGroups()]);
await loadReport();
} catch {
error.value = 'بارگذاری اولیه ناموفق بود.';
}
});
onUnmounted(() => stopPoll());
</script>
<template>
<a-flex vertical :gap="16">
<a-row :gutter="[16, 16]" align="middle" justify="space-between">
<a-col :xs="24" :md="14">
<a-typography-title :level="3" style="margin: 0">
گزارش فروش ته‌حساب
</a-typography-title>
<a-typography-text type="secondary">
اسنپ‌شات اسناد مشتریان لینک‌شده — طبقه‌بندی فروش/مرجوعی تقریبی است و بعد از بازخورد کارفرما اصلاح می‌شود.
</a-typography-text>
</a-col>
<a-col :xs="24" :md="10">
<a-flex justify="flex-end" wrap="wrap" :gap="8">
<a-button @click="router.push({ name: 'reports' })">بازگشت به گزارش‌ها</a-button>
<a-button :loading="loading" @click="loadReport">
<template #icon><ReloadOutlined /></template>
نمایش
</a-button>
<a-button type="primary" :loading="syncing" @click="startSync">
<template #icon><SyncOutlined /></template>
به‌روزرسانی از ته‌حساب
</a-button>
</a-flex>
</a-col>
</a-row>
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
<a-alert v-if="syncHint" type="info" show-icon :message="syncHint" />
<a-card :bordered="false" title="فیلترها">
<a-form layout="vertical">
<a-row :gutter="[16, 8]">
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="اتصال ته‌حساب">
<a-select
v-model:value="connectionId"
:options="connections.map((c) => ({ value: c.id, label: c.label }))"
placeholder="اتصال"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="از تاریخ">
<JalaliDateInput v-model="fromDate" placeholder="از" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="تا تاریخ">
<JalaliDateInput v-model="toDate" placeholder="تا" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="گروه مشتری">
<a-select
v-model:value="groupIds"
mode="multiple"
allow-clear
placeholder="همه گروه‌ها"
:options="(customerGroups as CustomerGroupDto[]).map((g) => ({ value: g.id, label: g.name }))"
:max-tag-count="2"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="نوع سند">
<a-select
v-model:value="kinds"
mode="multiple"
:options="kindOptions"
placeholder="نوع"
:max-tag-count="2"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="فلز">
<a-select
v-model:value="jensFelez"
allow-clear
placeholder="همه"
:options="metalOptions"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="شخص">
<a-select
v-model:value="personId"
show-search
allow-clear
placeholder="جستجوی نام…"
:filter-option="false"
:options="personOptions"
@search="searchPeople"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-card>
<a-row v-if="summary" :gutter="[16, 16]">
<a-col :xs="24" :sm="8">
<a-card :bordered="false">
<a-statistic title="تعداد ردیف" :value="summary.totalLines" />
</a-card>
</a-col>
<a-col :xs="24" :sm="8">
<a-card :bordered="false">
<a-statistic title="جمع مبلغ" :value="formatMali(summary.maliSum)" />
</a-card>
</a-col>
<a-col :xs="24" :sm="8">
<a-card :bordered="false">
<a-statistic title="جمع وزن" :value="formatNum(summary.vaznSum)" />
</a-card>
</a-col>
</a-row>
<a-row v-if="summary?.byKind?.length" :gutter="[16, 16]">
<a-col :xs="24" :lg="12">
<a-card :bordered="false" title="به تفکیک نوع">
<a-list
:data-source="summary.byKind"
item-layout="horizontal"
size="small"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :title="item.label">
<template #description>
{{ toFaDigits(String(item.lineCount)) }} ردیف ·
{{ formatMali(item.maliSum) }} · وزن {{ formatNum(item.vaznSum) }}
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :bordered="false" title="به تفکیک گروه">
<a-list
:data-source="summary.byGroup.slice(0, 8)"
item-layout="horizontal"
size="small"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :title="item.label">
<template #description>
{{ toFaDigits(String(item.lineCount)) }} ردیف ·
{{ formatMali(item.maliSum) }}
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-col>
</a-row>
<a-card v-if="summary?.distinctNos?.length" :bordered="false" title="مقادیر خام NO در این بازه">
<a-space wrap>
<a-tag v-for="no in summary.distinctNos" :key="no">{{ no }}</a-tag>
</a-space>
</a-card>
<a-card :bordered="false" title="ردیف‌ها">
<a-table
:columns="columns"
:data-source="report?.items ?? []"
:loading="loading"
:pagination="{
current: page,
pageSize,
total: report?.total ?? 0,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100'],
}"
row-key="id"
:scroll="{ x: 1100 }"
@change="onTableChange"
/>
</a-card>
</a-flex>
</template>
+105 -1
View File
@@ -226,7 +226,7 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
loyalty: "باشگاه مشتریان", loyalty: "باشگاه مشتریان",
reports: "گزارش‌ها", reports: "گزارش‌ها",
notes: "یادداشت‌ها", notes: "یادداشت‌ها",
tahesab: "ته‌حساب", tahesab: "ته‌حساب (اتصال و گزارش فروش)",
custom_fields: "فیلدهای سفارشی", custom_fields: "فیلدهای سفارشی",
currencies: "ارزها", currencies: "ارزها",
team_settings: "تیم و تنظیمات سازمان", team_settings: "تیم و تنظیمات سازمان",
@@ -4061,6 +4061,110 @@ export interface PersonAccountingLinkDto {
isDefaultConnection?: boolean; isDefaultConnection?: boolean;
} }
/** طبقه‌بندی تقریبی نوع سند از روی متن NO ته‌حساب */
export const TAHESAB_ASNAD_KINDS = [
"sale",
"return",
"purchase",
"other",
] as const;
export type TahesabAsnadKind = (typeof TAHESAB_ASNAD_KINDS)[number];
export const TAHESAB_ASNAD_KIND_LABELS: Record<TahesabAsnadKind, string> = {
sale: "فروش",
return: "مرجوعی",
purchase: "خرید",
other: "سایر",
};
export const TAHESAB_JENS_FELEZ_LABELS: Record<number, string> = {
0: "طلا",
1: "پلاتین",
2: "نقره",
};
export interface TahesabAsnadSyncRequest {
connectionId?: string;
/** شمسی YYYY-MM-DD */
from: string;
/** شمسی YYYY-MM-DD */
to: string;
}
export interface TahesabAsnadSyncJobDto {
id: string;
status: "pending" | "running" | "done" | "failed" | string;
connectionId: string | null;
from: string;
to: string;
processed: number;
total: number;
linesUpserted: number;
truncatedPersonIds: string[];
errorMessage: string | null;
createdAt: string;
updatedAt: string;
}
export interface TahesabAsnadLineDto {
id: string;
connectionId: string;
personId: string | null;
personName: string | null;
moshtariCode: number;
tarikh: string;
noRaw: string;
kind: TahesabAsnadKind | string;
jensFelez: number | null;
noKar: string | null;
vazn: number | null;
tabdilVazn: number | null;
mali: number | null;
count: number | null;
shFactor: number | null;
factorCode: string | null;
groups: CustomerGroupSummaryDto[];
syncedAt: string;
}
export interface TahesabAsnadReportQuery {
connectionId?: string;
from: string;
to: string;
groupIds?: string[];
personId?: string;
kinds?: TahesabAsnadKind[];
jensFelez?: number;
page?: number;
pageSize?: number;
}
export interface TahesabAsnadReportPageDto {
items: TahesabAsnadLineDto[];
total: number;
page: number;
pageSize: number;
}
export interface TahesabAsnadSummaryBucketDto {
key: string;
label: string;
lineCount: number;
maliSum: number;
vaznSum: number;
}
export interface TahesabAsnadSummaryDto {
totalLines: number;
maliSum: number;
vaznSum: number;
byKind: TahesabAsnadSummaryBucketDto[];
byMetal: TahesabAsnadSummaryBucketDto[];
byGroup: TahesabAsnadSummaryBucketDto[];
distinctNos: string[];
lastSyncedAt: string | null;
}
export interface MandehValueDto { export interface MandehValueDto {
amount: string | number; amount: string | number;
unit: string; unit: string;