project = $project; $this->filters = $filters; } 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; } 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++; } $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]; } }