add inventory and supply logic

This commit is contained in:
root
2026-03-10 18:21:08 -04:00
parent 17d73e2f92
commit 25e7b3c67c
62 changed files with 3846 additions and 2781 deletions
@@ -0,0 +1,298 @@
<?php
namespace App\Services\Inventory;
use App\Models\ClassSection;
use App\Models\InventoryItem;
use App\Models\InventoryMovement;
use App\Models\StudentClass;
use App\Models\TeacherClass;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class InventoryTeacherDistributionService
{
public function __construct(
private InventoryContextService $context,
private InventoryMovementService $movementService
) {
}
public function formData(int $userId, ?int $classSectionId, ?int $itemId): array
{
$ctx = $this->buildTeacherClassContext($userId);
if (!$ctx['ok']) {
return $ctx;
}
$classIds = $ctx['classSectionIds'];
$lockClassSection = count($classIds) === 1;
$selectedClassId = $lockClassSection
? (int) ($ctx['defaultClassSectionId'] ?? 0)
: (int) ($classSectionId ?? ($ctx['defaultClassSectionId'] ?? 0));
$classID = null;
if (!empty($selectedClassId)) {
$classID = ClassSection::getClassId($selectedClassId);
$classID = $classID !== null ? (int) $classID : null;
}
$builder = DB::table('inventory_items as 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 as c', 'c.id', '=', 'i.category_id')
->where('i.type', 'book');
if (!empty($classID)) {
$builder->where(function ($q) use ($classID) {
$q->where(function ($inner) use ($classID) {
$inner->whereNotNull('c.grade_min')
->whereNotNull('c.grade_max')
->where('c.grade_min', '<=', $classID)
->where('c.grade_max', '>=', $classID);
})->orWhere(function ($inner) use ($classID) {
$inner->whereNotNull('c.grade_min')
->whereNull('c.grade_max')
->where('c.grade_min', $classID);
})->orWhere(function ($inner) use ($classID) {
$inner->whereNotNull('c.grade_max')
->whereNull('c.grade_min')
->where('c.grade_max', $classID);
});
});
}
$rows = $builder->orderBy('i.name')->get()->map(fn ($r) => (array) $r)->all();
$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) {
$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'][$selectedClassId] ?? [];
$already = [];
$itemId = (int) ($itemId ?? 0);
if ($itemId && $selectedClassId) {
$rowsHist = InventoryMovement::query()
->selectRaw('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' => $selectedClassId,
'school_year' => $ctx['schoolYear'],
])
->groupBy('student_id')
->get()
->map(fn ($r) => (array) $r)
->all();
foreach ($rowsHist as $r) {
$sid = (int) ($r['student_id'] ?? 0);
if ($sid) $already[$sid] = (int) ($r['qty'] ?? 0);
}
}
$onHand = 0;
if ($itemId) {
$book = InventoryItem::query()->find($itemId);
if ($book && $book->type === 'book') {
$onHand = (int) ($book->quantity ?? 0);
}
}
return [
'ok' => true,
'booksGrouped' => $booksGrouped,
'items' => $rows,
'classes' => $ctx['classes'],
'class_section_id' => $selectedClassId,
'lockClassSection' => $lockClassSection,
'item_id' => $itemId,
'students' => $students,
'already' => $already,
'onHand' => $onHand,
'schoolYear' => $ctx['schoolYear'],
'semester' => $ctx['semester'],
];
}
public function distribute(int $userId, array $payload): array
{
$ctx = $this->buildTeacherClassContext($userId);
if (!$ctx['ok']) {
return $ctx;
}
$itemId = (int) ($payload['item_id'] ?? 0);
$postedClassId = (int) ($payload['class_section_id'] ?? 0);
$selectedIds = array_values(array_unique(array_map('intval', $payload['student_ids'] ?? [])));
$note = (string) ($payload['note'] ?? '');
$classIds = $ctx['classSectionIds'];
$classSectionId = (count($classIds) === 1)
? (int) $classIds[0]
: (in_array($postedClassId, $classIds, true) ? $postedClassId : 0);
if (!$classSectionId) {
return ['ok' => false, 'message' => 'Invalid class selection.'];
}
$book = InventoryItem::query()->find($itemId);
if (!$book || $book->type !== 'book') {
return ['ok' => false, 'message' => 'Invalid book.'];
}
$students = $ctx['studentsData'][$classSectionId] ?? [];
$validIds = [];
foreach ($students as $st) {
$sid = $st['student_id'] ?? ($st['id'] ?? null);
if ($sid) $validIds[(int) $sid] = true;
}
$selectedIds = array_values(array_filter($selectedIds, fn ($sid) => isset($validIds[$sid])));
$already = [];
$rows = InventoryMovement::query()
->selectRaw('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')
->get()
->map(fn ($r) => (array) $r)
->all();
foreach ($rows as $r) {
$sid = (int) ($r['student_id'] ?? 0);
if ($sid) $already[$sid] = (int) ($r['qty'] ?? 0);
}
$toGive = [];
foreach ($selectedIds as $sid) {
if (($already[$sid] ?? 0) < 1) {
$toGive[] = $sid;
}
}
$needed = count($toGive);
$onHand = (int) ($book->quantity ?? 0);
if ($needed > $onHand) {
return ['ok' => false, 'message' => 'Not enough stock to complete distribution.'];
}
$okCount = 0;
foreach ($toGive as $sid) {
$ok = $this->movementService->recordMovement($itemId, -1, 'distribution', 'Teacher distribution', $note, $classSectionId, $sid, $userId);
if ($ok) {
$okCount++;
}
}
return [
'ok' => true,
'distributed' => $okCount,
'already_assigned' => count($selectedIds) - $okCount,
];
}
private function buildTeacherClassContext(int $userId): array
{
if ($userId <= 0) {
return ['ok' => false, 'message' => 'Please log in first.'];
}
$classes = TeacherClass::getClassAssignmentsByUserId($userId, $this->context->schoolYear());
if (empty($classes)) {
return ['ok' => false, 'message' => 'You do not have an assigned class yet.'];
}
$user = User::query()->find($userId);
if (!$user) {
return ['ok' => false, 'message' => 'User not found.'];
}
$roles = DB::table('user_roles as ur')
->join('roles as r', 'r.id', '=', 'ur.role_id')
->where('ur.user_id', $userId)
->pluck('r.name')
->toArray();
if (!in_array('teacher', $roles, true) && !in_array('teacher_assistant', $roles, true)) {
return ['ok' => false, 'message' => 'Access denied.'];
}
$classSectionIds = array_column($classes, 'class_section_id');
$studentsData = [];
if (!empty($classSectionIds)) {
$students = StudentClass::getStudentsByClassSectionIds($classSectionIds);
foreach ($students as $student) {
$studentsData[$student['class_section_id']][] = $student;
}
}
$taAssignments = TeacherClass::query()
->whereIn('class_section_id', $classSectionIds)
->where('school_year', $this->context->schoolYear())
->where('position', 'ta')
->get()
->toArray();
$taNames = [];
foreach ($taAssignments as $ta) {
$taUser = User::query()->find($ta['teacher_id'] ?? 0);
if ($taUser) {
$csid = $ta['class_section_id'];
$taNames[$csid][] = trim(($taUser->firstname ?? '') . ' ' . ($taUser->lastname ?? ''));
}
}
$defaultClassSectionId = $classSectionIds[0] ?? null;
return [
'ok' => true,
'classes' => $classes,
'classSectionIds' => $classSectionIds,
'studentsData' => $studentsData,
'taNames' => $taNames,
'defaultClassSectionId' => $defaultClassSectionId,
'schoolYear' => $this->context->schoolYear(),
'semester' => $this->context->semester(),
];
}
}