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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user