From 22980e5152a6ba5e009d38bc5a52ba97b8b519c9 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sun, 27 Sep 2026 15:36:34 +0330 Subject: [PATCH] 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 --- apps/api/src/reports/reports.service.ts | 163 ++++++++++++++ apps/web/src/views/ReportsView.vue | 272 ++++++++++++++++++++++++ docs/قابلیت‌های-منتکت.md | 1 + docs/وضعیت-پیشرفت.md | 3 +- packages/shared/src/index.ts | 29 +++ 5 files changed, 467 insertions(+), 1 deletion(-) diff --git a/apps/api/src/reports/reports.service.ts b/apps/api/src/reports/reports.service.ts index e12b084..73a1959 100644 --- a/apps/api/src/reports/reports.service.ts +++ b/apps/api/src/reports/reports.service.ts @@ -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, diff --git a/apps/web/src/views/ReportsView.vue b/apps/web/src/views/ReportsView.vue index 2461dc9..7350a19 100644 --- a/apps/web/src/views/ReportsView.vue +++ b/apps/web/src/views/ReportsView.vue @@ -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(() => { }; }); +const conversionTimeOption = computed(() => { + 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}
نرخ تبدیل: ${toFaDigits(row.conversionPercent)}٪
موفق: ${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(() => { const prods = (data.value?.topProducts ?? []).slice(0, 5).reverse(); return { @@ -1386,6 +1485,113 @@ const activityMax = computed(() => +
+ +

+ از معاملات تازه‌ثبت‌شده در این بازه، چند درصد تا الان به فروش موفق رسیده‌اند + (متفاوت از «نرخ برد» که فقط معاملات بسته‌شده را می‌بیند). +

+
+
+ نرخ تبدیل + + {{ toFaDigits(data.conversionRate.conversionPercent) }}٪ + + + {{ formatChange(data.conversionRate.changePercent) }} نسبت به دوره قبل + +
+
+ ورودی دوره + {{ num(data.conversionRate.incomingCount) }} + + دوره قبل {{ num(data.conversionRate.previousIncomingCount) }} + +
+
+ موفق از همین ورودی‌ها + {{ num(data.conversionRate.wonCount) }} + + دوره قبل {{ num(data.conversionRate.previousWonCount) }} + +
+
+ +

در این بازه معامله ورودی ثبت نشده است.

+
+ + +

+ کیفیت تبدیل فرصت‌های هر فروشنده در همین بازه. +

+
    +
  • +
    + {{ u.label }} + {{ toFaDigits(u.conversionPercent) }}٪ +
    +
    +
    +
    + + {{ num(u.wonCount) }} موفق از {{ num(u.incomingCount) }} ورودی + +
  • +
+

داده کاربری نیست.

+
+ + +

+ کدام شیوه آشنایی / منبع ورودی بهتر به فروش می‌رسد. +

+
    +
  • +
    + {{ s.label }} + {{ toFaDigits(s.conversionPercent) }}٪ +
    +
    +
    +
    + + {{ num(s.wonCount) }} موفق از {{ num(s.incomingCount) }} ورودی + +
  • +
+

منبعی ثبت نشده است.

+
+
+
@@ -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 { diff --git a/docs/قابلیت‌های-منتکت.md b/docs/قابلیت‌های-منتکت.md index 8ca77a6..bdb5dd7 100644 --- a/docs/قابلیت‌های-منتکت.md +++ b/docs/قابلیت‌های-منتکت.md @@ -210,6 +210,7 @@ AI پیشنهاد Next Action می‌دهد؛ اعمال فقط با تأیید ### گزارشات و داشبورد تحلیلی فروش، قیف، فعالیت، تیم و تیکت با فیلتر تاریخ. +نرخ تبدیل (سبک دیدار): از معاملات تازه‌وارد چند درصد موفق شده‌اند — به‌تفکیک زمان، کاربر و منبع. ### تنظیمات تیم دعوت کاربر و مدیریت نقش‌ها. diff --git a/docs/وضعیت-پیشرفت.md b/docs/وضعیت-پیشرفت.md index 08c0ff9..bfb135b 100644 --- a/docs/وضعیت-پیشرفت.md +++ b/docs/وضعیت-پیشرفت.md @@ -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 وب‌هوک + تحویل دوره‌ای | diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6d4a56b..6e9b273 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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: {