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,134 @@
<?php
namespace App\Services\Inventory;
use App\Models\InventoryCategory;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class InventoryCategoryService
{
private array $types = ['classroom', 'book', 'office', 'kitchen'];
public function list(?string $type = null): array
{
$query = InventoryCategory::query()->orderBy('name');
if ($type) {
$query->where('type', $this->normalizeType($type));
}
return $query->get()->toArray();
}
public function create(array $data): array
{
try {
$payload = $this->normalizePayload($data);
} catch (\InvalidArgumentException $e) {
return ['ok' => false, 'message' => $e->getMessage()];
}
$existing = InventoryCategory::query()
->where('type', $payload['type'])
->where('name', $payload['name'])
->first();
if ($existing) {
return ['ok' => false, 'message' => 'This category already exists.'];
}
try {
$category = InventoryCategory::query()->create($payload);
} catch (\Throwable $e) {
Log::error('Inventory category create failed: ' . $e->getMessage());
return ['ok' => false, 'message' => 'Failed to save category.'];
}
return ['ok' => true, 'category' => $category->toArray()];
}
public function update(int $id, array $data): array
{
$category = InventoryCategory::query()->find($id);
if (!$category) {
return ['ok' => false, 'message' => 'Category not found.'];
}
try {
$payload = $this->normalizePayload($data, (string) $category->type);
} catch (\InvalidArgumentException $e) {
return ['ok' => false, 'message' => $e->getMessage()];
}
$existing = InventoryCategory::query()
->where('type', $payload['type'])
->where('name', $payload['name'])
->where('id', '!=', $id)
->first();
if ($existing) {
return ['ok' => false, 'message' => 'A category with this Type & Name already exists.'];
}
try {
$category->update($payload);
} catch (\Throwable $e) {
Log::error('Inventory category update failed: ' . $e->getMessage());
return ['ok' => false, 'message' => 'Failed to save category.'];
}
return ['ok' => true, 'category' => $category->toArray()];
}
public function delete(int $id): array
{
$category = InventoryCategory::query()->find($id);
if (!$category) {
return ['ok' => false, 'message' => 'Category not found.'];
}
try {
$category->delete();
} catch (\Throwable $e) {
Log::error('Inventory category delete failed: ' . $e->getMessage());
return ['ok' => false, 'message' => 'Failed to delete category.'];
}
return ['ok' => true];
}
private function normalizePayload(array $data, ?string $lockedType = null): array
{
$type = $lockedType ?? $this->normalizeType($data['type'] ?? 'office');
$name = preg_replace('/\\s+/', ' ', trim((string) ($data['name'] ?? '')));
$payload = [
'type' => $type,
'name' => $name,
'description' => (string) ($data['description'] ?? ''),
'grade_min' => null,
'grade_max' => null,
];
if ($type === 'book') {
$gmin = $data['grade_min'] ?? null;
$gmax = $data['grade_max'] ?? null;
$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) {
throw new \InvalidArgumentException('Grade Min cannot be greater than Grade Max.');
}
if ($gmin !== null && $gmin > 13) {
$gmin = 13;
}
if ($gmax !== null && $gmax > 13) {
$gmax = 13;
}
$payload['grade_min'] = $gmin;
$payload['grade_max'] = $gmax;
}
return $payload;
}
private function normalizeType(?string $type): string
{
$type = strtolower((string) $type);
return in_array($type, $this->types, true) ? $type : 'office';
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Services\Inventory;
use App\Models\Configuration;
class InventoryContextService
{
public function schoolYear(): string
{
return trim((string) (Configuration::getConfig('school_year') ?? ''));
}
public function semester(): string
{
return trim((string) (Configuration::getConfig('semester') ?? ''));
}
}
@@ -0,0 +1,306 @@
<?php
namespace App\Services\Inventory;
use App\Models\InventoryItem;
use App\Models\InventoryCategory;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class InventoryItemService
{
private array $types = ['classroom', 'book', 'office', 'kitchen'];
public function __construct(
private InventoryContextService $context,
private InventoryMovementService $movementService
) {
}
public function list(array $filters = []): array
{
$type = $this->normalizeType($filters['type'] ?? 'classroom');
$schoolYear = trim((string) ($filters['school_year'] ?? $this->context->schoolYear()));
$semester = trim((string) ($filters['semester'] ?? $this->context->semester()));
$q = trim((string) ($filters['q'] ?? ''));
$builder = InventoryItem::query()->where('type', $type);
if ($schoolYear !== '' && strtolower($schoolYear) !== 'all') {
$builder->where('school_year', $schoolYear);
}
if ($semester !== '') {
$builder->where('semester', $semester);
}
if ($q !== '') {
$builder->where('name', 'like', '%' . $q . '%');
}
$items = $builder->orderBy('name')->get()->toArray();
$categories = InventoryCategory::optionsForType($type);
$userIds = array_values(array_unique(array_map(static fn ($i) => (int) ($i['updated_by'] ?? 0), $items)));
$userNames = $this->userNamesByIds(array_values(array_filter($userIds)));
return [
'type' => $type,
'items' => $items,
'categories' => $categories,
'conditionOptions' => [
'good' => 'Good condition',
'needs_repair' => 'Needs repair',
'need_replace' => 'Need replace',
'cannot_find' => 'Cannot find',
],
'schoolYears' => $this->getSchoolYearsForType($type),
'selectedYear' => $schoolYear,
'currentYear' => $this->context->schoolYear(),
'semester' => $semester,
'userNames' => $userNames,
];
}
public function find(int $id): ?array
{
$item = InventoryItem::query()->find($id);
return $item ? $item->toArray() : null;
}
public function create(array $data, ?int $userId = null): array
{
$payload = $this->filterItemData($data, null, $userId);
try {
return DB::transaction(function () use ($payload) {
$item = InventoryItem::query()->create($payload);
$initialQty = (int) ($payload['quantity'] ?? 0);
if ($initialQty !== 0) {
$this->movementService->recordMovement((int) $item->id, $initialQty, 'initial', 'Initial stock');
} else {
$this->movementService->recalcQuantity((int) $item->id);
}
return ['ok' => true, 'item' => $item->toArray()];
});
} catch (\Throwable $e) {
Log::error('Inventory item create failed: ' . $e->getMessage());
return ['ok' => false, 'message' => 'Failed to create item.'];
}
}
public function update(int $id, array $data, ?int $userId = null): array
{
$item = InventoryItem::query()->find($id);
if (!$item) {
return ['ok' => false, 'message' => 'Item not found.'];
}
$payload = $this->filterItemData($data, (string) $item->type, $userId);
unset($payload['id']);
try {
$item->update($payload);
} catch (\Throwable $e) {
Log::error('Inventory item update failed: ' . $e->getMessage());
return ['ok' => false, 'message' => 'Failed to update item.'];
}
return ['ok' => true, 'item' => $item->toArray()];
}
public function delete(int $id): array
{
$item = InventoryItem::query()->find($id);
if (!$item) {
return ['ok' => false, 'message' => 'Item not found.'];
}
try {
$item->delete();
} catch (\Throwable $e) {
Log::error('Inventory item delete failed: ' . $e->getMessage());
return ['ok' => false, 'message' => 'Failed to delete item.'];
}
return ['ok' => true];
}
public function auditClassroom(int $itemId, array $data, ?int $userId = null): array
{
$item = InventoryItem::query()->find($itemId);
if (!$item || $item->type !== 'classroom') {
return ['ok' => false, 'message' => 'Invalid classroom item.'];
}
$prevLoss = (int) ($item->need_replace_qty ?? 0);
$prevMiss = (int) ($item->cannot_find_qty ?? 0);
$newRepair = max(0, (int) ($data['needs_repair_qty'] ?? 0));
$newLoss = max(0, (int) ($data['need_replace_qty'] ?? 0));
$newMiss = max(0, (int) ($data['cannot_find_qty'] ?? 0));
$onHandNow = (int) ($item->quantity ?? 0);
$newGood = max(0, $onHandNow - $newRepair);
$deltaLoss = $newLoss - $prevLoss;
$deltaMiss = $newMiss - $prevMiss;
$totalOut = 0;
if ($deltaLoss > 0) {
$totalOut += $deltaLoss;
}
if ($deltaMiss > 0) {
$totalOut += $deltaMiss;
}
$totalIn = 0;
if ($deltaLoss < 0) {
$totalIn += -$deltaLoss;
}
if ($deltaMiss < 0) {
$totalIn += -$deltaMiss;
}
if ($totalOut > 0 && !$this->movementService->recordMovement($itemId, -$totalOut, 'out', 'Audit: loss/missing increased')) {
return ['ok' => false, 'message' => 'Not enough stock to apply audit.'];
}
if ($totalIn > 0) {
$this->movementService->recordMovement($itemId, $totalIn, 'in', 'Audit: loss/missing decreased');
}
$this->movementService->recalcQuantity($itemId);
$item->update([
'good_qty' => $newGood,
'needs_repair_qty' => $newRepair,
'need_replace_qty' => $newLoss,
'cannot_find_qty' => $newMiss,
'updated_by' => $userId,
]);
return ['ok' => true, 'item' => $item->toArray()];
}
public function adjustStock(int $itemId, array $data, ?int $userId = null): array
{
$item = InventoryItem::query()->find($itemId);
if (!$item) {
return ['ok' => false, 'message' => 'Item not found.'];
}
$mode = (string) ($data['mode'] ?? '');
$qty = (int) ($data['quantity'] ?? 0);
$reason = (string) ($data['reason'] ?? '');
$note = (string) ($data['note'] ?? '');
if ($qty <= 0) {
return ['ok' => false, 'message' => 'Quantity must be > 0.'];
}
$delta = ($mode === 'out') ? -abs($qty) : (($mode === 'in') ? abs($qty) : $qty);
$type = ($mode === 'out') ? 'out' : (($mode === 'in') ? 'in' : 'adjust');
$ok = $this->movementService->recordMovement($itemId, $delta, $type, $reason, $note, null, null, $userId);
if (!$ok) {
return ['ok' => false, 'message' => 'Not enough stock to apply adjustment.'];
}
return ['ok' => true];
}
private function filterItemData(array $data, ?string $lockedType = null, ?int $userId = null): array
{
$type = $lockedType ?? $this->normalizeType($data['type'] ?? 'classroom');
$qtyRaw = $data['quantity'] ?? 0;
$quantity = is_numeric($qtyRaw) ? (int) $qtyRaw : 0;
$base = [
'type' => $type,
'category_id' => isset($data['category_id']) && $data['category_id'] !== '' ? (int) $data['category_id'] : null,
'name' => (string) ($data['name'] ?? ''),
'description' => $data['description'] ?? null,
'quantity' => $quantity,
'unit' => $data['unit'] ?? null,
'sku' => $data['sku'] ?? null,
'notes' => $data['notes'] ?? null,
'semester' => $this->context->semester(),
'school_year' => $this->context->schoolYear(),
'updated_by' => $userId,
];
$base['condition'] = ($type === 'classroom') ? ($data['condition'] ?? null) : null;
if ($type === 'book') {
$base['isbn'] = $data['isbn'] ?? null;
$base['edition'] = $data['edition'] ?? null;
} else {
$base['isbn'] = null;
$base['edition'] = null;
}
return $base;
}
private function normalizeType(?string $type): string
{
$type = strtolower((string) $type);
return in_array($type, $this->types, true) ? $type : 'classroom';
}
private function userNamesByIds(array $ids): array
{
$ids = array_values(array_unique(array_filter($ids)));
if (empty($ids)) {
return [];
}
$cols = DB::getSchemaBuilder()->getColumnListing('users');
$hasFirst = in_array('firstname', $cols, true);
$hasLast = in_array('lastname', $cols, true);
$nameCol = in_array('name', $cols, true) ? 'name' : null;
$emailCol = in_array('email', $cols, true) ? 'email' : null;
if ($hasFirst || $hasLast) {
$select = DB::raw("id, TRIM(CONCAT(IFNULL(firstname,''),' ',IFNULL(lastname,''))) AS display_name");
} elseif ($nameCol) {
$select = DB::raw("id, {$nameCol} AS display_name");
} elseif ($emailCol) {
$select = DB::raw("id, {$emailCol} AS display_name");
} else {
$out = [];
foreach ($ids as $id) {
$out[$id] = 'User #' . $id;
}
return $out;
}
$rows = DB::table('users')->select($select)->whereIn('id', $ids)->get();
$map = [];
foreach ($rows as $r) {
$disp = trim((string) ($r->display_name ?? ''));
$map[(int) $r->id] = $disp !== '' ? $disp : ('User #' . $r->id);
}
return $map;
}
private function getSchoolYearsForType(string $type): array
{
$years = InventoryItem::query()
->select('school_year')
->where('type', $type)
->whereNotNull('school_year')
->groupBy('school_year')
->orderByDesc('school_year')
->pluck('school_year')
->map(fn ($v) => trim((string) $v))
->filter()
->unique()
->values()
->all();
$currentYear = $this->context->schoolYear();
if ($currentYear && !in_array($currentYear, $years, true)) {
array_unshift($years, $currentYear);
}
return $years;
}
}
@@ -0,0 +1,341 @@
<?php
namespace App\Services\Inventory;
use App\Models\InventoryItem;
use App\Models\InventoryMovement;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class InventoryMovementService
{
public function __construct(private InventoryContextService $context)
{
}
public function list(array $filters = []): array
{
$selectedYear = trim((string) ($filters['school_year'] ?? $this->context->schoolYear()));
$selectedSem = trim((string) ($filters['semester'] ?? $this->context->semester()));
$builder = DB::table('inventory_movements as 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 as ii', 'ii.id', '=', 'm.item_id')
->leftJoin('users as pb', 'pb.id', '=', 'm.performed_by')
->leftJoin('users as t', 't.id', '=', 'm.teacher_id')
->leftJoin('students as s', 's.id', '=', 'm.student_id')
->leftJoin('classSection as 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);
}
return $builder->get()->map(fn ($r) => (array) $r)->all();
}
public function create(array $data, int $performedBy = 0): array
{
$itemId = (int) ($data['item_id'] ?? 0);
$movementType = (string) ($data['movement_type'] ?? '');
$qtyChange = (int) ($data['qty_change'] ?? 0);
$item = InventoryItem::query()->find($itemId);
if (!$item) {
return ['ok' => false, 'message' => 'Selected item not found.'];
}
$qtyChange = $this->normalizeMovementQty($movementType, $qtyChange);
if ($this->wouldGoNegative($itemId, $qtyChange)) {
return ['ok' => false, 'message' => 'Not enough stock to apply this movement.'];
}
$payload = [
'item_id' => $itemId,
'qty_change' => $qtyChange,
'movement_type' => $movementType,
'reason' => trim((string) ($data['reason'] ?? '')),
'note' => trim((string) ($data['note'] ?? '')),
'semester' => $data['semester'] ?? $this->context->semester(),
'school_year' => $data['school_year'] ?? $this->context->schoolYear(),
'performed_by' => $performedBy ?: null,
'teacher_id' => $this->nullableInt($data['teacher_id'] ?? null),
'student_id' => $this->nullableInt($data['student_id'] ?? null),
'class_section_id' => $this->nullableInt($data['class_section_id'] ?? null),
];
try {
InventoryMovement::query()->create($payload);
$this->recalcQuantity($itemId);
} catch (\Throwable $e) {
Log::error('Inventory movement create failed: ' . $e->getMessage());
return ['ok' => false, 'message' => 'Could not save movement.'];
}
return ['ok' => true];
}
public function update(int $id, array $data): array
{
$movement = InventoryMovement::query()->find($id);
if (!$movement) {
return ['ok' => false, 'message' => 'Movement not found.'];
}
$movementType = (string) ($data['movement_type'] ?? $movement->movement_type);
$qtyChange = (int) ($data['qty_change'] ?? $movement->qty_change);
$qtyChange = $this->normalizeMovementQty($movementType, $qtyChange);
$itemId = (int) $movement->item_id;
$delta = $qtyChange - (int) $movement->qty_change;
if ($delta !== 0 && $this->wouldGoNegative($itemId, $delta)) {
return ['ok' => false, 'message' => 'Not enough stock to apply this movement.'];
}
$payload = [
'qty_change' => $qtyChange,
'movement_type' => $movementType,
'reason' => trim((string) ($data['reason'] ?? '')),
'note' => trim((string) ($data['note'] ?? '')),
'semester' => $data['semester'] ?? $this->context->semester(),
'school_year' => $data['school_year'] ?? $this->context->schoolYear(),
'teacher_id' => $this->nullableInt($data['teacher_id'] ?? null),
'student_id' => $this->nullableInt($data['student_id'] ?? null),
'class_section_id' => $this->nullableInt($data['class_section_id'] ?? null),
];
try {
$movement->update($payload);
$this->recalcQuantity($itemId);
} catch (\Throwable $e) {
Log::error('Inventory movement update failed: ' . $e->getMessage());
return ['ok' => false, 'message' => 'Could not update movement.'];
}
return ['ok' => true];
}
public function delete(int $id): array
{
$movement = InventoryMovement::query()->find($id);
if (!$movement) {
return ['ok' => false, 'message' => 'Movement not found.'];
}
$itemId = (int) $movement->item_id;
try {
$movement->delete();
if ($itemId > 0) {
$this->recalcQuantity($itemId);
}
} catch (\Throwable $e) {
Log::error('Inventory movement delete failed: ' . $e->getMessage());
return ['ok' => false, 'message' => 'Could not delete movement.'];
}
return ['ok' => true];
}
public function bulkDelete(array $ids): array
{
$ids = array_values(array_filter(array_map('intval', $ids), static fn ($v) => $v > 0));
if (empty($ids)) {
return ['ok' => false, 'message' => 'No movements selected.'];
}
$itemIds = DB::table('inventory_movements')
->select('item_id')
->whereIn('id', $ids)
->get()
->map(fn ($r) => (int) ($r->item_id ?? 0))
->filter(fn ($v) => $v > 0)
->unique()
->values()
->all();
try {
InventoryMovement::query()->whereIn('id', $ids)->delete();
foreach ($itemIds as $itemId) {
$this->recalcQuantity($itemId);
}
} catch (\Throwable $e) {
Log::error('Inventory movement bulk delete failed: ' . $e->getMessage());
return ['ok' => false, 'message' => 'Bulk delete failed.'];
}
return ['ok' => true, 'deleted' => count($ids)];
}
public function recordMovement(
int $itemId,
int $qtyChange,
string $type,
?string $reason = null,
?string $note = null,
?int $classSectionId = null,
?int $studentId = null,
?int $performedBy = null
): bool {
$isInitial = ($type === 'initial');
if (!$isInitial) {
$this->ensureInitialMovement($itemId);
$this->ensureInitialMovementForYear($itemId, $this->context->schoolYear());
$this->recalcQuantity($itemId);
}
if (in_array($type, ['out', 'distribution'], true) && $qtyChange > 0) {
$qtyChange = -abs($qtyChange);
}
if (in_array($type, ['out', 'distribution'], true) && $this->wouldGoNegative($itemId, $qtyChange)) {
return false;
}
InventoryMovement::query()->create([
'item_id' => $itemId,
'qty_change' => $qtyChange,
'movement_type' => $type,
'reason' => $reason,
'note' => $note,
'semester' => $this->context->semester(),
'school_year' => $this->context->schoolYear(),
'performed_by' => $performedBy,
'teacher_id' => $performedBy,
'student_id' => $studentId,
'class_section_id' => $classSectionId,
]);
$this->recalcQuantity($itemId);
return true;
}
public function recalcQuantity(int $itemId): void
{
$sumRow = InventoryMovement::query()
->selectRaw('SUM(qty_change) as qty_change')
->where('item_id', $itemId)
->first();
$sum = (int) ($sumRow?->qty_change ?? 0);
if ($sum < 0) {
$sum = 0;
}
$item = InventoryItem::query()->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 === 'classroom') {
$needsRepair = (int) ($item->needs_repair_qty ?? 0);
$goodCalc = max(0, $sum - $needsRepair);
if ((int) ($item->good_qty ?? 0) !== $goodCalc) {
$updates['good_qty'] = $goodCalc;
}
}
if (!empty($updates)) {
$item->update($updates);
}
}
private function ensureInitialMovement(int $itemId): void
{
$hasMov = InventoryMovement::query()->where('item_id', $itemId)->exists();
if ($hasMov) {
return;
}
$item = InventoryItem::query()->select('quantity')->find($itemId);
$initialQty = (int) ($item?->quantity ?? 0);
InventoryMovement::query()->create([
'item_id' => $itemId,
'qty_change' => $initialQty,
'movement_type' => 'initial',
'semester' => $this->context->semester(),
'school_year' => $this->context->schoolYear(),
]);
}
private function ensureInitialMovementForYear(int $itemId, string $schoolYear): void
{
$hasAnyThisYear = InventoryMovement::query()
->where('item_id', $itemId)
->where('school_year', $schoolYear)
->exists();
if (!$hasAnyThisYear) {
$onHand = (int) (InventoryItem::query()->where('id', $itemId)->value('quantity') ?? 0);
InventoryMovement::query()->create([
'item_id' => $itemId,
'qty_change' => $onHand,
'movement_type' => 'initial',
'school_year' => $schoolYear,
'semester' => $this->context->semester() ?: null,
]);
return;
}
$hasInitial = InventoryMovement::query()
->where('item_id', $itemId)
->where('school_year', $schoolYear)
->where('movement_type', 'initial')
->exists();
if (!$hasInitial) {
$onHand = (int) (InventoryItem::query()->where('id', $itemId)->value('quantity') ?? 0);
$netYear = (int) (InventoryMovement::query()
->where('item_id', $itemId)
->where('school_year', $schoolYear)
->sum('qty_change') ?? 0);
$opening = $onHand - $netYear;
InventoryMovement::query()->create([
'item_id' => $itemId,
'qty_change' => $opening,
'movement_type' => 'initial',
'school_year' => $schoolYear,
'semester' => $this->context->semester() ?: null,
]);
}
}
private function normalizeMovementQty(string $movementType, int $qtyChange): int
{
if (in_array($movementType, ['out', 'distribution'], true) && $qtyChange > 0) {
return -abs($qtyChange);
}
return $qtyChange;
}
private function wouldGoNegative(int $itemId, int $delta): bool
{
$onHand = (int) (InventoryItem::query()->where('id', $itemId)->value('quantity') ?? 0);
return $onHand + $delta < 0;
}
private function nullableInt($val): ?int
{
if ($val === null || $val === '' || $val === '0') {
return null;
}
return (int) $val;
}
}
@@ -0,0 +1,191 @@
<?php
namespace App\Services\Inventory;
use App\Models\InventoryItem;
use Illuminate\Support\Facades\DB;
class InventorySummaryService
{
public function __construct(private InventoryContextService $context)
{
}
public function summary(string $type, ?string $schoolYear): array
{
$type = strtolower($type);
$selectedYear = trim((string) ($schoolYear ?? $this->context->schoolYear()));
$items = InventoryItem::query()
->where('type', $type)
->orderBy('name')
->get()
->toArray();
$itemIds = array_map(static fn ($i) => (int) ($i['id'] ?? 0), $items);
$agg = [];
if (!empty($itemIds)) {
$qb = DB::table('inventory_movements')
->selectRaw('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' && $selectedYear !== '') {
$qb->where('school_year', $selectedYear);
}
foreach ($qb->get() as $row) {
$agg[(int) $row->item_id] = [
'received' => (int) ($row->received ?? 0),
'issued' => (int) ($row->issued ?? 0),
];
}
}
return [
'items' => $items,
'agg' => $agg,
'selectedYear' => $selectedYear,
'currentYear' => $this->context->schoolYear(),
'schoolYears' => $this->getSchoolYearsForType($type),
];
}
public function summaryAll(?string $schoolYear, ?string $type): array
{
$selectedYear = trim((string) ($schoolYear ?? $this->context->schoolYear()));
$selectedType = strtolower((string) ($type ?? 'all'));
$isAllYears = strtolower($selectedYear) === 'all';
$qbItems = DB::table('inventory_items as i')
->select('i.*', 'c.name AS category_name', DB::raw("CONCAT(u.firstname, ' ', u.lastname) AS updated_by_name"))
->leftJoin('inventory_categories as c', 'c.id', '=', 'i.category_id')
->leftJoin('users as u', 'u.id', '=', 'i.updated_by')
->orderBy('i.name');
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()->map(fn ($r) => (array) $r)->all();
$rows = [];
$totals = [
'opening' => 0,
'received' => 0,
'distributed' => 0,
'other_out' => 0,
'adjust_net' => 0,
'ending' => 0,
'onhand' => 0,
'variance' => 0,
];
if (!empty($items)) {
$itemIds = array_map('intval', array_column($items, 'id'));
$aggById = [];
$qb = DB::table('inventory_movements as m')
->selectRaw("
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()->map(fn ($r) => (array) $r)->all() 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) {
$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'] ?? 0);
$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;
}
}
return [
'selectedYear' => $selectedYear,
'currentYear' => $this->context->schoolYear(),
'schoolYears' => $this->getSchoolYearsForType('book') ?: [$this->context->schoolYear(), 'All'],
'rows' => $rows,
'totals' => $totals,
'selectedType' => $selectedType,
];
}
private function getSchoolYearsForType(string $type): array
{
$years = InventoryItem::query()
->select('school_year')
->where('type', $type)
->whereNotNull('school_year')
->groupBy('school_year')
->orderByDesc('school_year')
->pluck('school_year')
->map(fn ($v) => trim((string) $v))
->filter()
->unique()
->values()
->all();
$currentYear = $this->context->schoolYear();
if ($currentYear && !in_array($currentYear, $years, true)) {
array_unshift($years, $currentYear);
}
return $years;
}
}
@@ -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(),
];
}
}
@@ -0,0 +1,91 @@
<?php
namespace App\Services\Inventory;
use App\Models\Supplier;
use Illuminate\Support\Facades\Log;
class SupplierService
{
public function list(array $filters = [], int $perPage = 20): array
{
$q = trim((string) ($filters['q'] ?? ''));
$query = Supplier::query();
if ($q !== '') {
$query->where(function ($builder) use ($q) {
$builder->where('name', 'like', '%' . $q . '%')
->orWhere('email', 'like', '%' . $q . '%')
->orWhere('phone', 'like', '%' . $q . '%');
});
}
$paginator = $query->orderBy('name')->paginate($perPage);
return [
'items' => $paginator->items(),
'pagination' => [
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'total_pages' => $paginator->lastPage(),
],
];
}
public function create(array $data): array
{
try {
$supplier = Supplier::query()->create($this->payload($data));
} catch (\Throwable $e) {
Log::error('Supplier create failed: ' . $e->getMessage());
return ['ok' => false, 'message' => 'Failed to save supplier.'];
}
return ['ok' => true, 'supplier' => $supplier->toArray()];
}
public function update(int $id, array $data): array
{
$supplier = Supplier::query()->find($id);
if (!$supplier) {
return ['ok' => false, 'message' => 'Supplier not found.'];
}
try {
$supplier->update($this->payload($data));
} catch (\Throwable $e) {
Log::error('Supplier update failed: ' . $e->getMessage());
return ['ok' => false, 'message' => 'Failed to update supplier.'];
}
return ['ok' => true, 'supplier' => $supplier->toArray()];
}
public function delete(int $id): array
{
$supplier = Supplier::query()->find($id);
if (!$supplier) {
return ['ok' => false, 'message' => 'Supplier not found.'];
}
try {
$supplier->delete();
} catch (\Throwable $e) {
Log::error('Supplier delete failed: ' . $e->getMessage());
return ['ok' => false, 'message' => 'Failed to delete supplier.'];
}
return ['ok' => true];
}
private function payload(array $data): array
{
return [
'name' => (string) ($data['name'] ?? ''),
'email' => $data['email'] ?? null,
'phone' => $data['phone'] ?? null,
'address' => $data['address'] ?? null,
'notes' => $data['notes'] ?? null,
];
}
}
@@ -0,0 +1,62 @@
<?php
namespace App\Services\Inventory;
use App\Models\SupplyCategory;
use Illuminate\Support\Facades\Log;
class SupplyCategoryService
{
public function list(): array
{
return SupplyCategory::query()->orderBy('name')->get()->toArray();
}
public function create(array $data): array
{
try {
$category = SupplyCategory::query()->create([
'name' => (string) ($data['name'] ?? ''),
]);
} catch (\Throwable $e) {
Log::error('Supply category create failed: ' . $e->getMessage());
return ['ok' => false, 'message' => 'Failed to save category.'];
}
return ['ok' => true, 'category' => $category->toArray()];
}
public function update(int $id, array $data): array
{
$category = SupplyCategory::query()->find($id);
if (!$category) {
return ['ok' => false, 'message' => 'Category not found.'];
}
try {
$category->update(['name' => (string) ($data['name'] ?? '')]);
} catch (\Throwable $e) {
Log::error('Supply category update failed: ' . $e->getMessage());
return ['ok' => false, 'message' => 'Failed to update category.'];
}
return ['ok' => true, 'category' => $category->toArray()];
}
public function delete(int $id): array
{
$category = SupplyCategory::query()->find($id);
if (!$category) {
return ['ok' => false, 'message' => 'Category not found.'];
}
try {
$category->delete();
} catch (\Throwable $e) {
Log::error('Supply category delete failed: ' . $e->getMessage());
return ['ok' => false, 'message' => 'Failed to delete category.'];
}
return ['ok' => true];
}
}