Files
CRM_Montzar/apps/api/src/reports/reports.service.ts
T

1064 lines
32 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import type { HomeDashboardDto, 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, toBaseAmount } from '../common/currency';
import {
addJalaliMonths,
dateToJalali,
endOfJalaliMonth,
jalaliMonthName,
startOfJalaliMonth,
} from '../common/jalali';
@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 invoiceScope = ownerWhere(actor);
const activityScope = assigneeWhere(actor);
const ticketScope = ticketAccessWhere(actor);
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId };
const durationMs = end.getTime() - start.getTime();
const prevEnd = new Date(start.getTime() - 1);
const prevStart = new Date(prevEnd.getTime() - durationMs);
const now = new Date();
const { jy, jm } = dateToJalali(now);
const sixAgo = addJalaliMonths(jy, jm, -5);
const sixMonthStart = startOfJalaliMonth(sixAgo.jy, sixAgo.jm);
const dealEarliest = new Date(
Math.min(sixMonthStart.getTime(), prevStart.getTime(), start.getTime()),
);
const rates = await loadOrgRates(this.prisma, orgId);
const invoiceStatuses: Array<'issued' | 'partially_paid' | 'paid'> = [
'issued',
'partially_paid',
'paid',
];
const [deals, stages, activities, tickets, people, users, invoices] =
await Promise.all([
this.prisma.deal.findMany({
where: {
organizationId: orgId,
...dealScope,
OR: [
{ status: 'open' },
{
status: { in: ['won', 'lost'] },
updatedAt: { gte: dealEarliest },
},
],
},
select: {
id: true,
status: true,
amount: true,
currency: true,
stageId: true,
ownerId: true,
source: true,
updatedAt: true,
createdAt: true,
lineItems: {
select: {
name: true,
quantity: true,
unitPrice: true,
currency: true,
product: { select: { name: true, category: 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.findMany({
where: {
organizationId: orgId,
...personScope,
},
select: { mood: true, createdAt: true },
}),
this.prisma.user.findMany({
where: { organizationId: orgId },
select: { id: true, fullName: true },
}),
this.prisma.invoice.findMany({
where: {
organizationId: orgId,
...invoiceScope,
status: { in: invoiceStatuses },
OR: [
{ issuedAt: { gte: prevStart, lte: end } },
{ createdAt: { gte: prevStart, lte: end } },
],
},
select: {
issuedAt: true,
createdAt: true,
lineItems: {
select: {
name: true,
quantity: true,
unitPrice: true,
currency: true,
product: { select: { name: true, category: true } },
},
},
},
}),
]);
const invoiceInRange = (inv: {
issuedAt: Date | null;
createdAt: Date;
}, from: Date, to: Date) => {
const anchor = inv.issuedAt ?? inv.createdAt;
return anchor >= from && anchor <= to;
};
const invoicesCur = invoices.filter((inv) =>
invoiceInRange(inv, start, end),
);
const invoicesPrev = invoices.filter((inv) =>
invoiceInRange(inv, prevStart, prevEnd),
);
const sumInvoiceLines = (
rows: typeof invoices,
): { count: number; amount: number } => {
let amount = 0;
for (const inv of rows) {
for (const line of inv.lineItems) {
const lineTotal = Number(line.quantity) * Number(line.unitPrice);
amount += toBaseAmount(lineTotal, line.currency, rates);
}
}
return { count: rows.length, amount };
};
const invCur = sumInvoiceLines(invoicesCur);
const invPrev = sumInvoiceLines(invoicesPrev);
const invoiceCount = invCur.count;
const invoiceAmount = Math.round(invCur.amount);
const invoiceCountPrev = invPrev.count;
const invoiceAmountPrev = Math.round(invPrev.amount);
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 wonPrev = deals.filter(
(d) =>
d.status === 'won' &&
d.updatedAt >= prevStart &&
d.updatedAt <= prevEnd,
);
const sumBase = (
rows: Array<{ amount: Prisma.Decimal | null; currency: string }>,
) =>
rows.reduce(
(acc, row) => acc + decimalToBase(row.amount, row.currency, rates),
0,
);
const wonAmount = Math.round(sumBase(wonInPeriod));
const wonAmountPrev = Math.round(sumBase(wonPrev));
const closedCount = wonInPeriod.length + lostInPeriod.length;
const winRate = closedCount ? wonInPeriod.length / closedCount : 0;
const peopleCreated = people.filter(
(p) => p.createdAt >= start && p.createdAt <= end,
).length;
const peopleCreatedPrev = people.filter(
(p) => p.createdAt >= prevStart && p.createdAt <= prevEnd,
).length;
const salesAmount = invoiceAmount > 0 ? invoiceAmount : wonAmount;
const salesAmountPrev =
invoiceAmountPrev > 0 ? invoiceAmountPrev : wonAmountPrev;
const avgInvoice =
invoiceCount > 0 ? Math.round(salesAmount / invoiceCount) : 0;
const avgInvoicePrev =
invoiceCountPrev > 0
? Math.round(salesAmountPrev / invoiceCountPrev)
: 0;
const avgSaleDenom = Math.max(invoiceCount || wonInPeriod.length, 1);
const avgSale = Math.round(salesAmount / avgSaleDenom);
const avgSalePrev = Math.round(
salesAmountPrev /
Math.max(invoiceCountPrev || wonPrev.length, 1),
);
const changeLabel = 'نسبت به دوره قبل';
const kpis: ReportsDashboardDto['kpis'] = [
{
key: 'new_customers',
label: 'مشتریان جدید',
value: peopleCreated,
format: 'number',
changePercent: pctChange(peopleCreated, peopleCreatedPrev),
changeLabel,
},
{
key: 'invoice_count',
label: 'تعداد فاکتور',
value: invoiceCount,
format: 'number',
changePercent: pctChange(invoiceCount, invoiceCountPrev),
changeLabel,
},
{
key: 'sales_amount',
label: 'مبلغ فروش',
value: salesAmount,
format: 'money',
changePercent: pctChange(salesAmount, salesAmountPrev),
changeLabel,
},
{
key: 'avg_invoice',
label: 'میانگین فاکتور',
value: avgInvoice,
format: 'money',
changePercent: pctChange(avgInvoice, avgInvoicePrev),
changeLabel,
},
{
key: 'avg_sale',
label: 'میانگین فروش',
value: avgSale,
format: 'money',
changePercent: pctChange(avgSale, avgSalePrev),
changeLabel,
},
];
const spanDays =
Math.ceil((end.getTime() - start.getTime()) / 86_400_000) || 1;
const salesTrend: ReportsDashboardDto['salesTrend'] = [];
if (spanDays <= 45) {
for (let i = 0; i < spanDays; i += 1) {
const dayStart = startOfDay(
new Date(start.getFullYear(), start.getMonth(), start.getDate() + i),
);
const dayEnd = endOfDay(dayStart);
if (dayStart > end) break;
const value = Math.round(
sumBase(
wonInPeriod.filter(
(d) => d.updatedAt >= dayStart && d.updatedAt <= dayEnd,
),
),
);
const key = `${dayStart.getFullYear()}-${String(dayStart.getMonth() + 1).padStart(2, '0')}-${String(dayStart.getDate()).padStart(2, '0')}`;
const j = dateToJalali(dayStart);
salesTrend.push({
key,
label: `${j.jd} ${jalaliMonthName(j.jm)}`,
value,
});
}
} else {
const buckets = new Map<
string,
{ label: string; start: Date; end: Date; value: number }
>();
for (const d of wonInPeriod) {
const j = dateToJalali(d.updatedAt);
const key = `${j.jy}-${String(j.jm).padStart(2, '0')}`;
let bucket = buckets.get(key);
if (!bucket) {
bucket = {
label: `${jalaliMonthName(j.jm)} ${j.jy}`,
start: startOfJalaliMonth(j.jy, j.jm),
end: endOfJalaliMonth(j.jy, j.jm),
value: 0,
};
buckets.set(key, bucket);
}
bucket.value += decimalToBase(d.amount, d.currency, rates);
}
const cursor = dateToJalali(start);
let cy = cursor.jy;
let cm = cursor.jm;
const endJ = dateToJalali(end);
while (cy < endJ.jy || (cy === endJ.jy && cm <= endJ.jm)) {
const key = `${cy}-${String(cm).padStart(2, '0')}`;
const existing = buckets.get(key);
salesTrend.push({
key,
label: `${jalaliMonthName(cm)} ${cy}`,
value: Math.round(existing?.value ?? 0),
});
const next = addJalaliMonths(cy, cm, 1);
cy = next.jy;
cm = next.jm;
}
}
const monthlyComparison: ReportsDashboardDto['monthlyComparison'] = [];
const customerGrowth: ReportsDashboardDto['customerGrowth'] = [];
for (let i = 5; i >= 0; i -= 1) {
const m = addJalaliMonths(jy, jm, -i);
const mStart = startOfJalaliMonth(m.jy, m.jm);
const mEnd = endOfJalaliMonth(m.jy, m.jm);
const key = `${m.jy}-${String(m.jm).padStart(2, '0')}`;
const label = jalaliMonthName(m.jm);
monthlyComparison.push({
key,
label,
value: Math.round(
sumBase(
deals.filter(
(d) =>
d.status === 'won' &&
d.updatedAt >= mStart &&
d.updatedAt <= mEnd,
),
),
),
});
customerGrowth.push({
key,
label,
value: people.filter(
(p) => p.createdAt >= mStart && p.createdAt <= mEnd,
).length,
});
}
const sourceMap = new Map<string, number>();
for (const d of wonInPeriod) {
const label = normalizeSource(d.source);
sourceMap.set(label, (sourceMap.get(label) ?? 0) + 1);
}
const sourceTotal =
[...sourceMap.values()].reduce((a, b) => a + b, 0) || 1;
const sources: ReportsDashboardDto['sources'] = [...sourceMap.entries()]
.map(([label, count]) => ({
label,
count,
percent: Math.round((count / sourceTotal) * 1000) / 10,
}))
.sort((a, b) => b.count - a.count);
type LineAgg = {
name: string;
category: string;
quantity: number;
amount: number;
};
const collectLines = (
lines: Array<{
name: string;
quantity: Prisma.Decimal;
unitPrice: Prisma.Decimal;
currency: string;
product: { name: string; category: string | null } | null;
}>,
): LineAgg[] =>
lines.map((line) => {
const qty = Number(line.quantity);
const amount = toBaseAmount(
qty * Number(line.unitPrice),
line.currency,
rates,
);
return {
name: line.product?.name?.trim() || line.name,
category: line.product?.category?.trim() || 'سایر',
quantity: qty,
amount,
};
});
const invoiceProductLines: LineAgg[] = [];
for (const inv of invoicesCur) {
invoiceProductLines.push(...collectLines(inv.lineItems));
}
let productLines = invoiceProductLines;
if (!productLines.length) {
productLines = [];
for (const d of wonInPeriod) {
productLines.push(...collectLines(d.lineItems));
}
}
const categoryMap = new Map<string, { count: number; amount: number }>();
for (const line of invoiceProductLines) {
const cur = categoryMap.get(line.category) ?? { count: 0, amount: 0 };
cur.count += 1;
cur.amount += line.amount;
categoryMap.set(line.category, cur);
}
const categoryTotal =
[...categoryMap.values()].reduce((a, b) => a + b.amount, 0) || 1;
const salesByCategory: ReportsDashboardDto['salesByCategory'] = [
...categoryMap.entries(),
]
.map(([label, row]) => ({
label,
count: row.count,
amount: Math.round(row.amount),
percent: Math.round((row.amount / categoryTotal) * 1000) / 10,
}))
.sort((a, b) => (b.amount ?? 0) - (a.amount ?? 0));
const productMap = new Map<
string,
{ name: string; quantity: number; amount: number }
>();
for (const line of productLines) {
const cur = productMap.get(line.name) ?? {
name: line.name,
quantity: 0,
amount: 0,
};
cur.quantity += line.quantity;
cur.amount += line.amount;
productMap.set(line.name, cur);
}
const topProducts: ReportsDashboardDto['topProducts'] = [
...productMap.values(),
]
.map((row) => ({
name: row.name,
quantity: Math.round(row.quantity * 100) / 100,
amount: Math.round(row.amount),
}))
.sort((a, b) => b.amount - a.amount)
.slice(0, 5);
let statusNew = 0;
let statusActive = 0;
let statusPotential = 0;
let statusInactive = 0;
for (const p of people) {
const isNew = p.createdAt >= start && p.createdAt <= end;
if (isNew) {
statusNew += 1;
} else if (p.mood === 'happy' || p.mood === 'special') {
statusActive += 1;
} else if (p.mood === 'newcomer') {
statusPotential += 1;
} else {
statusInactive += 1;
}
}
const statusTotal = people.length || 1;
const customerStatus: ReportsDashboardDto['customerStatus'] = [
{ label: 'جدید', count: statusNew },
{ label: 'فعال', count: statusActive },
{ label: 'بالقوه', count: statusPotential },
{ label: 'غیرفعال', count: statusInactive },
].map((row) => ({
...row,
percent: Math.round((row.count / statusTotal) * 1000) / 10,
}));
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 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;
const insights: string[] = [];
const hasData =
peopleCreated > 0 ||
wonAmount > 0 ||
invoiceAmount > 0 ||
invoiceCount > 0 ||
wonInPeriod.length > 0;
if (hasData) {
const salesChange = pctChange(salesAmount, salesAmountPrev);
if (salesChange != null) {
if (salesChange > 0) {
insights.push(
`مبلغ فروش نسبت به دوره قبل ${salesChange}٪ افزایش داشته است.`,
);
} else if (salesChange < 0) {
insights.push(
`مبلغ فروش نسبت به دوره قبل ${Math.abs(salesChange)}٪ کاهش داشته است.`,
);
} else {
insights.push('مبلغ فروش نسبت به دوره قبل تغییری نداشته است.');
}
}
if (peopleCreated > 0) {
insights.push(
`${peopleCreated} مشتری جدید در این بازه ثبت شده است.`,
);
}
if (topProducts[0]) {
insights.push(
`پرفروش‌ترین محصول «${topProducts[0].name}» با مبلغ ${topProducts[0].amount.toLocaleString('fa-IR')} است.`,
);
}
if (wonInPeriod.length + lostInPeriod.length > 0) {
insights.push(
`نرخ برد معاملات در این دوره ${Math.round(winRate * 1000) / 10}٪ بوده است.`,
);
}
if (invoiceCount > 0) {
insights.push(
`${invoiceCount} فاکتور صادر/پرداخت‌شده با مجموع ${invoiceAmount.toLocaleString('fa-IR')} ثبت شده است.`,
);
}
if (sources[0] && sources[0].count > 0) {
insights.push(
`بیشترین منبع معاملات برنده «${sources[0].label}» بوده است.`,
);
}
}
return {
from: start.toISOString(),
to: end.toISOString(),
currency: rates.baseCurrency,
kpis,
salesTrend,
monthlyComparison,
customerGrowth,
sources,
salesByCategory,
topProducts,
customerStatus,
activities: {
open: activityOpen,
done: activityDone,
overdue: activityOverdue,
byType: [...byTypeMap.entries()].map(([type, count]) => ({
type,
label: activityTypeLabel(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),
insights: insights.slice(0, 5),
sales: {
openCount: openDeals.length,
wonCount: wonInPeriod.length,
lostCount: lostInPeriod.length,
openAmount: Math.round(sumBase(openDeals)),
wonAmount,
lostAmount: Math.round(sumBase(lostInPeriod)),
winRate: Math.round(winRate * 1000) / 10,
},
funnel,
tickets: {
open: ticketOpen,
resolved: ticketResolved,
closed: ticketClosed,
slaBreached,
avgCsat: avgCsat == null ? null : Math.round(avgCsat * 10) / 10,
csatCount: csatScores.length,
},
peopleCreated,
invoiceCount,
invoiceAmount,
};
}
async home(actor: Actor): Promise<HomeDashboardDto> {
const orgId = actor.organizationId;
const dealScope = ownerWhere(actor);
const activityScope = assigneeWhere(actor);
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId };
const rates = await loadOrgRates(this.prisma, orgId);
const now = new Date();
const { jy, jm } = dateToJalali(now);
const curMonthStart = startOfJalaliMonth(jy, jm);
const curMonthEnd = endOfJalaliMonth(jy, jm);
const prev = addJalaliMonths(jy, jm, -1);
const prevMonthStart = startOfJalaliMonth(prev.jy, prev.jm);
const prevMonthEnd = endOfJalaliMonth(prev.jy, prev.jm);
const todayStart = startOfDay(now);
const todayEnd = endOfDay(now);
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStart = startOfDay(yesterday);
const yesterdayEnd = endOfDay(yesterday);
const weekAgo = new Date(now);
weekAgo.setDate(weekAgo.getDate() - 7);
const [
wonDeals,
openDeals,
staleDeals,
peopleCur,
peoplePrev,
openActivitiesToday,
openActivitiesYesterday,
openDealsWeekAgo,
goals,
overdueCount,
] = await Promise.all([
this.prisma.deal.findMany({
where: {
organizationId: orgId,
status: 'won',
...dealScope,
updatedAt: { gte: addMonthsStart(now, -5) },
},
select: {
amount: true,
currency: true,
updatedAt: true,
source: true,
companyId: true,
personId: true,
company: { select: { id: true, name: true } },
person: { select: { id: true, firstName: true, lastName: true } },
},
}),
this.prisma.deal.count({
where: { organizationId: orgId, status: 'open', ...dealScope },
}),
this.prisma.deal.findMany({
where: {
organizationId: orgId,
status: 'open',
...dealScope,
OR: [
{ isStale: true },
{ isRotten: true },
{ updatedAt: { lt: daysAgo(5) } },
],
},
select: {
id: true,
title: true,
company: { select: { name: true } },
person: { select: { firstName: true, lastName: true } },
},
orderBy: { updatedAt: 'asc' },
take: 5,
}),
this.prisma.person.count({
where: {
organizationId: orgId,
...personScope,
createdAt: { gte: curMonthStart, lte: curMonthEnd },
},
}),
this.prisma.person.count({
where: {
organizationId: orgId,
...personScope,
createdAt: { gte: prevMonthStart, lte: prevMonthEnd },
},
}),
this.prisma.activity.count({
where: {
organizationId: orgId,
...activityScope,
status: 'open',
dueAt: { gte: todayStart, lte: todayEnd },
},
}),
this.prisma.activity.count({
where: {
organizationId: orgId,
...activityScope,
dueAt: { gte: yesterdayStart, lte: yesterdayEnd },
},
}),
this.prisma.deal.count({
where: {
organizationId: orgId,
status: 'open',
...dealScope,
updatedAt: { lte: weekAgo },
},
}),
this.prisma.salesGoal.findMany({
where: {
organizationId: orgId,
periodStart: { lte: curMonthEnd },
periodEnd: { gte: curMonthStart },
},
select: { targetAmount: true },
}),
this.prisma.activity.count({
where: {
organizationId: orgId,
...activityScope,
status: 'open',
dueAt: { lt: now },
},
}),
]);
const sumWonInRange = (start: Date, end: Date) =>
wonDeals
.filter((d) => d.updatedAt >= start && d.updatedAt <= end)
.reduce(
(acc, d) => acc + decimalToBase(d.amount, d.currency, rates),
0,
);
const salesCur = sumWonInRange(curMonthStart, curMonthEnd);
const salesPrev = sumWonInRange(prevMonthStart, prevMonthEnd);
const targetAmount = goals.reduce(
(acc, g) => acc + Number(g.targetAmount),
0,
);
const trendMonths: Array<{ key: string; label: string; start: Date; end: Date }> =
[];
for (let i = 4; i >= 0; i -= 1) {
const m = addJalaliMonths(jy, jm, -i);
trendMonths.push({
key: `${m.jy}-${String(m.jm).padStart(2, '0')}`,
label: jalaliMonthName(m.jm),
start: startOfJalaliMonth(m.jy, m.jm),
end: endOfJalaliMonth(m.jy, m.jm),
});
}
const salesTrend = trendMonths.map((m) => ({
monthKey: m.key,
monthLabel: m.label,
amount: Math.round(sumWonInRange(m.start, m.end)),
}));
const sourceMap = new Map<string, number>();
for (const d of wonDeals) {
const label = normalizeSource(d.source);
sourceMap.set(label, (sourceMap.get(label) ?? 0) + 1);
}
const sourceTotal = [...sourceMap.values()].reduce((a, b) => a + b, 0) || 1;
const sources = [...sourceMap.entries()]
.map(([label, count]) => ({
label,
count,
percent: Math.round((count / sourceTotal) * 1000) / 10,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 6);
const customerMap = new Map<
string,
{ name: string; dealCount: number }
>();
for (const d of wonDeals) {
if (d.company) {
const cur = customerMap.get(`c:${d.company.id}`) ?? {
name: d.company.name,
dealCount: 0,
};
cur.dealCount += 1;
customerMap.set(`c:${d.company.id}`, cur);
} else if (d.person) {
const name = `${d.person.firstName} ${d.person.lastName}`.trim();
const cur = customerMap.get(`p:${d.person.id}`) ?? {
name,
dealCount: 0,
};
cur.dealCount += 1;
customerMap.set(`p:${d.person.id}`, cur);
}
}
const topCustomers = [...customerMap.entries()]
.map(([id, row]) => ({
id: id.slice(2),
name: row.name,
dealCount: row.dealCount,
rank: 0,
}))
.sort((a, b) => b.dealCount - a.dealCount)
.slice(0, 5)
.map((row, index) => ({ ...row, rank: index + 1 }));
const alerts: HomeDashboardDto['alerts'] = [];
if (staleDeals.length) {
const first = staleDeals[0]!;
const who =
first.company?.name ??
(first.person
? `${first.person.firstName} ${first.person.lastName}`.trim()
: first.title);
alerts.push({
key: 'stale-deal',
tone: 'urgent',
text: `${who} در مرحله تصمیم‌گیری است — امروز تماس بگیرید`,
link: '/pipeline',
});
}
if (staleDeals.length > 1) {
alerts.push({
key: 'stale-count',
tone: 'warning',
text: `${staleDeals.length} معامله بیش از ۵ روز پیگیری نشده`,
link: '/pipeline',
});
} else if (overdueCount > 0) {
alerts.push({
key: 'overdue',
tone: 'warning',
text: `${overdueCount} پیگیری عقب‌افتاده دارید`,
link: '/activities',
});
}
if (targetAmount > 0 && salesCur >= targetAmount) {
alerts.push({
key: 'target-ahead',
tone: 'success',
text: 'این ماه از تارگت جلوتر هستید',
link: '/sales/forecast',
});
} else if (targetAmount > 0 && salesCur >= targetAmount * 0.8) {
alerts.push({
key: 'target-close',
tone: 'info',
text: 'به تارگت ماه نزدیک شده‌اید',
link: '/sales/forecast',
});
}
return {
currency: rates.baseCurrency,
kpis: [
{
key: 'sales',
label: 'فروش این ماه',
value: Math.round(salesCur),
changePercent: pctChange(salesCur, salesPrev),
changeLabel: 'نسبت به ماه قبل',
},
{
key: 'customers',
label: 'مشتریان جدید',
value: peopleCur,
changePercent: pctChange(peopleCur, peoplePrev),
changeLabel: 'نسبت به ماه قبل',
},
{
key: 'open-deals',
label: 'معاملات باز',
value: openDeals,
changePercent: pctChange(openDeals, openDealsWeekAgo || openDeals),
changeLabel: 'نسبت به هفته قبل',
},
{
key: 'followups',
label: 'پیگیری امروز',
value: openActivitiesToday,
changePercent: pctChange(openActivitiesToday, openActivitiesYesterday),
changeLabel: 'نسبت به دیروز',
},
],
alerts,
salesTrend,
sources,
topCustomers,
};
}
}
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);
}
function daysAgo(n: number): Date {
const d = new Date();
d.setDate(d.getDate() - n);
return d;
}
function addMonthsStart(from: Date, deltaMonths: number): Date {
const { jy, jm } = dateToJalali(from);
const m = addJalaliMonths(jy, jm, deltaMonths);
return startOfJalaliMonth(m.jy, m.jm);
}
function pctChange(current: number, previous: number): number | null {
if (previous <= 0) return current > 0 ? 100 : null;
return Math.round(((current - previous) / previous) * 1000) / 10;
}
function normalizeSource(raw: string | null | undefined): string {
const value = raw?.trim();
if (!value) return 'سایر';
const map: Record<string, string> = {
referral: 'معرفی دوستان',
website: 'وب‌سایت',
social: 'شبکه‌های اجتماعی',
ads: 'تبلیغات',
instagram: 'شبکه‌های اجتماعی',
google: 'تبلیغات',
};
return map[value.toLowerCase()] ?? value;
}
function activityTypeLabel(type: string): string {
const map: Record<string, string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'وظیفه',
note: 'یادداشت',
};
return map[type] ?? type;
}