Files
GSB-Construction/Modules/ProjectManagement/app/Models/Project.php
2026-05-25 17:39:57 +08:00

214 lines
6.0 KiB
PHP

<?php
namespace Modules\ProjectManagement\Models;
use App\Models\User;
use App\Traits\BelongsToTenant;
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 Illuminate\Database\Eloquent\SoftDeletes;
use Modules\BiddingManagement\Models\BidPackage;
use Modules\ContractorManagement\Models\Contractor;
use Modules\MasterData\Models\MaterialGroup;
use Modules\MaterialLogistics\Models\ProjectInventory;
use Modules\MaterialLogistics\Models\Warehouse;
use Modules\ProjectManagement\Enums\DelayReason;
use Modules\ProjectManagement\Enums\ProjectStatus;
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::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);
}
// --- 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', DelayReason::Weather)
->sum('lost_hours');
return $milestoneDays + (int) ceil($taskDelayHours / 8);
}
}