normalizeType($filters['type'] ?? 'classroom'); $schoolYear = trim((string) ($filters['school_year'] ?? $this->context->schoolYear())); $semester = trim((string) ($filters['semester'] ?? $this->context->semester())); $q = trim((string) ($filters['q'] ?? '')); $builder = InventoryItem::query()->where('type', $type); if ($schoolYear !== '' && strtolower($schoolYear) !== 'all') { $builder->where('school_year', $schoolYear); } if ($semester !== '') { $builder->where('semester', $semester); } if ($q !== '') { $builder->where('name', 'like', '%'.$q.'%'); } $items = $builder->orderBy('name')->get()->toArray(); $categories = InventoryCategory::optionsForType($type); $userIds = array_values(array_unique(array_map(static fn ($i) => (int) ($i['updated_by'] ?? 0), $items))); $userNames = $this->userNamesByIds(array_values(array_filter($userIds))); return [ 'type' => $type, 'items' => $items, 'categories' => $categories, 'conditionOptions' => [ 'good' => 'Good condition', 'needs_repair' => 'Needs repair', 'need_replace' => 'Need replace', 'cannot_find' => 'Cannot find', ], 'schoolYears' => $this->getSchoolYearsForType($type), 'selectedYear' => $schoolYear, 'currentYear' => $this->context->schoolYear(), 'semester' => $semester, 'userNames' => $userNames, ]; } public function find(int $id): ?array { $item = InventoryItem::query()->find($id); return $item ? $item->toArray() : null; } public function create(array $data, ?int $userId = null): array { $payload = $this->filterItemData($data, null, $userId); try { return DB::transaction(function () use ($payload) { $item = InventoryItem::query()->create($payload); $initialQty = (int) ($payload['quantity'] ?? 0); if ($initialQty !== 0) { $this->movementService->recordMovement((int) $item->id, $initialQty, 'initial', 'Initial stock'); } else { $this->movementService->recalcQuantity((int) $item->id); } return ['ok' => true, 'item' => $item->toArray()]; }); } catch (\Throwable $e) { Log::error('Inventory item create failed: '.$e->getMessage()); return ['ok' => false, 'message' => 'Failed to create item.']; } } public function update(int $id, array $data, ?int $userId = null): array { $item = InventoryItem::query()->find($id); if (! $item) { return ['ok' => false, 'message' => 'Item not found.']; } $payload = $this->filterItemData($data, (string) $item->type, $userId); unset($payload['id']); try { $item->update($payload); } catch (\Throwable $e) { Log::error('Inventory item update failed: '.$e->getMessage()); return ['ok' => false, 'message' => 'Failed to update item.']; } return ['ok' => true, 'item' => $item->toArray()]; } public function delete(int $id): array { $item = InventoryItem::query()->find($id); if (! $item) { return ['ok' => false, 'message' => 'Item not found.']; } try { $item->delete(); } catch (\Throwable $e) { Log::error('Inventory item delete failed: '.$e->getMessage()); return ['ok' => false, 'message' => 'Failed to delete item.']; } return ['ok' => true]; } public function auditClassroom(int $itemId, array $data, ?int $userId = null): array { $item = InventoryItem::query()->find($itemId); if (! $item || $item->type !== 'classroom') { return ['ok' => false, 'message' => 'Invalid classroom item.']; } $prevLoss = (int) ($item->need_replace_qty ?? 0); $prevMiss = (int) ($item->cannot_find_qty ?? 0); $newRepair = max(0, (int) ($data['needs_repair_qty'] ?? 0)); $newLoss = max(0, (int) ($data['need_replace_qty'] ?? 0)); $newMiss = max(0, (int) ($data['cannot_find_qty'] ?? 0)); $onHandNow = (int) ($item->quantity ?? 0); $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 && ! $this->movementService->recordMovement($itemId, -$totalOut, 'out', 'Audit: loss/missing increased')) { return ['ok' => false, 'message' => 'Not enough stock to apply audit.']; } if ($totalIn > 0) { $this->movementService->recordMovement($itemId, $totalIn, 'in', 'Audit: loss/missing decreased'); } $this->movementService->recalcQuantity($itemId); $item->update([ 'good_qty' => $newGood, 'needs_repair_qty' => $newRepair, 'need_replace_qty' => $newLoss, 'cannot_find_qty' => $newMiss, 'updated_by' => $userId, ]); return ['ok' => true, 'item' => $item->toArray()]; } public function adjustStock(int $itemId, array $data, ?int $userId = null): array { $item = InventoryItem::query()->find($itemId); if (! $item) { return ['ok' => false, 'message' => 'Item not found.']; } $mode = (string) ($data['mode'] ?? ''); $qty = (int) ($data['quantity'] ?? 0); $reason = (string) ($data['reason'] ?? ''); $note = (string) ($data['note'] ?? ''); if ($qty <= 0) { return ['ok' => false, 'message' => 'Quantity must be > 0.']; } $delta = ($mode === 'out') ? -abs($qty) : (($mode === 'in') ? abs($qty) : $qty); $type = ($mode === 'out') ? 'out' : (($mode === 'in') ? 'in' : 'adjust'); $ok = $this->movementService->recordMovement($itemId, $delta, $type, $reason, $note, null, null, $userId); if (! $ok) { return ['ok' => false, 'message' => 'Not enough stock to apply adjustment.']; } return ['ok' => true]; } private function filterItemData(array $data, ?string $lockedType = null, ?int $userId = null): array { $type = $lockedType ?? $this->normalizeType($data['type'] ?? 'classroom'); $qtyRaw = $data['quantity'] ?? 0; $quantity = is_numeric($qtyRaw) ? (int) $qtyRaw : 0; $base = [ 'type' => $type, 'category_id' => isset($data['category_id']) && $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->context->semester(), 'school_year' => $this->context->schoolYear(), 'updated_by' => $userId, ]; $base['condition'] = ($type === 'classroom') ? ($data['condition'] ?? null) : null; if ($type === 'book') { $base['isbn'] = $data['isbn'] ?? null; $base['edition'] = $data['edition'] ?? null; } else { $base['isbn'] = null; $base['edition'] = null; } return $base; } private function normalizeType(?string $type): string { $type = strtolower((string) $type); return in_array($type, $this->types, true) ? $type : 'classroom'; } private function userNamesByIds(array $ids): array { $ids = array_values(array_unique(array_filter($ids))); if (empty($ids)) { return []; } $cols = DB::getSchemaBuilder()->getColumnListing('users'); $hasFirst = in_array('firstname', $cols, true); $hasLast = in_array('lastname', $cols, true); $nameCol = in_array('name', $cols, true) ? 'name' : null; $emailCol = in_array('email', $cols, true) ? 'email' : null; if ($hasFirst || $hasLast) { $select = DB::raw("id, TRIM(CONCAT(IFNULL(firstname,''),' ',IFNULL(lastname,''))) AS display_name"); } elseif ($nameCol) { $select = DB::raw("id, {$nameCol} AS display_name"); } elseif ($emailCol) { $select = DB::raw("id, {$emailCol} AS display_name"); } else { $out = []; foreach ($ids as $id) { $out[$id] = 'User #'.$id; } return $out; } $rows = DB::table('users')->select($select)->whereIn('id', $ids)->get(); $map = []; foreach ($rows as $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 = InventoryItem::query() ->select('school_year') ->where('type', $type) ->whereNotNull('school_year') ->groupBy('school_year') ->orderByDesc('school_year') ->pluck('school_year') ->map(fn ($v) => trim((string) $v)) ->filter() ->unique() ->values() ->all(); $currentYear = $this->context->schoolYear(); if ($currentYear && ! in_array($currentYear, $years, true)) { array_unshift($years, $currentYear); } return $years; } }