From 136219f592c40eaf69324fdd6104303c635bbb9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Bra=C3=B1a?= Date: Tue, 1 Sep 2026 14:44:11 +0200 Subject: [PATCH] feat: Phase 6.2 - Split ReportGenerator into DataAggregator + HtmlExporter + ExcelExporter - Create DataAggregator service (data aggregation logic) - Create HtmlExporter service (HTML/preview rendering) - Create ExcelExporter service (Excel download) - Refactor ReportGenerator to delegate to services Tests: 101 passing (319 assertions) --- app/Services/Report/DataAggregator.php | 548 ++++++++++++++++++++++++ app/Services/Report/ExcelExporter.php | 20 + app/Services/Report/HtmlExporter.php | 20 + app/Services/ReportGenerator.php | 551 ++----------------------- 4 files changed, 618 insertions(+), 521 deletions(-) create mode 100644 app/Services/Report/DataAggregator.php create mode 100644 app/Services/Report/ExcelExporter.php create mode 100644 app/Services/Report/HtmlExporter.php diff --git a/app/Services/Report/DataAggregator.php b/app/Services/Report/DataAggregator.php new file mode 100644 index 0000000..53610a7 --- /dev/null +++ b/app/Services/Report/DataAggregator.php @@ -0,0 +1,548 @@ +project = $project; + $this->filters = $filters; + } + + public function aggregate(): array + { + $data = [ + 'project' => $this->project, + 'filters' => $this->filters, + 'generated_at' => now(), + 'generated_by' => auth()->guard()->user(), + ]; + + // Always include summary KPIs + $data['summary'] = $this->buildSummary(); + + // Include requested entity sections + if (in_array('phases', $this->filters->entityTypes)) { + $data['phases'] = $this->buildPhasesData(); + } + + if (in_array('features', $this->filters->entityTypes)) { + $data['features'] = $this->buildFeaturesData(); + } + + if (in_array('inspections', $this->filters->entityTypes)) { + $data['inspections'] = $this->buildInspectionsData(); + } + + if (in_array('issues', $this->filters->entityTypes)) { + $data['issues'] = $this->buildIssuesData(); + } + + if (in_array('tasks', $this->filters->entityTypes)) { + $data['tasks'] = $this->buildTasksData(); + } + + if (in_array('media', $this->filters->entityTypes)) { + $data['media'] = $this->buildMediaData(); + } + + // Deviation analysis (always included if phases/features selected) + if (in_array('phases', $this->filters->entityTypes) || in_array('features', $this->filters->entityTypes)) { + $data['deviations'] = $this->buildDeviationsData(); + } + + // Progress curve data (for S-curve chart) + if ($this->filters->includeCharts) { + $data['progress_curve'] = $this->buildProgressCurveData(); + } + + return $data; + } + + protected function buildSummary(): array + { + $phases = $this->project->phases() + ->with(['layers.features']) + ->get(); + + $allFeatures = $phases->flatMap(fn ($p) => $p->layers->flatMap(fn ($l) => $l->features)); + $completedFeatures = $allFeatures->where('status', 'completed')->count(); + + $inspectionsQuery = Inspection::where('project_id', $this->project->id); + $this->applyDateFilter($inspectionsQuery, 'created_at'); + $totalInspections = $inspectionsQuery->count(); + $passedInspections = (clone $inspectionsQuery)->where('result', 'pass')->count(); + $failedInspections = (clone $inspectionsQuery)->where('result', 'fail')->count(); + + $issuesQuery = Issue::where('project_id', $this->project->id); + $this->applyDateFilter($issuesQuery, 'created_at'); + $openIssues = (clone $issuesQuery)->where('status', 'open')->count(); + $closedIssues = (clone $issuesQuery)->where('status', 'closed')->count(); + + $tasksQuery = Task::where('project_id', $this->project->id); + $this->applyDateFilter($tasksQuery, 'created_at'); + $totalTasks = $tasksQuery->count(); + $completedTasks = (clone $tasksQuery)->where('status', 'completed')->count(); + + // Calculate overall planned vs actual progress + $avgPlannedProgress = $phases->avg('planned_progress_at') ?? 0; + $avgActualProgress = $phases->avg('progress_percent') ?? 0; + $overallSpi = $avgPlannedProgress > 0 ? round($avgActualProgress / $avgPlannedProgress, 2) : null; + + return [ + 'total_features' => $allFeatures->count(), + 'completed_features' => $completedFeatures, + 'completion_rate' => $allFeatures->count() > 0 ? round($completedFeatures / $allFeatures->count() * 100, 1) : 0, + 'avg_planned_progress' => round($avgPlannedProgress, 1), + 'avg_actual_progress' => round($avgActualProgress, 1), + 'overall_spi' => $overallSpi, + 'total_inspections' => $totalInspections, + 'passed_inspections' => $passedInspections, + 'failed_inspections' => $failedInspections, + 'pass_rate' => $totalInspections > 0 ? round($passedInspections / $totalInspections * 100, 1) : 0, + 'open_issues' => $openIssues, + 'closed_issues' => $closedIssues, + 'total_tasks' => $totalTasks, + 'completed_tasks' => $completedTasks, + 'task_completion_rate' => $totalTasks > 0 ? round($completedTasks / $totalTasks * 100, 1) : 0, + 'phases_on_track' => $phases->where('is_on_track', true)->count(), + 'phases_delayed' => $phases->where('is_on_track', false)->count(), + 'phases_no_data' => $phases->where('is_on_track', null)->count(), + ]; + } + + protected function buildPhasesData(): array + { + $phases = $this->project->phases() + ->with(['layers.features']) + ->orderBy('order') + ->get(); + + return $phases->map(function ($phase) { + $phaseFeatures = $phase->layers->flatMap(fn ($l) => $l->features); + + return [ + 'id' => $phase->id, + 'name' => $phase->name, + 'description' => $phase->description, + 'order' => $phase->order, + 'color' => $phase->color, + 'planned_start' => $phase->planned_start?->format('d/m/Y'), + 'planned_end' => $phase->planned_end?->format('d/m/Y'), + 'actual_start' => $phase->actual_start?->format('d/m/Y'), + 'actual_end' => $phase->actual_end?->format('d/m/Y'), + 'baseline_start' => $phase->baseline_start?->format('d/m/Y'), + 'baseline_end' => $phase->baseline_end?->format('d/m/Y'), + 'progress_percent' => $phase->progress_percent, + 'planned_progress' => $phase->planned_progress_at, + 'deviation_days' => $phase->deviation_days, + 'start_deviation_days' => $phase->start_deviation_days, + 'spi' => $phase->spi, + 'is_on_track' => $phase->is_on_track, + 'features_count' => $phaseFeatures->count(), + 'completed_features' => $phaseFeatures->where('status', 'completed')->count(), + 'layers' => $phase->layers->map(fn ($l) => [ + 'id' => $l->id, + 'name' => $l->name, + 'features_count' => $l->features->count(), + ])->toArray(), + ]; + })->toArray(); + } + + protected function buildFeaturesData(): array + { + $query = Feature::whereHas('layer.phase', fn ($q) => $q->where('project_id', $this->project->id)) + ->with(['layer.phase', 'template', 'inspections', 'issues']); + + // Apply date filter on feature created_at if date range provided + $this->applyDateFilter($query, 'features.created_at'); + + $features = $query->orderBy('id')->get(); + + return $features->map(function ($feature) { + $lastInspection = $feature->inspections->sortByDesc('created_at')->first(); + + return [ + 'id' => $feature->id, + 'name' => $feature->name, + 'phase' => $feature->layer?->phase?->name ?? '—', + 'layer' => $feature->layer?->name ?? '—', + 'status' => $feature->status, + 'status_label' => $this->getFeatureStatusLabel($feature->status), + 'status_color' => $feature->status_color, + 'progress' => $feature->progress, + 'planned_progress' => $feature->planned_progress_at, + 'planned_start' => $feature->planned_start?->format('d/m/Y'), + 'planned_end' => $feature->planned_end?->format('d/m/Y'), + 'actual_start' => $feature->actual_start?->format('d/m/Y'), + 'actual_end' => $feature->actual_end?->format('d/m/Y'), + 'deviation_days' => $feature->deviation_days, + 'start_deviation_days' => $feature->start_deviation_days, + 'spi' => $feature->spi, + 'is_on_track' => $feature->is_on_track, + 'responsible' => $feature->responsible ?? $feature->responsibleUser?->name ?? '—', + 'template' => $feature->template?->name ?? '—', + 'last_inspection_date' => $lastInspection?->created_at?->format('d/m/Y'), + 'last_inspection_result' => $lastInspection?->result, + 'inspections_count' => $feature->inspections->count(), + 'open_issues_count' => $feature->issues->where('status', 'open')->count(), + ]; + })->toArray(); + } + + protected function buildInspectionsData(): array + { + $query = Inspection::where('project_id', $this->project->id) + ->with(['feature.layer.phase', 'template', 'user', 'media']); + + $this->applyDateFilter($query, 'created_at'); + + $inspections = $query->orderByDesc('created_at')->get(); + + return $inspections->map(function ($inspection) { + return [ + 'id' => $inspection->id, + 'feature' => $inspection->feature?->name ?? '—', + 'phase' => $inspection->feature?->layer?->phase?->name ?? '—', + 'template' => $inspection->template?->name ?? '—', + 'inspector' => $inspection->user?->name ?? '—', + 'date' => $inspection->created_at->format('d/m/Y H:i'), + 'status' => $inspection->status, + 'result' => $inspection->result, + 'result_label' => match ($inspection->result) { + 'pass' => 'Aprobada', + 'fail' => 'Fallida', + 'conditional' => 'Condicional', + default => '—', + }, + 'notes' => $inspection->notes, + 'photos_count' => $inspection->media->count(), + 'data' => $inspection->data ?? [], + ]; + })->toArray(); + } + + protected function buildIssuesData(): array + { + $query = Issue::where('project_id', $this->project->id) + ->with(['feature.layer.phase', 'reporter', 'assignee', 'tasks']); + + $this->applyDateFilter($query, 'created_at'); + + $issues = $query->orderByDesc('created_at')->get(); + + return $issues->map(function ($issue) { + return [ + 'id' => $issue->id, + 'title' => $issue->title, + 'feature' => $issue->feature?->name ?? '—', + 'phase' => $issue->feature?->layer?->phase?->name ?? '—', + 'priority' => $issue->priority, + 'priority_label' => match ($issue->priority) { + 'low' => 'Baja', + 'medium' => 'Media', + 'high' => 'Alta', + 'critical' => 'Crítica', + default => ucfirst($issue->priority ?? ''), + }, + 'status' => $issue->status, + 'status_label' => match ($issue->status) { + 'open' => 'Abierta', + 'in_review' => 'En revisión', + 'closed' => 'Cerrada', + default => ucfirst($issue->status ?? ''), + }, + 'reporter' => $issue->reporter?->name ?? '—', + 'assignee' => $issue->assignee?->name ?? '—', + 'created_at' => $issue->created_at->format('d/m/Y'), + 'closed_at' => $issue->closed_at?->format('d/m/Y'), + 'days_open' => $issue->created_at->diffInDays($issue->closed_at ?? now()), + 'tasks_total' => $issue->tasks->count(), + 'tasks_completed' => $issue->tasks->where('is_done', true)->count(), + ]; + })->toArray(); + } + + protected function buildTasksData(): array + { + $query = Task::where('project_id', $this->project->id) + ->with(['phase', 'assignee', 'creator', 'subtasks']); + + $this->applyDateFilter($query, 'created_at'); + + $tasks = $query->whereNull('parent_task_id')->orderBy('order')->get(); + + return $tasks->map(function ($task) { + return [ + 'id' => $task->id, + 'title' => $task->title, + 'phase' => $task->phase?->name ?? '—', + 'status' => $task->status, + 'status_label' => $task->status_label, + 'priority' => $task->priority, + 'priority_label' => $task->priority_label, + 'assignee' => $task->assignee?->name ?? '—', + 'creator' => $task->creator?->name ?? '—', + 'due_date' => $task->due_date?->format('d/m/Y'), + 'start_date' => $task->start_date?->format('d/m/Y'), + 'completed_at' => $task->completed_at?->format('d/m/Y'), + 'estimated_hours' => $task->estimated_hours, + 'actual_hours' => $task->actual_hours, + 'progress' => $task->progress, + 'is_overdue' => $task->is_overdue, + 'subtasks' => $task->subtasks->map(fn ($st) => [ + 'id' => $st->id, + 'title' => $st->title, + 'status' => $st->status, + 'assignee' => $st->assignee?->name ?? '—', + 'progress' => $st->progress, + ])->toArray(), + ]; + })->toArray(); + } + + protected function buildMediaData(): array + { + $query = Media::whereHas('mediable', function ($q) { + $q->where(function ($sub) { + $sub->where('mediable_type', Phase::class) + ->orWhere('mediable_type', Feature::class) + ->orWhere('mediable_type', Inspection::class) + ->orWhere('mediable_type', Issue::class) + ->orWhere('mediable_type', Task::class); + }); + })->whereHas('mediable', function ($q) { + // Filter by project through relationships + // This is complex with polymorphic, so we filter in PHP for simplicity + }); + + $this->applyDateFilter($query, 'created_at'); + + $media = $query->orderByDesc('created_at')->get(); + + // Filter by project in PHP (polymorphic complexity) + $filtered = $media->filter(function ($m) { + $mediable = $m->mediable; + if (! $mediable) { + return false; + } + + if ($mediable instanceof Phase) { + return $mediable->project_id === $this->project->id; + } + if ($mediable instanceof Feature) { + return $mediable->layer?->phase?->project_id === $this->project->id; + } + if ($mediable instanceof Inspection) { + return $mediable->project_id === $this->project->id; + } + if ($mediable instanceof Issue) { + return $mediable->project_id === $this->project->id; + } + if ($mediable instanceof Task) { + return $mediable->project_id === $this->project->id; + } + + return false; + }); + + return $filtered->map(function ($m) { + return [ + 'id' => $m->id, + 'name' => $m->name, + 'type' => $m->category, + 'entity' => class_basename($m->mediable_type), + 'entity_name' => $m->mediable?->name ?? $m->mediable?->title ?? '—', + 'size' => $m->file_size ? $this->formatBytes($m->file_size) : '—', + 'uploaded_by' => $m->uploadedBy?->name ?? '—', + 'uploaded_at' => $m->created_at->format('d/m/Y H:i'), + 'url' => $m->url, + ]; + })->values()->toArray(); + } + + protected function buildDeviationsData(): array + { + $phases = $this->project->phases() + ->with(['layers.features']) + ->get(); + + $phaseDeviations = $phases->map(function ($phase) { + return [ + 'id' => $phase->id, + 'name' => $phase->name, + 'planned_start' => $phase->planned_start?->format('d/m/Y'), + 'planned_end' => $phase->planned_end?->format('d/m/Y'), + 'actual_start' => $phase->actual_start?->format('d/m/Y'), + 'actual_end' => $phase->actual_end?->format('d/m/Y'), + 'start_deviation' => $phase->start_deviation_days, + 'end_deviation' => $phase->deviation_days, + 'planned_progress' => $phase->planned_progress_at, + 'actual_progress' => $phase->progress_percent, + 'progress_deviation' => round($phase->progress_percent - $phase->planned_progress_at, 1), + 'spi' => $phase->spi, + 'is_on_track' => $phase->is_on_track, + ]; + })->filter(fn ($p) => $p['planned_start'] || $p['planned_end'])->toArray(); + + $featureDeviations = Feature::whereHas('layer.phase', fn ($q) => $q->where('project_id', $this->project->id)) + ->whereNotNull('planned_end') + ->with(['layer.phase']) + ->get() + ->map(function ($feature) { + return [ + 'id' => $feature->id, + 'name' => $feature->name, + 'phase' => $feature->layer?->phase?->name ?? '—', + 'planned_start' => $feature->planned_start?->format('d/m/Y'), + 'planned_end' => $feature->planned_end?->format('d/m/Y'), + 'actual_start' => $feature->actual_start?->format('d/m/Y'), + 'actual_end' => $feature->actual_end?->format('d/m/Y'), + 'start_deviation' => $feature->start_deviation_days, + 'end_deviation' => $feature->deviation_days, + 'planned_progress' => $feature->planned_progress_at, + 'actual_progress' => $feature->progress, + 'progress_deviation' => round($feature->progress - $feature->planned_progress_at, 1), + 'spi' => $feature->spi, + 'is_on_track' => $feature->is_on_track, + 'responsible' => $feature->responsible ?? $feature->responsibleUser?->name ?? '—', + ]; + })->toArray(); + + return [ + 'phases' => $phaseDeviations, + 'features' => $featureDeviations, + 'summary' => [ + 'phases_delayed' => count(array_filter($phaseDeviations, fn ($p) => ($p['end_deviation'] ?? 0) > 0)), + 'phases_early' => count(array_filter($phaseDeviations, fn ($p) => ($p['end_deviation'] ?? 0) < 0)), + 'phases_on_time' => count(array_filter($phaseDeviations, fn ($p) => ($p['end_deviation'] ?? 0) === 0)), + 'features_delayed' => count(array_filter($featureDeviations, fn ($f) => ($f['end_deviation'] ?? 0) > 0)), + 'features_early' => count(array_filter($featureDeviations, fn ($f) => ($f['end_deviation'] ?? 0) < 0)), + 'features_on_time' => count(array_filter($featureDeviations, fn ($f) => ($f['end_deviation'] ?? 0) === 0)), + ], + ]; + } + + protected function buildProgressCurveData(): array + { + // Get snapshots for this project's phases and features + $phaseIds = $this->project->phases->pluck('id')->toArray(); + $featureIds = Feature::whereHas('layer.phase', fn ($q) => $q->whereIn('phase_id', $phaseIds)) + ->pluck('id')->toArray(); + $taskIds = Task::where('project_id', $this->project->id)->pluck('id')->toArray(); + + $from = $this->filters->dateFrom ?? now()->subMonths(6); + $to = $this->filters->dateTo ?? now(); + + $snapshots = ProgressSnapshot::whereIn('trackable_type', [Phase::class, Feature::class, Task::class]) + ->where(function ($q) use ($phaseIds, $featureIds, $taskIds) { + $q->where(function ($sub) use ($phaseIds) { + $sub->where('trackable_type', Phase::class) + ->whereIn('trackable_id', $phaseIds); + })->orWhere(function ($sub) use ($featureIds) { + $sub->where('trackable_type', Feature::class) + ->whereIn('trackable_id', $featureIds); + })->orWhere(function ($sub) use ($taskIds) { + $sub->where('trackable_type', Task::class) + ->whereIn('trackable_id', $taskIds); + }); + }) + ->whereBetween('snapshot_date', [$from, $to]) + ->orderBy('snapshot_date') + ->get(); + + // Group by date and calculate average progress + $grouped = $snapshots->groupBy('snapshot_date'); + + $dates = []; + $planned = []; + $actual = []; + + foreach ($grouped as $date => $snaps) { + $dates[] = Carbon::parse($date)->format('d/m/Y'); + + $phaseSnaps = $snaps->where('trackable_type', Phase::class); + $featureSnaps = $snaps->where('trackable_type', Feature::class); + $taskSnaps = $snaps->where('trackable_type', Task::class); + + $avgPlanned = 0; + $avgActual = 0; + $count = 0; + + foreach ($phaseSnaps as $snap) { + $avgPlanned += $snap->metadata['planned_progress'] ?? 0; + $avgActual += $snap->progress; + $count++; + } + foreach ($featureSnaps as $snap) { + $avgPlanned += $snap->metadata['planned_progress'] ?? 0; + $avgActual += $snap->progress; + $count++; + } + foreach ($taskSnaps as $snap) { + $avgPlanned += $snap->progress; // Tasks don't have planned progress easily + $avgActual += $snap->progress; + $count++; + } + + $dates[] = Carbon::parse($date)->format('d/m/Y'); + $planned[] = $count > 0 ? round($avgPlanned / $count, 1) : 0; + $actual[] = $count > 0 ? round($avgActual / $count, 1) : 0; + } + + return [ + 'dates' => $dates, + 'planned' => $planned, + 'actual' => $actual, + ]; + } + + protected function applyDateFilter($query, string $column): void + { + if ($this->filters->dateFrom) { + $query->whereDate($column, '>=', $this->filters->dateFrom); + } + if ($this->filters->dateTo) { + $query->whereDate($column, '<=', $this->filters->dateTo); + } + } + + protected function getFeatureStatusLabel(string $status): string + { + return match ($status) { + 'planned' => 'Planificado', + 'started' => 'Iniciado', + 'in_progress' => 'En progreso', + 'completed' => 'Completado', + 'verified' => 'Verificado', + default => ucfirst($status), + }; + } + + protected function formatBytes(int $bytes): string + { + if ($bytes >= 1048576) { + return round($bytes / 1048576, 1).' MB'; + } + if ($bytes >= 1024) { + return round($bytes / 1024, 1).' KB'; + } + return $bytes.' B'; + } +} \ No newline at end of file diff --git a/app/Services/Report/ExcelExporter.php b/app/Services/Report/ExcelExporter.php new file mode 100644 index 0000000..6c2b4ef --- /dev/null +++ b/app/Services/Report/ExcelExporter.php @@ -0,0 +1,20 @@ +name.'_'.now()->format('Ymd_His').'.xlsx'; + + return Excel::download($export, $filename); + } +} \ No newline at end of file diff --git a/app/Services/Report/HtmlExporter.php b/app/Services/Report/HtmlExporter.php new file mode 100644 index 0000000..f217c73 --- /dev/null +++ b/app/Services/Report/HtmlExporter.php @@ -0,0 +1,20 @@ +project = $project; $this->filters = $filters; + $this->dataAggregator = $dataAggregator ?? new DataAggregator($project, $filters); + $this->htmlExporter = $htmlExporter ?? new HtmlExporter(); + $this->excelExporter = $excelExporter ?? new ExcelExporter(); } public function generate(): array { - $data = [ - 'project' => $this->project, - 'filters' => $this->filters, - 'generated_at' => now(), - 'generated_by' => auth()->guard()->user(), - ]; - - // Always include summary KPIs - $data['summary'] = $this->buildSummary(); - - // Include requested entity sections - if (in_array('phases', $this->filters->entityTypes)) { - $data['phases'] = $this->buildPhasesData(); - } - - if (in_array('features', $this->filters->entityTypes)) { - $data['features'] = $this->buildFeaturesData(); - } - - if (in_array('inspections', $this->filters->entityTypes)) { - $data['inspections'] = $this->buildInspectionsData(); - } - - if (in_array('issues', $this->filters->entityTypes)) { - $data['issues'] = $this->buildIssuesData(); - } - - if (in_array('tasks', $this->filters->entityTypes)) { - $data['tasks'] = $this->buildTasksData(); - } - - if (in_array('media', $this->filters->entityTypes)) { - $data['media'] = $this->buildMediaData(); - } - - // Deviation analysis (always included if phases/features selected) - if (in_array('phases', $this->filters->entityTypes) || in_array('features', $this->filters->entityTypes)) { - $data['deviations'] = $this->buildDeviationsData(); - } - - // Progress curve data (for S-curve chart) - if ($this->filters->includeCharts) { - $data['progress_curve'] = $this->buildProgressCurveData(); - } - - return $data; + return $this->dataAggregator->aggregate(); } - protected function buildSummary(): array + public function renderHtml(): View { - $phases = $this->project->phases() - ->with(['layers.features']) - ->get(); - - $allFeatures = $phases->flatMap(fn ($p) => $p->layers->flatMap(fn ($l) => $l->features)); - $completedFeatures = $allFeatures->where('status', 'completed')->count(); - - $inspectionsQuery = Inspection::where('project_id', $this->project->id); - $this->applyDateFilter($inspectionsQuery, 'created_at'); - $totalInspections = $inspectionsQuery->count(); - $passedInspections = (clone $inspectionsQuery)->where('result', 'pass')->count(); - $failedInspections = (clone $inspectionsQuery)->where('result', 'fail')->count(); - - $issuesQuery = Issue::where('project_id', $this->project->id); - $this->applyDateFilter($issuesQuery, 'created_at'); - $openIssues = (clone $issuesQuery)->where('status', 'open')->count(); - $closedIssues = (clone $issuesQuery)->where('status', 'closed')->count(); - - $tasksQuery = Task::where('project_id', $this->project->id); - $this->applyDateFilter($tasksQuery, 'created_at'); - $totalTasks = $tasksQuery->count(); - $completedTasks = (clone $tasksQuery)->where('status', 'completed')->count(); - - // Calculate overall planned vs actual progress - $avgPlannedProgress = $phases->avg('planned_progress_at') ?? 0; - $avgActualProgress = $phases->avg('progress_percent') ?? 0; - $overallSpi = $avgPlannedProgress > 0 ? round($avgActualProgress / $avgPlannedProgress, 2) : null; - - return [ - 'total_features' => $allFeatures->count(), - 'completed_features' => $completedFeatures, - 'completion_rate' => $allFeatures->count() > 0 ? round($completedFeatures / $allFeatures->count() * 100, 1) : 0, - 'avg_planned_progress' => round($avgPlannedProgress, 1), - 'avg_actual_progress' => round($avgActualProgress, 1), - 'overall_spi' => $overallSpi, - 'total_inspections' => $totalInspections, - 'passed_inspections' => $passedInspections, - 'failed_inspections' => $failedInspections, - 'pass_rate' => $totalInspections > 0 ? round($passedInspections / $totalInspections * 100, 1) : 0, - 'open_issues' => $openIssues, - 'closed_issues' => $closedIssues, - 'total_tasks' => $totalTasks, - 'completed_tasks' => $completedTasks, - 'task_completion_rate' => $totalTasks > 0 ? round($completedTasks / $totalTasks * 100, 1) : 0, - 'phases_on_track' => $phases->where('is_on_track', true)->count(), - 'phases_delayed' => $phases->where('is_on_track', false)->count(), - 'phases_no_data' => $phases->where('is_on_track', null)->count(), - ]; + $data = $this->generate(); + return $this->htmlExporter->export($this->project, $data); } - protected function buildPhasesData(): array + public function renderPreview(): View { - $phases = $this->project->phases() - ->with(['layers.features']) - ->orderBy('order') - ->get(); - - return $phases->map(function ($phase) { - $phaseFeatures = $phase->layers->flatMap(fn ($l) => $l->features); - - return [ - 'id' => $phase->id, - 'name' => $phase->name, - 'description' => $phase->description, - 'order' => $phase->order, - 'color' => $phase->color, - 'planned_start' => $phase->planned_start?->format('d/m/Y'), - 'planned_end' => $phase->planned_end?->format('d/m/Y'), - 'actual_start' => $phase->actual_start?->format('d/m/Y'), - 'actual_end' => $phase->actual_end?->format('d/m/Y'), - 'baseline_start' => $phase->baseline_start?->format('d/m/Y'), - 'baseline_end' => $phase->baseline_end?->format('d/m/Y'), - 'progress_percent' => $phase->progress_percent, - 'planned_progress' => $phase->planned_progress_at, - 'deviation_days' => $phase->deviation_days, - 'start_deviation_days' => $phase->start_deviation_days, - 'spi' => $phase->spi, - 'is_on_track' => $phase->is_on_track, - 'features_count' => $phaseFeatures->count(), - 'completed_features' => $phaseFeatures->where('status', 'completed')->count(), - 'layers' => $phase->layers->map(fn ($l) => [ - 'id' => $l->id, - 'name' => $l->name, - 'features_count' => $l->features->count(), - ])->toArray(), - ]; - })->toArray(); + $data = $this->generate(); + return $this->htmlExporter->exportPreview($data); } - protected function buildFeaturesData(): array + public function exportExcel(): BinaryFileResponse { - $query = Feature::whereHas('layer.phase', fn ($q) => $q->where('project_id', $this->project->id)) - ->with(['layer.phase', 'template', 'inspections', 'issues']); - - // Apply date filter on feature created_at if date range provided - $this->applyDateFilter($query, 'features.created_at'); - - $features = $query->orderBy('id')->get(); - - return $features->map(function ($feature) { - $lastInspection = $feature->inspections->sortByDesc('created_at')->first(); - - return [ - 'id' => $feature->id, - 'name' => $feature->name, - 'phase' => $feature->layer?->phase?->name ?? '—', - 'layer' => $feature->layer?->name ?? '—', - 'status' => $feature->status, - 'status_label' => $this->getFeatureStatusLabel($feature->status), - 'status_color' => $feature->status_color, - 'progress' => $feature->progress, - 'planned_progress' => $feature->planned_progress_at, - 'planned_start' => $feature->planned_start?->format('d/m/Y'), - 'planned_end' => $feature->planned_end?->format('d/m/Y'), - 'actual_start' => $feature->actual_start?->format('d/m/Y'), - 'actual_end' => $feature->actual_end?->format('d/m/Y'), - 'deviation_days' => $feature->deviation_days, - 'start_deviation_days' => $feature->start_deviation_days, - 'spi' => $feature->spi, - 'is_on_track' => $feature->is_on_track, - 'responsible' => $feature->responsible ?? $feature->responsibleUser?->name ?? '—', - 'template' => $feature->template?->name ?? '—', - 'last_inspection_date' => $lastInspection?->created_at?->format('d/m/Y'), - 'last_inspection_result' => $lastInspection?->result, - 'inspections_count' => $feature->inspections->count(), - 'open_issues_count' => $feature->issues->where('status', 'open')->count(), - ]; - })->toArray(); + $data = $this->generate(); + return $this->excelExporter->export($this->project, $this->filters, $data); } - - protected function buildInspectionsData(): array - { - $query = Inspection::where('project_id', $this->project->id) - ->with(['feature.layer.phase', 'template', 'user', 'media']); - - $this->applyDateFilter($query, 'created_at'); - - $inspections = $query->orderByDesc('created_at')->get(); - - return $inspections->map(function ($inspection) { - return [ - 'id' => $inspection->id, - 'feature' => $inspection->feature?->name ?? '—', - 'phase' => $inspection->feature?->layer?->phase?->name ?? '—', - 'template' => $inspection->template?->name ?? '—', - 'inspector' => $inspection->user?->name ?? '—', - 'date' => $inspection->created_at->format('d/m/Y H:i'), - 'status' => $inspection->status, - 'result' => $inspection->result, - 'result_label' => match ($inspection->result) { - 'pass' => 'Aprobada', - 'fail' => 'Fallida', - 'conditional' => 'Condicional', - default => '—', - }, - 'notes' => $inspection->notes, - 'photos_count' => $inspection->media->count(), - 'data' => $inspection->data ?? [], - ]; - })->toArray(); - } - - protected function buildIssuesData(): array - { - $query = Issue::where('project_id', $this->project->id) - ->with(['feature.layer.phase', 'reporter', 'assignee', 'tasks']); - - $this->applyDateFilter($query, 'created_at'); - - $issues = $query->orderByDesc('created_at')->get(); - - return $issues->map(function ($issue) { - return [ - 'id' => $issue->id, - 'title' => $issue->title, - 'feature' => $issue->feature?->name ?? '—', - 'phase' => $issue->feature?->layer?->phase?->name ?? '—', - 'priority' => $issue->priority, - 'priority_label' => match ($issue->priority) { - 'low' => 'Baja', - 'medium' => 'Media', - 'high' => 'Alta', - 'critical' => 'Crítica', - default => ucfirst($issue->priority ?? ''), - }, - 'status' => $issue->status, - 'status_label' => match ($issue->status) { - 'open' => 'Abierta', - 'in_review' => 'En revisión', - 'closed' => 'Cerrada', - default => ucfirst($issue->status ?? ''), - }, - 'reporter' => $issue->reporter?->name ?? '—', - 'assignee' => $issue->assignee?->name ?? '—', - 'created_at' => $issue->created_at->format('d/m/Y'), - 'closed_at' => $issue->closed_at?->format('d/m/Y'), - 'days_open' => $issue->created_at->diffInDays($issue->closed_at ?? now()), - 'tasks_total' => $issue->tasks->count(), - 'tasks_completed' => $issue->tasks->where('is_done', true)->count(), - ]; - })->toArray(); - } - - protected function buildTasksData(): array - { - $query = Task::where('project_id', $this->project->id) - ->with(['phase', 'assignee', 'creator', 'subtasks']); - - $this->applyDateFilter($query, 'created_at'); - - $tasks = $query->whereNull('parent_task_id')->orderBy('order')->get(); - - return $tasks->map(function ($task) { - return [ - 'id' => $task->id, - 'title' => $task->title, - 'phase' => $task->phase?->name ?? '—', - 'status' => $task->status, - 'status_label' => $task->status_label, - 'priority' => $task->priority, - 'priority_label' => $task->priority_label, - 'assignee' => $task->assignee?->name ?? '—', - 'creator' => $task->creator?->name ?? '—', - 'due_date' => $task->due_date?->format('d/m/Y'), - 'start_date' => $task->start_date?->format('d/m/Y'), - 'completed_at' => $task->completed_at?->format('d/m/Y'), - 'estimated_hours' => $task->estimated_hours, - 'actual_hours' => $task->actual_hours, - 'progress' => $task->progress, - 'is_overdue' => $task->is_overdue, - 'subtasks' => $task->subtasks->map(fn ($st) => [ - 'id' => $st->id, - 'title' => $st->title, - 'status' => $st->status, - 'assignee' => $st->assignee?->name ?? '—', - 'progress' => $st->progress, - ])->toArray(), - ]; - })->toArray(); - } - - protected function buildMediaData(): array - { - $query = Media::whereHas('mediable', function ($q) { - $q->where(function ($sub) { - $sub->where('mediable_type', Phase::class) - ->orWhere('mediable_type', Feature::class) - ->orWhere('mediable_type', Inspection::class) - ->orWhere('mediable_type', Issue::class) - ->orWhere('mediable_type', Task::class); - }); - })->whereHas('mediable', function ($q) { - // Filter by project through relationships - // This is complex with polymorphic, so we filter in PHP for simplicity - }); - - $this->applyDateFilter($query, 'created_at'); - - $media = $query->orderByDesc('created_at')->get(); - - // Filter by project in PHP (polymorphic complexity) - $filtered = $media->filter(function ($m) { - $mediable = $m->mediable; - if (! $mediable) { - return false; - } - - if ($mediable instanceof Phase) { - return $mediable->project_id === $this->project->id; - } - if ($mediable instanceof Feature) { - return $mediable->layer?->phase?->project_id === $this->project->id; - } - if ($mediable instanceof Inspection) { - return $mediable->project_id === $this->project->id; - } - if ($mediable instanceof Issue) { - return $mediable->project_id === $this->project->id; - } - if ($mediable instanceof Task) { - return $mediable->project_id === $this->project->id; - } - - return false; - }); - - return $filtered->map(function ($m) { - return [ - 'id' => $m->id, - 'name' => $m->name, - 'type' => $m->category, - 'entity' => class_basename($m->mediable_type), - 'entity_name' => $m->mediable?->name ?? $m->mediable?->title ?? '—', - 'size' => $m->file_size ? $this->formatBytes($m->file_size) : '—', - 'uploaded_by' => $m->uploadedBy?->name ?? '—', - 'uploaded_at' => $m->created_at->format('d/m/Y H:i'), - 'url' => $m->url, - ]; - })->values()->toArray(); - } - - protected function buildDeviationsData(): array - { - $phases = $this->project->phases() - ->with(['layers.features']) - ->get(); - - $phaseDeviations = $phases->map(function ($phase) { - return [ - 'id' => $phase->id, - 'name' => $phase->name, - 'planned_start' => $phase->planned_start?->format('d/m/Y'), - 'planned_end' => $phase->planned_end?->format('d/m/Y'), - 'actual_start' => $phase->actual_start?->format('d/m/Y'), - 'actual_end' => $phase->actual_end?->format('d/m/Y'), - 'start_deviation' => $phase->start_deviation_days, - 'end_deviation' => $phase->deviation_days, - 'planned_progress' => $phase->planned_progress_at, - 'actual_progress' => $phase->progress_percent, - 'progress_deviation' => round($phase->progress_percent - $phase->planned_progress_at, 1), - 'spi' => $phase->spi, - 'is_on_track' => $phase->is_on_track, - ]; - })->filter(fn ($p) => $p['planned_start'] || $p['planned_end'])->toArray(); - - $featureDeviations = Feature::whereHas('layer.phase', fn ($q) => $q->where('project_id', $this->project->id)) - ->whereNotNull('planned_end') - ->with(['layer.phase']) - ->get() - ->map(function ($feature) { - return [ - 'id' => $feature->id, - 'name' => $feature->name, - 'phase' => $feature->layer?->phase?->name ?? '—', - 'planned_start' => $feature->planned_start?->format('d/m/Y'), - 'planned_end' => $feature->planned_end?->format('d/m/Y'), - 'actual_start' => $feature->actual_start?->format('d/m/Y'), - 'actual_end' => $feature->actual_end?->format('d/m/Y'), - 'start_deviation' => $feature->start_deviation_days, - 'end_deviation' => $feature->deviation_days, - 'planned_progress' => $feature->planned_progress_at, - 'actual_progress' => $feature->progress, - 'progress_deviation' => round($feature->progress - $feature->planned_progress_at, 1), - 'spi' => $feature->spi, - 'is_on_track' => $feature->is_on_track, - 'responsible' => $feature->responsible ?? $feature->responsibleUser?->name ?? '—', - ]; - })->toArray(); - - return [ - 'phases' => $phaseDeviations, - 'features' => $featureDeviations, - 'summary' => [ - 'phases_delayed' => count(array_filter($phaseDeviations, fn ($p) => ($p['end_deviation'] ?? 0) > 0)), - 'phases_early' => count(array_filter($phaseDeviations, fn ($p) => ($p['end_deviation'] ?? 0) < 0)), - 'phases_on_time' => count(array_filter($phaseDeviations, fn ($p) => ($p['end_deviation'] ?? 0) === 0)), - 'features_delayed' => count(array_filter($featureDeviations, fn ($f) => ($f['end_deviation'] ?? 0) > 0)), - 'features_early' => count(array_filter($featureDeviations, fn ($f) => ($f['end_deviation'] ?? 0) < 0)), - 'features_on_time' => count(array_filter($featureDeviations, fn ($f) => ($f['end_deviation'] ?? 0) === 0)), - ], - ]; - } - - protected function buildProgressCurveData(): array - { - // Get snapshots for this project's phases and features - $phaseIds = $this->project->phases->pluck('id')->toArray(); - $featureIds = Feature::whereHas('layer.phase', fn ($q) => $q->whereIn('phase_id', $phaseIds)) - ->pluck('id')->toArray(); - $taskIds = Task::where('project_id', $this->project->id)->pluck('id')->toArray(); - - $from = $this->filters->dateFrom ?? now()->subMonths(6); - $to = $this->filters->dateTo ?? now(); - - $snapshots = ProgressSnapshot::whereIn('trackable_type', [Phase::class, Feature::class, Task::class]) - ->where(function ($q) use ($phaseIds, $featureIds, $taskIds) { - $q->where(function ($sub) use ($phaseIds) { - $sub->where('trackable_type', Phase::class) - ->whereIn('trackable_id', $phaseIds); - })->orWhere(function ($sub) use ($featureIds) { - $sub->where('trackable_type', Feature::class) - ->whereIn('trackable_id', $featureIds); - })->orWhere(function ($sub) use ($taskIds) { - $sub->where('trackable_type', Task::class) - ->whereIn('trackable_id', $taskIds); - }); - }) - ->whereBetween('snapshot_date', [$from, $to]) - ->orderBy('snapshot_date') - ->get(); - - // Group by date and calculate average progress - $grouped = $snapshots->groupBy('snapshot_date'); - - $dates = []; - $planned = []; - $actual = []; - - foreach ($grouped as $date => $snaps) { - $dates[] = Carbon::parse($date)->format('d/m/Y'); - - $phaseSnaps = $snaps->where('trackable_type', Phase::class); - $featureSnaps = $snaps->where('trackable_type', Feature::class); - $taskSnaps = $snaps->where('trackable_type', Task::class); - - $avgPlanned = 0; - $avgActual = 0; - $count = 0; - - foreach ($phaseSnaps as $snap) { - $avgPlanned += $snap->metadata['planned_progress'] ?? 0; - $avgActual += $snap->progress; - $count++; - } - foreach ($featureSnaps as $snap) { - $avgPlanned += $snap->metadata['planned_progress'] ?? 0; - $avgActual += $snap->progress; - $count++; - } - foreach ($taskSnaps as $snap) { - $avgPlanned += $snap->progress; // Tasks don't have planned progress easily - $avgActual += $snap->progress; - $count++; - } - - $planned[] = $count > 0 ? round($avgPlanned / $count, 1) : 0; - $actual[] = $count > 0 ? round($avgActual / $count, 1) : 0; - } - - return [ - 'labels' => $dates, - 'planned' => $planned, - 'actual' => $actual, - ]; - } - - protected function applyDateFilter($query, string $column): void - { - if ($this->filters->dateFrom) { - $query->whereDate($column, '>=', $this->filters->dateFrom); - } - if ($this->filters->dateTo) { - $query->whereDate($column, '<=', $this->filters->dateTo); - } - } - - protected function getFeatureStatusLabel(string $status): string - { - return match ($status) { - 'planned' => 'Planificado', - 'started' => 'Iniciado', - 'in_progress' => 'En progreso', - 'completed' => 'Completado', - 'verified' => 'Verificado', - default => ucfirst($status), - }; - } - - protected function formatBytes(int $bytes): string - { - $units = ['B', 'KB', 'MB', 'GB']; - $i = 0; - while ($bytes >= 1024 && $i < count($units) - 1) { - $bytes /= 1024; - $i++; - } - - return round($bytes, 1).' '.$units[$i]; - } -} +} \ No newline at end of file