217 lines
9.3 KiB
PHP
217 lines
9.3 KiB
PHP
<?php
|
|
|
|
namespace Modules\ProjectManagement\Services;
|
|
|
|
use Modules\ProjectManagement\Models\Project;
|
|
use Modules\ProjectManagement\Enums\ProjectStatus;
|
|
|
|
class ProjectWorkflowService
|
|
{
|
|
/**
|
|
* Determine the current structural state of the project.
|
|
* Enforces the sequence: Planning -> Estimated -> Scheduled -> Procuring -> InProgress
|
|
*/
|
|
public function getProjectWorkflowState(Project $project): string
|
|
{
|
|
$hasEstimates = $project->materialsEstimates()->exists();
|
|
$hasTasks = $project->tasks()->exists();
|
|
|
|
if ($project->status === ProjectStatus::Completed || $project->status === ProjectStatus::Closed) {
|
|
return $project->status->value;
|
|
}
|
|
|
|
if ($project->status === ProjectStatus::InProgress) {
|
|
return 'in_progress';
|
|
}
|
|
|
|
if (!$hasEstimates) {
|
|
return 'planning'; // Step 1: Project Creation / Planning
|
|
}
|
|
|
|
if (!$hasTasks) {
|
|
return 'estimated'; // Step 2: Materials Estimated, Tasks not yet scheduled
|
|
}
|
|
|
|
// Has estimates and tasks, check if procurement (MR/PO) has occurred
|
|
return 'scheduled'; // Step 3: Tasks Scheduled & Resources assigned
|
|
}
|
|
|
|
/**
|
|
* Guard: check if Material Requisitions can be created for this project.
|
|
* Enforced strictly: Must have estimates and tasks first.
|
|
*/
|
|
public function canCreateRequisition(Project $project): bool
|
|
{
|
|
$state = $this->getProjectWorkflowState($project);
|
|
|
|
// Allowed if project is scheduled, procuring, or in progress
|
|
return in_array($state, ['scheduled', 'procuring', 'in_progress']) ||
|
|
$project->status === ProjectStatus::InProgress;
|
|
}
|
|
|
|
/**
|
|
* Guard: check if Purchase Orders can be created.
|
|
* Enforced strictly: Requires an approved material requisition for the project.
|
|
*/
|
|
public function canCreatePurchaseOrder(Project $project): bool
|
|
{
|
|
// Requisition must exist and be approved for this project
|
|
$hasApprovedMr = \Modules\MaterialLogistics\Models\MaterialRequisition::where('project_id', $project->id)
|
|
->where('status', 'approved')
|
|
->exists();
|
|
|
|
return $hasApprovedMr || $project->status === ProjectStatus::InProgress;
|
|
}
|
|
|
|
/**
|
|
* Retrieve the budget analysis for a project (Estimated vs. Requisitioned vs. PO value)
|
|
*/
|
|
public function getBudgetAnalysis(Project $project): array
|
|
{
|
|
$estimatedValue = $project->materialsEstimates->sum(fn ($est) => (float) $est->estimated_qty * (float) $est->unit_cost);
|
|
|
|
// Sum labor & equipment estimates
|
|
$laborEstimates = 0.00;
|
|
$equipmentEstimates = 0.00;
|
|
foreach ($project->tasks as $task) {
|
|
$laborEstimates += $task->estimated_labor_cost;
|
|
$equipmentEstimates += $task->estimated_equipment_cost;
|
|
}
|
|
|
|
$totalEstimated = $estimatedValue + $laborEstimates + $equipmentEstimates;
|
|
|
|
// Sum actual PO costs for warehouses belonging to this project
|
|
$poTotal = 0.00;
|
|
$warehouseIds = \Modules\MaterialLogistics\Models\Warehouse::where('project_id', $project->id)->pluck('id');
|
|
if ($warehouseIds->isNotEmpty()) {
|
|
$poTotal = (float) \Modules\MaterialLogistics\Models\PurchaseOrder::whereIn('target_warehouse_id', $warehouseIds)
|
|
->whereIn('status', ['approved', 'delivered'])
|
|
->get()
|
|
->sum('total_cost');
|
|
}
|
|
|
|
return [
|
|
'contract_value' => (float) $project->contract_value,
|
|
'materials_estimated' => $estimatedValue,
|
|
'labor_estimated' => $laborEstimates,
|
|
'equipment_estimated' => $equipmentEstimates,
|
|
'total_estimated' => $totalEstimated,
|
|
'po_total' => $poTotal,
|
|
'remaining_estimate' => max(0.00, $totalEstimated - $poTotal),
|
|
'is_over_budget' => $poTotal > $totalEstimated || $project->rollup_capitalization > $project->rollup_contract_value,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Compute remaining estimated materials for a project taking into account:
|
|
* - Project baseline estimated quantities
|
|
* - Quantities requested on active (non-rejected/cancelled) estimated MR items
|
|
* - Missing/shortage quantities released back from delivered POs
|
|
* - Optional exclusion of a requisition ID (for editing an existing draft MR)
|
|
*/
|
|
public function getRemainingMaterialEstimates(Project $project, ?int $excludeRequisitionId = null): array
|
|
{
|
|
$project->loadMissing('materialsEstimates.material');
|
|
|
|
// Sum requested quantities in non-rejected, non-cancelled MRs
|
|
$alreadyReqMap = \Modules\MaterialLogistics\Models\MaterialRequisitionItem::whereHas('requisition', function ($q) use ($project, $excludeRequisitionId) {
|
|
$q->where('project_id', $project->id)
|
|
->whereNotIn('status', ['rejected', 'cancelled']);
|
|
if ($excludeRequisitionId) {
|
|
$q->where('id', '!=', $excludeRequisitionId);
|
|
}
|
|
})
|
|
->where('is_unestimated', false)
|
|
->select('material_id', \Illuminate\Support\Facades\DB::raw('SUM(quantity) as total_qty'))
|
|
->groupBy('material_id')
|
|
->pluck('total_qty', 'material_id')
|
|
->toArray();
|
|
|
|
// Release shortage/missing quantities from delivered POs back into the available pool
|
|
$missingPoQtyMap = \Modules\MaterialLogistics\Models\PurchaseOrderItem::whereHas('purchaseOrder', function ($q) use ($project) {
|
|
$q->where('project_id', $project->id)
|
|
->where('status', 'delivered');
|
|
})
|
|
->where('missing_quantity', '>', 0)
|
|
->select('material_id', \Illuminate\Support\Facades\DB::raw('SUM(missing_quantity) as total_missing'))
|
|
->groupBy('material_id')
|
|
->pluck('total_missing', 'material_id')
|
|
->toArray();
|
|
|
|
$estimates = [];
|
|
foreach ($project->materialsEstimates as $estimate) {
|
|
if (!$estimate->material) {
|
|
continue;
|
|
}
|
|
|
|
$alreadyReq = (float) ($alreadyReqMap[$estimate->material_id] ?? 0);
|
|
$missingFromPo = (float) ($missingPoQtyMap[$estimate->material_id] ?? 0);
|
|
$netRequested = max(0, $alreadyReq - $missingFromPo);
|
|
$remaining = max(0, (float) $estimate->estimated_qty - $netRequested);
|
|
|
|
if ($remaining > 0) {
|
|
$estimates[] = [
|
|
'material_id' => $estimate->material_id,
|
|
'material_ulid' => $estimate->material->ulid,
|
|
'material_name' => $estimate->material->name,
|
|
'unit' => $estimate->material->unit,
|
|
'quantity' => $remaining,
|
|
'unit_cost' => (float) $estimate->unit_cost,
|
|
'estimated_qty' => (float) $estimate->estimated_qty,
|
|
'remaining_qty' => $remaining,
|
|
];
|
|
}
|
|
}
|
|
|
|
return $estimates;
|
|
}
|
|
|
|
/**
|
|
* Validate that estimated items do not exceed the remaining estimated quantities.
|
|
* Returns null if valid, or an error message string if any item exceeds limits.
|
|
*/
|
|
public function validateEstimatedQuantities(Project $project, array $items, ?int $excludeRequisitionId = null): ?string
|
|
{
|
|
$remainingEstimates = $this->getRemainingMaterialEstimates($project, $excludeRequisitionId);
|
|
$remainingByUlid = collect($remainingEstimates)->keyBy('material_ulid');
|
|
$remainingById = collect($remainingEstimates)->keyBy('material_id');
|
|
|
|
// Track cumulative requested quantities in this single submission to prevent duplicates bypassing limit
|
|
$cumulativeRequested = [];
|
|
|
|
foreach ($items as $item) {
|
|
$isUnestimated = isset($item['is_unestimated']) ? (bool) $item['is_unestimated'] : false;
|
|
if ($isUnestimated) {
|
|
continue; // Unestimated/supplemental items are not restricted by estimated budget caps
|
|
}
|
|
|
|
$materialUlid = $item['material_ulid'] ?? null;
|
|
$materialId = $item['material_id'] ?? null;
|
|
$qty = (float) ($item['quantity'] ?? 0);
|
|
|
|
$estimate = $materialUlid ? ($remainingByUlid[$materialUlid] ?? null) : ($remainingById[$materialId] ?? null);
|
|
|
|
if (!$estimate) {
|
|
$materialName = $item['material_name'] ?? 'Material';
|
|
if ($materialUlid) {
|
|
$mat = \Modules\MasterData\Models\Material::where('ulid', $materialUlid)->first();
|
|
if ($mat) {
|
|
$materialName = $mat->name;
|
|
}
|
|
}
|
|
return "The material '{$materialName}' has no remaining estimated quantity on this project. Please select Unestimated Materials for supplemental requests.";
|
|
}
|
|
|
|
$key = $estimate['material_ulid'];
|
|
$cumulativeRequested[$key] = ($cumulativeRequested[$key] ?? 0) + $qty;
|
|
$maxAllowed = (float) $estimate['remaining_qty'];
|
|
|
|
if ($cumulativeRequested[$key] > $maxAllowed + 0.0001) {
|
|
return "The requested quantity (" . number_format($cumulativeRequested[$key], 2) . ") for '{$estimate['material_name']}' exceeds the project's remaining estimate of " . number_format($maxAllowed, 2) . " {$estimate['unit']}. Please reduce the quantity or choose Unestimated Materials for additional items.";
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|