feat(reports): complete reporting system with deviations, progress curves, multi-format export
- Added planned/actual/baseline dates to Phase and Feature models
- Created ProgressSnapshot model + migration for historical tracking
- Implemented CaptureDailyProgressSnapshot job (scheduled daily at 06:30)
- Added DeviationCalculator logic (SPI, planned progress, deviation days)
- ReportGenerator service with complete data aggregation
- ReportController with builder, preview, generate (HTML/Excel)
- ReportBuilder Livewire component with date range, entity selection, format
- ProjectReportExport with 10 sheets (Summary, Phases, Features, Inspections, Issues, Tasks, Deviations, Media, Progress Curve, Parameters)
- Blade partials for all sections (header, summary, phases, features, inspections, issues, tasks, media, deviations, progress curve)
- New routes: /projects/{project}/reports/*
- Added 'generate reports' permission
- All 101 tests passing
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\DTO;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ReportFilters
|
||||
{
|
||||
public function __construct(
|
||||
public ?Carbon $dateFrom = null,
|
||||
public ?Carbon $dateTo = null,
|
||||
public array $entityTypes = [], // ['phases','features','inspections','issues','tasks','media']
|
||||
public bool $includePhotos = false,
|
||||
public string $format = 'html', // html|excel
|
||||
public bool $includeCharts = false,
|
||||
) {}
|
||||
|
||||
public static function fromRequest(array $data): self
|
||||
{
|
||||
return new self(
|
||||
dateFrom: isset($data['date_from']) ? Carbon::parse($data['date_from']) : null,
|
||||
dateTo: isset($data['date_to']) ? Carbon::parse($data['date_to']) : null,
|
||||
entityTypes: $data['entity_types'] ?? ['phases','features','inspections','issues','tasks'],
|
||||
includePhotos: (bool)($data['include_photos'] ?? false),
|
||||
format: $data['format'] ?? 'html',
|
||||
includeCharts: (bool)($data['include_charts'] ?? false),
|
||||
);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'date_from' => $this->dateFrom?->toDateString(),
|
||||
'date_to' => $this->dateTo?->toDateString(),
|
||||
'entity_types' => $this->entityTypes,
|
||||
'include_photos' => $this->includePhotos,
|
||||
'format' => $this->format,
|
||||
'include_charts' => $this->includeCharts,
|
||||
];
|
||||
}
|
||||
|
||||
public function getAvailableEntities(): array
|
||||
{
|
||||
return [
|
||||
'phases' => 'Fases',
|
||||
'features' => 'Elementos (Features)',
|
||||
'inspections' => 'Inspecciones',
|
||||
'issues' => 'Incidencias (Issues)',
|
||||
'tasks' => 'Tareas',
|
||||
'media' => 'Archivos/Media',
|
||||
];
|
||||
}
|
||||
|
||||
public function getDateRangeLabel(): string
|
||||
{
|
||||
if ($this->dateFrom && $this->dateTo) {
|
||||
return $this->dateFrom->format('d/m/Y') . ' - ' . $this->dateTo->format('d/m/Y');
|
||||
}
|
||||
if ($this->dateFrom) {
|
||||
return 'Desde ' . $this->dateFrom->format('d/m/Y');
|
||||
}
|
||||
if ($this->dateTo) {
|
||||
return 'Hasta ' . $this->dateTo->format('d/m/Y');
|
||||
}
|
||||
return 'Todo el período';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\DTO\ReportFilters;
|
||||
use App\Models\Project;
|
||||
use Maatwebsite\Excel\Concerns\FromArray;
|
||||
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use Maatwebsite\Excel\Concerns\WithColumnWidths;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
|
||||
class ProjectReportExport implements WithMultipleSheets
|
||||
{
|
||||
protected Project $project;
|
||||
protected ReportFilters $filters;
|
||||
protected array $data;
|
||||
|
||||
public function __construct(Project $project, ReportFilters $filters, array $data)
|
||||
{
|
||||
$this->project = $project;
|
||||
$this->filters = $filters;
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
public function sheets(): array
|
||||
{
|
||||
$sheets = [
|
||||
new SummarySheet($this->data['summary'] ?? [], $this->project, $this->filters),
|
||||
];
|
||||
|
||||
if (!empty($this->data['phases'])) {
|
||||
$sheets[] = new PhasesSheet($this->data['phases']);
|
||||
}
|
||||
|
||||
if (!empty($this->data['features'])) {
|
||||
$sheets[] = new FeaturesSheet($this->data['features']);
|
||||
}
|
||||
|
||||
if (!empty($this->data['inspections'])) {
|
||||
$sheets[] = new InspectionsSheet($this->data['inspections']);
|
||||
}
|
||||
|
||||
if (!empty($this->data['issues'])) {
|
||||
$sheets[] = new IssuesSheet($this->data['issues']);
|
||||
}
|
||||
|
||||
if (!empty($this->data['tasks'])) {
|
||||
$sheets[] = new TasksSheet($this->data['tasks']);
|
||||
}
|
||||
|
||||
if (!empty($this->data['deviations'])) {
|
||||
$sheets[] = new DeviationsSheet($this->data['deviations']);
|
||||
}
|
||||
|
||||
if (!empty($this->data['media'])) {
|
||||
$sheets[] = new MediaSheet($this->data['media']);
|
||||
}
|
||||
|
||||
if (!empty($this->data['progress_curve'])) {
|
||||
$sheets[] = new ProgressCurveSheet($this->data['progress_curve']);
|
||||
}
|
||||
|
||||
// Parameters sheet
|
||||
$sheets[] = new ParametersSheet($this->filters, $this->project);
|
||||
|
||||
return $sheets;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Base Sheet with common styling
|
||||
// ============================================================
|
||||
|
||||
abstract class BaseSheet implements FromArray, WithHeadings, WithStyles, WithColumnWidths, WithTitle
|
||||
{
|
||||
protected array $rows = [];
|
||||
|
||||
public function __construct(array $data = [])
|
||||
{
|
||||
$this->rows = $data;
|
||||
}
|
||||
|
||||
public function array(): array
|
||||
{
|
||||
return $this->rows;
|
||||
}
|
||||
|
||||
abstract public function headings(): array;
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return static::class;
|
||||
}
|
||||
|
||||
public function styles(Worksheet $sheet)
|
||||
{
|
||||
// Header row style
|
||||
$sheet->getStyle('1:1')->applyFromArray([
|
||||
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF'], 'size' => 11],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '1E3A8A']],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'wrapText' => true],
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
|
||||
]);
|
||||
|
||||
// Data rows
|
||||
$highestRow = $sheet->getHighestRow();
|
||||
if ($highestRow > 1) {
|
||||
$sheet->getStyle("2:{$highestRow}")->applyFromArray([
|
||||
'font' => ['size' => 10],
|
||||
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER, 'wrapText' => true],
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
|
||||
]);
|
||||
|
||||
// Alternate row colors
|
||||
for ($row = 2; $row <= $highestRow; $row++) {
|
||||
if ($row % 2 === 0) {
|
||||
$sheet->getStyle("{$row}:{$row}")->applyFromArray([
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'F8FAFC']],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-filter
|
||||
$sheet->setAutoFilter($sheet->calculateWorksheetDimension());
|
||||
|
||||
// Freeze header row
|
||||
$sheet->freezePane('A2');
|
||||
}
|
||||
|
||||
public function columnWidths(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Summary Sheet
|
||||
// ============================================================
|
||||
|
||||
class SummarySheet extends BaseSheet
|
||||
{
|
||||
protected Project $project;
|
||||
protected ReportFilters $filters;
|
||||
|
||||
public function __construct(array $summary, Project $project, ReportFilters $filters)
|
||||
{
|
||||
$this->project = $project;
|
||||
$this->filters = $filters;
|
||||
|
||||
$rows = [
|
||||
['Informe de Proyecto', $project->name],
|
||||
['Referencia', $project->reference ?? '—'],
|
||||
['Dirección', $project->address ?? '—'],
|
||||
['Estado', $project->status ?? '—'],
|
||||
['Fecha inicio', $project->start_date?->format('d/m/Y') ?? '—'],
|
||||
['Fecha fin estimada', $project->end_date_estimated?->format('d/m/Y') ?? '—'],
|
||||
['', ''],
|
||||
['Período del informe', $filters->getDateRangeLabel()],
|
||||
['Generado el', now()->format('d/m/Y H:i')],
|
||||
['Generado por', auth()->guard()->user()?->name ?? 'Sistema'],
|
||||
['', ''],
|
||||
['--- RESUMEN EJECUTIVO ---', ''],
|
||||
['Total elementos', $summary['total_features'] ?? 0],
|
||||
['Elementos completados', $summary['completed_features'] ?? 0],
|
||||
['Tasa completitud (%)', $summary['completion_rate'] ?? 0],
|
||||
['Progreso planificado medio (%)', $summary['avg_planned_progress'] ?? 0],
|
||||
['Progreso real medio (%)', $summary['avg_actual_progress'] ?? 0],
|
||||
['SPI global', $summary['overall_spi'] ?? 'N/A'],
|
||||
['', ''],
|
||||
['--- INSPECCIONES ---', ''],
|
||||
['Total inspecciones', $summary['total_inspections'] ?? 0],
|
||||
['Aprobadas', $summary['passed_inspections'] ?? 0],
|
||||
['Fallidas', $summary['failed_inspections'] ?? 0],
|
||||
['Tasa aprobación (%)', $summary['pass_rate'] ?? 0],
|
||||
['', ''],
|
||||
['--- INCIDENCIAS ---', ''],
|
||||
['Abiertas', $summary['open_issues'] ?? 0],
|
||||
['Cerradas', $summary['closed_issues'] ?? 0],
|
||||
['', ''],
|
||||
['--- TAREAS ---', ''],
|
||||
['Total tareas', $summary['total_tasks'] ?? 0],
|
||||
['Completadas', $summary['completed_tasks'] ?? 0],
|
||||
['Tasa completitud (%)', $summary['task_completion_rate'] ?? 0],
|
||||
['', ''],
|
||||
['--- FASES ---', ''],
|
||||
['En plazo', $summary['phases_on_track'] ?? 0],
|
||||
['Retrasadas', $summary['phases_delayed'] ?? 0],
|
||||
['Sin datos', $summary['phases_no_data'] ?? 0],
|
||||
];
|
||||
|
||||
parent::__construct($rows);
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return ['Concepto', 'Valor'];
|
||||
}
|
||||
|
||||
public function columnWidths(): array
|
||||
{
|
||||
return ['A' => 40, 'B' => 30];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Phases Sheet
|
||||
// ============================================================
|
||||
|
||||
class PhasesSheet extends BaseSheet
|
||||
{
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'ID', 'Fase', 'Orden', 'Color', 'Inicio Plan', 'Fin Plan',
|
||||
'Inicio Real', 'Fin Real', 'Progreso (%)', 'Progreso Plan (%)',
|
||||
'Desvío Fin (días)', 'Desvío Inicio (días)', 'SPI', 'En Plazo',
|
||||
'Elementos', 'Completados', 'Capas'
|
||||
];
|
||||
}
|
||||
|
||||
public function array(): array
|
||||
{
|
||||
return array_map(function ($phase) {
|
||||
return [
|
||||
$phase['id'],
|
||||
$phase['name'],
|
||||
$phase['order'],
|
||||
$phase['color'],
|
||||
$phase['planned_start'] ?? '—',
|
||||
$phase['planned_end'] ?? '—',
|
||||
$phase['actual_start'] ?? '—',
|
||||
$phase['actual_end'] ?? '—',
|
||||
$phase['progress_percent'],
|
||||
$phase['planned_progress'],
|
||||
$phase['deviation_days'] ?? '—',
|
||||
$phase['start_deviation_days'] ?? '—',
|
||||
$phase['spi'] ?? '—',
|
||||
$phase['is_on_track'] === true ? 'Sí' : ($phase['is_on_track'] === false ? 'No' : 'N/A'),
|
||||
$phase['features_count'],
|
||||
$phase['completed_features'],
|
||||
count($phase['layers']),
|
||||
];
|
||||
}, $this->rows);
|
||||
}
|
||||
|
||||
public function columnWidths(): array
|
||||
{
|
||||
return ['A' => 8, 'B' => 25, 'C' => 8, 'D' => 10, 'E' => 12, 'F' => 12,
|
||||
'G' => 12, 'H' => 12, 'I' => 12, 'J' => 14, 'K' => 14, 'L' => 14,
|
||||
'M' => 8, 'N' => 10, 'O' => 10, 'P' => 12, 'Q' => 10];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Features Sheet
|
||||
// ============================================================
|
||||
|
||||
class FeaturesSheet extends BaseSheet
|
||||
{
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'ID', 'Elemento', 'Fase', 'Capa', 'Estado', 'Progreso (%)', 'Progreso Plan (%)',
|
||||
'Inicio Plan', 'Fin Plan', 'Inicio Real', 'Fin Real', 'Desvío Fin (días)',
|
||||
'Desvío Inicio (días)', 'SPI', 'En Plazo', 'Responsable', 'Template',
|
||||
'Última Inspección', 'Resultado', 'Inspecciones', 'Issues Abiertos'
|
||||
];
|
||||
}
|
||||
|
||||
public function array(): array
|
||||
{
|
||||
return array_map(function ($feature) {
|
||||
return [
|
||||
$feature['id'],
|
||||
$feature['name'],
|
||||
$feature['phase'],
|
||||
$feature['layer'],
|
||||
$feature['status_label'],
|
||||
$feature['progress'],
|
||||
$feature['planned_progress'],
|
||||
$feature['planned_start'] ?? '—',
|
||||
$feature['planned_end'] ?? '—',
|
||||
$feature['actual_start'] ?? '—',
|
||||
$feature['actual_end'] ?? '—',
|
||||
$feature['deviation_days'] ?? '—',
|
||||
$feature['start_deviation_days'] ?? '—',
|
||||
$feature['spi'] ?? '—',
|
||||
$feature['is_on_track'] === true ? 'Sí' : ($feature['is_on_track'] === false ? 'No' : 'N/A'),
|
||||
$feature['responsible'],
|
||||
$feature['template'],
|
||||
$feature['last_inspection_date'] ?? '—',
|
||||
$feature['last_inspection_result'] ?? '—',
|
||||
$feature['inspections_count'],
|
||||
$feature['open_issues_count'],
|
||||
];
|
||||
}, $this->rows);
|
||||
}
|
||||
|
||||
public function columnWidths(): array
|
||||
{
|
||||
return ['A' => 8, 'B' => 25, 'C' => 20, 'D' => 20, 'E' => 15, 'F' => 12,
|
||||
'G' => 14, 'H' => 12, 'I' => 12, 'J' => 12, 'K' => 12, 'L' => 14,
|
||||
'M' => 14, 'N' => 8, 'O' => 10, 'P' => 20, 'Q' => 20, 'R' => 14,
|
||||
'S' => 12, 'T' => 12, 'U' => 12];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Inspections Sheet
|
||||
// ============================================================
|
||||
|
||||
class InspectionsSheet extends BaseSheet
|
||||
{
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'ID', 'Elemento', 'Fase', 'Template', 'Inspector', 'Fecha',
|
||||
'Estado', 'Resultado', 'Notas', 'Fotos'
|
||||
];
|
||||
}
|
||||
|
||||
public function array(): array
|
||||
{
|
||||
return array_map(function ($inspection) {
|
||||
return [
|
||||
$inspection['id'],
|
||||
$inspection['feature'],
|
||||
$inspection['phase'],
|
||||
$inspection['template'],
|
||||
$inspection['inspector'],
|
||||
$inspection['date'],
|
||||
$inspection['status'],
|
||||
$inspection['result_label'],
|
||||
$inspection['notes'] ?? '—',
|
||||
$inspection['photos_count'],
|
||||
];
|
||||
}, $this->rows);
|
||||
}
|
||||
|
||||
public function columnWidths(): array
|
||||
{
|
||||
return ['A' => 8, 'B' => 25, 'C' => 20, 'D' => 25, 'E' => 20, 'F' => 18,
|
||||
'G' => 12, 'H' => 12, 'I' => 40, 'J' => 8];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Issues Sheet
|
||||
// ============================================================
|
||||
|
||||
class IssuesSheet extends BaseSheet
|
||||
{
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'ID', 'Título', 'Elemento', 'Fase', 'Prioridad', 'Estado',
|
||||
'Reportado por', 'Asignado a', 'Creado', 'Cerrado', 'Días abierto',
|
||||
'Tareas total', 'Tareas completadas'
|
||||
];
|
||||
}
|
||||
|
||||
public function array(): array
|
||||
{
|
||||
return array_map(function ($issue) {
|
||||
return [
|
||||
$issue['id'],
|
||||
$issue['title'],
|
||||
$issue['feature'],
|
||||
$issue['phase'],
|
||||
$issue['priority_label'],
|
||||
$issue['status_label'],
|
||||
$issue['reporter'],
|
||||
$issue['assignee'],
|
||||
$issue['created_at'],
|
||||
$issue['closed_at'] ?? '—',
|
||||
$issue['days_open'],
|
||||
$issue['tasks_total'],
|
||||
$issue['tasks_completed'],
|
||||
];
|
||||
}, $this->rows);
|
||||
}
|
||||
|
||||
public function columnWidths(): array
|
||||
{
|
||||
return ['A' => 8, 'B' => 35, 'C' => 20, 'D' => 20, 'E' => 12, 'F' => 15,
|
||||
'G' => 20, 'H' => 20, 'I' => 12, 'J' => 12, 'K' => 12, 'L' => 12, 'M' => 14];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Tasks Sheet
|
||||
// ============================================================
|
||||
|
||||
class TasksSheet extends BaseSheet
|
||||
{
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'ID', 'Tarea', 'Fase', 'Estado', 'Prioridad', 'Asignado', 'Creador',
|
||||
'Fecha inicio', 'Fecha fin', 'Completada', 'Horas est.', 'Horas real',
|
||||
'Progreso (%)', 'Vencida', 'Subtareas'
|
||||
];
|
||||
}
|
||||
|
||||
public function array(): array
|
||||
{
|
||||
return array_map(function ($task) {
|
||||
$subtasksStr = implode('; ', array_map(fn($st) => "{$st['title']} ({$st['status']})", $task['subtasks']));
|
||||
return [
|
||||
$task['id'],
|
||||
$task['title'],
|
||||
$task['phase'],
|
||||
$task['status_label'],
|
||||
$task['priority_label'],
|
||||
$task['assignee'],
|
||||
$task['creator'],
|
||||
$task['start_date'] ?? '—',
|
||||
$task['due_date'] ?? '—',
|
||||
$task['completed_at'] ?? '—',
|
||||
$task['estimated_hours'] ?? '—',
|
||||
$task['actual_hours'] ?? '—',
|
||||
$task['progress'],
|
||||
$task['is_overdue'] ? 'Sí' : 'No',
|
||||
$subtasksStr ?: '—',
|
||||
];
|
||||
}, $this->rows);
|
||||
}
|
||||
|
||||
public function columnWidths(): array
|
||||
{
|
||||
return ['A' => 8, 'B' => 30, 'C' => 20, 'D' => 15, 'E' => 12, 'F' => 20,
|
||||
'G' => 20, 'H' => 12, 'I' => 12, 'J' => 12, 'K' => 10, 'L' => 10,
|
||||
'M' => 12, 'N' => 10, 'O' => 40];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Deviations Sheet
|
||||
// ============================================================
|
||||
|
||||
class DeviationsSheet extends BaseSheet
|
||||
{
|
||||
protected array $deviations;
|
||||
|
||||
public function __construct(array $deviations)
|
||||
{
|
||||
$this->deviations = $deviations;
|
||||
$rows = [];
|
||||
|
||||
// Phase deviations
|
||||
if (!empty($deviations['phases'])) {
|
||||
$rows[] = ['=== DESVÍOS POR FASE ===', '', '', '', '', '', '', '', '', '', ''];
|
||||
$rows[] = ['ID', 'Fase', 'Inicio Plan', 'Fin Plan', 'Inicio Real', 'Fin Real',
|
||||
'Desvío Inicio (d)', 'Desvío Fin (d)', 'Prog. Plan (%)', 'Prog. Real (%)',
|
||||
'Desvío Prog.', 'SPI', 'En Plazo'];
|
||||
|
||||
foreach ($deviations['phases'] as $phase) {
|
||||
$rows[] = [
|
||||
$phase['id'], $phase['name'], $phase['planned_start'], $phase['planned_end'],
|
||||
$phase['actual_start'], $phase['actual_end'],
|
||||
$phase['start_deviation'] ?? '—', $phase['end_deviation'] ?? '—',
|
||||
$phase['planned_progress'], $phase['actual_progress'],
|
||||
$phase['progress_deviation'], $phase['spi'] ?? '—',
|
||||
$phase['is_on_track'] === true ? 'Sí' : ($phase['is_on_track'] === false ? 'No' : 'N/A'),
|
||||
];
|
||||
}
|
||||
$rows[] = ['', '', '', '', '', '', '', '', '', '', ''];
|
||||
}
|
||||
|
||||
// Feature deviations
|
||||
if (!empty($deviations['features'])) {
|
||||
$rows[] = ['=== DESVÍOS POR ELEMENTO ===', '', '', '', '', '', '', '', '', '', '', '', ''];
|
||||
$rows[] = ['ID', 'Elemento', 'Fase', 'Inicio Plan', 'Fin Plan', 'Inicio Real', 'Fin Real',
|
||||
'Desvío Inicio (d)', 'Desvío Fin (d)', 'Prog. Plan (%)', 'Prog. Real (%)',
|
||||
'Desvío Prog.', 'SPI', 'En Plazo', 'Responsable'];
|
||||
|
||||
foreach ($deviations['features'] as $feature) {
|
||||
$rows[] = [
|
||||
$feature['id'], $feature['name'], $feature['phase'],
|
||||
$feature['planned_start'], $feature['planned_end'],
|
||||
$feature['actual_start'], $feature['actual_end'],
|
||||
$feature['start_deviation'] ?? '—', $feature['end_deviation'] ?? '—',
|
||||
$feature['planned_progress'], $feature['actual_progress'],
|
||||
$feature['progress_deviation'], $feature['spi'] ?? '—',
|
||||
$feature['is_on_track'] === true ? 'Sí' : ($feature['is_on_track'] === false ? 'No' : 'N/A'),
|
||||
$feature['responsible'],
|
||||
];
|
||||
}
|
||||
$rows[] = ['', '', '', '', '', '', '', '', '', '', '', '', '', ''];
|
||||
}
|
||||
|
||||
// Summary
|
||||
if (!empty($deviations['summary'])) {
|
||||
$rows[] = ['=== RESUMEN DESVÍOS ===', ''];
|
||||
$rows[] = ['Fases retrasadas', $deviations['summary']['phases_delayed'] ?? 0];
|
||||
$rows[] = ['Fases adelantadas', $deviations['summary']['phases_early'] ?? 0];
|
||||
$rows[] = ['Fases en plazo', $deviations['summary']['phases_on_time'] ?? 0];
|
||||
$rows[] = ['Elementos retrasados', $deviations['summary']['features_delayed'] ?? 0];
|
||||
$rows[] = ['Elementos adelantados', $deviations['summary']['features_early'] ?? 0];
|
||||
$rows[] = ['Elementos en plazo', $deviations['summary']['features_on_time'] ?? 0];
|
||||
}
|
||||
|
||||
$this->rows = $rows;
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return []; // Headings included in data rows
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return 'Desvíos';
|
||||
}
|
||||
|
||||
public function columnWidths(): array
|
||||
{
|
||||
return ['A' => 25, 'B' => 25, 'C' => 20, 'D' => 12, 'E' => 12, 'F' => 12,
|
||||
'G' => 12, 'H' => 14, 'I' => 14, 'J' => 14, 'K' => 14, 'L' => 10,
|
||||
'M' => 10, 'N' => 20];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Media Sheet
|
||||
// ============================================================
|
||||
|
||||
class MediaSheet extends BaseSheet
|
||||
{
|
||||
public function headings(): array
|
||||
{
|
||||
return ['ID', 'Nombre', 'Tipo', 'Entidad', 'Entidad Nombre', 'Tamaño', 'Subido por', 'Fecha', 'URL'];
|
||||
}
|
||||
|
||||
public function array(): array
|
||||
{
|
||||
return array_map(function ($media) {
|
||||
return [
|
||||
$media['id'],
|
||||
$media['name'],
|
||||
$media['type'],
|
||||
$media['entity'],
|
||||
$media['entity_name'],
|
||||
$media['size'],
|
||||
$media['uploaded_by'],
|
||||
$media['uploaded_at'],
|
||||
$media['url'],
|
||||
];
|
||||
}, $this->rows);
|
||||
}
|
||||
|
||||
public function columnWidths(): array
|
||||
{
|
||||
return ['A' => 8, 'B' => 30, 'C' => 12, 'D' => 15, 'E' => 30, 'F' => 12,
|
||||
'G' => 20, 'H' => 18, 'I' => 50];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Progress Curve Sheet
|
||||
// ============================================================
|
||||
|
||||
class ProgressCurveSheet extends BaseSheet
|
||||
{
|
||||
protected array $curveData;
|
||||
|
||||
public function __construct(array $curveData)
|
||||
{
|
||||
$this->curveData = $curveData;
|
||||
|
||||
$rows = [['Fecha', 'Progreso Planificado (%)', 'Progreso Real (%)']];
|
||||
|
||||
$labels = $curveData['labels'] ?? [];
|
||||
$planned = $curveData['planned'] ?? [];
|
||||
$actual = $curveData['actual'] ?? [];
|
||||
|
||||
for ($i = 0; $i < count($labels); $i++) {
|
||||
$rows[] = [$labels[$i], $planned[$i] ?? 0, $actual[$i] ?? 0];
|
||||
}
|
||||
|
||||
$this->rows = $rows;
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return ['Fecha', 'Progreso Planificado (%)', 'Progreso Real (%)'];
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return 'Curva S';
|
||||
}
|
||||
|
||||
public function columnWidths(): array
|
||||
{
|
||||
return ['A' => 14, 'B' => 22, 'C' => 20];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Parameters Sheet
|
||||
// ============================================================
|
||||
|
||||
class ParametersSheet extends BaseSheet
|
||||
{
|
||||
protected ReportFilters $filters;
|
||||
protected Project $project;
|
||||
|
||||
public function __construct(ReportFilters $filters, Project $project)
|
||||
{
|
||||
$this->filters = $filters;
|
||||
$this->project = $project;
|
||||
|
||||
$rows = [
|
||||
['Parámetro', 'Valor'],
|
||||
['Proyecto', $project->name],
|
||||
['Referencia', $project->reference ?? '—'],
|
||||
['Fecha desde', $filters->dateFrom?->format('d/m/Y') ?? '—'],
|
||||
['Fecha hasta', $filters->dateTo?->format('d/m/Y') ?? '—'],
|
||||
['Entidades incluidas', implode(', ', array_map(
|
||||
fn($e) => $filters->getAvailableEntities()[$e] ?? $e,
|
||||
$filters->entityTypes
|
||||
))],
|
||||
['Incluir fotos', $filters->includePhotos ? 'Sí' : 'No'],
|
||||
['Formato', strtoupper($filters->format)],
|
||||
['Incluir gráficos', $filters->includeCharts ? 'Sí' : 'No'],
|
||||
['Generado', now()->format('d/m/Y H:i')],
|
||||
['Generado por', auth()->guard()->user()?->name ?? 'Sistema'],
|
||||
];
|
||||
|
||||
parent::__construct($rows);
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return ['Parámetro', 'Valor'];
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return 'Parámetros';
|
||||
}
|
||||
|
||||
public function columnWidths(): array
|
||||
{
|
||||
return ['A' => 30, 'B' => 50];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\DTO\ReportFilters;
|
||||
use App\Models\Project;
|
||||
use App\Services\ReportGenerator;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use App\Exports\ProjectReportExport;
|
||||
|
||||
class ReportController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show report builder for a project
|
||||
*/
|
||||
public function show(Project $project)
|
||||
{
|
||||
$this->authorizeProjectAccess($project);
|
||||
|
||||
return view('reports.builder', [
|
||||
'project' => $project,
|
||||
'availableEntities' => (new ReportFilters)->getAvailableEntities(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate report (HTML preview or download)
|
||||
*/
|
||||
public function generate(Request $request, Project $project)
|
||||
{
|
||||
$this->authorizeProjectAccess($project);
|
||||
|
||||
$filters = ReportFilters::fromRequest($request->all());
|
||||
$generator = new ReportGenerator($project, $filters);
|
||||
$data = $generator->generate();
|
||||
|
||||
$format = $filters->format;
|
||||
|
||||
if ($format === 'excel') {
|
||||
return $this->downloadExcel($project, $filters, $data);
|
||||
}
|
||||
|
||||
// HTML format (default) - render printable view
|
||||
return view('reports.complete', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview partial (AJAX)
|
||||
*/
|
||||
public function preview(Request $request, Project $project)
|
||||
{
|
||||
$this->authorizeProjectAccess($project);
|
||||
|
||||
$filters = ReportFilters::fromRequest($request->all());
|
||||
$generator = new ReportGenerator($project, $filters);
|
||||
$data = $generator->generate();
|
||||
|
||||
return view('reports.partials._preview', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download Excel export
|
||||
*/
|
||||
protected function downloadExcel(Project $project, ReportFilters $filters, array $data)
|
||||
{
|
||||
$export = new ProjectReportExport($project, $filters, $data);
|
||||
$filename = 'informe_' . $project->name . '_' . now()->format('Ymd_His') . '.xlsx';
|
||||
|
||||
return Excel::download($export, $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize access to project
|
||||
*/
|
||||
protected function authorizeProjectAccess(Project $project): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if ($user->can('manage all')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$project->users()->where('user_id', $user->id)->exists()) {
|
||||
abort(403, 'No tienes acceso a este proyecto.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Feature;
|
||||
use App\Models\Phase;
|
||||
use App\Models\ProgressSnapshot;
|
||||
use App\Models\Task;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CaptureDailyProgressSnapshot implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public ?string $snapshotDate = null
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$date = $this->snapshotDate ? Carbon::parse($this->snapshotDate) : Carbon::today();
|
||||
|
||||
Log::info('Starting daily progress snapshot capture', ['date' => $date->toDateString()]);
|
||||
|
||||
$captured = 0;
|
||||
|
||||
// Capture Phase snapshots
|
||||
$captured += $this->capturePhaseSnapshots($date);
|
||||
|
||||
// Capture Feature snapshots
|
||||
$captured += $this->captureFeatureSnapshots($date);
|
||||
|
||||
// Capture Task snapshots
|
||||
$captured += $this->captureTaskSnapshots($date);
|
||||
|
||||
Log::info('Daily progress snapshot capture completed', [
|
||||
'date' => $date->toDateString(),
|
||||
'snapshots_captured' => $captured,
|
||||
]);
|
||||
}
|
||||
|
||||
private function capturePhaseSnapshots(Carbon $date): int
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
Phase::with(['project'])->chunkById(100, function ($phases) use ($date, &$count) {
|
||||
foreach ($phases as $phase) {
|
||||
// Skip if snapshot already exists for this date
|
||||
if (ProgressSnapshot::where('trackable_type', Phase::class)
|
||||
->where('trackable_id', $phase->id)
|
||||
->where('snapshot_date', $date)
|
||||
->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$metadata = [
|
||||
'project_id' => $phase->project_id,
|
||||
'spi' => $phase->spi,
|
||||
'is_on_track' => $phase->is_on_track,
|
||||
'deviation_days' => $phase->deviation_days,
|
||||
'start_deviation_days' => $phase->start_deviation_days,
|
||||
];
|
||||
|
||||
ProgressSnapshot::create([
|
||||
'trackable_type' => Phase::class,
|
||||
'trackable_id' => $phase->id,
|
||||
'snapshot_date' => $date,
|
||||
'progress' => $phase->progress_percent,
|
||||
'planned_start' => $phase->planned_start,
|
||||
'planned_end' => $phase->planned_end,
|
||||
'actual_start' => $phase->actual_start,
|
||||
'actual_end' => $phase->actual_end,
|
||||
'method' => 'manual',
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
|
||||
$count++;
|
||||
}
|
||||
});
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function captureFeatureSnapshots(Carbon $date): int
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
Feature::with(['layer.phase.project'])->chunkById(200, function ($features) use ($date, &$count) {
|
||||
foreach ($features as $feature) {
|
||||
if (ProgressSnapshot::where('trackable_type', Feature::class)
|
||||
->where('trackable_id', $feature->id)
|
||||
->where('snapshot_date', $date)
|
||||
->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$metadata = [
|
||||
'project_id' => $feature->layer->phase->project_id ?? null,
|
||||
'phase_id' => $feature->layer->phase_id ?? null,
|
||||
'layer_id' => $feature->layer_id,
|
||||
'spi' => $feature->spi,
|
||||
'is_on_track' => $feature->is_on_track,
|
||||
'deviation_days' => $feature->deviation_days,
|
||||
'start_deviation_days' => $feature->start_deviation_days,
|
||||
'status' => $feature->status,
|
||||
];
|
||||
|
||||
ProgressSnapshot::create([
|
||||
'trackable_type' => Feature::class,
|
||||
'trackable_id' => $feature->id,
|
||||
'snapshot_date' => $date,
|
||||
'progress' => $feature->progress,
|
||||
'planned_start' => $feature->planned_start,
|
||||
'planned_end' => $feature->planned_end,
|
||||
'actual_start' => $feature->actual_start,
|
||||
'actual_end' => $feature->actual_end,
|
||||
'method' => 'manual',
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
|
||||
$count++;
|
||||
}
|
||||
});
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function captureTaskSnapshots(Carbon $date): int
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
Task::with(['project', 'phase'])->chunkById(200, function ($tasks) use ($date, &$count) {
|
||||
foreach ($tasks as $task) {
|
||||
if (ProgressSnapshot::where('trackable_type', Task::class)
|
||||
->where('trackable_id', $task->id)
|
||||
->where('snapshot_date', $date)
|
||||
->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$metadata = [
|
||||
'project_id' => $task->project_id,
|
||||
'phase_id' => $task->phase_id,
|
||||
'status' => $task->status,
|
||||
'priority' => $task->priority,
|
||||
'assigned_to' => $task->assigned_to,
|
||||
'is_overdue' => $task->is_overdue,
|
||||
'estimated_hours' => $task->estimated_hours,
|
||||
'actual_hours' => $task->actual_hours,
|
||||
];
|
||||
|
||||
ProgressSnapshot::create([
|
||||
'trackable_type' => Task::class,
|
||||
'trackable_id' => $task->id,
|
||||
'snapshot_date' => $date,
|
||||
'progress' => $task->progress,
|
||||
'planned_start' => $task->start_date,
|
||||
'planned_end' => $task->due_date,
|
||||
'actual_start' => $task->start_date,
|
||||
'actual_end' => $task->completed_at?->toDateString(),
|
||||
'method' => 'task',
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
|
||||
$count++;
|
||||
}
|
||||
});
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Reports;
|
||||
|
||||
use App\DTO\ReportFilters;
|
||||
use App\Models\Project;
|
||||
use App\Services\ReportGenerator;
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\Layout;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class ReportBuilder extends Component
|
||||
{
|
||||
public Project $project;
|
||||
|
||||
public array $filters = [
|
||||
'date_from' => null,
|
||||
'date_to' => null,
|
||||
'entity_types' => ['phases', 'features', 'inspections', 'issues', 'tasks'],
|
||||
'include_photos' => false,
|
||||
'include_charts' => false,
|
||||
'format' => 'html',
|
||||
];
|
||||
|
||||
public bool $showPreview = false;
|
||||
public ?array $previewData = null;
|
||||
|
||||
public function mount(Project $project)
|
||||
{
|
||||
$this->project = $project;
|
||||
$this->authorizeAccess();
|
||||
}
|
||||
|
||||
public function authorizeAccess(): void
|
||||
{
|
||||
$user = auth()->guard()->user();
|
||||
if (!$user->can('manage all') && !$this->project->users()->where('user_id', $user->id)->exists()) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
public function setDateRange(string $preset): void
|
||||
{
|
||||
$now = now();
|
||||
match ($preset) {
|
||||
'week' => [
|
||||
$this->filters['date_from'] = $now->copy()->startOfWeek()->toDateString(),
|
||||
$this->filters['date_to'] = $now->copy()->endOfWeek()->toDateString(),
|
||||
],
|
||||
'month' => [
|
||||
$this->filters['date_from'] = $now->copy()->startOfMonth()->toDateString(),
|
||||
$this->filters['date_to'] = $now->copy()->endOfMonth()->toDateString(),
|
||||
],
|
||||
'quarter' => [
|
||||
$this->filters['date_from'] = $now->copy()->startOfQuarter()->toDateString(),
|
||||
$this->filters['date_to'] = $now->copy()->endOfQuarter()->toDateString(),
|
||||
],
|
||||
'year' => [
|
||||
$this->filters['date_from'] = $now->copy()->startOfYear()->toDateString(),
|
||||
$this->filters['date_to'] = $now->copy()->endOfYear()->toDateString(),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
public function clearDateRange(): void
|
||||
{
|
||||
$this->filters['date_from'] = null;
|
||||
$this->filters['date_to'] = null;
|
||||
}
|
||||
|
||||
public function generateReport(): void
|
||||
{
|
||||
$this->validate([
|
||||
'filters.date_from' => 'nullable|date',
|
||||
'filters.date_to' => 'nullable|date|after_or_equal:filters.date_from',
|
||||
'filters.entity_types' => 'required|array|min:1',
|
||||
'filters.format' => 'required|in:html,excel',
|
||||
]);
|
||||
|
||||
$reportFilters = ReportFilters::fromRequest($this->filters);
|
||||
$generator = new ReportGenerator($this->project, $reportFilters);
|
||||
$data = $generator->generate();
|
||||
|
||||
if ($this->filters['format'] === 'excel') {
|
||||
// Redirect to download route
|
||||
return redirect()->route('reports.project.excel', [
|
||||
'project' => $this->project,
|
||||
...$reportFilters->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
// For HTML, we'll render the complete report view
|
||||
// The view will be returned by the controller
|
||||
$this->redirectRoute('reports.project.generate', [
|
||||
'project' => $this->project,
|
||||
...$reportFilters->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function previewReport(): void
|
||||
{
|
||||
$this->validate([
|
||||
'filters.date_from' => 'nullable|date',
|
||||
'filters.date_to' => 'nullable|date|after_or_equal:filters.date_from',
|
||||
'filters.entity_types' => 'required|array|min:1',
|
||||
]);
|
||||
|
||||
$reportFilters = ReportFilters::fromRequest($this->filters);
|
||||
$generator = new ReportGenerator($this->project, $reportFilters);
|
||||
$this->previewData = $generator->generate();
|
||||
$this->showPreview = true;
|
||||
}
|
||||
|
||||
public function closePreview(): void
|
||||
{
|
||||
$this->showPreview = false;
|
||||
$this->previewData = null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.reports.report-builder', [
|
||||
'project' => $this->project,
|
||||
'availableEntities' => (new ReportFilters)->getAvailableEntities(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+82
-3
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use App\Traits\LogsActivity;
|
||||
|
||||
@@ -16,12 +17,20 @@ class Feature extends Model
|
||||
'layer_id', 'name', 'geometry', 'properties', 'template_id', 'feature_type_id',
|
||||
'progress', 'status', 'is_active', 'responsible', 'responsible_user_id',
|
||||
'uuid', 'client_updated_at',
|
||||
'planned_start', 'planned_end', 'actual_start', 'actual_end',
|
||||
'baseline_start', 'baseline_end',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'geometry' => 'array',
|
||||
'properties' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'geometry' => 'array',
|
||||
'properties' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'planned_start' => 'date',
|
||||
'planned_end' => 'date',
|
||||
'actual_start' => 'date',
|
||||
'actual_end' => 'date',
|
||||
'baseline_start' => 'date',
|
||||
'baseline_end' => 'date',
|
||||
];
|
||||
|
||||
public function featureType()
|
||||
@@ -64,6 +73,11 @@ class Feature extends Model
|
||||
return $this->morphMany(Media::class, 'mediable')->where('category', 'image');
|
||||
}
|
||||
|
||||
public function progressSnapshots(): MorphMany
|
||||
{
|
||||
return $this->morphMany(ProgressSnapshot::class, 'trackable');
|
||||
}
|
||||
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
return match($this->status) {
|
||||
@@ -75,4 +89,69 @@ class Feature extends Model
|
||||
default => '#6b7280',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deviation in days: positive = delayed, negative = early
|
||||
*/
|
||||
public function getDeviationDaysAttribute(): ?int
|
||||
{
|
||||
if (! $this->planned_end) {
|
||||
return null;
|
||||
}
|
||||
$end = $this->actual_end ?? now()->toDateString();
|
||||
|
||||
return $this->planned_end->diffInDays($end, false);
|
||||
}
|
||||
|
||||
public function getStartDeviationDaysAttribute(): ?int
|
||||
{
|
||||
if (! $this->planned_start) {
|
||||
return null;
|
||||
}
|
||||
$start = $this->actual_start ?? now()->toDateString();
|
||||
|
||||
return $this->planned_start->diffInDays($start, false);
|
||||
}
|
||||
|
||||
public function getPlannedProgressAtAttribute(?\Carbon\Carbon $date = null): float
|
||||
{
|
||||
$date = $date ?? now();
|
||||
|
||||
if (! $this->planned_start || ! $this->planned_end) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($date <= $this->planned_start) {
|
||||
return 0;
|
||||
}
|
||||
if ($date >= $this->planned_end) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
$totalDays = $this->planned_start->diffInDays($this->planned_end);
|
||||
$elapsedDays = $this->planned_start->diffInDays($date);
|
||||
|
||||
return round(($elapsedDays / $totalDays) * 100, 2);
|
||||
}
|
||||
|
||||
public function getSpiAttribute(): ?float
|
||||
{
|
||||
$pv = $this->planned_progress_at;
|
||||
$ev = $this->progress;
|
||||
|
||||
if ($pv <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round($ev / $pv, 2);
|
||||
}
|
||||
|
||||
public function getIsOnTrackAttribute(): ?bool
|
||||
{
|
||||
$spi = $this->spi;
|
||||
if ($spi === null) {
|
||||
return null;
|
||||
}
|
||||
return $spi >= 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
+78
-2
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Phase extends Model
|
||||
@@ -12,6 +13,7 @@ class Phase extends Model
|
||||
protected $fillable = [
|
||||
'project_id', 'name', 'description', 'order', 'color', 'progress_percent',
|
||||
'planned_start', 'planned_end', 'actual_start', 'actual_end',
|
||||
'baseline_start', 'baseline_end',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -19,6 +21,8 @@ class Phase extends Model
|
||||
'planned_end' => 'date',
|
||||
'actual_start' => 'date',
|
||||
'actual_end' => 'date',
|
||||
'baseline_start' => 'date',
|
||||
'baseline_end' => 'date',
|
||||
];
|
||||
|
||||
public function project()
|
||||
@@ -61,13 +65,85 @@ class Phase extends Model
|
||||
return $this->hasMany(Task::class);
|
||||
}
|
||||
|
||||
public function progressSnapshots(): MorphMany
|
||||
{
|
||||
return $this->morphMany(ProgressSnapshot::class, 'trackable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deviation in days: positive = delayed, negative = early
|
||||
*/
|
||||
public function getDeviationDaysAttribute(): ?int
|
||||
{
|
||||
if (! $this->planned_end) {
|
||||
return null;
|
||||
}
|
||||
$end = $this->actual_end ?? now();
|
||||
|
||||
$end = $this->actual_end ?? now()->toDateString();
|
||||
|
||||
return $this->planned_end->diffInDays($end, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start deviation: positive = delayed start, negative = early start
|
||||
*/
|
||||
public function getStartDeviationDaysAttribute(): ?int
|
||||
{
|
||||
if (! $this->planned_start) {
|
||||
return null;
|
||||
}
|
||||
$start = $this->actual_start ?? now()->toDateString();
|
||||
|
||||
return $this->planned_start->diffInDays($start, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Planned progress at a given date (linear interpolation)
|
||||
*/
|
||||
public function getPlannedProgressAtAttribute(?\Carbon\Carbon $date = null): float
|
||||
{
|
||||
$date = $date ?? now();
|
||||
|
||||
if (! $this->planned_start || ! $this->planned_end) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($date <= $this->planned_start) {
|
||||
return 0;
|
||||
}
|
||||
if ($date >= $this->planned_end) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
$totalDays = $this->planned_start->diffInDays($this->planned_end);
|
||||
$elapsedDays = $this->planned_start->diffInDays($date);
|
||||
|
||||
return round(($elapsedDays / $totalDays) * 100, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule Performance Index (SPI) = EV / PV
|
||||
*/
|
||||
public function getSpiAttribute(): ?float
|
||||
{
|
||||
$pv = $this->planned_progress_at; // Planned Value
|
||||
$ev = $this->progress_percent; // Earned Value (current progress)
|
||||
|
||||
if ($pv <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round($ev / $pv, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if phase is on track (SPI >= 0.95)
|
||||
*/
|
||||
public function getIsOnTrackAttribute(): ?bool
|
||||
{
|
||||
$spi = $this->spi;
|
||||
if ($spi === null) {
|
||||
return null;
|
||||
}
|
||||
return $spi >= 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class ProgressSnapshot extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'trackable_type',
|
||||
'trackable_id',
|
||||
'snapshot_date',
|
||||
'progress',
|
||||
'planned_start',
|
||||
'planned_end',
|
||||
'actual_start',
|
||||
'actual_end',
|
||||
'method',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'snapshot_date' => 'date',
|
||||
'planned_start' => 'date',
|
||||
'planned_end' => 'date',
|
||||
'actual_start' => 'date',
|
||||
'actual_end' => 'date',
|
||||
'progress' => 'decimal:2',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
public function trackable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope for a specific date range
|
||||
*/
|
||||
public function scopeDateRange($query, $from, $to)
|
||||
{
|
||||
return $query->whereBetween('snapshot_date', [$from, $to]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope for specific trackable
|
||||
*/
|
||||
public function scopeForTrackable($query, $model, $id = null)
|
||||
{
|
||||
$query->where('trackable_type', $model);
|
||||
if ($id) {
|
||||
$query->where('trackable_id', $id);
|
||||
}
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest snapshot for a trackable
|
||||
*/
|
||||
public static function latestFor($model, $id): ?self
|
||||
{
|
||||
return static::forTrackable($model, $id)
|
||||
->latest('snapshot_date')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress trend (last N snapshots)
|
||||
*/
|
||||
public static function trendFor($model, $id, int $limit = 10): array
|
||||
{
|
||||
return static::forTrackable($model, $id)
|
||||
->orderBy('snapshot_date')
|
||||
->take($limit)
|
||||
->get(['snapshot_date', 'progress', 'method'])
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,11 @@ class Task extends Model
|
||||
return $this->morphMany(ActivityLog::class, 'model');
|
||||
}
|
||||
|
||||
public function progressSnapshots(): MorphMany
|
||||
{
|
||||
return $this->morphMany(ProgressSnapshot::class, 'trackable');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Scopes
|
||||
// ============================================================
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
<?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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user