Files
construprogress/app/Services/ReportGenerator.php
T

546 lines
23 KiB
PHP
Raw Normal View History

<?php
namespace App\Services;
use App\DTO\ReportFilters;
use App\Models\Feature;
use App\Models\Inspection;
use App\Models\Issue;
use App\Models\Media;
use App\Models\Phase;
use App\Models\Project;
use App\Models\ProgressSnapshot;
use App\Models\Task;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class ReportGenerator
{
protected Project $project;
protected ReportFilters $filters;
public function __construct(Project $project, ReportFilters $filters)
{
$this->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];
}
}