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