Files
GSB-Construction/Modules/MaterialLogistics/app/Http/Controllers/PurchaseOrderController.php
Ajjj 10eb7be033
Some checks failed
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
Tests / PHP 8.5 (push) Has been cancelled
feat: implement module API routes, controllers, and comprehensive system testing with role-based access control
2026-08-06 19:38:51 +08:00

608 lines
24 KiB
PHP

<?php
namespace Modules\MaterialLogistics\Http\Controllers;
use App\Http\Controllers\Controller;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Modules\ApprovalWorkflow\Services\ApprovalService;
use Modules\MaterialLogistics\Events\PurchaseOrderPaid;
use Modules\MasterData\Models\Material;
use Modules\MaterialLogistics\Models\MaterialRequisition;
use Modules\MaterialLogistics\Models\PurchaseOrder;
use Modules\MaterialLogistics\Models\Warehouse;
use Modules\MaterialLogistics\Services\DocumentNumberService;
use Modules\ProjectManagement\Models\Project;
use Modules\ProjectManagement\Services\ProjectWorkflowService;
class PurchaseOrderController extends Controller
{
public function index(Request $request)
{
$query = PurchaseOrder::with([
'requester' => 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' => fn ($query) => $query->withoutGlobalScopes()->select('id', 'ulid', 'name'),
'approver' => fn ($query) => $query->withoutGlobalScopes()->select('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' => fn ($query) => $query->withoutGlobalScopes()->select('id', 'name'),
'approver' => fn ($query) => $query->withoutGlobalScopes()->select('id', 'name'),
'items.material:id,name,unit',
'requisitions:id,document_number',
'approvalChains.steps.approver:id,name',
]);
$approverDetails = [];
if ($purchaseOrder->approvalChains->isNotEmpty()) {
$chain = $purchaseOrder->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) && $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';
}
$validated = $request->validate($rules);
$file = $request->file('receipt');
if (!$hasFileInfo) {
$extension = strtolower($file->getClientOriginalExtension() ?: pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION));
if ($extension !== 'pdf') {
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
);
}
public function viewReceipt(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 response()->file($disk->path($purchaseOrder->receipt_path), [
'Content-Type' => $disk->mimeType($purchaseOrder->receipt_path) ?: 'application/octet-stream',
'Content-Disposition' => 'inline; filename="' . addslashes($purchaseOrder->receipt_original_name ?: 'receipt') . '"',
]);
}
private function availableProjectsQuery()
{
// Project's TenantScope is the source of truth for project visibility.
return Project::query();
}
}