chore: update document approval workflow and bug fixes
This commit is contained in:
75
Modules/ProjectManagement/app/Models/HseRecord.php
Normal file
75
Modules/ProjectManagement/app/Models/HseRecord.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Models;
|
||||
|
||||
use App\Traits\HasPublicIdentifier;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class HseRecord extends Model
|
||||
{
|
||||
use HasPublicIdentifier;
|
||||
|
||||
protected $fillable = [
|
||||
'weekly_status_report_id',
|
||||
// Proactive
|
||||
'toolbox_meetings',
|
||||
'safety_observations',
|
||||
// Reactive
|
||||
'fatalities',
|
||||
'major_injuries',
|
||||
'first_aid_cases',
|
||||
'medical_cases',
|
||||
'near_misses',
|
||||
'environmental_damage',
|
||||
'property_damage',
|
||||
'fines',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'fines' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function report(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WeeklyStatusReport::class, 'weekly_status_report_id');
|
||||
}
|
||||
|
||||
// --- Accessors ---
|
||||
|
||||
public function getTotalIncidentsAttribute(): int
|
||||
{
|
||||
return $this->fatalities
|
||||
+ $this->major_injuries
|
||||
+ $this->first_aid_cases
|
||||
+ $this->medical_cases
|
||||
+ $this->near_misses
|
||||
+ $this->environmental_damage
|
||||
+ $this->property_damage;
|
||||
}
|
||||
|
||||
public function getHasZeroIncidentsAttribute(): bool
|
||||
{
|
||||
return $this->total_incidents === 0 && (float) $this->fines === 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns each reactive field as a labeled array for UI rendering.
|
||||
*/
|
||||
public function getReactiveBreakdownAttribute(): array
|
||||
{
|
||||
return [
|
||||
['label' => 'Fatalities', 'value' => $this->fatalities],
|
||||
['label' => 'Major Injuries', 'value' => $this->major_injuries],
|
||||
['label' => 'First Aid Cases', 'value' => $this->first_aid_cases],
|
||||
['label' => 'Medical Cases', 'value' => $this->medical_cases],
|
||||
['label' => 'Near Misses', 'value' => $this->near_misses],
|
||||
['label' => 'Environmental Damage', 'value' => $this->environmental_damage],
|
||||
['label' => 'Property Damage', 'value' => $this->property_damage],
|
||||
['label' => 'Fines/Costs', 'value' => $this->fines, 'isCurrency' => true],
|
||||
];
|
||||
}
|
||||
}
|
||||
206
Modules/ProjectManagement/app/Models/Project.php
Normal file
206
Modules/ProjectManagement/app/Models/Project.php
Normal file
@@ -0,0 +1,206 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
83
Modules/ProjectManagement/app/Models/ProjectMilestone.php
Normal file
83
Modules/ProjectManagement/app/Models/ProjectMilestone.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Models;
|
||||
|
||||
use App\Traits\HasPublicIdentifier;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\ProjectManagement\Enums\WeatherCondition;
|
||||
|
||||
class ProjectMilestone extends Model
|
||||
{
|
||||
use HasPublicIdentifier;
|
||||
|
||||
protected $fillable = [
|
||||
'project_id',
|
||||
'name',
|
||||
'description',
|
||||
'planned_date',
|
||||
'actual_date',
|
||||
'sort_order',
|
||||
'weight_percentage',
|
||||
'is_default',
|
||||
'weather_impacted',
|
||||
'weather_condition',
|
||||
'weather_delay_days',
|
||||
'weather_notes',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'planned_date' => 'date',
|
||||
'actual_date' => 'date',
|
||||
'weight_percentage' => 'decimal:2',
|
||||
'is_default' => 'boolean',
|
||||
'weather_impacted' => 'boolean',
|
||||
'weather_condition' => WeatherCondition::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
// --- Accessors ---
|
||||
|
||||
public function getIsCompletedAttribute(): bool
|
||||
{
|
||||
return $this->actual_date !== null;
|
||||
}
|
||||
|
||||
public function getIsOverdueAttribute(): bool
|
||||
{
|
||||
return !$this->is_completed
|
||||
&& $this->planned_date
|
||||
&& $this->planned_date->isPast();
|
||||
}
|
||||
|
||||
public function getStatusAttribute(): string
|
||||
{
|
||||
if ($this->is_completed) return 'completed';
|
||||
if ($this->is_overdue) return 'overdue';
|
||||
return 'upcoming';
|
||||
}
|
||||
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
'completed' => 'emerald',
|
||||
'overdue' => 'amber',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
public function getDaysDelayedAttribute(): int
|
||||
{
|
||||
if (!$this->is_completed || !$this->planned_date) return 0;
|
||||
|
||||
$diff = $this->planned_date->diffInDays($this->actual_date, false);
|
||||
return max(0, $diff);
|
||||
}
|
||||
}
|
||||
126
Modules/ProjectManagement/app/Models/Task.php
Normal file
126
Modules/ProjectManagement/app/Models/Task.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Models;
|
||||
use Modules\MasterData\Models\Material;
|
||||
use Modules\MasterData\Models\MaterialGroup;
|
||||
|
||||
use App\Models\User;
|
||||
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\ProjectManagement\Enums\DelayReason;
|
||||
use Modules\ProjectManagement\Enums\TaskStatus;
|
||||
|
||||
class Task extends Model
|
||||
{
|
||||
use HasPublicIdentifier;
|
||||
|
||||
protected $fillable = [
|
||||
'project_id',
|
||||
'name',
|
||||
'description',
|
||||
'area',
|
||||
'status',
|
||||
'sort_order',
|
||||
'labor_cost',
|
||||
'estimated_hours',
|
||||
'actual_hours',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'actual_start_date',
|
||||
'actual_end_date',
|
||||
'completion_percentage',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => TaskStatus::class,
|
||||
'labor_cost' => 'decimal:2',
|
||||
'estimated_hours' => 'decimal:2',
|
||||
'actual_hours' => 'decimal:2',
|
||||
'completion_percentage' => 'decimal:2',
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'actual_start_date' => 'date',
|
||||
'actual_end_date' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class);
|
||||
}
|
||||
|
||||
public function taskMaterials(): HasMany
|
||||
{
|
||||
return $this->hasMany(TaskMaterial::class);
|
||||
}
|
||||
|
||||
public function activities(): HasMany
|
||||
{
|
||||
return $this->hasMany(TaskActivity::class)->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
public function delays(): HasMany
|
||||
{
|
||||
return $this->hasMany(TaskDelay::class)->orderByDesc('delay_date');
|
||||
}
|
||||
|
||||
public function getTotalDelayDaysAttribute(): float
|
||||
{
|
||||
return round((float) $this->delays->sum('lost_hours') / 8, 1);
|
||||
}
|
||||
|
||||
public function getWeatherDelayCountAttribute(): int
|
||||
{
|
||||
return $this->delays->where('reason_type', DelayReason::Weather)->count();
|
||||
}
|
||||
|
||||
public function getMaterialCostAttribute(): float
|
||||
{
|
||||
return $this->taskMaterials->sum(fn (TaskMaterial $tm) => $tm->effective_cost);
|
||||
}
|
||||
|
||||
public function getTotalCostAttribute(): float
|
||||
{
|
||||
return (float) $this->labor_cost + $this->material_cost;
|
||||
}
|
||||
|
||||
public function transitionTo(TaskStatus $newStatus): void
|
||||
{
|
||||
$currentStatus = $this->status;
|
||||
|
||||
if (!in_array($newStatus, $currentStatus->allowedTransitions())) {
|
||||
throw new \InvalidArgumentException(
|
||||
"Cannot transition task from {$currentStatus->label()} to {$newStatus->label()}"
|
||||
);
|
||||
}
|
||||
|
||||
$updates = ['status' => $newStatus];
|
||||
|
||||
if ($newStatus === TaskStatus::InProgress && !$this->actual_start_date) {
|
||||
$updates['actual_start_date'] = now();
|
||||
}
|
||||
|
||||
if ($newStatus === TaskStatus::Completed) {
|
||||
$updates['actual_end_date'] = now();
|
||||
$updates['completion_percentage'] = 100;
|
||||
}
|
||||
|
||||
$this->update($updates);
|
||||
}
|
||||
|
||||
public function scopeStatus($query, TaskStatus $status)
|
||||
{
|
||||
return $query->where('status', $status);
|
||||
}
|
||||
}
|
||||
|
||||
30
Modules/ProjectManagement/app/Models/TaskActivity.php
Normal file
30
Modules/ProjectManagement/app/Models/TaskActivity.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use App\Models\User;
|
||||
|
||||
class TaskActivity extends Model
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'task_id',
|
||||
'user_id',
|
||||
'description',
|
||||
'type',
|
||||
];
|
||||
|
||||
public function task(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Task::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
57
Modules/ProjectManagement/app/Models/TaskDelay.php
Normal file
57
Modules/ProjectManagement/app/Models/TaskDelay.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\HasPublicIdentifier;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\ProjectManagement\Enums\DelayReason;
|
||||
use Modules\ProjectManagement\Enums\WeatherCondition;
|
||||
|
||||
class TaskDelay extends Model
|
||||
{
|
||||
use HasPublicIdentifier;
|
||||
|
||||
protected $fillable = [
|
||||
'task_id',
|
||||
'reason_type',
|
||||
'weather_condition',
|
||||
'delay_date',
|
||||
'lost_hours',
|
||||
'notes',
|
||||
'reported_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'reason_type' => DelayReason::class,
|
||||
'weather_condition' => WeatherCondition::class,
|
||||
'delay_date' => 'date',
|
||||
'lost_hours' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function task(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Task::class);
|
||||
}
|
||||
|
||||
public function reporter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'reported_by');
|
||||
}
|
||||
|
||||
// --- Accessors ---
|
||||
|
||||
public function getIsWeatherRelatedAttribute(): bool
|
||||
{
|
||||
return $this->reason_type === DelayReason::Weather;
|
||||
}
|
||||
|
||||
public function getLostDaysAttribute(): float
|
||||
{
|
||||
return round((float) $this->lost_hours / 8, 1);
|
||||
}
|
||||
}
|
||||
63
Modules/ProjectManagement/app/Models/TaskMaterial.php
Normal file
63
Modules/ProjectManagement/app/Models/TaskMaterial.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Models;
|
||||
|
||||
use App\Traits\HasPublicIdentifier;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\MasterData\Models\Material;
|
||||
|
||||
class TaskMaterial extends Model
|
||||
{
|
||||
use HasPublicIdentifier;
|
||||
|
||||
protected $fillable = [
|
||||
'task_id',
|
||||
'material_id',
|
||||
'planned_qty',
|
||||
'actual_qty',
|
||||
'unit_cost',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'planned_qty' => 'decimal:2',
|
||||
'actual_qty' => 'decimal:2',
|
||||
'unit_cost' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function task(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Task::class);
|
||||
}
|
||||
|
||||
public function material(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Material::class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function getPlannedCostAttribute(): float
|
||||
{
|
||||
return (float) $this->planned_qty * (float) $this->unit_cost;
|
||||
}
|
||||
|
||||
public function getActualCostAttribute(): float
|
||||
{
|
||||
return (float) $this->actual_qty * (float) $this->unit_cost;
|
||||
}
|
||||
|
||||
public function getEffectiveCostAttribute(): float
|
||||
{
|
||||
return $this->actual_qty > 0 ? $this->actual_cost : $this->planned_cost;
|
||||
}
|
||||
|
||||
public function getVarianceAttribute(): float
|
||||
{
|
||||
return $this->planned_cost - $this->actual_cost;
|
||||
}
|
||||
}
|
||||
102
Modules/ProjectManagement/app/Models/WeeklyStatusReport.php
Normal file
102
Modules/ProjectManagement/app/Models/WeeklyStatusReport.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\HasPublicIdentifier;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Modules\ApprovalWorkflow\Traits\HasApprovable;
|
||||
use Modules\ProjectManagement\Enums\ReportStatus;
|
||||
|
||||
class WeeklyStatusReport extends Model
|
||||
{
|
||||
use HasPublicIdentifier, SoftDeletes, HasApprovable;
|
||||
|
||||
protected $fillable = [
|
||||
'project_id',
|
||||
'period_start',
|
||||
'period_end',
|
||||
'status',
|
||||
'narrative_status',
|
||||
'narrative_weather',
|
||||
'narrative_compliance',
|
||||
'submitted_by',
|
||||
'approved_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => ReportStatus::class,
|
||||
'period_start' => 'date',
|
||||
'period_end' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
// --- Relationships ---
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function workforceMetric(): HasOne
|
||||
{
|
||||
return $this->hasOne(WorkforceMetric::class);
|
||||
}
|
||||
|
||||
public function hseRecord(): HasOne
|
||||
{
|
||||
return $this->hasOne(HseRecord::class);
|
||||
}
|
||||
|
||||
public function submitter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'submitted_by');
|
||||
}
|
||||
|
||||
public function approver(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by');
|
||||
}
|
||||
|
||||
// --- State Machine ---
|
||||
|
||||
public function transitionTo(ReportStatus $newStatus): void
|
||||
{
|
||||
if (!in_array($newStatus, $this->status->allowedTransitions())) {
|
||||
throw new \InvalidArgumentException(
|
||||
"Cannot transition report from {$this->status->label()} to {$newStatus->label()}"
|
||||
);
|
||||
}
|
||||
|
||||
$this->update(['status' => $newStatus]);
|
||||
}
|
||||
|
||||
// --- Scopes ---
|
||||
|
||||
public function scopeStatus($query, ReportStatus $status)
|
||||
{
|
||||
return $query->where('status', $status);
|
||||
}
|
||||
|
||||
public function scopeApproved($query)
|
||||
{
|
||||
return $query->where('status', ReportStatus::Approved);
|
||||
}
|
||||
|
||||
// --- Accessors ---
|
||||
|
||||
public function getPeriodLabelAttribute(): string
|
||||
{
|
||||
return $this->period_start->format('M d') . ' – ' . $this->period_end->format('M d, Y');
|
||||
}
|
||||
|
||||
public function getDaysInPeriodAttribute(): int
|
||||
{
|
||||
return $this->period_start->diffInDays($this->period_end) + 1;
|
||||
}
|
||||
}
|
||||
54
Modules/ProjectManagement/app/Models/WorkforceMetric.php
Normal file
54
Modules/ProjectManagement/app/Models/WorkforceMetric.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectManagement\Models;
|
||||
|
||||
use App\Traits\HasPublicIdentifier;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WorkforceMetric extends Model
|
||||
{
|
||||
use HasPublicIdentifier;
|
||||
|
||||
protected $fillable = [
|
||||
'weekly_status_report_id',
|
||||
'active_workforce',
|
||||
'period_man_hours',
|
||||
'cumulative_man_hours',
|
||||
'logistics_km',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'period_man_hours' => 'decimal:2',
|
||||
'cumulative_man_hours' => 'decimal:2',
|
||||
'logistics_km' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function report(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WeeklyStatusReport::class, 'weekly_status_report_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cumulative man-hours from all previous approved reports + current period.
|
||||
*/
|
||||
public static function calculateCumulative(int $projectId, string $periodStart, float $currentPeriodHours, ?int $excludeReportId = null): float
|
||||
{
|
||||
$query = self::whereHas('report', fn ($q) => $q
|
||||
->where('project_id', $projectId)
|
||||
->where('status', 'approved')
|
||||
->where('period_end', '<', $periodStart)
|
||||
);
|
||||
|
||||
if ($excludeReportId) {
|
||||
$query->where('weekly_status_report_id', '!=', $excludeReportId);
|
||||
}
|
||||
|
||||
$previousTotal = $query->sum('period_man_hours');
|
||||
|
||||
return (float) $previousTotal + $currentPeriodHours;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user