fn ($userQuery) => $userQuery->withoutGlobalScopes()->select('id', 'ulid', 'name'), 'approver' => fn ($userQuery) => $userQuery->withoutGlobalScopes()->select('id', 'ulid', 'name'), 'project:id,ulid,name,code', 'requisitions:id,ulid,document_number', 'items:id,purchase_order_id,quantity,unit_cost' ]); // Purchase Orders inherit visibility from the projects available to // the authenticated site or contractor user. $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); } } $purchaseOrders = $query->latest()->paginate(20); return Inertia::render('MaterialLogistics::PurchaseOrders/Index', [ 'purchaseOrders' => $purchaseOrders, 'projects' => Project::active()->select('id', 'ulid', 'name', 'code')->get(), 'warehouses' => Warehouse::active()->select('id', 'ulid', 'name', 'code')->get(), ]); } public function create(Request $request) { $selectedProject = null; $budgetAnalysis = null; if ($request->filled('project_ulid')) { $selectedProject = Project::where('ulid', $request->project_ulid)->firstOrFail(); $workflowService = new ProjectWorkflowService(); if (!$workflowService->canCreatePurchaseOrder($selectedProject)) { return redirect()->back()->with('error', 'Cannot create Purchase Order: Project does not have approved Material Requisitions.'); } $budgetAnalysis = $workflowService->getBudgetAnalysis($selectedProject); } $materials = Material::select('id', 'ulid', 'name', 'unit', 'unit_cost') ->where('status', 'active') ->orderBy('name') ->get(); // Filter approved requisitions by project if selected $requisitionsQuery = MaterialRequisition::with(['items.material:id,ulid,name,unit,sku', 'requester:id,name']) ->where('status', 'approved') ->doesntHave('purchaseOrders'); if ($selectedProject) { $requisitionsQuery->where('project_id', $selectedProject->id); } $approvedRequisitions = $requisitionsQuery->orderByDesc('created_at')->get(); // Purchase Orders may be approved by an explicit permission or by // the system's Project Manager/executive roles. $approverIds = \App\Models\User::withoutGlobalScopes() ->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 Purchase Orders.'); } $warehousesQuery = Warehouse::active() ->whereDoesntHave('project', function ($q) { $q->whereIn('status', ['closed', 'completed']); }); if ($selectedProject) { $warehousesQuery->where('project_id', $selectedProject->id); } $warehouses = $warehousesQuery->with('project:id,ulid,name') ->select('id', 'ulid', 'name', 'code', 'project_id') ->get(); return Inertia::render('MaterialLogistics::PurchaseOrders/Form', [ 'materials' => $materials, 'approvedRequisitions' => $approvedRequisitions, 'projects' => Project::active()->select('id', 'ulid', 'name', 'code')->get(), 'selectedProject' => $selectedProject, 'budgetAnalysis' => $budgetAnalysis, 'warehouses' => $warehouses, ]); } public function store(Request $request) { $validated = $request->validate([ 'project_ulid' => 'required|string|exists:projects,ulid', 'target_warehouse_ulid' => 'required|string', 'supplier' => 'nullable|string|max:255', 'notes' => 'nullable|string|max:1000', 'requisition_ulids' => 'nullable|array', 'requisition_ulids.*' => 'string', '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.*.requisition_item_id' => 'nullable|integer', ]); $project = Project::where('ulid', $validated['project_ulid'])->firstOrFail(); $targetWarehouse = Warehouse::where('ulid', $validated['target_warehouse_ulid'])->firstOrFail(); if ($targetWarehouse->project_id !== $project->id) { return redirect()->back()->with('error', 'Target warehouse does not belong to the selected project.'); } $workflowService = new ProjectWorkflowService(); if (!$workflowService->canCreatePurchaseOrder($project)) { return redirect()->back()->with('error', 'Cannot create Purchase Order: Project workflow state does not permit PO creation.'); } $docService = new DocumentNumberService(); $po = DB::transaction(function () use ($validated, $project, $targetWarehouse, $docService) { $po = PurchaseOrder::create([ 'project_id' => $project->id, 'target_warehouse_id' => $targetWarehouse->id, 'document_number' => $docService->nextPoNumber(), 'supplier' => $validated['supplier'] ?? null, 'status' => 'draft', 'requested_by' => auth()->id(), 'notes' => $validated['notes'] ?? null, ]); foreach ($validated['items'] as $item) { $material = Material::where('ulid', $item['material_ulid'])->firstOrFail(); $po->items()->create([ 'material_id' => $material->id, 'material_requisition_item_id' => $item['requisition_item_id'] ?? null, 'quantity' => $item['quantity'], 'unit_cost' => $item['unit_cost'], ]); } // Link to MRs if provided if (!empty($validated['requisition_ulids'])) { $mrIds = MaterialRequisition::whereIn('ulid', $validated['requisition_ulids']) ->pluck('id'); $po->requisitions()->attach($mrIds); } return $po; }); return redirect()->route('purchase-orders.show', $po)->with('success', 'Purchase Order created.'); } public function edit(PurchaseOrder $purchaseOrder) { if ($purchaseOrder->status !== 'draft') { return redirect()->back()->with('error', 'Only draft POs can be edited.'); } $purchaseOrder->load('items.material:id,ulid,name,unit,unit_cost', 'requisitions:id,ulid,document_number', 'targetWarehouse:id,ulid,name,code', 'project:id,ulid,name,code'); $materials = Material::select('id', 'ulid', 'name', 'unit', 'unit_cost') ->where('status', 'active') ->orderBy('name') ->get(); $approvedRequisitions = MaterialRequisition::with(['items.material:id,ulid,name,unit,sku', 'requester:id,name']) ->where('status', 'approved') ->where(function ($query) use ($purchaseOrder) { $query->doesntHave('purchaseOrders') ->orWhereHas('purchaseOrders', function ($q) use ($purchaseOrder) { $q->where('purchase_orders.id', $purchaseOrder->id); }); }) ->orderByDesc('created_at') ->get(); $budgetAnalysis = null; if ($purchaseOrder->project) { $workflowService = new ProjectWorkflowService(); $budgetAnalysis = $workflowService->getBudgetAnalysis($purchaseOrder->project); } return Inertia::render('MaterialLogistics::PurchaseOrders/Form', [ 'purchaseOrder' => $purchaseOrder, 'materials' => $materials, 'approvedRequisitions' => $approvedRequisitions, 'projects' => Project::active()->select('id', 'ulid', 'name', 'code')->get(), 'selectedProject' => $purchaseOrder->project, 'budgetAnalysis' => $budgetAnalysis, 'warehouses' => Warehouse::active()->with('project:id,ulid,name')->select('id', 'ulid', 'name', 'code', 'project_id')->get(), ]); } public function update(Request $request, PurchaseOrder $purchaseOrder) { if ($purchaseOrder->status !== 'draft') { return redirect()->back()->with('error', 'Only draft POs can be updated.'); } $validated = $request->validate([ 'project_ulid' => 'required|string|exists:projects,ulid', 'target_warehouse_ulid' => 'required|string', 'supplier' => 'nullable|string|max:255', 'notes' => 'nullable|string|max:1000', 'requisition_ulids' => 'nullable|array', 'requisition_ulids.*' => 'string', '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.*.requisition_item_id' => 'nullable|integer', ]); $project = Project::where('ulid', $validated['project_ulid'])->firstOrFail(); $targetWarehouse = Warehouse::where('ulid', $validated['target_warehouse_ulid'])->firstOrFail(); if ($targetWarehouse->project_id !== $project->id) { return redirect()->back()->with('error', 'Target warehouse does not belong to the selected project.'); } $workflowService = new ProjectWorkflowService(); if (!$workflowService->canCreatePurchaseOrder($project)) { return redirect()->back()->with('error', 'Cannot update Purchase Order: Project workflow state does not permit PO updates.'); } DB::transaction(function () use ($validated, $project, $targetWarehouse, $purchaseOrder) { $purchaseOrder->update([ 'project_id' => $project->id, 'target_warehouse_id' => $targetWarehouse->id, 'supplier' => $validated['supplier'] ?? null, 'notes' => $validated['notes'] ?? null, ]); $purchaseOrder->items()->delete(); foreach ($validated['items'] as $item) { $material = Material::where('ulid', $item['material_ulid'])->firstOrFail(); $purchaseOrder->items()->create([ 'material_id' => $material->id, 'material_requisition_item_id' => $item['requisition_item_id'] ?? null, 'quantity' => $item['quantity'], 'unit_cost' => $item['unit_cost'], ]); } $purchaseOrder->requisitions()->detach(); if (!empty($validated['requisition_ulids'])) { $mrIds = MaterialRequisition::whereIn('ulid', $validated['requisition_ulids']) ->pluck('id'); $purchaseOrder->requisitions()->attach($mrIds); } }); return redirect()->route('purchase-orders.show', $purchaseOrder)->with('success', 'Purchase Order updated.'); } public function show(PurchaseOrder $purchaseOrder) { $purchaseOrder->load([ 'targetWarehouse:id,ulid,name', 'requester:id,ulid,name', 'approver:id,ulid,name', 'project:id,ulid,name,code', 'items.material' => function ($query) { $query->select('id', 'ulid', 'name', 'unit', 'unit_cost', 'type') ->with('components.component:id,name,unit'); }, 'requisitions:id,ulid,document_number', 'approvalChains.steps.approver:id,ulid,name', ]); $warehouses = Warehouse::active()->select('id', 'ulid', 'name', 'code')->get(); $budgetAnalysis = null; if ($purchaseOrder->project) { $workflowService = new ProjectWorkflowService(); $budgetAnalysis = $workflowService->getBudgetAnalysis($purchaseOrder->project); } return Inertia::render('MaterialLogistics::PurchaseOrders/Show', [ 'purchaseOrder' => $purchaseOrder, 'warehouses' => $warehouses, 'budgetAnalysis' => $budgetAnalysis, ]); } public function submitForApproval(PurchaseOrder $purchaseOrder) { if ($purchaseOrder->status !== 'draft') { return back()->with('error', 'Only draft purchase orders can be submitted.'); } // Use the same role-based approval rule during submission as on the // Purchase Order creation form. $approverIds = \App\Models\User::withoutGlobalScopes() ->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 Purchase Orders.'); } $approvalService = app(ApprovalService::class); $approvalService->createChain( $purchaseOrder, $approverIds, 'purchase_order', auth()->id(), ); $purchaseOrder->update(['status' => 'submitted']); return back()->with('success', 'Purchase Order submitted for approval.'); } public function downloadPdf(PurchaseOrder $purchaseOrder) { $purchaseOrder->load([ 'requester:id,name', 'approver:id,name', 'items.material:id,name,unit', 'requisitions:id,document_number', 'approvalChains.steps.approver:id,name', ]); $approverDetails = []; if ($purchaseOrder->approvalChains->isNotEmpty()) { $chain = $purchaseOrder->approvalChains->first(); foreach ($chain->steps as $step) { if ($step->status === 'approved' && $step->approver) { $approverDetails[] = [ 'name' => $step->approver->name, 'role' => 'Approver', 'date' => $step->acted_at?->format('M d, Y'), ]; } } } if (empty($approverDetails) && $purchaseOrder->status === 'approved' && $purchaseOrder->approver) { $approverDetails[] = [ 'name' => $purchaseOrder->approver->name, 'role' => 'Approver', 'date' => $purchaseOrder->approved_at ? $purchaseOrder->approved_at->format('M d, Y') : null, ]; } $pdf = Pdf::loadView('materiallogistics::pdf.purchase-order', [ 'purchaseOrder' => $purchaseOrder, 'approverDetails' => $approverDetails, ]); return $pdf->download("PO-{$purchaseOrder->document_number}.pdf"); } /** * JSON API: Search approved POs for the PO Lookup component. */ public function searchApproved(Request $request) { $request->validate([ 'q' => 'nullable|string|max:100', 'warehouse_ulid' => 'nullable|string', 'project_ulid' => 'nullable|string', ]); $query = PurchaseOrder::whereIn('status', ['approved', 'delivered']) ->with('items.material:id,ulid,name,unit,unit_cost'); if ($request->filled('q')) { $query->where('document_number', 'like', "%{$request->q}%"); } if ($request->filled('warehouse_ulid')) { $warehouse = Warehouse::where('ulid', $request->warehouse_ulid)->first(); if ($warehouse) { $query->where('target_warehouse_id', $warehouse->id); } } if ($request->filled('project_ulid')) { $project = Project::where('ulid', $request->project_ulid)->first(); if ($project) { $query->where('project_id', $project->id); } } $results = $query->select('id', 'ulid', 'document_number', 'supplier') ->latest() ->limit(20) ->get(); return response()->json($results); } public function markAsPaid(Request $request, PurchaseOrder $purchaseOrder) { if ($purchaseOrder->status !== 'approved') { return back()->with('error', 'Only approved purchase orders can be marked as paid.'); } if ($purchaseOrder->isPaid()) { return back()->with('error', 'This purchase order is already paid.'); } $hasFileInfo = extension_loaded('fileinfo'); $rules = [ 'receipt' => 'required|file|max:10240', 'warehouse_ulid' => 'nullable|string', ]; if ($hasFileInfo) { $rules['receipt'] .= '|mimes:pdf,jpg,jpeg,png'; } $validated = $request->validate($rules); $file = $request->file('receipt'); if (!$hasFileInfo) { $extension = strtolower($file->getClientOriginalExtension() ?: pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION)); if (!in_array($extension, ['pdf', 'jpg', 'jpeg', 'png'])) { return back()->withErrors(['receipt' => 'The receipt must be a file of type: pdf, jpg, jpeg, png.']); } } // Store receipt file if ($hasFileInfo) { $path = $file->store("po-receipts/{$purchaseOrder->id}", 'public'); } else { $extension = strtolower($file->getClientOriginalExtension() ?: pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION)); $fileName = \Illuminate\Support\Str::random(40) . '.' . $extension; $path = "po-receipts/{$purchaseOrder->id}/" . $fileName; \Illuminate\Support\Facades\Storage::disk('public')->put($path, fopen($file->getRealPath(), 'r')); } DB::transaction(function () use ($purchaseOrder, $path, $file, $request) { $purchaseOrder->update([ 'payment_status' => 'paid', 'paid_at' => now(), 'receipt_path' => $path, 'receipt_original_name' => $file->getClientOriginalName(), ]); if ($request->filled('warehouse_ulid')) { $warehouse = Warehouse::where('ulid', $request->warehouse_ulid)->firstOrFail(); $purchaseOrder->update([ 'status' => 'delivered', 'target_warehouse_id' => $warehouse->id, ]); $warehouseService = app(\Modules\MaterialLogistics\Services\WarehouseService::class); $purchaseOrder->load('items.material', 'items.requisitionItem'); foreach ($purchaseOrder->items as $item) { $warehouseService->receiveStock( warehouse: $warehouse, material: $item->material, quantity: (float) $item->quantity, unitCost: (float) $item->unit_cost, purchaseOrder: $purchaseOrder, materialRequisitionId: $item->requisitionItem?->material_requisition_id, performedBy: auth()->id(), ); } } }); $message = $request->filled('warehouse_ulid') ? 'Purchase Order marked as paid and materials delivered to warehouse.' : 'Purchase Order marked as paid.'; return back()->with('success', $message); } public function markAsDelivered(PurchaseOrder $purchaseOrder) { if ($purchaseOrder->status !== 'approved') { return back()->with('error', 'Only approved purchase orders can be marked as delivered.'); } if (!$purchaseOrder->target_warehouse_id) { return back()->with('error', 'No target warehouse assigned to this Purchase Order.'); } DB::transaction(function () use ($purchaseOrder) { $purchaseOrder->update(['status' => 'delivered']); // Dispatch event or call service to inject inventory $warehouseService = app(\Modules\MaterialLogistics\Services\WarehouseService::class); $warehouse = $purchaseOrder->targetWarehouse; $purchaseOrder->load('items.material', 'items.requisitionItem'); foreach ($purchaseOrder->items as $item) { $warehouseService->receiveStock( warehouse: $warehouse, material: $item->material, quantity: (float) $item->quantity, unitCost: (float) $item->unit_cost, purchaseOrder: $purchaseOrder, materialRequisitionId: $item->requisitionItem?->material_requisition_id, performedBy: auth()->id(), ); } }); return back()->with('success', 'Purchase Order marked as delivered. Materials are now in the warehouse.'); } public function downloadReceipt(PurchaseOrder $purchaseOrder) { if (!$purchaseOrder->hasReceipt()) { abort(404, 'No receipt uploaded.'); } $disk = \Storage::disk('public'); if (!$disk->exists($purchaseOrder->receipt_path)) { abort(404, 'Receipt file not found.'); } return $disk->download( $purchaseOrder->receipt_path, $purchaseOrder->receipt_original_name ); } private function availableProjectsQuery() { // Project's TenantScope is the source of truth for project visibility. return Project::query(); } }