207 lines
5.9 KiB
PHP
207 lines
5.9 KiB
PHP
<?php
|
|
|
|
namespace Modules\ProjectManagement\Models;
|
|
|
|
use App\Models\User;
|
|
use App\Traits\BelongsToTenant;
|
|
use Illuminate\Support\Str;
|
|
use App\Traits\HasPublicIdentifier;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
use Modules\BiddingManagement\Models\BidPackage;
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
use Modules\ContractorManagement\Models\Contractor;
|
|
use Modules\MasterData\Models\MaterialGroup;
|
|
use Modules\ProjectManagement\Enums\ProjectStatus;
|
|
use Modules\MaterialLogistics\Models\Warehouse;
|
|
|
|
class Project extends Model
|
|
{
|
|
use BelongsToTenant, HasPublicIdentifier, SoftDeletes;
|
|
|
|
protected $fillable = [
|
|
'name',
|
|
'code',
|
|
'description',
|
|
'customer_id',
|
|
'client_name',
|
|
'contractor_id',
|
|
'location',
|
|
'status',
|
|
'contract_value',
|
|
'contract_duration',
|
|
'start_date',
|
|
'target_end_date',
|
|
'actual_end_date',
|
|
'completion_percentage',
|
|
'total_capitalization',
|
|
];
|
|
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
'status' => 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);
|
|
}
|
|
}
|