fn ($userQuery) => $userQuery->withoutGlobalScopes()->select('id', 'ulid', 'name'), 'approver' => fn ($userQuery) => $userQuery->withoutGlobalScopes()->select('id', 'ulid', 'name'), 'project:id,ulid,name,code', 'items:id,material_requisition_id,quantity,unit_cost' ]); // Requisitions inherit visibility from their projects. $query->whereIn('project_id', $this->availableProjectsQuery()->select('projects.id')); if ($request->filled('project_ulid')) { $project = Project::where('ulid', $request->project_ulid)->first(); if ($project) { $query->where('project_id', $project->id); } } $requisitions = $query->latest()->paginate(20); return Inertia::render('MaterialLogistics::Requisitions/Index', [ 'requisitions' => $requisitions, 'projects' => Project::active()->where('current_wizard_step', '>=', 8)->select('id', 'ulid', 'name', 'code', 'client_name', 'location', 'start_date', 'target_end_date', 'status', 'contract_value')->get(), ]); } public function create(Request $request) { $selectedProject = null; $prefilledItems = []; $hasRemainingEstimates = false; $workflowService = new ProjectWorkflowService(); if ($request->filled('project_ulid')) { $selectedProject = $this->availableProjectsQuery() ->where('ulid', $request->project_ulid) ->with('materialsEstimates.material:id,ulid,name,unit,unit_cost') ->firstOrFail(); if (!$workflowService->canCreateRequisition($selectedProject)) { return redirect()->back()->with('error', 'Cannot create material requisition: Project is not in scheduled/procuring state or task resources are missing.'); } $prefilledItems = $workflowService->getRemainingMaterialEstimates($selectedProject); $hasRemainingEstimates = !empty($prefilledItems); } $materials = Material::select('id', 'ulid', 'name', 'unit', 'unit_cost', 'sku', 'category', 'status') ->where('status', 'active') ->orderBy('category') ->orderBy('name') ->get(); $materialGroups = Material::with('components.component:id,ulid,name,sku,category,unit,unit_cost') ->whereIn('type', ['kit', 'assembly']) ->where('status', 'active') ->orderBy('name') ->get() ->map(function ($kit) { return [ 'id' => $kit->id, 'ulid' => $kit->ulid, 'name' => $kit->name, 'description' => $kit->description ?? '', 'status' => $kit->status, 'materials' => $kit->components->map(function ($comp) { return array_merge($comp->component->toArray(), [ 'pivot' => ['quantity' => $comp->quantity] ]); })->toArray() ]; }); $projects = $this->availableProjectsQuery() ->active() ->where('current_wizard_step', '>=', 8) ->with(['contractor:id,company_name', 'materialsEstimates.material:id,ulid,name,unit,unit_cost']) ->select('id', 'ulid', 'name', 'code', 'contractor_id', 'client_name', 'location', 'start_date', 'target_end_date', 'status', 'contract_value') ->get() ->map(function ($proj) use ($workflowService) { $estimates = $workflowService->getRemainingMaterialEstimates($proj); return [ 'id' => $proj->id, 'ulid' => $proj->ulid, 'name' => $proj->name, 'code' => $proj->code, 'client_name' => $proj->client_name, 'location' => $proj->location, 'status' => $proj->status, 'start_date' => $proj->start_date, 'target_end_date' => $proj->target_end_date, 'contract_value' => $proj->contract_value, 'contractor' => $proj->contractor, 'has_remaining_estimates' => !empty($estimates), 'remaining_estimates' => $estimates, ]; }); return Inertia::render('MaterialLogistics::Requisitions/Form', [ 'materials' => $materials, 'materialGroups' => $materialGroups, 'projects' => $projects, 'selectedProject' => $selectedProject, 'prefilledItems' => $prefilledItems, 'hasRemainingEstimates' => $hasRemainingEstimates, ]); } public function store(Request $request) { $validated = $request->validate([ 'project_ulid' => 'required|string|exists:projects,ulid', 'requisition_type' => 'nullable|string|in:estimated,unestimated', 'needed_by_date' => 'required|date', 'purpose' => 'required|string|min:3|max:1000', 'notes' => 'nullable|string|max:1000', 'items' => 'required|array|min:1', 'items.*.material_ulid' => 'required|string', 'items.*.quantity' => 'required|numeric|min:0.01', 'items.*.unit_cost' => 'required|numeric|min:0', 'items.*.is_unestimated' => 'nullable|boolean', ]); $project = $this->availableProjectsQuery() ->where('ulid', $validated['project_ulid']) ->firstOrFail(); if ($project->current_wizard_step < 7) { return redirect()->back()->with('error', 'Cannot create material requisition: Project estimation is not submitted or approved.'); } $workflowService = new ProjectWorkflowService(); if (!$workflowService->canCreateRequisition($project)) { return redirect()->back()->with('error', 'Cannot create material requisition: Project is not in scheduled/procuring state.'); } $validationError = $workflowService->validateEstimatedQuantities($project, $validated['items']); if ($validationError) { return redirect()->back()->withInput()->with('error', $validationError); } $docService = new DocumentNumberService(); $requisitionType = $validated['requisition_type'] ?? 'estimated'; $mr = DB::transaction(function () use ($validated, $project, $docService, $requisitionType) { $mr = MaterialRequisition::create([ 'project_id' => $project->id, 'document_number' => $docService->nextMrNumber(), 'requisition_type' => $requisitionType, 'status' => 'draft', 'requested_by' => auth()->id(), 'needed_by_date' => $validated['needed_by_date'], 'purpose' => $validated['purpose'], 'notes' => $validated['notes'] ?? null, ]); foreach ($validated['items'] as $item) { $material = Material::where('ulid', $item['material_ulid'])->firstOrFail(); $isUnestimated = isset($item['is_unestimated']) ? (bool) $item['is_unestimated'] : ($requisitionType === 'unestimated'); $mr->items()->create([ 'material_id' => $material->id, 'quantity' => $item['quantity'], 'unit_cost' => $item['unit_cost'], 'is_unestimated' => $isUnestimated, ]); // Ensure the project material estimates registry reflects this requested material ProjectMaterialsEstimate::firstOrCreate( [ 'project_id' => $project->id, 'material_id' => $material->id, ], [ 'estimated_qty' => 0, 'unit_cost' => $item['unit_cost'], ] ); } // Automatically recalculate project capitalization so unestimated materials are added to the project cap $project->recalculateCapitalization(); return $mr; }); return redirect()->route('requisitions.show', $mr)->with('success', 'Material Requisition created as draft.'); } public function edit(MaterialRequisition $requisition) { if ($requisition->status !== 'draft') { return redirect()->route('requisitions.show', $requisition)->with('error', 'Only draft requisitions can be edited.'); } $requisition->load(['items.material', 'project']); $workflowService = new ProjectWorkflowService(); $materials = Material::select('id', 'ulid', 'name', 'unit', 'unit_cost', 'sku', 'category', 'status') ->where('status', 'active') ->orderBy('category') ->orderBy('name') ->get(); $materialGroups = Material::with('components.component:id,ulid,name,sku,category,unit,unit_cost') ->whereIn('type', ['kit', 'assembly']) ->where('status', 'active') ->orderBy('name') ->get() ->map(function ($kit) { return [ 'id' => $kit->id, 'ulid' => $kit->ulid, 'name' => $kit->name, 'description' => $kit->description ?? '', 'status' => $kit->status, 'materials' => $kit->components->map(function ($comp) { return array_merge($comp->component->toArray(), [ 'pivot' => ['quantity' => $comp->quantity] ]); })->toArray() ]; }); $projects = $this->availableProjectsQuery() ->active() ->where('current_wizard_step', '>=', 8) ->with(['contractor:id,company_name', 'materialsEstimates.material:id,ulid,name,unit,unit_cost']) ->select('id', 'ulid', 'name', 'code', 'contractor_id', 'client_name', 'location', 'start_date', 'target_end_date', 'status', 'contract_value') ->get() ->map(function ($proj) use ($workflowService) { $estimates = $workflowService->getRemainingMaterialEstimates($proj); return [ 'id' => $proj->id, 'ulid' => $proj->ulid, 'name' => $proj->name, 'code' => $proj->code, 'client_name' => $proj->client_name, 'location' => $proj->location, 'status' => $proj->status, 'start_date' => $proj->start_date, 'target_end_date' => $proj->target_end_date, 'contract_value' => $proj->contract_value, 'contractor' => $proj->contractor, 'has_remaining_estimates' => !empty($estimates), 'remaining_estimates' => $estimates, ]; }); $estimates = $workflowService->getRemainingMaterialEstimates($requisition->project); return Inertia::render('MaterialLogistics::Requisitions/Form', [ 'requisition' => [ 'id' => $requisition->id, 'ulid' => $requisition->ulid, 'document_number' => $requisition->document_number, 'status' => $requisition->status, 'requisition_type' => $requisition->requisition_type, 'needed_by_date' => $requisition->needed_by_date?->format('Y-m-d'), 'purpose' => $requisition->purpose, 'notes' => $requisition->notes, 'project_ulid' => $requisition->project->ulid, 'project' => $requisition->project, 'items' => $requisition->items->map(fn ($item) => [ 'material_ulid' => $item->material->ulid, 'quantity' => (string) $item->quantity, 'unit_cost' => (string) $item->unit_cost, 'is_unestimated' => (bool) $item->is_unestimated, ]), ], 'materials' => $materials, 'materialGroups' => $materialGroups, 'projects' => $projects, 'selectedProject' => $requisition->project, 'prefilledItems' => $estimates, 'hasRemainingEstimates' => !empty($estimates), ]); } public function update(Request $request, MaterialRequisition $requisition) { if ($requisition->status !== 'draft') { return redirect()->route('requisitions.show', $requisition)->with('error', 'Only draft requisitions can be edited.'); } $validated = $request->validate([ 'project_ulid' => 'required|string|exists:projects,ulid', 'requisition_type' => 'nullable|string|in:estimated,unestimated', 'needed_by_date' => 'required|date', 'purpose' => 'required|string|min:3|max:1000', 'notes' => 'nullable|string|max:1000', 'items' => 'required|array|min:1', 'items.*.material_ulid' => 'required|string', 'items.*.quantity' => 'required|numeric|min:0.01', 'items.*.unit_cost' => 'required|numeric|min:0', 'items.*.is_unestimated' => 'nullable|boolean', ]); $project = $this->availableProjectsQuery() ->where('ulid', $validated['project_ulid']) ->firstOrFail(); if ($project->current_wizard_step < 8) { return redirect()->back()->with('error', 'Cannot update material requisition: Project estimation is not approved.'); } $workflowService = new ProjectWorkflowService(); if (!$workflowService->canCreateRequisition($project)) { return redirect()->back()->with('error', 'Cannot update material requisition: Project is not in scheduled/procuring state.'); } $validationError = $workflowService->validateEstimatedQuantities($project, $validated['items'], $requisition->id); if ($validationError) { return redirect()->back()->withInput()->with('error', $validationError); } $requisitionType = $validated['requisition_type'] ?? ($requisition->requisition_type ?? 'estimated'); DB::transaction(function () use ($validated, $project, $requisition, $requisitionType) { $requisition->update([ 'project_id' => $project->id, 'requisition_type' => $requisitionType, 'needed_by_date' => $validated['needed_by_date'], 'purpose' => $validated['purpose'], 'notes' => $validated['notes'] ?? null, ]); $requisition->items()->delete(); // remove old items foreach ($validated['items'] as $item) { $material = Material::where('ulid', $item['material_ulid'])->firstOrFail(); $isUnestimated = isset($item['is_unestimated']) ? (bool) $item['is_unestimated'] : ($requisitionType === 'unestimated'); $requisition->items()->create([ 'material_id' => $material->id, 'quantity' => $item['quantity'], 'unit_cost' => $item['unit_cost'], 'is_unestimated' => $isUnestimated, ]); // Ensure the project material estimates registry reflects this requested/supplemental material ProjectMaterialsEstimate::firstOrCreate( [ 'project_id' => $project->id, 'material_id' => $material->id, ], [ 'estimated_qty' => 0, 'unit_cost' => $item['unit_cost'], ] ); } // Recalculate project capitalization $project->recalculateCapitalization(); }); return redirect()->route('requisitions.show', $requisition)->with('success', 'Material Requisition updated.'); } public function show(MaterialRequisition $requisition) { $requisition->load([ 'project:id,ulid,name,code', 'requester:id,ulid,name', 'approver:id,ulid,name', 'items.material' => function ($query) { $query->select('id', 'ulid', 'name', 'unit', 'unit_cost', 'type') ->with('components.component:id,name,unit'); }, 'approvalChains.steps.approver:id,ulid,name', ]); if ($requisition->status === 'submitted' && $requisition->approvalChains->isEmpty()) { $approverIds = \App\Models\User::withoutGlobalScopes() ->where('user_type', '!=', 'contractor') ->where(function ($query) { $query->whereHas('roles', function ($roleQuery) { $roleQuery->whereIn('name', ['Project Manager', 'project_manager', 'Super Admin', 'admin', 'Main Contractor Admin']); })->orWhere('user_type', 'admin'); }) ->where('status', 'active') ->pluck('id') ->values() ->toArray(); if (!empty($approverIds)) { $approvalService = app(\Modules\ApprovalWorkflow\Services\ApprovalService::class); $approvalService->createChain($requisition, $approverIds, 'material_requisition', $requisition->requested_by); $requisition->load('approvalChains.steps.approver:id,ulid,name'); } } return Inertia::render('MaterialLogistics::Requisitions/Show', [ 'requisition' => $requisition, ]); } public function submitForApproval(MaterialRequisition $requisition) { if ($requisition->status !== 'draft') { return back()->with('error', 'Only draft requisitions can be submitted.'); } // Material Requests may be approved by an explicit permission or by // the system's Project Manager/executive roles. Query without the // tenant scope so a site user can submit a request for an assigned // project even when the approver belongs to another tenant context. $approverIds = \App\Models\User::withoutGlobalScopes() ->where('user_type', '!=', 'contractor') ->whereDoesntHave('roles', function ($roleQuery) { $roleQuery->whereIn('name', [ 'Contractor Admin', 'Contractor', 'Subcontractor Staff', 'Site Technical', 'Construction Supervisor', ]); }) ->where(function ($query) { $query->whereHas('roles', function ($roleQuery) { $roleQuery->whereIn('name', [ 'Project Manager', 'project_manager', 'Super Admin', 'admin', ]); }) ->orWhere('user_type', 'admin'); }) ->where('status', 'active') ->pluck('id') ->values() ->toArray(); if (empty($approverIds)) { return back()->with('error', 'No approvers found in the system for Material Requests.'); } $approvalService = app(ApprovalService::class); $approvalService->createChain( $requisition, $approverIds, 'material_requisition', auth()->id(), ); $requisition->update(['status' => 'submitted']); return back()->with('success', 'Material Requisition submitted for approval.'); } public function downloadPdf(MaterialRequisition $requisition) { $requisition->load([ 'requester' => fn ($query) => $query->withoutGlobalScopes()->select('id', 'name'), 'approver' => fn ($query) => $query->withoutGlobalScopes()->select('id', 'name'), 'items.material:id,name,unit', 'approvalChains.steps.approver:id,name', ]); // Global approver roles $approverDetails = []; if ($requisition->approvalChains->isNotEmpty()) { $chain = $requisition->approvalChains->sortByDesc('created_at')->first(); foreach ($chain->steps as $step) { $stepStatus = $step->status instanceof \BackedEnum ? $step->status->value : $step->status; $approver = $step->approver ?: ($step->approver_id ? \App\Models\User::withoutGlobalScopes()->find($step->approver_id) : null); if ($stepStatus === 'approved' && $approver) { $approverDetails[] = [ 'name' => $approver->name, 'role' => 'Approver', 'date' => $step->acted_at?->format('M d, Y'), ]; } } } if (empty($approverDetails) && $requisition->status === 'approved' && $requisition->approver) { $approverDetails[] = [ 'name' => $requisition->approver->name, 'role' => 'Approver', 'date' => $requisition->approved_at ? $requisition->approved_at->format('M d, Y') : null, ]; } $pdf = Pdf::loadView('materiallogistics::pdf.material-requisition', [ 'requisition' => $requisition, 'approverDetails' => $approverDetails, ]); return $pdf->download("MR-{$requisition->document_number}.pdf"); } public function approve(Request $request, MaterialRequisition $requisition) { $user = $request->user(); $isAdminOrPm = $user->user_type === 'admin' || $user->hasRole(['Super Admin', 'admin', 'Project Manager', 'project_manager', 'Main Contractor Admin']) || $user->can('approve_mr'); if (!$isAdminOrPm) { return back()->with('error', 'Unauthorized to approve material requisitions.'); } $request->validate(['notes' => 'nullable|string|max:1000']); $chain = $requisition->approvalChains()->whereIn('status', ['pending', 'in_review'])->first(); if ($chain) { $approvalService = app(\Modules\ApprovalWorkflow\Services\ApprovalService::class); $approvalService->approve($chain, $user, $request->notes); } else { $requisition->update([ 'status' => 'approved', 'approved_by' => $user->id, 'approved_at' => now(), 'notes' => $request->notes ? ($requisition->notes ? $requisition->notes . "\n" . $request->notes : $request->notes) : $requisition->notes, ]); } return back()->with('success', 'Material Requisition approved successfully.'); } public function reject(Request $request, MaterialRequisition $requisition) { $user = $request->user(); $isAdminOrPm = $user->user_type === 'admin' || $user->hasRole(['Super Admin', 'admin', 'Project Manager', 'project_manager', 'Main Contractor Admin']) || $user->can('approve_mr'); if (!$isAdminOrPm) { return back()->with('error', 'Unauthorized to reject material requisitions.'); } $request->validate(['notes' => 'required|string|max:1000']); $chain = $requisition->approvalChains()->whereIn('status', ['pending', 'in_review'])->first(); if ($chain) { $approvalService = app(\Modules\ApprovalWorkflow\Services\ApprovalService::class); $approvalService->reject($chain, $user, $request->notes); } else { $requisition->update([ 'status' => 'rejected', 'notes' => $requisition->notes ? $requisition->notes . "\n[Rejection Reason]: " . $request->notes : "[Rejection Reason]: " . $request->notes, ]); } return back()->with('success', 'Material Requisition rejected.'); } private function availableProjectsQuery() { // Project's TenantScope is the source of truth for project visibility. return Project::query(); } }