272 lines
8.1 KiB
TypeScript
272 lines
8.1 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|||
|
|
import type { ReportsDashboardDto } from '@mento/shared';
|
||
|
|
import { Prisma } from '@prisma/client';
|
||
|
|
import { PrismaService } from '../prisma/prisma.service';
|
||
|
|
import {
|
||
|
|
type Actor,
|
||
|
|
assigneeWhere,
|
||
|
|
elevated,
|
||
|
|
ownerWhere,
|
||
|
|
ticketAccessWhere,
|
||
|
|
} from '../common/record-scope';
|
||
|
|
import { decimalToBase, loadOrgRates } from '../common/currency';
|
||
|
|
|
||
|
|
@Injectable()
|
||
|
|
export class ReportsService {
|
||
|
|
constructor(private readonly prisma: PrismaService) {}
|
||
|
|
|
||
|
|
async dashboard(
|
||
|
|
actor: Actor,
|
||
|
|
opts: { from?: string; to?: string },
|
||
|
|
): Promise<ReportsDashboardDto> {
|
||
|
|
const { start, end } = resolveRange(opts.from, opts.to);
|
||
|
|
const orgId = actor.organizationId;
|
||
|
|
const dealScope = ownerWhere(actor);
|
||
|
|
const activityScope = assigneeWhere(actor);
|
||
|
|
const ticketScope = ticketAccessWhere(actor);
|
||
|
|
|
||
|
|
const rates = await loadOrgRates(this.prisma, orgId);
|
||
|
|
|
||
|
|
const [deals, stages, activities, tickets, peopleCreated, users] =
|
||
|
|
await Promise.all([
|
||
|
|
this.prisma.deal.findMany({
|
||
|
|
where: {
|
||
|
|
organizationId: orgId,
|
||
|
|
...dealScope,
|
||
|
|
OR: [
|
||
|
|
{ createdAt: { gte: start, lte: end } },
|
||
|
|
{
|
||
|
|
status: { in: ['won', 'lost'] },
|
||
|
|
updatedAt: { gte: start, lte: end },
|
||
|
|
},
|
||
|
|
{ status: 'open' },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
select: {
|
||
|
|
id: true,
|
||
|
|
status: true,
|
||
|
|
amount: true,
|
||
|
|
currency: true,
|
||
|
|
stageId: true,
|
||
|
|
ownerId: true,
|
||
|
|
updatedAt: true,
|
||
|
|
createdAt: true,
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
this.prisma.pipelineStage.findMany({
|
||
|
|
where: { pipeline: { organizationId: orgId } },
|
||
|
|
include: { pipeline: { select: { name: true } } },
|
||
|
|
orderBy: [{ pipelineId: 'asc' }, { position: 'asc' }],
|
||
|
|
}),
|
||
|
|
this.prisma.activity.findMany({
|
||
|
|
where: {
|
||
|
|
organizationId: orgId,
|
||
|
|
...activityScope,
|
||
|
|
createdAt: { gte: start, lte: end },
|
||
|
|
},
|
||
|
|
select: {
|
||
|
|
id: true,
|
||
|
|
type: true,
|
||
|
|
status: true,
|
||
|
|
dueAt: true,
|
||
|
|
assigneeId: true,
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
this.prisma.ticket.findMany({
|
||
|
|
where: {
|
||
|
|
organizationId: orgId,
|
||
|
|
...ticketScope,
|
||
|
|
createdAt: { gte: start, lte: end },
|
||
|
|
},
|
||
|
|
select: {
|
||
|
|
status: true,
|
||
|
|
slaDueAt: true,
|
||
|
|
firstRespondedAt: true,
|
||
|
|
csatScore: true,
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
this.prisma.person.count({
|
||
|
|
where: {
|
||
|
|
organizationId: orgId,
|
||
|
|
createdAt: { gte: start, lte: end },
|
||
|
|
...(elevated(actor.role) ? {} : { ownerId: actor.userId }),
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
this.prisma.user.findMany({
|
||
|
|
where: { organizationId: orgId },
|
||
|
|
select: { id: true, fullName: true },
|
||
|
|
}),
|
||
|
|
]);
|
||
|
|
|
||
|
|
const openDeals = deals.filter((d) => d.status === 'open');
|
||
|
|
const wonInPeriod = deals.filter(
|
||
|
|
(d) =>
|
||
|
|
d.status === 'won' &&
|
||
|
|
d.updatedAt >= start &&
|
||
|
|
d.updatedAt <= end,
|
||
|
|
);
|
||
|
|
const lostInPeriod = deals.filter(
|
||
|
|
(d) =>
|
||
|
|
d.status === 'lost' &&
|
||
|
|
d.updatedAt >= start &&
|
||
|
|
d.updatedAt <= end,
|
||
|
|
);
|
||
|
|
|
||
|
|
const sumBase = (
|
||
|
|
rows: Array<{ amount: Prisma.Decimal | null; currency: string }>,
|
||
|
|
) =>
|
||
|
|
rows.reduce(
|
||
|
|
(acc, row) => acc + decimalToBase(row.amount, row.currency, rates),
|
||
|
|
0,
|
||
|
|
);
|
||
|
|
|
||
|
|
const closedCount = wonInPeriod.length + lostInPeriod.length;
|
||
|
|
const winRate = closedCount ? wonInPeriod.length / closedCount : 0;
|
||
|
|
|
||
|
|
const openByStage = new Map<string, { count: number; amount: number }>();
|
||
|
|
for (const d of openDeals) {
|
||
|
|
const cur = openByStage.get(d.stageId) ?? { count: 0, amount: 0 };
|
||
|
|
cur.count += 1;
|
||
|
|
cur.amount += decimalToBase(d.amount, d.currency, rates);
|
||
|
|
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 now = new Date();
|
||
|
|
const activityOpen = activities.filter((a) => a.status === 'open').length;
|
||
|
|
const activityDone = activities.filter((a) => a.status === 'done').length;
|
||
|
|
const activityOverdue = activities.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) {
|
||
|
|
byTypeMap.set(a.type, (byTypeMap.get(a.type) ?? 0) + 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
const teamMap = new Map<
|
||
|
|
string,
|
||
|
|
{ wonAmount: number; wonCount: number; activitiesDone: number }
|
||
|
|
>();
|
||
|
|
for (const u of users) {
|
||
|
|
teamMap.set(u.id, { wonAmount: 0, wonCount: 0, activitiesDone: 0 });
|
||
|
|
}
|
||
|
|
for (const d of wonInPeriod) {
|
||
|
|
if (!d.ownerId) continue;
|
||
|
|
const t = teamMap.get(d.ownerId)!;
|
||
|
|
t.wonCount += 1;
|
||
|
|
t.wonAmount += decimalToBase(d.amount, d.currency, rates);
|
||
|
|
}
|
||
|
|
for (const a of activities) {
|
||
|
|
if (a.status !== 'done' || !a.assigneeId) continue;
|
||
|
|
const t = teamMap.get(a.assigneeId);
|
||
|
|
if (t) t.activitiesDone += 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
const ticketOpen = tickets.filter((t) =>
|
||
|
|
['open', 'in_progress', 'waiting'].includes(t.status),
|
||
|
|
).length;
|
||
|
|
const ticketResolved = tickets.filter(
|
||
|
|
(t) => t.status === 'resolved',
|
||
|
|
).length;
|
||
|
|
const ticketClosed = tickets.filter((t) => t.status === 'closed').length;
|
||
|
|
const slaBreached = tickets.filter(
|
||
|
|
(t) =>
|
||
|
|
t.slaDueAt &&
|
||
|
|
!t.firstRespondedAt &&
|
||
|
|
['open', 'in_progress', 'waiting'].includes(t.status) &&
|
||
|
|
t.slaDueAt.getTime() < now.getTime(),
|
||
|
|
).length;
|
||
|
|
const csatScores = tickets
|
||
|
|
.map((t) => t.csatScore)
|
||
|
|
.filter((s): s is number => s != null);
|
||
|
|
const avgCsat = csatScores.length
|
||
|
|
? csatScores.reduce((a, b) => a + b, 0) / csatScores.length
|
||
|
|
: null;
|
||
|
|
|
||
|
|
return {
|
||
|
|
from: start.toISOString(),
|
||
|
|
to: end.toISOString(),
|
||
|
|
currency: rates.baseCurrency,
|
||
|
|
sales: {
|
||
|
|
openCount: openDeals.length,
|
||
|
|
wonCount: wonInPeriod.length,
|
||
|
|
lostCount: lostInPeriod.length,
|
||
|
|
openAmount: Math.round(sumBase(openDeals)),
|
||
|
|
wonAmount: Math.round(sumBase(wonInPeriod)),
|
||
|
|
lostAmount: Math.round(sumBase(lostInPeriod)),
|
||
|
|
winRate: Math.round(winRate * 1000) / 10,
|
||
|
|
},
|
||
|
|
funnel,
|
||
|
|
activities: {
|
||
|
|
open: activityOpen,
|
||
|
|
done: activityDone,
|
||
|
|
overdue: activityOverdue,
|
||
|
|
byType: [...byTypeMap.entries()].map(([type, count]) => ({
|
||
|
|
type,
|
||
|
|
count,
|
||
|
|
})),
|
||
|
|
},
|
||
|
|
team: users
|
||
|
|
.map((u) => {
|
||
|
|
const t = teamMap.get(u.id)!;
|
||
|
|
return {
|
||
|
|
userId: u.id,
|
||
|
|
fullName: u.fullName,
|
||
|
|
wonAmount: Math.round(t.wonAmount),
|
||
|
|
wonCount: t.wonCount,
|
||
|
|
activitiesDone: t.activitiesDone,
|
||
|
|
};
|
||
|
|
})
|
||
|
|
.filter(
|
||
|
|
(u) =>
|
||
|
|
elevated(actor.role) || u.wonCount > 0 || u.activitiesDone > 0,
|
||
|
|
)
|
||
|
|
.sort((a, b) => b.wonAmount - a.wonAmount),
|
||
|
|
tickets: {
|
||
|
|
open: ticketOpen,
|
||
|
|
resolved: ticketResolved,
|
||
|
|
closed: ticketClosed,
|
||
|
|
slaBreached,
|
||
|
|
avgCsat: avgCsat == null ? null : Math.round(avgCsat * 10) / 10,
|
||
|
|
csatCount: csatScores.length,
|
||
|
|
},
|
||
|
|
peopleCreated,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveRange(from?: string, to?: string): { start: Date; end: Date } {
|
||
|
|
const end = to ? endOfDay(parseDay(to)) : endOfDay(new Date());
|
||
|
|
const start = from
|
||
|
|
? startOfDay(parseDay(from))
|
||
|
|
: startOfDay(new Date(end.getFullYear(), end.getMonth(), 1));
|
||
|
|
return { start, end };
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseDay(value: string): Date {
|
||
|
|
const d = new Date(value);
|
||
|
|
if (Number.isNaN(d.getTime())) return new Date();
|
||
|
|
return d;
|
||
|
|
}
|
||
|
|
|
||
|
|
function startOfDay(d: Date): Date {
|
||
|
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
function endOfDay(d: Date): Date {
|
||
|
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
|
||
|
|
}
|