chore: update document approval workflow and bug fixes
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
<?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;
|
||||
|
||||
class PurchaseOrderController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$purchaseOrders = PurchaseOrder::with([
|
||||
'requester:id,ulid,name',
|
||||
'approver:id,ulid,name',
|
||||
'requisitions:id,ulid,document_number',
|
||||
'items:id,purchase_order_id,quantity,unit_cost'
|
||||
])
|
||||
->latest()
|
||||
->paginate(20);
|
||||
return Inertia::render('MaterialLogistics::PurchaseOrders/Index', [
|
||||
'purchaseOrders' => $purchaseOrders,
|
||||
'warehouses' => Warehouse::active()->select('id', 'ulid', 'name', 'code')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
|
||||
$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')
|
||||
->doesntHave('purchaseOrders')
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
// Find personnel with approve_po permission across the whole system
|
||||
$approverIds = \App\Models\User::permission('approve_po')
|
||||
->pluck('id')
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
if (empty($approverIds)) {
|
||||
return back()->with('error', 'No approvers found in the system for Purchase Orders.');
|
||||
}
|
||||
|
||||
return Inertia::render('MaterialLogistics::PurchaseOrders/Form', [
|
||||
'materials' => $materials,
|
||||
'approvedRequisitions' => $approvedRequisitions,
|
||||
'warehouses' => Inertia::lazy(fn () => Warehouse::active()
|
||||
->whereDoesntHave('project', function ($q) {
|
||||
$q->whereIn('status', ['closed', 'completed']);
|
||||
})
|
||||
->with('project:id,ulid,name')
|
||||
->select('id', 'ulid', 'name', 'code', 'project_id')
|
||||
->get()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'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',
|
||||
]);
|
||||
|
||||
$targetWarehouse = Warehouse::where('ulid', $validated['target_warehouse_ulid'])->firstOrFail();
|
||||
$docService = new DocumentNumberService();
|
||||
|
||||
$po = DB::transaction(function () use ($validated, $targetWarehouse, $docService) {
|
||||
$po = PurchaseOrder::create([
|
||||
'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');
|
||||
|
||||
$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();
|
||||
|
||||
return Inertia::render('MaterialLogistics::PurchaseOrders/Form', [
|
||||
'purchaseOrder' => $purchaseOrder,
|
||||
'materials' => $materials,
|
||||
'approvedRequisitions' => $approvedRequisitions,
|
||||
'warehouses' => Inertia::lazy(fn () => Warehouse::active()
|
||||
->whereDoesntHave('project', function ($q) {
|
||||
$q->whereIn('status', ['closed', 'completed']);
|
||||
})
|
||||
->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([
|
||||
'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',
|
||||
]);
|
||||
|
||||
$targetWarehouse = Warehouse::where('ulid', $validated['target_warehouse_ulid'])->firstOrFail();
|
||||
|
||||
DB::transaction(function () use ($validated, $targetWarehouse, $purchaseOrder) {
|
||||
$purchaseOrder->update([
|
||||
'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',
|
||||
'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();
|
||||
|
||||
return Inertia::render('MaterialLogistics::PurchaseOrders/Show', [
|
||||
'purchaseOrder' => $purchaseOrder,
|
||||
'warehouses' => $warehouses,
|
||||
]);
|
||||
}
|
||||
|
||||
public function submitForApproval(PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
if ($purchaseOrder->status !== 'draft') {
|
||||
return back()->with('error', 'Only draft purchase orders can be submitted.');
|
||||
}
|
||||
|
||||
// Find personnel with approve_po permission across the whole system
|
||||
$approverIds = \App\Models\User::permission('approve_po')
|
||||
->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',
|
||||
]);
|
||||
|
||||
$query = PurchaseOrder::where('status', 'approved')
|
||||
->with('items.material:id,ulid,name,unit,unit_cost');
|
||||
|
||||
if ($request->filled('q')) {
|
||||
$query->where('document_number', 'like', "%{$request->q}%");
|
||||
}
|
||||
|
||||
$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.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'receipt' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240',
|
||||
'warehouse_ulid' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// Store receipt file
|
||||
$file = $request->file('receipt');
|
||||
$path = $file->store("po-receipts/{$purchaseOrder->id}", 'public');
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user