307 lines
10 KiB
PHP
307 lines
10 KiB
PHP
<?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;
|
|
}
|
|
}
|