complete story 6.7 with QA tests and generated epic 10 to fix the branding colors
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\ConsultationStatus;
|
||||
use App\Enums\ConsultationType;
|
||||
use App\Enums\PostStatus;
|
||||
use App\Enums\TimelineStatus;
|
||||
use App\Enums\UserStatus;
|
||||
use App\Enums\UserType;
|
||||
use App\Models\Consultation;
|
||||
use App\Models\Post;
|
||||
use App\Models\Timeline;
|
||||
use App\Models\TimelineUpdate;
|
||||
use App\Models\User;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class MonthlyReportService
|
||||
{
|
||||
public function generate(int $year, int $month): StreamedResponse
|
||||
{
|
||||
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
|
||||
$endDate = $startDate->copy()->endOfMonth();
|
||||
$locale = Auth::user()->preferred_language ?? 'en';
|
||||
|
||||
$data = [
|
||||
'period' => $startDate->translatedFormat('F Y'),
|
||||
'periodMonth' => $startDate->translatedFormat('F'),
|
||||
'periodYear' => $year,
|
||||
'generatedAt' => now()->translatedFormat('d M Y H:i'),
|
||||
'locale' => $locale,
|
||||
'userStats' => $this->getUserStats($startDate, $endDate),
|
||||
'consultationStats' => $this->getConsultationStats($startDate, $endDate),
|
||||
'timelineStats' => $this->getTimelineStats($startDate, $endDate),
|
||||
'postStats' => $this->getPostStats($startDate, $endDate),
|
||||
'charts' => $this->renderChartsAsImages($startDate, $endDate, $locale),
|
||||
'previousMonth' => $this->getPreviousMonthComparison($startDate),
|
||||
'executiveSummary' => $this->generateExecutiveSummary($startDate, $endDate, $locale),
|
||||
];
|
||||
|
||||
$pdf = Pdf::loadView('pdf.monthly-report', $data)
|
||||
->setPaper('a4', 'portrait');
|
||||
|
||||
$pdf->setOption('isHtml5ParserEnabled', true);
|
||||
$pdf->setOption('defaultFont', 'DejaVu Sans');
|
||||
|
||||
$filename = "monthly-report-{$year}-{$month}.pdf";
|
||||
|
||||
return response()->streamDownload(
|
||||
fn () => print ($pdf->output()),
|
||||
$filename
|
||||
);
|
||||
}
|
||||
|
||||
public function getUserStats(Carbon $start, Carbon $end): array
|
||||
{
|
||||
$newClients = User::query()
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->whereIn('user_type', [UserType::Individual, UserType::Company])
|
||||
->count();
|
||||
|
||||
$totalActive = User::query()
|
||||
->where('status', UserStatus::Active)
|
||||
->where('created_at', '<=', $end)
|
||||
->whereIn('user_type', [UserType::Individual, UserType::Company])
|
||||
->count();
|
||||
|
||||
$individual = User::query()
|
||||
->where('user_type', UserType::Individual)
|
||||
->where('status', UserStatus::Active)
|
||||
->where('created_at', '<=', $end)
|
||||
->count();
|
||||
|
||||
$company = User::query()
|
||||
->where('user_type', UserType::Company)
|
||||
->where('status', UserStatus::Active)
|
||||
->where('created_at', '<=', $end)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'new_clients' => $newClients,
|
||||
'total_active' => $totalActive,
|
||||
'individual' => $individual,
|
||||
'company' => $company,
|
||||
];
|
||||
}
|
||||
|
||||
public function getConsultationStats(Carbon $start, Carbon $end): array
|
||||
{
|
||||
$total = Consultation::query()
|
||||
->whereBetween('booking_date', [$start, $end])
|
||||
->count();
|
||||
|
||||
$completed = Consultation::query()
|
||||
->whereBetween('booking_date', [$start, $end])
|
||||
->whereIn('status', [ConsultationStatus::Completed, ConsultationStatus::NoShow])
|
||||
->count();
|
||||
|
||||
$noShows = Consultation::query()
|
||||
->whereBetween('booking_date', [$start, $end])
|
||||
->where('status', ConsultationStatus::NoShow)
|
||||
->count();
|
||||
|
||||
$free = Consultation::query()
|
||||
->whereBetween('booking_date', [$start, $end])
|
||||
->where('consultation_type', ConsultationType::Free)
|
||||
->count();
|
||||
|
||||
$paid = Consultation::query()
|
||||
->whereBetween('booking_date', [$start, $end])
|
||||
->where('consultation_type', ConsultationType::Paid)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'approved' => Consultation::query()
|
||||
->whereBetween('booking_date', [$start, $end])
|
||||
->where('status', ConsultationStatus::Approved)
|
||||
->count(),
|
||||
'completed' => Consultation::query()
|
||||
->whereBetween('booking_date', [$start, $end])
|
||||
->where('status', ConsultationStatus::Completed)
|
||||
->count(),
|
||||
'cancelled' => Consultation::query()
|
||||
->whereBetween('booking_date', [$start, $end])
|
||||
->where('status', ConsultationStatus::Cancelled)
|
||||
->count(),
|
||||
'no_show' => $noShows,
|
||||
'free' => $free,
|
||||
'paid' => $paid,
|
||||
'no_show_rate' => $completed > 0 ? round(($noShows / $completed) * 100, 1) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function getTimelineStats(Carbon $start, Carbon $end): array
|
||||
{
|
||||
return [
|
||||
'active' => Timeline::query()
|
||||
->where('status', TimelineStatus::Active)
|
||||
->where('created_at', '<=', $end)
|
||||
->count(),
|
||||
'new' => Timeline::query()
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->count(),
|
||||
'updates' => TimelineUpdate::query()
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->count(),
|
||||
'archived' => Timeline::query()
|
||||
->where('status', TimelineStatus::Archived)
|
||||
->whereBetween('updated_at', [$start, $end])
|
||||
->count(),
|
||||
];
|
||||
}
|
||||
|
||||
public function getPostStats(Carbon $start, Carbon $end): array
|
||||
{
|
||||
return [
|
||||
'this_month' => Post::query()
|
||||
->where('status', PostStatus::Published)
|
||||
->whereBetween('published_at', [$start, $end])
|
||||
->count(),
|
||||
'total' => Post::query()
|
||||
->where('status', PostStatus::Published)
|
||||
->where('published_at', '<=', $end)
|
||||
->count(),
|
||||
];
|
||||
}
|
||||
|
||||
private function renderChartsAsImages(Carbon $start, Carbon $end, string $locale): array
|
||||
{
|
||||
$free = Consultation::query()
|
||||
->whereBetween('booking_date', [$start, $end])
|
||||
->where('consultation_type', ConsultationType::Free)
|
||||
->count();
|
||||
|
||||
$paid = Consultation::query()
|
||||
->whereBetween('booking_date', [$start, $end])
|
||||
->where('consultation_type', ConsultationType::Paid)
|
||||
->count();
|
||||
|
||||
$consultationPieChart = $this->generateQuickChart([
|
||||
'type' => 'pie',
|
||||
'data' => [
|
||||
'labels' => [
|
||||
__('report.free', [], $locale),
|
||||
__('report.paid', [], $locale),
|
||||
],
|
||||
'datasets' => [[
|
||||
'data' => [$free, $paid],
|
||||
'backgroundColor' => ['#0A1F44', '#D4AF37'],
|
||||
]],
|
||||
],
|
||||
'options' => [
|
||||
'plugins' => [
|
||||
'legend' => [
|
||||
'position' => 'bottom',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$trendChart = $this->generateTrendChart($start, $locale);
|
||||
|
||||
return [
|
||||
'consultation_pie' => $consultationPieChart,
|
||||
'trend_line' => $trendChart,
|
||||
];
|
||||
}
|
||||
|
||||
private function generateQuickChart(array $config): string
|
||||
{
|
||||
$url = 'https://quickchart.io/chart?c='.urlencode(json_encode($config)).'&w=400&h=300&bkg=white';
|
||||
|
||||
try {
|
||||
$response = Http::timeout(10)->get($url);
|
||||
|
||||
if ($response->successful()) {
|
||||
return 'data:image/png;base64,'.base64_encode($response->body());
|
||||
}
|
||||
|
||||
return '';
|
||||
} catch (\Exception $e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private function generateTrendChart(Carbon $endMonth, string $locale): string
|
||||
{
|
||||
$labels = [];
|
||||
$data = [];
|
||||
|
||||
for ($i = 5; $i >= 0; $i--) {
|
||||
$month = $endMonth->copy()->subMonths($i);
|
||||
$labels[] = $month->translatedFormat('M Y');
|
||||
$data[] = Consultation::query()
|
||||
->whereMonth('booking_date', $month->month)
|
||||
->whereYear('booking_date', $month->year)
|
||||
->count();
|
||||
}
|
||||
|
||||
return $this->generateQuickChart([
|
||||
'type' => 'line',
|
||||
'data' => [
|
||||
'labels' => $labels,
|
||||
'datasets' => [[
|
||||
'label' => __('report.consultations', [], $locale),
|
||||
'data' => $data,
|
||||
'borderColor' => '#D4AF37',
|
||||
'backgroundColor' => 'rgba(212, 175, 55, 0.1)',
|
||||
'fill' => true,
|
||||
'tension' => 0.3,
|
||||
]],
|
||||
],
|
||||
'options' => [
|
||||
'plugins' => [
|
||||
'legend' => [
|
||||
'position' => 'bottom',
|
||||
],
|
||||
],
|
||||
'scales' => [
|
||||
'y' => [
|
||||
'beginAtZero' => true,
|
||||
'ticks' => [
|
||||
'precision' => 0,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function getPreviousMonthComparison(Carbon $currentStart): ?array
|
||||
{
|
||||
$prevStart = $currentStart->copy()->subMonth()->startOfMonth();
|
||||
$prevEnd = $prevStart->copy()->endOfMonth();
|
||||
|
||||
$prevConsultations = Consultation::query()
|
||||
->whereBetween('booking_date', [$prevStart, $prevEnd])
|
||||
->count();
|
||||
|
||||
$prevClients = User::query()
|
||||
->whereBetween('created_at', [$prevStart, $prevEnd])
|
||||
->whereIn('user_type', [UserType::Individual, UserType::Company])
|
||||
->count();
|
||||
|
||||
if ($prevConsultations === 0 && $prevClients === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'consultations' => $prevConsultations,
|
||||
'clients' => $prevClients,
|
||||
];
|
||||
}
|
||||
|
||||
private function generateExecutiveSummary(Carbon $start, Carbon $end, string $locale): array
|
||||
{
|
||||
$userStats = $this->getUserStats($start, $end);
|
||||
$consultationStats = $this->getConsultationStats($start, $end);
|
||||
$previousMonth = $this->getPreviousMonthComparison($start);
|
||||
|
||||
$highlights = [];
|
||||
|
||||
// New clients highlight
|
||||
if ($userStats['new_clients'] > 0) {
|
||||
$highlights[] = __('report.highlight_new_clients', ['count' => $userStats['new_clients']], $locale);
|
||||
}
|
||||
|
||||
// Consultations highlight
|
||||
if ($consultationStats['total'] > 0) {
|
||||
$highlights[] = __('report.highlight_consultations', ['count' => $consultationStats['total']], $locale);
|
||||
}
|
||||
|
||||
// Month-over-month comparison
|
||||
if ($previousMonth) {
|
||||
$consultationChange = $consultationStats['total'] - $previousMonth['consultations'];
|
||||
if ($consultationChange > 0) {
|
||||
$highlights[] = __('report.highlight_growth', ['count' => $consultationChange], $locale);
|
||||
} elseif ($consultationChange < 0) {
|
||||
$highlights[] = __('report.highlight_decrease', ['count' => abs($consultationChange)], $locale);
|
||||
}
|
||||
}
|
||||
|
||||
// No-show rate alert
|
||||
if ($consultationStats['no_show_rate'] > 20) {
|
||||
$highlights[] = __('report.highlight_noshow_alert', ['rate' => $consultationStats['no_show_rate']], $locale);
|
||||
}
|
||||
|
||||
return $highlights;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user