ProjectStatus::class, 'contract_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', ]; } protected static function booted(): void { static::creating(function (self $project) { if (empty($project->code)) { $year = now()->year; $lastCode = static::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(\Modules\MaterialLogistics\Models\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); } // --- 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'); } } $this->update(['status' => $newStatus]); } // --- Scopes --- public function scopeStatus($query, ProjectStatus $status) { return $query->where('status', $status); } // --- 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; if ($milestones->isEmpty()) return 0; return (float) $milestones ->where('actual_date', '!=', null) ->sum('weight_percentage'); } 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', \Modules\ProjectManagement\Enums\DelayReason::Weather) ->sum('lost_hours'); return $milestoneDays + (int) ceil($taskDelayHours / 8); } }