item = model(InventoryItem::class); $this->cat = model(InventoryCategory::class); $this->mov = model(InventoryMovement::class); $this->user = model(User::class); $this->config = model(Configuration::class); $this->teacherClass = model(TeacherClass::class); $this->studentClass = model(StudentClass::class); $this->classSection = model(ClassSection::class); $this->teacher = model(Teacher::class); $this->schoolYear = (string) ($this->config->getConfig('school_year') ?? ''); $this->semester = (string) ($this->config->getConfig('semester') ?? ''); } /** * GET /api/v1/inventory * List inventory items filtered by type, school year, and semester */ public function index() { $type = $this->normalizeType($this->request->getGet('type') ?? 'classroom'); $schoolYears = $this->getSchoolYearsForType($type); $requested = trim((string) ($this->request->getGet('school_year') ?? '')); $selectedYear = $requested !== '' ? $requested : $this->schoolYear; $selectedSem = trim((string) ($this->request->getGet('semester') ?? '')); $builder = $this->item->where('type', $type); if (strtolower($selectedYear) !== 'all') { $builder->where('school_year', $selectedYear); } if ($selectedSem !== '') { $builder->where('semester', $selectedSem); } $items = $builder->orderBy('name', 'ASC')->findAll(); $categories = $this->cat->optionsForType($type); // Build UpdatedBy name map $userIds = array_values(array_unique(array_map(fn($i) => (int)($i['updated_by'] ?? 0), $items))); $userIds = array_values(array_filter($userIds)); $userNames = $this->userNamesByIds($userIds); return $this->success([ 'type' => $type, 'items' => $items, 'categories' => $categories, 'conditionOptions' => [ 'good' => 'Good condition', 'needs_repair' => 'Needs repair', 'need_replace' => 'Need replace', 'cannot_find' => 'Cannot find' ], 'schoolYears' => $schoolYears, 'selectedYear' => $selectedYear, 'currentYear' => $this->schoolYear, 'semester' => $selectedSem, 'userNames' => $userNames, ], 'Inventory items retrieved successfully'); } /** * GET /api/v1/inventory/{id} * Get a single inventory item */ public function show($id = null) { $item = $this->item->find($id); if (!$item) { return $this->error('Item not found.', Response::HTTP_NOT_FOUND); } $categories = $this->cat->optionsForType($item['type']); return $this->success([ 'type' => $item['type'], 'item' => $item, 'categories' => $categories, 'conditionOptions' => [ 'good' => 'Good condition', 'needs_repair' => 'Needs repair', 'need_replace' => 'Need replace', 'cannot_find' => 'Cannot find' ], 'isEdit' => true, ], 'Inventory item retrieved successfully'); } /** * GET /api/v1/inventory/create * Get form data for creating a new item */ public function create() { $type = $this->normalizeType($this->request->getGet('type') ?? 'classroom'); $categories = $this->cat->optionsForType($type); return $this->success([ 'type' => $type, 'item' => [], 'categories' => $categories, 'conditionOptions' => [ 'good' => 'Good condition', 'needs_repair' => 'Needs repair', 'need_replace' => 'Need replace', 'cannot_find' => 'Cannot find', ], ], 'Form data retrieved successfully'); } /** * POST /api/v1/inventory * Create a new inventory item */ public function store() { $data = $this->payloadData(); $itemData = $this->filterItemData($data); try { $itemId = $this->item->insert($itemData, true); if ($itemId) { $initialQty = (int) ($itemData['quantity'] ?? 0); if ($initialQty !== 0) { $this->recordMovement($itemId, $initialQty, 'initial', 'Initial stock'); } else { $this->recalcQuantity($itemId); } $item = $this->item->find($itemId); return $this->respondCreated($item, 'Item added.'); } return $this->error('Failed to create item', Response::HTTP_INTERNAL_SERVER_ERROR); } catch (\Throwable $e) { log_message('error', 'Inventory item creation error: ' . $e->getMessage()); return $this->error('Failed to create item: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * PATCH /api/v1/inventory/{id} * Update an inventory item */ public function update($id = null) { $item = $this->item->find($id); if (!$item) { return $this->error('Item not found.', Response::HTTP_NOT_FOUND); } $data = $this->filterItemData($this->payloadData(), $item['type']); unset($data['id']); try { if (!$this->item->update($id, $data)) { $errors = $this->item->errors() ?? []; return $this->respondValidationError($errors, 'Validation failed'); } $updatedItem = $this->item->find($id); return $this->success($updatedItem, 'Item updated.'); } catch (\Throwable $e) { log_message('error', 'Inventory item update error: ' . $e->getMessage()); return $this->error('Failed to update item: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * DELETE /api/v1/inventory/{id} * Delete an inventory item */ public function delete($id = null) { $item = $this->item->find($id); if (!$item) { return $this->error('Item not found.', Response::HTTP_NOT_FOUND); } try { $type = $item['type']; $this->item->delete($id); return $this->success(['type' => $type], 'Item deleted.'); } catch (\Throwable $e) { log_message('error', 'Inventory item deletion error: ' . $e->getMessage()); return $this->error('Failed to delete item: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * POST /api/v1/inventory/categories * Save a category (create or update) */ public function saveCategory() { $rawType = (string)($this->request->getPost('type') ?? ''); $type = strtolower(trim($rawType)); $id = $this->request->getPost('id'); $name = preg_replace('/\s+/', ' ', trim((string)$this->request->getPost('name'))); $data = [ 'type' => in_array($type, ['classroom', 'book', 'office', 'kitchen'], true) ? $type : 'office', 'name' => $name, 'description' => (string)$this->request->getPost('description'), ]; // Only books have grade range if ($data['type'] === 'book') { $gmin = $this->request->getPost('grade_min'); $gmax = $this->request->getPost('grade_max'); $gmin = ($gmin === '' || $gmin === null) ? null : max(0, (int)$gmin); $gmax = ($gmax === '' || $gmax === null) ? null : max(0, (int)$gmax); if ($gmin !== null && $gmax !== null && $gmin > $gmax) { return $this->error('Grade Min cannot be greater than Grade Max.', Response::HTTP_UNPROCESSABLE_ENTITY); } if ($gmin !== null && $gmin > 13) $gmin = 13; if ($gmax !== null && $gmax > 13) $gmax = 13; $data['grade_min'] = $gmin; $data['grade_max'] = $gmax; } else { $data['grade_min'] = null; $data['grade_max'] = null; } // Duplicate guard for (type, name) pair $existing = $this->cat ->where('type', $data['type']) ->where('name', $data['name']) ->first(); try { if ($id) { $pk = $this->cat->primaryKey ?? 'id'; if ($existing && (int)$existing[$pk] !== (int)$id) { return $this->error( 'A category with this Type & Name already exists. Please choose a different name or edit the existing one.', Response::HTTP_CONFLICT ); } $ok = $this->cat->update((int)$id, $data); } else { if ($existing) { return $this->error( 'This category already exists: ' . $data['type'] . ' — ' . $data['name'] . '.', Response::HTTP_CONFLICT ); } $ok = (bool) $this->cat->insert($data); } } catch (\Throwable $e) { $msg = $e->getMessage(); if (strpos($msg, 'Duplicate entry') !== false && strpos($msg, 'type_name') !== false) { return $this->error( 'Duplicate Type/Name. A category with this combination already exists.', Response::HTTP_CONFLICT ); } log_message('critical', 'Failed to save category: {msg}', ['msg' => $msg]); return $this->error('Failed to save category.', Response::HTTP_INTERNAL_SERVER_ERROR); } if (!$ok) { return $this->error('Failed to save category.', Response::HTTP_INTERNAL_SERVER_ERROR); } return $this->success(null, 'Category saved.'); } /** * GET /api/v1/inventory/classroom/{id}/audit * Get audit form data for a classroom item */ public function auditClassroomForm($itemId) { $item = $this->item->find($itemId); if (!$item || $item['type'] !== 'classroom') { return $this->error('Invalid classroom item.', Response::HTTP_NOT_FOUND); } $item['good_qty'] = (int)($item['good_qty'] ?? 0); $item['needs_repair_qty'] = (int)($item['needs_repair_qty'] ?? 0); $item['need_replace_qty'] = (int)($item['need_replace_qty'] ?? 0); $item['cannot_find_qty'] = (int)($item['cannot_find_qty'] ?? 0); return $this->success(['item' => $item], 'Audit form data retrieved successfully'); } /** * POST /api/v1/inventory/classroom/{id}/audit * Save audit data for a classroom item */ public function auditClassroomStore($itemId) { $item = $this->item->find($itemId); if (!$item || $item['type'] !== 'classroom') { return $this->error('Invalid classroom item.', Response::HTTP_NOT_FOUND); } $prevGood = (int)($item['good_qty'] ?? 0); $prevRepair = (int)($item['needs_repair_qty'] ?? 0); $prevLoss = (int)($item['need_replace_qty'] ?? 0); $prevMiss = (int)($item['cannot_find_qty'] ?? 0); $newRepair = max(0, (int)$this->request->getPost('needs_repair_qty')); $newLoss = max(0, (int)$this->request->getPost('need_replace_qty')); $newMiss = max(0, (int)$this->request->getPost('cannot_find_qty')); $onHandNow = (int)$item['quantity']; $newGood = max(0, $onHandNow - $newRepair); $deltaLoss = $newLoss - $prevLoss; $deltaMiss = $newMiss - $prevMiss; $totalOut = 0; if ($deltaLoss > 0) $totalOut += $deltaLoss; if ($deltaMiss > 0) $totalOut += $deltaMiss; $totalIn = 0; if ($deltaLoss < 0) $totalIn += -$deltaLoss; if ($deltaMiss < 0) $totalIn += -$deltaMiss; if ($totalOut > 0) { if (!$this->recordMovement($itemId, -$totalOut, 'out', 'Audit: loss/missing increased')) { return $this->error('Failed to record movement', Response::HTTP_INTERNAL_SERVER_ERROR); } } if ($totalIn > 0) { $this->recordMovement($itemId, +$totalIn, 'in', 'Audit: loss/missing decreased'); } $this->recalcQuantity($itemId); try { $this->item->update($itemId, [ 'good_qty' => $newGood, 'needs_repair_qty' => $newRepair, 'need_replace_qty' => $newLoss, 'cannot_find_qty' => $newMiss, 'updated_by' => $this->getCurrentUserId(), 'updated_at' => utc_now(), ]); return $this->success(null, 'Audit saved.'); } catch (\Throwable $e) { log_message('error', 'Audit save error: ' . $e->getMessage()); return $this->error('Failed to save audit: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * GET /api/v1/inventory/summary/{type?} * Get inventory summary for a type */ public function summary($type = 'classroom') { $type = $this->normalizeType($type); $currentSem = $this->semester; $currentYear = $this->schoolYear; $selectedYear = trim((string) ($this->request->getGet('school_year') ?? '')) ?: $currentYear; $items = $this->item->where('type', $type)->orderBy('name', 'ASC')->findAll(); $itemIds = array_map(fn($i) => (int)$i['id'], $items); $agg = []; if ($itemIds) { $qb = $this->mov->select('item_id, SUM(CASE WHEN qty_change>0 THEN qty_change ELSE 0 END) AS received, SUM(CASE WHEN qty_change<0 THEN -qty_change ELSE 0 END) AS issued') ->whereIn('item_id', $itemIds) ->groupBy('item_id'); if (strtolower($selectedYear) !== 'all') { $qb->where('school_year', $selectedYear); } foreach ($qb->findAll() as $row) { $agg[(int)$row['item_id']] = [ 'received' => (int)$row['received'], 'issued' => (int)$row['issued'], ]; } } $schoolYears = $this->getSchoolYearsForType($type); return $this->success([ 'type' => $type, 'items' => $items, 'agg' => $agg, 'selectedYear' => $selectedYear, 'currentYear' => $currentYear, 'schoolYears' => $schoolYears, ], 'Summary retrieved successfully'); } /** * GET /api/v1/inventory/summary-all * Get comprehensive inventory summary across all types */ public function summaryAll() { $selectedYear = trim((string) ($this->request->getGet('school_year') ?? '')) ?: (string) $this->schoolYear; $selectedType = strtolower((string) ($this->request->getGet('type') ?? 'all')); $isAllYears = (strtolower($selectedYear) === 'all'); $qbItems = DB::table('inventory_items i') ->select('i.*, c.name AS category_name, CONCAT(u.firstname, " ", u.lastname) AS updated_by_name') ->leftJoin('inventory_categories c', 'c.id', '=', 'i.category_id') ->leftJoin('users u', 'u.id', '=', 'i.updated_by') ->orderBy('i.name', 'ASC'); if (!$isAllYears) { $qbItems->where('i.school_year', $selectedYear); } if (in_array($selectedType, ['book', 'classroom', 'office', 'kitchen'], true)) { $qbItems->where('i.type', $selectedType); } $items = $qbItems->get()->toArray(); $rows = []; $totals = [ 'opening' => 0, 'received' => 0, 'distributed' => 0, 'other_out' => 0, 'adjust_net' => 0, 'ending' => 0, 'onhand' => 0, 'variance' => 0, ]; if (empty($items)) { return $this->success([ 'selectedYear' => $selectedYear, 'currentYear' => $this->schoolYear, 'schoolYears' => $this->getSchoolYearsForType('book') ?: [$this->schoolYear, 'All'], 'rows' => $rows, 'totals' => $totals, 'selectedType' => $selectedType, ], 'Summary retrieved successfully'); } $itemIds = array_map('intval', array_column($items, 'id')); $aggById = []; $qb = DB::table('inventory_movements m') ->select(DB::raw(" m.item_id, SUM(CASE WHEN m.movement_type = 'initial' THEN m.qty_change ELSE 0 END) AS opening, SUM(CASE WHEN m.qty_change > 0 AND m.movement_type IN ('in','adjust') THEN m.qty_change ELSE 0 END) AS received, SUM(CASE WHEN m.qty_change < 0 AND m.movement_type = 'distribution' THEN -m.qty_change ELSE 0 END) AS distributed, SUM(CASE WHEN m.qty_change < 0 AND m.movement_type = 'out' THEN -m.qty_change ELSE 0 END) AS other_out, SUM(CASE WHEN m.movement_type = 'adjust' THEN m.qty_change ELSE 0 END) AS adjust_net ")) ->whereIn('m.item_id', $itemIds) ->groupBy('m.item_id'); if (!$isAllYears) { $qb->where('m.school_year', $selectedYear); } foreach ($qb->get()->toArray() as $r) { $aggById[(int)$r->item_id] = [ 'opening' => (int)($r->opening ?? 0), 'received' => (int)($r->received ?? 0), 'distributed' => (int)($r->distributed ?? 0), 'other_out' => (int)($r->other_out ?? 0), 'adjust_net' => (int)($r->adjust_net ?? 0), ]; } foreach ($items as $it) { $it = (array)$it; $id = (int)$it['id']; $a = $aggById[$id] ?? ['opening' => 0, 'received' => 0, 'distributed' => 0, 'other_out' => 0, 'adjust_net' => 0]; $opening = (int)$a['opening']; $received = (int)$a['received']; $distributed = (int)$a['distributed']; $otherOut = (int)$a['other_out']; $adjustNet = (int)$a['adjust_net']; $ending = $opening + $received - $distributed - $otherOut + $adjustNet; $onhand = (int)$it['quantity']; $variance = $onhand - $ending; $rows[] = [ 'id' => $id, 'name' => (string)$it['name'], 'type' => (string)$it['type'], 'category' => (string)($it['category_name'] ?? ''), 'opening' => $opening, 'received' => $received, 'distributed' => $distributed, 'other_out' => $otherOut, 'adjust_net' => $adjustNet, 'ending' => $ending, 'onhand' => $onhand, 'variance' => $variance, ]; $totals['opening'] += $opening; $totals['received'] += $received; $totals['distributed'] += $distributed; $totals['other_out'] += $otherOut; $totals['adjust_net'] += $adjustNet; $totals['ending'] += $ending; $totals['onhand'] += $onhand; $totals['variance'] += $variance; } $schoolYears = $this->getSchoolYearsForType('book') ?: [$this->schoolYear, 'All']; return $this->success([ 'selectedYear' => $selectedYear, 'currentYear' => $this->schoolYear, 'schoolYears' => $schoolYears, 'rows' => $rows, 'totals' => $totals, 'selectedType' => $selectedType, ], 'Summary retrieved successfully'); } /** * GET /api/v1/inventory/{id}/adjust * Get adjust form data */ public function adjustForm($itemId) { $item = $this->item->find($itemId); if (!$item) { return $this->error('Item not found.', Response::HTTP_NOT_FOUND); } return $this->success(['item' => $item, 'type' => $item['type']], 'Adjust form data retrieved successfully'); } /** * POST /api/v1/inventory/{id}/adjust * Adjust inventory quantity */ public function adjustStore($itemId) { $item = $this->item->find($itemId); if (!$item) { return $this->error('Item not found.', Response::HTTP_NOT_FOUND); } $mode = $this->request->getPost('mode'); $qty = (int) $this->request->getPost('quantity'); $reason = (string) $this->request->getPost('reason'); $note = (string) $this->request->getPost('note'); if ($qty <= 0) { return $this->error('Quantity must be > 0.', Response::HTTP_UNPROCESSABLE_ENTITY); } $delta = ($mode === 'out') ? -abs($qty) : (($mode === 'in') ? abs($qty) : $qty); $type = ($mode === 'out') ? 'out' : (($mode === 'in') ? 'in' : 'adjust'); if (!$this->recordMovement((int)$itemId, $delta, $type, $reason, $note)) { return $this->error('Failed to record movement', Response::HTTP_INTERNAL_SERVER_ERROR); } return $this->success(['type' => $item['type']], 'Stock updated.'); } /** * GET /api/v1/inventory/books/distribute * Get teacher book distribution form data */ public function teacherDistributeForm() { $ctx = $this->buildTeacherClassContext(); if (is_array($ctx) && isset($ctx['error'])) { return $this->error($ctx['error'], $ctx['code'] ?? Response::HTTP_UNAUTHORIZED); } $lockClassSection = is_array($ctx['classSectionIds'] ?? null) && count($ctx['classSectionIds']) === 1; $classSectionId = $lockClassSection ? (int) ($ctx['defaultClassSectionId'] ?? 0) : (int) ($this->request->getGet('class_section_id') ?? ($ctx['defaultClassSectionId'] ?? 0)); $classID = null; if (!empty($classSectionId)) { $classID = $this->classSection->getClassId($classSectionId); if ($classID !== null) $classID = (int) $classID; } $itemId = (int) ($this->request->getGet('item_id') ?? 0); $db = DB::connection(); $builder = $db->table('inventory_items i') ->select('i.id,i.name,i.isbn,i.edition,i.quantity,i.category_id,i.type, c.name AS category_name, c.grade_min, c.grade_max') ->leftJoin('inventory_categories c', 'c.id', '=', 'i.category_id') ->where('i.type', 'book'); if (!empty($classID)) { $builder->where(function($query) use ($classID) { $query->where(function($q) use ($classID) { $q->whereNotNull('c.grade_min') ->whereNotNull('c.grade_max') ->where('c.grade_min', '<=', (int)$classID) ->where('c.grade_max', '>=', (int)$classID); }) ->orWhere(function($q) use ($classID) { $q->whereNotNull('c.grade_min') ->whereNull('c.grade_max') ->where('c.grade_min', (int)$classID); }) ->orWhere(function($q) use ($classID) { $q->whereNotNull('c.grade_max') ->whereNull('c.grade_min') ->where('c.grade_max', (int)$classID); }); }); } $rows = $builder->orderBy('i.name', 'ASC')->get()->toArray(); $labelFor = static function (?array $r): string { $name = $r['category_name'] ?? 'Uncategorized'; $gmin = $r['grade_min'] ?? null; $gmax = $r['grade_max'] ?? null; if ($gmin === null && $gmax === null) return $name; if ($gmin !== null && $gmax !== null) return $name . ' (G' . (int)$gmin . '–G' . (int)$gmax . ')'; if ($gmin !== null) return $name . ' (G' . (int)$gmin . '+)'; return $name . ' (≤G' . (int)$gmax . ')'; }; $booksGrouped = []; foreach ($rows as $r) { $r = (array)$r; $catId = (int)($r['category_id'] ?? 0); if (!isset($booksGrouped[$catId])) { $booksGrouped[$catId] = [ 'label' => $labelFor($r), 'items' => [], ]; } $display = $r['name']; if (!empty($r['isbn'])) $display .= ' — ISBN ' . $r['isbn']; if (!empty($r['edition'])) $display .= ' (' . $r['edition'] . ')'; $booksGrouped[$catId]['items'][] = [ 'id' => (int)$r['id'], 'display' => $display, 'quantity' => (int)($r['quantity'] ?? 0), ]; } uasort($booksGrouped, fn($a, $b) => strnatcasecmp($a['label'], $b['label'])); foreach ($booksGrouped as &$grp) { usort($grp['items'], fn($x, $y) => strnatcasecmp($x['display'], $y['display'])); } unset($grp); $students = $ctx['studentsData'][$classSectionId] ?? []; $already = []; if ($itemId && $classSectionId) { $rowsHist = $this->mov ->select('student_id, SUM(CASE WHEN qty_change < 0 THEN -qty_change ELSE 0 END) AS qty') ->where([ 'item_id' => $itemId, 'movement_type' => 'distribution', 'class_section_id' => $classSectionId, 'school_year' => $ctx['schoolYear'], ]) ->groupBy('student_id') ->findAll(); foreach ($rowsHist as $r) { $sid = (int) ($r['student_id'] ?? 0); if ($sid) $already[$sid] = (int) ($r['qty'] ?? 0); } } $onHand = 0; if ($itemId) { $book = $this->item->find($itemId); if ($book && ($book['type'] ?? '') === 'book') { $onHand = (int) ($book['quantity'] ?? 0); } } return $this->success([ 'booksGrouped' => $booksGrouped, 'items' => $rows, 'classes' => $ctx['classes'], 'class_section_id' => $classSectionId, 'lockClassSection' => $lockClassSection, 'item_id' => $itemId, 'students' => $students, 'already' => $already, 'onHand' => $onHand, 'schoolYear' => $ctx['schoolYear'], 'semester' => $ctx['semester'], ], 'Distribution form data retrieved successfully'); } /** * POST /api/v1/inventory/books/distribute * Distribute books to students */ public function teacherDistributeStore() { $ctx = $this->buildTeacherClassContext(); if (is_array($ctx) && isset($ctx['error'])) { return $this->error($ctx['error'], $ctx['code'] ?? Response::HTTP_UNAUTHORIZED); } $itemId = (int)$this->request->getPost('item_id'); $postedClassId = (int)$this->request->getPost('class_section_id'); $selectedIds = (array)$this->request->getPost('student_ids'); $note = (string)$this->request->getPost('note'); $classIds = $ctx['classSectionIds']; $classSectionId = (count($classIds) === 1) ? (int)$classIds[0] : (in_array($postedClassId, $classIds, true) ? $postedClassId : 0); if (!$classSectionId) { return $this->error('Invalid class selection.', Response::HTTP_UNPROCESSABLE_ENTITY); } $book = $this->item->find($itemId); if (!$book || $book['type'] !== 'book') { return $this->error('Invalid book.', Response::HTTP_NOT_FOUND); } $students = $ctx['studentsData'][$classSectionId] ?? []; $validIds = []; foreach ($students as $st) { $sid = $st['student_id'] ?? ($st['id'] ?? ($st['user_id'] ?? null)); if ($sid) $validIds[(int)$sid] = true; } $selectedIds = array_values(array_unique(array_map('intval', $selectedIds))); $selectedIds = array_values(array_filter($selectedIds, fn($sid) => isset($validIds[$sid]))); $already = []; $rows = $this->mov ->select('student_id, SUM(CASE WHEN qty_change < 0 THEN -qty_change ELSE 0 END) AS qty') ->where([ 'item_id' => $itemId, 'movement_type' => 'distribution', 'class_section_id' => $classSectionId, 'school_year' => $ctx['schoolYear'], ])->groupBy('student_id')->findAll(); foreach ($rows as $r) { $sid = (int)($r['student_id'] ?? 0); if ($sid) $already[$sid] = (int)$r['qty']; } $toGive = []; foreach ($selectedIds as $sid) { if (($already[$sid] ?? 0) < 1) $toGive[] = $sid; } $needed = count($toGive); $onHand = (int)$book['quantity']; if ($needed > $onHand) { return $this->error('Not enough stock: need ' . $needed . ', on hand ' . $onHand . '.', Response::HTTP_UNPROCESSABLE_ENTITY); } $okCount = 0; foreach ($toGive as $sid) { $ok = $this->recordMovement($itemId, -1, 'distribution', 'Teacher distribution', $note, $classSectionId, $sid); if ($ok) $okCount++; } if ($okCount === 0) { return $this->error('No changes (everyone selected already has this book).', Response::HTTP_UNPROCESSABLE_ENTITY); } return $this->success(['distributed_count' => $okCount], 'Distributed to ' . $okCount . ' student(s).'); } /** * GET /api/v1/inventory/movements * List inventory movements */ public function movementsIndex() { $selectedYear = (string)($this->request->getGet('school_year') ?? ''); $selectedSem = (string)($this->request->getGet('semester') ?? ''); $builder = DB::table('inventory_movements m') ->select([ 'm.*', 'ii.name AS item_name', DB::raw("CONCAT(pb.firstname, ' ', pb.lastname) AS performed_by_name"), DB::raw("CONCAT(t.firstname, ' ', t.lastname) AS teacher_name"), DB::raw("CONCAT(s.firstname, ' ', s.lastname) AS student_name"), 'cs.class_section_name', ]) ->leftJoin('inventory_items ii', 'ii.id', '=', 'm.item_id') ->leftJoin('users pb', 'pb.id', '=', 'm.performed_by') ->leftJoin('users t', 't.id', '=', 'm.teacher_id') ->leftJoin('students s', 's.id', '=', 'm.student_id') ->leftJoin('classSection cs', 'cs.class_section_id', '=', 'm.class_section_id') ->orderBy('m.id', 'DESC'); if ($selectedYear !== '') $builder->where('m.school_year', $selectedYear); if ($selectedSem !== '') $builder->where('m.semester', $selectedSem); $movements = $builder->get()->toArray(); return $this->success([ 'movements' => $movements, 'schoolYear' => $selectedYear, 'semester' => $selectedSem, ], 'Movements retrieved successfully'); } /** * GET /api/v1/inventory/movements/create * Get form data for creating a movement */ public function movementsCreate() { return $this->success([ 'movement' => $this->blankMovement(), 'movementTypes' => $this->movementTypes, 'semesters' => $this->semesters, 'items' => $this->getItems(), 'classSections' => $this->getClassSections(), 'teachers' => $this->getTeachers(), 'students' => $this->getStudents(), ], 'Form data retrieved successfully'); } /** * POST /api/v1/inventory/movements * Create a movement */ public function movementsStore() { $rules = [ 'item_id' => 'required|integer', 'qty_change' => 'required|integer', 'movement_type' => 'required|in_list[' . implode(',', $this->movementTypes) . ']', 'reason' => 'permit_empty|max_length[120]', 'note' => 'permit_empty|string', 'semester' => 'permit_empty|in_list[' . implode(',', $this->semesters) . ']', 'school_year' => 'permit_empty|max_length[16]', 'teacher_id' => 'permit_empty|integer', 'student_id' => 'permit_empty|integer', 'class_section_id' => 'permit_empty|integer', ]; $payload = $this->payloadData(); $errors = $this->validateRequest($payload, $rules); if (!empty($errors)) { return $this->respondValidationError($errors); } $userId = (int) ($this->getCurrentUserId() ?? 0); $data = [ 'item_id' => (int) $payload['item_id'], 'qty_change' => (int) $payload['qty_change'], 'movement_type' => $payload['movement_type'], 'reason' => trim((string)($payload['reason'] ?? '')), 'note' => trim((string)($payload['note'] ?? '')), 'semester' => $payload['semester'] ?? null, 'school_year' => $payload['school_year'] ?? null, 'performed_by' => $userId ?: null, 'teacher_id' => $this->nullableInt($payload['teacher_id'] ?? null), 'student_id' => $this->nullableInt($payload['student_id'] ?? null), 'class_section_id' => $this->nullableInt($payload['class_section_id'] ?? null), 'created_at' => utc_now(), 'updated_at' => utc_now(), ]; try { $ok = DB::table('inventory_movements')->insert($data); if (!$ok) { return $this->error('Could not save movement.', Response::HTTP_INTERNAL_SERVER_ERROR); } return $this->respondCreated($data, 'Movement created.'); } catch (\Throwable $e) { log_message('error', 'Movement creation error: ' . $e->getMessage()); return $this->error('Could not save movement: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * GET /api/v1/inventory/movements/{id} * Get a single movement */ public function movementsEdit($id) { $movement = DB::table('inventory_movements')->where('id', $id)->first(); if (!$movement) { return $this->error('Movement not found.', Response::HTTP_NOT_FOUND); } $movement = $this->hydrateNames((array)$movement); return $this->success([ 'movement' => $movement, 'movementTypes' => $this->movementTypes, 'semesters' => $this->semesters, 'items' => $this->getItems(), 'classSections' => $this->getClassSections(), 'teachers' => $this->getTeachers(), 'students' => $this->getStudents(), ], 'Movement retrieved successfully'); } /** * PATCH /api/v1/inventory/movements/{id} * Update a movement */ public function movementsUpdate($id) { $rules = [ 'qty_change' => 'required|integer', 'movement_type' => 'required|in_list[' . implode(',', $this->movementTypes) . ']', 'reason' => 'permit_empty|max_length[120]', 'note' => 'permit_empty|string', 'semester' => 'permit_empty|in_list[' . implode(',', $this->semesters) . ']', 'school_year' => 'permit_empty|max_length[16]', 'teacher_id' => 'permit_empty|integer', 'student_id' => 'permit_empty|integer', 'class_section_id' => 'permit_empty|integer', ]; $payload = $this->payloadData(); $errors = $this->validateRequest($payload, $rules); if (!empty($errors)) { return $this->respondValidationError($errors); } $existing = DB::table('inventory_movements')->where('id', $id)->first(); if (!$existing) { return $this->error('Movement not found.', Response::HTTP_NOT_FOUND); } $data = [ 'qty_change' => (int) $payload['qty_change'], 'movement_type' => $payload['movement_type'], 'reason' => trim((string)($payload['reason'] ?? '')), 'note' => trim((string)($payload['note'] ?? '')), 'semester' => $payload['semester'] ?? null, 'school_year' => $payload['school_year'] ?? null, 'teacher_id' => $this->nullableInt($payload['teacher_id'] ?? null), 'student_id' => $this->nullableInt($payload['student_id'] ?? null), 'class_section_id' => $this->nullableInt($payload['class_section_id'] ?? null), 'updated_at' => utc_now(), ]; try { $ok = DB::table('inventory_movements')->where('id', $id)->update($data); if (!$ok) { return $this->error('Could not update movement.', Response::HTTP_INTERNAL_SERVER_ERROR); } $updated = DB::table('inventory_movements')->where('id', $id)->first(); return $this->success($updated, 'Movement updated.'); } catch (\Throwable $e) { log_message('error', 'Movement update error: ' . $e->getMessage()); return $this->error('Could not update movement: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * DELETE /api/v1/inventory/movements/{id} * Delete a movement */ public function movementsDelete($id) { log_message('info', 'Inventory: delete one attempt', ['id' => $id, 'user' => (int)($this->getCurrentUserId() ?? 0)]); try { $ok = DB::table('inventory_movements')->where('id', $id)->delete(); log_message('info', 'Inventory: delete one result', ['id' => $id, 'ok' => (bool)$ok]); if (!$ok) { return $this->error('Could not delete movement.', Response::HTTP_INTERNAL_SERVER_ERROR); } return $this->success(null, 'Movement deleted.'); } catch (\Throwable $e) { log_message('error', 'Movement deletion error: ' . $e->getMessage()); return $this->error('Could not delete movement: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * POST /api/v1/inventory/movements/bulk-delete * Bulk delete movements */ public function movementsBulkDelete() { $ids = (array) $this->request->getPost('ids'); $ids = array_values(array_filter(array_map('intval', $ids), fn($v) => $v > 0)); if (empty($ids)) { log_message('warning', 'Inventory: bulk delete called with empty ids', ['user' => (int)($this->getCurrentUserId() ?? 0)]); return $this->error('No movements selected.', Response::HTTP_BAD_REQUEST); } log_message('info', 'Inventory: bulk delete attempt', ['count' => count($ids), 'ids' => $ids, 'user' => (int)($this->getCurrentUserId() ?? 0)]); try { $ok = DB::table('inventory_movements')->whereIn('id', $ids)->delete(); log_message('info', 'Inventory: bulk delete result', ['ok' => (bool)$ok]); if (!$ok) { return $this->error('Bulk delete failed.', Response::HTTP_INTERNAL_SERVER_ERROR); } return $this->success(['deleted_count' => count($ids)], count($ids) . ' movement(s) deleted.'); } catch (\Throwable $e) { log_message('error', 'Bulk delete error: ' . $e->getMessage()); return $this->error('Bulk delete failed: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); } } // ========== Private Helper Methods ========== private function normalizeType(?string $type): string { $type = strtolower((string)$type); return in_array($type, $this->types, true) ? $type : 'classroom'; } private function filterItemData(array $data, ?string $lockedType = null): array { $type = $lockedType ?? $this->normalizeType($data['type'] ?? 'classroom'); $qtyRaw = $data['quantity'] ?? null; $quantity = is_numeric($qtyRaw) ? (int)$qtyRaw : 0; $base = [ 'type' => $type, 'category_id' => (isset($data['category_id']) && $data['category_id'] !== null && $data['category_id'] !== '') ? (int)$data['category_id'] : null, 'name' => (string) ($data['name'] ?? ''), 'description' => $data['description'] ?? null, 'quantity' => $quantity, 'unit' => $data['unit'] ?? null, 'sku' => $data['sku'] ?? null, 'notes' => $data['notes'] ?? null, 'semester' => $this->semester, 'school_year' => $this->schoolYear, 'updated_by' => $this->getCurrentUserId(), ]; $base['condition'] = ($type === 'classroom') ? ($data['condition'] ?? null) : null; if ($type === 'book') { $base['isbn'] = $data['isbn'] ?? null; $base['edition'] = $data['edition'] ?? null; } else { $base['isbn'] = $base['edition'] = null; } return $base; } private function ensureInitialMovement(int $itemId): void { $hasMov = $this->mov->where('item_id', $itemId)->limit(1)->countAllResults(); if ($hasMov > 0) return; $item = $this->item->select('quantity')->find($itemId); $initialQty = (int)($item['quantity'] ?? 0); $this->mov->insert([ 'item_id' => $itemId, 'qty_change' => $initialQty, 'movement_type' => 'initial', 'semester' => $this->semester, 'school_year' => $this->schoolYear, 'performed_by' => $this->getCurrentUserId(), ], false); } private function recordMovement( int $itemId, int $qtyChange, string $type, ?string $reason = null, ?string $note = null, ?int $classSectionId = null, ?int $studentId = null ): bool { $me = $this->getCurrentUserId(); $isInitial = ($type === 'initial'); if (!$isInitial) { $this->ensureInitialMovement($itemId); $this->ensureInitialMovementForYear($itemId, $this->schoolYear); $this->recalcQuantity($itemId); } if (in_array($type, ['out', 'distribution'], true) && $qtyChange > 0) { $qtyChange = -abs($qtyChange); } if (in_array($type, ['out', 'distribution'], true)) { $onHand = (int) ($this->item->select('quantity')->where('id', $itemId)->first()['quantity'] ?? 0); if ($onHand + $qtyChange < 0) { return false; } } $ok = $this->mov->insert([ 'item_id' => $itemId, 'qty_change' => $qtyChange, 'movement_type' => $type, 'reason' => $reason, 'note' => $note, 'semester' => $this->semester, 'school_year' => $this->schoolYear, 'performed_by' => $me, 'teacher_id' => $me, 'student_id' => $studentId, 'class_section_id' => $classSectionId, ], false); if ($ok) { $this->recalcQuantity($itemId); return true; } return false; } private function recalcQuantity(int $itemId): void { $sumRow = $this->mov ->selectSum('qty_change') ->where('item_id', $itemId) ->first(); $sum = (int)($sumRow['qty_change'] ?? 0); if ($sum < 0) $sum = 0; $item = $this->item ->select('id, type, quantity, needs_repair_qty, good_qty') ->find($itemId); if (!$item) return; $updates = []; if ((int)$item['quantity'] !== $sum) { $updates['quantity'] = $sum; } if (($item['type'] ?? null) === 'classroom') { $needsRepair = (int)($item['needs_repair_qty'] ?? 0); $goodCalc = max(0, $sum - $needsRepair); if (!array_key_exists('good_qty', $item) || (int)$item['good_qty'] !== $goodCalc) { $updates['good_qty'] = $goodCalc; } } if (!empty($updates)) { try { $this->item->update($itemId, $updates); } catch (\Throwable $e) { log_message('error', 'recalcQuantity() skipped update: ' . $e->getMessage()); } } } private function userNamesByIds(array $ids): array { $ids = array_values(array_unique(array_filter($ids, fn($v) => !empty($v)))); if (!$ids) return []; if (!DB::getSchemaBuilder()->hasTable('users')) return []; $cols = DB::getSchemaBuilder()->getColumnListing('users'); $hasFirst = in_array('first_name', $cols, true) || in_array('firstname', $cols, true); $hasLast = in_array('last_name', $cols, true) || in_array('lastname', $cols, true); $firstCol = in_array('first_name', $cols, true) ? 'first_name' : (in_array('firstname', $cols, true) ? 'firstname' : null); $lastCol = in_array('last_name', $cols, true) ? 'last_name' : (in_array('lastname', $cols, true) ? 'lastname' : null); $nameCol = in_array('name', $cols, true) ? 'name' : null; $emailCol = in_array('email', $cols, true) ? 'email' : null; if ($firstCol || $lastCol) { $select = "id, TRIM(CONCAT(IFNULL($firstCol,''),' ',IFNULL($lastCol,''))) AS display_name"; } elseif ($nameCol) { $select = "id, $nameCol AS display_name"; } elseif ($emailCol) { $select = "id, $emailCol AS display_name"; } else { $out = []; foreach ($ids as $id) $out[(int)$id] = 'User #' . $id; return $out; } $rows = DB::table('users')->select(DB::raw($select))->whereIn('id', $ids)->get()->toArray(); $map = []; foreach ($rows as $r) { $r = (array)$r; $disp = trim((string)($r['display_name'] ?? '')); $map[(int)$r['id']] = $disp !== '' ? $disp : ('User #' . $r['id']); } return $map; } private function getSchoolYearsForType(string $type): array { $years = $this->item ->select('school_year') ->where('type', $type) ->whereRaw('school_year IS NOT NULL') ->groupBy('school_year') ->orderBy('school_year', 'DESC') ->pluck('school_year') ->toArray(); $years = array_values(array_unique(array_filter(array_map('trim', $years)))); $currentYear = $this->schoolYear; if ($currentYear && !in_array($currentYear, $years, true)) { array_unshift($years, $currentYear); $years = array_values(array_unique($years)); } return $years; } private function ensureInitialMovementForYear(int $itemId, string $schoolYear): void { $hasAnyThisYear = $this->mov ->where('item_id', $itemId) ->where('school_year', $schoolYear) ->limit(1)->countAllResults(); if ($hasAnyThisYear === 0) { $onHand = (int) ($this->item->select('quantity')->where('id', $itemId)->first()['quantity'] ?? 0); $this->mov->insert([ 'item_id' => $itemId, 'qty_change' => $onHand, 'movement_type' => 'initial', 'school_year' => $schoolYear, 'semester' => $this->semester ?? null, 'performed_by' => $this->getCurrentUserId(), ], false); return; } $hasInitialThisYear = $this->mov ->where('item_id', $itemId) ->where('school_year', $schoolYear) ->where('movement_type', 'initial') ->limit(1)->countAllResults(); if ($hasInitialThisYear === 0) { $onHand = (int) ($this->item->select('quantity')->where('id', $itemId)->first()['quantity'] ?? 0); $netYear = (int) ($this->mov->selectSum('qty_change') ->where('item_id', $itemId) ->where('school_year', $schoolYear) ->first()['qty_change'] ?? 0); $opening = $onHand - $netYear; $this->mov->insert([ 'item_id' => $itemId, 'qty_change' => $opening, 'movement_type' => 'initial', 'school_year' => $schoolYear, 'semester' => $this->semester ?? null, 'performed_by' => $this->getCurrentUserId(), ], false); } } private function buildTeacherClassContext() { $user_id = $this->getCurrentUserId(); if (!$user_id) { return ['error' => 'Please log in first', 'code' => Response::HTTP_UNAUTHORIZED]; } $classes = $this->teacherClass->getClassAssignmentsByUserId($user_id, $this->schoolYear); if (empty($classes)) { return ['error' => 'You do not have an assigned class yet. Please contact the administration.', 'code' => Response::HTTP_FORBIDDEN]; } $user = $this->user->find($user_id); if (!$user) { return ['error' => 'User not found', 'code' => Response::HTTP_NOT_FOUND]; } $roles = DB::table('user_roles ur') ->select('r.name') ->join('roles r', 'r.id', '=', 'ur.role_id') ->where('ur.user_id', $user_id) ->get()->toArray(); $roleNames = array_map(fn($r) => $r->name, $roles); if (in_array('teacher', $roleNames, true)) { $user['role_name'] = 'teacher'; } elseif (in_array('teacher_assistant', $roleNames, true)) { $user['role_name'] = 'teacher_assistant'; } else { return ['error' => 'Access denied', 'code' => Response::HTTP_FORBIDDEN]; } $classSectionIds = array_column($classes, 'class_section_id'); $studentsData = []; if (!empty($classSectionIds)) { $students = $this->studentClass->getStudentsByClassSectionIds($classSectionIds); foreach ($students as $student) { $studentsData[$student['class_section_id']][] = $student; } } $taAssignments = $this->teacherClass ->whereIn('class_section_id', $classSectionIds) ->where('school_year', $this->schoolYear) ->where('position', 'ta') ->findAll(); $taNames = []; foreach ($taAssignments as $ta) { $taUser = $this->user->find($ta['teacher_id']); if ($taUser) { $csid = $ta['class_section_id']; $taNames[$csid][] = ($taUser['firstname'] ?? '') . ' ' . ($taUser['lastname'] ?? ''); } } $defaultClassSectionId = $classSectionIds[0] ?? null; return [ 'user' => $user, 'roleNames' => $roleNames, 'classes' => $classes, 'classSectionIds' => $classSectionIds, 'studentsData' => $studentsData, 'taNames' => $taNames, 'defaultClassSectionId' => $defaultClassSectionId, 'schoolYear' => $this->schoolYear, 'semester' => $this->semester, 'user_id' => $user_id, ]; } private function blankMovement(): array { return [ 'id' => null, 'item_id' => null, 'qty_change' => 0, 'movement_type' => 'adjust', 'reason' => '', 'note' => '', 'semester' => null, 'school_year' => null, 'performed_by' => $this->getCurrentUserId(), 'teacher_id' => null, 'student_id' => null, 'class_section_id' => null, 'created_at' => null, 'updated_at' => null, ]; } private function nullableInt($val): ?int { if ($val === null || $val === '' || $val === '0') { return null; } return (int) $val; } private function hydrateNames(array $m): array { if (!empty($m['item_id'])) { $row = DB::table('inventory_items')->select('name')->where('id', $m['item_id'])->first(); $m['item_name'] = $row->name ?? null; } if (!empty($m['performed_by'])) { $row = DB::table('users') ->select(DB::raw("CONCAT(firstname, ' ', lastname) AS fullname")) ->where('id', $m['performed_by']) ->first(); $m['performed_by_name'] = $row->fullname ?? null; } if (!empty($m['teacher_id'])) { $row = DB::table('users') ->select(DB::raw("CONCAT(firstname, ' ', lastname) AS fullname")) ->where('id', $m['teacher_id']) ->first(); $m['teacher_name'] = $row->fullname ?? null; } if (!empty($m['student_id'])) { $row = DB::table('students') ->select(DB::raw("CONCAT(firstname, ' ', lastname) AS fullname")) ->where('id', $m['student_id']) ->first(); $m['student_name'] = $row->fullname ?? null; } if (!empty($m['class_section_id'])) { $row = DB::table('classSection') ->select('class_section_name') ->where('class_section_id', $m['class_section_id']) ->first(); $m['class_section_name'] = $row->class_section_name ?? null; } return $m; } private function getTeachers(): array { $rows = $this->teacher->getTeachersAndTAs(); return array_map(static function ($r) { $fullname = trim(($r['firstname'] ?? '') . ' ' . ($r['lastname'] ?? '')); return [ 'id' => (int) $r['id'], 'firstname' => $r['firstname'] ?? '', 'lastname' => $r['lastname'] ?? '', 'fullname' => $fullname, 'email' => $r['email'] ?? '', 'cellphone' => $r['cellphone'] ?? '', 'role' => $r['role'] ?? '', ]; }, $rows); } private function getItems(): array { return DB::table('inventory_items')->select('id', 'name')->orderBy('name', 'ASC')->get()->toArray(); } private function getClassSections(): array { return DB::table('classSection') ->select('class_section_id', 'class_section_name') ->orderBy('class_section_name', 'ASC')->get()->toArray(); } private function getStudents(): array { return DB::table('students') ->select(DB::raw("id, CONCAT(firstname, ' ', lastname) AS fullname")) ->orderBy('lastname', 'ASC') ->orderBy('firstname', 'ASC') ->get() ->toArray(); } }