Add deeper reports: stage conversion, period compare, and deal drill-down.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: 'تماس',
|
||||
|
||||
Reference in New Issue
Block a user