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,
+272
View File
@@ -712,6 +712,25 @@ function exportReport() {
...d.team.map(
(t) => `${t.fullName},${t.activitiesDone},${t.wonAmount}`,
),
'',
'نرخ تبدیل (ورودی به موفق)',
`کل,${d.conversionRate?.conversionPercent ?? ''}٪`,
`ورودی,${d.conversionRate?.incomingCount ?? 0}`,
`موفق,${d.conversionRate?.wonCount ?? 0}`,
'',
'نرخ تبدیل بر اساس زمان',
'بازه,ورودی,موفق,نرخ٪',
...(d.conversionRate?.byTime ?? []).map(
(r) =>
`${r.label},${r.incomingCount},${r.wonCount},${r.conversionPercent}`,
),
'',
'نرخ تبدیل بر اساس کاربر',
'کاربر,ورودی,موفق,نرخ٪',
...(d.conversionRate?.byUser ?? []).map(
(r) =>
`${r.label},${r.incomingCount},${r.wonCount},${r.conversionPercent}`,
),
];
const blob = new Blob(['\ufeff' + lines.join('\n')], {
type: 'text/csv;charset=utf-8',
@@ -879,6 +898,86 @@ const monthlyOption = computed<UiChartOption>(() => {
};
});
const conversionTimeOption = computed<UiChartOption>(() => {
const rows = data.value?.conversionRate?.byTime ?? [];
return {
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const list = Array.isArray(params) ? params : [params];
const name = list[0]?.name ?? '';
const row = rows.find((r) => r.label === name);
if (!row) return name;
return `${name}<br/>نرخ تبدیل: <strong>${toFaDigits(row.conversionPercent)}٪</strong><br/>موفق: ${num(row.wonCount)} از ${num(row.incomingCount)}`;
},
},
grid: { left: '3%', right: '4%', bottom: '8%', top: '14%', containLabel: true },
legend: {
top: 0,
textStyle: { fontFamily: 'IRANSans', fontSize: 11, color: '#475569' },
},
xAxis: {
type: 'category',
data: rows.map((r) => r.label),
axisLine: { lineStyle: { color: '#cbd5e1' } },
axisLabel: { color: '#64748b', fontFamily: 'IRANSans' },
},
yAxis: [
{
type: 'value',
name: 'تعداد',
axisLine: { show: false },
splitLine: { lineStyle: { color: '#f1f5f9', type: 'dashed' } },
axisLabel: {
color: '#64748b',
fontFamily: 'IRANSans',
formatter: (v: number) => num(v),
},
},
{
type: 'value',
name: 'نرخ ٪',
min: 0,
max: 100,
axisLine: { show: false },
splitLine: { show: false },
axisLabel: {
color: '#64748b',
fontFamily: 'IRANSans',
formatter: (v: number) => `${toFaDigits(v)}٪`,
},
},
],
series: [
{
name: 'ورودی',
type: 'bar',
barMaxWidth: 28,
itemStyle: { color: '#94a3b8', borderRadius: [6, 6, 0, 0] },
data: rows.map((r) => r.incomingCount),
},
{
name: 'موفق',
type: 'bar',
barMaxWidth: 28,
itemStyle: { color: '#14b8a6', borderRadius: [6, 6, 0, 0] },
data: rows.map((r) => r.wonCount),
},
{
name: 'نرخ تبدیل',
type: 'line',
yAxisIndex: 1,
smooth: true,
showSymbol: true,
symbolSize: 7,
itemStyle: { color: '#0f766e' },
lineStyle: { width: 2, color: '#0f766e' },
data: rows.map((r) => r.conversionPercent),
},
],
};
});
const topProductsOption = computed<UiChartOption>(() => {
const prods = (data.value?.topProducts ?? []).slice(0, 5).reverse();
return {
@@ -1386,6 +1485,113 @@ const activityMax = computed(() =>
</UiCard>
</div>
<div class="deep-grid conversion-grid">
<UiCard class="chart-card span-2" title="نرخ تبدیل">
<p class="deep-hint">
از معاملات تازه‌ثبت‌شده در این بازه، چند درصد تا الان به فروش موفق رسیده‌اند
(متفاوت از «نرخ برد» که فقط معاملات بسته‌شده را می‌بیند).
</p>
<div
v-if="data.conversionRate"
class="conversion-summary"
>
<div class="conversion-stat">
<span>نرخ تبدیل</span>
<strong>
{{ toFaDigits(data.conversionRate.conversionPercent) }}٪
</strong>
<small>
{{ formatChange(data.conversionRate.changePercent) }} نسبت به دوره قبل
</small>
</div>
<div class="conversion-stat">
<span>ورودی دوره</span>
<strong>{{ num(data.conversionRate.incomingCount) }}</strong>
<small>
دوره قبل {{ num(data.conversionRate.previousIncomingCount) }}
</small>
</div>
<div class="conversion-stat">
<span>موفق از همین ورودی‌ها</span>
<strong>{{ num(data.conversionRate.wonCount) }}</strong>
<small>
دوره قبل {{ num(data.conversionRate.previousWonCount) }}
</small>
</div>
</div>
<UiChart
v-if="data.conversionRate?.byTime?.length"
:option="conversionTimeOption"
height="240px"
/>
<p v-else class="empty-msg">در این بازه معامله ورودی ثبت نشده است.</p>
</UiCard>
<UiCard class="chart-card" title="نرخ تبدیل بر اساس کاربر">
<p class="deep-hint">
کیفیت تبدیل فرصت‌های هر فروشنده در همین بازه.
</p>
<ul
v-if="data.conversionRate?.byUser?.length"
class="conversion-list"
>
<li
v-for="u in data.conversionRate.byUser.slice(0, 10)"
:key="u.key"
>
<div class="conversion-list-top">
<strong>{{ u.label }}</strong>
<em>{{ toFaDigits(u.conversionPercent) }}٪</em>
</div>
<div class="hb-track">
<div
class="hb-fill"
:style="{
width: `${Math.max(4, Math.min(100, u.conversionPercent))}%`,
}"
/>
</div>
<small>
{{ num(u.wonCount) }} موفق از {{ num(u.incomingCount) }} ورودی
</small>
</li>
</ul>
<p v-else class="empty-msg">داده کاربری نیست.</p>
</UiCard>
<UiCard class="chart-card" title="نرخ تبدیل بر اساس منبع">
<p class="deep-hint">
کدام شیوه آشنایی / منبع ورودی بهتر به فروش می‌رسد.
</p>
<ul
v-if="data.conversionRate?.bySource?.length"
class="conversion-list"
>
<li
v-for="s in data.conversionRate.bySource.slice(0, 10)"
:key="s.key"
>
<div class="conversion-list-top">
<strong>{{ s.label }}</strong>
<em>{{ toFaDigits(s.conversionPercent) }}٪</em>
</div>
<div class="hb-track">
<div
class="hb-fill"
:style="{
width: `${Math.max(4, Math.min(100, s.conversionPercent))}%`,
}"
/>
</div>
<small>
{{ num(s.wonCount) }} موفق از {{ num(s.incomingCount) }} ورودی
</small>
</li>
</ul>
<p v-else class="empty-msg">منبعی ثبت نشده است.</p>
</UiCard>
</div>
<div class="charts-grid">
<!-- Sales trend (wide) -->
<UiCard class="chart-card span-2" title="روند فروش">
@@ -1757,6 +1963,68 @@ const activityMax = computed(() =>
line-height: 1.6;
}
.conversion-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--sp-3);
margin-bottom: var(--sp-4);
}
.conversion-stat {
display: grid;
gap: 0.25rem;
padding: 0.75rem 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-subtle, #f8fafc);
}
.conversion-stat span {
font-size: var(--fs-xs);
color: var(--muted);
}
.conversion-stat strong {
font-size: 1.35rem;
color: var(--accent-deep, #0f766e);
}
.conversion-stat small {
font-size: var(--fs-xs);
color: var(--ink-secondary);
}
.conversion-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.75rem;
}
.conversion-list li {
display: grid;
gap: 0.3rem;
}
.conversion-list-top {
display: flex;
justify-content: space-between;
gap: 0.5rem;
align-items: baseline;
}
.conversion-list-top em {
font-style: normal;
font-weight: 700;
color: var(--accent-deep, #0f766e);
}
.conversion-list small {
color: var(--muted);
font-size: var(--fs-xs);
}
.funnel-wrap {
display: grid;
gap: var(--sp-4);
@@ -1939,6 +2207,10 @@ const activityMax = computed(() =>
.deep-grid {
grid-template-columns: 1fr;
}
.conversion-summary {
grid-template-columns: 1fr;
}
}
.filter-toolbar {
+1
View File
@@ -210,6 +210,7 @@ AI پیشنهاد Next Action می‌دهد؛ اعمال فقط با تأیید
### گزارشات و داشبورد تحلیلی
فروش، قیف، فعالیت، تیم و تیکت با فیلتر تاریخ.
نرخ تبدیل (سبک دیدار): از معاملات تازه‌وارد چند درصد موفق شده‌اند — به‌تفکیک زمان، کاربر و منبع.
### تنظیمات تیم
دعوت کاربر و مدیریت نقش‌ها.
+2 -1
View File
@@ -14,6 +14,7 @@
| تاریخ | مورد |
|---|---|
| ۱۴۰۵/۷/۵ | نرخ تبدیل گزارشات (سبک دیدار): ورودی→موفق + محور زمان/کاربر/منبع |
| ۱۴۰۵/۷/۵ | خلاصه خودکار تماس VoIP (ضبط/متن → STT → AI → یادداشت call_result) |
| ۱۴۰۵/۷/۵ | هم‌ترازی چک‌لیست: API عمومی/وب‌هوک، پیامک گروهی، ووکامرس، VoIP؛ درگاه پرداخت عمداً نه |
| ۱۴۰۵/۷/۵ | چند مسئول روی معاملهٔ کاریز + ذخیره فیلتر در اشخاص و کاریز |
@@ -113,7 +114,7 @@
| وضعیت | مورد | توضیح |
|---|---|---|
| [x] | گزارشات و داشبورد | فروش/قیف/فعالیت/تیم/تیکت + فیلتر تاریخ |
| [x] | گزارشات و داشبورد | فروش/قیف/فعالیت/تیم/تیکت + فیلتر تاریخ + نرخ تبدیل ورودی→موفق |
| [x] | اتوماسیون اگر/آنگاه | deal_won / ticket_resolved → فعالیت، اعلان، امتیاز |
| [x] | SMS عملیاتی | OTP auth/پرتال؛ یادآور فعالیت (ترِز) |
| [x] | API عمومی + وب‌هوک خروجی | کلید API؛ `public/v1` افراد/معاملات؛ endpoint وب‌هوک + تحویل دوره‌ای |
+29
View File
@@ -2155,6 +2155,33 @@ export interface ReportsFunnelStageDto {
shareOfPipelinePercent: number;
}
/** یک ردیف نرخ تبدیل (ورودی → موفق) مثل دیدار */
export interface ReportsConversionRateRowDto {
key: string;
label: string;
incomingCount: number;
wonCount: number;
/** درصد؛ اگر ورودی صفر باشد ۰ */
conversionPercent: number;
}
/**
* نرخ تبدیل به سبک دیدار:
* از معاملات تازه‌ساخته‌شده در بازه، چند درصد الان برنده شده‌اند.
*/
export interface ReportsConversionRateDto {
incomingCount: number;
wonCount: number;
conversionPercent: number;
previousIncomingCount: number;
previousWonCount: number;
previousConversionPercent: number;
changePercent: number | null;
byTime: ReportsConversionRateRowDto[];
byUser: ReportsConversionRateRowDto[];
bySource: ReportsConversionRateRowDto[];
}
export interface ReportsPeriodCompareRowDto {
key: string;
label: string;
@@ -2219,6 +2246,8 @@ export interface ReportsDashboardDto {
winRate: number;
};
funnel: ReportsFunnelStageDto[];
/** نرخ تبدیل ورودی→موفق (سبک دیدار) */
conversionRate: ReportsConversionRateDto;
/** مقایسه صریح بازه فعلی با دوره قبل هم‌طول */
periodCompare: ReportsPeriodCompareRowDto[];
tickets: {