Files
alrahma_sunday_school_api/app/Http/Controllers/Api/InventoryController.php
T
2026-03-05 12:29:37 -05:00

1489 lines
55 KiB
PHP
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Http\Controllers\Api;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\InventoryCategory;
use App\Models\InventoryItem;
use App\Models\InventoryMovement;
use App\Models\StudentClass;
use App\Models\TeacherClass;
use App\Models\Teacher;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class InventoryController extends BaseApiController
{
protected InventoryItem $item;
protected InventoryCategory $cat;
protected InventoryMovement $mov;
protected Configuration $config;
protected TeacherClass $teacherClass;
protected StudentClass $studentClass;
protected User $user;
protected ClassSection $classSection;
protected Teacher $teacher;
protected string $schoolYear;
protected string $semester;
protected array $types = ['classroom', 'book', 'office', 'kitchen'];
protected array $movementTypes = ['initial', 'in', 'out', 'adjust', 'distribution'];
protected array $semesters = ['Spring', 'Fall'];
public function __construct()
{
parent::__construct();
$this->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();
}
}