ProjectStatus::class, 'contract_value' => 'decimal:2', 'total_estimated_value' => 'decimal:2', 'total_capitalization' => 'decimal:2', 'completion_percentage' => 'decimal:2', 'contract_duration' => 'integer', 'start_date' => 'date', 'target_end_date' => 'date', 'actual_end_date' => 'date', 'is_unprofitable' => 'boolean', ]; } protected static function booted(): void { static::creating(function (self $project) { if (empty($project->code)) { $year = now()->year; $lastCode = static::withoutGlobalScopes() ->withTrashed() ->where('code', 'like', "PRJ-{$year}-%") ->orderByDesc('code') ->value('code'); $next = 1; if ($lastCode && preg_match('/PRJ-\d{4}-(\d+)/', $lastCode, $m)) { $next = (int) $m[1] + 1; } $project->code = sprintf('PRJ-%d-%03d', $year, $next); } }); } // --- Relationships --- public function customer(): BelongsTo { return $this->belongsTo(User::class, 'customer_id'); } public function warehouse() { return $this->hasOne(Warehouse::class); } public function inventories(): HasMany { return $this->hasMany(ProjectInventory::class); } public function contractor(): BelongsTo { return $this->belongsTo(Contractor::class); } public function tasks(): HasMany { return $this->hasMany(Task::class)->orderBy('sort_order'); } public function milestones(): HasMany { return $this->hasMany(ProjectMilestone::class)->orderBy('sort_order'); } public function statusReports(): HasMany { return $this->hasMany(WeeklyStatusReport::class)->orderByDesc('period_end'); } public function personnel(): BelongsToMany { return $this->belongsToMany(User::class, 'project_user') ->withPivot('role') ->withTimestamps(); } public function materialGroups(): BelongsToMany { return $this->belongsToMany(MaterialGroup::class, 'project_material_groups') ->withTimestamps(); } public function bidPackages(): HasMany { return $this->hasMany(BidPackage::class); } public function parentProject(): BelongsTo { return $this->belongsTo(Project::class, 'parent_project_id'); } public function extensionProjects(): HasMany { return $this->hasMany(Project::class, 'parent_project_id'); } public function materialsEstimates(): HasMany { return $this->hasMany(ProjectMaterialsEstimate::class); } // --- State Machine Helpers --- public function transitionTo(ProjectStatus $newStatus): void { $currentStatus = $this->status; if (! in_array($newStatus, $currentStatus->allowedTransitions())) { throw new \InvalidArgumentException( "Cannot transition from {$currentStatus->label()} to {$newStatus->label()}" ); } // Guard: can't move to InProgress without assigned PM if ($newStatus === ProjectStatus::InProgress) { $hasPm = $this->personnel()->wherePivot('role', 'pm')->exists(); if (! $hasPm) { throw new \InvalidArgumentException('Cannot start project without a Project Manager assigned'); } } // Guard: prevent parent project from being completed/closed until all of its child projects are completed/closed if (in_array($newStatus, [ProjectStatus::Completed, ProjectStatus::Closed])) { $uncompletedChildren = $this->extensionProjects() ->whereNotIn('status', [ProjectStatus::Completed->value, ProjectStatus::Closed->value]) ->exists(); if ($uncompletedChildren) { throw new \InvalidArgumentException('Cannot complete or close parent project while there are active child projects (extensions).'); } } $this->update(['status' => $newStatus]); } // --- Scopes --- public function scopeStatus($query, ProjectStatus $status) { return $query->where('status', $status); } public function scopeActive($query) { return $query->whereNotIn('status', [ProjectStatus::Completed, ProjectStatus::Closed]); } // --- Accessors --- public function getProjectManagerAttribute(): ?User { return $this->personnel()->wherePivot('role', 'pm')->first(); } public function getCapitalizationPercentageAttribute(): float { if ($this->contract_value <= 0) { return 0; } return round(($this->total_capitalization / $this->contract_value) * 100, 2); } public function getIsOverBudgetAttribute(): bool { return $this->total_capitalization > $this->contract_value && $this->contract_value > 0; } public function recalculateCapitalization(): void { $total = $this->tasks->sum(fn (Task $task) => $task->total_cost); $this->update(['total_capitalization' => $total]); } public function getMilestoneCompletionAttribute(): float { $milestones = $this->milestones()->with('tasks')->get(); if ($milestones->isEmpty()) { return 0; } $totalProgress = 0; foreach ($milestones as $milestone) { $tasks = $milestone->tasks; $milestoneProgress = 0; if ($tasks->isNotEmpty()) { $milestoneProgress = $tasks->avg('completion_percentage') ?? 0; } elseif ($milestone->actual_date !== null) { $milestoneProgress = 100; } $totalProgress += ($milestoneProgress * (float) $milestone->weight_percentage) / 100; } return round($totalProgress, 2); } public function getRollupCapitalizationAttribute(): float { $childSum = $this->extensionProjects->sum(fn ($child) => $child->rollup_capitalization); return (float) $this->total_capitalization + $childSum; } public function getRollupEstimatedValueAttribute(): float { $childSum = $this->extensionProjects->sum(fn ($child) => $child->rollup_estimated_value); return (float) $this->total_estimated_value + $childSum; } public function getRollupContractValueAttribute(): float { $childSum = $this->extensionProjects->sum(fn ($child) => $child->rollup_contract_value); return (float) $this->contract_value + $childSum; } public function getWeatherDelayDaysAttribute(): int { $milestoneDays = (int) $this->milestones->sum('weather_delay_days'); $taskDelayHours = (float) $this->tasks->flatMap( fn (Task $task) => $task->delays )->where('reason_type', DelayReason::Weather) ->sum('lost_hours'); return $milestoneDays + (int) ceil($taskDelayHours / 8); } public function onApprovalCompleted($chain): void { if ($chain->status->value === 'approved') { $this->update([ 'status' => ProjectStatus::InProgress, 'current_wizard_step' => 8, ]); } elseif ($chain->status->value === 'rejected') { $this->update([ 'current_wizard_step' => 6, ]); } } }