po = model(PurchaseOrder::class); $this->item = model(PurchaseOrderItem::class); $this->supplier = model(Supplier::class); $this->inventoryItem = model(InventoryItem::class); $this->movement = model(InventoryMovement::class); } public function index() { $page = max(1, (int) ($this->request->getGet('page') ?? 1)); $perPage= min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20))); $keyword= trim((string) ($this->request->getGet('q') ?? '')); $query = $this->po->newQuery() ->select('purchase_orders.*', 'suppliers.name AS supplier_name') ->leftJoin('suppliers', 'suppliers.id', '=', 'purchase_orders.supplier_id') ->orderByDesc('purchase_orders.created_at'); if ($keyword !== '') { $query->where(function ($q) use ($keyword) { $q->where('po_number', 'LIKE', "%{$keyword}%") ->orWhere('suppliers.name', 'LIKE', "%{$keyword}%"); }); } $result = $this->paginate($query, $page, $perPage); return $this->success($result, 'Purchase orders retrieved successfully'); } public function show($id = null) { $po = $this->po->newQuery() ->select('purchase_orders.*', 'suppliers.name AS supplier_name') ->leftJoin('suppliers', 'suppliers.id', '=', 'purchase_orders.supplier_id') ->where('purchase_orders.id', $id) ->first(); if (!$po) { return $this->error('Purchase order not found', Response::HTTP_NOT_FOUND); } $items = $this->item->newQuery() ->select('purchase_order_items.*', 'inventory_items.name AS supply_name', 'inventory_items.unit AS supply_unit') ->leftJoin('inventory_items', 'inventory_items.id', '=', 'purchase_order_items.supply_id') ->where('purchase_order_id', $id) ->get() ->toArray(); $po['items'] = $items; return $this->success($po, 'Purchase order retrieved successfully'); } public function store() { $user = $this->getCurrentUser(); if (!$user) { return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); } $payload = $this->payloadData(); if (empty($payload)) { return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST); } // Support both CodeIgniter-style form arrays and modern JSON format $supplyIds = $payload['item_supply_id'] ?? $payload['items'] ?? []; $descs = $payload['item_description'] ?? []; $qtys = $payload['item_quantity'] ?? []; $unitCosts = $payload['item_unit_cost'] ?? []; // If items array is provided (modern format), extract data if (isset($payload['items']) && is_array($payload['items'])) { $items = []; foreach ($payload['items'] as $item) { if (isset($item['supply_id']) && isset($item['quantity']) && $item['quantity'] > 0) { $items[] = [ 'supply_id' => $item['supply_id'], 'description' => $item['description'] ?? '', 'quantity' => (int)$item['quantity'], 'unit_cost' => (float)($item['unit_cost'] ?? 0), ]; } } } else { // CodeIgniter-style form arrays if (empty($supplyIds) || !is_array($supplyIds)) { return $this->respondError('Add at least one line item.', Response::HTTP_BAD_REQUEST); } $items = []; foreach ($supplyIds as $i => $sid) { $q = max(0, (int)($qtys[$i] ?? 0)); $uc = (float)($unitCosts[$i] ?? 0); if ($sid && $q > 0) { $items[] = [ 'supply_id' => (int)$sid, 'description' => trim($descs[$i] ?? ''), 'quantity' => $q, 'unit_cost' => $uc, ]; } } } if (empty($items)) { return $this->respondError('Valid line items required.', Response::HTTP_BAD_REQUEST); } $rules = [ 'po_number' => 'required|max_length:255', 'supplier_id' => 'required|integer', 'order_date' => 'required|date', ]; $errors = $this->validateRequest($payload, $rules); if (!empty($errors)) { return $this->respondValidationError($errors); } try { DB::beginTransaction(); // Compute totals $subtotal = 0.0; foreach ($items as $item) { $subtotal += $item['quantity'] * $item['unit_cost']; } $tax = (float)($payload['tax'] ?? 0.00); $total = $subtotal + $tax; $status = $payload['status'] ?? 'ordered'; $status = in_array($status, ['draft', 'ordered'], true) ? $status : 'ordered'; $poData = [ 'po_number' => $payload['po_number'], 'supplier_id' => $payload['supplier_id'], 'order_date' => $payload['order_date'], 'expected_date' => $payload['expected_date'] ?? null, 'status' => $status, 'subtotal' => $subtotal, 'tax' => $tax, 'total' => $total, 'notes' => $payload['notes'] ?? null, ]; $po = $this->po->create($poData); $poId = $po->id ?? null; if (!$poId) { DB::rollBack(); return $this->respondError('Failed to create purchase order', Response::HTTP_INTERNAL_SERVER_ERROR); } foreach ($items as $item) { $this->item->create([ 'purchase_order_id' => $poId, 'supply_id' => $item['supply_id'], 'description' => $item['description'], 'quantity' => $item['quantity'], 'unit_cost' => $item['unit_cost'], 'received_qty' => 0, ]); } DB::commit(); $poArray = $this->show($poId)->getData(true)['data']; return $this->success($poArray, 'Purchase order created successfully', Response::HTTP_CREATED); } catch (\Throwable $e) { DB::rollBack(); log_message('error', 'Purchase order creation error: ' . $e->getMessage()); return $this->respondError('Failed to create purchase order: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); } } public function update($id = null) { $po = $this->po->find($id); if (!$po) { return $this->error('Purchase order not found', Response::HTTP_NOT_FOUND); } $payload = $this->payloadData(); if (empty($payload)) { return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST); } $allowedFields = ['status', 'expected_date', 'notes']; $updateData = []; foreach ($allowedFields as $field) { if (array_key_exists($field, $payload)) { $updateData[$field] = $payload[$field]; } } if (empty($updateData)) { return $this->error('No valid fields to update', Response::HTTP_BAD_REQUEST); } try { $this->po->update($id, $updateData); $updatedPo = $this->po->find($id); return $this->success($updatedPo, 'Purchase order updated successfully'); } catch (\Throwable $e) { log_message('error', 'Purchase order update error: ' . $e->getMessage()); return $this->respondError('Failed to update purchase order', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * POST /api/v1/purchase-orders/{id}/receive * Receive items (partial or full) from a purchase order */ public function receive($id = null) { $po = $this->po->find($id); if (!$po) { return $this->error('Purchase order not found', Response::HTTP_NOT_FOUND); } if (in_array($po['status'], ['canceled', 'received'], true)) { return $this->respondError('PO not receivable. Status: ' . $po['status'], Response::HTTP_BAD_REQUEST); } $payload = $this->payloadData(); $received = $payload['received'] ?? []; // [itemId => qty] if (empty($received) || !is_array($received)) { return $this->respondError('No items to receive.', Response::HTTP_BAD_REQUEST); } $user = $this->getCurrentUser(); $issuedBy = $user ? ($user->email ?? $user->username ?? 'system') : 'system'; try { DB::beginTransaction(); $completed = true; foreach ($received as $itemId => $qty) { $qty = (int)$qty; if ($qty <= 0) continue; $item = $this->item->where('purchase_order_id', $id)->find($itemId); if (!$item) { $completed = false; continue; } $remaining = (int)$item['quantity'] - (int)$item['received_qty']; $toReceive = min($remaining, $qty); if ($toReceive <= 0) continue; // Update item received qty $this->item->update($itemId, [ 'received_qty' => (int)$item['received_qty'] + $toReceive ]); // Update inventory item on hand (if supply_id maps to inventory_items) if (!empty($item['supply_id'])) { $inventoryItem = $this->inventoryItem->find($item['supply_id']); if ($inventoryItem) { $newQty = (int)($inventoryItem['quantity'] ?? 0) + $toReceive; $this->inventoryItem->update($inventoryItem['id'], [ 'quantity' => $newQty ]); // Log transaction IN using inventory movements $this->movement->insert([ 'item_id' => $inventoryItem['id'], 'qty_change' => $toReceive, 'movement_type' => 'in', 'reason' => 'PO ' . $po['po_number'], 'note' => 'Received against PO', 'performed_by' => $this->getCurrentUserId() ?? null, ]); } } if (($item['received_qty'] + $toReceive) < $item['quantity']) { $completed = false; } } // Set PO status $this->po->update($id, [ 'status' => $completed ? 'received' : 'ordered' ]); DB::commit(); $message = $completed ? 'PO fully received.' : 'PO partially received.'; return $this->success(null, $message); } catch (\Throwable $e) { DB::rollBack(); log_message('error', 'Receive PO error: ' . $e->getMessage()); return $this->respondError('Failed to receive items: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * POST /api/v1/purchase-orders/{id}/cancel * Cancel a purchase order */ public function cancel($id = null) { $po = $this->po->find($id); if (!$po) { return $this->error('Purchase order not found', Response::HTTP_NOT_FOUND); } if ($po['status'] === 'received') { return $this->respondError('Cannot cancel a received PO.', Response::HTTP_BAD_REQUEST); } try { $this->po->update($id, ['status' => 'canceled']); $updatedPo = $this->po->find($id); return $this->success($updatedPo, 'PO canceled.'); } catch (\Throwable $e) { log_message('error', 'Cancel PO error: ' . $e->getMessage()); return $this->respondError('Failed to cancel purchase order: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); } } }