chore: update document approval workflow and bug fixes

This commit is contained in:
2026-05-25 13:05:20 +08:00
parent d573c02893
commit 39a8e1d4cd
910 changed files with 49994 additions and 1010 deletions

View 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],
];
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}