379 lines
13 KiB
PHP
379 lines
13 KiB
PHP
<?php
|
|
|
|
namespace Modules\ProjectManagement\Models;
|
|
|
|
use App\Models\User;
|
|
use App\Traits\BelongsToTenant;
|
|
use App\Traits\HasPublicIdentifier;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
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\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, HasFactory, HasPublicIdentifier, SoftDeletes;
|
|
|
|
protected static function newFactory()
|
|
{
|
|
return \Modules\ProjectManagement\Database\Factories\ProjectFactory::new();
|
|
}
|
|
|
|
protected $fillable = [
|
|
'name',
|
|
'code',
|
|
'description',
|
|
'customer_id',
|
|
'client_name',
|
|
'contractor_id',
|
|
'location',
|
|
'status',
|
|
'contract_value',
|
|
'total_estimated_value',
|
|
'contract_duration',
|
|
'start_date',
|
|
'target_end_date',
|
|
'actual_end_date',
|
|
'completion_percentage',
|
|
'total_capitalization',
|
|
'parent_project_id',
|
|
'project_type',
|
|
'classifications',
|
|
'is_unprofitable',
|
|
'current_wizard_step',
|
|
];
|
|
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
'status' => 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',
|
|
'classifications' => 'array',
|
|
];
|
|
}
|
|
|
|
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 contractors(): BelongsToMany
|
|
{
|
|
return $this->belongsToMany(Contractor::class, 'project_contractor')
|
|
->withPivot('role', 'contract_amount')
|
|
->withTimestamps();
|
|
}
|
|
|
|
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 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);
|
|
}
|
|
|
|
public function projectClassifications(): BelongsToMany
|
|
{
|
|
return $this->belongsToMany(ProjectClassification::class, 'project_classification_pivot');
|
|
}
|
|
|
|
public const CANONICAL_CLASSIFICATIONS = [
|
|
'road highway, pavement, railways, airport horizontal structures and bridges' => 'Road, Highway, Pavement, Railways, Airport Horizontal Structures and Bridges',
|
|
'road, highway, pavement, railways, airport horizontal structures and bridges' => 'Road, Highway, Pavement, Railways, Airport Horizontal Structures and Bridges',
|
|
'irrigation and flood control' => 'Irrigation and Flood Control',
|
|
'dam, reservoir, and tunneling' => 'Dam, Reservoir, and Tunneling',
|
|
'dam, reservoir and tunneling' => 'Dam, Reservoir, and Tunneling',
|
|
'water supply' => 'Water Supply',
|
|
'port, harbor and offshore engineering' => 'Port, Harbor and Offshore Engineering',
|
|
'building and industrial plant' => 'Building and Industrial Plant',
|
|
'sewerage treatment/disposal plant' => 'Sewerage Treatment / Disposal Plant',
|
|
'sewerage treatment / disposal plant' => 'Sewerage Treatment / Disposal Plant',
|
|
'water treatment plant and system' => 'Water Treatment Plant and System',
|
|
'park, playground and recreational work' => 'Park, Playground and Recreational Work',
|
|
'electrical work' => 'Electrical Work',
|
|
];
|
|
|
|
/**
|
|
* Ensure classifications always return properly formatted Title Case strings.
|
|
*/
|
|
public function getClassificationsAttribute($value): array
|
|
{
|
|
if (is_null($value)) {
|
|
return [];
|
|
}
|
|
$decoded = is_string($value) ? json_decode($value, true) : $value;
|
|
if (!is_array($decoded)) {
|
|
return [];
|
|
}
|
|
|
|
return array_values(array_map(function ($name) {
|
|
$trimmed = trim((string) $name);
|
|
$lower = strtolower($trimmed);
|
|
return self::CANONICAL_CLASSIFICATIONS[$lower] ?? (self::CANONICAL_CLASSIFICATIONS[$trimmed] ?? $trimmed);
|
|
}, $decoded));
|
|
}
|
|
|
|
/**
|
|
* Mutate classifications on set to canonical Title Case strings.
|
|
*/
|
|
public function setClassificationsAttribute($value): void
|
|
{
|
|
if (is_null($value)) {
|
|
$this->attributes['classifications'] = json_encode([]);
|
|
return;
|
|
}
|
|
|
|
$array = is_string($value) ? json_decode($value, true) : $value;
|
|
if (!is_array($array)) {
|
|
$array = [];
|
|
}
|
|
|
|
$canonical = array_values(array_map(function ($name) {
|
|
$trimmed = trim((string) $name);
|
|
$lower = strtolower($trimmed);
|
|
return self::CANONICAL_CLASSIFICATIONS[$lower] ?? (self::CANONICAL_CLASSIFICATIONS[$trimmed] ?? $trimmed);
|
|
}, $array));
|
|
|
|
$this->attributes['classifications'] = json_encode($canonical);
|
|
}
|
|
|
|
// --- 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
|
|
{
|
|
$tasksTotal = (float) $this->tasks->sum(fn (Task $task) => $task->total_cost);
|
|
|
|
$unestimatedMaterialsTotal = 0.0;
|
|
if (\Illuminate\Support\Facades\Schema::hasTable('material_requisition_items') && \Illuminate\Support\Facades\Schema::hasColumn('material_requisition_items', 'is_unestimated')) {
|
|
$unestimatedMaterialsTotal = (float) \Modules\MaterialLogistics\Models\MaterialRequisitionItem::whereHas('requisition', function ($q) {
|
|
$q->where('project_id', $this->id)
|
|
->whereNotIn('status', ['rejected', 'cancelled']);
|
|
})
|
|
->where('is_unestimated', true)
|
|
->get()
|
|
->sum(fn ($item) => (float) $item->quantity * (float) $item->unit_cost);
|
|
}
|
|
|
|
$this->update(['total_capitalization' => $tasksTotal + $unestimatedMaterialsTotal]);
|
|
}
|
|
|
|
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,
|
|
]);
|
|
}
|
|
}
|
|
}
|