1489 lines
55 KiB
PHP
Executable File
1489 lines
55 KiB
PHP
Executable File
<?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();
|
||
}
|
||
}
|