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 = []; if ($request->filled('project_ulid')) { $selectedProject = $this->availableProjectsQuery() ->where('ulid', $request->project_ulid) ->with('materialsEstimates.material:id,ulid,name,unit,unit_cost') ->firstOrFail(); $workflowService = new ProjectWorkflowService(); 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.'); } // Auto-populate from project materials estimation foreach ($selectedProject->materialsEstimates as $estimate) { $prefilledItems[] = [ 'material_ulid' => $estimate->material->ulid, 'material_name' => $estimate->material->name, 'unit' => $estimate->material->unit, 'quantity' => (float) $estimate->estimated_qty, 'unit_cost' => (float) $estimate->unit_cost, ]; } } $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() ]; }); return Inertia::render('MaterialLogistics::Requisitions/Form', [ 'materials' => $materials, 'materialGroups' => $materialGroups, 'projects' => $this->availableProjectsQuery() ->active() ->where('current_wizard_step', '>=', 8) ->with('contractor:id,company_name') ->select('id', 'ulid', 'name', 'code', 'contractor_id', 'client_name', 'location', 'start_date', 'target_end_date', 'status', 'contract_value') ->get(), 'selectedProject' => $selectedProject, 'prefilledItems' => $prefilledItems, ]); } public function store(Request $request) { $validated = $request->validate([ 'project_ulid' => 'required|string|exists:projects,ulid', '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', ]); $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.'); } // Validate that all items are in the project's materials estimates $estimatedMaterialIds = $project->materialsEstimates()->pluck('material_id')->toArray(); foreach ($validated['items'] as $item) { $material = Material::where('ulid', $item['material_ulid'])->firstOrFail(); if (!in_array($material->id, $estimatedMaterialIds)) { return redirect()->back()->with('error', "Cannot create material requisition: Material '{$material->name}' is not in the project's approved estimation."); } } $docService = new DocumentNumberService(); $mr = DB::transaction(function () use ($validated, $project, $docService) { $mr = MaterialRequisition::create([ 'project_id' => $project->id, 'document_number' => $docService->nextMrNumber(), 'status' => 'draft', 'requested_by' => auth()->id(), 'notes' => $validated['notes'] ?? null, ]); foreach ($validated['items'] as $item) { $material = Material::where('ulid', $item['material_ulid'])->firstOrFail(); $mr->items()->create([ 'material_id' => $material->id, 'quantity' => $item['quantity'], 'unit_cost' => $item['unit_cost'], ]); } return $mr; }); return redirect()->route('requisitions.show', $mr)->with('success', 'Material Requisition created.'); } public function edit(MaterialRequisition $requisition) { if ($requisition->status !== 'draft') { return redirect()->back()->with('error', 'Only draft requisitions can be edited.'); } $requisition->load('items.material:id,ulid,name,unit,unit_cost', 'project:id,ulid,name,code'); $materials = Material::select('id', 'ulid', 'name', 'sku', 'category', 'unit', 'unit_cost', '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() ]; }); return Inertia::render('MaterialLogistics::Requisitions/Form', [ 'requisition' => $requisition, 'materials' => $materials, 'materialGroups' => $materialGroups, 'projects' => $this->availableProjectsQuery() ->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 update(Request $request, MaterialRequisition $requisition) { if ($requisition->status !== 'draft') { return redirect()->back()->with('error', 'Only draft requisitions can be updated.'); } $validated = $request->validate([ 'project_ulid' => 'required|string|exists:projects,ulid', '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', ]); $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.'); } // Validate that all items are in the project's materials estimates $estimatedMaterialIds = $project->materialsEstimates()->pluck('material_id')->toArray(); foreach ($validated['items'] as $item) { $material = Material::where('ulid', $item['material_ulid'])->firstOrFail(); if (!in_array($material->id, $estimatedMaterialIds)) { return redirect()->back()->with('error', "Cannot update material requisition: Material '{$material->name}' is not in the project's approved estimation."); } } DB::transaction(function () use ($validated, $project, $requisition) { $requisition->update([ 'project_id' => $project->id, 'notes' => $validated['notes'] ?? null, ]); $requisition->items()->delete(); // remove old items foreach ($validated['items'] as $item) { $material = Material::where('ulid', $item['material_ulid'])->firstOrFail(); $requisition->items()->create([ 'material_id' => $material->id, 'quantity' => $item['quantity'], 'unit_cost' => $item['unit_cost'], ]); } }); return redirect()->route('requisitions.show', $requisition)->with('success', 'Material Requisition updated.'); } public function show(MaterialRequisition $requisition) { $requisition->load([ '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', ]); 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"); } private function availableProjectsQuery() { // Project's TenantScope is the source of truth for project visibility. return Project::query(); } }