Add Didar-style conversion rate to sales reports.

Show won-over-incoming deals for the selected period with time, user, and source breakdowns alongside the existing win-rate metric.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-27 15:36:34 +03:30
co-authored by Cursor
parent c5c903d4b4
commit 22980e5152
5 changed files with 467 additions and 1 deletions
+163
View File
@@ -84,6 +84,7 @@ export class ReportsService {
status: { in: ['won', 'lost'] },
updatedAt: { gte: dealEarliest },
},
{ createdAt: { gte: dealEarliest } },
],
},
select: {
@@ -658,6 +659,154 @@ export class ReportsService {
const winRatePct = Math.round(winRate * 1000) / 10;
const winRatePrevPct = Math.round(winRatePrev * 1000) / 10;
const incomingCur = deals.filter(
(d) => d.createdAt >= start && d.createdAt <= end,
);
const incomingPrev = deals.filter(
(d) => d.createdAt >= prevStart && d.createdAt <= prevEnd,
);
const convWonCur = incomingCur.filter((d) => d.status === 'won').length;
const convWonPrev = incomingPrev.filter((d) => d.status === 'won').length;
const conversionPercent = incomingCur.length
? Math.round((convWonCur / incomingCur.length) * 1000) / 10
: 0;
const previousConversionPercent = incomingPrev.length
? Math.round((convWonPrev / incomingPrev.length) * 1000) / 10
: 0;
const conversionByTime: ReportsDashboardDto['conversionRate']['byTime'] =
[];
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 dayIncoming = incomingCur.filter(
(d) => d.createdAt >= dayStart && d.createdAt <= dayEnd,
);
const dayWon = dayIncoming.filter((d) => d.status === 'won').length;
const j = dateToJalali(dayStart);
const key = `${dayStart.getFullYear()}-${String(dayStart.getMonth() + 1).padStart(2, '0')}-${String(dayStart.getDate()).padStart(2, '0')}`;
conversionByTime.push({
key,
label: `${j.jd} ${jalaliMonthName(j.jm)}`,
incomingCount: dayIncoming.length,
wonCount: dayWon,
conversionPercent: dayIncoming.length
? Math.round((dayWon / dayIncoming.length) * 1000) / 10
: 0,
});
}
} else {
const buckets = new Map<
string,
{ label: string; incoming: number; won: number }
>();
for (const d of incomingCur) {
const j = dateToJalali(d.createdAt);
const key = `${j.jy}-${String(j.jm).padStart(2, '0')}`;
let bucket = buckets.get(key);
if (!bucket) {
bucket = {
label: `${jalaliMonthName(j.jm)} ${j.jy}`,
incoming: 0,
won: 0,
};
buckets.set(key, bucket);
}
bucket.incoming += 1;
if (d.status === 'won') bucket.won += 1;
}
for (const [key, b] of [...buckets.entries()].sort((a, b) =>
a[0].localeCompare(b[0]),
)) {
conversionByTime.push({
key,
label: b.label,
incomingCount: b.incoming,
wonCount: b.won,
conversionPercent: b.incoming
? Math.round((b.won / b.incoming) * 1000) / 10
: 0,
});
}
}
const conversionByUserMap = new Map<
string,
{ label: string; incoming: number; won: number }
>();
for (const u of users) {
conversionByUserMap.set(u.id, {
label: u.fullName,
incoming: 0,
won: 0,
});
}
for (const d of incomingCur) {
if (!d.ownerId) continue;
const row = conversionByUserMap.get(d.ownerId);
if (!row) continue;
row.incoming += 1;
if (d.status === 'won') row.won += 1;
}
const conversionByUser: ReportsDashboardDto['conversionRate']['byUser'] = [
...conversionByUserMap.entries(),
]
.map(([key, row]) => ({
key,
label: row.label,
incomingCount: row.incoming,
wonCount: row.won,
conversionPercent: row.incoming
? Math.round((row.won / row.incoming) * 1000) / 10
: 0,
}))
.filter((r) => r.incomingCount > 0)
.sort((a, b) => b.conversionPercent - a.conversionPercent);
const conversionBySourceMap = new Map<
string,
{ incoming: number; won: number }
>();
for (const d of incomingCur) {
const label = normalizeSource(d.source);
const row = conversionBySourceMap.get(label) ?? {
incoming: 0,
won: 0,
};
row.incoming += 1;
if (d.status === 'won') row.won += 1;
conversionBySourceMap.set(label, row);
}
const conversionBySource: ReportsDashboardDto['conversionRate']['bySource'] =
[...conversionBySourceMap.entries()]
.map(([label, row]) => ({
key: label,
label,
incomingCount: row.incoming,
wonCount: row.won,
conversionPercent: row.incoming
? Math.round((row.won / row.incoming) * 1000) / 10
: 0,
}))
.sort((a, b) => b.incomingCount - a.incomingCount);
const conversionRate: ReportsDashboardDto['conversionRate'] = {
incomingCount: incomingCur.length,
wonCount: convWonCur,
conversionPercent,
previousIncomingCount: incomingPrev.length,
previousWonCount: convWonPrev,
previousConversionPercent,
changePercent: pctChange(conversionPercent, previousConversionPercent),
byTime: conversionByTime,
byUser: conversionByUser,
bySource: conversionBySource,
};
const periodCompare: ReportsPeriodCompareRowDto[] = [
{
key: 'sales_amount',
@@ -694,6 +843,14 @@ export class ReportsService {
previous: winRatePrevPct,
changePercent: pctChange(winRatePct, winRatePrevPct),
},
{
key: 'conversion_rate',
label: 'نرخ تبدیل',
format: 'percent',
current: conversionPercent,
previous: previousConversionPercent,
changePercent: pctChange(conversionPercent, previousConversionPercent),
},
{
key: 'new_customers',
label: 'مشتریان جدید',
@@ -770,6 +927,11 @@ export class ReportsService {
`نرخ برد معاملات در این دوره ${winRatePct}٪ بوده است.`,
);
}
if (incomingCur.length > 0) {
insights.push(
`نرخ تبدیل ورودی‌ها ${conversionPercent}٪ است (${convWonCur} از ${incomingCur.length} معامله).`,
);
}
if (invoiceCount > 0) {
insights.push(
`${invoiceCount} فاکتور صادر/پرداخت‌شده با مجموع ${invoiceAmount.toLocaleString('fa-IR')} ثبت شده است.`,
@@ -837,6 +999,7 @@ export class ReportsService {
winRate: winRatePct,
},
funnel,
conversionRate,
periodCompare,
tickets: {
open: ticketOpen,