Add deeper reports: stage conversion, period compare, and deal drill-down.

This commit is contained in:
2026-09-27 10:39:09 +03:30
parent d133c8ca34
commit 6a9e864b07
5 changed files with 889 additions and 60 deletions
+19 -1
View File
@@ -1,4 +1,5 @@
import { IsOptional, IsString } from 'class-validator';
import { IsIn, IsOptional, IsString } from 'class-validator';
import { REPORTS_DRILL_KINDS } from '@mento/shared';
export class ReportsQueryDto {
@IsOptional()
@@ -9,3 +10,20 @@ export class ReportsQueryDto {
@IsString()
to?: string;
}
export class ReportsDrilldownQueryDto {
@IsIn([...REPORTS_DRILL_KINDS])
kind!: (typeof REPORTS_DRILL_KINDS)[number];
@IsOptional()
@IsString()
id?: string;
@IsOptional()
@IsString()
from?: string;
@IsOptional()
@IsString()
to?: string;
}
+21 -22
View File
@@ -1,7 +1,8 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { ReportsQueryDto } from './dto';
import type { Actor } from '../common/record-scope';
import { ReportsDrilldownQueryDto, ReportsQueryDto } from './dto';
import { ReportsService } from './reports.service';
@Controller('reports')
@@ -10,30 +11,28 @@ export class ReportsController {
constructor(private readonly reportsService: ReportsService) {}
@Get('dashboard')
dashboard(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
@Query() query: ReportsQueryDto,
dashboard(@CurrentUser() user: Actor, @Query() query: ReportsQueryDto) {
return this.reportsService.dashboard(user, {
from: query.from,
to: query.to,
});
}
@Get('drilldown')
drilldown(
@CurrentUser() user: Actor,
@Query() query: ReportsDrilldownQueryDto,
) {
return this.reportsService.dashboard(
{
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
},
{ from: query.from, to: query.to },
);
return this.reportsService.drilldown(user, {
kind: query.kind,
id: query.id,
from: query.from,
to: query.to,
});
}
@Get('home')
home(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
) {
return this.reportsService.home({
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
});
home(@CurrentUser() user: Actor) {
return this.reportsService.home(user);
}
}
+353 -21
View File
@@ -1,5 +1,12 @@
import { Injectable } from '@nestjs/common';
import type { HomeDashboardDto, ReportsDashboardDto } from '@mento/shared';
import { Injectable, BadRequestException } from '@nestjs/common';
import type {
HomeDashboardDto,
ReportsDashboardDto,
ReportsDrillKind,
ReportsDrilldownDto,
ReportsFunnelStageDto,
ReportsPeriodCompareRowDto,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
@@ -18,6 +25,8 @@ import {
startOfJalaliMonth,
} from '../common/jalali';
const DRILL_LIMIT = 50;
@Injectable()
export class ReportsService {
constructor(private readonly prisma: PrismaService) {}
@@ -92,14 +101,14 @@ export class ReportsService {
}),
this.prisma.pipelineStage.findMany({
where: { pipeline: { organizationId: orgId } },
include: { pipeline: { select: { name: true } } },
include: { pipeline: { select: { id: true, name: true } } },
orderBy: [{ pipelineId: 'asc' }, { position: 'asc' }],
}),
this.prisma.activity.findMany({
where: {
organizationId: orgId,
...activityScope,
createdAt: { gte: start, lte: end },
createdAt: { gte: prevStart, lte: end },
},
select: {
id: true,
@@ -107,6 +116,7 @@ export class ReportsService {
status: true,
dueAt: true,
assigneeId: true,
createdAt: true,
},
}),
this.prisma.ticket.findMany({
@@ -213,6 +223,19 @@ export class ReportsService {
d.updatedAt >= prevStart &&
d.updatedAt <= prevEnd,
);
const lostPrev = deals.filter(
(d) =>
d.status === 'lost' &&
d.updatedAt >= prevStart &&
d.updatedAt <= prevEnd,
);
const activitiesCur = activities.filter(
(a) => a.createdAt >= start && a.createdAt <= end,
);
const activitiesPrev = activities.filter(
(a) => a.createdAt >= prevStart && a.createdAt <= prevEnd,
);
const sumBase = (
rows: Array<{ amount: Prisma.Decimal | null; currency: string }>,
@@ -547,27 +570,58 @@ export class ReportsService {
openByStage.set(d.stageId, cur);
}
const funnel = stages.map((s) => {
const agg = openByStage.get(s.id) ?? { count: 0, amount: 0 };
return {
stageId: s.id,
stageName: s.name,
pipelineName: s.pipeline.name,
count: agg.count,
amount: Math.round(agg.amount),
};
});
const funnel: ReportsFunnelStageDto[] = [];
const stagesByPipeline = new Map<string, typeof stages>();
for (const s of stages) {
const list = stagesByPipeline.get(s.pipelineId) ?? [];
list.push(s);
stagesByPipeline.set(s.pipelineId, list);
}
for (const pipelineStages of stagesByPipeline.values()) {
const pipelineTotal =
pipelineStages.reduce(
(acc, s) => acc + (openByStage.get(s.id)?.count ?? 0),
0,
) || 1;
let prevCount: number | null = null;
for (const s of pipelineStages) {
const agg = openByStage.get(s.id) ?? { count: 0, amount: 0 };
const conversionFromPrevPercent =
prevCount == null
? null
: prevCount === 0
? agg.count > 0
? 100
: 0
: Math.round((agg.count / prevCount) * 1000) / 10;
funnel.push({
stageId: s.id,
stageName: s.name,
pipelineId: s.pipelineId,
pipelineName: s.pipeline.name,
count: agg.count,
amount: Math.round(agg.amount),
conversionFromPrevPercent,
shareOfPipelinePercent:
Math.round((agg.count / pipelineTotal) * 1000) / 10,
});
prevCount = agg.count;
}
}
const activityOpen = activities.filter((a) => a.status === 'open').length;
const activityDone = activities.filter((a) => a.status === 'done').length;
const activityOverdue = activities.filter(
const activityOpen = activitiesCur.filter((a) => a.status === 'open').length;
const activityDone = activitiesCur.filter((a) => a.status === 'done').length;
const activityDonePrev = activitiesPrev.filter(
(a) => a.status === 'done',
).length;
const activityOverdue = activitiesCur.filter(
(a) =>
a.status === 'open' &&
a.dueAt != null &&
a.dueAt.getTime() < now.getTime(),
).length;
const byTypeMap = new Map<string, number>();
for (const a of activities) {
for (const a of activitiesCur) {
byTypeMap.set(a.type, (byTypeMap.get(a.type) ?? 0) + 1);
}
@@ -584,12 +638,71 @@ export class ReportsService {
t.wonCount += 1;
t.wonAmount += decimalToBase(d.amount, d.currency, rates);
}
for (const a of activities) {
for (const a of activitiesCur) {
if (a.status !== 'done' || !a.assigneeId) continue;
const t = teamMap.get(a.assigneeId);
if (t) t.activitiesDone += 1;
}
const closedPrev = wonPrev.length + lostPrev.length;
const winRatePrev = closedPrev ? wonPrev.length / closedPrev : 0;
const winRatePct = Math.round(winRate * 1000) / 10;
const winRatePrevPct = Math.round(winRatePrev * 1000) / 10;
const periodCompare: ReportsPeriodCompareRowDto[] = [
{
key: 'sales_amount',
label: 'مبلغ فروش',
format: 'money',
current: salesAmount,
previous: salesAmountPrev,
changePercent: pctChange(salesAmount, salesAmountPrev),
drillKind: 'period_won',
},
{
key: 'won_count',
label: 'معاملات برنده',
format: 'number',
current: wonInPeriod.length,
previous: wonPrev.length,
changePercent: pctChange(wonInPeriod.length, wonPrev.length),
drillKind: 'period_won',
},
{
key: 'lost_count',
label: 'معاملات باخته',
format: 'number',
current: lostInPeriod.length,
previous: lostPrev.length,
changePercent: pctChange(lostInPeriod.length, lostPrev.length),
drillKind: 'period_lost',
},
{
key: 'win_rate',
label: 'نرخ برد',
format: 'percent',
current: winRatePct,
previous: winRatePrevPct,
changePercent: pctChange(winRatePct, winRatePrevPct),
},
{
key: 'new_customers',
label: 'مشتریان جدید',
format: 'number',
current: peopleCreated,
previous: peopleCreatedPrev,
changePercent: pctChange(peopleCreated, peopleCreatedPrev),
},
{
key: 'activities_done',
label: 'فعالیت انجام‌شده',
format: 'number',
current: activityDone,
previous: activityDonePrev,
changePercent: pctChange(activityDone, activityDonePrev),
},
];
const ticketOpen = tickets.filter((t) =>
['open', 'in_progress', 'waiting'].includes(t.status),
).length;
@@ -645,7 +758,7 @@ export class ReportsService {
}
if (wonInPeriod.length + lostInPeriod.length > 0) {
insights.push(
`نرخ برد معاملات در این دوره ${Math.round(winRate * 1000) / 10}٪ بوده است.`,
`نرخ برد معاملات در این دوره ${winRatePct}٪ بوده است.`,
);
}
if (invoiceCount > 0) {
@@ -712,9 +825,10 @@ export class ReportsService {
openAmount: Math.round(sumBase(openDeals)),
wonAmount,
lostAmount: Math.round(sumBase(lostInPeriod)),
winRate: Math.round(winRate * 1000) / 10,
winRate: winRatePct,
},
funnel,
periodCompare,
tickets: {
open: ticketOpen,
resolved: ticketResolved,
@@ -729,6 +843,179 @@ export class ReportsService {
};
}
async drilldown(
actor: Actor,
opts: {
kind: ReportsDrillKind;
id?: string;
from?: string;
to?: string;
},
): Promise<ReportsDrilldownDto> {
const { start, end } = resolveRange(opts.from, opts.to);
const orgId = actor.organizationId;
const dealScope = ownerWhere(actor);
const rates = await loadOrgRates(this.prisma, orgId);
const baseSelect = {
id: true,
title: true,
status: true,
amount: true,
currency: true,
source: true,
lostReason: true,
updatedAt: true,
owner: { select: { fullName: true } },
stage: { select: { name: true } },
} as const;
let where: Prisma.DealWhereInput = {
organizationId: orgId,
...dealScope,
};
let title = 'فهرست معاملات';
switch (opts.kind) {
case 'stage_open': {
if (!opts.id?.trim()) {
throw new BadRequestException('شناسه مرحله الزامی است');
}
const stage = await this.prisma.pipelineStage.findFirst({
where: {
id: opts.id.trim(),
pipeline: { organizationId: orgId },
},
select: { name: true, pipeline: { select: { name: true } } },
});
if (!stage) throw new BadRequestException('مرحله پیدا نشد');
where = {
...where,
status: 'open',
stageId: opts.id.trim(),
};
title = `معاملات باز — ${stage.pipeline.name} / ${stage.name}`;
break;
}
case 'team_won': {
if (!opts.id?.trim()) {
throw new BadRequestException('شناسه کاربر الزامی است');
}
const user = await this.prisma.user.findFirst({
where: {
id: opts.id.trim(),
memberships: { some: { organizationId: orgId } },
},
select: { fullName: true },
});
if (!user) throw new BadRequestException('کاربر پیدا نشد');
where = {
...where,
status: 'won',
ownerId: opts.id.trim(),
updatedAt: { gte: start, lte: end },
};
title = `معاملات برنده — ${user.fullName}`;
break;
}
case 'period_won': {
where = {
...where,
status: 'won',
updatedAt: { gte: start, lte: end },
};
title = 'معاملات برنده این دوره';
break;
}
case 'period_lost': {
where = {
...where,
status: 'lost',
updatedAt: { gte: start, lte: end },
};
title = 'معاملات باخته این دوره';
break;
}
case 'source': {
const raw = (opts.id ?? '').trim();
if (!raw) throw new BadRequestException('منبع الزامی است');
const all = await this.prisma.deal.findMany({
where: {
organizationId: orgId,
...dealScope,
status: 'won',
updatedAt: { gte: start, lte: end },
},
select: baseSelect,
orderBy: { updatedAt: 'desc' },
take: 500,
});
const matched = all.filter(
(d) => normalizeSource(d.source) === raw,
);
return {
kind: opts.kind,
title: `معاملات برنده — منبع «${raw}»`,
from: start.toISOString(),
to: end.toISOString(),
total: matched.length,
items: matched.slice(0, DRILL_LIMIT).map((d) =>
toDrillItem(d, rates.baseCurrency),
),
};
}
case 'lost_reason': {
const raw = (opts.id ?? '').trim();
if (!raw) throw new BadRequestException('دلیل شکست الزامی است');
const all = await this.prisma.deal.findMany({
where: {
organizationId: orgId,
...dealScope,
status: 'lost',
updatedAt: { gte: start, lte: end },
},
select: baseSelect,
orderBy: { updatedAt: 'desc' },
take: 500,
});
const matched = all.filter(
(d) => normalizeLostReason(d.lostReason) === raw,
);
return {
kind: opts.kind,
title: `معاملات باخته — «${raw}»`,
from: start.toISOString(),
to: end.toISOString(),
total: matched.length,
items: matched.slice(0, DRILL_LIMIT).map((d) =>
toDrillItem(d, rates.baseCurrency),
),
};
}
default:
throw new BadRequestException('نوع گزارش نامعتبر است');
}
const [total, rows] = await Promise.all([
this.prisma.deal.count({ where }),
this.prisma.deal.findMany({
where,
select: baseSelect,
orderBy: { updatedAt: 'desc' },
take: DRILL_LIMIT,
}),
]);
return {
kind: opts.kind,
title,
from: start.toISOString(),
to: end.toISOString(),
total,
items: rows.map((d) => toDrillItem(d, rates.baseCurrency)),
};
}
async home(actor: Actor): Promise<HomeDashboardDto> {
const orgId = actor.organizationId;
const dealScope = ownerWhere(actor);
@@ -1086,6 +1373,51 @@ function normalizeLostReason(raw: string | null | undefined): string {
return value;
}
function dealStatusLabel(status: string): string {
if (status === 'won') return 'برنده';
if (status === 'lost') return 'باخته';
if (status === 'open') return 'باز';
return status;
}
function toDrillItem(
d: {
id: string;
title: string;
status: string;
amount: Prisma.Decimal | null;
currency: string;
updatedAt: Date;
owner: { fullName: string } | null;
stage: { name: string } | null;
},
_baseCurrency: string,
): {
id: string;
title: string;
status: string;
statusLabel: string;
amount: number;
currency: string;
ownerName: string | null;
stageName: string | null;
updatedAt: string;
link: string;
} {
return {
id: d.id,
title: d.title,
status: d.status,
statusLabel: dealStatusLabel(d.status),
amount: d.amount == null ? 0 : Math.round(Number(d.amount)),
currency: d.currency,
ownerName: d.owner?.fullName ?? null,
stageName: d.stage?.name ?? null,
updatedAt: d.updatedAt.toISOString(),
link: `/deals/${d.id}`,
};
}
function activityTypeLabel(type: string): string {
const map: Record<string, string> = {
call: 'تماس',